On this page

Final project: CLI notes app in Rust

25 min read TextCh. 5 — Rust in Practice

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

  1. Async Rust: Study tokio and async/await for non-blocking I/O
  2. Web with Axum or Actix-web: Build REST APIs in Rust
  3. WebAssembly: Compile Rust to WASM with wasm-pack
  4. Embedded Rust: Program microcontrollers with Rust no_std
  5. The Rust Programming Language: The official book at doc.rust-lang.org/book
  6. 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!

Extend the project
Additional challenges: implement real persistence with std::fs::write and serde_json, add a TUI interface with the crossterm crate, implement date-based search with chrono, add Markdown note support, or turn the manager into an HTTP server with axum.
Project architecture
The project demonstrates: error types with Display + Error, traits for persistence abstraction (Storable), Box<dyn Trait> for dynamic polymorphism, Arc + Mutex for concurrency, iterators for search and filtering, and Result with ? for error propagation.
rust
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 &notes {
        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(())
}