Build a Task Tracker CLI

RustBeginner
Practice Now

Introduction

A useful command-line application connects several boundaries: typed arguments, domain data, persistent storage, clear output, recoverable errors, and automated checks. Building all of that from a blank file would hide the design under a large amount of typing.

In this lab, setup provides a complete task-tracker skeleton. You will read its module map, then complete one small boundary at a time: decode and save storage, add tasks, list tasks, mark tasks complete, and produce a tested release build. You will never need to paste an entire source file.

Read the Project Map and Command Interface

In this step, you will orient yourself in the prepared project and connect its files to the data flow before editing code.

Enter the project and list its source files:

cd /home/labex/project/tasker
ls src
lib.rs  main.rs  model.rs  store.rs

Each file has one main responsibility:

  • main.rs owns the process boundary: parse arguments, print success, and report failures;
  • lib.rs owns the task operations that the CLI and tests call;
  • model.rs defines one Task and converts it to and from a storage line;
  • store.rs reads and writes the task collection.

This separation keeps argument parsing, domain operations, and file details from becoming one large function. The setup has already written the module declarations and longer model parser; you will complete only the marked operation boundaries.

Inspect the CLI entry point:

nano src/main.rs

#[command(subcommand)] tells clap that the next command word selects a Commands enum variant. The --file option is marked global = true, so users can place it before or after a subcommand. Its PathBuf value defaults to tasks.db. Press Ctrl+X without changing the file.

Display the generated top-level help:

cargo run --quiet -- --help

The help lists add, list, and done. Ask for focused help on add:

cargo run --quiet -- add --help

The required <TITLE> comes from the title: String field in the Add variant. The interface already exists; later steps will make each command's library operation work.

Complete the Storage Boundary

In this step, you will finish the two small transformations that connect task values to a local text file.

Open the prepared storage module:

nano src/store.rs

The file format uses one task per line with three tab-separated fields:

id<TAB>status<TAB>title

model.rs already provides Task::encode and Task::decode. The storage module only needs to apply those helpers to the complete collection.

Replace the load TODO and the two lines below it with:

    let tasks = contents
        .lines()
        .filter(|line| !line.is_empty())
        .map(Task::decode)
        .collect::<Result<Vec<_>, _>>()?;
    Ok(tasks)

The iterator turns each non-empty line into a Result<Task, String>. Collecting into Result<Vec<_>, _> stops at the first invalid line or returns all decoded tasks. The question mark propagates that error from load.

In save, replace its TODO and the final three lines with:

    fs::write(path, contents)
        .map_err(|error| format!("could not write {}: {error}", path.display()))

fs::write creates or replaces the storage file. map_err adds the failing path while preserving a recoverable Result.

Save and exit nano. Run only the focused storage test:

cargo test store::tests::saves_and_loads_tasks

A single passed test proves a task collection can cross the file boundary and return as equal Rust values.

Add and Persist New Tasks

In this step, you will implement the library operation behind the add subcommand.

Open the library entry point:

nano src/lib.rs

Replace the add_task TODO and placeholder body with:

    let mut tasks = store::load(path)?;
    let next_id = tasks.iter().map(|task| task.id).max().unwrap_or(0) + 1;
    let task = Task::new(next_id, title);
    tasks.push(task.clone());
    store::save(path, &tasks)?;
    Ok(task)

The operation first loads the current state. max().unwrap_or(0) + 1 produces ID 1 for an empty file and one more than the largest existing ID otherwise. The task is cloned once because one owned copy enters the vector while the returned copy lets the CLI describe what was added.

Save and exit nano. Add two tasks to a dedicated demonstration file:

cargo run --quiet -- --file add-demo.db add "Write release notes"
Added 1: Write release notes
cargo run --quiet -- --file add-demo.db add "Tag version"
Added 2: Tag version

The second ID proves that the command loaded the first record before choosing and saving the next one.

Format the Task List

In this step, you will convert stored tasks into stable, human-readable command output.

Open src/lib.rs again:

nano src/lib.rs

Replace the list_tasks TODO and placeholder body with:

    let tasks = store::load(path)?;
    Ok(tasks
        .iter()
        .map(|task| {
            let marker = if task.done { "x" } else { " " };
            format!("[{marker}] {}: {}", task.id, task.title)
        })
        .collect())

The marker is a compact status view: [ ] means open and [x] means complete. This function returns display rows rather than printing them, so tests and other callers can inspect the result without capturing terminal output.

Save and exit nano. Reuse the file created in the previous step:

cargo run --quiet -- --file add-demo.db list
[ ] 1: Write release notes
[ ] 2: Tag version

The library owns formatting the task rows, while main.rs remains responsible only for printing returned lines.

Mark One Task Complete

In this step, you will update one task while preserving the rest of the stored collection.

Open the library source:

nano src/lib.rs

Replace the complete_task TODO and placeholder body with:

    let mut tasks = store::load(path)?;
    let task = tasks
        .iter_mut()
        .find(|task| task.id == id)
        .ok_or_else(|| format!("task {id} was not found"))?;
    task.done = true;
    let completed = task.clone();
    store::save(path, &tasks)?;
    Ok(completed)

iter_mut() supplies mutable references so the matching record can change in place. find returns None when the ID is absent; ok_or_else converts that absence into the function's explanatory error. The completed task is cloned before saving because the mutable borrow belongs to the vector being saved.

Save and exit nano. Complete task 1 in the demonstration file:

cargo run --quiet -- --file add-demo.db done 1
Completed 1: Write release notes

List the stored state again:

cargo run --quiet -- --file add-demo.db list
[x] 1: Write release notes
[ ] 2: Tag version

Try an absent ID:

cargo run --quiet -- --file add-demo.db done 99

This command is expected to fail. The message goes to stderr and the process exits nonzero because main.rs already converts the library Err at the process boundary.

Test and Build the Release

In this step, you will apply the handoff quality loop and produce a release executable from the completed project.

Start by formatting the edits you made across the storage and library modules:

cargo fmt

Confirm the formatter has no remaining changes:

cargo fmt -- --check

Run strict Clippy:

cargo clippy -- -D warnings

Run the complete test suite:

cargo test

Two tests should pass: the focused storage round trip and the complete add-list-done library workflow. Those tests use temporary files, so they exercise real persistence without depending on your demonstration database.

Build the optimized release target:

cargo build --release --locked

--release selects Cargo's optimized release profile instead of the faster-to-compile development profile. --locked requires the exact dependency graph already recorded in Cargo.lock. Run the resulting executable directly:

./target/release/tasker --file release-demo.db add "Publish tasker"
Added 1: Publish tasker
./target/release/tasker --file release-demo.db list
[ ] 1: Publish tasker

The direct path proves you are running the built artifact rather than asking Cargo to compile and launch it for you.

Summary

You completed a multi-command Rust CLI without retyping its architecture. The finished project parses typed subcommands with clap, persists task models through a focused storage module, propagates contextual errors, keeps process output in main, passes focused and end-to-end library tests, satisfies the quality loop, and produces a locked release executable.