diff --git a/Cargo.toml b/Cargo.toml index a161afd..7cbbc27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,8 @@ members = [ "ch03-traits-and-generics/exercises", "ch04-content-addressable", "ch04-content-addressable/exercises", + "ch05-cli-tools", + "ch05-cli-tools/exercises", ] [workspace.package] diff --git a/ch05-cli-tools/Cargo.toml b/ch05-cli-tools/Cargo.toml new file mode 100644 index 0000000..6281444 --- /dev/null +++ b/ch05-cli-tools/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "ch05-cli-tools" +version = "0.1.0" +edition.workspace = true +license.workspace = true +description = "Chapter 5: CLI Tools — building tools that carry their own context" + +[dependencies] +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/ch05-cli-tools/README.md b/ch05-cli-tools/README.md new file mode 100644 index 0000000..3dcc7a7 --- /dev/null +++ b/ch05-cli-tools/README.md @@ -0,0 +1,206 @@ +# Chapter 5: CLI Tools + +## The Big Idea + +Python's `argparse` and `click` are good at parsing command-line arguments. +But most CLI tools stop there — they parse args, do a thing, and print +text. The *user* is expected to carry context between commands: remember +which server they connected to, pipe output through `jq`, and mentally +track what state the tool left behind. + +Good CLI tools do more. They carry their own context: structured output +that other tools can consume, self-documenting help that teaches domain +concepts (not just flag names), and predictable behavior that composes +with other tools in a pipeline. + +Rust's `clap` library, combined with serde for structured output and +the type system for enforcing correct usage, makes it natural to build +this kind of tool. + +## Python Analogies + +### Argument parsing = `argparse` / `click` + +```python +import argparse + +parser = argparse.ArgumentParser(description="Manage documents") +subparsers = parser.add_subparsers(dest="command") + +add_parser = subparsers.add_parser("add", help="Add a document") +add_parser.add_argument("title", help="Document title") +add_parser.add_argument("--tag", action="append", help="Tags") + +list_parser = subparsers.add_parser("list", help="List documents") +list_parser.add_argument("--format", choices=["text", "json"], default="text") + +args = parser.parse_args() +``` + +```rust +use clap::Parser; + +#[derive(Parser)] +#[command(about = "Manage documents")] +enum Cli { + /// Add a document + Add { + /// Document title + title: String, + /// Tags for the document + #[arg(long)] + tag: Vec, + }, + /// List documents + List { + /// Output format + #[arg(long, default_value = "text")] + format: OutputFormat, + }, +} +``` + +**Key insight:** Python argument parsers are imperative — you call methods +to build up the parser. Rust's clap with derive macros is *declarative* — +you define a struct or enum and the parser is generated from the type. +The documentation, validation, and help text all come from the type +definition. + +### Structured output = "don't make humans parse text" + +```python +# Bad: text output that's hard to parse +def list_docs_text(docs): + for doc in docs: + print(f"{doc.title} ({len(doc.tags)} tags)") + +# Better: structured output that tools can consume +def list_docs_json(docs): + import json + print(json.dumps([{"title": d.title, "tags": d.tags} for d in docs])) +``` + +```rust +use serde::Serialize; + +#[derive(Serialize)] +struct DocOutput { + title: String, + tags: Vec, +} + +// One type, multiple output formats +fn output(docs: &[DocOutput], format: OutputFormat) { + match format { + OutputFormat::Text => { + for doc in docs { + println!("{} ({} tags)", doc.title, doc.tags.len()); + } + } + OutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(docs).unwrap()); + } + } +} +``` + +**Key insight:** When your output types implement `Serialize`, you get +JSON (and YAML, TOML, etc.) for free. The same data structure serves +both human-readable and machine-readable output. No separate code paths +that can drift apart. + +### Subcommands = Enums (exhaustive matching) + +```python +# Python: subcommands are strings — typos cause runtime errors +if args.command == "add": + handle_add(args) +elif args.command == "list": + handle_list(args) +elif args.command == "remove": + handle_remove(args) +# What if we add "update" and forget to add the elif? Silent bug. +``` + +```rust +// Rust: subcommands are enum variants — the compiler checks exhaustiveness +match cli { + Cli::Add { title, tag } => handle_add(title, tag), + Cli::List { format } => handle_list(format), + Cli::Remove { id } => handle_remove(id), + // If we add Cli::Update, the compiler forces us to handle it here +} +``` + +**Key insight:** Python subcommand dispatch is a chain of string +comparisons. Miss one and you get a silent bug. Rust enum matching is +exhaustive — add a new variant and the compiler tells you every place +that needs to handle it. + +### Exit codes = Structured error reporting + +```python +import sys + +def main(): + try: + result = do_work() + print(result) + except FileNotFoundError: + print("Error: file not found", file=sys.stderr) + sys.exit(1) + except PermissionError: + print("Error: permission denied", file=sys.stderr) + sys.exit(2) +``` + +```rust +// Rust: exit codes can be an enum too +#[derive(Debug)] +enum ExitCode { + Success = 0, + NotFound = 1, + PermissionDenied = 2, +} + +// Or use Result as main's return type +fn main() -> Result<(), Box> { + let result = do_work()?; // errors propagate automatically + println!("{result}"); + Ok(()) +} +``` + +## Design Principles for Good CLI Tools + +1. **Self-documenting**: Help text teaches the domain, not just the flags. + "Add a document to the content-addressed store" is better than + "Add a document." + +2. **Structured output**: Always support `--format json` (or similar). + Human-readable is the default; machine-readable is available. + +3. **Composable**: Output that other tools can consume. Input from stdin + when appropriate. Exit codes that scripts can check. + +4. **Predictable**: Same input = same output. No hidden state changes + that surprise the user. Make side effects visible. + +5. **Context-carrying**: The tool knows what it needs. Instead of making + the user provide 15 flags, carry configuration, know reasonable + defaults, and explain what it's doing. + +## Summary + +| Python | Rust | What Changes | +|--------|------|-------------| +| `argparse` / `click` | `clap` derive | Declarative from types | +| String-based dispatch | Enum matching | Exhaustive, no silent bugs | +| Print text + `jq` | `serde_json` + format flag | One type, multiple formats | +| Manual exit codes | Result-based main | Error propagation built in | +| Flat flag lists | Nested subcommand enums | Type-safe command trees | + +## Next Steps + +Open `src/lib.rs` to see these concepts in working code, then try the +exercises in `exercises/`. diff --git a/ch05-cli-tools/exercises/Cargo.toml b/ch05-cli-tools/exercises/Cargo.toml new file mode 100644 index 0000000..a648be6 --- /dev/null +++ b/ch05-cli-tools/exercises/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "ch05-exercises" +version = "0.1.0" +edition.workspace = true +license.workspace = true +description = "Exercises for Chapter 5: CLI Tools" + +[dependencies] +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/ch05-cli-tools/exercises/src/lib.rs b/ch05-cli-tools/exercises/src/lib.rs new file mode 100644 index 0000000..21551bb --- /dev/null +++ b/ch05-cli-tools/exercises/src/lib.rs @@ -0,0 +1,391 @@ +//! # Chapter 5 Exercises: CLI Tools +//! +//! These exercises build a task manager CLI from scratch. +//! Each exercise adds a new capability. +//! +//! Run tests: `cargo test -p ch05-exercises` + +#![allow(unused_variables, dead_code)] + +use clap::{Parser, Subcommand, ValueEnum}; +use serde::Serialize; + +// ============================================================ +// Exercise 1: Define a CLI with Subcommands +// ============================================================ +// +// Python version: +// ```python +// parser = argparse.ArgumentParser(description="Task manager") +// sub = parser.add_subparsers(dest="command") +// +// add = sub.add_parser("add") +// add.add_argument("description") +// add.add_argument("--priority", type=int, default=3) +// +// done = sub.add_parser("done") +// done.add_argument("id", type=int) +// +// list = sub.add_parser("list") +// list.add_argument("--status", choices=["all", "open", "done"], default="all") +// ``` +// +// Define a TaskCli struct with subcommands: Add, Done, List. +// The Add command takes a description (String) and optional --priority (u8, default 3). +// The Done command takes an id (usize). +// The List command takes an optional --status filter (TaskStatus enum). + +#[derive(ValueEnum, Debug, Clone, Copy, PartialEq)] +pub enum TaskStatusFilter { + All, + Open, + Done, +} + +#[derive(Parser, Debug)] +#[command(name = "tasks", about = "A simple task manager")] +pub struct TaskCli { + #[command(subcommand)] + pub command: TaskCommand, +} + +#[derive(Subcommand, Debug)] +pub enum TaskCommand { + /// Add a new task + Add { + /// Task description + description: String, + + /// Priority (1=highest, 5=lowest) + #[arg(long, default_value = "3")] + priority: u8, + }, + + /// Mark a task as done + Done { + /// Task ID to mark as done + id: usize, + }, + + /// List tasks + List { + /// Filter by status + #[arg(long, value_enum, default_value = "all")] + status: TaskStatusFilter, + }, +} + +// ============================================================ +// Exercise 2: Data Model +// ============================================================ +// +// Python version: +// ```python +// @dataclass +// class Task: +// id: int +// description: str +// priority: int +// done: bool = False +// ``` +// +// Define a Task struct that derives Debug, Clone, Serialize, PartialEq. + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct Task { + pub id: usize, + pub description: String, + pub priority: u8, + pub done: bool, +} + +// ============================================================ +// Exercise 3: Task Store with Filtering +// ============================================================ +// +// Python version: +// ```python +// class TaskStore: +// def __init__(self): +// self.tasks = [] +// self.next_id = 1 +// +// def add(self, description, priority=3): +// task = Task(self.next_id, description, priority) +// self.next_id += 1 +// self.tasks.append(task) +// return task.id +// +// def mark_done(self, id): +// for task in self.tasks: +// if task.id == id: +// task.done = True +// return True +// return False +// +// def list(self, status="all"): +// if status == "all": +// return sorted(self.tasks, key=lambda t: t.priority) +// elif status == "open": +// return sorted([t for t in self.tasks if not t.done], key=lambda t: t.priority) +// elif status == "done": +// return sorted([t for t in self.tasks if t.done], key=lambda t: t.priority) +// ``` + +pub struct TaskStore { + tasks: Vec, + next_id: usize, +} + +impl TaskStore { + pub fn new() -> Self { + Self { + tasks: Vec::new(), + next_id: 1, + } + } + + pub fn add(&mut self, description: &str, priority: u8) -> usize { + todo!("Create a task, push it, return the ID") + } + + /// Mark a task as done. Returns true if the task was found. + pub fn mark_done(&mut self, id: usize) -> bool { + todo!("Find task by id, set done=true, return whether it was found") + } + + /// List tasks filtered by status, sorted by priority (lowest number first). + pub fn list(&self, filter: TaskStatusFilter) -> Vec<&Task> { + todo!("Filter by status, sort by priority ascending") + } + + pub fn len(&self) -> usize { + self.tasks.len() + } + + pub fn is_empty(&self) -> bool { + self.tasks.is_empty() + } +} + +impl Default for TaskStore { + fn default() -> Self { + Self::new() + } +} + +// ============================================================ +// Exercise 4: Structured Output +// ============================================================ +// +// Python version: +// ```python +// def format_tasks(tasks, fmt="text"): +// if fmt == "json": +// return json.dumps([t.__dict__ for t in tasks], indent=2) +// lines = [] +// for t in tasks: +// status = "x" if t.done else " " +// lines.append(f"[{status}] #{t.id} (P{t.priority}) {t.description}") +// return "\n".join(lines) +// ``` +// +// Implement format_tasks for text and JSON output. +// Text format: "[x] #1 (P2) Buy milk" or "[ ] #2 (P3) Fix bug" + +#[derive(ValueEnum, Debug, Clone, Copy, PartialEq)] +pub enum OutputFormat { + Text, + Json, +} + +pub fn format_tasks(tasks: &[&Task], format: OutputFormat) -> String { + todo!("Format tasks as text or JSON") +} + +// ============================================================ +// Exercise 5: Command Dispatch +// ============================================================ +// +// Python version: +// ```python +// def dispatch(store, args): +// if args.command == "add": +// id = store.add(args.description, args.priority) +// return f"Added task #{id}" +// elif args.command == "done": +// if store.mark_done(args.id): +// return f"Marked #{args.id} as done" +// return f"Task #{args.id} not found" +// elif args.command == "list": +// tasks = store.list(args.status) +// return format_tasks(tasks, "text") +// ``` +// +// Implement dispatch using match on the TaskCommand enum. +// Return the output string. + +pub fn dispatch(store: &mut TaskStore, command: TaskCommand) -> String { + todo!("Match on command and dispatch to store methods") +} + +// ============================================================ +// Tests — do not modify below this line +// ============================================================ + +#[cfg(test)] +mod tests { + use super::*; + + // Exercise 1 — CLI parsing + #[test] + fn ex1_parse_add() { + let cli = TaskCli::parse_from(["tasks", "add", "Buy milk", "--priority", "1"]); + match cli.command { + TaskCommand::Add { + description, + priority, + } => { + assert_eq!(description, "Buy milk"); + assert_eq!(priority, 1); + } + _ => panic!("expected Add"), + } + } + + #[test] + fn ex1_parse_add_default_priority() { + let cli = TaskCli::parse_from(["tasks", "add", "Something"]); + match cli.command { + TaskCommand::Add { priority, .. } => assert_eq!(priority, 3), + _ => panic!("expected Add"), + } + } + + #[test] + fn ex1_parse_done() { + let cli = TaskCli::parse_from(["tasks", "done", "42"]); + match cli.command { + TaskCommand::Done { id } => assert_eq!(id, 42), + _ => panic!("expected Done"), + } + } + + #[test] + fn ex1_parse_list_filter() { + let cli = TaskCli::parse_from(["tasks", "list", "--status", "open"]); + match cli.command { + TaskCommand::List { status } => assert_eq!(status, TaskStatusFilter::Open), + _ => panic!("expected List"), + } + } + + // Exercise 3 — store + #[test] + fn ex3_add_and_list() { + let mut store = TaskStore::new(); + store.add("Task A", 3); + store.add("Task B", 1); + + let all = store.list(TaskStatusFilter::All); + assert_eq!(all.len(), 2); + // Should be sorted by priority: B (P1) before A (P3) + assert_eq!(all[0].description, "Task B"); + assert_eq!(all[1].description, "Task A"); + } + + #[test] + fn ex3_mark_done() { + let mut store = TaskStore::new(); + let id = store.add("Task", 3); + assert!(store.mark_done(id)); + assert!(!store.mark_done(999)); // not found + + let done = store.list(TaskStatusFilter::Done); + assert_eq!(done.len(), 1); + + let open = store.list(TaskStatusFilter::Open); + assert_eq!(open.len(), 0); + } + + // Exercise 4 — output formatting + #[test] + fn ex4_text_format() { + let task = Task { + id: 1, + description: "Buy milk".to_string(), + priority: 2, + done: false, + }; + let output = format_tasks(&[&task], OutputFormat::Text); + assert_eq!(output, "[ ] #1 (P2) Buy milk"); + } + + #[test] + fn ex4_text_done() { + let task = Task { + id: 1, + description: "Done task".to_string(), + priority: 1, + done: true, + }; + let output = format_tasks(&[&task], OutputFormat::Text); + assert_eq!(output, "[x] #1 (P1) Done task"); + } + + #[test] + fn ex4_json_format() { + let task = Task { + id: 1, + description: "Buy milk".to_string(), + priority: 2, + done: false, + }; + let output = format_tasks(&[&task], OutputFormat::Json); + let parsed: Vec = serde_json::from_str(&output).unwrap(); + assert_eq!(parsed[0].description, "Buy milk"); + } + + // Exercise 5 — dispatch + #[test] + fn ex5_dispatch_add() { + let mut store = TaskStore::new(); + let result = dispatch( + &mut store, + TaskCommand::Add { + description: "Test".to_string(), + priority: 2, + }, + ); + assert_eq!(result, "Added task #1"); + assert_eq!(store.len(), 1); + } + + #[test] + fn ex5_dispatch_done() { + let mut store = TaskStore::new(); + store.add("Task", 3); + + let result = dispatch(&mut store, TaskCommand::Done { id: 1 }); + assert_eq!(result, "Marked #1 as done"); + + let result = dispatch(&mut store, TaskCommand::Done { id: 999 }); + assert_eq!(result, "Task #999 not found"); + } + + #[test] + fn ex5_dispatch_list() { + let mut store = TaskStore::new(); + store.add("A", 3); + store.add("B", 1); + + let result = dispatch( + &mut store, + TaskCommand::List { + status: TaskStatusFilter::All, + }, + ); + // B should come first (P1 < P3) + assert!(result.starts_with("[ ] #2")); + } +} diff --git a/ch05-cli-tools/src/lib.rs b/ch05-cli-tools/src/lib.rs new file mode 100644 index 0000000..9fddf65 --- /dev/null +++ b/ch05-cli-tools/src/lib.rs @@ -0,0 +1,435 @@ +//! # Chapter 5: CLI Tools +//! +//! This module demonstrates building CLI tools in Rust using clap for +//! argument parsing and serde for structured output. +//! +//! Run the tests: `cargo test -p ch05-cli-tools` + +use clap::{Parser, Subcommand, ValueEnum}; +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// 1. Declarative argument parsing with clap derive +// --------------------------------------------------------------------------- + +/// A note-taking CLI — manages a list of notes with tags. +/// +/// Python equivalent: +/// ```python +/// parser = argparse.ArgumentParser(description="A simple note-taking tool") +/// subparsers = parser.add_subparsers(dest="command") +/// +/// add = subparsers.add_parser("add") +/// add.add_argument("text", help="The note content") +/// add.add_argument("--tag", "-t", action="append", default=[]) +/// +/// list = subparsers.add_parser("list") +/// list.add_argument("--tag", help="Filter by tag") +/// list.add_argument("--format", choices=["text", "json"], default="text") +/// +/// search = subparsers.add_parser("search") +/// search.add_argument("query") +/// ``` +#[derive(Parser, Debug)] +#[command(name = "notes", about = "A simple note-taking tool")] +pub struct Cli { + #[command(subcommand)] + pub command: Command, +} + +#[derive(Subcommand, Debug)] +pub enum Command { + /// Add a new note + Add { + /// The note content + text: String, + + /// Tags for the note (can be repeated) + #[arg(long, short)] + tag: Vec, + }, + + /// List all notes + List { + /// Filter by tag + #[arg(long)] + tag: Option, + + /// Output format + #[arg(long, value_enum, default_value = "text")] + format: OutputFormat, + }, + + /// Search notes by content + Search { + /// Search query (case-insensitive substring match) + query: String, + }, +} + +// --------------------------------------------------------------------------- +// 2. Output format as an enum +// --------------------------------------------------------------------------- + +/// Output format — derived from clap's ValueEnum. +/// +/// Python equivalent: +/// ```python +/// choices=["text", "json"] +/// ``` +/// +/// In Rust, this is a type — you can't pass an invalid format. +#[derive(ValueEnum, Debug, Clone, Copy, PartialEq)] +pub enum OutputFormat { + Text, + Json, +} + +// --------------------------------------------------------------------------- +// 3. The data model — serializable for structured output +// --------------------------------------------------------------------------- + +/// A single note with content and tags. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Note { + pub id: usize, + pub text: String, + pub tags: Vec, +} + +/// An in-memory note store. +/// +/// Python equivalent: +/// ```python +/// class NoteStore: +/// def __init__(self): +/// self.notes = [] +/// self.next_id = 1 +/// ``` +pub struct NoteStore { + notes: Vec, + next_id: usize, +} + +impl NoteStore { + pub fn new() -> Self { + Self { + notes: Vec::new(), + next_id: 1, + } + } + + /// Add a note and return its ID. + pub fn add(&mut self, text: &str, tags: Vec) -> usize { + let id = self.next_id; + self.next_id += 1; + self.notes.push(Note { + id, + text: text.to_string(), + tags, + }); + id + } + + /// List all notes, optionally filtered by tag. + pub fn list(&self, tag_filter: Option<&str>) -> Vec<&Note> { + self.notes + .iter() + .filter(|note| { + tag_filter + .map(|tag| note.tags.iter().any(|t| t == tag)) + .unwrap_or(true) + }) + .collect() + } + + /// Search notes by substring (case-insensitive). + pub fn search(&self, query: &str) -> Vec<&Note> { + let query_lower = query.to_lowercase(); + self.notes + .iter() + .filter(|note| note.text.to_lowercase().contains(&query_lower)) + .collect() + } + + /// Total number of notes. + pub fn len(&self) -> usize { + self.notes.len() + } + + pub fn is_empty(&self) -> bool { + self.notes.is_empty() + } +} + +impl Default for NoteStore { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// 4. Structured output — one function, multiple formats +// --------------------------------------------------------------------------- + +/// Format notes for output. +/// +/// Python equivalent: +/// ```python +/// def format_notes(notes, fmt): +/// if fmt == "json": +/// return json.dumps([n.__dict__ for n in notes], indent=2) +/// return "\n".join(f"[{n.id}] {n.text} {n.tags}" for n in notes) +/// ``` +/// +/// The Rust version uses serde Serialize — any type that implements +/// Serialize gets JSON output for free. +pub fn format_notes(notes: &[&Note], format: OutputFormat) -> String { + match format { + OutputFormat::Text => notes + .iter() + .map(|note| { + let tags = if note.tags.is_empty() { + String::new() + } else { + format!(" [{}]", note.tags.join(", ")) + }; + format!("#{}: {}{}", note.id, note.text, tags) + }) + .collect::>() + .join("\n"), + OutputFormat::Json => { + serde_json::to_string_pretty(notes).expect("note serialization should not fail") + } + } +} + +// --------------------------------------------------------------------------- +// 5. Command dispatch with exhaustive matching +// --------------------------------------------------------------------------- + +/// Process a command and return the output string. +/// +/// Python equivalent: +/// ```python +/// def dispatch(store, args): +/// if args.command == "add": +/// id = store.add(args.text, args.tag) +/// return f"Added note #{id}" +/// elif args.command == "list": +/// notes = store.list(args.tag) +/// return format_notes(notes, args.format) +/// elif args.command == "search": +/// results = store.search(args.query) +/// return format_notes(results, "text") +/// ``` +/// +/// The Rust match is exhaustive — add a new Command variant and the +/// compiler will tell you to handle it here. +pub fn dispatch(store: &mut NoteStore, command: Command) -> String { + match command { + Command::Add { text, tag } => { + let id = store.add(&text, tag); + format!("Added note #{id}") + } + Command::List { tag, format } => { + let notes = store.list(tag.as_deref()); + format_notes(¬es, format) + } + Command::Search { query } => { + let results = store.search(&query); + format_notes(&results, OutputFormat::Text) + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // CLI parsing tests — verify the derive macros work + + #[test] + fn parse_add_command() { + let cli = Cli::parse_from(["notes", "add", "Hello world", "--tag", "greeting"]); + match cli.command { + Command::Add { text, tag } => { + assert_eq!(text, "Hello world"); + assert_eq!(tag, vec!["greeting"]); + } + _ => panic!("expected Add command"), + } + } + + #[test] + fn parse_add_multiple_tags() { + let cli = Cli::parse_from(["notes", "add", "Note", "-t", "a", "-t", "b"]); + match cli.command { + Command::Add { tag, .. } => { + assert_eq!(tag, vec!["a", "b"]); + } + _ => panic!("expected Add command"), + } + } + + #[test] + fn parse_list_default_format() { + let cli = Cli::parse_from(["notes", "list"]); + match cli.command { + Command::List { format, tag } => { + assert_eq!(format, OutputFormat::Text); + assert!(tag.is_none()); + } + _ => panic!("expected List command"), + } + } + + #[test] + fn parse_list_json_format() { + let cli = Cli::parse_from(["notes", "list", "--format", "json"]); + match cli.command { + Command::List { format, .. } => { + assert_eq!(format, OutputFormat::Json); + } + _ => panic!("expected List command"), + } + } + + #[test] + fn parse_search() { + let cli = Cli::parse_from(["notes", "search", "hello"]); + match cli.command { + Command::Search { query } => { + assert_eq!(query, "hello"); + } + _ => panic!("expected Search command"), + } + } + + // Store tests + + #[test] + fn store_add_and_list() { + let mut store = NoteStore::new(); + store.add("First note", vec![]); + store.add("Second note", vec!["important".to_string()]); + + assert_eq!(store.len(), 2); + assert_eq!(store.list(None).len(), 2); + } + + #[test] + fn store_filter_by_tag() { + let mut store = NoteStore::new(); + store.add("Buy milk", vec!["shopping".to_string()]); + store.add("Fix bug", vec!["work".to_string()]); + store.add("Buy eggs", vec!["shopping".to_string()]); + + let shopping = store.list(Some("shopping")); + assert_eq!(shopping.len(), 2); + assert!(shopping + .iter() + .all(|n| n.tags.contains(&"shopping".to_string()))); + } + + #[test] + fn store_search_case_insensitive() { + let mut store = NoteStore::new(); + store.add("Hello World", vec![]); + store.add("Goodbye world", vec![]); + store.add("No match", vec![]); + + let results = store.search("world"); + assert_eq!(results.len(), 2); + } + + // Output format tests + + #[test] + fn format_text_output() { + let note = Note { + id: 1, + text: "Hello".to_string(), + tags: vec!["greeting".to_string()], + }; + let output = format_notes(&[¬e], OutputFormat::Text); + assert_eq!(output, "#1: Hello [greeting]"); + } + + #[test] + fn format_text_no_tags() { + let note = Note { + id: 1, + text: "Hello".to_string(), + tags: vec![], + }; + let output = format_notes(&[¬e], OutputFormat::Text); + assert_eq!(output, "#1: Hello"); + } + + #[test] + fn format_json_output() { + let note = Note { + id: 1, + text: "Hello".to_string(), + tags: vec!["greeting".to_string()], + }; + let output = format_notes(&[¬e], OutputFormat::Json); + let parsed: Vec = serde_json::from_str(&output).unwrap(); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].text, "Hello"); + } + + // Dispatch tests + + #[test] + fn dispatch_add() { + let mut store = NoteStore::new(); + let result = dispatch( + &mut store, + Command::Add { + text: "Test note".to_string(), + tag: vec!["test".to_string()], + }, + ); + assert_eq!(result, "Added note #1"); + assert_eq!(store.len(), 1); + } + + #[test] + fn dispatch_list() { + let mut store = NoteStore::new(); + store.add("Note A", vec![]); + store.add("Note B", vec![]); + + let result = dispatch( + &mut store, + Command::List { + tag: None, + format: OutputFormat::Text, + }, + ); + assert!(result.contains("#1: Note A")); + assert!(result.contains("#2: Note B")); + } + + #[test] + fn dispatch_search() { + let mut store = NoteStore::new(); + store.add("Buy groceries", vec![]); + store.add("Fix the car", vec![]); + + let result = dispatch( + &mut store, + Command::Search { + query: "buy".to_string(), + }, + ); + assert!(result.contains("Buy groceries")); + assert!(!result.contains("Fix the car")); + } +}