Rust: First Steps. The Todo That Remembers
At the end of part one you were left with a working program: a hundred lines, zero dependencies. It adds tasks, ticks them off and removes them.
And loses everything on exit.
Close the terminal — the list is gone. Press Ctrl-C — gone. Leave properly, through quit — still gone. A todo list that remembers nothing is useless.
Let’s teach it to remember. Step by step:
- split the code into files — storage needs one of its own;
- decide what a task looks like in a file;
- learn to read the file and write it;
- wire all of it into the program and get rid of
.unwrap(); - write tests;
- build a release and install the program for ourselves.
Along the way we’ll take apart modules, Result and the ? operator. Still zero dependencies.
Step 1. Splitting the code into files
Right now everything lives in main.rs. Reading and writing a file will add another seventy lines or so, and one file will become awkward. So first let’s lay out what we already have:
src/
main.rs — the loop and printing to the screen
task.rs — what a task is
command.rs — parsing a command
storage.rs — reading and writing the file
Create src/task.rs and move struct Task into it along with its impl. Move enum Command and the parse and number functions into src/command.rs. Create an empty src/storage.rs — we’ll fill it in step three. print_list and main stay in main.rs.
A file has to be declared
A file in the src folder means nothing on its own. To become part of the program, it’s declared in main.rs. Add at the top:
// src/main.rs
mod command;
mod storage;
mod task;
use std::io::{self, Write};
use command::Command;
use task::Task;
mod task; means “take the file task.rs and include it as the module task”. Forget this line and the compiler won’t see the file, even though it sits right there.
use does something else. It includes nothing; it only shortens a name: after use task::Task you can write Task instead of task::Task.
parse now lives in the command module, so in the loop the call is written like this: command::parse(&line).
Everything is closed until it’s opened
Build it:
error[E0603]: struct `Task` is private
--> src/main.rs:8:11
|
8 | use task::Task;
| ^^^^ private struct
|
note: the struct `Task` is defined here
--> src/task.rs:2:1
|
2 | struct Task {
| ^^^^^^^^^^^
You’ll get the same error about Command and about parse.
In Rust, everything declared in a module is visible only inside that module. While the code was in one file, that didn’t get in the way. Now main.rs is an outsider to task.rs, and access has to be opened explicitly, with the word pub:
// src/task.rs
#[derive(Debug, PartialEq)]
pub struct Task {
pub title: String,
pub done: bool,
}
impl Task {
pub fn new(title: String) -> Task {
Task { title, done: false }
}
pub fn mark(&self) -> char {
if self.done { 'x' } else { ' ' }
}
}
pub goes on the struct, on every field and on every method. Those are three separate decisions: a struct can be public while its fields are not. Then the struct is visible from outside, but you can’t look inside it.
The same in command.rs:
// src/command.rs
#[derive(Debug, PartialEq)]
pub enum Command {
Add(String),
Done(usize),
Remove(usize),
List,
Quit,
Unknown(String),
}
pub fn parse(line: &str) -> Command {
// unchanged from part one
}
fn number(rest: &str, make: fn(usize) -> Command) -> Command {
// unchanged from part one
}
Enum variants don’t need a pub of their own: if the enum is public, all its variants are too.
parse is public because main.rs calls it. number is only called by parse, from the same file, so we leave it closed.
One more new line — PartialEq in the derive of both types. We’ll need it in step five, for the tests. Add it now so you don’t have to come back.
At first, pub at every turn feels like noise. But it has a flip side: everything without pub is your own internal business. Nobody outside relies on it, so a function like that can be renamed or rewritten without checking the rest of the code.
Build again. The program works as before, only now it’s in four files.
Step 2. What a task looks like in a file
Before writing anything to disk, you have to decide in what form.
JSON is an option. But without libraries you’d have to assemble it by hand: escape quotes, backslashes, line breaks. You’d get it wrong somewhere.
You could separate the fields with a comma. But a comma can turn up in a task’s text — and the line falls apart.
We’ll take the simplest option: one task, one line. At the start, a flag, 1 or 0 — done or not. Then a tab. Then the task’s text:
1 buy milk
0 walk the cat
Why this is reliable:
- A task’s text never contains a line break. The program reads a command with
read_line, and that stops at a line break. - A tab in the text breaks nothing. We cut the line at the first tab. Everything after it is the task’s text, even if there are more tabs in it.
And a file like this can be opened in any editor and fixed by hand.
From a task to a line
Add a method to impl Task:
// src/task.rs, inside impl Task
/// One line of the file: the flag, a tab, the text.
pub fn to_line(&self) -> String {
let flag = if self.done { '1' } else { '0' };
format!("{flag}\t{}", self.title)
}
\t is a tab. Three slashes, ///, make a doc comment: an ordinary // is visible only in the code, while /// also ends up in the documentation that cargo doc builds.
From a line to a task
The reverse conversion can fail: a line in the file may turn out to be broken. So the method returns Option<Task> — a task or None:
// src/task.rs, inside impl Task
/// None if the line doesn't look like a task.
pub fn from_line(line: &str) -> Option<Task> {
let (flag, title) = line.split_once('\t')?;
Some(Task {
title: title.to_string(),
done: flag == "1",
})
}
There it is. The question mark.
You saw split_once in part one, where it cut a command at a space. Here it cuts at a tab and returns an Option: a pair of “before” and “after”, or None if the line has no tab.
The ? after it works like this:
- if there’s a
Some— take the value out and carry on; - if there’s a
None— leave the function right away and returnNone.
In other words, it’s a short way of writing this match:
let (flag, title) = match line.split_once('\t') {
Some(pair) => pair,
None => return None,
};
? has one condition: the function itself has to return an Option (or a Result — more on that in the next step). Otherwise it has nothing to return in the bad case. If from_line returned a plain Task, the compiler would say:
error[E0277]: the `?` operator can only be used in a method that returns `Result` or `Option` (or another type that implements `FromResidual`)
--> src/task.rs:24:46
|
23 | pub fn from_line(line: &str) -> Task {
| ------------------------------------ this function should return `Result` or `Option` to accept `?`
24 | let (flag, title) = line.split_once('\t')?;
| ^ cannot use the `?` operator in a method that returns `Task`
This version of from_line has a bug in it. You can’t see it by eye, so we leave it as it is for now — a test will find it in step five.
Step 3. Reading and writing the file
This part lives in storage.rs. Start the file with the imports:
// src/storage.rs
use std::fs;
use std::io::{self, ErrorKind};
use std::path::{Path, PathBuf};
use crate::task::Task;
use crate::task::Task is “take Task from our program’s task module”. crate means the root of the program, which is main.rs. From main.rs itself we wrote task::Task. From a neighbouring file the path starts with crate::.
Until main calls the functions in this file, the compiler will warn about unused code. That’s fine: the warnings go away in step four, when we wire them in.
Reading, and what a Result is
// src/storage.rs
pub fn load(path: &Path) -> io::Result<Vec<Task>> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
// No file yet is not an error: it is the first run.
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
Ok(text.lines().filter_map(Task::from_line).collect())
}
&Path is the path to the file. Where it comes from, we’ll get to a little further down.
fs::read_to_string reads the whole file and returns a Result. That’s an enum very much like Option. Only the second variant holds not emptiness but the reason for the failure:
enum Result<T, E> {
Ok(T), // it worked, here is the value
Err(E), // it didn't, here is why
}
Nothing more. An error in Rust is an ordinary value that a function returns.
io::Result<Vec<Task>> in the signature is shorthand for Result<Vec<Task>, io::Error>. Every input/output operation returns the same error type, io::Error, so there’s an abbreviation for them.
Now the match, branch by branch.
The file was read — we take the text.
There’s no file. The if after the pattern is an extra condition: the branch fires only if the error is specifically “file not found”. And this is an important decision: a missing file is not an error, it’s the first run. The right answer is an empty list.
Any other error — no permission to read, the file isn’t text, the disk failed. The program can’t fix that, so it hands the error back to whoever called it.
The last line reads left to right:
Ok(text.lines().filter_map(Task::from_line).collect())
lines()— go through the lines;filter_map(Task::from_line)— turn each line into a task, and throw away the onesfrom_linereturnedNonefor;collect()— gather the result into aVec<Task>. Which type to gather into,collectworks out from the function’s signature.
Broken lines are skipped silently. The file can be edited by hand, and one crooked line shouldn’t break the whole list.
Why an error in the type is a good thing
In most languages with exceptions, a function’s signature doesn’t tell you whether it can fail. You find out at runtime, from a stack trace.
In Rust it’s written in the type. io::Result<Vec<Task>> reads as “I’ll return a list of tasks or an input/output error”. To get to the list, you’ll have to decide what to do about the error. Keeping quiet won’t work — we’ll see that in step four.
Where the file lives
The obvious choice is todo.txt in the current folder. Don’t. Then every folder you launch the program from gets its own list: one from ~/work, another from ~/Downloads. And tasks will “disappear”.
What you need is one place for the whole system — a hidden file in the home folder, ~/.todo.txt:
// src/storage.rs
pub fn path() -> io::Result<PathBuf> {
match std::env::home_dir() {
Some(home) => Ok(home.join(".todo.txt")),
None => Err(io::Error::new(ErrorKind::NotFound, "no home directory")),
}
}
PathBuf and Path are the same kind of pair as String and &str from part one. PathBuf owns a path, &Path only looks at somebody else’s. So path() creates a path and returns a PathBuf, while load only reads the path and takes a &Path.
home.join(".todo.txt") adds one more piece to a path. Don’t glue paths together with format!: the separator depends on the system, and join takes care of that.
home_dir() returns an Option, because there may turn out to be no home folder. It’s rare, but it happens. And here there’s a temptation to substitute a fallback:
std::env::home_dir().unwrap_or_else(|| PathBuf::from("."))
“No home folder — we’ll take the current one.” Looks considerate. But it’s the very problem we just got away from: the list will quietly move to wherever the program was launched from.
So it’s more honest to refuse. path() returns io::Result<PathBuf>: a path or an error with a clear message. io::Error::new creates such an error — you give it its kind and a message.
Writing
// src/storage.rs
pub fn save(path: &Path, tasks: &[Task]) -> io::Result<()> {
let mut text = String::new();
for task in tasks {
text.push_str(&task.to_line());
text.push('\n');
}
fs::write(path, text)
}
We gather all the tasks into one string and write the whole file. fs::write creates the file if there isn’t one, and overwrites it if there is.
io::Result<()> is “either nothing, or an error”. () is the empty value, and in Rust that’s literally how it’s written. It resembles void from other languages, but it’s a real value: you can put it in Ok(()).
The last line without a semicolon is what the function returns. fs::write already returns io::Result<()>, so we hand its result over as it is.
Step 4. Wiring it into the program
We have path, load and save. All that’s left is to call them from main. And while we’re at it, deal with the two .unwrap()s from part one:
io::stdout().flush().unwrap();
let read = io::stdin().read_line(&mut line).unwrap();
.unwrap() means “give me the value, and if there’s an error in there, crash the program”. Fine for a draft. But now errors will be an everyday thing — no permission on the file, for example — and a person should see a clear message, not a crash dump.
You want to write all these calls with ?. But ? only works in a function that itself returns a Result. And main doesn’t return anything yet.
A main that returns a Result
This is allowed:
// src/main.rs
fn main() -> io::Result<()> {
let path = storage::path()?;
let mut tasks = storage::load(&path)?;
loop {
print!("> ");
io::stdout().flush()?;
// ...
}
Ok(())
}
If any ? gets an error, main finishes, and Rust prints the error by itself and exits with code 1. Let’s check: forbid reading ~/.todo.txt and run the program.
Error: Os { code: 13, kind: PermissionDenied, message: "Permission denied" }
It works. But look at it through the eyes of a person who wanted to open their todo list.
Os { code: 13, kind: PermissionDenied } is the error’s debug representation, the same output {:?} gives. Fine for a developer, not for a user. And most importantly: it doesn’t say which file. And that’s the one thing you need to know to go and fix it.
Let’s fix it in two steps.
The error knows its file
Add a small function to storage.rs:
// src/storage.rs
/// The same error, with the file name in front of the message.
fn with_path(path: &Path, e: io::Error) -> io::Error {
io::Error::new(e.kind(), format!("{}: {e}", path.display()))
}
It creates a new error of the same kind, but with the path at the start of the message. {e} in format! is the error’s text for a human. {e:?} would have given that very Os { code: 13, ... }.
Use it in load, in the last branch:
Err(e) => return Err(with_path(path, e)),
And in save, through map_err:
fs::write(path, text).map_err(|e| with_path(path, e))
map_err means: “if there’s an error in there, transform it with this function, and leave a successful result alone”.
main shows the error, run does the work
Move the whole body of main into a new function, run. And leave just one thing in main: show the error, if there was one.
// src/main.rs
fn main() {
if let Err(e) = run() {
eprintln!("todo: {e}");
std::process::exit(1);
}
}
fn run() -> io::Result<()> {
let path = storage::path()?;
let mut tasks = storage::load(&path)?;
let count = tasks.len();
let word = if count == 1 { "task" } else { "tasks" };
println!("todo — {count} {word}, {}", path.display());
println!("commands: add <text>, done <N>, rm <N>, list, quit");
loop {
print!("> ");
io::stdout().flush()?;
let mut line = String::new();
if io::stdin().read_line(&mut line)? == 0 {
println!();
break;
}
// the match over the command — see below
}
Ok(())
}
if let Err(e) = run() is a match where we care about one branch. If run returned an error, it lands in e. If all is well, nothing happens.
eprintln! prints not to the ordinary output but to the error stream. If somebody redirects the program’s output to a file, errors won’t go there and will stay on the screen.
std::process::exit(1) ends the program with code 1. That code is how other programs and scripts understand that something went wrong.
Now the same situation looks like this:
todo: /Users/jwo1f/.todo.txt: Permission denied (os error 13)
Which file, and what happened to it. Nothing else is needed.
The greeting changed along the way too. Now at start-up the program says how many tasks it loaded and from which file. The path is especially useful: it tells you what to back up. And word is there so we don’t write “1 tasks”.
In a real project, instead of with_path you’d take the anyhow library and write .context("could not read the task list"). It does the same thing, only more conveniently. But we have zero dependencies — and now at least it’s clear why it’s needed.
When to save
The first thing that comes to mind is on exit, once, before break.
Don’t. Ctrl-C, a closed terminal, a laptop battery running out — and every change of the session is lost. Exactly what we’re fixing.
You have to save after every change. Yes, that’s rewriting the whole file on every command. But a todo list is a couple of kilobytes, and writing it takes a fraction of a millisecond.
How do you avoid forgetting to save in any of the match branches? Let every branch answer one question: did the list change. The answer is true or false, and we save once, after the match:
// src/main.rs, inside the loop in run
let changed = match command::parse(&line) {
Command::Add(title) => {
if title.is_empty() {
println!(" add what?");
false
} else {
println!(" added: {title}");
tasks.push(Task::new(title));
true
}
}
Command::List => {
print_list(&tasks);
false
}
// ...the other commands, the same way
Command::Quit => break,
};
if changed {
storage::save(&path, &tasks)?;
}
It’s the same trick as if as an expression from part one. A branch has no return: its value is the last expression without a semicolon. The whole match becomes one value, and it goes into changed.
The Quit branch returns no value — it leaves the loop through break. The compiler understands that and doesn’t demand true or false from it.
All the branches in full are in the complete code at the end of the article.
And now what was promised. Here’s what happens if you forget the ? after save:
warning: unused `Result` that must be used
--> src/main.rs:92:7
|
92 | storage::save(&path, &tasks);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: this `Result` may be an `Err` variant, which should be handled
= note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default
help: use `let _ = ...` to ignore the resulting value
|
92 | let _ = storage::save(&path, &tasks);
| +++++++
A Result can’t just be thrown away. You can refuse it explicitly by writing let _ = — but that’s a conscious decision, not something you write by accident.
Step 5. Tests live next to the code
In Rust, unit tests are written in the same file as the code — at the end. Add to the end of task.rs:
// src/task.rs, at the end of the file
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn line_round_trip() {
let task = Task {
title: String::from("buy milk"),
done: true,
};
assert_eq!(Task::from_line(&task.to_line()), Some(task));
}
#[test]
fn tabs_are_the_only_separator() {
let task = Task::from_line("0\tbuy milk, and then\tthe cat").unwrap();
assert_eq!(task.title, "buy milk, and then\tthe cat");
}
#[test]
fn broken_lines_are_skipped() {
assert_eq!(Task::from_line("rubbish"), None);
assert_eq!(Task::from_line("1\t"), None);
assert_eq!(Task::from_line(""), None);
}
}
What’s what here:
mod tests { ... }— a module declared right inside the file. A module doesn’t need a file of its own.#[cfg(test)]— this module is built only bycargo test. It won’t end up in the ordinary program.use super::*— “take everything from the module one level up”, that is, fromtask.rs. Tests inside the file can see even what has nopub.#[test]— this function is a test.assert_eq!(a, b)— check thataequalsb. If not, the test fails.
That’s what #[derive(Debug, PartialEq)] was for. PartialEq lets assert_eq! compare two tasks. Debug lets it print them if they don’t match.
.unwrap() in tests is fine. If it hits an error, the test fails, and that’s exactly what you want.
The first test checks that a task turned into a line and back hasn’t changed. The second is the decision about tabs from step two, written down as a check. The third checks that broken lines don’t turn into tasks.
Run it:
cargo test
running 3 tests
test task::tests::tabs_are_the_only_separator ... ok
test task::tests::line_round_trip ... ok
test task::tests::broken_lines_are_skipped ... FAILED
failures:
---- task::tests::broken_lines_are_skipped stdout ----
thread 'task::tests::broken_lines_are_skipped' (5632722) panicked at src/task.rs:55:5:
assertion `left == right` failed
left: Some(Task { title: "", done: true })
right: None
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
task::tests::broken_lines_are_skipped
test result: FAILED. 2 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--bin todo`
And there’s the bug promised in step two.
The line "1\t" — there’s a flag, there’s a tab, there’s no text. from_line turned it into a task with an empty title. You can’t add a task like that through the program: to an empty add it answers “add what?”. But the file can be edited by hand, and one stray tab puts an empty numbered item in the list.
You’d never find this by hand. Who is going to check an empty task?
Fix from_line: a line with no text is not a task.
// src/task.rs, inside impl Task
pub fn from_line(line: &str) -> Option<Task> {
let (flag, title) = line.split_once('\t')?;
if title.is_empty() {
return None;
}
Some(Task {
title: title.to_string(),
done: flag == "1",
})
}
Notice how much the failed test told you: what was expected, what came out, and on which line.
Tests for the storage
At the end of storage.rs:
// src/storage.rs, at the end of the file
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_file_is_an_empty_list() {
let path = std::env::temp_dir().join("todo-test-no-such-file.txt");
let _ = fs::remove_file(&path);
assert_eq!(load(&path).unwrap(), Vec::new());
}
#[test]
fn what_is_saved_is_what_is_loaded() {
let path = std::env::temp_dir().join("todo-test-round-trip.txt");
let tasks = vec![
Task::new(String::from("buy milk")),
Task {
title: String::from("walk the cat"),
done: true,
},
];
save(&path, &tasks).unwrap();
assert_eq!(load(&path).unwrap(), tasks);
fs::remove_file(&path).unwrap();
}
}
The first test checks the decision “no file means an empty list”. The second checks that saved tasks read back unchanged.
Both work with files in the system’s temporary folder, std::env::temp_dir(). A test shouldn’t touch your real ~/.todo.txt.
let _ = fs::remove_file(&path) is that same explicit refusal of a Result. The file may well not exist before the test, and that’s fine: all we need is for it definitely not to be there.
Tests for the commands
At the end of command.rs:
// src/command.rs, at the end of the file
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_keeps_the_whole_tail() {
assert_eq!(
parse("add buy milk and the cat"),
Command::Add(String::from("buy milk and the cat"))
);
}
#[test]
fn empty_line_is_a_list() {
assert_eq!(parse(""), Command::List);
assert_eq!(parse(" \n"), Command::List);
}
#[test]
fn short_forms_work() {
assert_eq!(parse("d 3"), Command::Done(3));
assert_eq!(parse("a cat"), Command::Add(String::from("cat")));
}
#[test]
fn a_number_that_is_not_a_number() {
assert!(matches!(parse("done cat"), Command::Unknown(_)));
assert!(matches!(parse("done -1"), Command::Unknown(_)));
}
}
matches!(value, pattern) returns true if the value fits the pattern. Command::Unknown(_) is “any Unknown, whatever is inside”. The test doesn’t check the message text, so the text can change without breaking the test.
parse("done -1") checks a case related to the bug from part one. There, done 0 got as far as subtracting one and crashed the program. -1 never gets to the subtraction: a usize can’t be negative, and parsing the number returns an error.
Run everything together:
running 9 tests
test command::tests::empty_line_is_a_list ... ok
test command::tests::add_keeps_the_whole_tail ... ok
test command::tests::short_forms_work ... ok
test task::tests::broken_lines_are_skipped ... ok
test storage::tests::missing_file_is_an_empty_list ... ok
test command::tests::a_number_that_is_not_a_number ... ok
test task::tests::line_round_trip ... ok
test task::tests::tabs_are_the_only_separator ... ok
test storage::tests::what_is_saved_is_what_is_loaded ... ok
test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Nine tests, and no test framework — it’s all part of cargo.
The order of the lines is different on every run: the tests run in parallel.
Checking it
cargo run
todo — 0 tasks, /Users/jwo1f/.todo.txt
commands: add <text>, done <N>, rm <N>, list, quit
> add buy milk
added: buy milk
> add walk the cat
added: walk the cat
> add write the article
added: write the article
> done 2
done: walk the cat
>
1. [ ] buy milk
2. [x] walk the cat
3. [ ] write the article
> ^D
Leave with Ctrl-D and start it again:
todo — 3 tasks, /Users/jwo1f/.todo.txt
commands: add <text>, done <N>, rm <N>, list, quit
>
1. [ ] buy milk
2. [x] walk the cat
3. [ ] write the article
> ^D
It remembers.
And ~/.todo.txt holds exactly what we came up with in step two:
0 buy milk
1 walk the cat
0 write the article
Where this breaks
The program works, but it has weak spots. Better to know them in advance.
The write isn’t atomic. fs::write first empties the file and then writes into it. Cut the power between those two moments and the file is left empty or truncated. The usual cure: write to a temporary file next to it, then rename it over the old one with fs::rename. A rename either happens completely or doesn’t happen at all. For a todo list, I accept this risk. For important data, I wouldn’t.
Two running copies overwrite each other. Each keeps its own list in memory and saves it whole. Whichever saved last wins.
An error while saving ends the program. If the disk fills up, save returns an error, ? passes it from run to main, and the program exits with a message. The last change doesn’t reach the disk. We could show the error and keep going, but then a person would be adding tasks that aren’t saved anywhere. Stopping is more honest.
Building a release
Everything we’ve run so far is a debug build: no optimisations, extra checks. For actual use you build a release one:
cargo build --release
Finished `release` profile [optimized] target(s) in 0.42s
ls -lh target/release/todo
-rwxr-xr-x@ 1 jwo1f wheel 448K Sep 25 12:40 target/release/todo
448 kilobytes, one file. It needs neither a runtime nor a virtual machine.
Almost all of that size is the standard library, which is built into the file. For comparison: the Hello, world! from part one weighs 424 kilobytes in a release build. Our program added 24 to it.
You can copy a file like this to another computer with the same operating system and the same kind of processor, and it will run.
To be able to run the program from any folder, install it:
cargo install --path .
This command builds a release and puts it in ~/.cargo/bin. That folder is already in your PATH — rustup added it there when you installed it. Now typing todo is enough.
All the code
The four files in full — to check against your own.
// src/main.rs
mod command;
mod storage;
mod task;
use std::io::{self, Write};
use command::Command;
use task::Task;
fn print_list(tasks: &[Task]) {
if tasks.is_empty() {
println!(" (empty)");
return;
}
for (i, task) in tasks.iter().enumerate() {
println!("{:>3}. [{}] {}", i + 1, task.mark(), task.title);
}
}
fn main() {
if let Err(e) = run() {
eprintln!("todo: {e}");
std::process::exit(1);
}
}
fn run() -> io::Result<()> {
let path = storage::path()?;
let mut tasks = storage::load(&path)?;
let count = tasks.len();
let word = if count == 1 { "task" } else { "tasks" };
println!("todo — {count} {word}, {}", path.display());
println!("commands: add <text>, done <N>, rm <N>, list, quit");
loop {
print!("> ");
io::stdout().flush()?;
let mut line = String::new();
if io::stdin().read_line(&mut line)? == 0 {
println!();
break;
}
// Every branch answers one question: did the list change?
let changed = match command::parse(&line) {
Command::Add(title) => {
if title.is_empty() {
println!(" add what?");
false
} else {
println!(" added: {title}");
tasks.push(Task::new(title));
true
}
}
Command::Done(n) => match n.checked_sub(1).and_then(|i| tasks.get_mut(i)) {
Some(task) => {
task.done = true;
println!(" done: {}", task.title);
true
}
None => {
println!(" no task numbered {n}");
false
}
},
Command::Remove(n) => {
if n >= 1 && n <= tasks.len() {
let task = tasks.remove(n - 1);
println!(" removed: {}", task.title);
true
} else {
println!(" no task numbered {n}");
false
}
}
Command::List => {
print_list(&tasks);
false
}
Command::Unknown(what) => {
println!(" no such command: {what}");
false
}
Command::Quit => break,
};
if changed {
storage::save(&path, &tasks)?;
}
}
Ok(())
}
// src/task.rs
#[derive(Debug, PartialEq)]
pub struct Task {
pub title: String,
pub done: bool,
}
impl Task {
pub fn new(title: String) -> Task {
Task { title, done: false }
}
pub fn mark(&self) -> char {
if self.done { 'x' } else { ' ' }
}
/// One line of the file: the flag, a tab, the text.
pub fn to_line(&self) -> String {
let flag = if self.done { '1' } else { '0' };
format!("{flag}\t{}", self.title)
}
/// None if the line doesn't look like a task.
pub fn from_line(line: &str) -> Option<Task> {
let (flag, title) = line.split_once('\t')?;
if title.is_empty() {
return None;
}
Some(Task {
title: title.to_string(),
done: flag == "1",
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn line_round_trip() {
let task = Task {
title: String::from("buy milk"),
done: true,
};
assert_eq!(Task::from_line(&task.to_line()), Some(task));
}
#[test]
fn tabs_are_the_only_separator() {
let task = Task::from_line("0\tbuy milk, and then\tthe cat").unwrap();
assert_eq!(task.title, "buy milk, and then\tthe cat");
}
#[test]
fn broken_lines_are_skipped() {
assert_eq!(Task::from_line("rubbish"), None);
assert_eq!(Task::from_line("1\t"), None);
assert_eq!(Task::from_line(""), None);
}
}
// src/command.rs
#[derive(Debug, PartialEq)]
pub enum Command {
Add(String),
Done(usize),
Remove(usize),
List,
Quit,
Unknown(String),
}
pub fn parse(line: &str) -> Command {
let line = line.trim();
let (word, rest) = match line.split_once(' ') {
Some((word, rest)) => (word, rest.trim()),
None => (line, ""),
};
match word {
"" | "list" | "ls" => Command::List,
"add" | "a" => Command::Add(rest.to_string()),
"done" | "d" => number(rest, Command::Done),
"rm" => number(rest, Command::Remove),
"quit" | "q" | "exit" => Command::Quit,
other => Command::Unknown(other.to_string()),
}
}
fn number(rest: &str, make: fn(usize) -> Command) -> Command {
match rest.parse::<usize>() {
Ok(n) => make(n),
Err(_) => Command::Unknown(format!("that is not a number: {rest:?}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_keeps_the_whole_tail() {
assert_eq!(
parse("add buy milk and the cat"),
Command::Add(String::from("buy milk and the cat"))
);
}
#[test]
fn empty_line_is_a_list() {
assert_eq!(parse(""), Command::List);
assert_eq!(parse(" \n"), Command::List);
}
#[test]
fn short_forms_work() {
assert_eq!(parse("d 3"), Command::Done(3));
assert_eq!(parse("a cat"), Command::Add(String::from("cat")));
}
#[test]
fn a_number_that_is_not_a_number() {
assert!(matches!(parse("done cat"), Command::Unknown(_)));
assert!(matches!(parse("done -1"), Command::Unknown(_)));
}
}
// src/storage.rs
use std::fs;
use std::io::{self, ErrorKind};
use std::path::{Path, PathBuf};
use crate::task::Task;
pub fn path() -> io::Result<PathBuf> {
match std::env::home_dir() {
Some(home) => Ok(home.join(".todo.txt")),
None => Err(io::Error::new(ErrorKind::NotFound, "no home directory")),
}
}
pub fn load(path: &Path) -> io::Result<Vec<Task>> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
// No file yet is not an error: it is the first run.
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(with_path(path, e)),
};
Ok(text.lines().filter_map(Task::from_line).collect())
}
pub fn save(path: &Path, tasks: &[Task]) -> io::Result<()> {
let mut text = String::new();
for task in tasks {
text.push_str(&task.to_line());
text.push('\n');
}
fs::write(path, text).map_err(|e| with_path(path, e))
}
/// The same error, with the file name in front of the message.
fn with_path(path: &Path, e: io::Error) -> io::Error {
io::Error::new(e.kind(), format!("{}: {e}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_file_is_an_empty_list() {
let path = std::env::temp_dir().join("todo-test-no-such-file.txt");
let _ = fs::remove_file(&path);
assert_eq!(load(&path).unwrap(), Vec::new());
}
#[test]
fn what_is_saved_is_what_is_loaded() {
let path = std::env::temp_dir().join("todo-test-round-trip.txt");
let tasks = vec![
Task::new(String::from("buy milk")),
Task {
title: String::from("walk the cat"),
done: true,
},
];
save(&path, &tasks).unwrap();
assert_eq!(load(&path).unwrap(), tasks);
fs::remove_file(&path).unwrap();
}
}
What next
We wrote a program without a single dependency. On purpose: that way we were getting to know the language, not somebody else’s libraries. Real projects aren’t done like this. Here’s what’s worth trying next:
serdeandserde_json— saving data.to_lineandfrom_lineare replaced by a single line,#[derive(Serialize, Deserialize)], and the file becomes ordinary JSON.anyhow— convenient errors in programs. What we did withwith_pathandmap_err, in one line.thiserror— your own error types, for when you’re writing a library.clap— parsing command-line arguments. Keep in mind: it parses the arguments a program is launched with, and our program is a dialogue. Withclapit would become this:todo add buy milk— one command, and back to the terminal. On the other hand, it’ll write--helpfor you.
A library is added with one command:
cargo add serde --features derive
What to read:
- The Rust Programming Language — the official book. Long, but nothing better has been written about the language.
- Rustlings — a set of small broken programs you have to fix. It checks for itself whether you’ve managed. An hour a day for a couple of weeks, and the syntax stops getting in the way.
- Rust by Example — for those who find code easier to read than prose.
All of this and the rest of the official material is gathered at rust-lang.org.
One last thing
In part one the program could do what it was asked. Now it knows what to do when it can’t. No file — that’s the first run. No permission — say which file. A broken line — skip it.
And all of this is written down not in the author’s head but in the code. io::Result in a signature won’t let you forget that a function can fail. ? won’t let you silence an error. A test won’t let a bug come back.
Rust won’t make a program correct for you. It just won’t let you pretend the bad case doesn’t happen.
Comments 0
No comments yet.