Part 1 was the fun part: clap a couple commands together, print some output, feel productive.
Part 2 is where your CLI stops being a demo and starts acting like a real tool you'd actually trust with your future self's sanity.
We're going to add:
a default storage file (plus a --file option)
load on startup, save on updates
a stable on-disk format (JSON or TOML) that won't randomly break later
actionable error handling (no more "something went wrong lol")
(optional) integration tests for the CLI surface (the kind that catch regressions before users do)
One important note before we start: the code snippets below are a reference implementation used for explanation (i.e., example code I'm walking through - not code I'm claiming I wrote). You can copy ideas, adapt patterns, or rewrite it to match your style.
Production Grade Rust CLI: https://tobiweissmann.gumroad.com/l/vxlefy
1) Storage defaults that "just work" (and don't surprise power users)
A CLI should be usable in 2 seconds:
todo add "Buy milk"
todo list
No setup. No config file ceremony. No "please pass --db every time".
At the same time, power users want control:
different file per project
sync folder location
ephemeral file for scripts
testing with temp files
That's where a --file option shines.
Clap setup (global --file)
use clap::{Parser, Subcommand};
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(name = "todo", version, about = "A tiny Rust to-do CLI")]
struct Cli {
/// Path to the storage file (overrides default)
#[arg(long, global = true)]
file: Option<PathBuf>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
Add { text: String },
List,
Done { id: u64 },
Rm { id: u64 },
Clear,
}
Now every command can share the same --file without repeating it.
Choosing a default path
On Linux/macOS/Windows, users expect you to put app data in the platform-appropriate place.
Use dirs or directories crate.
Example default:
Linux: ~/.local/share/todo/tasks.json
macOS: ~/Library/Application Support/todo/tasks.json
Windows: %APPDATA%\todo\tasks.json
use std::path::PathBuf;
fn default_store_path() -> PathBuf {
let proj_dirs = directories::ProjectDirs::from("com", "example", "todo")
.expect("Could not determine data directory");
proj_dirs.data_dir().join("tasks.json")
}
And then:
let store_path = cli.file.unwrap_or_else(default_store_path);
That's the whole UX win: zero-config default, override when needed.
2) Load on startup, save on updates (the "don't lose my stuff" contract)
A to-do app that forgets your tasks is not a to-do app. It's a motivational quote generator.
A clean mental model:
Startup: read file → parse tasks → keep in memory
Mutating commands: apply change → write file atomically
Read-only commands (list): no write
Data model + stable IDs
Keep it boring and stable:
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Task {
id: u64,
text: String,
done: bool,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct TaskDb {
next_id: u64,
tasks: Vec<Task>,
}
Why next_id? Because "ID is index in a Vec" is how you create sadness.
3) Pick a format: JSON vs TOML (and keep it stable)
JSON is a great default for CLIs
Pros:
ubiquitous tooling
easy debugging
stable schema evolution
faster to parse than you think for small files
TOML looks nicer to humans, but JSON is the "least surprising" for programs and scripts.
My practical advice:
choose JSON unless your audience strongly prefers editing by hand
version your schema early (even if you don't need it yet)
Add a file format version (future-proofing)
#[derive(Debug, Serialize, Deserialize)]
struct StoredDb {
version: u32,
db: TaskDb,
}
Write version: 1 now. In 6 months, you'll thank yourself.
4) Atomic writes: the difference between "reliable" and "one crash = data loss"
If you write directly to tasks.json and the process dies mid-write (power loss, Ctrl+C, OS update, you name it), you can end up with a half-written file.
The classic fix:
write to tasks.json.tmp
flush
rename to tasks.json (atomic on most filesystems)
use std::{fs, io};
use std::path::Path;
fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("tmp");
fs::write(&tmp, bytes)?;
fs::rename(tmp, path)?;
Ok(())
}
For extra safety, you can sync_all() file/dir handles-but for most personal CLI use cases, this is already a huge step up.
5) Tighten error handling so failures are actionable
"Error: failed" is not an error message. It's a shrug.
Actionable means:
what happened
where it happened (which file)
what to do next (permissions? corrupted file? wrong flag?)
Use thiserror for domain errors
use thiserror::Error;
use std::path::PathBuf;
#[derive(Debug, Error)]
enum TodoError {
#[error("Could not read storage file: {path}\nReason: {source}")]
ReadFailed { path: PathBuf, source: std::io::Error },
#[error("Storage file is not valid JSON: {path}\nReason: {source}\nTip: fix the file or move it aside to start fresh.")]
JsonInvalid { path: PathBuf, source: serde_json::Error },
#[error("Could not write storage file: {path}\nReason: {source}\nTip: check permissions or choose a different path with --file.")]
WriteFailed { path: PathBuf, source: std::io::Error },
}
Load function with good errors
use std::{fs, path::PathBuf};
fn load_db(path: PathBuf) -> Result<TaskDb, TodoError> {
match fs::read_to_string(&path) {
Ok(s) => {
if s.trim().is_empty() {
return Ok(TaskDb::default());
}
let stored: StoredDb = serde_json::from_str(&s)
.map_err(|e| TodoError::JsonInvalid { path: path.clone(), source: e })?;
Ok(stored.db)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(TaskDb::default()),
Err(e) => Err(TodoError::ReadFailed { path, source: e }),
}
}
That NotFound → empty DB behavior is crucial: first run should feel effortless.
Save function
fn save_db(path: PathBuf, db: &TaskDb) -> Result<(), TodoError> {
let stored = StoredDb { version: 1, db: db.clone() };
let bytes = serde_json::to_vec_pretty(&stored)
.expect("Serialization should not fail for valid types");
atomic_write(&path, &bytes)
.map_err(|e| TodoError::WriteFailed { path, source: e })
}
Now when something breaks, the user gets a message they can actually act on.
6) Wire it into commands: mutate → save
The flow in main() becomes simple and predictable:
fn main() -> Result<(), TodoError> {
let cli = Cli::parse();
let path = cli.file.unwrap_or_else(default_store_path);
let mut db = load_db(path.clone())?;
match cli.command {
Commands::Add { text } => {
let id = db.next_id + 1;
db.next_id = id;
db.tasks.push(Task { id, text, done: false });
save_db(path, &db)?;
println!("Added task #{id}");
}
Commands::List => {
for t in &db.tasks {
let mark = if t.done { "✅" } else { "⬜" };
println!("{mark} {}: {}", t.id, t.text);
}
}
Commands::Done { id } => {
if let Some(t) = db.tasks.iter_mut().find(|t| t.id == id) {
t.done = true;
save_db(path, &db)?;
println!("Marked #{id} as done");
} else {
eprintln!("No task with id {id}");
}
}
Commands::Rm { id } => {
let before = db.tasks.len();
db.tasks.retain(|t| t.id != id);
if db.tasks.len() != before {
save_db(path, &db)?;
println!("Removed #{id}");
} else {
eprintln!("No task with id {id}");
}
}
Commands::Clear => {
db.tasks.clear();
save_db(path, &db)?;
println!("Cleared all tasks");
}
}
Ok(())
}
This is the "adult" CLI pattern:
state in memory
deterministic saves
minimal surprises
7) Optional: CLI integration tests (the easiest tests that catch the most bugs)
Unit tests won't catch:
clap parsing changes
output formatting regressions
"it works locally but fails when executed like a real binary"
Integration tests will.
Use:
assert_cmd to run the binary
tempfile to isolate storage
predicates to check output
Cargo.toml (dev-deps)
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
tempfile = "3"
Example test: add + list
use assert_cmd::Command;
use predicates::str::contains;
use tempfile::tempdir;
#[test]
fn add_then_list_shows_task() {
let dir = tempdir().unwrap();
let file = dir.path().join("tasks.json");
Command::cargo_bin("todo").unwrap()
.args(["--file", file.to_str().unwrap(), "add", "hello"])
.assert()
.success();
Command::cargo_bin("todo").unwrap()
.args(["--file", file.to_str().unwrap(), "list"])
.assert()
.success()
.stdout(contains("hello"));
}
This test doesn't care how you implement storage internally - only that the CLI behaves correctly. That's what you want.
The real takeaway: you're building trust, not features
Part 2 isn't about fancy commands.
It's about trust.
default file location that behaves like a real app
stable storage format that doesn't self-destruct
error messages that tell users what to do next
tests that prevent accidental breakage
Once those foundations are in place, Part 3 can be the fun stuff again:
priorities, due dates, tags, search, interactive TUI… whatever you want.
But without Part 2? Every new feature is built on sand.
Production Grade Rust CLI: https://tobiweissmann.gumroad.com/l/vxlefy
Top comments (0)