Skip to content
Learn Rust
Theme

Project: a todo CLI

This page builds one small program from an empty folder to a tested command-line tool, todo. It does not teach the pieces again. Each step names the lesson or guide that does, and shows what the code and the compiler did in the pinned image (Rust 1.99.1, Linux ARM64). A first build on a fresh machine downloads the crates (needs network); the dependency versions are fixed by the Cargo.lock that comes with the project. The sessions leave out Cargo’s Compiling, Checking and Finished lines.

RememberPut the rules in src/lib.rs, keep src/main.rs thin (parse arguments, call the library, print), and test the library with unit tests and the binary with tests that run it.

Project layout
todo/
├── src/
│   ├── lib.rs                  the rules: Task and List
│   ├── main.rs                 the command line
│   └── store.rs                reading and writing the JSON file
├── tests/
│   └── cli.rs                  tests that run the binary
├── Cargo.lock                  committed: pins every version
└── Cargo.toml                  the four dependencies
RustCargo.toml
[package]
name = "todo"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.104"
clap = { version = "4.6.7", features = ["derive"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154"

cargo new todo makes the package, and cargo add writes the dependency lines (Using crates). There are four: clap for arguments, serde and serde_json for the file, anyhow for errors.

A package with both src/lib.rs and src/main.rs builds a library and a program that uses it. The Rust Book gives the reason in its testing chapter: a project with only src/main.rs cannot have integration tests that use its functions, so “Rust projects that provide a binary have a straightforward src/main.rs file that calls logic that lives in the src/lib.rs file” (chapter 11.3). Project structure shows the layout in a larger package. Here it gives the split: everything that decides what a to-do list is goes in the library, and everything about the terminal goes in main.rs.

Step 1: the rules, with no input or output

Section titled “Step 1: the rules, with no input or output”
Rustlib.rs
//! A to-do list: the data and its rules, with no input or output.
use std::fmt;
use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
/// One thing to do.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Task {
pub id: u32,
pub text: String,
pub priority: u8,
pub done: bool,
}
impl fmt::Display for Task {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let mark = if self.done { 'x' } else { ' ' };
write!(
f,
"[{mark}] {:>2} p{} {}",
self.id, self.priority, self.text
)
}
}
/// All the tasks, and the number the next one gets.
#[derive(Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct List {
next_id: u32,
tasks: Vec<Task>,
}
impl List {
pub fn add(&mut self, text: &str, priority: u8) -> Result<&Task> {
let text = text.trim();
if text.is_empty() {
bail!("a task needs some text");
}
if !(1..=3).contains(&priority) {
bail!("priority must be 1, 2 or 3, not {priority}");
}
self.next_id += 1;
self.tasks.push(Task {
id: self.next_id,
text: text.to_string(),
priority,
done: false,
});
Ok(self.tasks.last().expect("a task was just pushed"))
}
pub fn complete(&mut self, id: u32) -> Result<&Task> {
let Some(task) = self.tasks.iter_mut().find(|t| t.id == id) else {
bail!("no task with id {id}");
};
task.done = true;
Ok(task)
}
pub fn remove(&mut self, id: u32) -> Result<Task> {
let Some(pos) = self.tasks.iter().position(|t| t.id == id) else {
bail!("no task with id {id}");
};
Ok(self.tasks.remove(pos))
}
pub fn tasks(&self) -> &[Task] {
&self.tasks
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ids_count_up_and_are_not_reused() {
let mut list = List::default();
list.add("one", 2).unwrap();
let two = list.add("two", 2).unwrap().id;
list.remove(two).unwrap();
assert_eq!(list.add("three", 2).unwrap().id, 3);
}
#[test]
fn completing_marks_the_task() {
let mut list = List::default();
let id = list.add("one", 1).unwrap().id;
assert!(list.complete(id).unwrap().done);
assert!(list.tasks()[0].done);
}
#[test]
fn rules_are_enforced() {
let mut list = List::default();
assert_eq!(
list.add(" ", 2).unwrap_err().to_string(),
"a task needs some text"
);
assert_eq!(
list.add("x", 4).unwrap_err().to_string(),
"priority must be 1, 2 or 3, not 4"
);
assert_eq!(
list.complete(7).unwrap_err().to_string(),
"no task with id 7"
);
assert!(list.tasks().is_empty());
}
#[test]
fn display_marks_done_tasks() {
let mut list = List::default();
list.add("write", 1).unwrap();
list.complete(1).unwrap();
assert_eq!(list.tasks()[0].to_string(), "[x] 1 p1 write");
}
}
$ cargo test --lib # timings replaced by X.XXs, build hash by HASH
     Running unittests src/lib.rs (target/debug/deps/todo-HASH)
test tests::completing_marks_the_task ... ok
test tests::display_marks_done_tasks ... ok
test tests::ids_count_up_and_are_not_reused ... ok
test tests::rules_are_enforced ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs

This file is src/lib.rs after step 1. There is no file and no command line yet, so a test can build a List in memory and check it.

