Vibe Engines
YouTube

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

Learn RustChapter 9: The real model

Lesson 9.4 · 8 min

Chapter review: the real model

Chatterbox now saves its history as JSON, sends the conversation to a real model and comes back with an answer, and it is installed as v1.0. Before chapter 10 makes the answers stream, this page mixes the whole chapter together: eight quick predictions, then one exercise that walks the path from the saved file to the model's answer.

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

Three lessons took Chatterbox from a program that talks to itself to one that talks to a model, and then out of its project folder. The ideas, one line each:

  • Lesson 9.1: #[derive(Serialize, Deserialize)] asks serde to write the code that turns a struct into JSON text and back. Writing practically never fails; reading text from outside can, so serde_json::from_str returns a Result.
  • Lesson 9.2: a request and a reply are just structs. You name only the fields you read, and serde skips the rest.
  • Also in lesson 9.2: settings and secrets come from environment variables, which env::var hands back as a Result, and one impl From block lets ? turn someone else's error into your own error type.
  • Lesson 9.3: cargo test before you hand it over, cargo build --release for the fast binary, cargo install --path . --locked to run it from anywhere, and a README so someone else can.

Nothing new appears on this page. If a question stumps you, its answer names the lesson to reread.

Mixed quiz

Eight small programs, each run through the real compiler when the course was built. Guess first, then open the answer. They jump between the lessons on purpose: picking the right idea is half the work.

PredictThe whole history as JSON
use serde::Serialize;
 
#[derive(Serialize)]
struct Message {
author: String,
text: String,
}
 
fn main() {
let history = vec![
Message { author: String::from("you"), text: String::from("hi") },
Message { author: String::from("bot"), text: String::from("you said: hi") },
];
println!("{}", serde_json::to_string(&history).unwrap());
}

What does this program print?

  • [{"author":"you","text":"hi"},{"author":"bot","text":"you said: hi"}]
  • {"author":"you","text":"hi"}{"author":"bot","text":"you said: hi"}
  • {"history":[{"author":"you","text":"hi"},{"author":"bot","text":"you said: hi"}]}
Reveal the answer

The program prints: [{"author":"you","text":"hi"},{"author":"bot","text":"you said: hi"}] A Vec of things that can be written out can itself be written out, as a JSON list in square brackets with one record per message. The name history belongs to the program, not to the data, so it never shows up in the JSON. This is exactly what chatterbox.json holds (lesson 9.1).

PredictRead the key straight into a String
use std::env;
 
fn main() {
let key: String = env::var("CHATTERBOX_API_KEY");
println!("the key has {} characters", key.len());
}

Does it compile, and if it does, does it run?

  • It compiles and runs
  • It compiles, then panics
  • Compile error E0308
Reveal the answer

It does not compile: E0308. It never gets as far as running. env::var returns a Result, because the setting may not be there, and a Result is not a String, so the compiler reports mismatched types. Lesson 9.2 handled it with map_err and ?; a match works too.

error[E0308]: mismatched types
 --> src/main.rs:4:23
  |
4 |     let key: String = env::var("CHATTERBOX_API_KEY");
  |              ------   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `String`, found `Result<String, VarError>`
  |              |
  |              expected due to this
  |
  = note: expected struct `String`
               found enum `Result<String, VarError>`
help: consider using `Result::expect` to unwrap the `Result<String, VarError>` value, panicking if the value is a `Result::Err`
  |
4 |     let key: String = env::var("CHATTERBOX_API_KEY").expect("REASON");
  |                                                     +++++++++++++++++
PredictA reply with fields nobody asked for
use serde::Deserialize;
 
#[derive(Deserialize)]
struct Response {
choices: Vec<Choice>,
}
 
#[derive(Deserialize)]
struct Choice {
message: ApiReply,
}
 
#[derive(Deserialize)]
struct ApiReply {
content: String,
}
 
