Vibe Engines
YouTube

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

Learn RustChapter 10: Streaming replies

Lesson 10.1 · 14 min

Waiting without freezing

Chatterbox v1.0 can talk to a real model, and while it waits for the answer it is frozen: no first word, no way to stop it, nothing until the whole reply has arrived. Chapter 10 fixes that. This lesson learns the tool first, on small programs: async, .await and a runtime called tokio. Chatterbox itself does not change yet.

Adds to ChatterboxNothing new yet: async, .await and tokio on small programs.

Compiler errors you will meet E0728

Frozen while it waits

In lesson 9.2, Model::reply sends the conversation with reqwest::blocking. Blocking means what it sounds like: the program stops on that line until the whole answer has come back. A model writes its answer a few words at a time, and a long one can take many seconds, but Chatterbox sees none of it until the last word arrives. It cannot print the first words, and it cannot notice you pressing a key, because it is not doing anything. It is waiting.

Real chat apps show the answer as it is written. For that, a program has to wait for several things at once: the next piece of the answer, and you. Rust's way to do that is async code, code that can pause at a wait and let other work run meanwhile. This lesson teaches it on small programs; Chatterbox stays at v1.0 until the next lesson.

A recipe, not a meal

Put async in front of fn and the function changes in one surprising way: calling it does not run it. It hands back a future, a value that describes the work, like a recipe nobody has cooked yet. The work happens when you write .await after the future. .await means "do this now, and if it has to wait, pause here until it is done".

