Building a Pomodoro Timer CLI in Rust
Over the past few weeks, I've been learning Rust by building something I actually use every day: a Pomodoro timer. Yes, there are a million Pomodoro apps out there, but building my own has been one of the best learning experiences I've had in a while.
Why a CLI Tool?
I spend most of my day in the terminal anyway, so why switch contexts to a GUI app? Plus, CLI tools are perfect for learning a new language because they're:
- Simple enough to finish
- Complex enough to be interesting
- Actually useful in your workflow
The Basic Requirements
Here's what I wanted:
- Start a 25-minute focus session
- 5-minute short break
- 15-minute long break (after 4 sessions)
- Desktop notifications when time's up
- Simple commands:
pomo start,pomo break,pomo status
Getting Started with Rust
I'm coming from JavaScript/TypeScript, so Rust's ownership model took some getting used to. Here's a simple timer function:
use std::time::Duration;
use std::thread;
fn start_timer(minutes: u64) {
let duration = Duration::from_secs(minutes * 60);
println!("Timer started for {} minutes", minutes);
thread::sleep(duration);
println!("Time's up! ⏰");
}Pretty straightforward! But then I wanted to add a countdown display that updates every second...
The Challenge: Updating the Display
In JavaScript, I'd use setInterval. In Rust, I learned about threads and channels:
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
fn start_timer_with_display(minutes: u64) {
let total_seconds = minutes * 60;
let start_time = Instant::now();
loop {
let elapsed = start_time.elapsed().as_secs();
let remaining = total_seconds.saturating_sub(elapsed);
if remaining == 0 {
break;
}
let mins = remaining / 60;
let secs = remaining % 60;
print!("\r{:02}:{:02} remaining", mins, secs);
std::io::stdout().flush().unwrap();
thread::sleep(Duration::from_secs(1));
}
println!("\n\nTime's up! ⏰");
}That \r character is doing the heavy lifting here - it returns the cursor to the start of the line so we can overwrite the previous time.
Adding Desktop Notifications
This is where Rust's ecosystem really shines. The notify-rust crate makes desktop notifications trivial:
use notify_rust::Notification;
fn notify(title: &str, body: &str) {
Notification::new()
.summary(title)
.body(body)
.timeout(0) // Don't auto-dismiss
.show()
.unwrap();
}
// Usage
notify("Pomodoro Complete!", "Time for a break 🎉");Works on macOS, Linux, and Windows. Love it.
Persistent State
I wanted to track how many pomodoros I've completed today, which means persisting state between runs. I went with a simple JSON file in the home directory:
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Serialize, Deserialize, Default)]
struct PomodoroState {
completed_today: u32,
total_completed: u32,
current_session: u32,
}
fn get_state_path() -> PathBuf {
let mut path = dirs::home_dir().unwrap();
path.push(".pomo_state.json");
path
}
fn load_state() -> PomodoroState {
let path = get_state_path();
if path.exists() {
let contents = fs::read_to_string(path).unwrap();
serde_json::from_str(&contents).unwrap()
} else {
PomodoroState::default()
}
}
fn save_state(state: &PomodoroState) {
let path = get_state_path();
let json = serde_json::to_string_pretty(state).unwrap();
fs::write(path, json).unwrap();
}The serde crate handles JSON serialization beautifully. Coming from JavaScript, this feels like magic - type-safe JSON parsing with zero runtime overhead!
The Command Line Interface
For parsing CLI arguments, I used clap. It's incredibly powerful:
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "pomo")]
#[command(about = "A simple Pomodoro timer", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Start a focus session
Start {
/// Duration in minutes (default: 25)
#[arg(short, long, default_value_t = 25)]
minutes: u64,
},
/// Take a break
Break {
/// Short break (5 min) or long break (15 min)
#[arg(short, long)]
long: bool,
},
/// Show current status
Status,
}This generates help text automatically and handles all the parsing. Run pomo --help and you get beautiful, formatted documentation for free.
What I Learned
1. The Borrow Checker is Your Friend
At first, the borrow checker felt like fighting the compiler. But after a few days, I realized it was catching real bugs. No more undefined is not a function runtime errors!
2. Error Handling is Explicit
Every function that can fail returns a Result. No silent failures, no forgotten error handling. It forces you to think about what can go wrong:
use std::fs;
use std::io;
fn read_config() -> Result<String, io::Error> {
fs::read_to_string("config.json")
}
// You must handle the Result
match read_config() {
Ok(config) => println!("Config: {}", config),
Err(e) => eprintln!("Failed to read config: {}", e),
}3. The Rust Community is Amazing
The documentation is excellent, crates.io is well-organized, and when I got stuck, the Rust Discord was incredibly helpful.
What's Next?
I'm planning to add:
- Sound alerts (different sounds for breaks vs. sessions)
- Statistics and charts (using
termgraphmaybe?) - Integration with task management tools
- Maybe a TUI with
ratatui?
Try It Yourself
If you're interested in learning Rust, I highly recommend building a CLI tool. Start with something simple like a todo app or timer, and gradually add features. The instant feedback of a working tool makes learning stick.
Here are some resources I found helpful:
- The Rust Book - Start here
- Rust by Example - Great for hands-on learning
- Command Line Apps in Rust - Perfect for CLI projects
Have you built any CLI tools recently? What's your favorite project to build when learning a new language?