fn main() {
let body = r#"{"id":"r7","model":"mock-model","choices":[{"index":0,"message":{"role":"assistant","content":"Hi Sam!"}}],"usage":{"total_tokens":12}}"#;
let reply: Response = serde_json::from_str(body).unwrap();
println!("bot: {}", reply.choices[0].message.content);
}

The reply has an id, a model name and token counts that the structs never mention. What happens?

  • It compiles and runs
  • It compiles, then panics
  • Compile error E0063
Reveal the answer

It compiles and runs. It runs and prints bot: Hi Sam!. serde quietly skips any field your struct does not name, which is why the structs in lesson 9.2 list only the path choices, message, content. Extra fields are fine; a missing one is not, as a later question shows.

PredictBorrow a sample reply from the tests
fn main() {
println!("{}", tests::sample_reply());
}
 
#[cfg(test)]
mod tests {
pub fn sample_reply() -> String {
String::from("you said: hi")
}
}

This is a normal build, not cargo test. Does it compile?

  • It compiles and runs
  • Compile error E0433
  • Compile error E0425
Reveal the answer

It does not compile: E0433. #[cfg(test)] means "only build this for cargo test". In a normal build the whole tests module does not exist, so tests:: leads nowhere and the compiler cannot resolve the path. That is the point lesson 9.3 made: none of your tests ends up in the program you ship, so nothing outside them may lean on them.

error[E0433]: cannot find module or crate `tests` in this scope
 --> src/main.rs:2:20
  |
2 |     println!("{}", tests::sample_reply());
  |                    ^^^^^ use of unresolved module or unlinked crate `tests`
  |
  = help: you might be missing a crate named `tests`
PredictA message that can only be read
use serde::Deserialize;
 
#[derive(Deserialize)]
struct Message {
author: String,
text: String,
}
 
fn main() {
let m = Message { author: String::from("you"), text: String::from("hi") };
println!("{}", serde_json::to_string(&m).unwrap());
}

Does this compile?

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

It does not compile: E0277. Deserialize only promises "I can be read in". to_string accepts a value only if its type keeps the other promise, Serialize, so the trait bound is not met: E0277, the missing-promise error from lesson 8.4. Each direction has its own derive (lesson 9.1).

error[E0277]: the trait bound `Message: serde::Serialize` is not satisfied
    --> src/main.rs:11:42
     |
  11 |     println!("{}", serde_json::to_string(&m).unwrap());
     |                    --------------------- ^^ unsatisfied trait bound
     |                    |
     |                    required by a bound introduced by this call
     |