You have met this laziness before. In lesson 6.5, .map(...) on an iterator did nothing until .collect() asked for the results. A future is the same: nothing happens until something asks. (The #[tokio::main] line is explained just below.) Guess the first line this prints:

PredictMake a future, then await it
async fn greet(name: &str) -> String {
println!("greet starts");
format!("hi, {name}!")
}
 
#[tokio::main]
async fn main() {
let later = greet("Sam");
println!("future made");
let text = later.await;
println!("{text}");
}

What is the FIRST line this program prints?

  • future made
  • greet starts
  • hi, Sam!
Reveal the answer

The program prints: future made greet("Sam") only made the future, so future made comes first. The body of greet, its println! included, runs at later.await, and only then does hi, Sam! come back.

Who does the cooking: tokio

Something has to run futures: keep track of which ones are waiting, and wake each one up when what it waits for is ready. That something is called a runtime. The Rust language gives you async and .await but no runtime, so you pick one from a crate. The most used one is tokio.

The line #[tokio::main] above async fn main() is an attribute, like #[test] in lesson 7.3. It rewrites main so that it starts a tokio runtime and hands it your async main to run. Without it, nobody does the cooking, and the compiler says so. Here is the mistake almost everyone makes first: .await inside a plain main.

Compiler CoachThe compiler, translated

This is the exact output of the real compiler. Toggle the English, then pick a fix and see what actually happens.

async fn greet(name: &str) -> String {
format!("hi, {name}!")
}
 
fn main() {
let text = greet("Sam").await;
println!("{text}");
}
error[E0728]: `await` is only allowed inside `async` functions and blocks
 --> src/main.rs:6:29
  |
5 | fn main() {
  | --------- this is not `async`
6 |     let text = greet("Sam").await;
  |                             ^^^^^ only allowed inside `async` functions and blocks

E0728 in the compiler error atlas →

  1. `await` is only allowed inside `async` functions and blocks: .await means "pause here", and only async code has somewhere to pause to.
  2. this is not `async` points at fn main(): a plain function runs straight through from top to bottom, so it cannot stop halfway and pick up later.
  3. The greet function is fine. The mistake is where its future is awaited, not how it is written.
  4. For main, adding async alone is not enough, because something has to run an async main. That is the trap below.

Fixes that were compiled for real

  • Let tokio run an async main — compiles. <code>#[tokio::main]</code> starts the runtime, and <code>async fn main()</code> may now pause at <code>.await</code>.
  • Trap: async main with no runtime — still E0752. Marking <code>main</code> async without <code>#[tokio::main]</code> leaves nobody to run it. The compiler refuses with a different error: <code>main</code> is not allowed to be async on its own.

Two waits at once

Now the point of all this. tokio::time::sleep(Duration::from_millis(1000)) makes a future that is ready after 1000 milliseconds, one second. Duration is the standard library's type for a length of time. Instant::now() notes the current moment, so that start.elapsed() can tell how long has passed since, and .as_secs() gives that in whole seconds. Awaiting two sleeps one after the other takes two seconds, as you would expect:

One wait, then the other: each .await finishes before the next line starts
use std::time::{Duration, Instant};
use tokio::time::sleep;
 
async fn fetch(name: &str, ms: u64) -> String {
sleep(Duration::from_millis(ms)).await;
format!("{name} is ready")
}
 
#[tokio::main]
async fn main() {
let start = Instant::now();
let a = fetch("first answer", 1000).await;
let b = fetch("second answer", 1000).await;
println!("{a}");
println!("{b}");
println!("took {} s", start.elapsed().as_secs());
}
$ cargo run
first answer is ready
second answer is ready
took 2 s

tokio::join! is different. Like println!, it is a macro (the ! says so). It takes several futures, runs them all at once, waits until every one is done, and hands back their results together as a tuple (lesson 2.6). The same two one-second waits, now joined:

PredictTwo one-second waits, joined
use std::time::{Duration, Instant};
use tokio::time::sleep;
 
async fn fetch(name: &str, ms: u64) -> String {
sleep(Duration::from_millis(ms)).await;
format!("{name} is ready")
}
 
#[tokio::main]
async fn main() {
let start = Instant::now();
let (a, b) = tokio::join!(fetch("first answer", 1000), fetch("second answer", 1000));
println!("{a}");
println!("{b}");
println!("took {} s", start.elapsed().as_secs());
}

What is the LAST line this program prints?

  • took 2 s
  • took 1 s
  • took 0 s
Reveal the answer

The program prints: took 1 s Both sleeps start together and finish together, about one second later. An .await is where a future says "I cannot go on yet", and the runtime uses that moment to drive the other one. Neither wait has to finish before the other may start.

No second copy of the program ran. One runtime drove both futures, switching between them at their waits. That is why async is cheap: a future that is waiting costs almost nothing, so one program can have many waits going at once.

The one rule: never block inside async

A future can only step aside at an .await. Code that waits without one never steps aside. std::thread::sleep is the blocking kind of sleep: it stops the whole thread (the worker that runs your code, step by step), and with it every future that thread is driving. Swap it into the joined program and the join stops helping:

Joined, but each wait blocks: the second cannot start until the first lets go
use std::time::{Duration, Instant};
 
async fn fetch(name: &str, ms: u64) -> String {
std::thread::sleep(Duration::from_millis(ms)); // blocks: no .await, never steps aside
format!("{name} is ready")
}
 
#[tokio::main]
async fn main() {
let start = Instant::now();
let (a, b) = tokio::join!(fetch("first answer", 1000), fetch("second answer", 1000));
println!("{a}");
println!("{b}");
println!("took {} s", start.elapsed().as_secs());
}
$ cargo run
first answer is ready
second answer is ready
took 2 s
ProgramFirst waitSecond waitTotal
.await, then .await0 to 1 s1 to 2 s2 s
tokio::join! with tokio's sleep0 to 1 s0 to 1 s1 s
tokio::join! with std::thread::sleep0 to 1 s1 to 2 s2 s

This is Chatterbox's problem in miniature. reqwest::blocking waits the way std::thread::sleep does. reqwest also has an async side, where .send() gives back a future you .await, and the rest of this chapter switches to it.

The trait that will not fit in a box

So the plan for Chatterbox sounds simple: make reply an async fn in the Replier trait. Rust does allow async functions in traits. But Chatterbox keeps its replier as Box<dyn Replier> (lesson 8.4), so that /model can swap the echo, the pretend model and the real one while it runs. Here is a cut-down version of that. Will it still compile?

PredictAn async fn behind Box<dyn>
trait Replier {
async fn reply(&self, message: &str) -> String;
}
 
struct Echo;
 
impl Replier for Echo {
async fn reply(&self, message: &str) -> String {
format!("you said: {message}")
}
}
 
fn main() {
let replier: Box<dyn Replier> = Box::new(Echo);
println!("chosen");
let _ = replier;
}

Does it compile? You do not need to know any error number: the choice is between running, crashing while it runs, and the compiler refusing.

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

It does not compile: E0038. The compiler refuses: the trait `Replier` is not dyn compatible. Dyn compatible means "can be used behind Box<dyn …>". The note mentions a vtable: the table of methods a Box<dyn Replier> uses to find the right function while the program runs. dyn Replier means "some Replier, decided while the program runs", so every method must be callable without knowing the exact type. An async fn returns a future whose type the compiler invents for each implementor: one for Echo, another for Pretend, each with its own size. Code that holds only the box cannot know which future it would get back, and the note names the cause: method `reply` is `async`.

error[E0038]: the trait `Replier` is not dyn compatible
  --> src/main.rs:14:26
   |
14 |     let replier: Box<dyn Replier> = Box::new(Echo);
   |                          ^^^^^^^ `Replier` is not dyn compatible
   |
note: for a trait to be dyn compatible it needs to allow building a vtable
      for more information, visit <https://doc.rust-lang.org/reference/items/traits.html#dyn-compatibility>
  --> src/main.rs:2:14
   |
 1 | trait Replier {
   |       ------- this trait is not dyn compatible...
 2 |     async fn reply(&self, message: &str) -> String;
   |              ^^^^^ ...because method `reply` is `async`
   = help: consider moving `reply` to another trait
   = help: only type `Echo` implements `Replier`; consider using it directly instead.

That is the problem the next lesson solves. It keeps reply a plain function that returns at once, and what it returns is a channel: a pipe the answer arrives through later, piece by piece. Every replier returns the same kind of channel, so Box<dyn Replier> works again, and each word can be printed the moment it comes out of the pipe.

Try itYour turn: a pretend model that waits politely

A warm-up, separate from Chatterbox. Fill in pretend_reply: wait ms milliseconds without blocking, then answer you said: … (or say something first for an empty message). Then fill in two_replies so that both replies are worked on at the same time. The hidden tests are #[tokio::test] functions: #[test], but run inside a tokio runtime so they may be async and use .await.

A warm-up, separate from Chatterbox. Fill in pretend_reply: wait ms milliseconds without blocking, then answer you said: … (or say something first for an empty message). Then fill in two_replies so that both replies are worked on at the same time. The hidden tests are #[tokio::test] functions: #[test], but run inside a tokio runtime so they may be async and use .await.

use std::time::Duration;
use tokio::time::sleep;
 
/// Chatterbox's pretend model, made async: think for `ms` milliseconds
/// without blocking anything else, then answer like the echo does.
/// An empty message gets "say something first".
async fn pretend_reply(message: &str, ms: u64) -> String {
todo!("wait for ms milliseconds, then answer {message:?} after {ms} ms")
}
 
/// Ask for two replies at the same time and hand back both, in order.
async fn two_replies(first: &str, second: &str) -> (String, String) {
todo!("start both replies to {first:?} and {second:?}, then wait for both")
}
 
#[tokio::main]
async fn main() {
let (a, b) = two_replies("hi", "how are you?").await;
println!("bot: {a}");
println!("bot: {b}");
}

Hidden tests (5): an_empty_message_gets_a_nudge answers_like_the_echo it_really_waits the_wait_steps_aside both_replies_come_back_in_order

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

If you have coded before

Python's asyncio has the same shape: async def is async fn, await is .await, asyncio.run(main()) is the runtime that #[tokio::main] starts, and asyncio.gather plays the part of tokio::join!. Calling an async def function also gives back an object that does nothing until it is awaited. In JavaScript, a promise starts running as soon as it is made.

Self-check

Say it out loud, then reveal

Self-checkRetrieval beats re-reading
What does calling an <code>async fn</code> give you, and when does its body run?Reveal
A future: a description of the work. The body runs when the future is awaited (or otherwise driven by a runtime), not when the function is called.
Why does <code>.await</code> inside a plain <code>fn main</code> fail with E0728, and what is the fix?Reveal
Only async functions and blocks can pause at .await. Write #[tokio::main] above async fn main() so tokio runs it.
Two one-second sleeps inside <code>tokio::join!</code> take about one second. Why does the same program with <code>std::thread::sleep</code> take two?Reveal
tokio's sleep pauses at an .await, so the runtime drives the other future meanwhile. std::thread::sleep blocks the whole thread, so nothing else runs until it ends.
Why can a trait with an <code>async fn</code> not be used as <code>Box&lt;dyn Trait&gt;</code>?Reveal
Each implementor's async fn returns its own future type, with its own size, so code that only knows "some implementor" cannot know what it gets back. The trait is not dyn compatible (E0038).
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 10.1. Tick it off so the course remembers where you are.

Questions people ask

What does async mean in Rust?

An async function can pause while it waits for something, such as a timer or a network reply, and let other work run in the meantime. Calling it gives back a future, which does nothing until it is awaited.

What does .await do in Rust?

It runs a future and, if the future has to wait, pauses the current async function at that point until the future is ready. While it is paused, the runtime can run other futures.

What is tokio?

Tokio is the most widely used async runtime for Rust. Rust has async and await in the language but no built-in runtime; tokio runs futures and provides async timers, input and output, and network tools. You start it by writing #[tokio::main] above async fn main.

What is Rust error E0728?

E0728 means .await was used outside async code, for example in a plain fn main. Mark the function async, and for main also add #[tokio::main] so a runtime runs it.

Can I use std::thread::sleep in async code?

It compiles, but it blocks the whole thread, so every other future on that thread stops too. Use tokio::time::sleep with .await instead.

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