Capstone 2: error handling
The tool of the previous lesson reports failures in three formats: a parse error is a String, a model error is a ModelError and a file error is an io::Error. The handlers turn each into text with the debug format, which is fine for the author and wrong for the user. This lesson gives the whole program one error type.
AppError has a variant per layer, a Display that writes a sentence for the user and a status that writes the number for the shell. Two From implementations let the ? convert a model or file error on the way out, which is what keeps the handlers short.
A failure that is a value
run now returns Result<(), AppError>. A missing task is a variant of its own rather than a formatted string, and the caller prints it with the Display that the enum implements.
The program calls the same handler the command line calls, with the arguments of a task that is not in the file, and prints the result. The file does not exist, which the loader turns into an empty list first.
#[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),
}
}
}
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))
}
#[derive(Debug, Clone, PartialEq)]
enum Command {
List,
Add { title: String, due: Option<u32> },
Done { id: u32 },
Help,
}
fn parse_args(args: &[String]) -> Result<Command, String> {
let mut rest = args.iter();
let name = match rest.next() {
Some(name) => name.as_str(),
None => return Ok(Command::Help),
};
match name {
"list" => Ok(Command::List),
"help" => Ok(Command::Help),
"add" => {
let title = rest.next().ok_or("add needs a title")?.clone();
let mut due = None;
while let Some(flag) = rest.next() {
match flag.as_str() {
"--due" => {
let value = rest.next().ok_or("--due needs a day")?;
due = Some(value.parse().map_err(|_| format!("bad day: {value}"))?);
}
other => return Err(format!("unknown option {other}")),
}
}
Ok(Command::Add { title, due })
}
"done" => {
let value = rest.next().ok_or("done needs an id")?;
let id = value.parse().map_err(|_| format!("bad id: {value}"))?;
Ok(Command::Done { id })
}
other => Err(format!("unknown command {other}")),
}
}
#[allow(dead_code)]
#[derive(Debug)]
enum AppError {
Usage(String),
Model(ModelError),
Missing(u32),
Io(std::io::Error),
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AppError::Usage(message) => write!(f, "{message}"),
AppError::Model(ModelError::EmptyTitle) => write!(f, "a task needs a title"),
AppError::Model(ModelError::AlreadyDone) => write!(f, "the task is already done"),
AppError::Model(ModelError::AlreadyOpen) => write!(f, "the task is already open"),
AppError::Missing(id) => write!(f, "no task {id}"),
AppError::Io(error) => write!(f, "file error: {error}"),
}
}
}
impl From<ModelError> for AppError {
fn from(error: ModelError) -> AppError {
AppError::Model(error)
}
}
impl From<std::io::Error> for AppError {
fn from(error: std::io::Error) -> AppError {
AppError::Io(error)
}
}
#[allow(dead_code)]
impl AppError {
fn status(&self) -> u8 {
match self {
AppError::Usage(_) => 2,
_ => 1,
}
}
}
fn load_or_empty(path: &str) -> Vec<Task> {
match load(path) {
Ok((tasks, skipped)) => {
if skipped > 0 {
eprintln!("skipped {skipped} lines");
}
tasks
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(error) => {
eprintln!("cannot read {path}: {error}");
Vec::new()
}
}
}
fn next_id(tasks: &[Task]) -> u32 {
tasks.iter().map(|task| task.id).max().unwrap_or(0) + 1
}
fn run(command: Command, path: &str) -> Result<(), AppError> {
let mut tasks = load_or_empty(path);
match command {
Command::List => {
for task in &tasks {
println!("{}", task.describe());
}
println!("{} tasks", tasks.len());
}
Command::Add { title, due } => {
let id = next_id(&tasks);
tasks.push(Task::new(id, &title, due)?);
save(path, &tasks)?;
println!("added {id}");
}
Command::Done { id } => match tasks.iter_mut().find(|task| task.id == id) {
Some(task) => {
task.complete()?;
save(path, &tasks)?;
println!("done {id}");
}
None => return Err(AppError::Missing(id)),
},
Command::Help => print_help(),
}
Ok(())
}
fn print_help() {
println!("tasks list");
println!("tasks add <title> [--due <day>]");
println!("tasks done <id>");
println!("tasks help");
}
fn main() {
let args: Vec<String> = ["done", "99"].iter().map(|item| item.to_string()).collect();
match parse_args(&args) {
Ok(command) => match run(command, "tasks.txt") {
Ok(()) => println!("ok"),
Err(error) => println!("{error}"),
},
Err(message) => println!("{message}"),
}
}
rm -f tasks.txt
cargo run -q
no task 99
The sentence is the whole message and it came from the enum, not from the handler. A caller that needs the variant can match on AppError::Missing(id) and use the number, which a formatted string would have hidden.
The lesson on error types builds the same shape for a library, and error propagation covers the layers above it.
The message and the status
main prints the message to stderr and returns the status from the error. A usage error is two and every other failure is one, which is the same split the previous lesson had with a String, now derived from the variant rather than decided at each call site.
The transcript runs three failing commands: an unknown command, a title of spaces and an id that is not in the file. The three messages come from three different variants.
use std::process::ExitCode;
#[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),
}
}
}
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))
}
#[derive(Debug, Clone, PartialEq)]
enum Command {
List,
Add { title: String, due: Option<u32> },
Done { id: u32 },
Help,
}
fn parse_args(args: &[String]) -> Result<Command, String> {
let mut rest = args.iter();
let name = match rest.next() {
Some(name) => name.as_str(),
None => return Ok(Command::Help),
};
match name {
"list" => Ok(Command::List),
"help" => Ok(Command::Help),
"add" => {
let title = rest.next().ok_or("add needs a title")?.clone();
let mut due = None;
while let Some(flag) = rest.next() {
match flag.as_str() {
"--due" => {
let value = rest.next().ok_or("--due needs a day")?;
due = Some(value.parse().map_err(|_| format!("bad day: {value}"))?);
}
other => return Err(format!("unknown option {other}")),
}
}
Ok(Command::Add { title, due })
}
"done" => {
let value = rest.next().ok_or("done needs an id")?;
let id = value.parse().map_err(|_| format!("bad id: {value}"))?;
Ok(Command::Done { id })
}
other => Err(format!("unknown command {other}")),
}
}
#[derive(Debug)]
enum AppError {
Usage(String),
Model(ModelError),
Missing(u32),
Io(std::io::Error),
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AppError::Usage(message) => write!(f, "{message}"),
AppError::Model(ModelError::EmptyTitle) => write!(f, "a task needs a title"),
AppError::Model(ModelError::AlreadyDone) => write!(f, "the task is already done"),
AppError::Model(ModelError::AlreadyOpen) => write!(f, "the task is already open"),
AppError::Missing(id) => write!(f, "no task {id}"),
AppError::Io(error) => write!(f, "file error: {error}"),
}
}
}
impl From<ModelError> for AppError {
fn from(error: ModelError) -> AppError {
AppError::Model(error)
}
}
impl From<std::io::Error> for AppError {
fn from(error: std::io::Error) -> AppError {
AppError::Io(error)
}
}
impl AppError {
fn status(&self) -> u8 {
match self {
AppError::Usage(_) => 2,
_ => 1,
}
}
}
fn load_or_empty(path: &str) -> Vec<Task> {
match load(path) {
Ok((tasks, skipped)) => {
if skipped > 0 {
eprintln!("skipped {skipped} lines");
}
tasks
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(error) => {
eprintln!("cannot read {path}: {error}");
Vec::new()
}
}
}
fn next_id(tasks: &[Task]) -> u32 {
tasks.iter().map(|task| task.id).max().unwrap_or(0) + 1
}
fn run(command: Command, path: &str) -> Result<(), AppError> {
let mut tasks = load_or_empty(path);
match command {
Command::List => {
for task in &tasks {
println!("{}", task.describe());
}
println!("{} tasks", tasks.len());
}
Command::Add { title, due } => {
let id = next_id(&tasks);
tasks.push(Task::new(id, &title, due)?);
save(path, &tasks)?;
println!("added {id}");
}
Command::Done { id } => match tasks.iter_mut().find(|task| task.id == id) {
Some(task) => {
task.complete()?;
save(path, &tasks)?;
println!("done {id}");
}
None => return Err(AppError::Missing(id)),
},
Command::Help => print_help(),
}
Ok(())
}
fn print_help() {
println!("tasks list");
println!("tasks add <title> [--due <day>]");
println!("tasks done <id>");
println!("tasks help");
}
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
match parse_args(&args).map_err(AppError::Usage).and_then(|command| run(command, "tasks.txt")) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("error: {error}");
ExitCode::from(error.status())
}
}
}
rm -f tasks.txt
cargo run -q -- fly; echo "status $?"
cargo run -q -- add " "; echo "status $?"
cargo run -q -- done 99; echo "status $?"
error: unknown command fly
status 2
error: a task needs a title
status 1
error: no task 99
status 1
A program with more layers keeps the enum and adds a variant per layer, and the Display grows one arm at a time. The alternative, a boxed Box<dyn Error> at the top, is shorter to write and loses the ability to match on the kind; a tool with three exit codes needs the kind. The ? in the handlers and the map_err in main are the two directions of the same conversion: a model error becomes an AppError on the way up, and the parse error becomes one because parse_args is the only function that still returns a String.
The output of a failing command is short on purpose: one line with the word error, one sentence and the status. A longer report would be read once and remembered never, and the status is what the next program in the pipeline actually uses.
The messages are written for a person and the statuses for a program, which is why they are separate methods of the same type. The lesson on exit codes covers the type, and the lesson on traits covers the Display implementation that makes println! accept the error.
Several errors at once
One command with one bad title should report one error. A batch of titles should report every bad one, because fixing them one run at a time is the slowest possible loop. The batch function returns a Vec<AppError> and the program prints one line per error.
The program hands four titles to the batch, two of which are empty or only spaces. Nothing is saved when any title fails, so the file and the errors never disagree.
#[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),
}
}
}
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))
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq)]
enum Command {
List,
Add { title: String, due: Option<u32> },
Done { id: u32 },
Help,
}
#[allow(dead_code)]
fn parse_args(args: &[String]) -> Result<Command, String> {
let mut rest = args.iter();
let name = match rest.next() {
Some(name) => name.as_str(),
None => return Ok(Command::Help),
};
match name {
"list" => Ok(Command::List),
"help" => Ok(Command::Help),
"add" => {
let title = rest.next().ok_or("add needs a title")?.clone();
let mut due = None;
while let Some(flag) = rest.next() {
match flag.as_str() {
"--due" => {
let value = rest.next().ok_or("--due needs a day")?;
due = Some(value.parse().map_err(|_| format!("bad day: {value}"))?);
}
other => return Err(format!("unknown option {other}")),
}
}
Ok(Command::Add { title, due })
}
"done" => {
let value = rest.next().ok_or("done needs an id")?;
let id = value.parse().map_err(|_| format!("bad id: {value}"))?;
Ok(Command::Done { id })
}
other => Err(format!("unknown command {other}")),
}
}
#[allow(dead_code)]
#[derive(Debug)]
enum AppError {
Usage(String),
Model(ModelError),
Missing(u32),
Io(std::io::Error),
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AppError::Usage(message) => write!(f, "{message}"),
AppError::Model(ModelError::EmptyTitle) => write!(f, "a task needs a title"),
AppError::Model(ModelError::AlreadyDone) => write!(f, "the task is already done"),
AppError::Model(ModelError::AlreadyOpen) => write!(f, "the task is already open"),
AppError::Missing(id) => write!(f, "no task {id}"),
AppError::Io(error) => write!(f, "file error: {error}"),
}
}
}
impl From<ModelError> for AppError {
fn from(error: ModelError) -> AppError {
AppError::Model(error)
}
}
impl From<std::io::Error> for AppError {
fn from(error: std::io::Error) -> AppError {
AppError::Io(error)
}
}
#[allow(dead_code)]
impl AppError {
fn status(&self) -> u8 {
match self {
AppError::Usage(_) => 2,
_ => 1,
}
}
}
fn load_or_empty(path: &str) -> Vec<Task> {
match load(path) {
Ok((tasks, skipped)) => {
if skipped > 0 {
eprintln!("skipped {skipped} lines");
}
tasks
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(error) => {
eprintln!("cannot read {path}: {error}");
Vec::new()
}
}
}
fn next_id(tasks: &[Task]) -> u32 {
tasks.iter().map(|task| task.id).max().unwrap_or(0) + 1
}
#[allow(dead_code)]
fn run(command: Command, path: &str) -> Result<(), AppError> {
let mut tasks = load_or_empty(path);
match command {
Command::List => {
for task in &tasks {
println!("{}", task.describe());
}
println!("{} tasks", tasks.len());
}
Command::Add { title, due } => {
let id = next_id(&tasks);
tasks.push(Task::new(id, &title, due)?);
save(path, &tasks)?;
println!("added {id}");
}
Command::Done { id } => match tasks.iter_mut().find(|task| task.id == id) {
Some(task) => {
task.complete()?;
save(path, &tasks)?;
println!("done {id}");
}
None => return Err(AppError::Missing(id)),
},
Command::Help => print_help(),
}
Ok(())
}
fn print_help() {
println!("tasks list");
println!("tasks add <title> [--due <day>]");
println!("tasks done <id>");
println!("tasks help");
}
fn add_batch(titles: &[String], path: &str) -> Result<(), Vec<AppError>> {
let mut tasks = load_or_empty(path);
let mut errors = Vec::new();
for title in titles {
match Task::new(next_id(&tasks), title, None) {
Ok(task) => tasks.push(task),
Err(error) => errors.push(AppError::Model(error)),
}
}
if errors.is_empty() {
save(path, &tasks).map_err(|error| vec![AppError::Io(error)])?;
println!("added {} tasks", tasks.len());
Ok(())
} else {
Err(errors)
}
}
fn main() {
let titles: Vec<String> = ["write the model", " ", "read the tests", ""]
.iter()
.map(|item| item.to_string())
.collect();
match add_batch(&titles, "tasks.txt") {
Ok(()) => println!("all added"),
Err(errors) => {
println!("{} errors", errors.len());
for error in errors {
println!("- {error}");
}
}
}
}
rm -f tasks.txt
cargo run -q
2 errors
- a task needs a title
- a task needs a title
The errors are values like any other, so the loop that builds them is an ordinary loop with a push. That is the difference the whole lesson is about: a C++ program would have thrown on the first empty title and lost the second, unless it caught and re-threw in a loop that was written for it. The two good titles are not reported and are not saved, because a batch that half succeeds is a state nobody can describe in a message. A tool that wants partial success returns the saved tasks with the errors, and the caller decides; the choice belongs in the interface, not in the loop.
The lesson on iterator adaptors covers the map and collect that build the vector of titles, and the lesson on vectors covers the push that grows the error list.
The question mark that was skipped
The tempting shortcut is to add ? to main and let the runtime print the error. It does not compile, because ? needs a function that returns a type it can build from the error, and ExitCode is not one.
use std::process::ExitCode;
fn main() -> ExitCode {
let text = std::fs::read_to_string("tasks.txt")?;
println!("{}", text.len());
ExitCode::SUCCESS
}
cargo build
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
--> src/main.rs:5:52
|
4 | fn main() -> ExitCode {
| --------------------- this function should return `Result` or `Option` to accept `?`
5 | let text = std::fs::read_to_string("tasks.txt")?;
| ^ cannot use the `?` operator in a function that returns `ExitCode`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `tasks` (bin "tasks") due to 1 previous error
The message also shows the limits of the shortcut: a main that returns a Result prints the debug format of the error, with the variant name and its fields, which is not a sentence for a user. The fix is the match of the previous section: main is the boundary where an error becomes a message and a number, and it can only do that by looking at the error. A main that returns Result<(), AppError> compiles and prints the debug format, which is the shortcut for a small program and the wrong thing for a tool with a usage message.
The lesson on Result covers the operator and its From conversion, and the next lesson tests the messages and the statuses without starting a process.
Exercises
- Add an AppError::Corrupt(usize) for the skipped lines and print it once at the end of a command.
- Make the batch save the good titles and return the errors, and decide what the status should be.
- Write a test that calls parse_args with a bad option and checks the message and the status.
Questions
A new set of questions in a new order every time. Choose one answer for each, then check.
-
Why does the program have one
AppErrorfor three layers?Answer
One type carries the message and the status, and the question mark converts into it
The enum is the meeting point of the layers and the only place the status is decided.
-
What does
?do with an error of a different type?Answer
It converts it with
Fromand returns it from the functionThe From implementation of the function error type is what makes the operator work.
-
Why does a usage error have a different status from a failed command?
Answer
A script can tell a wrong call from a failed operation
The status is part of the interface, and two kinds of failure deserve two numbers.
-
Why does the batch return every error instead of the first?
Answer
The user fixes every bad title in one round
One run that lists every problem is cheaper than one run per problem.
-
Why does the message go to stderr while data goes to stdout?
Answer
A pipeline reads stdout and a person reads stderr
Separating the two keeps a program usable in a pipe and readable in a terminal.
-
What does this program print?
#[derive(Debug)] enum AppError { Usage(String), Missing(u32), } impl std::fmt::Display for AppError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { AppError::Usage(message) => write!(f, "usage: {message}"), AppError::Missing(id) => write!(f, "no task {id}"), } } } fn main() { println!("{}", AppError::Missing(4)); println!("{}", AppError::Usage("list".to_string())); }Answer
no task 4 / usage: list
The Display implementation formats each variant with its own sentence.
-
What does this program print?
fn convert(text: &str) -> Result<u32, String> { text.parse().map_err(|_| format!("bad number {text}")) } fn double(text: &str) -> Result<u32, String> { let value = convert(text)?; Ok(value * 2) } fn main() { println!("{:?}", double("21")); println!("{:?}", double("x")); }Answer
Ok(42) / Err("bad number x")
The good value is doubled and the bad one keeps the message of the conversion.
-
What does this program print?
fn main() { let titles = ["a", "", "b", " "]; let mut errors = Vec::new(); for title in titles { if title.trim().is_empty() { errors.push(format!("bad title at {}", title.len())); } } println!("{} {}", errors.len(), errors[0]); }Answer
2 bad title at 0
Two titles are empty after the trim, and the first one has length zero.
-
What does this program print?
#[derive(Debug, PartialEq)] enum Kind { Usage, Failed, } fn status(kind: &Kind) -> u8 { match kind { Kind::Usage => 2, Kind::Failed => 1, } } fn main() { let kinds = [Kind::Failed, Kind::Usage, Kind::Failed]; let total: u8 = kinds.iter().map(status).sum(); println!("{}", total); }Answer
4
One and two and one add to four.
-
What is the first line of the compiler output for this program?
use std::process::ExitCode; fn main() -> ExitCode { let text = std::fs::read_to_string("tasks.txt")?; println!("{}", text.len()); ExitCode::SUCCESS }Answer
error[E0277]: the
?operator can only be used in a function that returnsResultorOption(or another type that implementsFromResidual)ExitCode cannot be built from an io::Error, so the operator is refused in main.
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