Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

Learn RustChapter 8: Organising code

Lesson 8.6 · 8 min

Chapter review: Organising code

This chapter split Chatterbox into files, taught it to remember across restarts, wrote one function for any kind of list, and let you swap who replies while you chat. Before the bot talks to a real model, check that it has all stuck: a mixed quiz, then one bigger exercise that uses the chapter together.

Adds to ChatterboxNothing new: a mixed quiz on the whole chapter, and a bigger exercise that uses all of it.

What this chapter gave Chatterbox

Chatterbox v0.8 is five files instead of one: main.rs keeps the chat loop, and message.rs, command.rs, store.rs and replier.rs each hold one job. Quit and start it again and the conversation is still there, read back from chatterbox.log. /last uses one last_n that works for any list, and /model pretend swaps who answers in the middle of a chat. Here is the chapter, one line per lesson. If a line feels shaky, the link takes you back.

  • 8.1 Splitting code into modules: mod makes a named box, everything inside is private until marked pub, use brings a name in, and mod message; means "the box is in message.rs".
  • 8.2 Saving and loading: fs::write and fs::read_to_string can fail with an io::Error; a missing file on the first run just means "no history yet", and main can return a Result so ? works inside it.
  • 8.3 Code for any type: generics: <T> leaves a blank for a type the caller chooses. The body may only do what works for every T, unless a bound such as T: PartialOrd asks for more.
  • 8.4 Traits: a trait is a named promise of methods, impl Replier for Echo keeps it, and Box<dyn Replier> holds any type that keeps it, chosen while the program runs.
  • 8.5 How long a borrow lasts: a borrow must not outlive its owner. With one borrowed input Rust works out where a returned borrow comes from; with two, or none, you name it with 'a.

Mixed quiz

Seven short programs, all new, with the ideas shuffled on purpose: a module, then a generic, then a lifetime, then a file. Mixing them is harder than seven of the same kind in a row, and that is what makes it stick. For each one, decide first: does it compile? If it does, what exactly does it print? Every answer was produced by the real compiler.

PredictA parse nobody can call
mod command {
pub enum Command {
Help,
Quit,
}
 
fn parse(line: &str) -> Option<Command> {
match line.trim() {
"/help" => Some(Command::Help),
"/quit" => Some(Command::Quit),
_ => None,
}
}
}
 
fn main() {
match command::parse("/help") {
Some(_) => println!("bot: a command"),
None => println!("bot: just chat"),
}
}

What happens when this program is compiled?

  • It compiles and runs
  • Compile error E0603
  • Compile error E0616
Reveal the answer

It does not compile: E0603. Command is pub, but parse is not, so it is private to the command box and main may not call it. That is E0603, "function is private". E0616 is the same idea for a struct field. Lesson 8.1 has the rule; the fix is pub fn parse.

error[E0603]: function `parse` is private
  --> src/main.rs:17:20
   |
