Capstone 2: a task model
The second capstone is a task tracker in five lessons: a model, a file format, a command line, an error path and a test suite. This lesson builds the model, which is the part the other four depend on. A task has an id, a title, a state and an optional due day, and every operation that changes it either succeeds or returns the reason it did not.
The project is a cargo package with one file, src/main.rs, and every section below shows that file in full. cargo new --vcs none tasks creates it, and cargo run builds and starts it.
What a task is
The state is an enum with two variants rather than a boolean, so a third state is an added variant and a compile error at every match rather than a flag that some code forgets to check. The due day is an Option<u32>: a task may have no deadline, and the type says so.
The constructor trims the title and refuses one that is empty or only spaces, which keeps the invariant in one place. The program creates two tasks, describes them, completes the first and reopens it, so the three parts of the model appear in the output.
#[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)]
enum ModelError {
EmptyTitle,
AlreadyDone,
AlreadyOpen,
}
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),
}
}
}
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());
for task in &tasks {
println!("{}", task.describe());
}
tasks[0].complete().unwrap();
println!("{}", tasks[0].describe());
tasks[0].reopen().unwrap();
println!("{}", tasks[0].describe());
println!("{}", tasks.len());
}
cargo run -q
#1 write the model [open] due 110
#2 read the tests [open] no due date
#1 write the model [done] due 110
#1 write the model [open] due 110
2
A model like this is the part of a program that a test can exercise without a file or a terminal, so the suite of lesson 132 spends most of its rows here. It is also the part that survives a change of interface: the command line of lesson 130 and the file format of lesson 129 both call these three functions and nothing else.
The second task lost the spaces around its title, which the output shows as the trimmed text. The state change and the state change back are the two operations of the next section, and they print their result there.
A due day is a day number and not a date structure, which keeps the model independent of a calendar library: the command line converts a date to a day number before it calls the model, and the model only compares numbers. The lesson on dates covers the conversion, and Option covers the field that may be absent.
Operations that keep it valid
complete and reopen take &mut self and return a Result<(), ModelError>. A task that is already done cannot be completed again, and a task that is open cannot be reopened; the error names the case instead of silently doing nothing.
The program makes each call twice and prints the results, then asks for a task with a title of spaces. Every error of the model appears in one run.
#[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)]
enum ModelError {
EmptyTitle,
AlreadyDone,
AlreadyOpen,
}
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),
}
}
}
fn main() {
let mut task = Task::new(7, "review", Some(120)).unwrap();
println!("{:?}", task.complete());
println!("{:?}", task.complete());
println!("{:?}", task.reopen());
println!("{:?}", task.reopen());
println!("{:?}", Task::new(8, " ", None));
println!("{}", task.describe());
}
cargo run -q
Ok(())
Err(AlreadyDone)
Ok(())
Err(AlreadyOpen)
Err(EmptyTitle)
#7 review [open] due 120
An error type with one variant per refusal is what makes a user message possible later. A single "cannot change the state" string would be shorter and would force the command line to guess which case it is. The debug format of the error is enough at this layer: the command line of lesson 130 turns each variant into a sentence for the user. The important property is that the second completion changed nothing, which the last line proves: the task is still open.
An operation that returns Result can be ignored with let _ = or unwrapped, and the ? in a function that returns the same error type passes it on. The lesson on error types covers the enum and its Display, and traits covers the implementations that the storage layer shares.
Days until the due date
The comparison of a due day with today is a subtraction that may be negative, so the return type is Option<i64>: None when there is no due date and a signed number when there is. A u32 would wrap on an overdue task, which is the bug this signature prevents.
The program asks four tasks about day one hundred and prints the three cases: due today, overdue, due later and no date.
#[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)]
enum ModelError {
EmptyTitle,
AlreadyDone,
AlreadyOpen,
}
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 days_left(&self, today: u32) -> Option<i64> {
self.due.map(|due| due as i64 - today as i64)
}
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),
}
}
}
fn main() {
let today = 100;
for (id, title, due) in [(1, "today", Some(100)), (2, "late", Some(95)), (3, "later", Some(107)), (4, "someday", None)] {
let task = Task::new(id, title, due).unwrap();
match task.days_left(today) {
Some(days) if days < 0 => println!("#{} overdue by {} days", task.id, -days),
Some(days) => println!("#{} due in {} days", task.id, days),
None => println!("#{} has no due date", task.id),
}
}
let mut task = Task::new(5, "check the transitions", None).unwrap();
task.complete().unwrap();
task.reopen().unwrap();
println!("{}", task.describe());
}
cargo run -q
#1 due in 0 days
#2 overdue by 5 days
#3 due in 7 days
#4 has no due date
#5 check the transitions [open] no due date
The match has a guard for the negative case, so the overdue message uses the positive number and the word that belongs to it. A subtraction of two u32 values in the other order would have panicked in a debug build or wrapped in a release build; the cast to i64 before the subtraction is what makes both cases ordinary numbers.
The last line is the same task after a completion and a reopen, which is there to show that the due field is untouched by the state transitions. The lesson on numbers covers the casts and the overflow rules that this subtraction avoids.
The transition that is not allowed
The mistake is to treat a state change as an assignment. The program below completes a task twice and the second call is refused, which is the model's answer to a caller that lost track of the state.
The first line of main also calls reopen on an open task, which is the other half of the same rule. Both refusals are values, so the program prints them and keeps going.
#[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)]
enum ModelError {
EmptyTitle,
AlreadyDone,
AlreadyOpen,
}
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),
}
}
}
fn main() {
let mut task = Task::new(9, "close twice", None).unwrap();
task.reopen().unwrap_err();
println!("{:?}", task.complete());
match task.complete() {
Ok(()) => println!("closed again"),
Err(error) => println!("refused: {error:?}"),
}
println!("{}", task.describe());
}
cargo run -q
Ok(())
refused: AlreadyDone
#9 close twice [done] no due date
The task ends in the state the first call put it in, and the second call only reported that there was nothing to do. A model that returned () and did the assignment would have hidden a double submission from a user interface, which is the bug this design exists to prevent.
A stricter model would make the state a type parameter, so that a completed task has no complete method at all and the second call does not compile. The lesson on API design builds that version, and the next lesson writes the tasks to a file.
Exercises
- Add a priority field with three levels and a constructor that refuses the lowest one for a task with no due date.
- Add a rename operation that trims the title and refuses an empty one.
- Write a sort_key method that returns the due day or a large number, and sort a vector of tasks with it.
Questions
A new set of questions in a new order every time. Choose one answer for each, then check.
-
Why is the state an enum rather than a boolean?
Answer
A third state becomes a variant and the compiler finds every match that must change
Exhaustiveness turns a new state into a compile error instead of a forgotten check.
-
What does the constructor guarantee about a title?
Answer
That it is trimmed and not empty
The trim and the empty check happen once, in the only function that builds a task.
-
Why does
days_leftreturnOption<i64>?Answer
The due date may be absent and the difference may be negative
Option covers the missing date and the signed type covers an overdue one.
-
What does
completereturn when the task is already done?Answer
Err(AlreadyDone), and the task is unchangedThe operation reports the refusal instead of repeating a change.
-
Why is the due day a number rather than a date structure?
Answer
The model compares numbers and stays free of a calendar library
The conversion happens at the edge, so the model has one simple field.
-
What does this program print?
#[derive(Debug, Clone, Copy, PartialEq)] enum State { Open, Done, } #[derive(Debug)] struct Task { title: String, state: State, } impl Task { fn complete(&mut self) -> Result<(), &'static str> { if self.state == State::Done { return Err("already done"); } self.state = State::Done; Ok(()) } } fn main() { let mut task = Task { title: "a".to_string(), state: State::Open }; println!("{:?}", task.complete()); println!("{:?}", task.complete()); }Answer
Ok(()) / Err("already done")
The first call changes the state and the second refuses.
-
What does this program print?
fn main() { let today = 10u32; for due in [Some(10u32), Some(7), None] { match due.map(|day| day as i64 - today as i64) { Some(days) if days < 0 => println!("late {}", -days), Some(days) => println!("in {}", days), None => println!("none"), } } }Answer
in 0 / late 3 / none
The first day is today, the second is three days late and the third has no date.
-
What does this program print?
fn main() { let titles = [" a ", "", " ", "b"]; let mut kept = Vec::new(); for title in titles { let trimmed = title.trim(); if !trimmed.is_empty() { kept.push(trimmed.to_string()); } } println!("{} {}", kept.len(), kept.join("-")); }Answer
2 a-b
Two titles survive the trim and the empty ones are dropped.
-
What does this program print?
#[derive(Debug, PartialEq)] enum State { Open, Done, } fn next(state: State) -> Result<State, &'static str> { match state { State::Open => Ok(State::Done), State::Done => Err("nothing to do"), } } fn main() { println!("{:?}", next(State::Open).and_then(next)); println!("{:?}", next(State::Done).and_then(next)); }Answer
Err("nothing to do") / Err("nothing to do")
The first chain runs the second step on a done state, so both end in the same error.
-
What does this program print?
struct Task { id: u32, state: bool, } fn main() { let mut tasks = vec![ Task { id: 1, state: true }, Task { id: 2, state: false }, ]; tasks[1].state = true; let done = tasks.iter().filter(|task| task.state).count(); println!("{} {}", done, tasks.len()); }Answer
2 2
Both tasks end with the flag set, and the vector still has two of them.
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