diff --git a/nbd/.nbd/tickets/9ad11f.md b/nbd/.nbd/tickets/9ad11f.md new file mode 100644 index 0000000..629761b --- /dev/null +++ b/nbd/.nbd/tickets/9ad11f.md @@ -0,0 +1,90 @@ ++++ +title = "Add 'nbd graph' CLI subcommand" +priority = 5 +status = "done" +ticket_type = "feature" +dependencies = ["9c9ebe", "e14172"] ++++ +Wire up the `graph` subcommand in `src/main.rs` to expose the ASCII dependency graph and JSON output. + +## Motivation + +With `src/graph.rs` and the rendering functions in `display.rs` implemented, this ticket connects them to the CLI so users (and agents) can invoke `nbd graph`. + +## CLI interface + +```sh +nbd graph # full dependency forest (all tickets) +nbd graph # subtree rooted at the given ticket ID (or unique prefix) +nbd graph --json # machine-readable adjacency list (all tickets) +nbd graph --json # machine-readable subtree +nbd graph --filter type=bug # forest of only bug tickets and their deps +``` + +## Clap definition (add to `Commands` enum in main.rs) + +```rust +/// Draw an ASCII dependency graph of all tickets, or a subtree rooted at a +/// specific ticket. +/// +/// Without an ID, renders every ticket as a dependency forest (roots first, +/// dependencies indented below). With an ID, renders only the subtree reachable +/// from that ticket via its dependencies. +Graph { + /// Optional ticket ID or unique prefix to use as the graph root. + /// + /// When omitted, the full dependency forest is rendered. + id: Option, + + /// Filter tickets by field before building the graph: repeatable `key=value` pairs. + /// + /// Applied to the full ticket list before graph construction. + /// Keys: `status`, `type`, `priority`, `title`. Values support globs. + #[arg(long = "filter", value_name = "KEY=VALUE")] + filter: Vec, +} +``` + +## Handler: `cmd_graph` + +```rust +async fn cmd_graph(id: Option, filter_args: Vec, json: bool) -> store::Result<()> +``` + +Steps: +1. `find_nbd_root()` and `list_tickets(&root).await?`. +2. Apply `parse_filters(&filter_args)?` to narrow the ticket list. +3. `let graph = TicketGraph::build(&tickets);` +4. If `id` is `Some`: + a. Resolve `id` via `resolve_id(&root, &id).await?`. + b. If `json`: print `graph.to_subtree_json_value(&id)` (or similar — see graph.rs ticket for the JSON shape). + c. Else: `display::print_subtree(&graph, &id)`. +5. If `id` is `None`: + a. If `json`: print `graph.to_json_value()`. + b. Else: `display::print_graph(&graph)`. + +## JSON output shapes + +**Full graph (`nbd graph --json`):** +```json +{ + "nodes": [ + {"id": "a3f9c2", "title": "Fix login bug", "status": "todo", "priority": 8, "dependencies": ["b7d41e"]}, + ... + ], + "edges": [ + {"from": "a3f9c2", "to": "b7d41e"}, + ... + ] +} +``` + +**Subtree (`nbd graph --json`):** +Same shape as full graph but only including nodes and edges reachable from ``. + +## Files touched +- `src/main.rs` — `Commands::Graph` variant, `cmd_graph`, dispatch in `dispatch()` + +## Depends on +- `9c9ebe` — graph computation module +- `e14172` — ASCII rendering functions \ No newline at end of file diff --git a/nbd/CLAUDE.md b/nbd/CLAUDE.md index 45a6cbe..ec35fb6 100644 --- a/nbd/CLAUDE.md +++ b/nbd/CLAUDE.md @@ -64,6 +64,8 @@ nbd ready [--json] nbd update [--title "..."] [--body "..."] [--priority N] [--status ...] [--type ...] [--deps ...] [--json] + +nbd graph [] [--filter KEY=VALUE ...] [--json] ``` `--json` is available on all commands for machine-readable output. @@ -75,7 +77,9 @@ nbd update [--title "..."] [--body "..."] [--priority N] | `main.rs` | `clap` CLI definition and subcommand dispatch only | | `ticket.rs` | `Ticket` struct, enums, ID generation, validation | | `store.rs` | directory traversal, file read/write, CRUD operations | -| `display.rs` | tabular formatting and JSON output | +| `display.rs` | tabular formatting, JSON output, ASCII graph rendering | +| `graph.rs` | dependency graph computation (`TicketGraph`, edges, subtrees) | +| `filter.rs` | glob-pattern ticket filtering | | `tests.rs` | unit tests for all modules | ## Task Tracking with nbd diff --git a/nbd/README.md b/nbd/README.md index 125f89c..27382fc 100644 --- a/nbd/README.md +++ b/nbd/README.md @@ -125,6 +125,33 @@ nbd ready nbd ready --json ``` +### Visualise dependencies + +Draw an ASCII dependency graph showing which tickets block which others. +Roots (tickets with no dependencies) appear at the top; tickets that depend on +them are indented below using box-drawing characters: + +```sh +nbd graph # full dependency forest +nbd graph # subtree rooted at the given ticket +nbd graph --json # machine-readable adjacency list +nbd graph --json # machine-readable subtree +nbd graph --filter type=bug # only bug tickets and their dependents +``` + +Example output: + +``` +a3f9c2 [done] Fix login bug +├── b7d41e [in_progress] Add rate limiting +│ └── c9e823 [todo] Write tests +└── d1f302 [done] Update docs +``` + +When a ticket appears in multiple subtrees it is rendered as `[cycle]` on its +second occurrence to keep the output finite. Edges in JSON output use +`{"from": , "to": }` — the blocking ticket is `from`. + ### Print a CLAUDE.md snippet Print a ready-to-paste section for adopting `nbd` in any project's CLAUDE.md: @@ -163,6 +190,9 @@ cargo run -- read cargo run -- update --status in_progress cargo run -- archive cargo run -- list --json +cargo run -- graph +cargo run -- graph +cargo run -- graph --json cargo run -- claude-md cargo run -- claude-md --json ``` diff --git a/nbd/src/display.rs b/nbd/src/display.rs index 7c2b8f2..1f64097 100644 --- a/nbd/src/display.rs +++ b/nbd/src/display.rs @@ -287,7 +287,7 @@ pub fn format_graph(graph: &TicketGraph<'_>) -> String { let mut out = String::new(); let mut visited: HashSet = HashSet::new(); for root in graph.roots() { - render_node(graph, &root.id, "", "", &mut visited, &mut out); + render_node(graph, &root.id, "", "", "", &mut visited, &mut out); } out } @@ -307,7 +307,7 @@ pub fn format_subtree(graph: &TicketGraph<'_>, root_id: &str) -> String { let mut out = String::new(); let mut visited: HashSet = HashSet::new(); if graph.get_node(root_id).is_some() { - render_node(graph, root_id, "", "", &mut visited, &mut out); + render_node(graph, root_id, "", "", "", &mut visited, &mut out); } out } @@ -321,9 +321,14 @@ pub fn print_subtree(graph: &TicketGraph<'_>, root_id: &str) { /// Recursively render a single node and its dependents into `out`. /// -/// `prefix` is the accumulated indentation from ancestor levels. -/// `connector` is the box-drawing characters connecting this node to its -/// parent (`""` for roots, `"├── "` or `"└── "` for children). +/// Parameters: +/// - `prefix` — the indentation printed before `connector` on this node's line. +/// This is the `child_base` that the parent passed down. +/// - `connector` — the box-drawing connector for this node (`""` for roots, +/// `"├── "` or `"└── "` for children). +/// - `child_base` — the base prefix handed to this node's *children* (it +/// becomes their `prefix`). It accumulates one more level of `│ ` or +/// ` ` with each descent. /// /// When a node ID is already in `visited`, it is rendered as `[cycle]` and /// the recursion stops, preventing infinite loops in cyclic data. @@ -332,6 +337,7 @@ fn render_node( id: &str, prefix: &str, connector: &str, + child_base: &str, visited: &mut HashSet, out: &mut String, ) { @@ -362,9 +368,19 @@ fn render_node( let n = dependents.len(); for (i, dep_id) in dependents.iter().enumerate() { let is_last = i == n - 1; + // The child's connector on its own line. let child_connector = if is_last { "└── " } else { "├── " }; - let child_prefix = format!("{prefix}{}", if is_last { " " } else { "│ " }); - render_node(graph, dep_id, &child_prefix, child_connector, visited, out); + // The grandchildren's base: extend child_base by one more level. + let grandchild_base = format!("{child_base}{}", if is_last { " " } else { "│ " }); + render_node( + graph, + dep_id, + child_base, + child_connector, + &grandchild_base, + visited, + out, + ); } } diff --git a/nbd/src/main.rs b/nbd/src/main.rs index 3dbbf84..81d2ac6 100644 --- a/nbd/src/main.rs +++ b/nbd/src/main.rs @@ -24,6 +24,7 @@ mod tests; use clap::{Parser, Subcommand}; +use crate::graph::TicketGraph; use crate::store::{ detect_format, ensure_tickets_dir, find_nbd_root, find_ticket_path, list_tickets, migrate_tickets, read_ticket, resolve_id, write_ticket, FileFormat, @@ -192,6 +193,32 @@ enum Commands { /// Does not require a `.nbd/` store to be present. ClaudeMd, + /// Draw an ASCII dependency graph of all tickets, or a subtree rooted at a + /// specific ticket. + /// + /// Without an ID, renders every ticket as a dependency forest: roots (tickets + /// with no dependencies) appear at the top; tickets that depend on them are + /// indented below using box-drawing characters (├──, └──, │). + /// + /// With an ID, renders only the subtree reachable from that ticket via its + /// dependents (tickets that list it as a dependency, transitively). + /// + /// Cycles are detected and labelled `[cycle]` rather than looping forever. + Graph { + /// Optional ticket ID or unique prefix to root the graph at. + /// + /// When omitted, the full dependency forest is rendered. + id: Option, + + /// Filter tickets before building the graph: repeatable `key=value` pairs. + /// + /// Keys: `status`, `type`, `priority`, `title`. Values support globs. + /// Applied to the full ticket list before graph construction; tickets that + /// do not match are excluded from all nodes and edges. + #[arg(long = "filter", value_name = "KEY=VALUE")] + filter: Vec, + }, + /// Update fields of an existing ticket and print the result. /// /// Only the flags you supply are changed; all other fields retain their @@ -281,6 +308,8 @@ async fn dispatch(cli: Cli) -> store::Result<()> { Commands::ClaudeMd => cmd_claude_md(cli.json), + Commands::Graph { id, filter } => cmd_graph(id, filter, cli.json).await, + Commands::Read { id } => cmd_read(id, cli.json).await, Commands::List { filter, all } => cmd_list(filter, all, cli.json).await, @@ -680,6 +709,47 @@ fn cmd_claude_md(json: bool) -> store::Result<()> { Ok(()) } +/// Render the ticket dependency graph and print it. +/// +/// With no `id`, renders the full dependency forest (all tickets that pass +/// `filter_args`). With an `id` (or unique prefix), renders only the subtree +/// reachable from that ticket via its dependents. +/// +/// `filter_args` are applied to the full ticket list before building the graph; +/// tickets that do not match are excluded from all nodes and edges. +async fn cmd_graph(id: Option, filter_args: Vec, json: bool) -> store::Result<()> { + let filter = crate::filter::parse_filters(&filter_args)?; + let root = find_nbd_root()?; + let tickets: Vec = list_tickets(&root) + .await? + .into_iter() + .filter(|t| filter.matches(t)) + .collect(); + let graph = TicketGraph::build(&tickets); + + match id { + Some(raw_id) => { + let resolved = resolve_id(&root, &raw_id).await?; + if json { + let value = graph.to_subtree_json_value(&resolved); + println!("{}", serde_json::to_string_pretty(&value)?); + } else { + display::print_subtree(&graph, &resolved); + } + } + None => { + if json { + let value = graph.to_json_value(); + println!("{}", serde_json::to_string_pretty(&value)?); + } else { + display::print_graph(&graph); + } + } + } + + 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