help: the trait `Serialize` is not implemented for `Message`
    --> src/main.rs:4:1
     |
   4 | struct Message {
     | ^^^^^^^^^^^^^^
     = note: for local types consider adding `#[derive(serde::Serialize)]` to your `Message` type
     = note: for types from other crates check whether the crate offers a `serde` feature flag
     = help: the following other types implement trait `Serialize`:
               &'a T
               &'a mut T
               ()
               (T,)
               (T0, T1)
               (T0, T1, T2)
               (T0, T1, T2, T3)
               (T0, T1, T2, T3, T4)
             and 129 others
note: required by a bound in `serde_json::to_string`
    --> ~/.cargo/registry/src/index.crates.io/serde_json-1.0.154/src/ser.rs:2247:17
     |
2245 | pub fn to_string<T>(value: &T) -> Result<String>
     |        --------- required by a bound in this function
2246 | where
2247 |     T: ?Sized + Serialize,
     |                 ^^^^^^^^^ required by this bound in `to_string`
PredictAsk for a setting nobody set
use std::env;
 
fn setting(name: &str) -> Result<String, String> {
env::var(name).map_err(|_| format!("{name} is not set"))
}
 
fn main() {
match setting("CHATTERBOX_MODEL") {
Ok(model) => println!("model: {model}"),
Err(why) => println!("(that went wrong: {why})"),
}
}

Nobody has set CHATTERBOX_MODEL. What does this print?

  • (that went wrong: CHATTERBOX_MODEL is not set)
  • model: CHATTERBOX_MODEL
  • model:
Reveal the answer

The program prints: (that went wrong: CHATTERBOX_MODEL is not set) The setting is missing, so env::var gives an Err, and map_err swaps that error for a sentence that names the setting. An Ok would have passed through untouched. It is the same shape as Model::from_env in lesson 9.2, which is why a missing key is a message and never a crash.

PredictLoad a history with a hole in it
use serde::Deserialize;
 
#[derive(Deserialize)]
struct Message {
author: String,
text: String,
}
 
fn main() {
let saved = r#"[{"author":"you","text":"hi"},{"author":"bot"}]"#;
let history: Vec<Message> = serde_json::from_str(saved).unwrap();
println!("loaded {} messages", history.len());
}

The saved file has a message with no text. What happens?

  • It compiles and runs
  • It compiles, then panics
  • Compile error E0308
Reveal the answer

It compiles, then panics at run time. Every Message needs a text, and the second record has none, so from_str returns an Err and unwrap() stops the program. serde does not hand back a half-filled history; the text is checked at the door (lesson 9.1). Chatterbox's load uses ? instead, so the problem is reported, not a crash.

PredictA ? with nowhere to go
use serde::Deserialize;
 
#[derive(Deserialize)]
struct Message {
text: String,
}
 
#[derive(Debug)]
enum ChatError {
Empty,
}
 
fn first_text(saved: &str) -> Result<String, ChatError> {
let history: Vec<Message> = serde_json::from_str(saved)?;
match history.into_iter().next() {
Some(m) => Ok(m.text),
None => Err(ChatError::Empty),
}
}
 
fn main() {
println!("{:?}", first_text(r#"[{"text":"hi"}]"#));
}

Does this compile?

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

It does not compile: E0277. from_str fails with a serde_json::Error, but first_text promises a ChatError. ? converts between the two only if someone wrote impl From<serde_json::Error> for ChatError, and nobody did, so the compiler says ? could not convert the error. Lesson 9.2 wrote that block for reqwest's error.

error[E0277]: `?` couldn't convert the error to `ChatError`
  --> src/main.rs:14:60
   |
13 | fn first_text(saved: &str) -> Result<String, ChatError> {
   |                               ------------------------- expected `ChatError` because of this
14 |     let history: Vec<Message> = serde_json::from_str(saved)?;
   |                                 ---------------------------^ the trait `From<serde_json::Error>` is not implemented for `ChatError`
   |                                 |
   |                                 this can't be annotated with `?` because it has type `Result<_, serde_json::Error>`
   |
note: `ChatError` needs to implement `From<serde_json::Error>`
  --> src/main.rs:9:1
   |
 9 | enum ChatError {
   | ^^^^^^^^^^^^^^
   = note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait

Milestone: from the saved file to the answer

When Chatterbox starts with a real model and an old chatterbox.json, two jobs sit either side of the HTTP call. Before it: read the saved history and build the request body. After it: dig the answer out of the reply. The call itself needs the network, which this page does not have, but both jobs are plain serde, so they run right here.

This time they return a Result with one error type, ChatError, so every from_str can end in ?. That needs the From block first, and it is yours to finish too.

Try itMilestone: resume a conversation

Fill in three bodies. from wraps a serde_json error in the right ChatError variant. resume reads the saved history, refuses an empty one, translates you to user and anyone else to assistant, and writes the request as JSON. answer reads a reply and returns the first choice's content. The hidden tests try a real history, an empty one, a file in the old you: hi format, a message without text, a reply full of extra fields, two choices, no choices and an error page.

Fill in three bodies. from wraps a serde_json error in the right ChatError variant. resume reads the saved history, refuses an empty one, translates you to user and anyone else to assistant, and writes the request as JSON. answer reads a reply and returns the first choice's content. The hidden tests try a real history, an empty one, a file in the old you: hi format, a message without text, a reply full of extra fields, two choices, no choices and an error page.

use serde::{Deserialize, Serialize};
 
/// Chatterbox's own message, as saved in chatterbox.json.
#[derive(Serialize, Deserialize)]
struct Message {
author: String,
text: String,
}
 
// What goes out to the model.
#[derive(Serialize)]
struct ApiMessage {
role: String,
content: String,
}
 
#[derive(Serialize)]
struct Request {
model: String,
messages: Vec<ApiMessage>,
}
 
// What comes back (only the parts we read).
#[derive(Deserialize)]
struct Response {
choices: Vec<Choice>,
}
 
#[derive(Deserialize)]
struct Choice {
message: ApiReply,
}
 
#[derive(Deserialize)]
struct ApiReply {
content: String,
}
 
/// Everything that can go wrong between the saved file and the answer.
#[derive(Debug)]
enum ChatError {
/// The text was not the JSON we expected.
Json(serde_json::Error),
/// Nothing to send, or nothing came back.
Empty,
}
 
/// So that ? can turn a serde_json error into a ChatError by itself.
impl From<serde_json::Error> for ChatError {
fn from(e: serde_json::Error) -> ChatError {
todo!("wrap {e:?} in the right ChatError variant")
}
}
 
/// Pick up where you left off: read the saved history (the JSON in
/// chatterbox.json) and build the chat-completions request body for `model`,
/// with "you" as "user" and anyone else as "assistant".
/// Err(ChatError::Json) if the saved text does not fit; Err(ChatError::Empty)
/// if it holds no messages, because there is nothing to send.
fn resume(saved: &str, model: &str) -> Result<String, ChatError> {
todo!("read {saved:?}, then build the request for {model:?}")
}
 
/// The answer inside a chat-completions reply: the first choice's content.
/// Err(ChatError::Json) if the reply does not fit; Err(ChatError::Empty) if
/// there are no choices.
fn answer(json: &str) -> Result<String, ChatError> {
todo!("read {json:?}, then take the first choice")
}
 
fn main() {
let saved = r#"[{"author":"you","text":"hi"},{"author":"bot","text":"you said: hi"}]"#;
match resume(saved, "mock-model") {
Ok(body) => println!("would send: {body}"),
Err(e) => println!("(that went wrong: {e:?})"),
}
let canned = r#"{"choices":[{"message":{"role":"assistant","content":"Hello again!"}}]}"#;
println!("{:?}", answer(canned));
}

Hidden tests (8): an_empty_history_is_nothing_to_send no_choices_is_empty an_error_page_is_a_json_error a_message_without_text_does_not_fit resume_sends_every_message_with_its_role the_answer_ignores_fields_it_does_not_name the_first_choice_wins the_old_log_format_is_a_json_error

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

Before chapter 10

If the exercise went through, you have every piece of the model path in your hands: shapes as structs, text checked at the door, failures as values, and a binary someone else can install. Chapter 10 keeps all of it and changes one thing: instead of waiting for the whole answer, Chatterbox prints it as the model writes it.

Self-check

Say it out loud, then reveal

Self-checkRetrieval beats re-reading
Why does <code>serde_json::from_str</code> return a Result while writing a struct out practically never fails?Reveal
The text comes from outside the program: a file or a server. It can be missing a field or not be JSON at all. A struct you write out is already shaped correctly.
A reply has dozens of fields and you need one. What do your structs have to name?Reveal
Only the path to the field you read. serde skips every field a struct does not name; it refuses only when a field the struct does name is missing.
What has to exist for <code>?</code> to turn a <code>serde_json::Error</code> into your own <code>ChatError</code>?Reveal
An impl From<serde_json::Error> for ChatError block. ? looks for exactly that conversion; without it the compiler stops with E0277.
Why does the API key come from an environment variable, and what does <code>env::var</code> give back?Reveal
A key typed into the code ends up in version control and logs. env::var returns a Result: Ok with the value, or Err if the setting is not there.
Name the three cargo commands that take Chatterbox from your folder to someone else.Reveal
cargo test to check it, cargo build --release for the fast binary, and cargo install --path . --locked to put it where it runs from any folder.
Your Chatterbox so far: v1.0 (after lesson 9.3)
Chatterbox v1.0 after lesson 9.3: 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, Model, 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) = (1, 0);
/// 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|real>, /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".
// With a key in the environment, start with the real model.
let mut replier: Box<dyn Replier> = match Model::from_env() {
Ok(model) => Box::new(model),
Err(_) => 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) => match replier.reply(&[Message::new("you", text)]) {
Ok(answer) => println!("bot: {answer}"),
Err(e) => println!("bot: (that went wrong: {e})"),
},
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: Result<Box<dyn Replier>, String> = match choice.as_str() {
"echo" => Ok(Box::new(Echo { max_len: MAX_LEN })),
"pretend" => Ok(Box::new(Pretend)),
"real" => match Model::from_env() {
Ok(model) => Ok(Box::new(model)),
Err(e) => Err(e.to_string()),
},
_ => Err(format!(
"I don't know a model called {choice}. Try echo, pretend or real"
)),
};
match next {
Ok(r) => {
replier = r;
println!("bot: replies now come from {}", replier.name());
}
Err(why) => println!("bot: {why}"),
}
}
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);
match replier.reply(&history) {
Ok(answer) => {
println!("bot: {answer}");
history.push(Message::new("bot", &answer));
}
Err(e) => println!("bot: (that went wrong: {e})"),
}
}
}
}
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.
use serde::{Deserialize, Serialize};
 
