On this page
Final project: CLI notes app in Rust
Final project: CLI notes app in Rust
In this final lesson we integrate everything learned in the course to build a command-line notes manager. The project demonstrates how Rust concepts combine into a real, maintainable architecture.
Integrated concepts
| Concept | Lesson | Use in the project |
|---|---|---|
| Structs | L7 | Note, NotesManager, MemoryStorage |
| Enums | L8 | AppError, Priority |
| Traits | L10 | Storable with multiple implementations |
| Generics | L11 | impl Storable + 'static |
| Result + ? | L12 | The entire error handling chain |
| Modules | L13 | Organization by responsibility |
| Iterators | L14 | search(), by_priority(), statistics() |
| Concurrency | L15 | Arc<Mutex<>>, auto-save thread |
| Ownership | L4 | Transfer of Box<dyn Storable> |
| Borrowing | L5 | References in read-only methods |
Project architecture
notes-cli/
├── Cargo.toml
└── src/
├── main.rs # Entry point + orchestration
├── error.rs # Centralized AppError
├── models/
│ ├── mod.rs
│ ├── note.rs # Note struct + Priority enum
│ └── manager.rs # NotesManager
└── storage/
├── mod.rs # Storable trait
├── memory.rs # In-memory implementation
└── file.rs # File implementation (extension)Design analysis
The `Storable` trait
trait Storable {
fn save(&self, notes: &[Note]) -> Result<(), AppError>;
fn load(&self) -> Result<Vec<Note>, AppError>;
}This trait defines the persistence contract without specifying the mechanism. The MemoryStorage implementation uses a HashMap in a Mutex. A real file implementation would use std::fs.
`Box`
struct NotesManager {
storage: Box<dyn Storable>,
}Box<dyn Storable> is a trait object — it allows NotesManager to work with any implementation without knowing the concrete type. This is dynamic dispatch: more flexible than generics when you need to swap implementations at runtime.
Arc> for auto-save
let shared_manager = Arc::new(Mutex::new(manager));
let _auto_save = start_auto_save(Arc::clone(&shared_manager), Duration::from_secs(30));The auto-save thread shares the manager with the main thread using Arc<Mutex<>>. The Arc allows multiple owners (thread-safe), the Mutex guarantees exclusive access.
Possible extensions
1. File persistence
use std::fs;
struct FileStorage { path: String }
impl Storable for FileStorage {
fn save(&self, notes: &[Note]) -> Result<(), AppError> {
let json = serde_json::to_string_pretty(notes)
.map_err(|e| AppError::StorageFailed(e.to_string()))?;
fs::write(&self.path, json)
.map_err(|e| AppError::StorageFailed(e.to_string()))
}
fn load(&self) -> Result<Vec<Note>, AppError> {
if !std::path::Path::new(&self.path).exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(&self.path)
.map_err(|e| AppError::StorageFailed(e.to_string()))?;
serde_json::from_str(&content)
.map_err(|e| AppError::StorageFailed(e.to_string()))
}
}2. CLI commands with clap
[dependencies]
clap = { version = "4", features = ["derive"] }use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "notes")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Add { title: String, content: String },
List,
Search { term: String },
Delete { id: u64 },
}3. Advanced sorting with multiple criteria
fn sort_notes<'a>(
notes: &'a [Note],
criterion: &str,
ascending: bool,
) -> Vec<&'a Note> {
let mut result: Vec<&Note> = notes.iter().collect();
match criterion {
"date" => result.sort_by_key(|n| n.created_at),
"title" => result.sort_by(|a, b| a.title.cmp(&b.title)),
"priority" => result.sort_by(|a, b| {
let weight = |p: &Priority| match p {
Priority::High => 0,
Priority::Medium => 1,
Priority::Low => 2,
};
weight(&a.priority).cmp(&weight(&b.priority))
}),
_ => {}
}
if !ascending {
result.reverse();
}
result
}Congratulations!
You have completed the Rust from Scratch course. You now master:
- Rust's unique type system: ownership, borrowing, lifetimes
- Compound types: structs, enums, exhaustive pattern matching
- Standard library collections: Vec, String, HashMap
- Abstractions: traits, generics, monomorphization
- Idiomatic error handling with Result and ?
- The ecosystem: modules, crates, Cargo
- Functional programming: closures, lazy iterators
- Safe concurrency: threads, Arc, Mutex, channels
Next steps
- Async Rust: Study
tokioandasync/awaitfor non-blocking I/O - Web with Axum or Actix-web: Build REST APIs in Rust
- WebAssembly: Compile Rust to WASM with
wasm-pack - Embedded Rust: Program microcontrollers with Rust
no_std - The Rust Programming Language: The official book at doc.rust-lang.org/book
- Rustlings: Interactive exercises at github.com/rust-lang/rustlings
Rust rewards patience. The borrow checker may frustrate you at first, but once your code compiles, it generally works correctly. Welcome to the Rust community!
use std::collections::HashMap;
use std::fmt;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
// === Custom error types ===
#[derive(Debug)]
enum AppError {
NoteNotFound(u64),
StorageFailed(String),
InvalidInput(String),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoteNotFound(id) => write!(f, "Note #{id} not found"),
Self::StorageFailed(e) => write!(f, "Storage error: {e}"),
Self::InvalidInput(e) => write!(f, "Invalid input: {e}"),
}
}
}
impl std::error::Error for AppError {}
// === Models ===
#[derive(Debug, Clone, PartialEq)]
enum Priority { Low, Medium, High }
impl fmt::Display for Priority {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Priority::Low => write!(f, "Low"),
Priority::Medium => write!(f, "Medium"),
Priority::High => write!(f, "High"),
}
}
}
#[derive(Debug, Clone)]
struct Note {
id: u64,
title: String,
content: String,
tags: Vec<String>,
priority: Priority,
created_at: u64,
}
impl Note {
fn new(title: &str, content: &str, priority: Priority) -> Self {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Note {
id: timestamp,
title: title.to_string(),
content: content.to_string(),
tags: Vec::new(),
priority,
created_at: timestamp,
}
}
fn add_tag(&mut self, tag: &str) {
let t = tag.trim().to_lowercase();
if !t.is_empty() && !self.tags.contains(&t) {
self.tags.push(t);
}
}
}
impl fmt::Display for Note {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[#{}] {} [{}]", self.id, self.title, self.priority)?;
if !self.tags.is_empty() {
write!(f, " — {:?}", self.tags)?;
}
Ok(())
}
}
// === Trait: persistence ===
trait Storable {
fn save(&self, notes: &[Note]) -> Result<(), AppError>;
fn load(&self) -> Result<Vec<Note>, AppError>;
}
// In-memory implementation (simulated)
struct MemoryStorage {
data: Arc<Mutex<HashMap<u64, Note>>>,
}
impl MemoryStorage {
fn new() -> Self {
MemoryStorage {
data: Arc::new(Mutex::new(HashMap::new())),
}
}
}
impl Storable for MemoryStorage {
fn save(&self, notes: &[Note]) -> Result<(), AppError> {
let mut map = self.data.lock()
.map_err(|e| AppError::StorageFailed(e.to_string()))?;
map.clear();
for note in notes {
map.insert(note.id, note.clone());
}
Ok(())
}
fn load(&self) -> Result<Vec<Note>, AppError> {
let map = self.data.lock()
.map_err(|e| AppError::StorageFailed(e.to_string()))?;
let mut notes: Vec<Note> = map.values().cloned().collect();
notes.sort_by_key(|n| n.created_at);
Ok(notes)
}
}
// === Notes manager ===
struct NotesManager {
notes: Vec<Note>,
storage: Box<dyn Storable>,
}
impl NotesManager {
fn new(storage: impl Storable + 'static) -> Result<Self, AppError> {
let notes = storage.load()?;
Ok(NotesManager { notes, storage: Box::new(storage) })
}
fn add(&mut self, note: Note) -> Result<u64, AppError> {
let id = note.id;
self.notes.push(note);
self.storage.save(&self.notes)?;
Ok(id)
}
fn get(&self, id: u64) -> Option<&Note> {
self.notes.iter().find(|n| n.id == id)
}
fn delete(&mut self, id: u64) -> Result<(), AppError> {
let pos = self.notes.iter().position(|n| n.id == id)
.ok_or(AppError::NoteNotFound(id))?;
self.notes.remove(pos);
self.storage.save(&self.notes)
}
fn search(&self, term: &str) -> Vec<&Note> {
let term = term.to_lowercase();
self.notes.iter()
.filter(|n| {
n.title.to_lowercase().contains(&term)
|| n.content.to_lowercase().contains(&term)
|| n.tags.iter().any(|t| t.contains(&term))
})
.collect()
}
fn by_priority(&self, priority: &Priority) -> Vec<&Note> {
self.notes.iter()
.filter(|n| &n.priority == priority)
.collect()
}
fn statistics(&self) -> HashMap<String, usize> {
let mut stats = HashMap::new();
stats.insert("total".to_string(), self.notes.len());
stats.insert("high".to_string(), self.by_priority(&Priority::High).len());
stats.insert("medium".to_string(), self.by_priority(&Priority::Medium).len());
stats.insert("low".to_string(), self.by_priority(&Priority::Low).len());
let total_tags: usize = self.notes.iter()
.map(|n| n.tags.len())
.sum();
stats.insert("tags".to_string(), total_tags);
stats
}
}
// === Auto-save in background thread ===
fn start_auto_save(
manager: Arc<Mutex<NotesManager>>,
interval: Duration,
) -> thread::JoinHandle<()> {
thread::spawn(move || {
loop {
thread::sleep(interval);
if let Ok(m) = manager.lock() {
if m.storage.save(&m.notes).is_ok() {
println!("[Auto-save OK]");
}
}
}
})
}
fn main() -> Result<(), AppError> {
println!("=== Rust CLI Notes App ===\n");
let storage = MemoryStorage::new();
let mut manager = NotesManager::new(storage)?;
// Create notes
let mut n1 = Note::new("Learn Rust", "Complete the Rust from Scratch course", Priority::High);
n1.add_tag("rust");
n1.add_tag("learning");
let mut n2 = Note::new("Buy groceries", "Milk, bread, cheese, fruit", Priority::Medium);
n2.add_tag("personal");
let mut n3 = Note::new("Review PR", "Review pull request #42 from the team", Priority::High);
n3.add_tag("work");
n3.add_tag("rust");
let mut n4 = Note::new("Read article", "Blog post about ownership and lifetimes", Priority::Low);
n4.add_tag("rust");
n4.add_tag("reading");
let id1 = manager.add(n1)?;
let _id2 = manager.add(n2)?;
let _id3 = manager.add(n3)?;
let _id4 = manager.add(n4)?;
// Show all notes
println!("--- All notes ---");
let notes = manager.storage.load()?;
for note in ¬es {
println!(" {note}");
}
// Search
println!("\n--- Search: 'rust' ---");
for note in manager.search("rust") {
println!(" {note}");
}
// By priority
println!("\n--- High priority ---");
for note in manager.by_priority(&Priority::High) {
println!(" {}", note.title);
}
// Get a specific note
println!("\n--- Note #{id1} ---");
if let Some(note) = manager.get(id1) {
println!(" Title: {}", note.title);
println!(" Content: {}", note.content);
println!(" Tags: {:?}", note.tags);
}
// Statistics
println!("\n--- Statistics ---");
let stats = manager.statistics();
let mut keys: Vec<&String> = stats.keys().collect();
keys.sort();
for key in keys {
println!(" {key}: {}", stats[key]);
}
// Delete a note
manager.delete(id1)?;
println!("\nNote #{id1} deleted. Total: {}", manager.notes.len());
println!("\nProject complete! Rust is fearlessly concurrent.");
Ok(())
}
Sign in to track your progress