17 |     match command::parse("/help") {
   |                    ^^^^^ private function
   |
note: the function `parse` is defined here
  --> src/main.rs:7:5
   |
 7 |     fn parse(line: &str) -> Option<Command> {
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
PredictCount anything you can compare
fn count_of<T: PartialEq>(items: &[T], target: T) -> usize {
let mut n = 0;
for item in items {
if item == &target {
n += 1;
}
}
n
}
 
fn main() {
let history = vec!["hi", "Hi", "hi ", "hi"];
println!("{}", count_of(&history, "hi"));
}

What does this print?

  • 2
  • 4
  • 1
Reveal the answer

The program prints: 2 Here T is &str, and the bound T: PartialEq is what allows == inside the body. Text compares exactly: Hi has a capital and hi has a space, so only the two plain hi messages count. Lesson 8.3 covers bounds.

PredictThe latest message, or a fallback
fn latest_or(history: &[String], fallback: &str) -> &str {
match history.last() {
Some(m) => m,
None => fallback,
}
}
 
fn main() {
let history = vec![String::from("hi")];
println!("{}", latest_or(&history, "nothing yet"));
}

What happens when this program is compiled?

  • It compiles and runs
  • Compile error E0106
  • Compile error E0597
Reveal the answer

It does not compile: E0106. The answer can borrow from either input, the history or the fallback, and with two borrowed inputs no elision rule says which. So the signature is missing a lifetime: E0106. Naming one, 'a, on both inputs and the output fixes it. Lesson 8.5 walks through the same repair.

error[E0106]: missing lifetime specifier
 --> src/main.rs:1:53
  |
1 | fn latest_or(history: &[String], fallback: &str) -> &str {
  |                       ---------            ----     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `history` or `fallback`
help: consider introducing a named lifetime parameter
  |
1 | fn latest_or<'a>(history: &'a [String], fallback: &'a str) -> &'a str {
  |             ++++           ++                      ++          ++
PredictThe very first run
use std::fs;
use std::io;
 
fn main() {
match fs::read_to_string("no-such-chat.log") {
Ok(text) => println!("loaded {} bytes", text.len()),
Err(e) if e.kind() == io::ErrorKind::NotFound => println!("no history yet"),
Err(e) => println!("could not read: {e}"),
}
}

There is no file called no-such-chat.log. What does this print?

  • no history yet
  • loaded 0 bytes
  • could not read: No such file or directory (os error 2)
Reveal the answer

The program prints: no history yet Reading a file that is not there is an Err, not an empty text, so the first arm is out. The second arm has a guard, and its condition is true because the kind of error is NotFound, so it wins before the third arm is tried. That is how Chatterbox treats its first run in lesson 8.2.

PredictTwo repliers in one list
trait Replier {
fn name(&self) -> &str;
fn reply(&self, message: &str) -> String;
}
 
struct Echo;
struct Shout;
 
impl Replier for Echo {
fn name(&self) -> &str {
"echo"
}
 
fn reply(&self, message: &str) -> String {
format!("you said: {message}")
}
}
 
impl Replier for Shout {
fn name(&self) -> &str {
"shout"
}
 
fn reply(&self, message: &str) -> String {
message.to_uppercase()
}
}
 
fn main() {
let repliers: Vec<Box<dyn Replier>> = vec![Box::new(Echo), Box::new(Shout)];
for r in &repliers {
println!("{}: {}", r.name(), r.reply("hi there"));
}
}

What is the second line this program prints?

  • shout: HI THERE
  • echo: you said: hi there
  • shout: you said: HI THERE
Reveal the answer

The program prints: shout: HI THERE Each Box<dyn Replier> remembers which type is inside it, so r.reply runs that type's own method, looked up while the program runs. The second box holds a Shout, whose reply is just the message in capitals. Lesson 8.4 shows why the list needs dyn.

PredictAnnounce anything
fn announce<T>(item: T) {
println!("new: {item}");
}
 
fn main() {
announce("hello");
}

What happens when this program is compiled?

  • It compiles and runs
  • Compile error E0277
  • Compile error E0369
Reveal the answer

It does not compile: E0277. The compiler checks announce for every type T could be, and not every type can be printed with {}. So it says T does not implement Display: E0277, a trait bound that is not satisfied. Writing T: Display asks for the ability, as in lessons 8.3 and 8.4.

error[E0277]: `T` doesn't implement `std::fmt::Display`
 --> src/main.rs:2:20
  |
2 |     println!("new: {item}");
  |                    ^^^^^^ `T` cannot be formatted with the default formatter
  |
  = note: in format strings you may be able to use `{:?}` (or `{:#?}` for pretty-print) instead
help: consider restricting type parameter `T` with trait `Display`
  |
1 | fn announce<T: std::fmt::Display>(item: T) {
  |              +++++++++++++++++++
PredictA private field behind a public method
mod message {
pub struct Message {
pub author: String,
text: String,
}
 
impl Message {
pub fn new(author: &str, text: &str) -> Message {
Message { author: author.to_string(), text: text.to_string() }
}
 
pub fn shout(&self) -> String {
self.text.to_uppercase()
}
}
}
 
use message::Message;
 
fn main() {
let m = Message::new("you", "hi");
println!("{}: {}", m.author, m.shout());
}

What happens when this program is compiled?

  • It compiles and runs
  • Compile error E0616
  • Compile error E0603
Reveal the answer

It compiles and runs. main never touches the private text field. It calls shout, which is pub and lives inside the message box, where private things are allowed. It prints you: HI. That is the "show it on your terms" fix from lesson 8.1.

Milestone: a module of repliers

Now use the chapter together. Below is a small program, separate from Chatterbox, built the way Chatterbox is. A module, replier, holds a Replier trait and two types that keep it. You fill in three bodies:

  • Shout's reply: the message in capitals with a ! at the end (lesson 8.4).
  • pick: the box for a name it knows, None for any other. It is pub so main can call it, and its answer is an Option<Box<dyn Replier>>, which is how one function can hand back either type (lessons 8.1 and 8.4).
  • lines_from: the text of every history line a given author wrote. Its signature is already written, and the 'a in it says the answer borrows from the history and not from who. One hidden test throws who away before it uses the answer, and that only compiles because of that signature (lesson 8.5).

Saving to a file has no place here: the online Playground keeps no files between runs, so fs is a thing to try on your own computer with Chatterbox itself.

Try itMilestone: repliers and a borrowed transcript

Fill in Shout's reply, pick and lines_from. pick("shout") gives a replier whose reply to hi is HI!, pick("banana") gives None, and lines_from with "you" gives the text of your lines only, in order.

Fill in Shout's reply, pick and lines_from. pick("shout") gives a replier whose reply to hi is HI!, pick("banana") gives None, and lines_from with "you" gives the text of your lines only, in order.

mod replier {
/// Anything that can answer a message.
pub trait Replier {
fn name(&self) -> &str;
fn reply(&self, message: &str) -> String;
}
 
/// Repeats what you said.
pub struct Echo;
 
/// Repeats what you said in capitals, with a "!" at the end.
pub struct Shout;
 
impl Replier for Echo {
fn name(&self) -> &str {
"echo"
}
 
fn reply(&self, message: &str) -> String {
format!("you said: {message}")
}
}
 
impl Replier for Shout {
fn name(&self) -> &str {
"shout"
}
 
fn reply(&self, message: &str) -> String {
todo!()
}
}
 
/// The replier with this name, or None for a name nobody knows.
pub fn pick(name: &str) -> Option<Box<dyn Replier>> {
todo!()
}
}
 
use replier::{Replier, pick};
 
/// The text of every line in the history written by `who`, borrowed from the history.
/// A line looks like "you: hello": the author, a colon and a space, then the text.
fn lines_from<'a>(history: &'a [String], who: &str) -> Vec<&'a str> {
todo!()
}
 
fn main() {
let history = vec![
String::from("you: hi"),
String::from("bot: you said: hi"),
String::from("you: bye"),
];
println!("you said: {:?}", lines_from(&history, "you"));
let names = vec!["echo", "shout", "banana"];
for name in names {
match pick(name) {
Some(r) => println!("{} -> {}", r.name(), r.reply("hi")),
None => println!("bot: nobody called {name} can reply"),
}
}
}

Hidden tests (9): an_unknown_name_is_none an_empty_history_has_no_lines a_colon_inside_the_text_survives pick_knows_echo pick_knows_shout shout_answers_in_capitals the_author_must_match_exactly who_can_go_before_the_answer_is_used your_lines_in_order

This exercise runs real Rust in your browser through the official Rust Playground, so it needs JavaScript.

What you have now

If the quiz and the milestone went well, you can put code in boxes and choose what each one shows, write one function for many types and say what those types must be able to do, give several types one promise and swap between them while the program runs, and read a lifetime as a description of where a borrow comes from. As a preview, the next chapter gives Chatterbox a replier that is not pretending.

Self-check

Say it out loud, then reveal

Self-checkRetrieval beats re-reading
An item inside a <code>mod</code> has no <code>pub</code>. Who can use it?Reveal
Only code inside that module, including its own tests. Code outside gets E0603 for a private function, or E0616 for a private field.
Why is <code>{item}</code> refused inside <code>fn announce&lt;T&gt;(item: T)</code>?Reveal
The body must work for every type T, and not every type can be printed with {}. The bound T: Display narrows T to types that can.
When do you need <code>Box&lt;dyn Replier&gt;</code> instead of a generic <code>T: Replier</code>?Reveal
When the type is chosen while the program runs, like pick or /model, or when different types must sit in one list. A generic fixes the type when the program is compiled.
In <code>fn lines_from&lt;'a&gt;(history: &amp;'a [String], who: &amp;str) -&gt; Vec&lt;&amp;'a str&gt;</code>, why does <code>who</code> have no <code>'a</code>?Reveal
The answer only ever borrows from the history. Leaving who out of the lifetime lets the caller throw it away while still using the answer.
Your Chatterbox so far: v0.8 (after lesson 8.4)
Chatterbox v0.8 after lesson 8.4: the whole program
src/main.rs
// Chatterbox: a small chat program, built one lesson at a time.
mod command;
mod message;
mod replier;
mod store;
 
use command::{Command, parse};
use message::Message;
use replier::{Echo, Pretend, Replier};
use std::collections::HashMap;
use std::io;
 
/// What the bot is called. A constant: fixed for the whole program.
const BOT: &str = "Chatterbox";
/// The version as a pair of numbers: (major, minor).
const VERSION: (u32, u32) = (0, 8);
/// The longest message we accept, in bytes.
const MAX_LEN: usize = 200;
/// What /help prints.
const HELP: &str = "commands: /help, /again, /history, /count, /stats, /remember <name> <fact>, /recall <name>, /find <word>, /last <n>, /model <echo|pretend>, /say <text>, /shout <text>, /debug <text>, /quit (or /h, /q, /exit)";
 
/// The first line Chatterbox prints.
fn banner() -> String {
format!("{} v{}.{}", BOT, VERSION.0, VERSION.1)
}
 
/// The greeting, with a name filled in.
fn greeting(name: &str) -> String {
let mut text = format!("Hi {name}! I'm {BOT}.");
text.push_str(" Ask me anything.");
text
}
 
/// The last `n` items of any slice: messages, numbers, words, anything.
/// `<T>` says "for any type T"; the body never needs to know which one.
fn last_n<T>(items: &[T], n: usize) -> &[T] {
let start = items.len().saturating_sub(n);
&items[start..]
}
 
fn main() -> Result<(), io::Error> {
let mut messages_seen: u32 = 0;
let quiet = false;
let mut last: Option<String> = None; // the last thing you said, for /again
let mut history: Vec<Message> = store::load(store::FILE)?; // last time's conversation, if any
let mut facts: HashMap<String, String> = HashMap::new(); // name -> fact, for /recall
// Box<dyn Replier>: "some Replier, decided while the program runs".
let mut replier: Box<dyn Replier> = Box::new(Echo { max_len: MAX_LEN });
 
println!("{} (replies by: {})", banner(), replier.name());
println!("What should I call you?");
let mut name_line = String::new();
io::stdin().read_line(&mut name_line)?;
let name = name_line.trim();
if !quiet {
println!("{}", greeting(name));
}
if !history.is_empty() {
println!(
"(I remember {} messages from last time. /history shows them.)",
history.len()
);
}
 
let mut line = String::new();
loop {
line.clear();
let bytes = io::stdin().read_line(&mut line)?;
if bytes == 0 {
break; // no more input: the keyboard was closed
}
let message = line.trim();
let cmd = match parse(message) {
Ok(cmd) => cmd,
Err(e) => {
println!("bot: {e}");
continue;
}
};
match cmd {
Command::Empty => {} // nothing typed: nothing to answer
Command::Quit => {
println!("bot: bye, {name}!");
break;
}
Command::Help => println!("bot: {HELP}"),
Command::Again => match &last {
Some(text) => println!("bot: {}", replier.reply(&[Message::new("you", text)])),
None => println!("bot: nothing to repeat yet"),
},
Command::History => {
if history.is_empty() {
println!("bot: nothing said yet");
} else {
for (i, m) in history.iter().enumerate() {
println!("{}. {}", i + 1, m.to_line());
}
}
}
Command::Count => {
let bytes: usize = history.iter().map(|m| m.text.len()).sum();
println!("bot: {} messages, {} bytes of text", history.len(), bytes);
}
Command::Stats => {
// One chain of steps: your messages -> their words -> a count per word.
let mut counts: HashMap<String, u32> = HashMap::new();
for word in history
.iter()
.filter(|m| m.author == "you")
.flat_map(|m| m.text.split_whitespace())
{
*counts.entry(word.to_lowercase()).or_insert(0) += 1;
}
let mut top: Vec<(String, u32)> = counts.into_iter().collect();
// most used first; ties in alphabetical order, so the answer never changes
top.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
let words: Vec<String> = top
.iter()
.take(3)
.map(|(word, n)| format!("{word} ({n})"))
.collect();
if words.is_empty() {
println!("bot: nothing said yet");
} else {
println!("bot: your top words: {}", words.join(", "));
}
}
Command::Remember(key, fact) => {
println!("bot: got it: {key} is {fact}");
facts.insert(key, fact);
}
Command::Recall(Some(key)) => match facts.get(&key) {
Some(fact) => println!("bot: {key} is {fact}"),
None => println!("bot: I don't know anything about {key}"),
},
Command::Recall(None) => {
if facts.is_empty() {
println!("bot: I don't remember anything yet. Try /remember");
} else {
// A HashMap has no order, so sort the names before printing them.
let mut names = Vec::new();
for name in facts.keys() {
names.push(name.as_str());
}
names.sort();
println!("bot: I remember: {}", names.join(", "));
}
}
Command::Find(word) => {
// The closure borrows `word` from here: a small function made on the spot.
let hits: Vec<&Message> = history
.iter()
.filter(|m| m.author == "you" && m.text.contains(&word))
.collect();
if hits.is_empty() {
println!("bot: you never said {word}");
} else {
for m in hits {
println!("{}", m.to_line());
}
}
}
Command::Last(n) => {
for m in last_n(&history, n) {
println!("{}", m.to_line());
}
}
Command::Model(choice) => {
let next: Option<Box<dyn Replier>> = match choice.as_str() {
"echo" => Some(Box::new(Echo { max_len: MAX_LEN })),
"pretend" => Some(Box::new(Pretend)),
_ => None,
};
match next {
Some(r) => {
replier = r;
println!("bot: replies now come from {}", replier.name());
}
None => {
println!("bot: I don't know a model called {choice}. Try echo or pretend")
}
}
}
Command::Say(text) => println!("bot: {text}"),
Command::Shout(text) => println!("bot: {}", Message::new("you", &text).shout()),
Command::Debug(text) => println!("bot: {:?}", parse(&text)),
Command::Chat(text) => {
messages_seen += 1;
history.push(Message::new("you", &text));
// the history holds its own copy, so `text` can move into `last`
last = Some(text);
let answer = replier.reply(&history);
println!("bot: {answer}");
history.push(Message::new("bot", &answer));
}
}
}
println!("messages so far: {messages_seen}");
store::save(store::FILE, &history)?;
let s = if history.len() == 1 { "" } else { "s" };
println!("saved {} message{s} to {}", history.len(), store::FILE);
Ok(())
}
 
// Tests: `cargo test` builds and runs them; the program itself never sees them.
#[cfg(test)]
mod tests {
use super::*;
 
#[test]
fn last_n_takes_from_the_end() {
assert_eq!(last_n(&[1, 2, 3, 4], 2), &[3, 4]);
assert_eq!(last_n(&["a", "b"], 5), &["a", "b"]);
assert!(last_n(&[1, 2, 3], 0).is_empty());
}
}
src/command.rs
//! Commands: what a line of input means, and what can be wrong with it.
use std::fmt;
 
/// Everything a line of input can mean. One enum, so a `match` on it must
/// handle every kind of line: the compiler checks that nothing is forgotten.
#[derive(Debug)]
pub enum Command {
Empty,
Help,
Quit,
Again,
History,
Count,
Stats,
Remember(String, String),
Recall(Option<String>),
Find(String),
Last(usize),
Model(String),
Say(String),
Shout(String),
Debug(String),
Chat(String),
}
 
/// Everything that can be wrong with a line of input. parse() returns one of
/// these instead of a Command, and main prints it.
#[derive(Debug)]
pub enum CommandError {
Unknown(String),
Missing(String, String),
Usage(String),
NotACount(String),
}
 
/// How a CommandError reads when printed with {}.
impl fmt::Display for CommandError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
CommandError::Unknown(word) => {
write!(f, "I don't know the command {word}. Try /help.")
}
CommandError::Missing(verb, example) => {
write!(f, "{verb} what? Try /{verb} {example}")
}
CommandError::Usage(form) => write!(f, "try {form}"),
CommandError::NotACount(text) => write!(f, "'{text}' is not a count. Try /last 3"),
}
}
}
 
