TL;DR
Build a complete command-line todo application combining structs, enums, error handling, traits, and collections
Key concepts
- Rust capstone project
- Rust CLI todo app
- Rust project tutorial
Capstone Project: CLI Todo App
You are going to build taskwork: a command-line tool that reads a file of tasks, filters them by priority, and prints the result either as a human-readable list or as JSON. It is the kind of small utility someone actually installs — a team lead keeps a plain-text task file in a repo, and taskwork tasks.txt --priority high --json feeds the urgent ones straight into another tool.
Here is what it does when it is finished:
$ taskwork tasks.txt --priority high
[ ] Write report (high)
[ ] Fix parser (high)
2 of 4 tasks
$ taskwork tasks.txt --priority high --json
[{"id":1,"title":"Write report","priority":"high","done":false},{"id":2,"title":"Fix parser","priority":"high","done":false}]
What You'll Learn
By the end of this lesson you will have assembled a working command-line program out of four modules that you can reason about one at a time: a data model, a parser that rejects bad input without crashing, a renderer that emits JSON by hand, and an argument parser. The last section hands you the whole thing with three functions removed and a battery of checks that fail until you put them back.
Nothing here is a new language feature. What is new is the assembly — deciding which module owns which job, so that a change to the file format touches one function and nothing else. You arrive able to model data with structs and enums (Structs and Enums), return a Result instead of crashing (Error Handling), walk collections with adapters (Iterators), read a file (File I/O), emit JSON without a crate (Serde and JSON), read arguments (CLI and Args), and write tests (Testing in Rust). This lesson spends all of it at once.
The Shape of the Program
Every command-line tool of this kind is the same pipeline, and it is worth holding in your head before any code appears:
text -> Result<Task, ParseError> -> Vec<Task> -> Vec<&Task> -> String
read parse one line collect filter render
Each arrow is a module, and each arrow is where a failure can happen or be absorbed. The model is predictive: when a requirement changes, you can say which arrow moves before you open the editor. A new field in the file format moves the second arrow only. A new output format moves the last one only. A new flag moves nothing but the argument parser. Hold on to that claim — the reflection prompt at the end asks you to test it.
Four inline mod blocks carry the four stages:
| module | what it owns | the lesson it comes from |
|---|---|---|
model | Task, Priority, and how they print | Structs and Enums |
parse | one line of text to one Result<Task, ParseError> | Error Handling |
render | tasks to JSON or to a human list | Serde and JSON |
cli | argv to a settings struct | CLI and Args |
They are inline mod blocks rather than separate files because the Run button compiles exactly one file. That is the same constraint Modules and Crates described: a module is a namespace, and whether it lives in its own file is a separate question from whether it exists.
Milestone 1: The Data Model
This is the whole model module, and it is the only milestone shown in full — from here on, each milestone shows only its new module and refers back to the ones already built.
A Task owns its title as a String rather than borrowing a &str, because the text it came from is a temporary read from disk that will be gone before the tasks are printed. Priority is Copy because it is one byte — three variants need one byte to tell apart, and a reference to it would be eight. Copying it is therefore cheaper than the reference that would avoid the copy, and making it Copy keeps Some(p) comparisons from moving anything. Option<Priority> is also one byte, because the compiler folds the None case into a bit pattern the three variants do not use.
mod model {
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Priority {
Low,
Medium,
High,
}
impl fmt::Display for Priority {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let word = match self {
Priority::Low => "low",
Priority::Medium => "medium",
Priority::High => "high",
};
f.write_str(word)
}
}
#[derive(Debug, Clone)]
pub struct Task {
pub id: u32,
pub title: String,
pub priority: Priority,
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, self.title, self.priority)
}
}
}
use model::{Priority, Task};
fn main() {
let t = Task {
id: 1,
title: String::from("Write report"),
priority: Priority::High,
done: false,
};
println!("{}", t);
let mut finished = t.clone();
finished.done = true;
println!("{}", finished);
assert_eq!(format!("{}", Priority::Medium), "medium");
assert_eq!(format!("{}", t), "[ ] Write report (high)");
println!("All checks passed.");
}
Capstone milestone
Milestone 1 — the task model. You have the two types the rest of taskwork is built out of, and each one prints itself through a Display impl rather than through a function the caller has to remember to call. Confirm you can say why the title is an owned String and why the priority is Copy, because those two choices decide what the next three modules are allowed to do.
- Defined a Priority enum with three variants and a Display impl
- Defined a Task struct that owns its title
- Wrote a Display impl for Task showing the done marker, the title and the priority
- Ran it and saw both the undone and the done rendering
Milestone 2: Parsing, and Refusing to Crash
The parse module turns one line of text into one Result<Task, ParseError>. This is Error Handling applied to untrusted input, and the shape is the point: a malformed line becomes a value the caller can inspect, not a panic that takes the program down.
task_from_line returns Result rather than Option because there are three distinct ways a line can be wrong and the caller needs to say which one happened. An Option would compress all three into None, and the error report would collapse to a count. Each variant carries the line number, so a message can point at a place in the file rather than at a place in the program.
The file this reads is the one the program writes two lines earlier. Every Run starts a fresh machine, so a snippet expecting a file left behind by an earlier snippet finds nothing. Writing before reading inside the same program is what makes each fence here self-contained, and it exercises both directions of file I/O the way File I/O described.
use std::fs;
mod model {
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Priority {
Low,
Medium,
High,
}
impl fmt::Display for Priority {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let word = match self {
Priority::Low => "low",
Priority::Medium => "medium",
Priority::High => "high",
};
f.write_str(word)
}
}
#[derive(Debug, Clone)]
pub struct Task {
pub id: u32,
pub title: String,
pub priority: Priority,
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, self.title, self.priority)
}
}
}
mod parse {
use super::model::{Priority, Task};
use std::fmt;
#[derive(Debug, PartialEq)]
pub enum ParseError {
MissingField { line_no: usize, wanted: &'static str },
UnknownPriority { line_no: usize, found: String },
EmptyTitle { line_no: usize },
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ParseError::MissingField { line_no, wanted } => {
write!(f, "line {}: missing the {} field", line_no, wanted)
}
ParseError::UnknownPriority { line_no, found } => {
write!(f, "line {}: unknown priority '{}'", line_no, found)
}
ParseError::EmptyTitle { line_no } => {
write!(f, "line {}: title is empty", line_no)
}
}
}
}
pub fn priority_from_str(line_no: usize, raw: &str) -> Result<Priority, ParseError> {
match raw.trim() {
"low" => Ok(Priority::Low),
"medium" => Ok(Priority::Medium),
"high" => Ok(Priority::High),
other => Err(ParseError::UnknownPriority {
line_no,
found: other.to_string(),
}),
}
}
pub fn task_from_line(line_no: usize, id: u32, line: &str) -> Result<Task, ParseError> {
let (title_raw, priority_raw) =
line.split_once('|').ok_or(ParseError::MissingField {
line_no,
wanted: "priority",
})?;
let title = title_raw.trim();
if title.is_empty() {
return Err(ParseError::EmptyTitle { line_no });
}
let priority = priority_from_str(line_no, priority_raw)?;
Ok(Task {
id,
title: title.to_string(),
priority,
done: false,
})
}
}
const SAMPLE: &str = "Write report | high\nFix parser | high\nWater plants | low\nbroken line\n | low\nPlan sprint | urgent\n";
fn main() {
fs::write("/tmp/tasks.txt", SAMPLE).expect("could not write the sample task file");
let text = fs::read_to_string("/tmp/tasks.txt").expect("could not read the task file back");
let mut tasks = Vec::new();
let mut rejected = Vec::new();
for (index, line) in text.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let line_no = index + 1;
match parse::task_from_line(line_no, tasks.len() as u32 + 1, line) {
Ok(task) => tasks.push(task),
Err(e) => rejected.push(e),
}
}
for t in &tasks {
println!("#{} {}", t.id, t);
}
for e in &rejected {
println!("rejected: {}", e);
}
assert_eq!(tasks.len(), 3);
assert_eq!(rejected.len(), 3);
println!("All checks passed.");
}
Three of the six lines are rejected and the program still finishes with a clean exit. That is precisely the behaviour the definition of done below calls "a malformed line is rejected, not fatal".
Predict
The parser above was handed six lines and produced three tasks and three rejections. Below is the parser reduced to just the path that line takes, so you can reason about it in isolation. Before you run it: what does task_from_line return for the line reading Plan sprint, a vertical bar, and the word urgent?
#[derive(Debug, PartialEq)]
enum ParseError {
MissingField { line_no: usize },
UnknownPriority { line_no: usize, found: String },
EmptyTitle { line_no: usize },
}
#[derive(Debug, PartialEq)]
enum Priority {
Low,
Medium,
High,
}
fn priority_from_str(line_no: usize, raw: &str) -> Result<Priority, ParseError> {
match raw.trim() {
"low" => Ok(Priority::Low),
"medium" => Ok(Priority::Medium),
"high" => Ok(Priority::High),
other => Err(ParseError::UnknownPriority {
line_no,
found: other.to_string(),
}),
}
}
fn task_from_line(line_no: usize, line: &str) -> Result<(String, Priority), ParseError> {
let (title_raw, priority_raw) = line
.split_once('|')
.ok_or(ParseError::MissingField { line_no })?;
let title = title_raw.trim();
if title.is_empty() {
return Err(ParseError::EmptyTitle { line_no });
}
let priority = priority_from_str(line_no, priority_raw)?;
Ok((title.to_string(), priority))
}
fn main() {
println!("{:?}", task_from_line(6, "Plan sprint | urgent"));
}Reading the errors: the ones you designed, and the one rustc writes
The rejections above are values this program built, and they read well because we chose their wording. The other kind of error — the one the compiler writes — is worth practising on while this code is in front of you, because it is the one people bounce off.
Add a line that peeks at the vector before the loop pushes to it, and use it afterwards:
let first = tasks.first();
match parse::task_from_line(line_no, tasks.len() as u32 + 1, line) {
Ok(task) => tasks.push(task),
Err(e) => rejected.push(e),
}
println!("{:?}", first);
The compiler refuses it, and this is the whole message. The path in the second line is whatever file the compiler was given — src/main.rs in a Cargo project, /tmp/main.rs when you press Run on this page — but every other character is the same:
error[E0502]: cannot borrow `tasks` as mutable because it is also borrowed as immutable
--> src/main.rs:114:25
|
112 | let first = tasks.first();
| ----- immutable borrow occurs here
113 | match parse::task_from_line(line_no, tasks.len() as u32 + 1, line) {
114 | Ok(task) => tasks.push(task),
| ^^^^^^^^^^^^^^^^ mutable borrow occurs here
...
117 | println!("{:?}", first);
| ----- immutable borrow later used here
error: aborting due to 1 previous error
Read it in parts, each with a job. The --> line says where the compiler gave up, and notice that it points at line 114, the push — not at line 112, where the borrow that causes the problem was created. The line rustc names is rarely the line you edit.
The three underlined spans are the argument, and reading them in order is the routine. The first, ---- under tasks.first(), is the immutable borrow being created. The second, ^^^^ under the push, is the mutable borrow that cannot coexist with it. The third, ---- under first inside the println!, is the load-bearing one: it is the reason the first borrow is still alive at line 114. Delete that last line and the whole error disappears, because a borrow nobody uses again ends early and conflicts with nothing. That is why the routine is to find the third span first — it tells you how far the borrow has to stretch, and shortening that stretch is usually the fix.
Notice what this message does not have: a help:. rustc offers a suggestion when it has a good one, and its absence here is information — there is no mechanical edit that fixes this, only a decision about which of the two borrows you actually need. When help: does appear, treat it as a suggestion rather than an instruction: rustc will often propose .clone(), which compiles immediately and quietly copies data to sidestep a borrow you could have restructured instead.
The error code is the stable part. rustc --explain E0502 prints a full worked explanation, and unlike the message text, the code does not drift between compiler releases. When a diagnostic points somewhere other than the fix, the code is what you search for; the line number is only where the compiler ran out of patience.
Capstone milestone
Milestone 2 — the error pipeline. Parsing now separates the lines it can use from the lines it cannot, and it says which of three things went wrong with each rejected line. Confirm you can explain why the failure is a returned value rather than a panic, and why collecting errors into a second vector is what keeps one bad line from ending the run.
- Defined a ParseError enum with one variant per failure mode, each carrying its line number
- Wrote task_from_line returning Result and used the question mark operator to propagate
- Collected successes and failures into two separate vectors
- Ran it and saw three tasks parsed and three lines rejected, with the program still exiting cleanly
Capstone milestone
Milestone 2 also gave you the file half: the program writes its own sample file and reads it straight back. Confirm you can say why the write has to live in the same program as the read, and what would happen to a snippet that assumed the file was already there.
- Used fs::write to create the task file and fs::read_to_string to read it back
- Walked the text with lines() and skipped the blank ones
- Understood that every Run starts a fresh machine, so nothing persists between snippets
Milestone 3: Rendering, Including JSON by Hand
The render module turns tasks into text. There is no serde here, and that is not a shortcut: the sandbox compiles a single file with no crates available, so use serde::Serialize; fails outright with error[E0432]: unresolved import. Serde and JSON already had you hand-roll this escaper for the same reason, and doing it by hand is what makes the derive concrete on the day you do reach for it in a Cargo project.
Two design choices in this module are worth stating, because both of them are about boundaries rather than about JSON.
escape is a separate function from to_json. Escaping is a property of a single string; assembling an array is a property of a list. Keeping them apart means the escaping can be tested with no Task in sight — which is exactly what the test module in the next milestone does, and it is why that test is one line long instead of a fixture.
JSON requires escaping a double quote, a backslash, and every control character from U+0000 through U+001F. Newline and tab use their short escapes here; the remaining controls use a four-digit Unicode escape. A carriage return in the middle of a title is valid task data, so the renderer must encode it rather than reject the title or emit the control character raw.
Both render functions take &[&Task] rather than Vec<Task>. Filtering hands us a view of the tasks, and rendering only reads them, so there is no reason for it to own anything. The consequence is worth noticing: across the whole finished program, a Task is cloned exactly zero times.
use std::fs;
mod model {
// Unchanged from Milestone 1: Task, Priority, and their Display impls.
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Priority {
Low,
Medium,
High,
}
impl fmt::Display for Priority {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let word = match self {
Priority::Low => "low",
Priority::Medium => "medium",
Priority::High => "high",
};
f.write_str(word)
}
}
#[derive(Debug, Clone)]
pub struct Task {
pub id: u32,
pub title: String,
pub priority: Priority,
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, self.title, self.priority)
}
}
}
mod parse {
// Unchanged from Milestone 2: task_from_line, priority_from_str, ParseError.
use super::model::{Priority, Task};
use std::fmt;
#[derive(Debug, PartialEq)]
pub enum ParseError {
MissingField { line_no: usize, wanted: &'static str },
UnknownPriority { line_no: usize, found: String },
EmptyTitle { line_no: usize },
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ParseError::MissingField { line_no, wanted } => {
write!(f, "line {}: missing the {} field", line_no, wanted)
}
ParseError::UnknownPriority { line_no, found } => {
write!(f, "line {}: unknown priority '{}'", line_no, found)
}
ParseError::EmptyTitle { line_no } => {
write!(f, "line {}: title is empty", line_no)
}
}
}
}
pub fn priority_from_str(line_no: usize, raw: &str) -> Result<Priority, ParseError> {
match raw.trim() {
"low" => Ok(Priority::Low),
"medium" => Ok(Priority::Medium),
"high" => Ok(Priority::High),
other => Err(ParseError::UnknownPriority {
line_no,
found: other.to_string(),
}),
}
}
pub fn task_from_line(line_no: usize, id: u32, line: &str) -> Result<Task, ParseError> {
let (title_raw, priority_raw) =
line.split_once('|').ok_or(ParseError::MissingField {
line_no,
wanted: "priority",
})?;
let title = title_raw.trim();
if title.is_empty() {
return Err(ParseError::EmptyTitle { line_no });
}
let priority = priority_from_str(line_no, priority_raw)?;
Ok(Task {
id,
title: title.to_string(),
priority,
done: false,
})
}
}
mod render {
use super::model::Task;
// Escaping is a property of one string, so it lives apart from the array
// assembly below and can be tested with no Task in sight.
pub fn escape(s: &str) -> String {
let mut out = String::new();
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\t' => out.push_str("\\t"),
'\u{0000}'..='\u{001f}' => out.push_str(&format!("\\u{:04x}", c as u32)),
_ => out.push(c),
}
}
out
}
// Takes a slice of references, not owned tasks: filtering hands us a view,
// and rendering has no reason to own what it only reads.
pub fn to_json(tasks: &[&Task]) -> String {
let entries: Vec<String> = tasks
.iter()
.map(|t| {
format!(
"{{\"id\":{},\"title\":\"{}\",\"priority\":\"{}\",\"done\":{}}}",
t.id,
escape(&t.title),
t.priority,
t.done
)
})
.collect();
format!("[{}]", entries.join(","))
}
// Builds a Vec of lines and joins it, rather than pushing separators by
// hand: join places them, so there is no first-or-last special case.
pub fn to_human(tasks: &[&Task], total: usize) -> String {
let mut lines: Vec<String> = tasks.iter().map(|t| t.to_string()).collect();
lines.push(format!("{} of {} tasks", tasks.len(), total));
lines.join("\n")
}
}
const SAMPLE: &str = "Write report | high\nFix parser | high\nWater plants | low\nSay \"hello\" | medium\n";
fn main() {
fs::write("/tmp/tasks.txt", SAMPLE).expect("could not write the sample task file");
let text = fs::read_to_string("/tmp/tasks.txt").expect("could not read the task file back");
let mut tasks = Vec::new();
for (index, line) in text.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
if let Ok(task) = parse::task_from_line(index + 1, tasks.len() as u32 + 1, line) {
tasks.push(task);
}
}
let high: Vec<&model::Task> = tasks
.iter()
.filter(|t| t.priority == model::Priority::High)
.collect();
println!("{}", render::to_human(&high, tasks.len()));
println!("{}", render::to_json(&high));
assert_eq!(render::escape("a\"b"), "a\\\"b");
assert_eq!(high.len(), 2);
assert_eq!(
render::to_json(&high),
"[{\"id\":1,\"title\":\"Write report\",\"priority\":\"high\",\"done\":false},{\"id\":2,\"title\":\"Fix parser\",\"priority\":\"high\",\"done\":false}]"
);
println!("All checks passed.");
}
Capstone milestone
Milestone 3 — JSON output. You are emitting JSON without a serialization crate, which means you own the escaping. Confirm you can say which characters must be escaped inside a JSON string and why escape is a separate function from to_json rather than a branch inside it.
- Wrote escape, handling the double quote, the backslash and the newline
- Wrote to_json, assembling the array with join rather than by hand-placing commas
- Rendered a task whose title contains a quote and checked the output is still valid JSON
- Took a slice of references rather than owned tasks, so nothing is copied to be printed
Capstone milestone
The filter that feeds the renderer is an iterator chain that ends in collect, and its element type is a reference. Confirm you can say what Vec<&Task> means here and why the filter can borrow from the tasks vector rather than consuming it.
- Built a filtered view with iter().filter(...).collect() into a Vec of references
- Understood that iter() borrows where into_iter() would consume
- Passed that view to a function taking a slice of references
Capstone milestone
Look back over the parsing, filtering and rendering path and count the calls to clone on a Task. There are none — the only clone in the whole lesson is the one in Milestone 1's demo, which exists to show two renderings of the same task side by side and is not part of the pipeline. Confirm you can explain how the program gets from an owned Vec<Task> to a rendered string without copying a task, and where the one owned String in the whole design lives.
- The tasks vector owns every Task exactly once
- Filtering produces references into that vector rather than copies
- Rendering takes a slice of references and returns a newly built String
Capstone milestone
The tasks live in a Vec that grows as lines are parsed, and the rejected errors live in a second one. Confirm you can say why two collections is the right answer here rather than one collection of Results.
- Pushed successes and failures into two separate vectors in one pass
- Used the length of the tasks vector to assign the next id
- Reported both counts at the end rather than only the successes
Milestone 4: Reading the Command Line
The cli module turns argv into an Args struct. This is CLI and Args applied to a real program, and it carries that lesson's trap: index 0 is conventionally the program name — the standard library documents it as traditionally the path of the executable, not as a guarantee — so the walk starts at index 1.
The loop advances by two after --priority and by one otherwise. That asymmetry is the entire bug surface of hand-written argument parsing, and it is the seed of the debugging exercise below. It is also why this is a while loop with an explicit index rather than a for over the arguments: a for loop advances by one and gives you no way to say "and also consume the next one".
There is one honest compromise. The Run button starts this program with no arguments at all, so std::env::args() yields exactly one entry and every flag path would be unreachable. effective_argv uses the real arguments whenever there are any and falls back to a written-out command line when there are none. On a real terminal that fallback never runs; here it is the only way to exercise the code you are reading.
mod model {
// Unchanged from Milestone 1: Task, Priority, and their Display impls.
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Priority {
Low,
Medium,
High,
}
impl fmt::Display for Priority {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let word = match self {
Priority::Low => "low",
Priority::Medium => "medium",
Priority::High => "high",
};
f.write_str(word)
}
}
#[derive(Debug, Clone)]
pub struct Task {
pub id: u32,
pub title: String,
pub priority: Priority,
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, self.title, self.priority)
}
}
}
mod parse {
// Unchanged from Milestone 2: task_from_line, priority_from_str, ParseError.
use super::model::{Priority, Task};
use std::fmt;
#[derive(Debug, PartialEq)]
pub enum ParseError {
MissingField { line_no: usize, wanted: &'static str },
UnknownPriority { line_no: usize, found: String },
EmptyTitle { line_no: usize },
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ParseError::MissingField { line_no, wanted } => {
write!(f, "line {}: missing the {} field", line_no, wanted)
}
ParseError::UnknownPriority { line_no, found } => {
write!(f, "line {}: unknown priority '{}'", line_no, found)
}
ParseError::EmptyTitle { line_no } => {
write!(f, "line {}: title is empty", line_no)
}
}
}
}
pub fn priority_from_str(line_no: usize, raw: &str) -> Result<Priority, ParseError> {
match raw.trim() {
"low" => Ok(Priority::Low),
"medium" => Ok(Priority::Medium),
"high" => Ok(Priority::High),
other => Err(ParseError::UnknownPriority {
line_no,
found: other.to_string(),
}),
}
}
pub fn task_from_line(line_no: usize, id: u32, line: &str) -> Result<Task, ParseError> {
let (title_raw, priority_raw) =
line.split_once('|').ok_or(ParseError::MissingField {
line_no,
wanted: "priority",
})?;
let title = title_raw.trim();
if title.is_empty() {
return Err(ParseError::EmptyTitle { line_no });
}
let priority = priority_from_str(line_no, priority_raw)?;
Ok(Task {
id,
title: title.to_string(),
priority,
done: false,
})
}
}
mod cli {
use super::model::Priority;
use super::parse::priority_from_str;
use std::fmt;
#[derive(Debug, PartialEq)]
pub enum ArgError {
MissingValue(String),
UnknownFlag(String),
NoPath,
BadPriority(String),
}
impl fmt::Display for ArgError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ArgError::MissingValue(flag) => write!(f, "{} needs a value after it", flag),
ArgError::UnknownFlag(flag) => write!(f, "unknown flag {}", flag),
ArgError::NoPath => f.write_str("no task file given"),
ArgError::BadPriority(v) => write!(f, "not a priority: {}", v),
}
}
}
#[derive(Debug, PartialEq)]
pub struct Args {
pub path: String,
pub priority: Option<Priority>,
pub json: bool,
}
// Walks argv with an explicit index rather than a for loop, because a flag
// that takes a value has to consume TWO slots and advance by two. That is
// the whole reason this is a while loop and not an iterator chain.
pub fn parse_args(argv: &[String]) -> Result<Args, ArgError> {
let mut path: Option<String> = None;
let mut priority = None;
let mut json = false;
let mut i = 1;
while i < argv.len() {
let arg = argv[i].as_str();
if arg == "--priority" {
let value = argv
.get(i + 1)
.ok_or_else(|| ArgError::MissingValue("--priority".to_string()))?;
priority = Some(
priority_from_str(0, value)
.map_err(|_| ArgError::BadPriority(value.clone()))?,
);
i += 2;
} else if arg == "--json" {
json = true;
i += 1;
} else if arg.starts_with("--") {
return Err(ArgError::UnknownFlag(arg.to_string()));
} else {
path = Some(arg.to_string());
i += 1;
}
}
match path {
Some(p) => Ok(Args { path: p, priority, json }),
None => Err(ArgError::NoPath),
}
}
}
fn effective_argv() -> Vec<String> {
let real: Vec<String> = std::env::args().collect();
if real.len() > 1 {
return real;
}
// The Run button starts this program with no arguments, so argv holds only the
// program name. Fall back to a written-out command line so the parser has
// something to chew on. On a real terminal this branch never runs.
vec![
"taskwork".to_string(),
"/tmp/tasks.txt".to_string(),
"--priority".to_string(),
"high".to_string(),
]
}
fn main() {
let argv = effective_argv();
println!("argv holds {} entries", argv.len());
let args = cli::parse_args(&argv).expect("the fallback command line should parse");
println!("path: {}", args.path);
println!("priority: {:?}", args.priority);
println!("json: {}", args.json);
assert_eq!(args.path, "/tmp/tasks.txt");
assert_eq!(args.priority, Some(model::Priority::High));
assert_eq!(args.json, false);
let flagged = vec![
"taskwork".to_string(),
"tasks.txt".to_string(),
"--json".to_string(),
];
let parsed = cli::parse_args(&flagged).expect("--json should parse");
assert_eq!(parsed.json, true);
assert_eq!(parsed.priority, None);
let dangling = vec!["taskwork".to_string(), "--priority".to_string()];
assert!(cli::parse_args(&dangling).is_err());
let bare = vec!["taskwork".to_string()];
assert!(cli::parse_args(&bare).is_err());
println!("All checks passed.");
}
Capstone milestone
Milestone 4 — argument parsing. The parser distinguishes a flag that takes a value from a flag that does not, and it refuses a command line it cannot make sense of instead of guessing. Confirm you can say why the index advances by two in one branch and by one in the others, and what the program does when a flag is given with nothing after it.
- Walked argv from index 1, treating index 0 as the program name
- Advanced by two after a flag that consumes a value, and by one otherwise
- Returned an error for a dangling flag, an unknown flag, and a missing path
- Used a documented fallback argv, because the Run button supplies no arguments
Before assembling everything, one exercise on the loop you just read.
Debug
This is the argument walk from the module above with one line changed. It compiles, it runs, it exits cleanly, and it reports a path that nobody typed. Commit a hypothesis about what the loop is doing to the word high before you change anything, then fix it so the path is inbox.txt.
#[derive(Debug, PartialEq, Clone, Copy)]
enum Priority {
Low,
Medium,
High,
}
fn priority_from_str(raw: &str) -> Option<Priority> {
match raw.trim() {
"low" => Some(Priority::Low),
"medium" => Some(Priority::Medium),
"high" => Some(Priority::High),
_ => None,
}
}
#[derive(Debug, PartialEq)]
struct Args {
path: String,
priority: Option<Priority>,
}
// Walks argv, picking up the --priority flag and one positional path.
fn parse_args(argv: &[String]) -> Args {
let mut path = String::from("tasks.txt");
let mut priority = None;
let mut i = 1;
while i < argv.len() {
let arg = argv[i].as_str();
if arg == "--priority" {
if let Some(value) = argv.get(i + 1) {
priority = priority_from_str(value);
}
i += 1;
} else {
path = arg.to_string();
i += 1;
}
}
Args { path, priority }
}
fn main() {
let argv: Vec<String> = vec![
"taskwork".to_string(),
"inbox.txt".to_string(),
"--priority".to_string(),
"high".to_string(),
];
let args = parse_args(&argv);
println!("path: {}", args.path);
println!("priority: {:?}", args.priority);
}Expected output: path: inbox.txt
priority: Some(High)
Milestone 5: Wiring It Together, and Testing It
Everything below the four modules is wiring: read the arguments, read the file, parse the lines, filter, render, print. The modules themselves are unchanged from milestones 1 to 4 and are marked as such, so what is new here is only the last forty lines.
Before you read the wiring, put it in order yourself. The six lines use a cut-down frame rather than the four modules — same shape, fewer names to carry, and parse_all and render stand in for the module functions you built:
use std::fs;
#[derive(PartialEq)]
enum Priority { Low, High }
struct Task { title: String, priority: Priority }
fn parse_all(text: &str) -> Vec<Task> { /* splits each line on the bar */ }
fn render(tasks: &[&Task]) -> String { /* joins the titles with commas */ }
fn main() {
fs::write("/tmp/tasks.txt", "write report | high\nbuy milk | low\nship it | high\n")
.expect("the sandbox tmp dir is writable");
// the six shuffled lines go here
}
Arrange the code
These six lines are the body of taskwork's main, minus the argument handling: they take a path, read the file, parse it, keep the high-priority tasks, render them, and print. The pieces are shuffled. Put them in the order that runs — then answer what the order is really testing: which line introduces the value that the line below it consumes, and what would the compiler say if you moved the render line up by one?
let report = render(&chosen);let tasks = parse_all(&text);println!("{}", report);let path = String::from("/tmp/tasks.txt");let text = fs::read_to_string(&path).expect("the file was just written");let chosen: Vec<&Task> = tasks.iter().filter(|t| t.priority == Priority::High).collect();
The full program, with the modules folded down to what you already built:
use std::fs;
mod model {
// Unchanged from Milestone 1: Task, Priority, and their Display impls.
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Priority {
Low,
Medium,
High,
}
impl fmt::Display for Priority {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let word = match self {
Priority::Low => "low",
Priority::Medium => "medium",
Priority::High => "high",
};
f.write_str(word)
}
}
#[derive(Debug, Clone)]
pub struct Task {
pub id: u32,
pub title: String,
pub priority: Priority,
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, self.title, self.priority)
}
}
}
mod parse {
// Unchanged from Milestone 2: task_from_line, priority_from_str, ParseError.
use super::model::{Priority, Task};
use std::fmt;
#[derive(Debug, PartialEq)]
pub enum ParseError {
MissingField { line_no: usize, wanted: &'static str },
UnknownPriority { line_no: usize, found: String },
EmptyTitle { line_no: usize },
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ParseError::MissingField { line_no, wanted } => {
write!(f, "line {}: missing the {} field", line_no, wanted)
}
ParseError::UnknownPriority { line_no, found } => {
write!(f, "line {}: unknown priority '{}'", line_no, found)
}
ParseError::EmptyTitle { line_no } => {
write!(f, "line {}: title is empty", line_no)
}
}
}
}
pub fn priority_from_str(line_no: usize, raw: &str) -> Result<Priority, ParseError> {
match raw.trim() {
"low" => Ok(Priority::Low),
"medium" => Ok(Priority::Medium),
"high" => Ok(Priority::High),
other => Err(ParseError::UnknownPriority {
line_no,
found: other.to_string(),
}),
}
}
pub fn task_from_line(line_no: usize, id: u32, line: &str) -> Result<Task, ParseError> {
let (title_raw, priority_raw) =
line.split_once('|').ok_or(ParseError::MissingField {
line_no,
wanted: "priority",
})?;
let title = title_raw.trim();
if title.is_empty() {
return Err(ParseError::EmptyTitle { line_no });
}
let priority = priority_from_str(line_no, priority_raw)?;
Ok(Task {
id,
title: title.to_string(),
priority,
done: false,
})
}
}
mod render {
// Unchanged from Milestone 3: escape, to_json, to_human.
use super::model::Task;
pub fn escape(s: &str) -> String {
let mut out = String::new();
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\t' => out.push_str("\\t"),
'\u{0000}'..='\u{001f}' => out.push_str(&format!("\\u{:04x}", c as u32)),
_ => out.push(c),
}
}
out
}
pub fn to_json(tasks: &[&Task]) -> String {
let entries: Vec<String> = tasks
.iter()
.map(|t| {
format!(
"{{\"id\":{},\"title\":\"{}\",\"priority\":\"{}\",\"done\":{}}}",
t.id,
escape(&t.title),
t.priority,
t.done
)
})
.collect();
format!("[{}]", entries.join(","))
}
pub fn to_human(tasks: &[&Task], total: usize) -> String {
let mut lines: Vec<String> = tasks.iter().map(|t| t.to_string()).collect();
lines.push(format!("{} of {} tasks", tasks.len(), total));
lines.join("\n")
}
}
mod cli {
// Unchanged from Milestone 4: Args, ArgError, parse_args.
use super::model::Priority;
use super::parse::priority_from_str;
use std::fmt;
#[derive(Debug, PartialEq)]
pub enum ArgError {
MissingValue(String),
UnknownFlag(String),
NoPath,
BadPriority(String),
}
impl fmt::Display for ArgError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ArgError::MissingValue(flag) => write!(f, "{} needs a value after it", flag),
ArgError::UnknownFlag(flag) => write!(f, "unknown flag {}", flag),
ArgError::NoPath => f.write_str("no task file given"),
ArgError::BadPriority(v) => write!(f, "not a priority: {}", v),
}
}
}
#[derive(Debug, PartialEq)]
pub struct Args {
pub path: String,
pub priority: Option<Priority>,
pub json: bool,
}
pub fn parse_args(argv: &[String]) -> Result<Args, ArgError> {
let mut path: Option<String> = None;
let mut priority = None;
let mut json = false;
let mut i = 1;
while i < argv.len() {
let arg = argv[i].as_str();
if arg == "--priority" {
let value = argv
.get(i + 1)
.ok_or_else(|| ArgError::MissingValue("--priority".to_string()))?;
priority = Some(
priority_from_str(0, value)
.map_err(|_| ArgError::BadPriority(value.clone()))?,
);
i += 2;
} else if arg == "--json" {
json = true;
i += 1;
} else if arg.starts_with("--") {
return Err(ArgError::UnknownFlag(arg.to_string()));
} else {
path = Some(arg.to_string());
i += 1;
}
}
match path {
Some(p) => Ok(Args { path: p, priority, json }),
None => Err(ArgError::NoPath),
}
}
}
const SAMPLE: &str = "Write report | high\nFix parser | high\nWater plants | low\nSay \"hello\" | medium\nbroken line\nPlan sprint | urgent\n";
fn effective_argv() -> Vec<String> {
let real: Vec<String> = std::env::args().collect();
if real.len() > 1 {
return real;
}
vec![
"taskwork".to_string(),
"/tmp/tasks.txt".to_string(),
"--priority".to_string(),
"high".to_string(),
]
}
fn load(path: &str) -> (Vec<model::Task>, Vec<parse::ParseError>) {
let text = match fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
eprintln!("taskwork: cannot read {}: {}", path, e);
std::process::exit(1);
}
};
let mut tasks = Vec::new();
let mut rejected = Vec::new();
for (index, line) in text.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
match parse::task_from_line(index + 1, tasks.len() as u32 + 1, line) {
Ok(task) => tasks.push(task),
Err(e) => rejected.push(e),
}
}
(tasks, rejected)
}
fn select<'a>(tasks: &'a [model::Task], wanted: Option<model::Priority>) -> Vec<&'a model::Task> {
tasks
.iter()
.filter(|t| match wanted {
Some(p) => t.priority == p,
None => true,
})
.collect()
}
fn main() {
fs::write("/tmp/tasks.txt", SAMPLE).expect("could not write the sample task file");
let argv = effective_argv();
let args = match cli::parse_args(&argv) {
Ok(a) => a,
Err(e) => {
eprintln!("taskwork: {}", e);
std::process::exit(1);
}
};
let (tasks, rejected) = load(&args.path);
let chosen = select(&tasks, args.priority);
if args.json {
println!("{}", render::to_json(&chosen));
} else {
println!("{}", render::to_human(&chosen, tasks.len()));
}
for e in &rejected {
eprintln!("skipped: {}", e);
}
assert_eq!(tasks.len(), 4, "four well-formed lines survive parsing");
assert_eq!(rejected.len(), 2, "two malformed lines are rejected, not fatal");
assert_eq!(chosen.len(), 2, "two of the four are high priority");
assert_eq!(
render::to_json(&select(&tasks, Some(model::Priority::Medium))),
"[{\"id\":4,\"title\":\"Say \\\"hello\\\"\",\"priority\":\"medium\",\"done\":false}]"
);
println!("{} parsed, {} rejected", tasks.len(), rejected.len());
println!("All checks passed.");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_a_well_formed_line() {
let t = parse::task_from_line(1, 7, "Write report | high").expect("should parse");
assert_eq!(t.id, 7);
assert_eq!(t.title, "Write report");
assert_eq!(t.priority, model::Priority::High);
assert_eq!(t.done, false);
}
#[test]
fn rejects_a_line_with_no_separator() {
let e = parse::task_from_line(4, 1, "broken line").unwrap_err();
assert_eq!(
e,
parse::ParseError::MissingField {
line_no: 4,
wanted: "priority"
}
);
}
#[test]
fn rejects_an_unknown_priority() {
let e = parse::task_from_line(6, 1, "Plan sprint | urgent").unwrap_err();
assert_eq!(
e,
parse::ParseError::UnknownPriority {
line_no: 6,
found: "urgent".to_string()
}
);
}
#[test]
fn escapes_a_quote_and_a_backslash() {
assert_eq!(render::escape("a\"b"), "a\\\"b");
assert_eq!(render::escape("c\\d"), "c\\\\d");
}
#[test]
fn parse_args_reads_a_flag_and_a_path() {
let argv = vec![
"taskwork".to_string(),
"tasks.txt".to_string(),
"--priority".to_string(),
"low".to_string(),
];
let args = cli::parse_args(&argv).expect("should parse");
assert_eq!(args.path, "tasks.txt");
assert_eq!(args.priority, Some(model::Priority::Low));
assert_eq!(args.json, false);
}
#[test]
fn parse_args_rejects_a_dangling_flag() {
let argv = vec!["taskwork".to_string(), "--priority".to_string()];
assert!(cli::parse_args(&argv).is_err());
}
}
The main battery and the test module both exist, and it is worth being explicit about why, because it looks like duplication and is not.
The #[cfg(test)] mod tests block is what cargo test runs in a real project, and it is where the #[test] vocabulary from Testing in Rust lives. The Run button on this page does not run it. As Testing in Rust explained, the runner compiles and runs the program in one command with no test mode, so #[cfg(test)] code is stripped before compilation and no #[test] function is ever called here. That is a property of this page, not of your code — the same file in a Cargo project runs all six tests.
The assert_eq! calls inside main are therefore what actually executes when you press Run. They are the same assertions the tests make, placed where this environment can reach them. In a real project you would delete them and keep the test module; here they are the only thing standing between "it printed something" and "it printed the right thing".
Recall
Without scrolling up: the program above defines six #[test] functions, and you press Run. How many of them execute, and why? Testing in Rust told you the rule.
Capstone milestone
Milestone 5 — the test battery. The program carries two sets of checks that assert the same things and reach the learner by different routes: six #[test] functions the Run button never compiles, and a set of assert_eq! calls inside main that it does. Confirm you can say which set proves the program on this page works, and why keeping both is not duplication.
Hint: Testing in Rust established the rule this milestone rests on: cfg(test) is a condition on COMPILATION, so the test module is absent rather than skipped. That is the whole reason the main battery exists beside it.
- Wrote a #[cfg(test)] mod tests with #[test] functions covering the parser, the escaper and the argument parser
- Repeated the load-bearing assertions inside main, where the Run button can execute them
- Can say why the cfg(test) set contributes no code at all to the binary this page runs
- Each assertion compares against an exact expected value rather than a property like is_ok
Recall
Inside mod parse, the import reads use super::model::Task rather than use crate::model::Task. In this file both would compile. What does super name, and why is it the one written here?
Capstone milestone
Milestone 5 — the module split. The finished program is one file carrying four inline mod blocks, each publishing a narrow surface and reaching its neighbours with use super::. Confirm you can name what each module owns, and say which module a change to the task file format would touch.
Hint: Modules and Crates made the point this milestone tests: whether a module lives in its own file is a separate question from whether the module exists. There is no Cargo.toml behind the Run button, so inline mod blocks are the only layout available — and they are the same module system either way.
- Split the program into model, parse, render and cli, each declared as an inline mod block
- Marked only the items the other modules need as pub, leaving the rest private
- Reached across module boundaries with use super:: rather than crate::, and can say what that buys
- Can name which single module a new field in the file format would move, and which one a new output format would move
Recall
task_from_line uses line.split_once('|') instead of locating the bar's byte position and slicing the string there. Both find the same separator. What does the split buy, and what specifically goes wrong with the index version? Strings and Text covered this.
Transfer
The same job taskwork does can be written as a shell pipeline: grep filters the lines, cut splits the fields, and jq builds the JSON. Both designs separate parsing, filtering and rendering into stages. Which statement names what genuinely transfers between the two, rather than a surface resemblance?
Build It Yourself
Everything above was a program to read. This one is a program to finish. Three functions have had their bodies removed and replaced with stubs that compile but return the wrong thing: parse::task_from_line, render::escape and render::to_json. Each carries a numbered TODO naming the work and giving one worked input-to-output line.
Below the line that reads --- Build checks: Do not edit below this line. --- is a battery of assertions that runs in TODO order, so the first failure you see is TODO 1. The program reports its own pass or fail: while a stub is unfinished it stops at an assertion and tells you which promise was broken, and when all three are right it prints All checks passed. and the JSON it built.
Build
Implement the three stubbed functions so the whole pipeline runs. TODO 1 turns one line into a Task or one of three ParseErrors. TODO 2 escapes a string for JSON. TODO 3 assembles the array. The checks below the line run in TODO order and each one names the promise it is testing, so work top to bottom and do not edit the battery.
use std::fs;
mod model {
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Priority {
Low,
Medium,
High,
}
impl fmt::Display for Priority {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let word = match self {
Priority::Low => "low",
Priority::Medium => "medium",
Priority::High => "high",
};
f.write_str(word)
}
}
#[derive(Debug, Clone)]
pub struct Task {
pub id: u32,
pub title: String,
pub priority: Priority,
pub done: bool,
}
}
mod parse {
use super::model::{Priority, Task};
#[derive(Debug, PartialEq)]
pub enum ParseError {
MissingField,
UnknownPriority,
EmptyTitle,
}
pub fn priority_from_str(raw: &str) -> Result<Priority, ParseError> {
match raw.trim() {
"low" => Ok(Priority::Low),
"medium" => Ok(Priority::Medium),
"high" => Ok(Priority::High),
_ => Err(ParseError::UnknownPriority),
}
}
// TODO 1: turn one line of the task file into a Task.
// A line is a title, a vertical bar, and a priority word: "Write report | high"
// becomes a Task whose title is "Write report" (trimmed, no bar) and whose
// priority is Priority::High. Give the Task the id you were passed and done: false.
// Three lines must be rejected instead: no bar at all is ParseError::MissingField,
// a priority word that is not low, medium or high is ParseError::UnknownPriority
// (priority_from_str above already returns that one), and a title that is empty
// once trimmed is ParseError::EmptyTitle.
pub fn task_from_line(_id: u32, _line: &str) -> Result<Task, ParseError> {
Err(ParseError::MissingField)
}
}
mod render {
use super::model::Task;
// TODO 2: make one string safe to sit inside JSON double quotes.
// A double quote becomes backslash-quote, a backslash becomes two backslashes,
// a newline becomes backslash-n and a tab becomes backslash-t.
// Other U+0000..U+001F controls become a backslash, u, and four hex digits.
// All remaining characters pass through unchanged, so
// escape("Say \"hi\"") is the eleven characters Say \"hi\" with each quote
// preceded by a backslash. Build the result a character at a time.
pub fn escape(_s: &str) -> String {
String::new()
}
// TODO 3: render the tasks as a JSON array, using escape above for the title.
// One task with id 1, title Write report, priority high and done false renders as
// [{"id":1,"title":"Write report","priority":"high","done":false}]
// with a comma and no space between entries, and [] when there are none.
pub fn to_json(_tasks: &[&Task]) -> String {
String::new()
}
}
const SAMPLE: &str = "Write report | high\nSay \"hello\" | medium\nbroken line\nPlan sprint | urgent\n | low\n";
fn main() {
fs::write("/tmp/tasks.txt", SAMPLE).expect("could not write the sample task file");
let text = fs::read_to_string("/tmp/tasks.txt").expect("could not read the task file back");
let mut tasks: Vec<model::Task> = Vec::new();
let mut rejected: Vec<parse::ParseError> = Vec::new();
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
match parse::task_from_line(tasks.len() as u32 + 1, line) {
Ok(task) => tasks.push(task),
Err(e) => rejected.push(e),
}
}
// --- Build checks: Do not edit below this line. ---
assert_eq!(tasks.len(), 2, "TODO 1: two of the five lines are well formed");
assert_eq!(
rejected.len(),
3,
"TODO 1: the other three are rejected, not fatal"
);
assert_eq!(
tasks[0].title, "Write report",
"TODO 1: the title is trimmed and the bar is not part of it"
);
assert_eq!(
tasks[0].priority,
model::Priority::High,
"TODO 1: the word after the bar becomes the priority"
);
assert_eq!(tasks[0].done, false, "TODO 1: a freshly parsed task is not done");
assert_eq!(
tasks[0].id, 1,
"TODO 1: the id passed in is the id stored"
);
assert_eq!(
rejected[0],
parse::ParseError::MissingField,
"TODO 1: a line with no bar is MissingField"
);
assert_eq!(
rejected[1],
parse::ParseError::UnknownPriority,
"TODO 1: urgent is not a priority"
);
assert_eq!(
rejected[2],
parse::ParseError::EmptyTitle,
"TODO 1: a title that is empty after trimming is EmptyTitle"
);
assert_eq!(
render::escape("plain"),
"plain",
"TODO 2: text with nothing to escape comes back unchanged"
);
assert_eq!(
render::escape("a\"b"),
"a\\\"b",
"TODO 2: a double quote gains a backslash"
);
assert_eq!(
render::escape("c\\d"),
"c\\\\d",
"TODO 2: a backslash is doubled"
);
assert_eq!(
render::escape("e\nf"),
"e\\nf",
"TODO 2: a newline becomes backslash n, not a real newline"
);
assert_eq!(
render::escape("e\tf"),
"e\\tf",
"TODO 2: a tab becomes backslash t, not a real tab"
);
assert_eq!(
render::escape("\0\r\u{001f}"),
"\\u0000\\u000d\\u001f",
"TODO 2: remaining JSON control characters use four-digit Unicode escapes"
);
for control in 0..=0x1f_u8 {
let escaped = render::escape(&char::from(control).to_string());
assert!(
!escaped.is_empty() && escaped.bytes().all(|byte| byte >= 0x20),
"TODO 2: no U+0000..U+001F control may remain raw inside a JSON string"
);
}
let none: Vec<&model::Task> = Vec::new();
assert_eq!(render::to_json(&none), "[]", "TODO 3: no tasks is an empty array");
let first: Vec<&model::Task> = tasks.iter().take(1).collect();
assert_eq!(
render::to_json(&first),
"[{\"id\":1,\"title\":\"Write report\",\"priority\":\"high\",\"done\":false}]",
"TODO 3: one task, four fields, in this order"
);
let both: Vec<&model::Task> = tasks.iter().collect();
assert_eq!(
render::to_json(&both),
"[{\"id\":1,\"title\":\"Write report\",\"priority\":\"high\",\"done\":false},{\"id\":2,\"title\":\"Say \\\"hello\\\"\",\"priority\":\"medium\",\"done\":false}]",
"TODO 3: two tasks are separated by a comma, and TODO 2 escapes the quoted title"
);
println!("All checks passed.");
println!("{} parsed, {} rejected", tasks.len(), rejected.len());
println!("{}", render::to_json(&both));
}Expected output: All checks passed.
2 parsed, 3 rejected
[{"id":1,"title":"Write report","priority":"high","done":false},{"id":2,"title":"Say \"hello\"","priority":"medium","done":false}]
Once it passes, try two variations. Predict what each one does before you run it.
Variation 1: a title that contains the separator. Add a line to SAMPLE reading Fix the a|b parser | high and run again. Predict which module breaks and what the parsed title will be. The answer is a real limit in the design, not a bug in your code: split_once splits at the first bar, so the title comes back as Fix the a and b parser is read as the priority — which fails as UnknownPriority, so the line is rejected rather than silently truncated. One character of user data collides with the format, and the parser has no way to tell the two apart. That is why real formats quote or escape their separators, and it is the single most useful thing to notice about the file format you have been using all lesson.
Variation 2: rendering after filtering, or before. In main, pass --json and --priority high together and predict whether the filter applies before or after the JSON is built. Then move the to_json call so it renders &tasks instead of &chosen and run again. The output grows from two entries to four, and the check battery does not notice, because the assertions test the render functions directly rather than the wiring between them. That is worth sitting with: a check suite that covers every function can still miss the way they are connected, and the only thing standing between you and that class of mistake is reading the pipeline.
What "Finished" Means Here, Precisely
Not "it compiles" and not "it printed something". taskwork is finished when all four of these hold at once.
Behavior. The build task prints All checks passed., then 2 parsed, 3 rejected, then the two-element JSON array with Say \"hello\" escaped as Say \\"hello\\". Every assertion in the battery passes on the first run after your last edit, not after a retry.
Safety. A malformed line is rejected, not fatal. Feed the parser a line with no separator, a line with an unknown priority word, and a line whose title is empty, and the program reports all three and still exits cleanly with the tasks it could read. Nothing in the pipeline calls unwrap on data that came from the file.
Maintainability. A change to the input format touches parse and nothing else. You can state, without opening the file, that adding a fourth field changes task_from_line's body and leaves render, cli and the model alone — and that adding a new output format changes render and leaves the parser alone.
Explanation. You can say why each module has the boundary it has: why escape is separate from to_json, why parse returns Result and not Option, why the render functions take &[&Task] instead of owning their input, and why the argument loop advances by two in one branch.
Reflection: Audit Your Own Design
Do not write code for this one. Answer it against the program you just finished, and be specific about function names.
The task file format is going to change. Consider three changes independently:
- It gains a fourth field — a due date, after the priority.
- The delimiter changes from a vertical bar to a tab.
- Titles are allowed to contain the delimiter, escaped somehow.
For each one: which functions would you have to open, and which would not notice at all? Be honest about the third — it is the one that breaks the neat answer, because handling it properly means the splitting logic can no longer be a single call, and that has consequences for where the line number is tracked and how MissingField is detected.
Then the question the architecture model at the top of this lesson was really asking: was the claim true? You were told that a format change touches parse only. Two of these three changes honour that claim. The third one tests where it leaks. Finding the leak is the point — a design is only as good as the change you have actually traced through it.
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.