/// One line of the conversation: who said it, and what they said.
#[derive(Serialize, Deserialize)]
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.rs
//! Who answers: anything that can reply, behind one trait.
use crate::message::Message;
use serde::{Deserialize, Serialize};
use std::env;
use std::fmt;
 
/// What can go wrong while getting a reply.
#[derive(Debug)]
pub enum ChatError {
/// The request did not get a good answer (no network, bad key, server error).
Http(reqwest::Error),
/// The model answered, but with nothing in it.
Empty,
/// /model real was asked for, but a setting is missing.
NotSet(&'static str),
}
 
impl fmt::Display for ChatError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ChatError::Http(e) => write!(f, "the model call failed: {e}"),
ChatError::Empty => write!(
f,
"there was nothing to reply with (an empty conversation or an empty answer)"
),
ChatError::NotSet(name) => write!(f, "set {name} first (see lesson 9.2)"),
}
}
}
 
/// Lets `?` turn a reqwest error into a ChatError on its own.
impl From<reqwest::Error> for ChatError {
fn from(e: reqwest::Error) -> Self {
ChatError::Http(e)
}
}
 
/// A promise with a name: any type that implements Replier can answer a
/// conversation. A real model can fail, so the answer is a Result.
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]) -> Result<String, ChatError>;
}
 