/// The text after a command word, or the error that says it is missing.
fn need(line: &str, verb: &str, example: &str) -> Result<String, CommandError> {
match arg(line) {
Some(text) => Ok(String::from(text)),
None => Err(CommandError::Missing(
String::from(verb),
String::from(example),
)),
}
}
 
/// How many messages /last should show. "two" or "-1" is an error.
fn parse_count(text: &str) -> Result<usize, CommandError> {
match text.parse::<usize>() {
Ok(n) => Ok(n),
Err(_) => Err(CommandError::NotACount(String::from(text))),
}
}
 
/// Turn one line of input into a Command, or say what is wrong with it.
/// Each `?` means: if this step failed, return its error right here.
pub fn parse(line: &str) -> Result<Command, CommandError> {
let cmd = match first_word(line) {
"" => Command::Empty,
"/help" | "/h" => Command::Help,
"/quit" | "/q" | "/exit" => Command::Quit,
"/again" => Command::Again,
"/history" => Command::History,
"/count" => Command::Count,
"/stats" => Command::Stats,
"/remember" => {
let usage = CommandError::Usage(String::from("/remember <name> <fact>"));
let rest = match arg(line) {
Some(rest) => rest,
None => return Err(usage),
};
match arg(rest) {
Some(fact) => Command::Remember(String::from(first_word(rest)), String::from(fact)),
None => return Err(usage),
}
}
"/recall" => match arg(line) {
Some(name) => Command::Recall(Some(String::from(name))),
None => Command::Recall(None),
},
"/find" => Command::Find(need(line, "find", "hello")?),
"/last" => Command::Last(parse_count(&need(line, "last", "3")?)?),
"/model" => Command::Model(need(line, "model", "pretend")?),
"/debug" => Command::Debug(need(line, "debug", "/say hi")?),
"/say" => Command::Say(need(line, "say", "hello")?),
"/shout" => Command::Shout(need(line, "shout", "hello")?),
word => {
if is_command(word) {
return Err(CommandError::Unknown(String::from(word)));
}
Command::Chat(String::from(line))
}
};
Ok(cmd)
}
 