  • List keeps its fields private, so the only way to add a task in code is add, which checks the rules: some text, a priority from 1 to 3 (Modules). That covers code that calls the library. A file read by load in step 2 is not checked at all; the limits section runs an example.
  • add and complete return Result<&Task>: a reference into the list, which the caller can print and must drop before changing the list again (Borrowing).
  • bail! and Result come from anyhow, which fits an application: it holds any error and prints a message (Errors in real apps). A library that others depend on is often better off with its own error type; the “what to try next” list has that.
  • let ... else returns early when the id is not found (Option).
  • next_id only goes up, so removing task 2 and adding another gives task 3. The first test checks it.
  • The two derive lines for serde are there for step 2. The tests sit in a child module with #[cfg(test)] (Modules).
Rustsrc/store.rs
//! Reading the list from a JSON file and writing it back.
use std::ffi::OsString;
use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::List;
/// A file that does not exist yet is an empty list; any other problem is an error.
pub fn load(path: &Path) -> Result<List> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(List::default()),
Err(e) => return Err(e).with_context(|| format!("could not read {}", path.display())),
};
serde_json::from_str(&text)
.with_context(|| format!("{} is not a valid to-do file", path.display()))
}
/// Write to `<path>.tmp`, then rename it over `path`.
pub fn save(list: &List, path: &Path) -> Result<()> {
let mut tmp: OsString = path.as_os_str().to_owned();
tmp.push(".tmp");
let tmp = PathBuf::from(tmp);
let json = serde_json::to_string_pretty(list)?;
fs::write(&tmp, json + "\n").with_context(|| format!("could not write {}", tmp.display()))?;
fs::rename(&tmp, path).with_context(|| format!("could not replace {}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// A folder under the system temp dir, removed again when dropped.
struct TempDir(PathBuf);
impl TempDir {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!("todo-{}-{name}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
TempDir(dir)
}
fn file(&self, name: &str) -> PathBuf {
self.0.join(name)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn a_saved_list_loads_back_equal() {
let dir = TempDir::new("round-trip");
let path = dir.file("todo.json");
let mut list = List::default();
list.add("write the page", 1).unwrap();
list.add("run the tests", 2).unwrap();
list.complete(1).unwrap();
save(&list, &path).unwrap();
assert_eq!(load(&path).unwrap(), list);
assert!(!dir.file("todo.json.tmp").exists());
}
#[test]
fn a_missing_file_is_an_empty_list() {
let dir = TempDir::new("missing");
assert_eq!(load(&dir.file("nope.json")).unwrap(), List::default());
}
#[test]
fn garbage_is_an_error_that_names_the_file() {
let dir = TempDir::new("garbage");
let path = dir.file("bad.json");
fs::write(&path, "{oops").unwrap();
let message = format!("{:#}", load(&path).unwrap_err());
assert!(
message.contains("bad.json is not a valid to-do file"),
"{message}"
);
}
}
$ cargo test --lib # timings replaced by X.XXs, build hash by HASH
     Running unittests src/lib.rs (target/debug/deps/todo-HASH)
test store::tests::a_missing_file_is_an_empty_list ... ok
test store::tests::a_saved_list_loads_back_equal ... ok
test store::tests::garbage_is_an_error_that_names_the_file ... ok
test tests::completing_marks_the_task ... ok
test tests::display_marks_done_tasks ... ok
test tests::ids_count_up_and_are_not_reused ... ok
test tests::rules_are_enforced ... ok
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs

lib.rs gains two lines to use it, mod store; and pub use store::{load, save};, so callers write todo::load. The two derives let serde turn a List into JSON text and back (serde guide), which is what to_string_pretty and from_str do.

  • A file that does not exist is an empty list. Any other failure to read is an error, and the match on ErrorKind::NotFound is how the code tells them apart (Files and I/O).
  • .with_context(...) wraps the cause with a message that names the file, so the user sees which file was bad, not only that the JSON was.
  • save writes <file>.tmp and then renames it over the real file. That is a shorter form of the idiom in Replacing a file safely, without its sync_all, so the page says nothing about a power cut. The rename happens after the write call returns, which is the order of the program and nothing more. No test here interrupts a write. The test only checks the temporary file is gone after a save.
  • The storage tests use a folder under std::env::temp_dir() named with the process id, and a Drop that removes it, as in Temporary files that clean up.

Seven unit tests now cover the model and the file, and none of them needed a terminal.

Rustsrc/main.rs
use std::path::PathBuf;
use anyhow::Result;
use clap::{Parser, Subcommand};
use todo::{List, load, save};
/// A to-do list kept in a JSON file
#[derive(Parser)]
#[command(version)]
struct Cli {
/// Where the list is stored
#[arg(long, global = true, default_value = "todo.json")]
file: PathBuf,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Add a task
Add {
/// What to do
text: String,
/// 1 (high) to 3 (low)
#[arg(short, long, default_value_t = 2)]
priority: u8,
},
/// Show the tasks
List {
/// Hide finished tasks
#[arg(long)]
open: bool,
},
/// Mark a task as done
Done { id: u32 },
/// Delete a task
Remove { id: u32 },
}
fn main() -> Result<()> {
let cli = Cli::parse();
let mut list: List = load(&cli.file)?;
match cli.command {
Command::Add { text, priority } => {
println!("added {}", list.add(&text, priority)?);
save(&list, &cli.file)?;
}
Command::List { open } => {
let shown: Vec<_> = list.tasks().iter().filter(|t| !open || !t.done).collect();
if shown.is_empty() {
println!("no tasks");
}
for task in shown {
println!("{task}");
}
}
Command::Done { id } => {
println!("finished {}", list.complete(id)?);
save(&list, &cli.file)?;
}
Command::Remove { id } => {
println!("removed {}", list.remove(id)?);
save(&list, &cli.file)?;
}
}
Ok(())
}
$ cargo run -- list
no tasks
$ cargo run -- add "write the page" -p 1
added [ ]  1  p1  write the page
$ cargo run -- add "run the tests"
added [ ]  2  p2  run the tests
$ cargo run -- done 1
finished [x]  1  p1  write the page
$ cargo run -- list
[x]  1  p1  write the page
[ ]  2  p2  run the tests
$ cargo run -- list --open
[ ]  2  p2  run the tests
$ cargo run -- list --file todo.json
[x]  1  p1  write the page
[ ]  2  p2  run the tests
$ cat todo.json
{
  "next_id": 2,
  "tasks": [
    {
      "id": 1,
      "text": "write the page",
      "priority": 1,
      "done": true
    },
    {
      "id": 2,
      "text": "run the tests",
      "priority": 2,
      "done": false
    }
  ]
}
$ cargo run -- --help
A to-do list kept in a JSON file

Usage: todo [OPTIONS] <COMMAND>

Commands:
  add     Add a task
  list    Show the tasks
  done    Mark a task as done
  remove  Delete a task
  help    Print this message or the help of the given subcommand(s)

Options:
      --file <FILE>  Where the list is stored [default: todo.json]
  -h, --help         Print help
  -V, --version      Print version
$ cargo run -- add --help
Add a task

Usage: todo add [OPTIONS] <TEXT>

Arguments:
  <TEXT>  What to do

Options:
      --file <FILE>          Where the list is stored [default: todo.json]
  -p, --priority <PRIORITY>  1 (high) to 3 (low) [default: 2]
  -h, --help                 Print help

Each cargo run above is a new process that reads todo.json, does one thing and writes the file back, which the cat shows. The derive attributes are the clap guide’s; what is specific here:

  • global = true on --file lets it go before or after the subcommand (the session runs list --file todo.json), and it shows up in the help of add (the clap guide explains global flags).
  • main returns anyhow::Result<()>. If it returns Err, the runtime prints Error: and the error’s debug form and exits with status 1 (Errors, main can return a Result). anyhow’s docs say that is the form you get by default when fn main returns an error.
  • The match on Command has no _ arm, so adding a command later without handling it is a compile error (Enums).
  • main is short glue. The rules it relies on are covered by the tests from steps 1 and 2.
terminal
$ cargo run -- done 9
Error: no task with id 9
$ cargo run -- add "x" -p 5
Error: priority must be 1, 2 or 3, not 5
$ echo '{oops' > bad.json
$ cargo run -- --file bad.json list
Error: bad.json is not a valid to-do file

Caused by:
    key must be a string at line 1 column 2
$ cargo run -- add
error: the following required arguments were not provided:
  <TEXT>

Usage: todo add <TEXT>

For more information, try '--help'.

The first two messages come from the library’s bail!. The third shows the chain: the outer message names the file, and Caused by is serde_json’s own message. The last one is clap’s: it exits with status 2 for a usage error, our errors exit with 1. The first three stop before save is reached, so they wrote nothing; the fourth never gets past clap.

Rusttests/cli.rs
//! Tests that run the real `todo` binary, the way a user does.
use std::fs;
use std::path::PathBuf;
use std::process::{Command, Output};
/// A private folder for one test's list file, removed when the test ends.
struct Sandbox {
dir: PathBuf,
}
impl Sandbox {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!("todo-cli-{}-{name}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
Sandbox { dir }
}
fn file(&self) -> PathBuf {
self.dir.join("todo.json")
}
/// Run `todo --file <this sandbox's file> <args...>`.
fn todo(&self, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_todo"))
.arg("--file")
.arg(self.file())
.args(args)
.output()
.expect("the binary starts")
}
}
impl Drop for Sandbox {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.dir);
}
}
fn stdout(out: &Output) -> String {
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn stderr(out: &Output) -> String {
String::from_utf8_lossy(&out.stderr).into_owned()
}
#[test]
fn a_new_list_is_empty() {
let sb = Sandbox::new("empty");
let out = sb.todo(&["list"]);
assert!(out.status.success());
assert_eq!(stdout(&out), "no tasks\n");
}
#[test]
fn added_tasks_are_listed_and_kept_between_runs() {
let sb = Sandbox::new("add");
let out = sb.todo(&["add", "write the page", "-p", "1"]);
assert!(out.status.success(), "{}", stderr(&out));
assert_eq!(stdout(&out), "added [ ] 1 p1 write the page\n");
assert_eq!(stdout(&sb.todo(&["list"])), "[ ] 1 p1 write the page\n");
}
#[test]
fn done_tasks_can_be_hidden() {
let sb = Sandbox::new("done");
sb.todo(&["add", "one"]);
sb.todo(&["add", "two"]);
sb.todo(&["done", "1"]);
assert_eq!(stdout(&sb.todo(&["list", "--open"])), "[ ] 2 p2 two\n");
}
#[test]
fn an_unknown_id_fails_with_a_message() {
let sb = Sandbox::new("unknown-id");
let out = sb.todo(&["done", "9"]);
assert_eq!(out.status.code(), Some(1));
assert!(
stderr(&out).contains("no task with id 9"),
"{}",
stderr(&out)
);
}
#[test]
fn a_bad_priority_is_refused_and_nothing_is_saved() {
let sb = Sandbox::new("bad-priority");
let out = sb.todo(&["add", "x", "-p", "5"]);
assert_eq!(out.status.code(), Some(1));
assert!(stderr(&out).contains("priority must be 1, 2 or 3, not 5"));
assert!(!sb.file().exists());
}
#[test]
fn a_corrupt_file_is_reported_with_its_path() {
let sb = Sandbox::new("corrupt");
fs::write(sb.file(), "{oops").unwrap();
let out = sb.todo(&["list"]);
assert_eq!(out.status.code(), Some(1));
let err = stderr(&out);
assert!(err.contains("todo.json is not a valid to-do file"), "{err}");
assert!(err.contains("Caused by"), "{err}");
}
#[test]
fn a_usage_error_exits_with_2() {
let sb = Sandbox::new("usage");
let out = sb.todo(&["add"]);
assert_eq!(out.status.code(), Some(2));
assert!(stderr(&out).contains("Usage:"));
}
$ cargo test # one test thread so the order is stable; timings replaced by X.XXs, build hash by HASH
     Running unittests src/lib.rs (target/debug/deps/todo-HASH)
test store::tests::a_missing_file_is_an_empty_list ... ok
test store::tests::a_saved_list_loads_back_equal ... ok
test store::tests::garbage_is_an_error_that_names_the_file ... ok
test tests::completing_marks_the_task ... ok
test tests::display_marks_done_tasks ... ok
test tests::ids_count_up_and_are_not_reused ... ok
test tests::rules_are_enforced ... ok
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs
     Running unittests src/main.rs (target/debug/deps/todo-HASH)
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs
     Running tests/cli.rs (target/debug/deps/cli-HASH)
test a_bad_priority_is_refused_and_nothing_is_saved ... ok
test a_corrupt_file_is_reported_with_its_path ... ok
test a_new_list_is_empty ... ok
test a_usage_error_exits_with_2 ... ok
test added_tasks_are_listed_and_kept_between_runs ... ok
test an_unknown_id_fails_with_a_message ... ok
test done_tasks_can_be_hidden ... ok
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs
   Doc-tests todo
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs
$ for i in 1 2 3 4 5; do cargo test --test cli; done # default thread count; only the result lines
test result: ok. 7 passed; 0 failed
test result: ok. 7 passed; 0 failed
test result: ok. 7 passed; 0 failed
test result: ok. 7 passed; 0 failed
test result: ok. 7 passed; 0 failed

Each file in tests/ is its own crate, built with the library and the binary. Cargo sets CARGO_BIN_EXE_<name> while compiling an integration test: “the absolute path to a binary target’s executable”, usable with the env! macro (Cargo environment variables). So the tests need only std::process::Command: no test-helper crate.

  • Each test gets its own Sandbox folder and passes --file into it, so tests do not share a list file. --test-threads=1 is in the command only so the lines print in the same order each time; the second step runs the tests five times with the default thread count and they passed each time (Testing).
  • They check what a user sees: standard output, standard error, and the exit status (Some(1) for our errors, Some(2) for clap’s).
  • a_bad_priority_is_refused_and_nothing_is_saved checks the file does not exist afterwards.

Why bother when the unit tests pass? Here is a bug in main.rs that the library tests cannot see: the done arm forgets to call save. The first step below deletes that line, then both test sets run:

terminal
$ sed -i '/Command::Done/{n;n;d;}' src/main.rs    # delete the save call in the Done arm
57:        Command::Done { id } => {
58-            println!("finished {}", list.complete(id)?);
59-        }
60-        Command::Remove { id } => {
$ cargo test --lib    # timings replaced by X.XXs
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs
$ cargo test --test cli    # one test thread; timings, build hash and thread id masked
     Running tests/cli.rs (target/debug/deps/cli-HASH)

running 7 tests
test a_bad_priority_is_refused_and_nothing_is_saved ... ok
test a_corrupt_file_is_reported_with_its_path ... ok
test a_new_list_is_empty ... ok
test a_usage_error_exits_with_2 ... ok
test added_tasks_are_listed_and_kept_between_runs ... ok
test an_unknown_id_fails_with_a_message ... ok
test done_tasks_can_be_hidden ... FAILED

failures:

---- done_tasks_can_be_hidden stdout ----

thread 'done_tasks_can_be_hidden' (N) panicked at tests/cli.rs:70:5:
assertion `left == right` failed
  left: "[ ]  1  p2  one\n[ ]  2  p2  two\n"
 right: "[ ]  2  p2  two\n"
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    done_tasks_can_be_hidden

test result: FAILED. 6 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs

error: test failed, to rerun pass `--test cli`

The seven library tests still pass, because the library is fine. The CLI test that marks a task done and then lists the open ones fails, and its message shows both tasks still open. Finally, cargo fmt --check and cargo clippy --all-targets have nothing to say about the finished project:

terminal
$ cargo fmt --check
$ cargo clippy --all-targets

(Empty output and a successful exit mean no complaints: cargo fmt --check printed nothing and clippy printed no warnings.)

In this project Where it is explained
lib.rs and main.rs in one package Project structure
derive(Parser), subcommands, global flags clap
derive(Serialize, Deserialize), serde_json serde
anyhow::Result, bail!, .with_context Errors in real apps, Errors
ErrorKind, temp files, replacing a file Files and I/O
unit tests, tests/, Command Testing
pub, private fields, mod, pub use Modules

Everything ran on one machine, Linux ARM64, so nothing here shows how fs::rename behaves on other systems. Limits of the code as written:

  • load trusts the file: it checks no text, no priority and no id. Changing next_id back by hand makes the next add reuse an id, and a hand-written file with empty text and priority 9 loads and lists:
terminal
$ cargo run -- add "first"
added [ ]  1  p2  first
$ cargo run -- add "second"
added [ ]  2  p2  second
$ sed -i 's/"next_id": 2/"next_id": 0/' todo.json    # edit the file by hand
$ cargo run -- add "third"
added [ ]  1  p2  third
$ cargo run -- list
[ ]  1  p2  first
[ ]  2  p2  second
[ ]  1  p2  third
$ echo '{"next_id":1,"tasks":[{"id":1,"text":"","priority":9,"done":false}]}' > odd.json
$ cargo run -- --file odd.json list
[ ]  1  p9  
  • Two todo processes at the same moment could each read the old list, and the second save would overwrite the first. Nothing here locks the file (not run).
  • The .tmp file would be left behind if rename failed (not run).
  1. Close the id hole. Make load return an error when two tasks share an id or next_id is not above every id, and add a unit test that feeds it such a file.
  2. Give the library its own error type. Replace anyhow in lib.rs with an enum written with thiserror, keeping anyhow in main.rs and store.rs (it needs cargo add thiserror, which needs network and changes Cargo.lock). The rules_are_enforced test can then match on a variant instead of a message (app-errors).
  3. Read the file name from the environment. clap can read an argument from an environment variable when its env feature is on (see the env attribute in the derive reference). Decide which wins, the flag or the variable, and write a test for it (environment variables).
  4. Add a command. Add edit <id> <text> to the enum and watch the compiler list what is missing. Then write its unit test and its CLI test before the implementation.
  5. Split into a workspace. Move the library to its own package and keep the binary as the second (project structure).