Capstone 2: saving tasks
A task that exists only in memory disappears with the process, so the tracker needs a file. The format is one line per task with four fields separated by a bar: the id, the state, the due day and the title. It is readable in a terminal, sortable with the standard tools and easy to parse, which is what a small tool wants before it wants a database.
The storage layer is one function to write and one to read, both over the model of the previous lesson. The file shown in each section is the whole src/main.rs, and cargo run -q runs it in the package directory.
The model is kept in full although a section does not call every part of it, so a few items carry #[allow(dead_code)] in these versions of the file.
Writing the file
save builds the whole text in a String and writes it in one call. A file that is written line by line can be left half finished when a program dies, and the single write keeps the window small; a stricter program writes a temporary file and renames it.
The program creates three tasks, completes the third and saves them. It then reads the file back and prints it, which is the fastest way to see what the format looks like.
#[derive(Debug, Clone, Copy, PartialEq)]
enum State {
Open,
Done,
}
#[derive(Debug, Clone, PartialEq)]
struct Task {
id: u32,
title: String,
state: State,
due: Option<u32>,
}
#[derive(Debug, PartialEq)]
#[allow(dead_code)]
enum ModelError {
EmptyTitle,
AlreadyDone,
AlreadyOpen,
}
#[allow(dead_code)]
impl Task {
fn new(id: u32, title: &str, due: Option<u32>) -> Result<Task, ModelError> {
if title.trim().is_empty() {
return Err(ModelError::EmptyTitle);
}
Ok(Task {
id,
title: title.trim().to_string(),
state: State::Open,
due,
})
}
fn complete(&mut self) -> Result<(), ModelError> {
if self.state == State::Done {
return Err(ModelError::AlreadyDone);
}
self.state = State::Done;
Ok(())
}
fn reopen(&mut self) -> Result<(), ModelError> {
if self.state == State::Open {
return Err(ModelError::AlreadyOpen);
}
self.state = State::Open;
Ok(())
}
fn describe(&self) -> String {
let state = match self.state {
State::Open => "open",
State::Done => "done",
};
match self.due {
Some(due) => format!("#{} {} [{}] due {}", self.id, self.title, state, due),
None => format!("#{} {} [{}] no due date", self.id, self.title, state),
}
}
}
#[allow(dead_code)]
fn state_name(state: State) -> &'static str {
match state {
State::Open => "open",
State::Done => "done",
}
}
#[allow(dead_code)]
fn save(path: &str, tasks: &[Task]) -> std::io::Result<()> {
let mut out = String::new();
for task in tasks {
let due = task.due.map(|day| day.to_string()).unwrap_or_default();
out.push_str(&format!("{}|{}|{}|{}\n", task.id, state_name(task.state), due, task.title));
}
std::fs::write(path, out)
}
#[allow(dead_code)]
fn parse_line(line: &str) -> Option<Task> {
let mut parts = line.split('|');
let id: u32 = parts.next()?.parse().ok()?;
let state = match parts.next()? {
"open" => State::Open,
"done" => State::Done,
_ => return None,
};
let due_text = parts.next()?;
let due = if due_text.is_empty() {
None
} else {
Some(due_text.parse().ok()?)
};
let title = parts.next()?;
let mut task = Task::new(id, title, due).ok()?;
if state == State::Done {
task.complete().ok()?;
}
Some(task)
}
#[allow(dead_code)]
fn load(path: &str) -> std::io::Result<(Vec<Task>, usize)> {
let text = std::fs::read_to_string(path)?;
let mut tasks = Vec::new();
let mut skipped = 0;
for line in text.lines() {
match parse_line(line) {
Some(task) => tasks.push(task),
None => skipped += 1,
}
}
Ok((tasks, skipped))
}
fn main() {
let mut tasks = Vec::new();
tasks.push(Task::new(1, "write the model", Some(110)).unwrap());
tasks.push(Task::new(2, "read the tests", None).unwrap());
let mut third = Task::new(3, "ship it", Some(90)).unwrap();
third.complete().unwrap();
tasks.push(third);
save("tasks.txt", &tasks).unwrap();
println!("{}", std::fs::read_to_string("tasks.txt").unwrap());
println!("saved {}", tasks.len());
}
cargo run -q
1|open|110|write the model
2|open||read the tests
3|done|90|ship it
saved 3
The third field is empty for a task without a due date, and the line still has four fields, which is what makes the parser simple: splitting on the bar always gives four parts. The blank line in the output is the newline at the end of the file, printed by println! after the text that already ends with one.
A bar is a safe separator here because a title cannot contain one in practice; a title with a bar would break the format, and a real tool escapes it or picks a separator the field cannot hold. The lesson on binary files shows the length-prefixed alternative for data that may contain any byte.
Reading it back
load reads the whole file, splits it into lines and parses each one with the model constructor, so a loaded task went through the same checks as a task created in the program. It returns the tasks and the number of lines it could not use.
The program saves the same three tasks and loads them, then prints the count and each task through describe.
#[derive(Debug, Clone, Copy, PartialEq)]
enum State {
Open,
Done,
}
#[derive(Debug, Clone, PartialEq)]
struct Task {
id: u32,
title: String,
state: State,
due: Option<u32>,
}
#[derive(Debug, PartialEq)]
#[allow(dead_code)]
enum ModelError {
EmptyTitle,
AlreadyDone,
AlreadyOpen,
}
#[allow(dead_code)]
impl Task {
fn new(id: u32, title: &str, due: Option<u32>) -> Result<Task, ModelError> {
if title.trim().is_empty() {
return Err(ModelError::EmptyTitle);
}
Ok(Task {
id,
title: title.trim().to_string(),
state: State::Open,
due,
})
}
fn complete(&mut self) -> Result<(), ModelError> {
if self.state == State::Done {
return Err(ModelError::AlreadyDone);
}
self.state = State::Done;
Ok(())
}
fn reopen(&mut self) -> Result<(), ModelError> {
if self.state == State::Open {
return Err(ModelError::AlreadyOpen);
}
self.state = State::Open;
Ok(())
}
fn describe(&self) -> String {
let state = match self.state {
State::Open => "open",
State::Done => "done",
};
match self.due {
Some(due) => format!("#{} {} [{}] due {}", self.id, self.title, state, due),
None => format!("#{} {} [{}] no due date", self.id, self.title, state),
}
}
}
#[allow(dead_code)]
fn state_name(state: State) -> &'static str {
match state {
State::Open => "open",
State::Done => "done",
}
}
#[allow(dead_code)]
fn save(path: &str, tasks: &[Task]) -> std::io::Result<()> {
let mut out = String::new();
for task in tasks {
let due = task.due.map(|day| day.to_string()).unwrap_or_default();
out.push_str(&format!("{}|{}|{}|{}\n", task.id, state_name(task.state), due, task.title));
}
std::fs::write(path, out)
}
#[allow(dead_code)]
fn parse_line(line: &str) -> Option<Task> {
let mut parts = line.split('|');
let id: u32 = parts.next()?.parse().ok()?;
let state = match parts.next()? {
"open" => State::Open,
"done" => State::Done,
_ => return None,
};
let due_text = parts.next()?;
let due = if due_text.is_empty() {
None
} else {
Some(due_text.parse().ok()?)
};
let title = parts.next()?;
let mut task = Task::new(id, title, due).ok()?;
if state == State::Done {
task.complete().ok()?;
}
Some(task)
}
#[allow(dead_code)]
fn load(path: &str) -> std::io::Result<(Vec<Task>, usize)> {
let text = std::fs::read_to_string(path)?;
let mut tasks = Vec::new();
let mut skipped = 0;
for line in text.lines() {
match parse_line(line) {
Some(task) => tasks.push(task),
None => skipped += 1,
}
}
Ok((tasks, skipped))
}
fn main() {
let mut tasks = Vec::new();
tasks.push(Task::new(1, "write the model", Some(110)).unwrap());
tasks.push(Task::new(2, "read the tests", None).unwrap());
let mut third = Task::new(3, "ship it", Some(90)).unwrap();
third.complete().unwrap();
tasks.push(third);
save("tasks.txt", &tasks).unwrap();
let (loaded, skipped) = load("tasks.txt").unwrap();
println!("loaded {} skipped {}", loaded.len(), skipped);
for task in &loaded {
println!("{}", task.describe());
}
}
cargo run -q
loaded 3 skipped 0
#1 write the model [open] due 110
#2 read the tests [open] no due date
#3 ship it [done] due 90
The round trip through the file keeps the state and the due day, which is what the last column proves. The line that ends the file produces no task because lines stops at the last newline; a format with a trailing newline is the one every text tool expects.
The split is lazy and the parser consumes the parts in order, so a line with a missing field ends with None from next rather than a panic. That is the property the next section uses on purpose. The lesson on parsing text covers the iterators behind the split.
The line that is skipped
A file on disk is edited by people, so a line will be wrong one day. The loader counts it and goes on instead of refusing the whole file, which is the choice a personal tool makes: one lost task is better than a tracker that will not start.
The program writes a file with two bad lines, one that is not a record at all and one with a due field that is not a number. It loads the file and prints the tasks it kept.
#[derive(Debug, Clone, Copy, PartialEq)]
enum State {
Open,
Done,
}
#[derive(Debug, Clone, PartialEq)]
struct Task {
id: u32,
title: String,
state: State,
due: Option<u32>,
}
#[derive(Debug, PartialEq)]
#[allow(dead_code)]
enum ModelError {
EmptyTitle,
AlreadyDone,
AlreadyOpen,
}
#[allow(dead_code)]
impl Task {
fn new(id: u32, title: &str, due: Option<u32>) -> Result<Task, ModelError> {
if title.trim().is_empty() {
return Err(ModelError::EmptyTitle);
}
Ok(Task {
id,
title: title.trim().to_string(),
state: State::Open,
due,
})
}
fn complete(&mut self) -> Result<(), ModelError> {
if self.state == State::Done {
return Err(ModelError::AlreadyDone);
}
self.state = State::Done;
Ok(())
}
fn reopen(&mut self) -> Result<(), ModelError> {
if self.state == State::Open {
return Err(ModelError::AlreadyOpen);
}
self.state = State::Open;
Ok(())
}
fn describe(&self) -> String {
let state = match self.state {
State::Open => "open",
State::Done => "done",
};
match self.due {
Some(due) => format!("#{} {} [{}] due {}", self.id, self.title, state, due),
None => format!("#{} {} [{}] no due date", self.id, self.title, state),
}
}
}
#[allow(dead_code)]
fn state_name(state: State) -> &'static str {
match state {
State::Open => "open",
State::Done => "done",
}
}
#[allow(dead_code)]
fn save(path: &str, tasks: &[Task]) -> std::io::Result<()> {
let mut out = String::new();
for task in tasks {
let due = task.due.map(|day| day.to_string()).unwrap_or_default();
out.push_str(&format!("{}|{}|{}|{}\n", task.id, state_name(task.state), due, task.title));
}
std::fs::write(path, out)
}
#[allow(dead_code)]
fn parse_line(line: &str) -> Option<Task> {
let mut parts = line.split('|');
let id: u32 = parts.next()?.parse().ok()?;
let state = match parts.next()? {
"open" => State::Open,
"done" => State::Done,
_ => return None,
};
let due_text = parts.next()?;
let due = if due_text.is_empty() {
None
} else {
Some(due_text.parse().ok()?)
};
let title = parts.next()?;
let mut task = Task::new(id, title, due).ok()?;
if state == State::Done {
task.complete().ok()?;
}
Some(task)
}
#[allow(dead_code)]
fn load(path: &str) -> std::io::Result<(Vec<Task>, usize)> {
let text = std::fs::read_to_string(path)?;
let mut tasks = Vec::new();
let mut skipped = 0;
for line in text.lines() {
match parse_line(line) {
Some(task) => tasks.push(task),
None => skipped += 1,
}
}
Ok((tasks, skipped))
}
fn main() {
let text = "1|open|110|write the model\n2|done||read the tests\nbroken line\n3|open|x|no due number\n4|open|90|ship it\n";
std::fs::write("mixed.txt", text).unwrap();
let (loaded, skipped) = load("mixed.txt").unwrap();
println!("loaded {} skipped {}", loaded.len(), skipped);
for task in &loaded {
println!("{}", task.describe());
}
}
cargo run -q
loaded 3 skipped 2
#1 write the model [open] due 110
#2 read the tests [done] no due date
#4 ship it [open] due 90
The format has one failure mode that the count cannot show: a line whose title contains a bar is split in the wrong place and may parse as a task with a truncated title. That is why the next version of the tool escapes the bar, and why a format with a free-text field usually picks a separator the field cannot contain or a length prefix.
The count of two is the whole diagnostic: the caller can print it and the user knows that something in the file needs attention. A loader that panicked on the first bad line would have lost the three good tasks with it, and a loader that guessed the missing number would have written a wrong due date back on the next save.
The id is kept from the file rather than assigned again, which is why the output jumps from two to four. Keeping ids is what lets a command refer to a task by its number between two runs, and it is the reason the command line of the next lesson can say done 3.
The file that is not there
The first run of the program has no file yet, and that is not an error. The loader returns the io::Error and the caller decides whether a missing file means an empty list or a message.
The program asks for a file that does not exist and prints the comparison and the kind of error, which is the part of the error a program can branch on.
#[derive(Debug, Clone, Copy, PartialEq)]
enum State {
Open,
Done,
}
#[derive(Debug, Clone, PartialEq)]
struct Task {
id: u32,
title: String,
state: State,
due: Option<u32>,
}
#[derive(Debug, PartialEq)]
#[allow(dead_code)]
enum ModelError {
EmptyTitle,
AlreadyDone,
AlreadyOpen,
}
#[allow(dead_code)]
impl Task {
fn new(id: u32, title: &str, due: Option<u32>) -> Result<Task, ModelError> {
if title.trim().is_empty() {
return Err(ModelError::EmptyTitle);
}
Ok(Task {
id,
title: title.trim().to_string(),
state: State::Open,
due,
})
}
fn complete(&mut self) -> Result<(), ModelError> {
if self.state == State::Done {
return Err(ModelError::AlreadyDone);
}
self.state = State::Done;
Ok(())
}
fn reopen(&mut self) -> Result<(), ModelError> {
if self.state == State::Open {
return Err(ModelError::AlreadyOpen);
}
self.state = State::Open;
Ok(())
}
fn describe(&self) -> String {
let state = match self.state {
State::Open => "open",
State::Done => "done",
};
match self.due {
Some(due) => format!("#{} {} [{}] due {}", self.id, self.title, state, due),
None => format!("#{} {} [{}] no due date", self.id, self.title, state),
}
}
}
#[allow(dead_code)]
fn state_name(state: State) -> &'static str {
match state {
State::Open => "open",
State::Done => "done",
}
}
#[allow(dead_code)]
fn save(path: &str, tasks: &[Task]) -> std::io::Result<()> {
let mut out = String::new();
for task in tasks {
let due = task.due.map(|day| day.to_string()).unwrap_or_default();
out.push_str(&format!("{}|{}|{}|{}\n", task.id, state_name(task.state), due, task.title));
}
std::fs::write(path, out)
}
#[allow(dead_code)]
fn parse_line(line: &str) -> Option<Task> {
let mut parts = line.split('|');
let id: u32 = parts.next()?.parse().ok()?;
let state = match parts.next()? {
"open" => State::Open,
"done" => State::Done,
_ => return None,
};
let due_text = parts.next()?;
let due = if due_text.is_empty() {
None
} else {
Some(due_text.parse().ok()?)
};
let title = parts.next()?;
let mut task = Task::new(id, title, due).ok()?;
if state == State::Done {
task.complete().ok()?;
}
Some(task)
}
#[allow(dead_code)]
fn load(path: &str) -> std::io::Result<(Vec<Task>, usize)> {
let text = std::fs::read_to_string(path)?;
let mut tasks = Vec::new();
let mut skipped = 0;
for line in text.lines() {
match parse_line(line) {
Some(task) => tasks.push(task),
None => skipped += 1,
}
}
Ok((tasks, skipped))
}
fn main() {
match load("absent.txt") {
Ok((tasks, skipped)) => println!("loaded {} skipped {}", tasks.len(), skipped),
Err(error) => println!("{} {:?}", error.kind() == std::io::ErrorKind::NotFound, error.kind()),
}
}
cargo run -q
true NotFound
The error type also carries the path, which the debug format would print and a user message should. A command line that prints the message directly gets a sentence with the file name in it, and that is usually the whole report a missing file deserves. A command line turns that true into an empty list and carries on, while a permission error is reported and ends the program. The difference is a match on the kind, not on the text of the message, which is why the message is never parsed. The lesson on text files covers the rest of the std::fs API, and the next lesson puts these two functions behind a command line.
Exercises
- Add a header line to the format and make the loader skip it without counting it as corrupt.
- Escape a bar in a title on the way out and unescape it on the way in.
- Write the file through a temporary name and rename it, so a crash cannot leave half a file.
Questions
A new set of questions in a new order every time. Choose one answer for each, then check.
-
Why does
savebuild the whole text before it writes?Answer
The single write keeps the window in which the file is half written small
One write means the file is either old or new, and a stricter version renames a temporary file.
-
Why does an empty due field keep the four-field shape of a line?
Answer
The parser always gets four parts from the split
A fixed number of fields is the simplest rule for the parser to rely on.
-
What does the loader do with a bad line?
Answer
It counts it, skips it and keeps the good tasks
A personal tool prefers three good tasks and a count to no tasks at all.
-
Why does the loader keep the id from the file?
Answer
So a command can name the same task after a restart
A stable id is what makes done 3 mean the same task tomorrow.
-
What does the missing file look like to the caller?
Answer
An io::Error whose kind is NotFound
The kind is the part to match on; the text of the message is for people.
-
What does this program print?
fn parse(line: &str) -> Option<(u32, Option<u32>, String)> { let mut parts = line.split('|'); let id = parts.next()?.parse().ok()?; let due_text = parts.next()?; let due = if due_text.is_empty() { None } else { Some(due_text.parse().ok()?) }; let title = parts.next()?.to_string(); Some((id, due, title)) } fn main() { println!("{:?}", parse("1||first")); println!("{:?}", parse("2|x|second")); println!("{:?}", parse("3")); }Answer
Some((1, None, "first")) / None / None
The first line has an empty due field, the second has a bad number and the third has no title.
-
What does this program print?
fn main() { let text = "1|open|a\n2|done|b\n\n"; let lines: Vec<&str> = text.lines().collect(); println!("{} {}", lines.len(), lines[2].is_empty()); }Answer
3 true
lines drops the final newline but keeps the empty line before it, so there are three.
-
What does this program print?
fn main() { let mut out = String::new(); let tasks = [(1u32, "a", true), (2, "b", false)]; for (id, title, done) in tasks { let state = if done { "done" } else { "open" }; out.push_str(&format!("{id}|{state}|{title}\n")); } print!("{out}"); println!("{}", out.lines().count()); }Answer
1|done|a / 2|open|b / 2
Two lines are written, and lines counts them without the trailing newline.
-
What does this program print?
use std::io::ErrorKind; fn kind_name(kind: ErrorKind) -> &'static str { match kind { ErrorKind::NotFound => "missing", ErrorKind::PermissionDenied => "denied", _ => "other", } } fn main() { println!("{} {}", kind_name(ErrorKind::NotFound), kind_name(ErrorKind::Other)); }Answer
missing other
The first kind matches and the second falls into the wildcard arm.
-
What does this program print?
#[derive(Debug, PartialEq)] enum State { Open, Done, } fn parse_state(text: &str) -> Option<State> { match text { "open" => Some(State::Open), "done" => Some(State::Done), _ => None, } } fn main() { let states = ["open", "Done", "done"]; let mut count = 0; for text in states { if parse_state(text).is_some() { count += 1; } } println!("{count}"); }Answer
2
The capitalised Done is not a word the parser knows, so two of the three match.
The code on this page was built with cargo 1.96.1 and rustc 1.96.1 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.
Article author: Arthur Isaev