Vibe Engines
YouTube

Lesson 7.4 · 11 min

Saving and loading

Close Chatterbox and the whole conversation is gone. A file on disk fixes that, and reading and writing it is one more thing that can fail, which is exactly what Result and ? are for.

Adds to ChatterboxHistory survives a restart.

Two functions and a file

The standard library's std::fs module has the two functions we need. fs::write(path, text) puts text in a file, replacing whatever was there. fs::read_to_string(path) gives the whole file back as a String. Both return a Result with an io::Error as the reason, because disks fill up, folders go missing and permissions say no.

Files hold text, but our history is a list of Message values. So we need two small translators: one that turns the list into lines of text, and one that turns lines back into the list. Each line looks like user: hello: the author, a colon and a space, then the message.

These two functions touch no files, so you can test them anywhere. That is deliberate: keep the part that can be checked separate from the part that talks to the disk.

Try itYour turn: history to text and back

Write to_lines (one line per message, like user: hello, each ending in a newline) and from_lines (the reverse). A line with no colon-and-space is skipped. split_once(": ") splits at the first colon-and-space only, so a message that itself contains a colon survives.

Write to_lines (one line per message, like user: hello, each ending in a newline) and from_lines (the reverse). A line with no colon-and-space is skipped. split_once(": ") splits at the first colon-and-space only, so a message that itself contains a colon survives.

#[derive(Debug, Clone, PartialEq)]
struct Message {
author: String,
text: String,
}
 
fn to_lines(history: &[Message]) -> String {
// one "role: text" line per message
String::new()
}
 
fn from_lines(s: &str) -> Vec<Message> {
// the reverse; skip lines you do not understand
Vec::new()
}
 
fn main() {
let h = vec![
Message { author: String::from("user"), text: String::from("hi") },
Message { author: String::from("assistant"), text: String::from("hello!") },
];
let text = to_lines(&h);
print!("{text}");
println!("{}", from_lines(&text) == h);
}

Hidden tests (4): colon_inside_the_text_survives unknown_lines_are_skipped round_trip writes_one_line_per_message

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

Talking to the disk

This listing is trimmed to the part this lesson is about; the commands from earlier chapters (/say, /history, /stats, /long) and the per-author counts are still in your project.

Now the thin layer on top. save is one line. load has one subtlety: the very first time Chatterbox runs there is no log file, and that is not an error, it just means "no history yet". So we look at the kind of error. A NotFound becomes an empty history; any other problem is passed up with Err. The if after Err(e) is a guard: that arm only matches when its condition is true.

Finally main returns Result<(), io::Error> so ? works in it (lesson 7.2). A failed load or save stops the program with the error instead of being ignored.

Chatterbox v0.7: history persists across runs
use std::fs;
use std::io;
 
const LOG: &str = "chatterbox.log";
 
#[derive(Debug, Clone, PartialEq)]
struct Message {
author: String,
text: String,
}
 
fn to_lines(history: &[Message]) -> String {
let mut out = String::new();
for m in history {
out.push_str(&format!("{}: {}\n", m.author, m.text));
}
out
}
 
fn from_lines(s: &str) -> Vec<Message> {
let mut out = Vec::new();
for line in s.lines() {
if let Some((author, text)) = line.split_once(": ") {
out.push(Message { author: author.to_string(), text: text.to_string() });
}
}
out
}
 
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),
}
}
 
fn save(path: &str, history: &[Message]) -> Result<(), io::Error> {
fs::write(path, to_lines(history))
}
 
fn main() -> Result<(), io::Error> {
let mut history = load(LOG)?;
println!("Chatterbox v0.7: {} saved messages", history.len());
loop {
let mut input = String::new();
let bytes = io::stdin().read_line(&mut input)?;
let line = input.trim();
if bytes == 0 || line == "/quit" {
break;
}
if line.is_empty() {
continue;
}
history.push(Message { author: "user".to_string(), text: line.to_string() });
history.push(Message { author: "assistant".to_string(), text: format!("you said: {line}") });
}
save(LOG, &history)?;
println!("saved {} messages to {LOG}", history.len());
Ok(())
}

This one reads from the keyboard and writes a file, so it needs a real terminal. Here it is run twice in a project folder. The second run starts by loading what the first one saved.

Two runs, one log file
$ printf 'hello\nwhat is a token?\n/quit\n' | cargo run -q
Chatterbox v0.7: 0 saved messages
saved 4 messages to chatterbox.log
$ cat chatterbox.log
user: hello
assistant: you said: hello
user: what is a token?
assistant: you said: what is a token?
$ printf 'again\n/quit\n' | cargo run -q
Chatterbox v0.7: 4 saved messages
saved 6 messages to chatterbox.log

What you have now

Chatterbox remembers. Start it, talk, quit, start it again: the history is still there. Along the way you used Result to say what can fail, ? to pass failures up, a module-sized habit of keeping testable code apart from disk code, and a real file. Chapter 8 gives the bot something to say back: a real model.

Self-check

Say it out loud, then reveal

Self-checkRetrieval beats re-reading
Why is a missing log file handled differently from other io errors?Reveal
The first run has no file yet, which just means an empty history. Other errors (permissions, a broken disk) are real problems and are passed up.
Why does main return Result<(), io::Error>?Reveal
So ? can be used in main. A failed load or save then ends the program with the error instead of being silently ignored.
Why keep to_lines and from_lines separate from the file functions?Reveal
They touch no disk, so they can be tested anywhere with plain strings.

That is lesson 7.4. Tick it off so the course remembers where you are.

Next: 8.1 JSON with serde →
Learn Rust by building a chat app · Lesson 7.4 · every compiler message above is real output from rustc 1.99.0 (b940084d7 2026-09-28) (edition 2024) · Vibe Engines · 2026