/// 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]) -> Result<String, ChatError> {
let message = match history.last() {
Some(m) => &m.text,
None => return Err(ChatError::Empty),
};
let text = 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. Try /model real to ask a real model.")
} else {
format!("you said: {message}")
};
Ok(text)
}
}
 
/// A stand-in for a real model: it shows it was handed the whole conversation.
pub struct Pretend;
 
impl Replier for Pretend {
fn name(&self) -> &str {
"pretend"
}
 
fn reply(&self, history: &[Message]) -> Result<String, ChatError> {
match history.last() {
Some(m) => Ok(format!(
"(pretend model) I read all {} message{}. You last said: {}",
history.len(),
if history.len() == 1 { "" } else { "s" },
m.text
)),
None => Err(ChatError::Empty),
}
}
}
 
/// One message as a chat API expects it: a role and some content.
#[derive(Serialize)]
struct ApiMessage {
role: String,
content: String,
}
 
/// The body we send: which model, and the whole conversation.
#[derive(Serialize)]
struct Request {
model: String,
messages: Vec<ApiMessage>,
}
 
/// The parts of the answer we read. serde ignores every other field.
#[derive(Deserialize)]
struct Response {
choices: Vec<Choice>,
}
 
#[derive(Deserialize)]
struct Choice {
message: ApiReply,
}
 