/// True when the text is a command: it starts with a slash.
fn is_command(text: &str) -> bool {
text.starts_with('/')
}
 
/// The first word of the text: a slice (&str) pointing into it, not a copy.
fn first_word(text: &str) -> &str {
match text.find(' ') {
Some(i) => &text[..i],
None => text,
}
}
 
/// The text after the command word, if there is any: Some("hello") for
/// "/say hello", None for a bare "/say". No more "" standing in for nothing.
fn arg(line: &str) -> Option<&str> {
match line.find(' ') {
Some(i) => {
let rest = line[i + 1..].trim();
if rest.is_empty() { None } else { Some(rest) }
}
None => None,
}
}
 
#[cfg(test)]
mod tests {
use super::*;
 
#[test]
fn plain_text_is_chat() {
assert!(matches!(parse("hello there"), Ok(Command::Chat(t)) if t == "hello there"));
}
 
#[test]
fn help_has_a_short_form() {
assert!(matches!(parse("/help"), Ok(Command::Help)));
assert!(matches!(parse("/h"), Ok(Command::Help)));
}
 
#[test]
fn say_needs_text() {
assert_eq!(
parse("/say").unwrap_err().to_string(),
"say what? Try /say hello"
);
}
 
#[test]
fn last_needs_a_number() {
assert!(matches!(parse("/last 3"), Ok(Command::Last(3))));
assert_eq!(
parse("/last two").unwrap_err().to_string(),
"'two' is not a count. Try /last 3"
);
}
 
#[test]
fn unknown_commands_are_errors() {
assert_eq!(
parse("/dance").unwrap_err().to_string(),
"I don't know the command /dance. Try /help."
);
}
 
#[test]
fn remember_takes_a_name_and_a_fact() {
assert!(
matches!(parse("/remember pizza extra cheese"), Ok(Command::Remember(k, v)) if k == "pizza" && v == "extra cheese")
);
assert!(parse("/remember pizza").is_err());
}
}
src/message.rs
//! A Message: one line of the conversation.
 
