You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

468 lines
14 KiB
Rust

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

//! `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 010 (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<String>,
},
/// 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<String>,
/// New body.
#[arg(long)]
body: Option<String>,
/// New priority (010).
#[arg(long)]
priority: Option<u8>,
/// New status: `todo`, `in_progress`, or `done`.
#[arg(long)]
status: Option<String>,
/// New ticket type: `project`, `feature`, `task`, or `bug`.
#[arg(long = "type")]
ticket_type: Option<String>,
/// New comma-separated dependency IDs (replaces the existing list).
#[arg(long)]
deps: Option<String>,
},
}
// ── 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<Status> {
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<TicketType> {
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<String>`.
///
/// Returns an empty `Vec` when `deps` is `None` or an empty string.
fn parse_deps(deps: Option<&str>) -> Vec<String> {
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<dyn std::error::Error + Send + Sync> {
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::<Vec<_>>());
} else {
display::print_list(&ready.into_iter().cloned().collect::<Vec<_>>());
}
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<String>,
json: bool,
) -> store::Result<()> {
validate_priority(priority)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { 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<String>,
body: Option<String>,
priority: Option<u8>,
status: Option<String>,
ticket_type: Option<String>,
deps: Option<String>,
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<dyn std::error::Error + Send + Sync> { 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(())
}