Build a CLI Memory Companion in Rust
Build a Rust CLI tool that stores and recalls facts using SQLite.
Introduction
30 Second Summary
Think about how you remember things about the people you talk to. Their name, where they live, how they're feeling today. What if your terminal could do the same thing?
In this project, you will build a CLI tool called Mira that remembers facts about you across terminal sessions. She stores everything in a local SQLite database, so her memory persists even after you close the terminal.
What You'll Build
A Rust command-line companion with three subcommands that stores and retrieves personal facts using a local SQLite database.
By the end of this project, you'll have:
- A working Rust binary with three subcommands: chat, memory, and forget.
- A local SQLite database with two tables persisting conversation history and extracted facts between sessions.
- Hands-on experience with rusqlite: opening connections, inserting rows, querying data, and performing upserts with INSERT OR REPLACE.
- Secret Mission: Add a who command that prints a formatted identity card of everything Mira knows about you.
Are there any prerequisites?
You should have basic familiarity with Rust syntax (variables, functions, match statements). No database experience is needed.
Set Up Your Rust Project
Before Mira can remember anything about you, she needs a home. That home is a Rust binary project with two key dependencies: one for talking to a SQLite database, and one for parsing terminal commands.
In this step, you will verify your Rust installation, scaffold a new project, and pull in the two crates that power everything Mira does.
In this step, get ready to:
- Verify your Rust toolchain is recent enough for the project.
- Scaffold a new binary project with Cargo.
- Add the rusqlite and clap dependencies and confirm compilation.
Verify your Rust installation
The clap crate (which handles CLI parsing) requires Rust 1.85 or higher. A quick version check confirms your toolchain is ready.
- Check your Rust compiler version by running this command in your terminal:
rustc --version
You should see output like rustc 1.85.0 or higher. The exact patch number does not matter as long as the major.minor is at least 1.85.
Seeing a version lower than 1.85?
Update your toolchain by running rustup update stable in your terminal, then re-run the version check.
If you see "command not found", install Rust from the official site at https://rustup.rs and restart your terminal.
Help me troubleshoot my Rust installation
Create the mira project
Cargo is Rust's build tool and package manager. One command scaffolds a complete binary project with a Cargo.toml manifest and a src/main.rs entry point.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create a new Rust binary project and move into it by running these commands:
cargo new mira
cd mira
- Confirm the project files exist by running:
ls
You should see Cargo.toml and a src folder listed in the output.
What did cargo new create?
Cargo.toml is the project manifest. It declares your project's name, version, and dependencies.
src/main.rs is the entry point for your binary. Right now it just prints "Hello, world!" to prove the project compiles.
Don't see Cargo.toml?
Make sure you ran cd mira after cargo new mira. The ls command shows files in your current directory, and you need to be inside the mira folder.
Help me find my mira project folder
Add project dependencies
Mira needs two external crates. rusqlite gives Rust the ability to talk to a SQLite database. clap handles parsing the subcommands you type in the terminal.
- Open the mira folder in Cursor. Press Cmd+O (macOS) or Ctrl+O (Windows), then navigate to Desktop > mira and click Open.
- In the Explorer sidebar, click Cargo.toml to open it.
- Replace the entire contents of Cargo.toml with the following:
[package]
name = "mira"
version = "0.1.0"
edition = "2021"
[dependencies]
rusqlite = { version = "0.39.0", features = ["bundled"] }
clap = { version = "4.6.1", features = ["derive"] }
What do the features do?
The bundled feature on rusqlite compiles SQLite directly from source and links it into your binary. This means you do not need SQLite installed separately on your system.
The derive feature on clap enables the #[derive(Parser)] macro. This lets you define your CLI interface as a Rust struct instead of writing verbose builder code.
- Save Cargo.toml.
- Build the project to download and compile all dependencies by running this command in your terminal:
cargo build
This might take a minute or two
The bundled feature compiles SQLite from C source code. The first build downloads crates and compiles everything from scratch. Future builds will be much faster because Cargo caches compiled dependencies.
Once it finishes, you should see a line ending in Finished with no errors. This confirms both crates downloaded and compiled successfully.
Seeing compilation errors?
Double check your Cargo.toml for typos. The feature names must be exactly bundled and derive (lowercase, inside square brackets).
On Linux, if you see a C compiler error, you may need to install build essentials. On macOS and Windows this is handled automatically.
Help me fix my cargo build errors
✔️ Awesome, I've got everything!
Great. Double check you have saved Cargo.toml and that your build completed without errors.
ⓧ I'd like to double check the full code
Here is what your Cargo.toml should look like:
[package]
name = "mira"
version = "0.1.0"
edition = "2021"
[dependencies]
rusqlite = { version = "0.39.0", features = ["bundled"] }
clap = { version = "4.6.1", features = ["derive"] }
And here is what src/main.rs should look like (untouched from the scaffold):
fn main() {
println!("Hello, world!");
}
Your project skeleton is ready and both dependencies compile cleanly. Next up, you will define Mira's CLI interface so she can understand the commands you type.
Define the CLI with Clap
Your project compiles and all dependencies are in place. Now it's time to give Mira a way to understand what you're asking her to do.
Mira needs three subcommands: one for chatting, one for recalling memories, and one for forgetting everything. The clap crate lets you declare these as a Rust enum, and it handles all the argument parsing for you.
In this step, get ready to:
- Define the Cli struct and Commands enum using clap's derive macros.
- Wire up main() to parse arguments and dispatch commands.
- Test all three subcommands from the terminal.
Define the Cli struct and Commands enum
Clap's derive macros let you describe your CLI as regular Rust types. A Parser struct represents the top-level app, and a Subcommand enum lists each available command.
- In Cursor, open src/main.rs
- Select all the existing code and replace it with this:
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "mira", about = "Your CLI memory companion")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Talk to Mira
Chat {
/// Your message
message: String,
},
/// Show recent conversations
Memory,
/// Wipe Mira's memory
Forget,
}
What does this code do?
The Cli struct is the entry point for argument parsing. The #[command(name = "mira")] attribute sets the binary name that appears in help text.
The Commands enum declares three subcommands. Chat takes a required message argument, while Memory and Forget take no arguments. The /// doc comments become the help descriptions for each command.
You won't be able to compile yet because there's no main() function. The next chunk adds it.
Wire up main() to parse arguments
With the types defined, you need a main() function that calls Cli::parse() and uses a match statement to dispatch each command.
- Add this main() function below the Commands enum:
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Chat { message } => println!("Chat: {}", message),
Commands::Memory => println!("Memory"),
Commands::Forget => println!("Forget"),
}
}
What does this code do?
Cli::parse() reads the command-line arguments and populates the Cli struct automatically. If the user passes invalid arguments, clap prints an error and exits before your code runs.
The match statement destructures whichever variant the user invoked. For now, each branch prints a placeholder so you can verify the plumbing works.
- Save src/main.rs.
- Confirm it compiles by running:
cargo build
You should see a Compiling mira message followed by Finished with no errors.
Seeing a compile error?
- Make sure the use clap::{Parser, Subcommand}; import is at the very top of the file.
- Check that Cargo.toml has features = ["derive"] on the clap dependency. Without it, the #[derive(Parser)] macro is unavailable.
- Verify you deleted the old fn main() that printed hello world. Rust only allows one main() function per binary.
Why won't my code compile after adding clap?
Test the subcommands
Each subcommand should print its placeholder text when you run it. The -- separator tells cargo that everything after it is for your binary, not for cargo itself.
- Test the chat subcommand by running:
cargo run -- chat "hello"
You should see Chat: hello printed to the terminal.
- Test the memory subcommand by running:
cargo run -- memory
You should see Memory printed.
- Test the forget subcommand by running:
cargo run -- forget
You should see Forget printed.
What about invalid commands?
Try running cargo run -- dance and you'll see clap automatically prints an error message listing the valid subcommands. You get input validation for free.
Seeing an unrecognized subcommand error?
- Make sure you include the -- separator between cargo run and your subcommand. Without it, cargo tries to interpret chat as a cargo flag.
- Check that your message is wrapped in quotes: "hello". Without quotes, the shell may split it into multiple arguments.
Why is cargo not recognizing my subcommand?
✔️ Awesome, I've got everything!
Make sure you've saved src/main.rs and all three commands print their placeholder text.
ⓧ I'd like to double check the full code
Here's what your src/main.rs should look like at this point:
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "mira", about = "Your CLI memory companion")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Talk to Mira
Chat {
/// Your message
message: String,
},
/// Show recent conversations
Memory,
/// Wipe Mira's memory
Forget,
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Chat { message } => println!("Chat: {}", message),
Commands::Memory => println!("Memory"),
Commands::Forget => println!("Forget"),
}
}
Mira can hear you now, but she can't remember anything yet. Next up, you'll create the SQLite database where Mira stores her memories.
Create the Database and Tables
Your CLI structure is working. Mira can parse subcommands and print placeholder messages. But right now she has no memory at all.
For Mira to remember anything, she needs a database. In this step, you will create a local SQLite database that lives right next to your binary. Every time Mira starts, she opens this database and makes sure her tables exist.
In this step, get ready to:
- Write helper functions that open a SQLite connection and enable WAL mode.
- Define two tables: one for conversation episodes and one for remembered facts.
- Verify that running a command creates the database file on disk.
Write the database helper functions
Mira needs two things to talk to SQLite: a path to the database file, and a function that opens a connection and prepares the tables.
- In src/main.rs, find the existing import line at the very top:
use clap::{Parser, Subcommand};
- Add a new import line directly below it so that both lines look like this:
use clap::{Parser, Subcommand};
use rusqlite::{Connection, Result as SqlResult};
What are these imports?
Connection is the rusqlite type that represents an open SQLite database. Result as SqlResult renames rusqlite's Result type so it does not clash with Rust's standard Result.
- Now scroll down past the Commands enum. Add the following two functions between the closing } of the enum and the fn main() line:
fn db_path() -> String {
// Return the file path for Mira's database
"mira.db".to_string()
}
fn open_db() -> SqlResult<Connection> {
// Open (or create) the SQLite database file
let conn = Connection::open(db_path())?;
// Enable Write-Ahead Logging for better performance
conn.pragma_update(None, "journal_mode", "wal")?;
// Create both tables if they don't already exist
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS episodes (
id INTEGER PRIMARY KEY,
message TEXT NOT NULL,
timestamp TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS facts (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);"
)?;
Ok(conn)
}
What does this code do?
db_path() returns the string "mira.db". This keeps the database in whatever directory you run the command from.
open_db() does three things in sequence. First, it opens (or creates) the database file. Then it enables WAL mode, which is SQLite's recommended journal mode for faster writes. Finally, it runs execute_batch to create both tables if they do not already exist.
The episodes table logs every message the user sends, along with a timestamp. The facts table stores key-value pairs (like user_name = Pratay) so Mira can remember structured information across sessions.
Why WAL mode?
WAL stands for Write-Ahead Logging. It is SQLite's recommended journal mode for most applications because it allows reads and writes to happen concurrently without blocking each other. For a CLI tool, it also means slightly faster writes since the data goes to a log file before being merged into the main database.
- Save src/main.rs.
Seeing a red underline on Connection or SqlResult?
Make sure your use rusqlite::{Connection, Result as SqlResult}; line is at the top of the file, directly below the clap import. A missing semicolon or typo in the import will cause the compiler to not recognize these types.
help me fix a rusqlite import error in my Rust file
Call open_db from main
The database functions exist, but nothing calls them yet. You need to open the database at the start of main() so the connection is ready before Mira processes any command.
- In src/main.rs, find your existing main() function. It currently looks like this:
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Chat { message } => println!("Chat: {}", message),
Commands::Memory => println!("Memory"),
Commands::Forget => println!("Forget"),
}
}
- Replace the entire main() function with this updated version that calls open_db() and handles errors:
fn main() {
let cli = Cli::parse();
// Open the database connection, exit if it fails
let conn = match open_db() {
Ok(c) => c,
Err(e) => {
eprintln!("Failed to open database: {}", e);
std::process::exit(1);
}
};
match cli.command {
Commands::Chat { message } => println!("Chat: {}", message),
Commands::Memory => println!("Memory"),
Commands::Forget => println!("Forget"),
}
}
What does this change?
The new code calls open_db() and pattern-matches on the result. If the database opens successfully, the connection is stored in conn. If it fails, the program prints an error message and exits with code 1.
The conn variable is not used yet (the match arms still print placeholders), but it will be essential in the next step when you implement the actual chat, memory, and forget logic.
- Save src/main.rs.
Compiler warning about unused variable conn?
This is expected. Rust warns you about variables you declare but never use. You will use conn in the next step. The warning disappears once you pass the connection to your command handlers.
help me understand this unused variable warning in Rust
Verify the database file appears
Time to confirm everything works. When you run any Mira command, open_db() should create the mira.db file in your project directory.
- Run a test command by entering:
cargo run -- chat "test"
- Confirm the database file exists by running:
ls mira.db
- You should see mira.db printed in the output. This means SQLite created the file and your tables are ready.
Don't see mira.db?
Make sure you are running the command from inside the mira/ project directory. The database is created relative to wherever you run cargo run.
If you see a compile error instead, check that your Cargo.toml includes rusqlite with the bundled feature. Without bundled, the build will fail looking for system SQLite libraries.
help me fix a cargo build error with rusqlite in my Mira project
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/main.rs and confirmed mira.db exists in your project folder.
ⓧ I'd like to double check the full code
use clap::{Parser, Subcommand};
use rusqlite::{Connection, Result as SqlResult};
#[derive(Parser)]
#[command(name = "mira", about = "Your CLI memory companion")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Talk to Mira
Chat {
/// Your message
message: String,
},
/// Show recent conversations
Memory,
/// Wipe Mira's memory
Forget,
}
fn db_path() -> String {
"mira.db".to_string()
}
fn open_db() -> SqlResult<Connection> {
let conn = Connection::open(db_path())?;
conn.pragma_update(None, "journal_mode", "wal")?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS episodes (
id INTEGER PRIMARY KEY,
message TEXT NOT NULL,
timestamp TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS facts (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);"
)?;
Ok(conn)
}
fn main() {
let cli = Cli::parse();
let conn = match open_db() {
Ok(c) => c,
Err(e) => {
eprintln!("Failed to open database: {}", e);
std::process::exit(1);
}
};
match cli.command {
Commands::Chat { message } => println!("Chat: {}", message),
Commands::Memory => println!("Memory"),
Commands::Forget => println!("Forget"),
}
}
Mira now has a home for her memories. Every time she starts, she opens mira.db and ensures both tables are ready. Next up, you will teach her to actually save messages and extract facts from what you say.
Implement the Chat Command
Your database is ready and waiting. Now it is time to make Mira actually do something when you talk to her.
In this step, you will build the core chat logic. When a user sends a message, Mira will log it, scan it for personal facts, and respond with something relevant.
In this step, get ready to:
- Write a handle_chat function that inserts messages into the episodes table with timestamps.
- Add fact extraction logic that detects patterns like "my name is" and upserts into the facts table.
- Add response generation that queries stored facts or returns a friendly default reply.
Write the handle_chat function
Every message the user sends to Mira needs to be recorded with a timestamp. This creates a conversation history that Mira can look back on later.
- In src/main.rs, find the existing use statements at the top of the file.
- Replace the two import lines with the following updated imports:
use clap::{Parser, Subcommand};
use rusqlite::{params, Connection, Result as SqlResult};
use std::time::{SystemTime, UNIX_EPOCH};
What changed in the imports?
You added params to the rusqlite import. This macro makes it easy to pass parameters to SQL queries without worrying about type conversions.
You also brought in SystemTime and UNIX_EPOCH from the standard library. These let you generate timestamps without adding an external dependency.
- Below the open_db() function, add the now_timestamp() helper and the handle_chat() function by pasting this code:
fn now_timestamp() -> String {
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap();
let secs = duration.as_secs();
let hours = (secs / 3600) % 24;
let minutes = (secs / 60) % 60;
let seconds = secs % 60;
format!("{:02}:{:02}:{:02} UTC", hours, minutes, seconds)
}
fn handle_chat(conn: &Connection, message: &str) -> SqlResult<()> {
// Record the message with a timestamp
let timestamp = now_timestamp();
conn.execute(
"INSERT INTO episodes (message, timestamp) VALUES (?1, ?2)",
params![message, timestamp],
)?;
// Extract any personal facts from the message
extract_facts(conn, message)?;
// Generate and print a response
let response = generate_response(conn, message)?;
println!("Mira: {}", response);
Ok(())
}
What does this code do?
The now_timestamp() function calculates the current UTC time from the system clock. It formats it as HH:MM:SS UTC using basic arithmetic on the seconds since the Unix epoch.
The handle_chat() function is the orchestrator for every chat message. It inserts the message into the episodes table, extracts any facts, and generates a response. The params! macro binds message and timestamp to the ?1 and ?2 placeholders in the SQL.
You will see compiler errors right now because extract_facts and generate_response do not exist yet. You will add them in the next two substeps.
Add fact extraction logic
Mira needs to pick up on personal details you share. When you say something like "my name is Alex", she should store that fact so she can recall it later.
- In src/main.rs, add the following three functions between now_timestamp() and handle_chat():
fn extract_facts(conn: &Connection, message: &str) -> SqlResult<()> {
let lower = message.to_lowercase();
// Check for known patterns and store the extracted value
if let Some(name) = extract_after(&lower, "my name is ") {
upsert_fact(conn, "user_name", &name)?;
}
if let Some(city) = extract_after(&lower, "i live in ") {
upsert_fact(conn, "user_city", &city)?;
}
if let Some(mood) = extract_after(&lower, "i'm feeling ") {
upsert_fact(conn, "user_mood", &mood)?;
}
if let Some(mood) = extract_after(&lower, "im feeling ") {
upsert_fact(conn, "user_mood", &mood)?;
}
Ok(())
}
fn extract_after(text: &str, prefix: &str) -> Option<String> {
// Find the prefix in the text, then grab everything after it
if let Some(start) = text.find(prefix) {
let after = &text[start + prefix.len()..];
let value = after.split(|c: char| c == '.' || c == ',' || c == '!')
.next()
.unwrap_or("")
.trim()
.to_string();
if !value.is_empty() {
return Some(value);
}
}
None
}
fn upsert_fact(conn: &Connection, key: &str, value: &str) -> SqlResult<()> {
// INSERT OR REPLACE overwrites the old value if the key already exists
conn.execute(
"INSERT OR REPLACE INTO facts (key, value) VALUES (?1, ?2)",
params![key, value],
)?;
Ok(())
}
What does this code do?
The extract_facts() function lowercases the message and checks for known prefixes like "my name is " or "i live in ". When it finds a match, it passes the extracted value to upsert_fact().
The extract_after() helper locates a prefix in the text, then grabs everything after it up to the first punctuation mark. This way "my name is Alex." extracts just alex.
The upsert_fact() function uses INSERT OR REPLACE. Since key is the primary key in the facts table, this overwrites the old value if you tell Mira your name again.
What is an UPSERT?
An UPSERT is a database operation that inserts a new row or updates an existing one if a conflict is detected. In SQLite, INSERT OR REPLACE deletes the conflicting row and inserts the new one. This keeps your facts table tidy with one value per key.
One more function to go. The code still will not compile because generate_response is missing.
Add response generation and wire up main
Mira should respond differently depending on what you ask. If you ask "who am i", she should recall your name. Otherwise, she picks a friendly default reply.
- In src/main.rs, add the following two functions between upsert_fact() and handle_chat():
fn get_fact(conn: &Connection, key: &str) -> SqlResult<Option<String>> {
// Query a single fact by key, returning None if it doesn't exist
let result = conn.query_row(
"SELECT value FROM facts WHERE key = ?1",
params![key],
|row| row.get(0),
);
match result {
Ok(val) => Ok(Some(val)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e),
}
}
fn generate_response(conn: &Connection, message: &str) -> SqlResult<String> {
let lower = message.to_lowercase();
// Check if the user is asking about themselves
if lower.contains("who am i") {
if let Some(name) = get_fact(conn, "user_name")? {
return Ok(format!("You're {}! I remember.", name));
} else {
return Ok("I don't know your name yet. Tell me!".to_string());
}
}
if lower.contains("where do i live") {
if let Some(city) = get_fact(conn, "user_city")? {
return Ok(format!("You live in {}!", city));
} else {
return Ok("You haven't told me where you live yet.".to_string());
}
}
// Default to a friendly hardcoded response
let responses = [
"Hmm, interesting. Tell me more.",
"Got it. I'll remember that.",
"Okay! What else?",
"Noted. Anything else on your mind?",
"I see. Keep talking, I'm listening.",
];
let index = message.len() % responses.len();
Ok(responses[index].to_string())
}
What does this code do?
The get_fact() function queries the facts table for a single key. It uses query_row and matches on the result. If no row matches, it returns Ok(None) instead of an error.
The generate_response() function checks if the user is asking a question Mira can answer from stored facts. If not, it picks a default reply based on the message length modulo the number of available responses. This gives variety without randomness.
Now you need to update main() to call handle_chat instead of the placeholder.
- In src/main.rs, find the match cli.command block at the bottom of main().
- Find this code:
match cli.command {
Commands::Chat { message } => println!("Chat: {}", message),
Commands::Memory => println!("Memory"),
Commands::Forget => println!("Forget"),
}
- Replace it with the following code that calls handle_chat and handles errors from all commands:
let result = match cli.command {
Commands::Chat { message } => handle_chat(&conn, &message),
Commands::Memory => { println!("Memory"); Ok(()) },
Commands::Forget => { println!("Forget"); Ok(()) },
};
if let Err(e) = result {
eprintln!("Error: {}", e);
std::process::exit(1);
}
Why wrap match arms in a result?
Since handle_chat returns SqlResult<()>, every match arm needs to return the same type. The Memory and Forget arms wrap their placeholder prints in Ok(()) to keep the types aligned. You will replace these with real implementations in the next step.
- Save src/main.rs.
- Test the chat command by running:
cargo run -- chat "my name is Alex"
- You should see a response from Mira like Mira: Got it. I'll remember that. (the exact reply depends on message length).
- Now ask Mira if she remembers you by running:
cargo run -- chat "who am i"
- You should see Mira: You're alex! I remember. printed to the terminal.
Seeing a compile error?
Make sure you placed the functions in the correct order. The file should read: imports, then Cli/Commands definitions, then db_path, open_db, now_timestamp, extract_facts, extract_after, upsert_fact, get_fact, generate_response, handle_chat, then main.
Check that you added params to the rusqlite use statement. Without it, the params! macro calls will fail.
If you see an error about SystemTime or UNIX_EPOCH not being found, confirm the use std::time::{SystemTime, UNIX_EPOCH}; line is at the top.
Help me fix a compile error in my Mira chat command
✔️ Awesome, I've got everything!
Great. Double check you have saved src/main.rs.
ⓧ I'd like to double check the full code
Here is the complete src/main.rs at this point:
use clap::{Parser, Subcommand};
use rusqlite::{params, Connection, Result as SqlResult};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Parser)]
#[command(name = "mira", about = "Your CLI memory companion")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Talk to Mira
Chat {
/// Your message
message: String,
},
/// Show recent conversations
Memory,
/// Wipe Mira's memory
Forget,
}
fn db_path() -> String {
"mira.db".to_string()
}
fn open_db() -> SqlResult<Connection> {
let conn = Connection::open(db_path())?;
conn.pragma_update(None, "journal_mode", "wal")?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS episodes (
id INTEGER PRIMARY KEY,
message TEXT NOT NULL,
timestamp TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS facts (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);"
)?;
Ok(conn)
}
fn now_timestamp() -> String {
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap();
let secs = duration.as_secs();
let hours = (secs / 3600) % 24;
let minutes = (secs / 60) % 60;
let seconds = secs % 60;
format!("{:02}:{:02}:{:02} UTC", hours, minutes, seconds)
}
fn extract_facts(conn: &Connection, message: &str) -> SqlResult<()> {
let lower = message.to_lowercase();
if let Some(name) = extract_after(&lower, "my name is ") {
upsert_fact(conn, "user_name", &name)?;
}
if let Some(city) = extract_after(&lower, "i live in ") {
upsert_fact(conn, "user_city", &city)?;
}
if let Some(mood) = extract_after(&lower, "i'm feeling ") {
upsert_fact(conn, "user_mood", &mood)?;
}
if let Some(mood) = extract_after(&lower, "im feeling ") {
upsert_fact(conn, "user_mood", &mood)?;
}
Ok(())
}
fn extract_after(text: &str, prefix: &str) -> Option<String> {
if let Some(start) = text.find(prefix) {
let after = &text[start + prefix.len()..];
let value = after.split(|c: char| c == '.' || c == ',' || c == '!')
.next()
.unwrap_or("")
.trim()
.to_string();
if !value.is_empty() {
return Some(value);
}
}
None
}
fn upsert_fact(conn: &Connection, key: &str, value: &str) -> SqlResult<()> {
conn.execute(
"INSERT OR REPLACE INTO facts (key, value) VALUES (?1, ?2)",
params![key, value],
)?;
Ok(())
}
fn get_fact(conn: &Connection, key: &str) -> SqlResult<Option<String>> {
let result = conn.query_row(
"SELECT value FROM facts WHERE key = ?1",
params![key],
|row| row.get(0),
);
match result {
Ok(val) => Ok(Some(val)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e),
}
}
fn generate_response(conn: &Connection, message: &str) -> SqlResult<String> {
let lower = message.to_lowercase();
if lower.contains("who am i") {
if let Some(name) = get_fact(conn, "user_name")? {
return Ok(format!("You're {}! I remember.", name));
} else {
return Ok("I don't know your name yet. Tell me!".to_string());
}
}
if lower.contains("where do i live") {
if let Some(city) = get_fact(conn, "user_city")? {
return Ok(format!("You live in {}!", city));
} else {
return Ok("You haven't told me where you live yet.".to_string());
}
}
let responses = [
"Hmm, interesting. Tell me more.",
"Got it. I'll remember that.",
"Okay! What else?",
"Noted. Anything else on your mind?",
"I see. Keep talking, I'm listening.",
];
let index = message.len() % responses.len();
Ok(responses[index].to_string())
}
fn handle_chat(conn: &Connection, message: &str) -> SqlResult<()> {
let timestamp = now_timestamp();
conn.execute(
"INSERT INTO episodes (message, timestamp) VALUES (?1, ?2)",
params![message, timestamp],
)?;
extract_facts(conn, message)?;
let response = generate_response(conn, message)?;
println!("Mira: {}", response);
Ok(())
}
fn main() {
let cli = Cli::parse();
let conn = match open_db() {
Ok(c) => c,
Err(e) => {
eprintln!("Failed to open database: {}", e);
std::process::exit(1);
}
};
let result = match cli.command {
Commands::Chat { message } => handle_chat(&conn, &message),
Commands::Memory => { println!("Memory"); Ok(()) },
Commands::Forget => { println!("Forget"); Ok(()) },
};
if let Err(e) = result {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
Mira can now chat, remember facts, and recall them when asked. Next up, you will build the memory and forget commands so you can review conversation history and wipe the slate clean.
Implement Memory and Forget Commands
Mira can chat with you and quietly extract facts from your messages. But right now, you have no way to see what she remembers or to wipe her memory clean.
In this step, you will build the two remaining commands. The memory command queries recent conversations from the database. The forget command deletes everything and gives Mira a fresh start.
In this step, get ready to:
- Write a handle_memory function that queries the last 5 episodes.
- Write a handle_forget function that wipes both tables.
- Test the full chat-memory-forget flow end to end.
Write the handle_memory function
The memory command lets you peek at your recent conversation history. It needs to query the episodes table, grab the most recent entries, and print them with timestamps.
- In src/main.rs, add the following handle_memory function above fn main():
fn handle_memory(conn: &Connection) -> SqlResult<()> {
// Prepare a query for the 5 most recent episodes
let mut stmt = conn.prepare(
"SELECT message, timestamp FROM episodes ORDER BY id DESC LIMIT 5"
)?;
// Iterate over each row returned by the query
let rows = stmt.query_map([], |row| {
let message: String = row.get(0)?;
let timestamp: String = row.get(1)?;
Ok((message, timestamp))
})?;
// Print each conversation entry with its timestamp
let mut found = false;
for row in rows {
let (message, timestamp) = row?;
if !found {
println!("\u{1f4dd} Recent conversations:");
println!();
found = true;
}
println!(" [{}] {}", timestamp, message);
}
// Handle the empty case
if !found {
println!("No memories yet. Start chatting!");
}
Ok(())
}
What does this code do?
This function uses conn.prepare() to create a prepared statement. Prepared statements are reusable query templates that SQLite can optimize.
The query_map method runs the query and maps each row into a Rust tuple. The empty [] means no parameters are needed for this query.
The found flag tracks whether any rows came back. If the table is empty, the loop never runs and the function prints a friendly fallback message instead.
- Save src/main.rs.
Seeing a compile error?
Make sure handle_memory is placed outside of any existing function. It should be a standalone function at the module level, not nested inside main() or handle_chat.
Check that the return type is SqlResult<()> and you have the ? operator after conn.prepare().
Help me fix a compile error in my handle_memory function.
Write the handle_forget function
Sometimes you want a clean slate. The forget command deletes all rows from both the episodes and facts tables.
- In src/main.rs, add the following handle_forget function below handle_memory:
fn handle_forget(conn: &Connection) -> SqlResult<()> {
// Delete all conversation history
conn.execute("DELETE FROM episodes", [])?;
// Delete all stored facts
conn.execute("DELETE FROM facts", [])?;
println!("\u{1f9f9} Memory wiped. Fresh start!");
Ok(())
}
What does this code do?
This function runs two DELETE statements, one for each table. Unlike DROP TABLE, DELETE FROM removes all rows but keeps the table structure intact. Mira can start collecting new memories immediately after a forget.
The empty [] passed as the second argument means no parameters. The ? operator propagates any database errors up to the caller.
- Save src/main.rs.
Wire up the match arms and test the full flow
The main() function still has placeholder messages for the Memory and Forget match arms. You need to replace them with calls to your new handler functions.
- In src/main.rs, scroll down to the main() function.
- Find these two lines inside the match cli.command block:
Commands::Memory => { println!("Memory"); Ok(()) },
Commands::Forget => { println!("Forget"); Ok(()) },
- Replace those two lines with calls to your new handler functions:
Commands::Memory => handle_memory(&conn),
Commands::Forget => handle_forget(&conn),
- Save src/main.rs.
Now test the full flow. First, send a couple of chat messages to populate the database.
- Chat a couple of messages by running these commands:
cargo run -- chat "my name is Alex"
cargo run -- chat "i live in Tokyo"
- Check Mira's memory by running:
cargo run -- memory
You should see your recent messages listed with timestamps. The most recent message appears first.
- Wipe Mira's memory by running:
cargo run -- forget
You should see the confirmation message.
- Confirm the wipe worked by running:
cargo run -- memory
This time you should see "No memories yet. Start chatting!" since both tables are now empty.
Memory still showing old messages?
Make sure you saved src/main.rs after updating the match arms. If the placeholder text is still printing, Cargo is running the old compiled version.
If forget runs but memory still shows rows, check that your handle_forget function deletes from both episodes and facts.
Help me debug why forget isn't clearing my Mira database.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/main.rs and that all three commands work.
ⓧ I'd like to double check the full code
Here is the complete src/main.rs file at this point:
use clap::{Parser, Subcommand};
use rusqlite::{params, Connection, Result as SqlResult};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Parser)]
#[command(name = "mira", about = "Your CLI memory companion")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Talk to Mira
Chat {
/// Your message
message: String,
},
/// Show recent conversations
Memory,
/// Wipe Mira's memory
Forget,
}
fn db_path() -> String {
"mira.db".to_string()
}
fn open_db() -> SqlResult<Connection> {
let conn = Connection::open(db_path())?;
conn.pragma_update(None, "journal_mode", "wal")?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS episodes (
id INTEGER PRIMARY KEY,
message TEXT NOT NULL,
timestamp TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS facts (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);"
)?;
Ok(conn)
}
fn now_timestamp() -> String {
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap();
let secs = duration.as_secs();
let hours = (secs / 3600) % 24;
let minutes = (secs / 60) % 60;
let seconds = secs % 60;
format!("{:02}:{:02}:{:02} UTC", hours, minutes, seconds)
}
fn extract_facts(conn: &Connection, message: &str) -> SqlResult<()> {
let lower = message.to_lowercase();
if let Some(name) = extract_after(&lower, "my name is ") {
upsert_fact(conn, "user_name", &name)?;
}
if let Some(city) = extract_after(&lower, "i live in ") {
upsert_fact(conn, "user_city", &city)?;
}
if let Some(mood) = extract_after(&lower, "i'm feeling ") {
upsert_fact(conn, "user_mood", &mood)?;
}
if let Some(mood) = extract_after(&lower, "im feeling ") {
upsert_fact(conn, "user_mood", &mood)?;
}
Ok(())
}
fn extract_after(text: &str, prefix: &str) -> Option<String> {
if let Some(start) = text.find(prefix) {
let after = &text[start + prefix.len()..];
let value = after.split(|c: char| c == '.' || c == ',' || c == '!')
.next()
.unwrap_or("")
.trim()
.to_string();
if !value.is_empty() {
return Some(value);
}
}
None
}
fn upsert_fact(conn: &Connection, key: &str, value: &str) -> SqlResult<()> {
conn.execute(
"INSERT OR REPLACE INTO facts (key, value) VALUES (?1, ?2)",
params![key, value],
)?;
Ok(())
}
fn get_fact(conn: &Connection, key: &str) -> SqlResult<Option<String>> {
let result = conn.query_row(
"SELECT value FROM facts WHERE key = ?1",
params![key],
|row| row.get(0),
);
match result {
Ok(val) => Ok(Some(val)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e),
}
}
fn generate_response(conn: &Connection, message: &str) -> SqlResult<String> {
let lower = message.to_lowercase();
if lower.contains("who am i") {
if let Some(name) = get_fact(conn, "user_name")? {
return Ok(format!("You're {}! I remember.", name));
} else {
return Ok("I don't know your name yet. Tell me!".to_string());
}
}
if lower.contains("where do i live") {
if let Some(city) = get_fact(conn, "user_city")? {
return Ok(format!("You live in {}!", city));
} else {
return Ok("You haven't told me where you live yet.".to_string());
}
}
let responses = [
"Hmm, interesting. Tell me more.",
"Got it. I'll remember that.",
"Okay! What else?",
"Noted. Anything else on your mind?",
"I see. Keep talking, I'm listening.",
];
let index = message.len() % responses.len();
Ok(responses[index].to_string())
}
fn handle_chat(conn: &Connection, message: &str) -> SqlResult<()> {
let timestamp = now_timestamp();
conn.execute(
"INSERT INTO episodes (message, timestamp) VALUES (?1, ?2)",
params![message, timestamp],
)?;
extract_facts(conn, message)?;
let response = generate_response(conn, message)?;
println!("Mira: {}", response);
Ok(())
}
fn handle_memory(conn: &Connection) -> SqlResult<()> {
let mut stmt = conn.prepare(
"SELECT message, timestamp FROM episodes ORDER BY id DESC LIMIT 5"
)?;
let rows = stmt.query_map([], |row| {
let message: String = row.get(0)?;
let timestamp: String = row.get(1)?;
Ok((message, timestamp))
})?;
let mut found = false;
for row in rows {
let (message, timestamp) = row?;
if !found {
println!("\u{1f4dd} Recent conversations:");
println!();
found = true;
}
println!(" [{}] {}", timestamp, message);
}
if !found {
println!("No memories yet. Start chatting!");
}
Ok(())
}
fn handle_forget(conn: &Connection) -> SqlResult<()> {
conn.execute("DELETE FROM episodes", [])?;
conn.execute("DELETE FROM facts", [])?;
println!("\u{1f9f9} Memory wiped. Fresh start!");
Ok(())
}
fn main() {
let cli = Cli::parse();
let conn = match open_db() {
Ok(c) => c,
Err(e) => {
eprintln!("Failed to open database: {}", e);
std::process::exit(1);
}
};
let result = match cli.command {
Commands::Chat { message } => handle_chat(&conn, &message),
Commands::Memory => handle_memory(&conn),
Commands::Forget => handle_forget(&conn),
};
if let Err(e) = result {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
Secret mission
Add an Identity Card Command
Mira knows facts about you, but there's no way to see them all at once. What if you could ask her to show you everything she remembers in a neat, bordered card?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The mira/ project folder on your Desktop (includes src/main.rs, Cargo.toml, and mira.db).
- The Rust toolchain (rustc, cargo, rustup).
Keep everything running
No action needed. Choose this if you're still actively building or want to keep experimenting with Mira.
- Your mira/ folder stays on your Desktop. You can run cargo run at any time to interact with Mira again.
- The mira.db file keeps all your stored facts and conversation episodes intact.
- The Rust toolchain remains installed and available for other projects.
Pause - I'll come back to this later
Since Mira is a CLI tool that only runs when you invoke it, there are no background processes to stop. Your files stay on disk, ready for when you return.
- Nothing to shut down. Mira does not run in the background.
- Your project folder, database, and Rust toolchain all remain in place.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below.
- Delete the project folder on macOS or Linux by running this command:
- Delete the project folder on Windows by running this command in PowerShell:
rm -rf ~/Desktop/mira
On Windows, run this instead:
Remove-Item -Recurse -Force ~\Desktop\mira
This removes all source code, the compiled binary, and the mira.db database file.
- To uninstall the Rust toolchain entirely, run:
rustup self uninstall
This removes rustc, cargo, rustup, and all installed toolchains from your system.
Nice Work!
Nice Work!
You just built Mira, your very own CLI memory companion in Rust. She remembers who you are, where you live, and how you're feeling across terminal sessions.
You've learned how to:
- Built a complete Rust CLI application with subcommands using clap and the derive macro.
- Created and managed a SQLite database with rusqlite including opening connections, inserting rows, querying rows, and deleting data.
- Implemented UPSERT logic with INSERT OR REPLACE to keep your facts table current without duplicate entries.
- Added a mira who identity card command that queries all stored facts and prints them in a formatted bordered card.
Ready to quiz yourself?