todo cli in rust.


why build another todo app?

every developer builds a todo app at some point - it's like the "hello world" of actual applications. simple enough to finish, complex enough to be useful. when i was learning rust, i wanted something that would teach me about the ecosystem, error handling, and how to write clean idiomatic code.


what does it do?

it's a minimal command-line tool for managing tasks with three commands and zero configuration:

todo add "buy groceries"
todo view
todo remove 1

tasks persist to json and include timestamps. no databases, no web servers, just a simple file.


what's it built with?

  • clap — command-line parsing with derive macros
  • serde + serde_json — data serialization
  • chrono — timestamp handling
  • anyhow — error handling that doesn't make you cry

how did it evolve?

the git history tells the real story: 20+ commits of constant refactoring, going from working-but-messy to clean-and-simple.

the process was basically: build it and make it work, then clean up the loops and clones and redundant code, then extract methods and improve the structure and add proper errors, and finally simplify and remove features until it felt right.


interesting bits

→ smart file loading

the first-run experience matters. instead of crashing when list.json doesn't exist, it just starts with an empty list:

fn load() -> Result<Self> {
    match fs::read_to_string("list.json") {
        Ok(data) => serde_json::from_str(&data).map_err(Into::into),
        Err(e) if e.kind() == ErrorKind::NotFound => Ok(Tasks(vec![])),
        Err(e) => Err(e.into()),
    }
}

this handles three cases: successful load, missing file, and actual errors. no crashes, no confusing messages for new users.

→ the command handler

early versions had scattered logic with repeated patterns all over the place. the final version puts everything in one clean handler:

impl Command {
    fn handle(self) -> Result<()> {
        let mut tasks = Tasks::load()?;

        match self {
            Command::Add { name } => {
                tasks.add(&name)?;
                tasks.save()?;
            }
            Command::Remove { id } => {
                tasks.remove(id)?;
                tasks.save()?;
            }
            Command::View => {
                tasks.view();
            }
        }
        Ok(())
    }
}

load, execute, save. the ? operator handles errors and the compiler makes sure every command is covered.

→ validation that fails fast

catching mistakes immediately is better than persisting bad data:

fn add(&mut self, name: &str) -> Result<()> {
    if name.is_empty() {
        bail!("Name cannot be empty.");
    }
    self.0.push(Task {
        name: name.to_string(),
        timestamp: Local::now(),
    });
    println!("{name} added!");
    Ok(())
}

the bail! macro from anyhow makes early returns clean without nested if-statements everywhere.


what did i learn?

  • refactoring is more valuable than getting it right the first time - the messy working version taught me what the clean version should look like
  • rust's type system catches so many bugs at compile time, and exhaustive matching means you can't forget to handle a case
  • sometimes removing features is the right move - i had a "complete" command that added complexity without adding much value
  • anyhow made error handling so much cleaner than nested result wrapping
  • small commits with clear messages make it way easier to understand your own thought process later
  • clippy is harsh but it makes you write better rust
  • it took 20+ commits to make something this simple, which says something about how hard simplicity actually is

final thoughts

the best part is that the final version is easier to understand, easier to maintain, and harder to break than the first working draft. and because it's clean, i can actually add and change things without dreading it. i'll keep improving it as i get more experience and ideas.