/// One line of the conversation: who said it, and what they said.
pub struct Message {
pub author: String,
pub text: String,
}
 
impl Message {
/// Make a Message from two pieces of borrowed text.
pub fn new(author: &str, text: &str) -> Message {
Message {
author: String::from(author),
text: String::from(text),
}
}
 
/// The message as one transcript line: "you: hello".
pub fn to_line(&self) -> String {
format!("{}: {}", self.author, self.text)
}
 
/// The text in capitals, for /shout.
pub fn shout(&self) -> String {
self.text.to_uppercase()
}
}
src/replier.rsnew file
//! Who answers: anything that can reply, behind one trait.
use crate::message::Message;
 
/// A promise with a name: any type that implements Replier can answer a
/// conversation. The chat loop only ever talks to this promise.
pub trait Replier {
/// A short name, for "/model" and the banner.
fn name(&self) -> &str;
/// The answer to the conversation so far (the last message is the newest).
fn reply(&self, history: &[Message]) -> String;
}
 
/// The echo Chatterbox has had since chapter 3, now as one kind of Replier.
pub struct Echo {
pub max_len: usize,
}
 
impl Replier for Echo {
fn name(&self) -> &str {
"echo"
}
 
fn reply(&self, history: &[Message]) -> String {
let message = match history.last() {
Some(m) => &m.text,
None => return String::from("(nothing to reply to)"),
};
if message.len() > self.max_len {
format!("that is too long (the limit is {} bytes)", self.max_len)
} else if message.ends_with('?') {
String::from("Good question! I can only echo for now. Ask me again in chapter 9.")
} else {
format!("you said: {message}")
}
}
}
 
