feat(nbd): add 'nbd graph' CLI subcommand [9ad11f]

Wires up the graph command in main.rs with cmd_graph(). Supports
full dependency forest (nbd graph), subtree by ID/prefix (nbd graph
<id>), --filter narrowing, and --json machine-readable output. Fixes
tree-rendering indentation bug (child_base vs prefix separation).
Updates README.md and CLAUDE.md with graph command documentation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
quotesdb
Elijah Voigt 3 months ago
parent d5697d5c4e
commit b0359c4392

@ -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 <id> # subtree rooted at the given ticket ID (or unique prefix)
nbd graph --json # machine-readable adjacency list (all tickets)
nbd graph <id> --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<String>,
/// 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<String>,
}
```
## Handler: `cmd_graph`
```rust
async fn cmd_graph(id: Option<String>, filter_args: Vec<String>, 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 <id> --json`):**
Same shape as full graph but only including nodes and edges reachable from `<id>`.
## Files touched
- `src/main.rs``Commands::Graph` variant, `cmd_graph`, dispatch in `dispatch()`
## Depends on
- `9c9ebe` — graph computation module
- `e14172` — ASCII rendering functions

@ -64,6 +64,8 @@ nbd ready [--json]
nbd update <id> [--title "..."] [--body "..."] [--priority N]
[--status ...] [--type ...] [--deps ...] [--json]
nbd graph [<id>] [--filter KEY=VALUE ...] [--json]
```
`--json` is available on all commands for machine-readable output.
@ -75,7 +77,9 @@ nbd update <id> [--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

@ -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 <id> # subtree rooted at the given ticket
nbd graph --json # machine-readable adjacency list
nbd graph <id> --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": <blocker>, "to": <blocked>}` — 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 <id>
cargo run -- update <id> --status in_progress
cargo run -- archive <id>
cargo run -- list --json
cargo run -- graph
cargo run -- graph <id>
cargo run -- graph --json
cargo run -- claude-md
cargo run -- claude-md --json
```

@ -287,7 +287,7 @@ pub fn format_graph(graph: &TicketGraph<'_>) -> String {
let mut out = String::new();
let mut visited: HashSet<String> = 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<String> = 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<String>,
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,
);
}
}

@ -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<String>,
/// 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<String>,
},
/// 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<String>, filter_args: Vec<String>, json: bool) -> store::Result<()> {
let filter = crate::filter::parse_filters(&filter_args)?;
let root = find_nbd_root()?;
let tickets: Vec<Ticket> = 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

Loading…
Cancel
Save