//! `nbd` — CLI entry point. //! //! Parses subcommands with `clap` and dispatches to the appropriate command //! handler. All file I/O is delegated to [`store`] and output is rendered by //! [`display`]. mod display; mod store; mod ticket; #[cfg(test)] mod tests; use clap::{Parser, Subcommand}; use crate::store::{ ensure_tickets_dir, find_nbd_root, list_tickets, migrate_tickets, read_ticket, resolve_id, write_ticket, }; use crate::ticket::{generate_id, validate_priority, Status, Ticket, TicketType}; // ── CLI definition ──────────────────────────────────────────────────────────── /// CLI for managing work tickets targeted at agent workflows. /// /// Tickets are stored as JSON files in `.nbd/tickets/` inside the nearest /// ancestor directory that contains a `.nbd/` folder, discovered by traversing /// upward from the current working directory (like `git` finds `.git/`). #[derive(Parser)] #[command(name = "nbd", about = "Manage work tickets for agent workflows")] struct Cli { /// Output machine-readable JSON instead of a human-readable table. #[arg(long, global = true)] json: bool, #[command(subcommand)] command: Commands, } /// Available `nbd` subcommands. #[derive(Subcommand)] enum Commands { /// Create a new ticket and print it. Create { /// Short summary of the work to be done. #[arg(long)] title: String, /// Long-form description (optional, defaults to empty). #[arg(long, default_value = "")] body: String, /// Priority on a scale of 0–10 (default: 5). #[arg(long, default_value_t = 5)] priority: u8, /// Lifecycle status: `todo`, `in_progress`, or `done` (default: `todo`). #[arg(long, default_value = "todo")] status: String, /// Ticket category: `project`, `feature`, `task`, or `bug` (default: `task`). #[arg(long = "type", default_value = "task")] ticket_type: String, /// Comma-separated list of dependency ticket IDs (e.g. `a3f9c2,b7d41e`). #[arg(long)] deps: Option, }, /// Print a single ticket by ID. Read { /// The 6-character hex ticket ID to look up. id: String, }, /// List all tickets sorted by priority (highest first). List, /// Initialise a new `.nbd/tickets/` store in the current directory. /// /// Analogous to `git init` — safe to run multiple times (idempotent). Init, /// List tickets that are ready to work on right now. /// /// A ticket is ready when its status is not `done` and every ticket it /// depends on has status `done`. Tickets with no dependencies and status /// `todo` or `in_progress` are always ready. Ready, /// Re-serialise all ticket files through the current schema. /// /// Brings existing files into conformance with the current data model: /// removes stale fields, adds new fields with their defaults, and /// normalises formatting. Exits zero even when some files have errors. Migrate { /// Print what would change without writing any files. #[arg(long)] dry_run: bool, }, /// Update fields of an existing ticket and print the result. /// /// Only the flags you supply are changed; all other fields retain their /// current values. Update { /// The 6-character hex ticket ID to modify. id: String, /// New title. #[arg(long)] title: Option, /// New body. #[arg(long)] body: Option, /// New priority (0–10). #[arg(long)] priority: Option, /// New status: `todo`, `in_progress`, or `done`. #[arg(long)] status: Option, /// New ticket type: `project`, `feature`, `task`, or `bug`. #[arg(long = "type")] ticket_type: Option, /// New comma-separated dependency IDs (replaces the existing list). #[arg(long)] deps: Option, }, } // ── Entry point ─────────────────────────────────────────────────────────────── #[async_std::main] async fn main() { let cli = Cli::parse(); if let Err(e) = dispatch(cli).await { eprintln!("error: {e}"); std::process::exit(1); } } /// Route the parsed CLI arguments to the appropriate command handler. async fn dispatch(cli: Cli) -> store::Result<()> { match cli.command { Commands::Create { title, body, priority, status, ticket_type, deps, } => cmd_create(title, body, priority, status, ticket_type, deps, cli.json).await, Commands::Init => cmd_init(cli.json).await, Commands::Ready => cmd_ready(cli.json).await, Commands::Migrate { dry_run } => cmd_migrate(dry_run, cli.json).await, Commands::Read { id } => cmd_read(id, cli.json).await, Commands::List => cmd_list(cli.json).await, Commands::Update { id, title, body, priority, status, ticket_type, deps, } => { cmd_update( id, title, body, priority, status, ticket_type, deps, cli.json, ) .await } } } // ── Parsing helpers ─────────────────────────────────────────────────────────── /// Parse a [`Status`] from its lowercase string representation. /// /// Accepts `"todo"`, `"in_progress"`, and `"done"`. /// /// # Errors /// /// Returns an error if `s` does not match a known variant. fn parse_status(s: &str) -> store::Result { match s { "todo" => Ok(Status::Todo), "in_progress" => Ok(Status::InProgress), "done" => Ok(Status::Done), other => Err(format!( "unknown status '{other}'; expected 'todo', 'in_progress', or 'done'" ) .into()), } } /// Parse a [`TicketType`] from its lowercase string representation. /// /// Accepts `"project"`, `"feature"`, `"task"`, and `"bug"`. /// /// # Errors /// /// Returns an error if `s` does not match a known variant. fn parse_ticket_type(s: &str) -> store::Result { match s { "project" => Ok(TicketType::Project), "feature" => Ok(TicketType::Feature), "task" => Ok(TicketType::Task), "bug" => Ok(TicketType::Bug), other => Err(format!( "unknown ticket type '{other}'; expected 'project', 'feature', 'task', or 'bug'" ) .into()), } } /// Split a comma-separated dependency string into a `Vec`. /// /// Returns an empty `Vec` when `deps` is `None` or an empty string. fn parse_deps(deps: Option<&str>) -> Vec { match deps { None | Some("") => Vec::new(), Some(s) => s.split(',').map(|id| id.trim().to_string()).collect(), } } /// Verify that every ID in `deps` refers to an existing ticket. /// /// Each entry may be a full ID or a unique prefix; `resolve_id` is used to /// expand prefixes before checking existence. The `deps` slice is mutated /// in-place so that all entries are replaced with their resolved full IDs. /// /// # Errors /// /// Returns an error that names the first missing or ambiguous dependency. async fn validate_deps(root: &std::path::Path, deps: &mut [String]) -> store::Result<()> { for dep_id in deps.iter_mut() { let resolved = resolve_id(root, dep_id).await.map_err( |_| -> Box { format!("dependency '{dep_id}' not found").into() }, )?; *dep_id = resolved; } Ok(()) } // ── Command handlers ────────────────────────────────────────────────────────── /// Initialise a `.nbd/tickets/` store in the current working directory. /// /// Uses `create_dir_all`, so it is safe to call repeatedly (idempotent). /// Does **not** call [`find_nbd_root`] — the store is always created in cwd. async fn cmd_init(json: bool) -> store::Result<()> { let cwd = std::env::current_dir()?; ensure_tickets_dir(&cwd).await?; if json { let path = cwd.join(".nbd").join("tickets"); println!( "{{\"root\":{}}}", serde_json::to_string(&path.to_string_lossy())? ); } else { println!( "initialised .nbd/tickets/ in {}", cwd.join(".nbd").join("tickets").display() ); } Ok(()) } /// List all tickets that are ready to work on and print them. /// /// A ticket is *ready* when its status is not [`Status::Done`] and every ID in /// its `dependencies` list belongs to a ticket with `status == Done`. /// Missing dependency IDs are treated conservatively — the ticket is **not** /// ready if any dep cannot be resolved. async fn cmd_ready(json: bool) -> store::Result<()> { let root = find_nbd_root()?; let all = list_tickets(&root).await?; // Build the set of IDs that are fully done. let done_ids: std::collections::HashSet<&str> = all .iter() .filter(|t| t.status == crate::ticket::Status::Done) .map(|t| t.id.as_str()) .collect(); let ready: Vec<&crate::ticket::Ticket> = all .iter() .filter(|t| { t.status != crate::ticket::Status::Done && t.dependencies .iter() .all(|dep| done_ids.contains(dep.as_str())) }) .collect(); if json { display::print_list_json(&ready.into_iter().cloned().collect::>()); } else { display::print_list(&ready.into_iter().cloned().collect::>()); } Ok(()) } /// Re-serialise all ticket files through the current schema and print a summary. /// /// When `dry_run` is `true`, describe what *would* change without writing any /// files. The command exits zero even when individual files fail to parse — /// those errors are included in the summary. async fn cmd_migrate(dry_run: bool, json: bool) -> store::Result<()> { let root = find_nbd_root()?; let report = migrate_tickets(&root, dry_run).await?; if json { display::print_migrate_report_json(&report); } else { display::print_migrate_report(&report); } Ok(()) } /// Create a new ticket, persist it, and print it. /// /// Generates a fresh ID, validates `priority` and all dependency IDs, then /// writes the ticket to `.nbd/tickets/{id}.json`. async fn cmd_create( title: String, body: String, priority: u8, status: String, ticket_type: String, deps: Option, json: bool, ) -> store::Result<()> { validate_priority(priority) .map_err(|e| -> Box { e.into() })?; let root = find_nbd_root()?; ensure_tickets_dir(&root).await?; let mut dependencies = parse_deps(deps.as_deref()); validate_deps(&root, &mut dependencies).await?; let id = generate_id(); let mut ticket = Ticket::new(id, title); ticket.body = body; ticket.priority = priority; ticket.status = parse_status(&status)?; ticket.ticket_type = parse_ticket_type(&ticket_type)?; ticket.dependencies = dependencies; write_ticket(&root, &ticket).await?; if json { display::print_ticket_json(&ticket); } else { display::print_ticket(&ticket); } Ok(()) } /// Read a ticket by ID (or unique prefix) and print it. async fn cmd_read(id: String, json: bool) -> store::Result<()> { let root = find_nbd_root()?; let id = resolve_id(&root, &id).await?; let ticket = read_ticket(&root, &id).await?; if json { display::print_ticket_json(&ticket); } else { display::print_ticket(&ticket); } Ok(()) } /// List all tickets sorted by priority and print them. async fn cmd_list(json: bool) -> store::Result<()> { let root = find_nbd_root()?; let tickets = list_tickets(&root).await?; if json { display::print_list_json(&tickets); } else { display::print_list(&tickets); } Ok(()) } /// Update the specified fields of an existing ticket, persist it, and print it. /// /// Only the flags explicitly passed on the command line are applied; all other /// fields keep their current values. `id` may be a full 6-character ID or a /// unique prefix. #[allow(clippy::too_many_arguments)] async fn cmd_update( id: String, title: Option, body: Option, priority: Option, status: Option, ticket_type: Option, deps: Option, json: bool, ) -> store::Result<()> { let root = find_nbd_root()?; let id = resolve_id(&root, &id).await?; let mut ticket = read_ticket(&root, &id).await?; if let Some(t) = title { ticket.title = t; } if let Some(b) = body { ticket.body = b; } if let Some(p) = priority { validate_priority(p) .map_err(|e| -> Box { e.into() })?; ticket.priority = p; } if let Some(s) = status { ticket.status = parse_status(&s)?; } if let Some(tt) = ticket_type { ticket.ticket_type = parse_ticket_type(&tt)?; } if deps.is_some() { let mut dependencies = parse_deps(deps.as_deref()); validate_deps(&root, &mut dependencies).await?; ticket.dependencies = dependencies; } write_ticket(&root, &ticket).await?; if json { display::print_ticket_json(&ticket); } else { display::print_ticket(&ticket); } Ok(()) }