/// A stand-in for a real model: it shows that it was handed the WHOLE
/// conversation, which is exactly what the real one will need.
pub struct Pretend;
 
impl Replier for Pretend {
fn name(&self) -> &str {
"pretend"
}
 
fn reply(&self, history: &[Message]) -> String {
match history.last() {
Some(m) => format!(
"(pretend model) I read all {} message{}. You last said: {}",
history.len(),
if history.len() == 1 { "" } else { "s" },
m.text
),
None => String::from("(pretend model) nothing to read yet"),
}
}
}
 
#[cfg(test)]
mod tests {
use super::*;
 
fn said(text: &str) -> Vec<Message> {
vec![Message::new("you", text)]
}
 
#[test]
fn echo_repeats_you() {
assert_eq!(Echo { max_len: 200 }.reply(&said("hi")), "you said: hi");
}
 
#[test]
fn echo_refuses_long_messages() {
assert!(
Echo { max_len: 5 }
.reply(&said("far too long"))
.starts_with("that is too long")
);
}
 
#[test]
fn pretend_reads_the_whole_history() {
let history = vec![
Message::new("you", "a"),
Message::new("bot", "b"),
Message::new("you", "c"),
];
assert_eq!(
Pretend.reply(&history),
"(pretend model) I read all 3 messages. You last said: c"
);
}
}
src/store.rs
//! Saving the conversation to a file and reading it back.
use crate::message::Message;
use std::fs;
use std::io;
 