#[derive(Deserialize)]
struct ApiReply {
content: String,
}
 
/// A real model behind an OpenAI-style chat endpoint, configured from
/// environment variables so the key never sits in the code.
pub struct Model {
url: String,
key: String,
model: String,
}
 
impl Model {
/// Read the three settings, or say which one is missing.
pub fn from_env() -> Result<Model, ChatError> {
let key =
env::var("CHATTERBOX_API_KEY").map_err(|_| ChatError::NotSet("CHATTERBOX_API_KEY"))?;
let url = env::var("CHATTERBOX_URL").map_err(|_| ChatError::NotSet("CHATTERBOX_URL"))?;
let model =
env::var("CHATTERBOX_MODEL").map_err(|_| ChatError::NotSet("CHATTERBOX_MODEL"))?;
Ok(Model { url, key, model })
}
}
 
impl Replier for Model {
fn name(&self) -> &str {
"real"
}
 
fn reply(&self, history: &[Message]) -> Result<String, ChatError> {
// Our names ("you", "bot") become the API's ("user", "assistant").
let mut messages = Vec::new();
for m in history {
let role = if m.author == "you" {
"user"
} else {
"assistant"
};
messages.push(ApiMessage {
role: String::from(role),
content: m.text.clone(),
});
}
let body = Request {
model: self.model.clone(),
messages,
};
let answer: Response = reqwest::blocking::Client::new()
.post(&self.url)
.bearer_auth(&self.key)
.json(&body)
.send()?
.error_for_status()?
.json()?;
match answer.choices.into_iter().next() {
Some(choice) => Ok(choice.message.content),
None => Err(ChatError::Empty),
}
}
}
 
#[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")).unwrap(),
"you said: hi"
);
}
 
#[test]
fn echo_refuses_long_messages() {
let long = "x".repeat(10);
assert!(
Echo { max_len: 5 }
.reply(&said(&long))
.unwrap()
.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).unwrap(),
"(pretend model) I read all 3 messages. You last said: c"
);
}
 
#[test]
fn nothing_to_reply_to_is_an_error() {
assert!(matches!(
Echo { max_len: 200 }.reply(&[]),
Err(ChatError::Empty)
));
}
}
src/store.rs
//! Saving the conversation to a file and reading it back, as JSON.
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.json";
 
/// 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) {
// `?` turns a JSON error into an io::Error for us (serde_json says how).
Ok(text) => Ok(serde_json::from_str(&text)?),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e),
}
}
 
/// Write the whole history to the file as pretty-printed JSON.
pub fn save(path: &str, history: &[Message]) -> Result<(), io::Error> {
let text = serde_json::to_string_pretty(history)?;
fs::write(path, text)
}
Cargo.toml
[package]
name = "chatterbox"
version = "1.0.0"
edition = "2024"
 
[dependencies]
reqwest = { version = "0.13", features = ["blocking", "json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

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

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

Questions people ask

Does serde fail if the JSON has extra fields?

No, not by default. serde ignores every field in the JSON that your struct does not name, so you only declare the ones you read. It does fail when a field your struct names is missing.

Why does the ? operator say it cannot convert the error?

The function returns one error type and the failing call produces another. Write an impl From block that builds your error type out of the other one, and ? will use it automatically.

Can I test the HTTP call on the Rust Playground?

No. The Playground has no network access. Test the parts on either side of the call, building the request JSON and reading the reply JSON, which are plain functions, and run the call itself on your own machine.

Do I need both Serialize and Deserialize on every struct?

Only on the ones you both write and read, such as a saved message. A request that is only sent needs Serialize, and a reply that is only read needs Deserialize.

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