← Table of contents

3.6 Rooms in the Grimoire

In a workshop without rooms, everything can be found. For this you need to know exactly where - that knowledge arrives on its own, explaining it in advance is not customary. With one craftsman and one workbench, this is enough. Then there are more workbenches. The knowledge stays the same. This is usually called experience. The accurate word is accumulated disorder.

The Attempt

Eight types, six traits, two hundred and fifty lines in main.rs. The task’s shape, the store, the errors, the voice - all in one file. A reasonable desire: give each part its own file.

Start small - move just Status and Task. Create src/task.rs with those two types:

// src/task.rs
#[derive(Debug)]
enum Status {
    Todo,
    Done,
}

#[derive(Debug)]
struct Task {
    id: u64,
    title: String,
    status: Status,
}

Delete those two declarations from main.rs. The compiler notices:

error[E0412]: cannot find type `Task` in this scope
  --> src/main.rs:XX:XX
   |
XX | impl Task {
   |      ^^^^ not found in this scope

What the Compiler Knows

Rust does not include files automatically. Creating src/task.rs does not tell the compiler about it. That requires a declaration in src/main.rs:

// src/main.rs
mod task;

mod task; says: find src/task.rs and include its contents as a module named task. The module now exists, but Task and Status have not been brought into the current scope. Add:

// src/main.rs
use task::{Status, Task};

Try again:

error[E0603]: struct `Task` is private
  --> src/main.rs:XX:XX
   |
XX | use task::{Status, Task};
   |            ^^^^^^ private struct

The compiler sees the module and knows Task is in there - but it is closed. In Rust everything is private by default: defining a type in a module does not open it for use outside. That is what pub is for:

// src/task.rs
#[derive(Debug)]
pub enum Status {
    Todo,
    Done,
}

#[derive(Debug)]
pub struct Task {
    pub id: u64,
    pub title: String,
    pub status: Status,
}

pub on the type name makes it visible from outside. pub on a field opens that field for reading and writing from outside the module - without it the struct is visible but its contents are unreachable.

The Enchantment

Three files. Each holds what belongs to it. Start with src/error.rs - it depends on nothing else inside the crate:

// src/error.rs - NEW
use std::fmt;

#[derive(Debug)]
pub enum TqError {
    EmptyTitle,
    NotFound(u64),
}

pub type TqResult<T> = Result<T, TqError>;

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),
        }
    }
}

Then src/task.rs. It uses types from error.rs - and refers to them through crate::, the path from the crate root:

// src/task.rs - NEW
use crate::error::{TqError, TqResult};
use std::fmt;

pub trait HasId {
    fn id(&self) -> u64;
}

#[derive(Debug)]
pub enum Status {
    Todo,
    Done,
}

#[derive(Debug)]
pub struct Task {
    pub id: u64,
    pub title: String,
    pub status: Status,
}

impl Task {
    pub fn new(id: u64, title: &str) -> TqResult<Task> {
        if title.is_empty() {
            return Err(TqError::EmptyTitle);
        }
        Ok(Task {
            id,
            title: String::from(title),
            status: Status::Todo,
        })
    }

    pub fn complete(&mut self) {
        self.status = Status::Done;
    }

    pub fn is_done(&self) -> bool {
        match self.status {
            Status::Done => true,
            Status::Todo => false,
        }
    }
}

impl From<&str> for Task {
    fn from(title: &str) -> Self {
        Task {
            id: 0,
            title: title.to_string(),
            status: Status::Todo,
        }
    }
}

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

impl HasId for Task {
    fn id(&self) -> u64 {
        self.id
    }
}

HasId lives here, next to Task: for now it is only needed by her. src/store.rs will refer to it through crate::task::HasId.

Now src/store.rs. The items field has become private - store.items[0] from main.rs is no longer reachable. That is the point of the module: implementation details stay inside. In place of direct access - a get_mut method:

// src/store.rs - NEW
use crate::error::{TqError, TqResult};
use crate::task::{HasId, Task};

pub trait Store<T: HasId> {
    fn add(&mut self, item: impl Into<T>) -> TqResult<()>;
    fn get(&self, id: u64) -> TqResult<&T>;
    fn get_mut(&mut self, id: u64) -> TqResult<&mut T>;
    fn all(&self) -> &[T];
}

pub struct TaskStore {
    items: Vec<Task>,
    next_id: u64,
}

impl TaskStore {
    pub fn new() -> Self {
        TaskStore {
            items: Vec::new(),
            next_id: 1,
        }
    }
}

impl Store<Task> for TaskStore {
    fn add(&mut self, item: impl Into<Task>) -> TqResult<()> {
        let mut task = item.into();
        task.id = self.next_id;
        self.next_id += 1;
        self.items.push(task);
        Ok(())
    }

    fn get(&self, id: u64) -> TqResult<&Task> {
        self.items
            .iter()
            .find(|task| task.id() == id)
            .ok_or(TqError::NotFound(id))
    }

    fn get_mut(&mut self, id: u64) -> TqResult<&mut Task> {
        self.items
            .iter_mut()
            .find(|task| task.id() == id)
            .ok_or(TqError::NotFound(id))
    }

    fn all(&self) -> &[Task] {
        &self.items
    }
}

get_mut was not planned - the private field demanded it. That is the effect of a module boundary: visibility forces you to think about what should be accessible from outside.

Finally, src/main.rs - module declarations, imports, updated calls:

// src/main.rs - CHANGED
mod error;
mod store;
mod task;

use error::TqResult;
use store::{Store, TaskStore};
use task::Task;

fn main() -> TqResult<()> {
    let mut store = TaskStore::new();

    store.add("Buy coffee")?;
    store.add("Buy milk")?;
    store.add("Buy eggs")?;

    for task in store.all() {
        println!("{}", task);
    }

    match store.get(1) {
        Ok(task) => println!("found: {}", task),
        Err(e) => println!("{}", e),
    }

    match store.get(99) {
        Ok(task) => println!("{}", task),
        Err(e) => println!("{}", e),
    }

    store.get_mut(1)?.complete(); // CHANGED: was &mut store.items[0]
    println!("done: {}", store.get(1)?.is_done());

    match Task::new(0, "") {
        Ok(task) => store.add(task)?,
        Err(e) => println!("rejected: {}", e),
    }

    Ok(())
}

The output is unchanged:

#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

Tests follow the same scheme - the full suite is in 3-a-voice/06-rooms-in-the-grimoire/.

make ci passes. Every type now has its own room.

The complete tq code for this chapter is in 3-a-voice/06-rooms-in-the-grimoire/.