/// Where the history is kept, next to wherever you run Chatterbox.
pub const FILE: &str = "chatterbox.log";
 
/// The history as text, one "author: text" line per message.
fn to_lines(history: &[Message]) -> String {
let mut out = String::new();
for m in history {
out.push_str(&m.to_line());
out.push('\n');
}
out
}
 
/// Text in that format, back into messages. Lines that do not fit are skipped.
fn from_lines(text: &str) -> Vec<Message> {
let mut out = Vec::new();
for line in text.lines() {
if let Some((author, said)) = line.split_once(": ") {
out.push(Message::new(author, said));
}
}
out
}
 
/// The saved history, or an empty one the very first time (no file yet).
pub fn load(path: &str) -> Result<Vec<Message>, io::Error> {
match fs::read_to_string(path) {
Ok(text) => Ok(from_lines(&text)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e),
}
}
 
/// Write the whole history to the file, replacing what was there.
pub fn save(path: &str, history: &[Message]) -> Result<(), io::Error> {
fs::write(path, to_lines(history))
}

Download this version (zip)Unzip, then cargo run inside the folder.

That is lesson 8.6. Tick it off so the course remembers where you are. That is also the end of chapter 8: a few minutes with the course flashcards now makes it stick, and the chapter’s cheat sheet has every idea on one screen.

Next: 9.1 JSON with serde →

Questions people ask

What is the difference between E0603 and E0616 in Rust?

Both mean something is private to its module. E0603 is a private item, such as a function or struct, used from outside. E0616 is a private field of a struct read from outside. Add pub, or offer a pub method instead.

When should I use generics and when should I use dyn Trait in Rust?

Use a generic with a bound when the type is known where the code is compiled: it costs nothing while running. Use Box dyn Trait when the type is only known while the program runs, or when values of different types must share one list.

Why does Rust say missing lifetime specifier on my function?

The function returns a reference and has two reference inputs, or none, so Rust cannot tell where the answer borrows from. Name a lifetime and put it on the output and on the input the answer really comes from.

Why does my file not load in the Rust Playground?

The online Playground keeps no files between runs. Run programs that save and load files on your own computer with cargo run.

Learn Rust by building a chat app · Lesson 8.6 · every compiler message above is real output from rustc 1.99.0 (b940084d7 2026-09-28) (edition 2024) · Vibe Engines · 2026