← Table of contents

3.5 A Voice of Its Own

Signing an obligation is not the last step. A craftsman who can make pieces but cannot describe them works for one audience. A technical report is precise and complete - for the person who compiled it. This is usually called precision. The accurate word is half the work.

The Attempt

Right now a task is printed by hand, field by field:

// src/main.rs
for task in store.all() {
    println!("#{}: {} - {:?}", task.id, task.title, task.status);
}

The data is correct. But the format is a decision made by main, not by the task. Try demanding that the task speak for itself. Replace the output line with:

// src/main.rs
println!("{}", task);

The compiler notices:

error[E0277]: `Task` doesn't implement `std::fmt::Display`
  --> src/main.rs:XX:XX
   |
XX |         println!("{}", task);
   |                        ^^^^ `Task` cannot be formatted with the default formatter
   |
   = help: the trait `std::fmt::Display` is not implemented for `Task`
   = note: in format strings you may be able to use `{:?}` (or `{:#?}` for pretty-print) instead

{:?} works because #[derive(Debug)] is already on Task. {} requires something different.

What the Compiler Knows

{} and {:?} are two different calls to two different traits.

{:?} calls fmt::Debug. It is generated by #[derive(Debug)]: it shows fields and values as they are stored in memory. That is exactly why it can be derived automatically - the structure is already known to the compiler.

{} calls fmt::Display. It cannot be derived automatically: the compiler does not know what “human-readable” means for your type. That is the author’s decision - and only theirs.

The trait is std::fmt::Display. To avoid writing the full path every time, add this at the top of src/main.rs:

// src/main.rs
use std::fmt;

use and modules are covered in chapter 3.6; for now it is enough to know that fmt::Display and std::fmt::Display are the same thing.

The trait requires one method:

fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result

fmt::Formatter<'_> is the receiver for the formatted string; write!(f, ...) writes into it. The apostrophe '_ is shorthand for a reference lifetime - that is covered in chapter 6. Copy the signature as-is.

write!(f, ...) works like println!, but writes into f. The last write! in the method is returned directly - it is the fmt::Result.

The Enchantment

In src/main.rs, after impl From<&str> for Task:

// src/main.rs - NEW
impl fmt::Display for Task {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "#{}: {} [{:?}]", self.id, self.title, self.status)
    }
}

{:?} for status - Status is a simple enum with no data; its Debug output (Todo, Done) reads as well as any hand-written alternative.

The same trait for errors. In src/main.rs, after the TqError declaration:

// src/main.rs - NEW
impl fmt::Display for TqError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            TqError::EmptyTitle => write!(f, "task title cannot be empty"),
            TqError::NotFound(id) => write!(f, "task {} not found", id),
        }
    }
}

In main, remove the hand-assembled string. The loop becomes:

// src/main.rs - CHANGED: manual string → Display
for task in store.all() {
    println!("{}", task);
}

Every place where errors were printed with {:?} switches to {}. For example:

// src/main.rs - CHANGED: {:?} → {}
Err(e) => println!("rejected: {}", e),

There is one further consequence. Previously main matched NotFound in a separate arm and printed the message by hand:

Err(TqError::NotFound(id)) => println!("task {} not found", id),

Now that duplicates Display. The arm in that form is redundant:

// src/main.rs - CHANGED: specific arm removed, Display is enough
match store.get(99) {
    Ok(task) => println!("{}", task),
    Err(e) => println!("{}", e),
}

Output:

#1: Buy coffee [Todo]
#2: Buy milk [Todo]
#3: Buy eggs [Todo]
found: #1: Buy coffee [Todo]
task 99 not found
done: true
rejected: task title cannot be empty

Two tests fix both formats:

#[test]
fn task_display_todo() {
    let task = Task::new(1, "Buy coffee").unwrap();
    assert_eq!(format!("{}", task), "#1: Buy coffee [Todo]");
}

#[test]
fn error_display_not_found() {
    assert_eq!(format!("{}", TqError::NotFound(99)), "task 99 not found");
}

make ci passes. The task can describe itself - main no longer assembles strings from fields.

The complete tq code for this chapter is in 3-a-voice/05-a-voice-of-its-own/.

Scroll: Rust lifetimes - not a duration, but a dependency


Lore: std::error::Error

Display is the first half of the standard contract for error types. The standard library has a trait std::error::Error, and it requires Display as a precondition: an error must be able to describe itself in plain language.

TqError does not yet sign this contract. But when file I/O errors arrive, libraries will require std::error::Error for compatibility. Display is already in place - that is one of the two lines needed.