From b335009a21b5d9d0187aa14ffb5ed6de946fecbf Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Wed, 11 Mar 2026 10:22:52 -0700 Subject: [PATCH] chore: remove nbd project nbd has been superseded by beans for issue tracking. All tickets were migrated to .beans/ in the previous commits. Remove the nbd/ project directory, its flake input, and all references. - Remove nbd/ (source, tests, docs, beans, skills, flake) - Remove nbd flake input and nbd package from devShell in flake.nix - Update flake.lock to drop nbd and its transitive inputs - Remove nbd entry from PROJECTS.md Co-Authored-By: Claude Sonnet 4.6 --- PROJECTS.md | 3 - flake.lock | 57 +- flake.nix | 6 +- nbd/.beans.yml | 6 - .../nbd-08jg--add-nbd-next-subcommand.md | 184 -- ...7dp--ascii-graph-rendering-in-displayrs.md | 73 - nbd/.beans/nbd-2i1e--nbd-claude-md-command.md | 71 - ...e-done-tickets-from-nbd-list-by-default.md | 138 - .../nbd-56ho--tests-for-nbd-graph-command.md | 95 - ...ts-in-markdown-format-instead-of-key-va.md | 100 - ...nbd-7cab--add-backlog-status-to-tickets.md | 83 - ...convenience-sub-commands-open-start-com.md | 51 - nbd/.beans/nbd-95l6--nbd-init-command.md | 36 - ...atus-sub-commands-list-todo-list-backlo.md | 52 - ...-nbdconfigtoml-for-per-project-defaults.md | 76 - ...add-graph-computation-module-srcgraphrs.md | 79 - .../nbd-d9dh--add-nbd-graph-cli-subcommand.md | 96 - .../nbd-fgwx--nbd-update-diff-output.md | 56 - ...de-md-snippet-to-show-json-on-all-comma.md | 54 - nbd/.beans/nbd-i0fc--nbd-ready-command.md | 44 - ...chiveclosed-archivedone-closedcancelled.md | 91 - ...ext-and-nbd-ready-by-dependency-subtree.md | 105 - ...rientation-show-goals-at-root-prerequis.md | 48 - ...ph-id-show-ancestry-path-through-ticket.md | 62 - ...2--nbd-init-add-cachedb-to-nbdgitignore.md | 36 - ...put-format-that-wraps-each-ticket-secti.md | 107 - ...-user-configurable-type-and-status-stri.md | 51 - ...-add-version-flag-with-xyzgitsha-format.md | 129 - ...--remove-id-field-from-ticket-json-body.md | 83 - ...-oziy--turso-cache-for-list-performance.md | 70 - ...-flag-into-list-ready-and-migrate-comma.md | 155 -- ...--nbd-archive-command-and-closed-status.md | 53 - nbd/.beans/nbd-q2d1--nbd-migrate-command.md | 116 - ...ype-filtered-sub-commands-next-bug-next.md | 47 - nbd/.beans/nbd-rulc--partial-id-matching.md | 46 - ...tiple-file-format-support-md-toml-jsonb.md | 87 - ...-filtering-tickets-by-project-stream-of.md | 34 - nbd/.beans/nbd-wbf7--nix-flake-for-nbd.md | 41 - ...e-compile-into-binary-via-include-str-a.md | 79 - ...change-graph-cycle-marker-from-cycle-to.md | 61 - ...-status-new-default-for-tickets-lacking.md | 85 - ...-ticketfilter-module-with-glob-matching.md | 135 - nbd/.claude/settings.json | 11 - nbd/.claude/skills/triage/SKILL.md | 12 - nbd/.claude/skills/work/SKILL.md | 15 - nbd/CLAUDE.md | 184 -- nbd/Cargo.lock | 2465 ----------------- nbd/Cargo.toml | 28 - nbd/PLAN.md | 179 -- nbd/README.md | 268 -- nbd/TODO.md | 1 - nbd/build.rs | 31 - nbd/docs/ARCHITECTURE.md | 127 - nbd/docs/PLANNING.md | 79 - nbd/flake.lock | 48 - nbd/flake.nix | 98 - nbd/src/claude_md_snippet.md | 11 - nbd/src/display.rs | 473 ---- nbd/src/filter.rs | 267 -- nbd/src/graph.rs | 287 -- nbd/src/main.rs | 938 ------- nbd/src/store.rs | 837 ------ nbd/src/tests.rs | 1698 ------------ nbd/src/ticket.rs | 165 -- nbd/tests/integration.rs | 2401 ---------------- 65 files changed, 3 insertions(+), 13701 deletions(-) delete mode 100644 nbd/.beans.yml delete mode 100644 nbd/.beans/nbd-08jg--add-nbd-next-subcommand.md delete mode 100644 nbd/.beans/nbd-17dp--ascii-graph-rendering-in-displayrs.md delete mode 100644 nbd/.beans/nbd-2i1e--nbd-claude-md-command.md delete mode 100644 nbd/.beans/nbd-4w8z--exclude-done-tickets-from-nbd-list-by-default.md delete mode 100644 nbd/.beans/nbd-56ho--tests-for-nbd-graph-command.md delete mode 100644 nbd/.beans/nbd-6j0q--print-tickets-in-markdown-format-instead-of-key-va.md delete mode 100644 nbd/.beans/nbd-7cab--add-backlog-status-to-tickets.md delete mode 100644 nbd/.beans/nbd-8yn8--add-status-convenience-sub-commands-open-start-com.md delete mode 100644 nbd/.beans/nbd-95l6--nbd-init-command.md delete mode 100644 nbd/.beans/nbd-9mxu--add-list-status-sub-commands-list-todo-list-backlo.md delete mode 100644 nbd/.beans/nbd-9vrn--add-nbdconfigtoml-for-per-project-defaults.md delete mode 100644 nbd/.beans/nbd-csdh--add-graph-computation-module-srcgraphrs.md delete mode 100644 nbd/.beans/nbd-d9dh--add-nbd-graph-cli-subcommand.md delete mode 100644 nbd/.beans/nbd-fgwx--nbd-update-diff-output.md delete mode 100644 nbd/.beans/nbd-flbj--update-claude-md-snippet-to-show-json-on-all-comma.md delete mode 100644 nbd/.beans/nbd-i0fc--nbd-ready-command.md delete mode 100644 nbd/.beans/nbd-jc1v--split-archiveclosed-archivedone-closedcancelled.md delete mode 100644 nbd/.beans/nbd-kq6o--scope-nbd-next-and-nbd-ready-by-dependency-subtree.md delete mode 100644 nbd/.beans/nbd-l38k--fix-graph-orientation-show-goals-at-root-prerequis.md delete mode 100644 nbd/.beans/nbd-lins--fix-nbd-graph-id-show-ancestry-path-through-ticket.md delete mode 100644 nbd/.beans/nbd-m9q2--nbd-init-add-cachedb-to-nbdgitignore.md delete mode 100644 nbd/.beans/nbd-mkkm--add-xml-output-format-that-wraps-each-ticket-secti.md delete mode 100644 nbd/.beans/nbd-n2xz--investigate-user-configurable-type-and-status-stri.md delete mode 100644 nbd/.beans/nbd-no88--add-version-flag-with-xyzgitsha-format.md delete mode 100644 nbd/.beans/nbd-o3k8--remove-id-field-from-ticket-json-body.md delete mode 100644 nbd/.beans/nbd-oziy--turso-cache-for-list-performance.md delete mode 100644 nbd/.beans/nbd-p9na--wire-filter-flag-into-list-ready-and-migrate-comma.md delete mode 100644 nbd/.beans/nbd-pq2x--nbd-archive-command-and-closed-status.md delete mode 100644 nbd/.beans/nbd-q2d1--nbd-migrate-command.md delete mode 100644 nbd/.beans/nbd-ql0c--add-next-type-filtered-sub-commands-next-bug-next.md delete mode 100644 nbd/.beans/nbd-rulc--partial-id-matching.md delete mode 100644 nbd/.beans/nbd-s16w--multiple-file-format-support-md-toml-jsonb.md delete mode 100644 nbd/.beans/nbd-urlz--investigate-filtering-tickets-by-project-stream-of.md delete mode 100644 nbd/.beans/nbd-wbf7--nix-flake-for-nbd.md delete mode 100644 nbd/.beans/nbd-wi74--version-file-compile-into-binary-via-include-str-a.md delete mode 100644 nbd/.beans/nbd-y4nv--change-graph-cycle-marker-from-cycle-to.md delete mode 100644 nbd/.beans/nbd-yh0v--add-triage-status-new-default-for-tickets-lacking.md delete mode 100644 nbd/.beans/nbd-zz62--implement-ticketfilter-module-with-glob-matching.md delete mode 100644 nbd/.claude/settings.json delete mode 100644 nbd/.claude/skills/triage/SKILL.md delete mode 100644 nbd/.claude/skills/work/SKILL.md delete mode 100644 nbd/CLAUDE.md delete mode 100644 nbd/Cargo.lock delete mode 100644 nbd/Cargo.toml delete mode 100644 nbd/PLAN.md delete mode 100644 nbd/README.md delete mode 100644 nbd/TODO.md delete mode 100644 nbd/build.rs delete mode 100644 nbd/docs/ARCHITECTURE.md delete mode 100644 nbd/docs/PLANNING.md delete mode 100644 nbd/flake.lock delete mode 100644 nbd/flake.nix delete mode 100644 nbd/src/claude_md_snippet.md delete mode 100644 nbd/src/display.rs delete mode 100644 nbd/src/filter.rs delete mode 100644 nbd/src/graph.rs delete mode 100644 nbd/src/main.rs delete mode 100644 nbd/src/store.rs delete mode 100644 nbd/src/tests.rs delete mode 100644 nbd/src/ticket.rs delete mode 100644 nbd/tests/integration.rs diff --git a/PROJECTS.md b/PROJECTS.md index 4d654db..a137838 100644 --- a/PROJECTS.md +++ b/PROJECTS.md @@ -1,8 +1,5 @@ # TODO -## Tools -- [x] `nbd` agent tasks management cli - ## Projects - [ ] QuotesDB website - [ ] Local-first Grocery List app diff --git a/flake.lock b/flake.lock index fcec752..eeb5fe0 100644 --- a/flake.lock +++ b/flake.lock @@ -18,38 +18,7 @@ "type": "github" } }, - "nbd": { - "inputs": { - "nixpkgs": "nixpkgs", - "rust-overlay": "rust-overlay" - }, - "locked": { - "path": "./nbd", - "type": "path" - }, - "original": { - "path": "./nbd", - "type": "path" - }, - "parent": [] - }, "nixpkgs": { - "locked": { - "lastModified": 1771369470, - "narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "0182a361324364ae3f436a63005877674cf45efb", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_2": { "locked": { "lastModified": 1772956932, "narHash": "sha256-M0yS4AafhKxPPmOHGqIV0iKxgNO8bHDWdl1kOwGBwRY=", @@ -68,33 +37,11 @@ "root": { "inputs": { "flake-utils": "flake-utils", - "nbd": "nbd", - "nixpkgs": "nixpkgs_2", - "rust-overlay": "rust-overlay_2" + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" } }, "rust-overlay": { - "inputs": { - "nixpkgs": [ - "nbd", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1771816254, - "narHash": "sha256-vkp3iTF6QmHMvL+34DI93IiMPjS2lqcMlA1fl7nXVsQ=", - "owner": "oxalica", - "repo": "rust-overlay", - "rev": "085bdbf5dde5477538e4c87d1684b6c6df56c0ad", - "type": "github" - }, - "original": { - "owner": "oxalica", - "repo": "rust-overlay", - "type": "github" - } - }, - "rust-overlay_2": { "inputs": { "nixpkgs": [ "nixpkgs" diff --git a/flake.nix b/flake.nix index c7eb520..3ee739b 100644 --- a/flake.nix +++ b/flake.nix @@ -8,10 +8,9 @@ url = "github:oxalica/rust-overlay"; inputs.nixpkgs.follows = "nixpkgs"; }; - nbd.url = "path:./nbd"; }; - outputs = { self, nixpkgs, flake-utils, rust-overlay, nbd }: + outputs = { self, nixpkgs, flake-utils, rust-overlay }: flake-utils.lib.eachDefaultSystem (system: let overlays = [ (import rust-overlay) ]; @@ -52,9 +51,6 @@ # jq for json parsing pkgs.jq - # NBD task management - nbd.packages.${system}.nbd - # Task management pkgs.beans diff --git a/nbd/.beans.yml b/nbd/.beans.yml deleted file mode 100644 index 146c095..0000000 --- a/nbd/.beans.yml +++ /dev/null @@ -1,6 +0,0 @@ -beans: - path: .beans - prefix: nbd- - id_length: 4 - default_status: todo - default_type: task diff --git a/nbd/.beans/nbd-08jg--add-nbd-next-subcommand.md b/nbd/.beans/nbd-08jg--add-nbd-next-subcommand.md deleted file mode 100644 index 385f146..0000000 --- a/nbd/.beans/nbd-08jg--add-nbd-next-subcommand.md +++ /dev/null @@ -1,184 +0,0 @@ ---- -# nbd-08jg -title: Add nbd next subcommand -status: completed -type: feature -priority: high -created_at: 2026-03-10T23:30:31Z -updated_at: 2026-03-10T23:30:32Z -blocked_by: - - nbd-p9na ---- - -## Summary - -Add `nbd next` subcommand that selects the single highest-priority ticket that is -ready to work on. Supports `--filter` for additional narrowing within the ready set. - -Depends on: `--filter` wired into CLI (ticket 887344). Implicitly depends on the -TicketFilter module (ticket c2a024) through that. - -## Definition of "ready" - -Same semantics as `nbd ready`: -- `status \!= done` -- Every ID in `ticket.dependencies` belongs to a ticket with `status == done`. -- Missing dependency IDs are treated conservatively: the ticket is NOT ready. - -## Behaviour - -1. Load all tickets with `list_tickets(&root)` (already sorted by priority desc). -2. Build `done_ids` set (same as `cmd_ready`). -3. Find the first ticket (highest priority) where: - - status \!= done - - all dependencies are in done_ids - - `filter.matches(t)` (if a filter was provided) -4. If found: display the single ticket. -5. If not found: print a "no ready tickets" message. Exit 0 (not an error). - -## Output - -Without `--json`: -- Found: print the ticket in the same tabular format as `nbd read` (via `display::print_ticket`). -- Not found: `No ready tickets.` - -With `--json`: -- Found: `{"next": { ...ticket fields including id... }}` -- Not found: `{"next": null}` - -Wrapping in `{"next": ...}` (rather than bare ticket or bare null) makes the JSON -unambiguously parseable by consumers — they always get an object with a `next` key. - -## Implementation - -Add to `Commands` enum: - -```rust -/// Choose the highest-priority ticket that is ready to work on. -/// -/// A ticket is ready when its status is not `done` and every ticket it -/// depends on has status `done`. Returns the single highest-priority -/// ready ticket, optionally narrowed by `--filter KEY=VALUE`. -/// -/// Exits 0 even when no ready ticket exists. -Next { - /// Filter ready tickets: key=value pairs (repeatable). - /// AND between different keys, OR within same key. - #[arg(long = "filter", value_name = "KEY=VALUE")] - filter: Vec, -}, -``` - -Add dispatch arm: - -```rust -Commands::Next { filter } => cmd_next(filter, cli.json).await, -``` - -Implement `cmd_next`: - -```rust -async fn cmd_next(filter_args: Vec, json: bool) -> store::Result<()> { - let root = find_nbd_root()?; - let all = list_tickets(&root).await?; // sorted by priority desc - let filter = filter::parse_filters(&filter_args)?; - - let done_ids: std::collections::HashSet<&str> = all - .iter() - .filter(|t| t.status == Status::Done) - .map(|t| t.id.as_str()) - .collect(); - - let next = all.iter().find(|t| { - t.status \!= Status::Done - && t.dependencies.iter().all(|dep| done_ids.contains(dep.as_str())) - && filter.matches(t) - }); - - if json { - match next { - Some(ticket) => { - let value = serde_json::json\!({ - "next": display::ticket_to_json_value(ticket) - }); - println\!("{}", serde_json::to_string_pretty(&value)?); - } - None => println\!("{}", serde_json::json\!({"next": null})), - } - } else { - match next { - Some(ticket) => display::print_ticket(ticket), - None => println\!("No ready tickets."), - } - } - - Ok(()) -} -``` - -## display.rs: make ticket_to_json_value pub(crate) - -`ticket_to_json_value` is currently private in `display.rs`. It needs to be accessible -from `cmd_next` in `main.rs`. Change its visibility: - -```rust -pub(crate) fn ticket_to_json_value(ticket: &Ticket) -> serde_json::Value { ... } -``` - -This is the cleanest approach — it reuses the existing id-injection logic rather than -duplicating it. - -## README update - -Add a `### Find the next ticket to work on` section: - -```markdown -### Find the next ticket to work on - -Returns the single highest-priority ticket that is ready to work on — not done -and with all dependencies completed. - -```sh -nbd next -nbd next --json -nbd next --filter type=bug # highest-priority ready bug -nbd next --filter priority=9 # highest-priority ready ticket with priority 9 -``` - -Exits 0 even when no ready ticket exists. -``` - -## CLAUDE.md update - -Update the "Workflow" section to mention `nbd next` as an alternative to `nbd ready` -when the caller just wants to begin the single most important task: - -``` -**To get the single best ticket to work on next:** - -```sh -cargo run -- next --json -``` -``` - -## Files touched - -- `src/main.rs` — `Next` command variant, `cmd_next` handler, dispatch arm -- `src/display.rs` — `ticket_to_json_value` changed to `pub(crate)` -- `tests/integration.rs` — integration tests -- `README.md` — new section for `nbd next` -- `CLAUDE.md` — update workflow section - -## Integration tests to add (tests/integration.rs) - -- Three tickets: A (priority 5, no deps), B (priority 8, dep A), C (priority 7, no deps). - `nbd next --json` returns C (highest priority ready ticket — B is blocked by A). -- After marking A done: `nbd next --json` returns B (priority 8, now unblocked). -- With only done tickets: `nbd next --json` returns `{"next": null}`. -- `nbd next` (no `--json`) with no ready tickets prints "No ready tickets." and exits 0. -- `nbd next --filter type=bug --json`: create a bug and a task, both ready. - Returns the bug if it's the highest-priority bug, otherwise the highest-priority bug. - (Create bug priority 8, task priority 9: with filter, should return the bug.) -- `nbd next --json` returns a JSON object with a `"next"` key containing all ticket fields - including `"id"`. -- `nbd next --filter priority=99 --json` returns `{"next": null}` (no ticket has priority 99). diff --git a/nbd/.beans/nbd-17dp--ascii-graph-rendering-in-displayrs.md b/nbd/.beans/nbd-17dp--ascii-graph-rendering-in-displayrs.md deleted file mode 100644 index 0951dbd..0000000 --- a/nbd/.beans/nbd-17dp--ascii-graph-rendering-in-displayrs.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -# nbd-17dp -title: ASCII graph rendering in display.rs -status: completed -type: feature -priority: normal -created_at: 2026-03-10T23:30:31Z -updated_at: 2026-03-10T23:30:32Z -blocked_by: - - nbd-csdh ---- - -Add `format_graph` and `print_graph` to `src/display.rs` to render a ticket dependency DAG as an ASCII tree. - -## Motivation - -The `nbd graph` command needs to convert the `TicketGraph` data structure (from `src/graph.rs`) into a human-readable ASCII representation. Rendering belongs in `display.rs` next to `format_list` and `format_ticket`. - -## Output format - -The graph is a forest of trees. Each root ticket (no in-graph dependencies) starts at column 0. Its dependents (tickets that depend on it) are indented below it using box-drawing characters. - -``` -a3f9c2 [todo] Fix login bug -├── b7d41e [todo] Add rate limiting -│ └── c9e823 [in_progress] Write tests -└── d1f302 [done] Update docs -e4a781 [todo] New feature (no deps) -``` - -**Node format per line:** -``` -{prefix}{id} [{status}] {title} -``` - -Where `{prefix}` is built from indentation characters (`│ `, `├── `, `└── `). - -**Blocked indicator (optional):** Consider marking tickets that have unresolved (non-done/closed) dependencies with a `[blocked]` tag or `!` prefix so the graph visually distinguishes ready from blocked tickets. - -## Signatures - -```rust -/// Render the full dependency forest as an ASCII tree string. -pub fn format_graph(graph: &TicketGraph) -> String - -/// Print the full dependency forest. -pub fn print_graph(graph: &TicketGraph) - -/// Render the subtree rooted at `root_id` as an ASCII tree string. -pub fn format_subtree(graph: &TicketGraph, root_id: &str) -> String - -/// Print the subtree rooted at `root_id`. -pub fn print_subtree(graph: &TicketGraph, root_id: &str) -``` - -## Implementation notes - -- Use a recursive helper that tracks a `prefix: String` carrying the accumulated indentation characters. -- For each node's children (its dependents in the graph), iterate: - - If not the last child: prefix extension is `│ `; connector is `├── `. - - If the last child: prefix extension is ` `; connector is `└── `. -- Cycle guard: track a `visited: HashSet<&str>` across the recursion; if a node ID is already visited, render `{prefix}{connector}{id} [cycle]` and stop descending. -- Status is shown as the serde string: `todo`, `in_progress`, `done`, `closed`. - -## Files touched -- `src/display.rs` — `format_graph`, `print_graph`, `format_subtree`, `print_subtree` - -## Tests (unit, in `src/tests.rs`) -- `format_graph` on a single ticket with no deps produces a single line. -- `format_graph` on a two-ticket chain shows the child indented with `└──`. -- `format_graph` with a branching parent shows `├──` for all but the last child and `└──` for the last. -- `format_subtree` only shows the specified root's subtree. -- A cycle in the graph does not cause infinite recursion; the repeated node is labelled `[cycle]`. diff --git a/nbd/.beans/nbd-2i1e--nbd-claude-md-command.md b/nbd/.beans/nbd-2i1e--nbd-claude-md-command.md deleted file mode 100644 index 088ce9c..0000000 --- a/nbd/.beans/nbd-2i1e--nbd-claude-md-command.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -# nbd-2i1e -title: nbd claude-md command -status: completed -type: feature -priority: normal -created_at: 2026-03-10T23:30:31Z -updated_at: 2026-03-10T23:30:31Z ---- - -Add `nbd claude-md` subcommand that prints a ready-to-paste CLAUDE.md snippet for adopting `nbd` in any project. The snippet content is maintained as a source file and baked into the binary at compile time via `include_str!`. - -## Motivation - -Every project that wants to use `nbd` needs the same boilerplate in its CLAUDE.md: what `nbd` is, how to invoke it, the create/update/done workflow, and the key guidelines. Without this command, that block has to be written by hand and tends to drift out of date as `nbd` evolves. By owning the canonical snippet in the `nbd` source tree and embedding it into the binary, the snippet stays in sync with the tool automatically — projects just run `nbd claude-md >> CLAUDE.md` when they adopt or upgrade `nbd`. - -## Snippet file - -Create `src/claude_md_snippet.md`. This is a plain markdown file, checked into the repository, that contains the CLAUDE.md section any adopting project should paste in. It should cover: - -- One-sentence description of `nbd`. -- Initialisation (`nbd init`). -- The four core commands with examples (`create`, `list`, `read`, `update`). -- The `nbd ready` command for finding unblocked tickets. -- The workflow (create before starting → set in_progress → set done). -- Guidelines: always `--json`, priority scale, type choices, dep usage. - -The file is written for a project where `nbd` is installed in PATH (i.e. not via `cargo run`). It should be self-contained — no references to the `nbd` crate internals. - -## Binary embedding - -In `main.rs`, embed the snippet at compile time: - -```rust -const CLAUDE_MD_SNIPPET: &str = include_str!("claude_md_snippet.md"); -``` - -`include_str!` resolves paths relative to the source file (`src/`), so this looks for `src/claude_md_snippet.md`. Cargo rebuilds the binary automatically when the file changes. - -## Command implementation - -Add `ClaudeMd` variant to `Commands` in `main.rs`: - -```rust -/// Print a CLAUDE.md snippet for adopting nbd in a project. -ClaudeMd, -``` - -The `--json` global flag applies: -- Without `--json`: `print!("{CLAUDE_MD_SNIPPET}")` — raw markdown, suitable for redirect (`nbd claude-md >> CLAUDE.md`). -- With `--json`: output `{"snippet": ""}` — for programmatic consumption. - -No store access needed — this command is pure output from the embedded constant. It does not call `find_nbd_root()`. - -## No display.rs changes needed - -The output is a single `print!` call in the command handler. No tabular formatting. - -## Tests - -Integration tests (`tests/integration.rs`): -- `nbd claude-md` exits zero and stdout is non-empty. -- stdout contains key strings (`"nbd"`, `"CLAUDE.md"` or similar section header, `"--json"`). -- `nbd claude-md --json` exits zero and stdout is valid JSON with a `"snippet"` key whose value is a non-empty string. -- `nbd claude-md` works even when run from a directory with no `.nbd/` (no `find_nbd_root` call). - -## Files touched -- `src/claude_md_snippet.md` — new file; the canonical snippet content -- `src/main.rs` — `include_str!` constant, `ClaudeMd` command variant and handler -- `tests/integration.rs` — integration tests -- `README.md` — mention `nbd claude-md` in the Usage section diff --git a/nbd/.beans/nbd-4w8z--exclude-done-tickets-from-nbd-list-by-default.md b/nbd/.beans/nbd-4w8z--exclude-done-tickets-from-nbd-list-by-default.md deleted file mode 100644 index 723c5e2..0000000 --- a/nbd/.beans/nbd-4w8z--exclude-done-tickets-from-nbd-list-by-default.md +++ /dev/null @@ -1,138 +0,0 @@ ---- -# nbd-4w8z -title: Exclude done tickets from nbd list by default -status: completed -type: feature -priority: high -created_at: 2026-03-10T23:30:30Z -updated_at: 2026-03-10T23:30:32Z -blocked_by: - - nbd-p9na ---- - -## Summary - -Change `nbd list` to hide `done` tickets by default. Users must explicitly opt in -via `--filter status=done` or `--filter status=*` to see completed tickets. - -This is a **breaking change in default behavior**. - -Depends on: `--filter` wired into `list` (ticket 887344). - -## Current behaviour - -`nbd list` shows ALL tickets regardless of status. - -## New behaviour - -| Command | Shows | -|---|---| -| `nbd list` | todo + in_progress only (done excluded) | -| `nbd list --filter status=done` | done only | -| `nbd list --filter status=*` | all tickets (all statuses) | -| `nbd list --filter status=todo --filter status=in_progress` | todo and in_progress | -| `nbd list --filter type=bug` | non-done bug tickets (done still excluded) | -| `nbd list --filter type=bug --filter status=*` | all bug tickets including done | - -The key rule: **if the user provides no `status` filter key, done tickets are excluded**. -If the user provides any `--filter status=...` argument, the explicit status filter -is used as-is with no implicit exclusion. - -## Implementation (src/main.rs) - -In `cmd_list`, after parsing the filter and loading tickets, apply the logic: - -```rust -let filter = filter::parse_filters(&filter_args)?; -let tickets = list_tickets(&root).await?; - -let tickets: Vec = tickets - .into_iter() - .filter(|t| { - // If no status filter was provided by the user, exclude done tickets. - let status_ok = if filter.has_status_filter() { - // User expressed intent about status: use their filter. - filter.matches_status(t) - } else { - // Default: hide done tickets. - t.status \!= Status::Done - }; - - // Apply remaining filter keys (type, priority, title) regardless. - status_ok && filter.matches_except_status(t) - }) - .collect(); -``` - -This requires two additional methods on `TicketFilter` (add to `src/filter.rs`): - -```rust -/// Returns true if the ticket's status matches any of the status patterns. -/// Caller is responsible for only calling this when `has_status_filter()` is true. -pub fn matches_status(&self, ticket: &Ticket) -> bool; - -/// Returns true if the ticket matches all non-status filter groups (type, priority, title). -/// The status group is intentionally excluded so callers can handle it separately. -pub fn matches_except_status(&self, ticket: &Ticket) -> bool; -``` - -## CLI help text update - -Update the `List` variant doc comment: - -```rust -/// List tickets sorted by priority (highest first). -/// -/// By default, tickets with status `done` are excluded. Use -/// `--filter status=*` to include all tickets, or -/// `--filter status=done` to show only completed tickets. -List { - #[arg(long = "filter", value_name = "KEY=VALUE")] - filter: Vec, -}, -``` - -## Existing tests that need updating - -The integration test `list_shows_created_tickets` creates two tickets with default -status (`todo`) and asserts both appear in `nbd list`. This test is unaffected because -the default tickets are not done. However, any future test that creates a ticket and -immediately lists without marking it done will still work. - -Check: is there any existing test that creates a done ticket and expects it in `nbd list`? -If so, update that test to use `--filter status=done` or `--filter status=*`. - -## New integration tests to add (tests/integration.rs) - -- Create two tickets: one todo, one done. `nbd list` shows only the todo one. -- Create two tickets: one todo, one done. `nbd list --filter status=done` shows only the done one. -- Create two tickets: one todo, one done. `nbd list --filter status=*` shows both. -- Create 3 tickets: bug/todo, bug/done, task/todo. - `nbd list --filter type=bug` shows only bug/todo (done excluded by default). - `nbd list --filter type=bug --filter status=*` shows both bug tickets. -- `nbd list --json` does not include done tickets (verify JSON array length). -- `nbd list --filter status=* --json` includes done tickets. - -## README update - -Update the "List all tickets" section in README.md: - -``` -### List all tickets - -```sh -nbd list # excludes done tickets -nbd list --filter status=* # all tickets including done -nbd list --filter status=done # only completed tickets -nbd list --filter type=bug # non-done bug tickets -nbd list --json -``` -``` - -## Files touched - -- `src/main.rs` — `cmd_list` implementation and `List` help text -- `src/filter.rs` — `matches_status`, `matches_except_status` methods -- `src/tests.rs` — unit tests for new filter methods -- `tests/integration.rs` — new tests, update any affected existing tests -- `README.md` — updated usage section diff --git a/nbd/.beans/nbd-56ho--tests-for-nbd-graph-command.md b/nbd/.beans/nbd-56ho--tests-for-nbd-graph-command.md deleted file mode 100644 index c1d169f..0000000 --- a/nbd/.beans/nbd-56ho--tests-for-nbd-graph-command.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -# nbd-56ho -title: Tests for nbd graph command -status: completed -type: task -priority: normal -created_at: 2026-03-10T23:30:31Z -updated_at: 2026-03-10T23:30:32Z -blocked_by: - - nbd-d9dh ---- - -Add comprehensive unit and integration tests for the `nbd graph` command introduced across tickets `9c9ebe`, `e14172`, and `9ad11f`. - -## Unit tests (src/tests.rs) - -These test `graph.rs` and the rendering helpers in `display.rs` in isolation using in-memory `Ticket` values (no temp files needed). - -### graph.rs tests - -``` -test_graph_build_empty - TicketGraph::build(&[]) has no nodes. - -test_graph_roots_no_deps - Two tickets with no dependencies → both appear in roots(). - -test_graph_roots_with_chain - Ticket B depends on A → only A is a root. - -test_graph_subtree_linear - A → B → C; subtree("a") returns ["a", "b", "c"] (DFS order). - -test_graph_subtree_single - subtree of a leaf returns just that ID. - -test_graph_subtree_cycle - A.deps = [B], B.deps = [A] → subtree("a") returns both without panic/infinite loop. - -test_graph_to_json_nodes_and_edges - Three tickets with two edges → JSON "nodes" has 3 entries, "edges" has 2. -``` - -### display.rs tests - -``` -test_format_graph_single_ticket - One ticket, no deps → output contains the ID, status, and title on one line; no connector chars. - -test_format_graph_two_ticket_chain - A → B; output has A at col 0 and B indented with "└──". - -test_format_graph_branching - A has two dependents B and C; B's line uses "├──" and C's uses "└──". - -test_format_subtree_scope - A → B, C (unrelated root); format_subtree(graph, "a") does not contain C's ID. - -test_format_graph_cycle_label - Cycle present → output contains "[cycle]" and does not repeat infinitely. -``` - -## Integration tests (tests/integration.rs) - -Use a real temp directory set up with `cargo run -- init` and tickets created via `cmd_create`. - -``` -test_graph_empty_store - `nbd graph` on an empty store produces an empty line (or at least exits 0). - -test_graph_all_no_deps - Create two tickets without deps; `nbd graph` output contains both IDs with no indentation. - -test_graph_chain - Create A (no deps) and B (--deps A); `nbd graph` output shows A at top level and B indented. - -test_graph_single_ticket - `nbd graph ` for A returns only A and its subtree, not unrelated tickets. - -test_graph_json_output - `nbd graph --json` parses as valid JSON with "nodes" and "edges" arrays. - -test_graph_json_subtree - `nbd graph --json` returns JSON whose "nodes" array contains only reachable tickets. - -test_graph_filter - `nbd graph --filter type=bug` only shows bug tickets and their reachable deps. - -test_graph_partial_id - `nbd graph <3-char-prefix>` resolves to the correct ticket (prefix resolution). -``` - -## Files touched -- `src/tests.rs` — unit tests for `TicketGraph` and `format_graph` / `format_subtree` -- `tests/integration.rs` — CLI-level integration tests diff --git a/nbd/.beans/nbd-6j0q--print-tickets-in-markdown-format-instead-of-key-va.md b/nbd/.beans/nbd-6j0q--print-tickets-in-markdown-format-instead-of-key-va.md deleted file mode 100644 index dafca50..0000000 --- a/nbd/.beans/nbd-6j0q--print-tickets-in-markdown-format-instead-of-key-va.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -# nbd-6j0q -title: Print tickets in markdown format instead of key-value table -status: completed -type: feature -priority: normal -created_at: 2026-03-10T23:30:30Z -updated_at: 2026-03-10T23:30:30Z ---- - -## Goal - -When a ticket is printed to stdout (e.g. by `nbd read`, `nbd create`, `nbd archive`, `nbd next`, `nbd update` without `--json`), it should be rendered as markdown with TOML frontmatter — the same format used by `--ftype md` files on disk — rather than the current key-value table. - -`--json` output is unaffected. - -## Current output (`nbd read `) - -``` -ID: a3f9c2 -Title: Fix login bug -Body: Users cannot log in with email + -Priority: 8 -Status: in_progress -Type: bug -Dependencies: b7d41e, c9e823 -``` - -## Target output - -``` -+++ -title = "Fix login bug" -priority = 8 -status = "in_progress" -ticket_type = "bug" -dependencies = ["b7d41e", "c9e823"] -+++ -Users cannot log in with email + -``` - -The `id` field is not in the frontmatter on disk (filename is the source of truth), but for display purposes it should appear as a comment or additional line. Recommended approach: add `id = "a3f9c2"` as the **first key** in the frontmatter so it's immediately visible: - -``` -+++ -id = "a3f9c2" -title = "Fix login bug" -priority = 8 -status = "in_progress" -ticket_type = "bug" -dependencies = ["b7d41e", "c9e823"] -+++ -Users cannot log in with email + -``` - -## Files to change - -### `src/display.rs` - -Replace `format_ticket` with a markdown-rendering implementation. The simplest approach reuses `serialize_markdown` from `src/store.rs`, adding `id` to the frontmatter output. - -Option A (preferred): expose `serialize_markdown` from `store.rs` as `pub(crate)` and call it from `display.rs`, prepending the `id` line to the TOML frontmatter block. - -Option B: duplicate the logic in `display.rs` as a display-specific formatter that includes `id`. - -The `print_ticket` function should call the new formatter. - -### `src/tests.rs` - -Update any unit tests for `format_ticket` that check the old key-value table format. They should now expect TOML frontmatter. - -### `tests/integration.rs` - -Update any integration tests that check the plain-text output of `nbd read`, `nbd create`, etc. - -## Commands affected - -Any command that calls `display::print_ticket`: -- `nbd create` (non-JSON path) -- `nbd read` (non-JSON path) -- `nbd archive` (non-JSON path) -- `nbd next` (non-JSON path, single ticket) -- `nbd update` currently calls `print_diff`, so it is **not** affected - -## Validation - -```sh -cargo fmt && cargo check && cargo clippy && cargo test -cargo run -- create --title "Test ticket" --body "Some body text" --priority 7 --type bug -# Output should be: -# +++ -# id = "" -# title = "Test ticket" -# priority = 7 -# status = "todo" -# ticket_type = "bug" -# dependencies = [] -# +++ -# Some body text -``` diff --git a/nbd/.beans/nbd-7cab--add-backlog-status-to-tickets.md b/nbd/.beans/nbd-7cab--add-backlog-status-to-tickets.md deleted file mode 100644 index 1caaf41..0000000 --- a/nbd/.beans/nbd-7cab--add-backlog-status-to-tickets.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -# nbd-7cab -title: Add backlog status to tickets -status: completed -type: feature -priority: normal -created_at: 2026-03-10T23:30:31Z -updated_at: 2026-03-10T23:30:31Z ---- - -## Goal - -Add a `backlog` status variant so tickets can be created without immediately surfacing in the active work queue. Tickets in `backlog` are created but intentionally deferred; they should not appear in `nbd list`, `nbd ready`, or `nbd next` by default. - -## Semantics - -- `backlog` = created, but not yet ready to be worked on (intentionally deferred) -- Default status for new tickets remains `todo` -- `backlog` tickets are excluded from `nbd list` (same as `done` and `closed`) -- `backlog` tickets are excluded from `nbd ready` and `nbd next` -- `backlog` tickets do **not** count as resolved for dependency purposes (unlike `done` and `closed`) — a dependency on a `backlog` ticket still blocks -- `backlog` tickets are visible with `--all`, `--filter status=backlog`, or `--filter status=*` - -## Files to change - -### `src/ticket.rs` - -Add `Backlog` variant to the `Status` enum: - -```rust -pub enum Status { - #[default] - Todo, - InProgress, - Done, - Closed, - Backlog, // new -} -``` - -Serialises as `"backlog"`. - -### `src/main.rs` - -- `parse_status`: add `"backlog" => Ok(Status::Backlog)` arm and update the error message -- `cmd_list`: exclude `Status::Backlog` in the default (no `--all`, no `status=` filter) case, alongside `Done` and `Closed` -- `cmd_ready`: exclude `Status::Backlog` from the ready set (a backlog ticket is never ready) -- `cmd_next`: same exclusion as `cmd_ready` - -### `src/display.rs` - -- `status_str`: add `Status::Backlog => "backlog"` arm - -### `src/graph.rs` - -- `status_str` (internal helper, duplicated): add `Status::Backlog => "backlog"` arm - -### README.md - -- Update the Status table to include `backlog` -- Update `nbd list` usage examples to mention that `backlog` is also hidden by default - -### CLAUDE.md - -- Update CLI Interface block to show `backlog` as a valid status value - -### `src/tests.rs` - -- Add unit test: a ticket with `Status::Backlog` is excluded from the ready list and is visible under `--all` - -### `tests/integration.rs` - -- Add integration test: create a ticket with `--status backlog`, confirm it does not appear in plain `nbd list` or `nbd ready`, and does appear in `nbd list --all` - -## Validation - -```sh -cargo fmt && cargo check && cargo clippy && cargo test -cargo run -- create --title "Backlog item" --status backlog --json -cargo run -- list --json # should NOT appear -cargo run -- list --all --json # should appear -cargo run -- ready --json # should NOT appear -``` diff --git a/nbd/.beans/nbd-8yn8--add-status-convenience-sub-commands-open-start-com.md b/nbd/.beans/nbd-8yn8--add-status-convenience-sub-commands-open-start-com.md deleted file mode 100644 index 8c0d8fa..0000000 --- a/nbd/.beans/nbd-8yn8--add-status-convenience-sub-commands-open-start-com.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -# nbd-8yn8 -title: Add status convenience sub-commands (open, start, complete, close) -status: todo -type: feature -priority: normal -created_at: 2026-03-10T23:30:31Z -updated_at: 2026-03-10T23:30:31Z ---- - -## Problem - -`nbd update --status ` is verbose for the most common lifecycle transitions. `nbd archive` already exists as a top-level convenience — the same pattern should apply to all transitions. - -## Sub-commands to add - -| Command | Status set | -|---|---| -| `nbd open ` | `todo` | -| `nbd start ` | `in_progress` | -| `nbd complete ` | `done` | -| `nbd close ` | `closed` | - -(`nbd archive` already exists — do not duplicate it.) - -## Implementation - -**`src/main.rs`** - -Add four variants to the `Commands` enum, following the existing `Archive` pattern (line 183): - -```rust -Open { id: String }, -Start { id: String }, -Complete { id: String }, -Close { id: String }, -``` - -Add handler functions `cmd_open`, `cmd_start`, `cmd_complete`, `cmd_close`. Each handler should: -1. Call `resolve_id` (supports partial IDs) -2. `find_ticket_path` + `detect_format` to preserve existing file format -3. `read_ticket` -4. Set `ticket.status` to the target status -5. `write_ticket` in the same format -6. Print the ticket (tabular or JSON via `--json`) - -Wire up in `dispatch()` following the same pattern as `Commands::Archive`. - -**`tests/integration.rs`** - -Add integration tests for each command analogous to the existing archive test. diff --git a/nbd/.beans/nbd-95l6--nbd-init-command.md b/nbd/.beans/nbd-95l6--nbd-init-command.md deleted file mode 100644 index 3dc904e..0000000 --- a/nbd/.beans/nbd-95l6--nbd-init-command.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -# nbd-95l6 -title: nbd init command -status: completed -type: feature -priority: high -created_at: 2026-03-10T23:30:30Z -updated_at: 2026-03-10T23:30:30Z ---- - -Add an explicit `nbd init` subcommand that creates `.nbd/tickets/` in the current working directory, analogous to `git init`. - -## Motivation - -Currently users must run `mkdir -p .nbd/tickets` manually before first use. This is a friction point — especially for first-time users and agent workflows bootstrapping a new project. - -## Approach - -1. Add `Init` variant to the `Commands` enum in `main.rs`. -2. Implement `cmd_init(json: bool) -> store::Result<()>`: - - Get cwd via `std::env::current_dir()`. - - Call `store::ensure_tickets_dir(&cwd)` (already exists, idempotent). - - Print confirmation: `initialised .nbd/tickets/ in ` (or JSON: `{"root": ""}`). -3. No changes to `store.rs` needed — `ensure_tickets_dir` already uses `create_dir_all` (idempotent). -4. The command should NOT require `.nbd/` to already exist (i.e. do NOT call `find_nbd_root` here — use cwd directly). - -## Tests - -- Integration test: run `nbd init` in a fresh tempdir, verify `.nbd/tickets/` is created. -- Integration test: run `nbd init` twice in the same dir — succeeds both times (idempotent). -- Integration test: `nbd init --json` outputs valid JSON with a `root` field. - -## Files touched -- `src/main.rs` — new `Init` variant and `cmd_init` handler -- `tests/integration.rs` — new integration tests -- `README.md` — update Initialise section diff --git a/nbd/.beans/nbd-9mxu--add-list-status-sub-commands-list-todo-list-backlo.md b/nbd/.beans/nbd-9mxu--add-list-status-sub-commands-list-todo-list-backlo.md deleted file mode 100644 index 1dfb076..0000000 --- a/nbd/.beans/nbd-9mxu--add-list-status-sub-commands-list-todo-list-backlo.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -# nbd-9mxu -title: Add list status sub-commands (list todo, list backlog, etc.) -status: todo -type: feature -priority: low -created_at: 2026-03-10T23:30:30Z -updated_at: 2026-03-10T23:30:30Z ---- - -## Problem - -Filtering by status requires the verbose `--filter status=X`. Common patterns like listing only backlog or only completed tickets should have ergonomic shortcuts. - -## Sub-commands to add (positional arg to `list`) - -Accept an optional positional `` argument to `nbd list`: - -```sh -nbd list backlog # equivalent to: nbd list --filter status=backlog -nbd list closed # equivalent to: nbd list --filter status=closed -nbd list completed # equivalent to: nbd list --filter status=done -nbd list todo # equivalent to: nbd list --filter status=todo -nbd list in_progress # equivalent to: nbd list --filter status=in_progress -``` - -Note: `completed` is an alias for `done` (avoids the awkward `nbd list done`). - -## Implementation - -**`src/main.rs`** — `Commands::List` - -Add an optional positional argument `status` to the `List` variant: - -```rust -List { - status: Option, // new: positional shorthand - filter: Vec, - all: bool, -} -``` - -In `cmd_list`, when `status` is `Some(s)`: -- Map `"completed"" → "done"`, others pass through -- Treat it as if the caller had passed `--filter status=` -- The explicit `--filter` and `--all` flags should still override as today - -If both `status` and `--filter status=...` are given, merge them (OR behaviour within the status group, consistent with `TicketFilter`). - -**`tests/integration.rs`** - -Add tests for each status shorthand. diff --git a/nbd/.beans/nbd-9vrn--add-nbdconfigtoml-for-per-project-defaults.md b/nbd/.beans/nbd-9vrn--add-nbdconfigtoml-for-per-project-defaults.md deleted file mode 100644 index 5da448e..0000000 --- a/nbd/.beans/nbd-9vrn--add-nbdconfigtoml-for-per-project-defaults.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -# nbd-9vrn -title: Add .nbd/config.toml for per-project defaults -status: todo -type: feature -priority: normal -created_at: 2026-03-10T23:30:30Z -updated_at: 2026-03-10T23:30:30Z ---- - -## Problem - -All defaults (output format, file type, default status) are hard-coded in the CLI. Users in a project that always uses `--json` or always creates `md`-format tickets must pass these flags repeatedly. A per-project config file would let them set these once. - -## Config file - -Location: `.nbd/config.toml` (inside the `.nbd/` root, alongside `tickets/` and `cache.db`). - -Format (all keys optional — missing keys fall back to compiled defaults): - -```toml -[nbd] -json = false # default: false — use tabular output -ftype = "json" # default: "json" — ticket storage format -status = "todo" # default: "todo" — initial status for new tickets -``` - -## Precedence (highest to lowest) - -1. Explicit CLI flag (e.g., `--json`, `--ftype md`) -2. Environment variable (not in scope for this ticket) -3. `.nbd/config.toml` -4. Compiled-in default - -## Implementation - -**`src/store.rs`** (or a new `src/config.rs`) - -Add a `NbdConfig` struct and a `load_config(root: &Path) -> NbdConfig` function: - -```rust -pub struct NbdConfig { - pub json: bool, - pub ftype: FileFormat, - pub status: Status, -} - -impl Default for NbdConfig { /* compiled-in defaults */ } - -pub fn load_config(root: &Path) -> NbdConfig { /* read .nbd/config.toml, fall back to Default */ } -``` - -Parse with the `toml` crate (already in `Cargo.toml`). Errors in the config file should produce a helpful message to stderr and fall back to defaults rather than aborting. - -**`src/main.rs`** — `dispatch()` - -After `find_nbd_root()`, call `load_config`. Merge config values with CLI flags: - -- `cli.json = cli.json || config.json` (CLI flag wins) -- For `--ftype`: if the user did not pass `--ftype`, use `config.ftype` -- For `--status` in `create`: if the user did not pass `--status`, use `config.status` - -**`src/main.rs`** — `cmd_init` - -After creating the tickets directory, write `.nbd/config.toml` with default values if it does not already exist. - -**`tests/integration.rs`** - -- Test that a config with `json = true` causes tabular commands to emit JSON. -- Test that a config with `ftype = "md"` causes `create` to write `.md` files. -- Test that explicit CLI flags override config values. -- Test idempotency of `nbd init` (does not overwrite existing config). - -## Dependencies - -None. Can be implemented independently of other tickets. diff --git a/nbd/.beans/nbd-csdh--add-graph-computation-module-srcgraphrs.md b/nbd/.beans/nbd-csdh--add-graph-computation-module-srcgraphrs.md deleted file mode 100644 index 2c35d57..0000000 --- a/nbd/.beans/nbd-csdh--add-graph-computation-module-srcgraphrs.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -# nbd-csdh -title: Add graph computation module (src/graph.rs) -status: completed -type: feature -priority: normal -created_at: 2026-03-10T23:30:31Z -updated_at: 2026-03-10T23:30:31Z ---- - -Implement `src/graph.rs` — a module that builds a directed dependency graph from a flat list of tickets and provides the data structures needed by the ASCII renderer and JSON output. - -## Motivation - -The `nbd graph` command (see CLI ticket) needs to traverse `Ticket.dependencies` edges to produce an ordered, tree-structured representation of the dependency DAG. This module isolates that logic from I/O and rendering. - -## Data structures - -```rust -/// A node in the dependency graph. -pub struct GraphNode<'a> { - pub ticket: &'a Ticket, - /// Direct dependents (tickets that list this ticket as a dependency). - pub dependents: Vec<&'a str>, - /// Direct dependencies (tickets this ticket depends on). - pub dependencies: Vec<&'a str>, -} - -/// A directed dependency graph built from a flat list of tickets. -pub struct TicketGraph<'a> { - nodes: IndexMap<&'a str, GraphNode<'a>>, -} -``` - -## Functions to implement - -### `TicketGraph::build(tickets: &[Ticket]) -> TicketGraph` -- Constructs `nodes` map keyed by ticket ID. -- Iterates `ticket.dependencies` to populate both `dependencies` (forward) and `dependents` (reverse) edges. -- IDs in `dependencies` that do not correspond to a known ticket are silently ignored (dangling references are tolerated). - -### `TicketGraph::roots(&self) -> Vec<&Ticket>` -- Returns tickets with no dependencies (or whose dependencies are all outside the graph). -- Sorted by priority descending (same ordering as `list_tickets`). -- These become the starting points for the recursive ASCII tree renderer. - -### `TicketGraph::subtree(&self, root_id: &str) -> Vec<&str>` -- Returns all ticket IDs reachable from `root_id` by following `dependencies` edges in depth-first order, including `root_id` itself. -- Cycles: track visited set; stop recursion when an ID has been visited. This makes the function safe even if the data contains cycles. - -### `TicketGraph::to_json_value(&self) -> serde_json::Value` -- Returns an object like: - ```json - { - "nodes": [ - {"id": "a3f9c2", "title": "...", "status": "todo", "dependencies": ["b7d41e"]}, - ... - ], - "edges": [ - {"from": "a3f9c2", "to": "b7d41e"}, - ... - ] - } - ``` - -## Crate dependencies - -No new crates needed. If `IndexMap` insertion-order is useful, `indexmap` can be added — but a `HashMap` with a separate sorted `Vec<&str>` of keys also works. Prefer whatever avoids adding a new crate dependency. - -## Files touched -- `src/graph.rs` — new file, public module -- `src/main.rs` — `mod graph;` declaration - -## Tests (unit, in `src/tests.rs`) -- `build` with an empty slice returns an empty graph. -- `roots` returns only tickets with no in-graph dependencies. -- `subtree` returns the correct set of IDs for a linear chain. -- `subtree` does not infinite-loop when the data contains a cycle. -- `to_json_value` contains all expected IDs in `nodes` and all edges in `edges`. diff --git a/nbd/.beans/nbd-d9dh--add-nbd-graph-cli-subcommand.md b/nbd/.beans/nbd-d9dh--add-nbd-graph-cli-subcommand.md deleted file mode 100644 index 98e2d98..0000000 --- a/nbd/.beans/nbd-d9dh--add-nbd-graph-cli-subcommand.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -# nbd-d9dh -title: Add 'nbd graph' CLI subcommand -status: completed -type: feature -priority: normal -created_at: 2026-03-10T23:30:31Z -updated_at: 2026-03-10T23:30:32Z -blocked_by: - - nbd-csdh - - nbd-17dp ---- - -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 diff --git a/nbd/.beans/nbd-fgwx--nbd-update-diff-output.md b/nbd/.beans/nbd-fgwx--nbd-update-diff-output.md deleted file mode 100644 index 9d7c579..0000000 --- a/nbd/.beans/nbd-fgwx--nbd-update-diff-output.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -# nbd-fgwx -title: nbd update diff output -status: completed -type: feature -priority: normal -created_at: 2026-03-10T23:30:30Z -updated_at: 2026-03-10T23:30:30Z ---- - -Show a git-diff-style +/- summary of what changed when `nbd update` is run without `--json`. - -## Motivation - -Currently `nbd update` prints the full ticket after the change, making it hard to see at a glance what actually changed. A diff view — showing only changed fields — is more informative. - -## Approach - -### display.rs -Add `format_diff(old: &Ticket, new: &Ticket) -> String`: -- Compare each field between `old` and `new`. -- For each field that changed, emit two lines: - ``` - - status: todo - + status: in_progress - ``` -- If no fields changed, emit `(no changes)`. -- Fields compared: `title`, `body`, `priority`, `status`, `ticket_type`, `dependencies`. -- `id` is never shown (it cannot change). -- Label width matches `format_ticket` (LABEL_WIDTH). - -Add `print_diff(old: &Ticket, new: &Ticket)` that calls `println!("{}" format_diff(...))`. - -### main.rs -In `cmd_update`: -- Before applying changes: `let old = ticket.clone();` -- After `write_ticket`: - - If `json`: current behaviour (print new ticket as JSON). - - Else: `display::print_diff(&old, &ticket)`. - -## Tests - -Unit tests in `src/tests.rs`: -- `format_diff` shows changed fields only. -- `format_diff` with identical tickets outputs `(no changes)`. -- Changed dependencies are shown as comma-separated lists on each line. - -Integration test: -- `nbd update --status in_progress` (no `--json`) prints `- status:` and `+ status:` lines. -- `nbd update --json` still prints full JSON (no diff). - -## Files touched -- `src/display.rs` — `format_diff`, `print_diff` -- `src/main.rs` — `cmd_update` uses `print_diff` -- `src/tests.rs` — unit tests for `format_diff` -- `tests/integration.rs` — integration tests diff --git a/nbd/.beans/nbd-flbj--update-claude-md-snippet-to-show-json-on-all-comma.md b/nbd/.beans/nbd-flbj--update-claude-md-snippet-to-show-json-on-all-comma.md deleted file mode 100644 index 64fae38..0000000 --- a/nbd/.beans/nbd-flbj--update-claude-md-snippet-to-show-json-on-all-comma.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -# nbd-flbj -title: Update claude-md snippet to show --json on all commands -status: completed -type: task -priority: low -created_at: 2026-03-10T23:30:31Z -updated_at: 2026-03-10T23:30:31Z ---- - -## Goal - -The `nbd claude-md` command emits `src/claude_md_snippet.md` verbatim. The current snippet shows core commands without `--json`, contradicting the guideline at the bottom that says to always pass it. - -## What to change - -In `src/claude_md_snippet.md`, update the **Core commands** section so every example includes `--json`: - -```sh -# Create a new ticket (use --ftype md for a human-readable body) -nbd create --title "Add OAuth login" --type feature --priority 7 --ftype md --json - -# List all open tickets (sorted by priority) -nbd list --json - -# Read a specific ticket -nbd read --json - -# Update a ticket -nbd update --status in_progress --json -nbd update --status done --json -``` - -Also update the **Workflow** numbered list to use `--json` in every command: - -``` -1. **Before starting** — create a ticket: `nbd create --title "..." --ftype md --json` -2. **When starting** — mark it in progress: `nbd update --status in_progress --json` -3. **When done** — mark it complete: `nbd update --status done --json` -``` - -The `nbd ready` and `nbd next` sections should also include `--json`. - -## Files - -- `src/claude_md_snippet.md` — the only file that needs changing - -## Validation - -```sh -cargo run -- claude-md | grep -c '\-\-json' -``` - -The count should go up after the change. All existing tests should still pass. diff --git a/nbd/.beans/nbd-i0fc--nbd-ready-command.md b/nbd/.beans/nbd-i0fc--nbd-ready-command.md deleted file mode 100644 index 5a871f8..0000000 --- a/nbd/.beans/nbd-i0fc--nbd-ready-command.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -# nbd-i0fc -title: nbd ready command -status: completed -type: feature -priority: high -created_at: 2026-03-10T23:30:31Z -updated_at: 2026-03-10T23:30:31Z ---- - -Add `nbd ready` subcommand that lists tickets which are actionable right now: not yet done and with all dependencies completed. - -## Motivation - -Agent workflows need to know which tickets are unblocked. `nbd list` shows everything; `nbd ready` narrows to what can actually be started immediately. - -## Approach - -1. Add `Ready` variant to `Commands` enum in `main.rs`. -2. Implement `cmd_ready(json: bool)`: - a. `list_tickets(root)` to fetch all tickets. - b. Build a set of IDs for tickets with `status == Status::Done`. - c. Filter to tickets where: - - `ticket.status != Status::Done` (not already finished) - - All IDs in `ticket.dependencies` are in the done-set (or the dep doesn't exist — treat missing deps as unresolved, not ready). - d. Print the filtered slice using existing `display::print_list` / `print_list_json`. -3. No new store or display functions needed — reuse existing. - -## Edge cases -- A ticket with no dependencies and status `todo` → ready. -- A ticket whose dep is `in_progress` → NOT ready. -- Missing dep ID → NOT ready (treat conservatively). -- Empty store → returns empty list (not an error). - -## Tests - -Unit-style integration tests: -- Three tickets: A (no deps, todo), B (dep A, todo), C (no deps, done). `nbd ready` should return only A. -- After marking A done, `nbd ready` should return B. -- `nbd ready --json` returns a JSON array of the ready tickets. - -## Files touched -- `src/main.rs` — new `Ready` variant and `cmd_ready` handler -- `tests/integration.rs` — new integration tests diff --git a/nbd/.beans/nbd-jc1v--split-archiveclosed-archivedone-closedcancelled.md b/nbd/.beans/nbd-jc1v--split-archiveclosed-archivedone-closedcancelled.md deleted file mode 100644 index d9e8453..0000000 --- a/nbd/.beans/nbd-jc1v--split-archiveclosed-archivedone-closedcancelled.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -# nbd-jc1v -title: 'Split archive/closed: archive=done, closed=cancelled' -status: completed -type: bug -priority: normal -created_at: 2026-03-10T23:30:31Z -updated_at: 2026-03-10T23:30:31Z ---- - -## Problem - -Currently `nbd archive` sets a ticket's status to `closed`. This conflates two distinct intents: - -- A ticket that **was completed** and is being retired from the active list -- A ticket that **will not be completed** (e.g. cancelled, superseded, won't-fix) - -## Desired semantics - -| Status | Meaning | Resolved (unblocks deps)? | Visible in `nbd list` by default? | -|---|---|---|---| -| `archived` | Completed and soft-deleted from view | Yes | No | -| `closed` | Won't be completed (cancelled/won't-fix) | Yes (avoids deadlock) | No | - -Both statuses resolve dependencies, so a ticket depending on an `archived` or `closed` ticket becomes unblocked. This matches current behaviour for `closed`. - -## Changes required - -### `src/ticket.rs` - -Add `Archived` variant to `Status`: - -```rust -pub enum Status { - #[default] - Todo, - InProgress, - Done, - Closed, - Archived, // new -} -``` - -Serialises as `"archived"`. - -### `src/main.rs` - -- `parse_status`: add `"archived" => Ok(Status::Archived)` arm and update error message -- `cmd_archive`: change `ticket.status = Status::Closed` → `ticket.status = Status::Archived` -- `cmd_list`: exclude `Status::Archived` alongside `Done` and `Closed` in the default filter path -- `cmd_ready` / `cmd_next`: treat `Status::Archived` as resolved (count it in `done_ids`) -- Update the `Archive` command docstring to say it sets status to `archived` not `closed` - -### `src/display.rs` - -- `status_str`: add `Status::Archived => "archived"` arm - -### `src/graph.rs` - -- `status_str` (internal helper): add `Status::Archived => "archived"` arm - -### README.md - -- Update the Status table to show `archived` as a valid status -- Update `nbd archive` description to say it sets status to `archived` -- Update the note about `archived` vs `closed` semantics - -### CLAUDE.md - -- Update CLI Interface block to include `archived` in valid status values -- Update `nbd archive` description - -### `src/tests.rs` / `tests/integration.rs` - -- Update any test that checks `status == "closed"` after `nbd archive` → expect `"archived"` instead -- Add test: `nbd archive ` produces `"archived"` status -- Add test: a ticket with `status=closed` (manually set) is also excluded from list by default and treated as resolved - -## Migration concern - -Existing tickets on disk that have `status: "closed"` set by previous `nbd archive` calls will retain `closed` status. This is acceptable — `closed` remains a valid status. Users who want to distinguish can `nbd update --status archived` on historical tickets, or run `nbd migrate` once the schema is updated. - -## Validation - -```sh -cargo fmt && cargo check && cargo clippy && cargo test -cargo run -- create --title "Test archive" --json | jq -r '.id' | xargs -I{} cargo run -- archive {} --json -# → status should be "archived" -cargo run -- create --title "Cancelled ticket" --json | jq -r '.id' | xargs -I{} cargo run -- update {} --status closed --json -# → status should be "closed" -``` diff --git a/nbd/.beans/nbd-kq6o--scope-nbd-next-and-nbd-ready-by-dependency-subtree.md b/nbd/.beans/nbd-kq6o--scope-nbd-next-and-nbd-ready-by-dependency-subtree.md deleted file mode 100644 index d57b329..0000000 --- a/nbd/.beans/nbd-kq6o--scope-nbd-next-and-nbd-ready-by-dependency-subtree.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -# nbd-kq6o -title: Scope nbd next and nbd ready by dependency subtree -status: completed -type: feature -priority: normal -created_at: 2026-03-10T23:30:30Z -updated_at: 2026-03-10T23:30:30Z ---- - -## Summary - -Add an optional positional `` argument to `nbd next` and `nbd ready` that scopes -the results to the dependency subtree of the given ticket. - -## Motivation - -When a project has many tickets, you often want to focus on tickets that directly -unblock a specific goal ticket. Currently `nbd next` and `nbd ready` operate over -the entire ticket store, with no way to narrow by project/feature scope except -through `--filter` expressions that don't understand the dependency graph. - -## Desired behaviour - -```sh -# Highest-priority ready ticket that is a dependency of abc123 (directly or transitively) -nbd next abc123 -nbd next abc123 --json - -# All ready tickets that are dependencies of abc123 (directly or transitively) -nbd ready abc123 -nbd ready abc123 --json - -# Both commands still accept --filter alongside the scoping ID -nbd next abc123 --filter type=bug -nbd ready abc123 --filter priority=8 -``` - -The root ticket itself (abc123) is *not* included in the results — only its -dependencies (the tickets it is waiting on). - -## Implementation plan - -### 1. CLI changes (`src/main.rs`) - -In `Commands::Next` and `Commands::Ready`, add an optional positional argument: - -```rust -id: Option, // Optional ticket ID or prefix to scope results -``` - -Update `dispatch` to pass the new argument to `cmd_next` and `cmd_ready`. - -Update the CLI doc-comments for both subcommands to mention the scoping -behaviour. - -### 2. Logic changes (`src/main.rs`) - -In `cmd_next` and `cmd_ready`, after loading all tickets, if `scope_id` is -`Some`: - -1. Resolve the prefix to a full ID using `resolve_id`. -2. Build a `TicketGraph` from all tickets (the full list, not filtered). -3. Call `graph.subtree(&resolved_id)` to get the set of all dependency IDs - reachable from the root (this already excludes the root itself when it is - not in its own dependency list). -4. Convert the subtree IDs into a `HashSet`. -5. Restrict the ready/next candidate pool to tickets whose ID is in that set - *and* whose ID is not the scoping ticket itself (the root should never be - returned as a "what to do next" result when it is the scope target). - -`TicketGraph::subtree` is already implemented in `src/graph.rs` and handles -cycles correctly via a visited set. - -### 3. Test coverage (`tests/integration.rs`) - -Add integration tests (using `tempdir`): - -- **`test_next_scoped_by_id`**: create a project ticket P with deps [A, B], - where A has dep [C]. Mark C as done. Verify `nbd next P` returns A (the - highest-priority ready dep of P), not P itself or any unrelated ticket. -- **`test_ready_scoped_by_id`**: same setup; verify `nbd ready P` returns - [A] (B is blocked because... actually B has no deps so it's also ready). - Adjust setup so exactly the expected set is returned. -- **`test_next_scoped_no_ready`**: all deps of P are done; verify `nbd next P` - returns no ticket (`{"next": null}` in JSON mode). -- **`test_ready_scoped_with_filter`**: verify `--filter` still narrows within - the scoped set. - -### 4. Relevant files - -| File | Change | -|---|---| -| `src/main.rs:135-156` | `Commands::Ready` and `Commands::Next` — add `id: Option` | -| `src/main.rs:308-310` | `dispatch` arms for `Next` and `Ready` — pass new arg | -| `src/main.rs:473-511` | `cmd_ready` — add subtree scoping logic | -| `src/main.rs:522-566` | `cmd_next` — add subtree scoping logic | -| `src/graph.rs:165-172` | `TicketGraph::subtree` — already correct, no change needed | -| `tests/integration.rs` | Add 4 new scoped-by-id tests | - -### 5. Validation - -```sh -cargo fmt && cargo check && cargo clippy && cargo test -``` diff --git a/nbd/.beans/nbd-l38k--fix-graph-orientation-show-goals-at-root-prerequis.md b/nbd/.beans/nbd-l38k--fix-graph-orientation-show-goals-at-root-prerequis.md deleted file mode 100644 index ae026ff..0000000 --- a/nbd/.beans/nbd-l38k--fix-graph-orientation-show-goals-at-root-prerequis.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -# nbd-l38k -title: 'Fix graph orientation: show goals at root, prerequisites as leaves' -status: completed -type: bug -priority: high -created_at: 2026-03-10T23:30:30Z -updated_at: 2026-03-10T23:30:30Z ---- - -## Problem - -The dependency graph is inverted. Currently: -- **Roots** = tickets with no dependencies (foundation/leaf-level work) -- **Children** = dependents of the root (tickets blocked by it) - -This means the graph reads bottom-up: you see "what blocks what" rather than "what needs what". - -## Desired behavior - -If ticket A depends on B, C, and D: -- `nbd graph` should show A at the top with B, C, D indented as children (leaves) -- Roots = tickets with **no dependents** (nobody depends on them — these are the top-level goals) -- Children = the **dependencies** of the current node (prerequisites) - -This makes the graph read naturally: goals at the top, work to be done first at the bottom. - -## Files to change - -### `src/graph.rs` - -- `roots()`: change predicate from `node.dependencies.is_empty()` to `node.dependents.is_empty()` -- `subtree()` + `dfs_subtree()`: currently traverses `dependents` edges; should traverse `dependencies` edges so subtree(A) returns A + what A depends on (recursively) -- `to_json_value()` and `to_subtree_json_value()`: edge direction in JSON output should be `{"from": dependent_id, "to": dependency_id}` (A→B means A depends on B), or reconsider the `from`/`to` labels in light of the new orientation -- Update all doc comments to reflect the new semantics - -### `src/display.rs` - -- `render_node()`: iterate `node.dependencies` as children (not `node.dependents`) -- Update doc comments for `format_graph()` and `format_subtree()` - -### `src/graph.rs` module-level doc comment - -The edge semantics paragraph should be updated: "A is a dependency of B" means A appears as a child in the tree of B. - -### Tests - -Update any graph-related tests in `src/tests.rs` and `tests/integration.rs` that assert the current (inverted) traversal order. diff --git a/nbd/.beans/nbd-lins--fix-nbd-graph-id-show-ancestry-path-through-ticket.md b/nbd/.beans/nbd-lins--fix-nbd-graph-id-show-ancestry-path-through-ticket.md deleted file mode 100644 index 22d304a..0000000 --- a/nbd/.beans/nbd-lins--fix-nbd-graph-id-show-ancestry-path-through-ticket.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -# nbd-lins -title: 'Fix nbd graph : show ancestry path through ticket, not just subtree' -status: todo -type: bug -priority: normal -created_at: 2026-03-10T23:30:29Z -updated_at: 2026-03-10T23:30:31Z -blocked_by: - - nbd-l38k ---- - -## Problem - -Currently `nbd graph ` renders the subtree **below** the given ticket via dependent edges (tickets blocked by it). After the graph orientation fix (see ticket 668150), `nbd graph ` will render the dependency subtree **below** the given ticket. - -But the desired behavior for `nbd graph B` (where A depends on B, C, and D) is more specific: - -> `nbd graph B` should graph up to the root level, down to B, and the rest of the way down from B, but not include siblings like C and D. - -## Desired behavior - -``` -A [todo] High-level goal -└── B [todo] Implement feature B - └── E [todo] Write unit tests for B -``` - -- A is shown because it depends on B (ancestor) -- C and D are NOT shown (they are sibling dependencies of A, not on the path to B) -- E is shown because B depends on E (B's own dependency subtree) - -## Algorithm - -1. **Ancestors of B**: traverse the `dependents` edges upward (who depends on B, and who depends on those, etc.) — collecting all ancestor tickets -2. **Filtered ancestor tree**: when rendering ancestors, only show the branch that leads toward B — i.e., at each ancestor, only the child that is on the path to B is shown (not siblings like C and D) -3. **B's dependency subtree**: show B and all its dependencies recursively (as per the corrected subtree logic from ticket 668150) - -## Implementation approach - -In `src/graph.rs`: -- Add `ancestors(target_id: &str) -> Vec<&str>`: collect all IDs that transitively depend on `target_id` (follow `dependents` edges upward) -- Add `ancestry_path_subtree(target_id: &str) -> ???`: returns the combined view — ancestor tree filtered to the path, plus target's dependency subtree - -In `src/display.rs`: -- New or updated `format_subtree()` to render the ancestry+subtree combined view -- The ancestry portion must filter children to only show the branch toward the target (not all dependents of each ancestor) - -In `src/main.rs`: -- Update `cmd_graph()` to use the new combined view when an ID is provided - -## Edge cases - -- If B has no dependents (it is itself a root), just show B's dependency subtree (no ancestor section) -- If B appears in multiple dependency chains, show all paths to it -- Cycle detection still applies (mark revisited nodes with `*`) - -## Tests - -Add integration tests in `tests/integration.rs`: -- Create tickets A, B, C, D, E with A depending on B/C/D, and B depending on E -- Assert `nbd graph B` output contains A and E but not C and D diff --git a/nbd/.beans/nbd-m9q2--nbd-init-add-cachedb-to-nbdgitignore.md b/nbd/.beans/nbd-m9q2--nbd-init-add-cachedb-to-nbdgitignore.md deleted file mode 100644 index ae31620..0000000 --- a/nbd/.beans/nbd-m9q2--nbd-init-add-cachedb-to-nbdgitignore.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -# nbd-m9q2 -title: 'nbd init: add cache.db to .nbd/.gitignore' -status: todo -type: task -priority: normal -created_at: 2026-03-10T23:30:30Z -updated_at: 2026-03-10T23:30:30Z ---- - -## Problem - -`.nbd/cache.db` is a Turso/libsql SQLite cache file created automatically by `list_tickets_cached`. It should never be committed to git. Currently `nbd init` does not create a `.gitignore` to exclude it. - -## Implementation - -**`src/main.rs`** — `cmd_init` - -After `ensure_tickets_dir` succeeds, write (or append to) `.nbd/.gitignore`: - -``` -cache.db -``` - -Use idempotent logic: read the file if it exists, check whether `cache.db` is already listed, and only append/create if it is absent. This keeps `nbd init` safe to run multiple times. - -Suggested helper (can be inline in `cmd_init`): -1. Read `.nbd/.gitignore` if it exists. -2. If `cache.db` is not a line in the file, append `cache.db\n`. -3. If the file does not exist, create it with `cache.db\n`. - -The JSON output for `--json` should include a `gitignore` key indicating the path that was created/updated (or unchanged). - -**`tests/integration.rs`** - -Add a test that runs `nbd init` and asserts `.nbd/.gitignore` contains `cache.db`. Run `nbd init` a second time and assert the file is unchanged (idempotent). diff --git a/nbd/.beans/nbd-mkkm--add-xml-output-format-that-wraps-each-ticket-secti.md b/nbd/.beans/nbd-mkkm--add-xml-output-format-that-wraps-each-ticket-secti.md deleted file mode 100644 index b48e563..0000000 --- a/nbd/.beans/nbd-mkkm--add-xml-output-format-that-wraps-each-ticket-secti.md +++ /dev/null @@ -1,107 +0,0 @@ ---- -# nbd-mkkm -title: Add --xml output format that wraps each ticket section in XML tags -status: todo -type: feature -priority: low -created_at: 2026-03-10T23:30:30Z -updated_at: 2026-03-10T23:30:30Z ---- - -## Background - -From `nbd/TODO.md`: - -> Add a `--xml` output format that prints tickets with XML around each section to make it easier to parse metadata. - -Unlike `--json` (which is a complete, structured parse), the XML format adds lightweight tags around each metadata field in the human-readable output. This makes it trivial for scripts to extract specific fields using simple text tools (e.g., `grep`, `sed`, XPath) without pulling in a full JSON parser. - -## Intended XML format - -For a single ticket (`nbd read --xml`): - -```xml - - a3f9c2 - Fix login bug - 8 - in_progress - bug - - b7d41e - c9e823 - - Users cannot log in with email addresses containing + - -``` - -For a list (`nbd list --xml`): - -```xml - - ... - ... - -``` - -## Implementation - -### `src/main.rs` - -Add a global `--xml` flag to the `Cli` struct, parallel to `--json`: - -```rust -/// Output XML instead of a human-readable table. -#[arg(long, global = true)] -xml: bool, -``` - -Precedence: if both `--json` and `--xml` are supplied, `--json` wins (or return an error — TBD, but JSON-first is simpler). - -Pass `cli.xml` through `dispatch` to every command handler, alongside `cli.json`. - -Each command handler signature gains an `xml: bool` parameter. When `xml` is true and `json` is false, call the new `display::print_ticket_xml` / `display::print_list_xml` functions. - -### `src/display.rs` - -Add: - -- `format_ticket_xml(ticket: &Ticket) -> String` — serialises a single ticket as XML -- `print_ticket_xml(ticket: &Ticket)` — wraps `format_ticket_xml` + `println!` -- `format_list_xml(tickets: &[Ticket]) -> String` — wraps list in `` -- `print_list_xml(tickets: &[Ticket])` - -XML escaping: at minimum, escape `&`, `<`, `>`, `"`, `'` in field values. Use a small helper `xml_escape(s: &str) -> String` rather than pulling in an XML crate. - -For `dependencies`: render each as a `` child element. - -### `src/tests.rs` - -Add unit tests: -- `format_ticket_xml` with a ticket that has special characters in the title and body (`&`, `<`, `>`) -- `format_ticket_xml` with empty dependencies -- `format_ticket_xml` with multiple dependencies -- `format_list_xml` with zero and multiple tickets - -### `tests/integration.rs` - -Add integration tests: -- `nbd read --xml` returns valid XML containing the ticket's fields -- `nbd list --xml` returns valid XML wrapping multiple tickets -- `nbd create ... --xml` returns the created ticket as XML -- `nbd update ... --xml` returns the updated ticket as XML - -## Scope - -All commands that support `--json` should also support `--xml`: -- `nbd create` -- `nbd read` -- `nbd list` -- `nbd update` -- `nbd ready` -- `nbd next` -- `nbd archive` -- `nbd migrate` -- `nbd graph` (the JSON graph format has a defined structure; XML should mirror it) -- `nbd claude-md` (wrap snippet in `` tag) -- `nbd init` (wrap root path in ``) diff --git a/nbd/.beans/nbd-n2xz--investigate-user-configurable-type-and-status-stri.md b/nbd/.beans/nbd-n2xz--investigate-user-configurable-type-and-status-stri.md deleted file mode 100644 index 5d679e2..0000000 --- a/nbd/.beans/nbd-n2xz--investigate-user-configurable-type-and-status-stri.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -# nbd-n2xz -title: 'Investigate: user-configurable type and status strings with lifecycle phases' -status: draft -type: task -priority: low -created_at: 2026-03-10T23:30:29Z -updated_at: 2026-03-10T23:30:29Z ---- - -## Problem - -`type` and `status` are currently hard-coded Rust enums. Users cannot add custom values (e.g., `status = "review"` or `type = "spike"`) without modifying the codebase. But certain status values (`done`, `archived`, `closed`) have special semantics (excluded from `list`/`ready`/`next`; counted as resolved for deps). Making these extensible requires a design that lets users declare which values are "pre-work", "in-work", and "post-work". - -## Design questions - -### Lifecycle phases (proposed) - -Instead of hard-coding which statuses are excluded, introduce a three-phase model: - -| Phase | Examples | Behaviour | -|---|---|---| -| `pre` | `triage`, `backlog` | Excluded from `list`, `ready`, `next`. Not resolved. | -| `during` | `todo`, `in_progress`, `review` | Included in `list`, `ready`, `next`. Not resolved. | -| `post` | `done`, `archived`, `closed` | Excluded from `list`, `ready`, `next`. Counted as resolved. | - -Users would configure phases in `.nbd/config.toml`: - -```toml -[nbd.status] -pre = ["triage", "backlog"] -during = ["todo", "in_progress", "review", "qa"] -post = ["done", "archived", "closed"] -default = "triage" -``` - -### Type extensibility - -Allow `type` to be any string. Keep built-in types (`project`, `feature`, `task`, `bug`) but do not reject unknown values; validate only that the string is non-empty. - -## Questions to answer - -1. Is TOML config the right place for lifecycle phase definitions, or should there be a separate schema file? -2. How does serialisation/deserialisation change? `Status` can no longer be an enum if it is user-defined. -3. What is the migration path for existing tickets serialised with the current enum values? -4. Are there cases where a single status should belong to multiple phases (e.g., `review` might be both `during` and gating some post-processing)? -5. What is the minimum viable change? Could we start by making status a `String` internally while keeping the built-in values and their semantics, then layer config-driven phases on top? - -## Expected output - -Create one or more follow-up tickets with a concrete implementation plan and migration strategy. diff --git a/nbd/.beans/nbd-no88--add-version-flag-with-xyzgitsha-format.md b/nbd/.beans/nbd-no88--add-version-flag-with-xyzgitsha-format.md deleted file mode 100644 index fbc4145..0000000 --- a/nbd/.beans/nbd-no88--add-version-flag-with-xyzgitsha-format.md +++ /dev/null @@ -1,129 +0,0 @@ ---- -# nbd-no88 -title: Add --version flag with X.Y.Z+GitSha format -status: completed -type: feature -priority: normal -created_at: 2026-03-10T23:30:31Z -updated_at: 2026-03-10T23:30:31Z ---- - -## Goal - -Add a `--version` / `-V` flag to `nbd` that prints the version in the format: - -``` -0.1.0+7e311d6 -``` - -where `0.1.0` comes from `Cargo.toml` and `7e311d6` is the short git SHA of the commit the binary was built from, embedded at compile time via `build.rs`. - -## Why - -Allows agents and users to confirm exactly which build of `nbd` is running without inspecting the binary separately. - -## Implementation plan - -### 1. Create `build.rs` at the crate root - -```rust -fn main() { - // Capture the short git SHA at build time. - // Falls back to "unknown" when git is unavailable (e.g. CI without repo). - let sha = std::process::Command::new("git") - .args(["rev-parse", "--short", "HEAD"]) - .output() - .ok() - .and_then(|o| if o.status.success() { Some(o.stdout) } else { None }) - .and_then(|b| String::from_utf8(b).ok()) - .map(|s| s.trim().to_string()) - .unwrap_or_else(|| "unknown".to_string()); - - println\!("cargo:rustc-env=GIT_SHORT_SHA={sha}"); - // Re-run whenever HEAD changes (new commits). - println\!("cargo:rerun-if-changed=.git/HEAD"); - println\!("cargo:rerun-if-changed=.git/refs"); -} -``` - -Key points: -- Uses `std::process::Command` — no extra build dependencies. -- Graceful fallback to `"unknown"` when git is absent (clean Nix sandbox builds may not have `.git/`). -- `rerun-if-changed` directives ensure the SHA is refreshed on every commit without forcing a full rebuild every run. - -### 2. Add a `VERSION` constant in `src/main.rs` - -Add near the top of `main.rs` after the existing `CLAUDE_MD_SNIPPET` constant: - -```rust -/// Full version string embedded at compile time: `"X.Y.Z+shortsha"`. -const VERSION: &str = concat\!(env\!("CARGO_PKG_VERSION"), "+", env\!("GIT_SHORT_SHA")); -``` - -### 3. Wire `VERSION` into the clap `#[command(...)]` attribute - -Change: - -```rust -#[command(name = "nbd", about = "Manage work tickets for agent workflows")] -``` - -to: - -```rust -#[command(name = "nbd", about = "Manage work tickets for agent workflows", version = VERSION)] -``` - -clap automatically handles `-V` / `--version` when `version` is set: it prints the string and exits 0. No manual subcommand or dispatch code needed. - -### 4. Update `README.md` - -Add a brief note in the **Usage** section or **Installation** section: - -```sh -nbd --version # prints e.g. 0.1.0+7e311d6 -``` - -### 5. Add an integration test in `tests/integration.rs` - -```rust -/// `nbd --version` exits 0 and stdout contains the semver. -#[test] -fn version_flag_exits_zero_with_semver() { - let tmp = tempfile::tempdir().expect("tempdir"); - let output = std::process::Command::new(env\!("CARGO_BIN_EXE_nbd")) - .arg("--version") - .current_dir(tmp.path()) - .output() - .expect("spawn nbd"); - assert\!(output.status.success(), "--version should exit 0"); - let stdout = String::from_utf8(output.stdout).unwrap(); - // Should contain the package version (semver). - assert\!(stdout.contains("0.1.0"), "--version should include semver: {stdout}"); - // Should contain a '+' separator and at least one hex char after it. - assert\!(stdout.contains('+'), "--version should contain '+': {stdout}"); -} -``` - -## Files to change - -| File | Change | -|---|---| -| `build.rs` | **Create** — emit `GIT_SHORT_SHA` env var | -| `src/main.rs` | Add `VERSION` const; add `version = VERSION` to `#[command]` | -| `README.md` | Add `nbd --version` example | -| `tests/integration.rs` | Add `version_flag_exits_zero_with_semver` test | - -## Edge cases - -- **No git available (Nix sandbox):** `build.rs` falls back to `"unknown"`, producing `0.1.0+unknown`. This is acceptable for packaged builds where the version is already pinned by the derivation. -- **clap version output format:** clap 4 prints `nbd X.Y.Z+sha` (prefixed by the binary name) to stdout, then exits 0. This is standard behaviour — the test should check `stdout.contains("0.1.0")` rather than an exact match. -- **No `--json` support for `--version`:** clap intercepts `--version` before dispatch reaches the `--json` handling. This is acceptable; version output is always plain text. - -## Validation - -```sh -cargo fmt && cargo check && cargo clippy && cargo test -cargo run -- --version -# Expected output: nbd 0.1.0+ -``` diff --git a/nbd/.beans/nbd-o3k8--remove-id-field-from-ticket-json-body.md b/nbd/.beans/nbd-o3k8--remove-id-field-from-ticket-json-body.md deleted file mode 100644 index 52101c1..0000000 --- a/nbd/.beans/nbd-o3k8--remove-id-field-from-ticket-json-body.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -# nbd-o3k8 -title: Remove id field from ticket JSON body -status: completed -type: task -priority: critical -created_at: 2026-03-10T23:30:31Z -updated_at: 2026-03-10T23:30:31Z ---- - -The ticket ID is already encoded in the filename (`a3f9c2.json`). Storing it redundantly inside the JSON body creates a potential consistency hazard (the two could disagree) and wastes space. The filename should be the sole source of truth for the ID. - -## Motivation - -- Eliminates a redundancy: filename stem already IS the id. -- Removes the risk of id mismatch (e.g. if a file is renamed manually). -- Simplifies the JSON schema — consumers only need the body fields, not a duplicated key. - -## Serde approach - -In `ticket.rs`, annotate the `id` field with `#[serde(skip)]`: - -```rust -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct Ticket { - #[serde(skip)] - pub id: String, - // ... -} -``` - -`#[serde(skip)]` means: -- **Serialise:** the `id` field is omitted from JSON output entirely. -- **Deserialise:** the field is not read from JSON; it is initialised with `String::default()` (empty string) and must be set manually after deserialization. - -Because serde ignores unknown fields by default, **existing files with `"id": "..."` in the JSON body continue to deserialise without error** — the field is simply discarded. This means the change is backwards-compatible for reads; existing stores work immediately. A separate `nbd migrate` command (see companion ticket) cleans up the stale `id` field from disk. - -## store.rs changes - -`read_ticket(root, id)` — inject id from the `id` parameter after deserialising: -```rust -let mut ticket: Ticket = serde_json::from_slice(&bytes)?; -ticket.id = id.to_string(); // authoritative source: the filename -Ok(ticket) -``` - -`list_tickets(root)` — inject id from each file's stem: -```rust -let stem = path.file_stem().and_then(|s| s.to_str()).ok_or("invalid filename")?; -let mut ticket: Ticket = serde_json::from_slice(&bytes)?; -ticket.id = stem.to_string(); -tickets.push(ticket); -``` - -`write_ticket` — no change needed; `#[serde(skip)]` already prevents `id` from being written. - -`ticket_path` — no change; it already takes `id: &str` as a separate parameter. - -## Impact on other tickets - -- The `nbd migrate` companion ticket (see deps) provides the command to scrub the old `id` field from existing files. -- Partial ID matching (`resolve_id`) is unaffected — it works on filenames, not JSON content. -- All display, list, and read commands continue to work; `ticket.id` is populated from the filename in every read path. - -## Tests - -Unit tests (`src/tests.rs`): -- `write_ticket` output does NOT contain the `"id"` key. -- `read_ticket` with a file that has NO `id` field correctly sets `ticket.id` from the parameter. -- `read_ticket` with an old-format file (has `"id"` in JSON) still sets `ticket.id` from the parameter (ignores JSON value). -- `list_tickets` injects correct ids from filenames for all tickets. -- Serialisation roundtrip: write then read, id is preserved via filename not JSON. - -Integration tests (`tests/integration.rs`): -- `nbd create` output (tabular and `--json`) contains the correct ID. -- The created `.json` file on disk does NOT contain the `"id"` key. -- `nbd read ` displays the correct ID. - -## Files touched -- `src/ticket.rs` — add `#[serde(skip)]` to `id` -- `src/store.rs` — `read_ticket` and `list_tickets` inject id from filename -- `src/tests.rs` — update and add unit tests -- `tests/integration.rs` — add assertion that written files lack `"id"` key diff --git a/nbd/.beans/nbd-oziy--turso-cache-for-list-performance.md b/nbd/.beans/nbd-oziy--turso-cache-for-list-performance.md deleted file mode 100644 index c0937d4..0000000 --- a/nbd/.beans/nbd-oziy--turso-cache-for-list-performance.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -# nbd-oziy -title: Turso cache for list performance -status: completed -type: feature -priority: low -created_at: 2026-03-10T23:30:30Z -updated_at: 2026-03-10T23:30:30Z ---- - -Add a Turso (libsql) cache in `.nbd/cache.db` to accelerate `nbd list` and `nbd ready` for large ticket stores. - -## Motivation - -`list_tickets` currently does O(n) file reads on every call. For stores with hundreds of tickets this is measurably slow. A Turso/libsql cache avoids re-reading unchanged files by comparing file modification times (mtimes). - -## Approach - -### Runtime migration: async-std → tokio - -libsql requires tokio. Migrate the entire crate: - -- `Cargo.toml`: remove `async-std`, add `tokio = { version = "1", features = ["full"] }` and `libsql = "0.6"` -- `src/main.rs`: `#[async_std::main]` → `#[tokio::main]`, `async_std::fs::remove_file` → `tokio::fs::remove_file` -- `src/store.rs`: `use async_std::fs` → `use tokio::fs`, `use async_std::prelude::*` removed (tokio ReadDir uses `.next_entry().await` not stream iteration) -- `src/tests.rs`: `#[async_std::test]` → `#[tokio::test]`, `async_std::fs` → `tokio::fs` - -### Crate dependency - -```toml -tokio = { version = "1", features = ["full"] } -libsql = "0.6" -``` - -### store.rs additions - -New async function `open_cache(root: &Path) -> Result`: -- Opens (or creates) `.nbd/cache.db` via `libsql::Builder::new_local(path).build().await?.connect()`. -- Runs migration: `CREATE TABLE IF NOT EXISTS tickets (id TEXT PRIMARY KEY, json TEXT NOT NULL, mtime INTEGER NOT NULL)`. - -New function `list_tickets_cached(root: &Path) -> Result>`: -1. Scan directory for files + mtimes (using tokio::fs::read_dir + metadata). -2. Open cache DB. -3. For each file: query cache for matching (id, mtime); if hit, deserialise from cached JSON; if miss, read file, parse, upsert to DB. -4. Delete DB rows for IDs no longer on disk. -5. Return sorted by priority desc. - -Fall back to `list_tickets` on any cache error. - -### main.rs - -`cmd_list`, `cmd_ready`, `cmd_next` use `list_tickets_cached` instead of `list_tickets`. - -## Files to change - -| File | Change | -|---|---| -| `Cargo.toml` | Replace async-std with tokio, add libsql | -| `src/store.rs` | async_std → tokio fs API, add cache functions | -| `src/main.rs` | async_std → tokio main/fs | -| `src/tests.rs` | async_std → tokio test/fs | -| `tests/integration.rs` | Add cache smoke test | - -## Validation - -```sh -cargo fmt && cargo check && cargo clippy && cargo test -cargo run -- list # should create .nbd/cache.db on first run -cargo run -- list # second run uses cache -``` diff --git a/nbd/.beans/nbd-p9na--wire-filter-flag-into-list-ready-and-migrate-comma.md b/nbd/.beans/nbd-p9na--wire-filter-flag-into-list-ready-and-migrate-comma.md deleted file mode 100644 index e019885..0000000 --- a/nbd/.beans/nbd-p9na--wire-filter-flag-into-list-ready-and-migrate-comma.md +++ /dev/null @@ -1,155 +0,0 @@ ---- -# nbd-p9na -title: Wire --filter flag into list, ready, and migrate commands -status: completed -type: feature -priority: critical -created_at: 2026-03-10T23:30:30Z -updated_at: 2026-03-10T23:30:32Z -blocked_by: - - nbd-zz62 ---- - -## Summary - -Add `--filter KEY=VALUE` (repeatable) to the `list`, `ready`, and `migrate` CLI commands. -Parse filter arguments into a `TicketFilter` and apply it in each command handler. - -Depends on: TicketFilter module (ticket c2a024). - -## CLI changes (src/main.rs) - -Add to `Commands::List`, `Commands::Ready`, and `Commands::Migrate` variants: - -```rust -/// Filter tickets: key=value pairs (repeatable). -/// Keys: priority, type, status, title. -/// Different keys are ANDed; same key with multiple values is ORed. -/// Values support glob wildcards: status=* matches all statuses. -#[arg(long = "filter", value_name = "KEY=VALUE")] -filter: Vec, -``` - -Update the `dispatch` function to pass filter args through to each handler. - -## Handler changes - -### cmd_list(filter_args, json) - -```rust -let filter = filter::parse_filters(&filter_args)?; -let tickets: Vec = list_tickets(&root).await? - .into_iter() - .filter(|t| filter.matches(t)) - .collect(); -``` - -Note: the default done-exclusion behaviour (ticket for that is separate) will also -live here, layered on top of this filter application. - -### cmd_ready(filter_args, json) - -Apply the user filter AFTER the ready check. The ready check (not done + all deps done) -is always applied first; the user's filter narrows further within ready tickets. - -```rust -let filter = filter::parse_filters(&filter_args)?; -// ... build done_ids as before ... -let ready: Vec = all - .into_iter() - .filter(|t| { - t.status \!= Status::Done - && t.dependencies.iter().all(|dep| done_ids.contains(dep.as_str())) - && filter.matches(t) - }) - .collect(); -``` - -### cmd_migrate(filter_args, dry_run, json) - -For migrate, the filter selects which tickets are candidates for migration. -Tickets not matching the filter are skipped (counted separately, not treated as errors). - -Add a `skipped` field to `MigrateReport` in `store.rs`: - -```rust -pub struct MigrateReport { - pub updated: usize, - pub already_current: usize, - pub skipped: usize, // NEW: tickets excluded by filter - pub errors: Vec<(String, String)>, -} -``` - -Update `migrate_tickets` signature in `store.rs`: - -```rust -pub async fn migrate_tickets( - root: &Path, - dry_run: bool, - filter: &TicketFilter, -) -> Result -``` - -Inside the per-file loop, after deserialising the ticket, check `filter.matches(&ticket)`. -If false: increment `report.skipped` and continue to next file. - -Update `cmd_migrate` to parse the filter and pass it to `migrate_tickets`. - -## display.rs changes - -Update `format_migrate_report` and `format_migrate_report_json` to include the -`skipped` count: - -Human format: -``` -Migrated 3 tickets. -Current 5 tickets (already up to date). -Skipped 2 tickets (did not match filter). -Errors 1 ticket could not be migrated: - bad_ticket.json: trailing comma at line 4 -``` - -Only print the "Skipped" line when `skipped > 0`. - -JSON format: add `"skipped": N` key to the existing object. - -## files touched - -- `src/main.rs` — `filter` fields on List/Ready/Migrate variants, updated dispatch, - updated cmd_list/cmd_ready/cmd_migrate handlers -- `src/store.rs` — `MigrateReport::skipped`, `migrate_tickets` gains `filter` param -- `src/display.rs` — updated `format_migrate_report` and `format_migrate_report_json` -- `src/tests.rs` — unit tests for updated migrate report formatting -- `tests/integration.rs` — integration tests - -## Integration tests to add (tests/integration.rs) - -**list filtering:** -- Create tickets: 1 bug/todo, 1 task/in_progress, 1 bug/done. - `nbd list --filter type=bug` shows only the bug tickets (done-exclusion is separate, - but this test can use non-done bugs). -- `nbd list --filter status=in_progress` shows only in_progress tickets. -- `nbd list --filter status=todo --filter status=in_progress` shows both todo and in_progress - (OR within same key). -- `nbd list --filter type=bug --filter status=todo` shows only bug+todo tickets - (AND across keys). -- `nbd list --filter title=*login*` shows only tickets whose title contains "login". -- `nbd list --filter status=*` matches all statuses (wildcard). -- `nbd list --filter type=unknown` exits non-zero with an error (unknown key passes through - as a value, but "unknown" does not match any type → empty results, or error? Error on - unknown key is preferable). -- `nbd list --filter badformat` (no `=`) exits non-zero with an error. - -**ready filtering:** -- `nbd ready --filter type=bug` returns only ready bug tickets. -- `nbd ready --filter priority=8` returns only ready tickets with priority 8. - -**migrate filtering:** -- Create two tickets. Run `nbd migrate --filter status=todo --dry-run`. - Verify `skipped` count in JSON output matches tickets not matching the filter. -- `nbd migrate --filter status=todo --json` includes `skipped` key. - -**error cases:** -- `--filter` with unknown key exits non-zero. -- `--filter` with no `=` exits non-zero. diff --git a/nbd/.beans/nbd-pq2x--nbd-archive-command-and-closed-status.md b/nbd/.beans/nbd-pq2x--nbd-archive-command-and-closed-status.md deleted file mode 100644 index b362a52..0000000 --- a/nbd/.beans/nbd-pq2x--nbd-archive-command-and-closed-status.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -# nbd-pq2x -title: nbd archive command and Closed status -status: completed -type: feature -priority: normal -created_at: 2026-03-10T23:30:29Z -updated_at: 2026-03-10T23:30:29Z ---- - -Add `Status::Closed` (serialised as `"closed"`) and a convenience `nbd archive ` command that sets it. - -## Motivation - -`done` tickets clutter `nbd list`. `closed` provides a soft-delete: the ticket is preserved on disk but excluded from normal listings by default. - -## Approach - -### ticket.rs -- Add `Closed` variant to `Status` enum (after `Done`). -- `#[serde(rename_all = "snake_case")]` already handles serialisation → `"closed"`. - -### main.rs -- Update `parse_status` to accept `"closed"`. -- Update `status_str` in `display.rs` to map `Status::Closed` → `"closed"`. -- Add `Archive` variant to `Commands`: - ``` - Archive { id: String } - ``` -- Implement `cmd_archive(id, json)`: read ticket → set status to `Closed` → write → print. - This is syntactic sugar for `nbd update --status closed`. - -### display.rs -- Add `"closed"` to `status_str` match arm. - -### list filtering -- `nbd list` currently shows all tickets. After this change, it should by default **hide** `Closed` tickets. -- Add a `--all` flag to `nbd list` to show all tickets including closed ones. -- Update `list_tickets` or filter at the command handler level. Prefer filtering in `cmd_list` to keep `list_tickets` generic. - -## Tests -- Unit test: `Status::Closed` serialises to `"closed"` and back. -- Integration test: `nbd archive ` sets status to `closed`. -- Integration test: `nbd list` does not show closed tickets. -- Integration test: `nbd list --all` shows closed tickets. - -## Files touched -- `src/ticket.rs` — add `Closed` variant -- `src/main.rs` — `Archive` command, `parse_status` update, `--all` flag on `list` -- `src/display.rs` — `status_str` update -- `src/tests.rs` — unit tests -- `tests/integration.rs` — integration tests -- `README.md` — document archive and --all diff --git a/nbd/.beans/nbd-q2d1--nbd-migrate-command.md b/nbd/.beans/nbd-q2d1--nbd-migrate-command.md deleted file mode 100644 index 92e8a73..0000000 --- a/nbd/.beans/nbd-q2d1--nbd-migrate-command.md +++ /dev/null @@ -1,116 +0,0 @@ ---- -# nbd-q2d1 -title: nbd migrate command -status: completed -type: feature -priority: critical -created_at: 2026-03-10T23:30:29Z -updated_at: 2026-03-10T23:30:31Z -blocked_by: - - nbd-o3k8 ---- - -Add `nbd migrate` to bring all ticket files on disk into conformance with the current schema. This is the standard mechanism for handling any schema change — field removals, field additions, renames, or type changes. - -## Motivation - -Schema changes (like removing `id` from the JSON body, adding new fields with defaults, or renaming a field) leave existing ticket files in an old format. `nbd migrate` re-serialises every ticket through the current serde schema, which: -- **Removes** fields that no longer exist in `Ticket` (they are ignored on deserialise, then absent on re-serialise). -- **Adds** new fields with their `#[serde(default)]` values. -- **Normalises** any formatting differences (e.g. key order, whitespace). - -The current immediate use case is scrubbing the `"id"` key from all existing `.json` files after the id-from-filename schema change. - -## Design principles - -- **Idempotent.** Running `nbd migrate` on an already-current store is a no-op (files are re-written identically). -- **Non-destructive.** A failure on one ticket does not abort the rest; errors are collected and reported at the end. -- **Source of truth unchanged.** If a ticket cannot be parsed, it is left on disk as-is and reported as an error. -- **Dry-run available.** `--dry-run` prints what would change without writing. - -## Approach - -### main.rs - -Add `Migrate` variant to `Commands`: -``` -Migrate { - /// Print changes without writing them. - #[arg(long)] - dry_run: bool, -} -``` - -Implement `cmd_migrate(dry_run: bool) -> store::Result<()>`: -1. `find_nbd_root()` -2. Call `store::migrate_tickets(&root, dry_run).await` -3. Print a summary: `Migrated N tickets (M errors)`. - -### store.rs - -Add `migrate_tickets(root: &Path, dry_run: bool) -> Result`: -1. `fs::read_dir(tickets_dir(root))` -2. For each `*.json`, `*.md`, `*.toml`, `*.jsonb` file (all supported formats — the ones that exist now and future ones added by the multi-format feature): - a. Read the raw bytes. - b. Attempt to deserialise into current `Ticket`, injecting `id` from filename. - c. Re-serialise to the current schema (same format as the original file's extension). - d. Compare raw bytes. If unchanged, skip (count as already-current). - e. If changed and `dry_run`: print `would update {filename}`, do not write. - f. If changed and not `dry_run`: write the new bytes to the same path. - g. If deserialise fails: record the error, leave the file untouched. -3. Return `MigrateReport { updated: usize, already_current: usize, errors: Vec<(String, String)> }`. - -```rust -pub struct MigrateReport { - pub updated: usize, - pub already_current: usize, - pub errors: Vec<(String, String)>, // (filename, error message) -} -``` - -### display.rs - -Add `print_migrate_report(report: &MigrateReport)`: -``` -Migrated 3 tickets. -Current 5 tickets (already up to date). -Errors 1 ticket could not be migrated: - bad_ticket.json: trailing comma at line 4 -``` - -When `--json`, serialise `MigrateReport` directly (derive `Serialize`). - -## How schema changes use this - -For **field removal** (e.g. removing `id` from JSON): -- Old files have `"id": "..."` → on deserialise, serde ignores it (unknown field). -- Re-serialise → `id` is absent (since `#[serde(skip)]`). -- File bytes differ → `migrate` rewrites. - -For **field addition** (e.g. adding `tags: Vec` later): -- New field in `Ticket` gets `#[serde(default)]`. -- Old files lack `tags` → deserialise gives `vec![]`. -- Re-serialise → `"tags": []` is written. -- File bytes differ → `migrate` rewrites. - -## Tests - -Unit tests (`src/tests.rs`): -- `migrate_tickets` on a store with old-format files (containing `"id"`) rewrites them without `id`. -- `migrate_tickets` on an already-current store returns `updated: 0`, `already_current: N`. -- `migrate_tickets --dry-run` does not modify files on disk. -- A file with invalid JSON is counted in `errors` and left unchanged. - -Integration tests (`tests/integration.rs`): -- Create tickets with old code (inject `id` manually into JSON), run `nbd migrate`, verify `id` is gone from files. -- `nbd migrate --dry-run` reports changes but does not modify files. -- `nbd migrate` exits zero even when some tickets error (but prints error summary). -- `nbd migrate --json` outputs a valid JSON object with `updated`, `already_current`, `errors` fields. - -## Files touched -- `src/main.rs` — `Migrate` command, `cmd_migrate` -- `src/store.rs` — `migrate_tickets`, `MigrateReport` -- `src/display.rs` — `print_migrate_report` -- `src/tests.rs` — unit tests -- `tests/integration.rs` — integration tests -- `README.md` — document `nbd migrate` diff --git a/nbd/.beans/nbd-ql0c--add-next-type-filtered-sub-commands-next-bug-next.md b/nbd/.beans/nbd-ql0c--add-next-type-filtered-sub-commands-next-bug-next.md deleted file mode 100644 index 74a801c..0000000 --- a/nbd/.beans/nbd-ql0c--add-next-type-filtered-sub-commands-next-bug-next.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -# nbd-ql0c -title: Add next filtered sub-commands (next bug, next feature, next task) -status: todo -type: feature -priority: low -created_at: 2026-03-10T23:30:30Z -updated_at: 2026-03-10T23:30:30Z ---- - -## Problem - -`nbd next --filter type=bug` is verbose. When an engineer wants the highest-priority ready bug, they should be able to say `nbd next bug`. - -## Sub-commands to add (positional arg to `next`) - -Accept an optional positional `` argument to `nbd next`: - -```sh -nbd next feature # equivalent to: nbd next --filter type=feature -nbd next task # equivalent to: nbd next --filter type=task -nbd next bug # equivalent to: nbd next --filter type=bug -nbd next project # equivalent to: nbd next --filter type=project -``` - -## Implementation - -**`src/main.rs`** — `Commands::Next` - -Add an optional positional argument `ticket_type` to the `Next` variant: - -```rust -Next { - ticket_type: Option, // new: positional shorthand - filter: Vec, -} -``` - -In `cmd_next`, when `ticket_type` is `Some(t)`: -- Validate it is one of `project`, `feature`, `task`, `bug` (return an error otherwise) -- Prepend `format!("type={t}")` to the effective filter list before calling `parse_filters` - -Explicit `--filter type=...` values are ORed with the positional shorthand, consistent with `TicketFilter` behaviour. - -**`tests/integration.rs`** - -Add tests verifying that `next bug` returns only bug-type ready tickets and that `next` with no argument still works as before. diff --git a/nbd/.beans/nbd-rulc--partial-id-matching.md b/nbd/.beans/nbd-rulc--partial-id-matching.md deleted file mode 100644 index 7c7e859..0000000 --- a/nbd/.beans/nbd-rulc--partial-id-matching.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -# nbd-rulc -title: Partial ID matching -status: completed -type: feature -priority: critical -created_at: 2026-03-10T23:30:31Z -updated_at: 2026-03-10T23:30:31Z ---- - -Allow `nbd read`, `nbd update`, and dependency resolution to accept a prefix of a ticket ID (e.g. `nbd read a3f` resolves to `a3f9c2`). - -## Motivation - -6-character hex IDs are tedious to type in full. Prefix matching — like git's short-SHA resolution — significantly improves interactive ergonomics and makes agent-generated commands shorter. - -## Approach - -Add `resolve_id(root: &Path, id_or_prefix: &str) -> Result` in `store.rs`: -1. If `id_or_prefix` is exactly 6 characters, try `read_ticket` as-is (fast path, existing behaviour). -2. Otherwise (or if not found), scan `.nbd/tickets/` for files whose stem starts with `id_or_prefix`. -3. Collect all matches. - - 0 matches → error: `"no ticket found matching '{prefix}'"` - - 1 match → return the full ID - - 2+ matches → error: `"ambiguous prefix '{prefix}' matches: {id1}, {id2}, ..."` - -Use `resolve_id` inside `cmd_read` and `cmd_update` (replacing the bare `id` string passed to `read_ticket`). Also use it inside `validate_deps` so dependency flags can use short IDs too. - -## Tests - -Unit tests in `src/tests.rs`: -- Exact 6-char match still works. -- 3-char prefix resolves correctly. -- Ambiguous prefix returns an error listing all matching IDs. -- Unknown prefix returns a not-found error. - -Integration tests in `tests/integration.rs`: -- `nbd read <3-char-prefix>` resolves and prints the ticket. -- `nbd update <3-char-prefix> --status done` succeeds. -- Ambiguous prefix exits non-zero with an informative message. - -## Files touched -- `src/store.rs` — new `resolve_id` function -- `src/main.rs` — `cmd_read`, `cmd_update`, `validate_deps` use `resolve_id` -- `src/tests.rs` — unit tests -- `tests/integration.rs` — integration tests diff --git a/nbd/.beans/nbd-s16w--multiple-file-format-support-md-toml-jsonb.md b/nbd/.beans/nbd-s16w--multiple-file-format-support-md-toml-jsonb.md deleted file mode 100644 index 46cb657..0000000 --- a/nbd/.beans/nbd-s16w--multiple-file-format-support-md-toml-jsonb.md +++ /dev/null @@ -1,87 +0,0 @@ ---- -# nbd-s16w -title: Multiple file format support (md, toml, jsonb) -status: completed -type: feature -priority: normal -created_at: 2026-03-10T23:30:30Z -updated_at: 2026-03-10T23:30:30Z ---- - -Add `--ftype` flag to `create` and `update` to write tickets in markdown, TOML, or binary JSON (CBOR) in addition to the existing JSON format. Format is detected from file extension on read. - -## Motivation - -Markdown format lets agents write long-form ticket bodies with full markdown syntax, and makes tickets human-readable in a file browser. TOML is a natural config format. CBOR offers compact binary storage. - -## Approach - -### New crate dependencies (Cargo.toml) -Evaluate and add: -- `toml` — TOML serialisation (likely `toml = "0.8"`) -- `serde_yml` or `serde_yaml` — YAML frontmatter (for `.md` files) -- `ciborium` — CBOR binary JSON (`.jsonb`) - -### ticket.rs -No changes needed — `Ticket` already derives `Serialize`/`Deserialize`. - -### store.rs -New enum `FileFormat { Json, Markdown, Toml, Jsonb }`. - -New function `detect_format(path: &Path) -> FileFormat`: -- `.json` → `Json` -- `.md` → `Markdown` -- `.toml` → `Toml` -- `.jsonb` → `Jsonb` -- Unknown → `Json` (fallback) - -Update `ticket_path(root, id, format)` to use the format-appropriate extension. This is a breaking change to the function signature — update all callers. - -Update `read_ticket(root, id)`: -1. Try each known extension in order until a file is found. -2. Read the file and dispatch to the format-appropriate deserialiser. - -Serialisation helpers (private): -- `serialize_json(ticket) -> String` -- `serialize_toml(ticket) -> String` -- `serialize_markdown(ticket) -> String` — TOML frontmatter (`+++` delimiters) with body as file content -- `serialize_jsonb(ticket) -> Vec` - -Deserialization helpers (private): -- `deserialize_markdown(bytes) -> Result` — parse frontmatter + body - -Update `list_tickets` to scan for `*.json`, `*.md`, `*.toml`, `*.jsonb` files. - -Update `write_ticket` to accept `format: FileFormat` and write in the appropriate format. - -### main.rs -Add `--ftype [json|md|toml|jsonb]` option (default `json`) to `create` and `update`. -Conversion on `update --ftype`: read old file, write new format, delete old file (if extension changed). - -## Markdown format (TOML frontmatter) -``` -+++ -id = "a3f9c2" -title = "Fix login bug" -priority = 8 -status = "in_progress" -ticket_type = "bug" -dependencies = ["b7d41e"] -+++ - -Long-form body text goes here. Supports full markdown. -``` - -## Tests -- Unit tests: roundtrip each format (JSON already tested). -- Integration tests: `nbd create --ftype md` creates a `.md` file; `nbd read` finds and parses it. -- Integration test: `nbd update --ftype toml` converts format and removes old file. - -## Files touched -- `Cargo.toml` — new dependencies -- `src/store.rs` — format detection, multi-format read/write, updated `list_tickets` -- `src/main.rs` — `--ftype` flags -- `src/tests.rs` — format roundtrip tests -- `tests/integration.rs` — format integration tests -- `README.md` — document `--ftype` -- `docs/ARCHITECTURE.md` — update storage layout section diff --git a/nbd/.beans/nbd-urlz--investigate-filtering-tickets-by-project-stream-of.md b/nbd/.beans/nbd-urlz--investigate-filtering-tickets-by-project-stream-of.md deleted file mode 100644 index 63cf1b1..0000000 --- a/nbd/.beans/nbd-urlz--investigate-filtering-tickets-by-project-stream-of.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -# nbd-urlz -title: 'Investigate: filtering tickets by project / stream of work' -status: draft -type: task -priority: low -created_at: 2026-03-10T23:30:30Z -updated_at: 2026-03-10T23:30:30Z ---- - -## Problem - -When multiple streams of work coexist (e.g., refactoring vs. new feature), there is no way to select tickets for one stream only. `nbd next` and `nbd list` operate across all tickets. - -## Questions to answer - -1. Does the existing `project`-type ticket + `deps` mechanism serve this need? Could a project ticket act as a grouping node, and filtering by `--filter type=project` or by the project ticket's subtree (`nbd graph --json`) provide what is needed? - -2. Are there cases where a ticket belongs to multiple projects? If so, a single-parent `deps` tree cannot represent the relationship. - -3. Is a dedicated `project` or `label` field (multi-valued) preferable? What are the trade-offs? - -4. How does this interact with `nbd ready` and `nbd next`? Would a `--project ` flag on these commands be sufficient? - -## Approach - -Investigate by: -1. Manually modelling two parallel workstreams using `project`-type tickets and `deps`. -2. Evaluating whether `nbd graph --json` provides enough to extract a project-scoped ticket list. -3. Writing up findings and creating actionable implementation tickets. - -## Expected output - -Create one or more follow-up tickets with a concrete implementation plan, or close this ticket with a rationale if the existing tools are sufficient. diff --git a/nbd/.beans/nbd-wbf7--nix-flake-for-nbd.md b/nbd/.beans/nbd-wbf7--nix-flake-for-nbd.md deleted file mode 100644 index 7db092b..0000000 --- a/nbd/.beans/nbd-wbf7--nix-flake-for-nbd.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -# nbd-wbf7 -title: Nix flake for nbd -status: completed -type: task -priority: low -created_at: 2026-03-10T23:30:30Z -updated_at: 2026-03-10T23:30:30Z ---- - -Add `nbd/flake.nix` so that `nbd` can be consumed as a Nix package and used as a CLI tool in other projects in the mono-repo. - -## Motivation - -Other `vibed` services and external projects should be able to include `nbd` as a dev dependency via Nix, getting a pinned, reproducible binary without needing Cargo installed. - -## Approach - -### nbd/flake.nix - -Follow the pattern from the repo root `flake.nix`. The service flake should: -1. Inherit the base flake's inputs (nixpkgs, rust-overlay, etc.) or declare its own. -2. Define a `packages.default` attribute that builds the `nbd` crate with `rustPlatform.buildRustPackage`. -3. Define a `devShells.default` that includes the `nbd` binary and standard Rust tooling (rustfmt, clippy, cargo). -4. Optionally expose an `apps.default` for `nix run .#nbd`. - -### Cargo.lock -Ensure `Cargo.lock` is committed (it already is) — required for reproducible Nix builds. - -### cargoHash / cargoSha256 -The `buildRustPackage` derivation requires a `cargoHash` (or `cargoSha256`). Use the correct fetcher approach (vendored deps or `fetchCargoTarball`). - -## Steps -1. Read the root `flake.nix` to understand the base structure. -2. Write `nbd/flake.nix` following the same conventions. -3. Run `nix build .#nbd` from the `nbd/` directory to verify it builds. -4. Run `nix run .#nbd -- --help` to verify the binary works. - -## Files touched -- `nbd/flake.nix` — new file -- `README.md` — add `nix run` usage section diff --git a/nbd/.beans/nbd-wi74--version-file-compile-into-binary-via-include-str-a.md b/nbd/.beans/nbd-wi74--version-file-compile-into-binary-via-include-str-a.md deleted file mode 100644 index 339e568..0000000 --- a/nbd/.beans/nbd-wi74--version-file-compile-into-binary-via-include-str-a.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -# nbd-wi74 -title: '''VERSION file: compile into binary via include_str\! and add nbd version subcommand''' -status: todo -type: feature -priority: normal -created_at: 2026-03-10T23:30:30Z -updated_at: 2026-03-10T23:30:30Z ---- - -## Problem - -The current version string is assembled in `main.rs` from `CARGO_PKG_VERSION` (Cargo.toml) and `GIT_SHORT_SHA` (build.rs). There is no `nbd version` subcommand — only `--version`. And the `/work` skill does not bump any version on completion. - -## Changes required - -### 1. Create `VERSION` file - -Create a `VERSION` file at the crate root (`nbd/VERSION`) containing the initial version: - -``` -0.1.0 -``` - -### 2. Use `VERSION` in the binary - -In `src/main.rs`, replace `CARGO_PKG_VERSION` with an `include_str\!` of the `VERSION` file (trimmed): - -```rust -const VERSION: &str = concat\!( - env\!("CARGO_PKG_VERSION"), // keep for crate metadata - "+", - env\!("GIT_SHORT_SHA"), -); -``` - -Actually, use `include_str\!("../VERSION")` trimmed and concatenated with the git SHA: - -```rust -const VERSION_FILE: &str = include_str\!("../VERSION"); -const VERSION: &str = /* VERSION_FILE.trim() + "+" + GIT_SHORT_SHA at runtime or via concat */ ; -``` - -Note: `include_str\!` and `concat\!` cannot trim at compile time. Use a `build.rs` approach: read `VERSION` in `build.rs`, trim it, and emit it via `cargo:rustc-env=NBD_VERSION=...`. Then: - -```rust -const VERSION: &str = concat\!(env\!("NBD_VERSION"), "+", env\!("GIT_SHORT_SHA")); -``` - -Update `build.rs` to read `VERSION` and emit `NBD_VERSION`. - -### 3. Add `nbd version` subcommand - -Add a `Version` variant to the `Commands` enum: - -```rust -/// Print the nbd version string and exit. -Version, -``` - -Handler prints the same string as `--version`. With `--json`, output `{"version": "X.Y.Z+sha"}`. - -### 4. Update `/work` skill - -Edit `.claude/skills/work/SKILL.md` to add a version-bump step after marking a ticket complete: - -- Breaking changes (major rework, API change) → bump major: `X.0.0` -- Features (`ticket_type == feature`) → bump minor: `X.Y.0` -- Tasks and bugs (`ticket_type == task | bug`) → bump patch: `X.Y.Z` - -The skill should read `VERSION`, parse the semver, increment the appropriate component, and write it back. Also update `Cargo.toml` `version` to match (keep in sync). - -## Relevant files - -- `nbd/VERSION` (new) -- `nbd/build.rs` (update: emit `NBD_VERSION`) -- `nbd/src/main.rs` (update: use `NBD_VERSION`, add `Version` subcommand) -- `nbd/Cargo.toml` (keep version in sync with `VERSION` file) -- `.claude/skills/work/SKILL.md` (update: add version-bump step) diff --git a/nbd/.beans/nbd-y4nv--change-graph-cycle-marker-from-cycle-to.md b/nbd/.beans/nbd-y4nv--change-graph-cycle-marker-from-cycle-to.md deleted file mode 100644 index 973b420..0000000 --- a/nbd/.beans/nbd-y4nv--change-graph-cycle-marker-from-cycle-to.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -# nbd-y4nv -title: Change graph cycle marker from [cycle] to * -status: completed -type: task -priority: low -created_at: 2026-03-10T23:30:30Z -updated_at: 2026-03-10T23:30:30Z ---- - -## Goal - -When `nbd graph` renders a node that has already been visited (a node appearing in multiple branches of the tree), it currently labels the repeat occurrence as `[cycle]`. This label is misleading — the node isn't truly in a cycle, it's simply appearing twice in the tree because it's depended on from multiple places. Change the marker to `*` to indicate "this ticket appears elsewhere in the tree". - -## Current output - -``` -a3f9c2 [todo] Fix login bug -├── b7d41e [in_progress] Add rate limiting -│ └── c9e823 [todo] Write tests -└── c9e823 [cycle] -``` - -## Target output - -``` -a3f9c2 [todo] Fix login bug -├── b7d41e [in_progress] Add rate limiting -│ └── c9e823 [todo] Write tests -└── c9e823 * -``` - -## Files to change - -### `src/display.rs` - -In the `render_node` function (around line 409), change the cycle rendering line from: - -```rust -append_line(out, &format!("{prefix}{connector}{id} [cycle]")); -``` - -to: - -```rust -append_line(out, &format!("{prefix}{connector}{id} *")); -``` - -### README.md - -Update the `nbd graph` section example to replace `[cycle]` with `*` in the documentation. - -## Validation - -```sh -cargo fmt && cargo check && cargo clippy && cargo test -# Create two tickets that share a dependency, then graph them -cargo run -- graph -``` - -Any test that checks for `[cycle]` in graph output needs updating to expect `*` instead. diff --git a/nbd/.beans/nbd-yh0v--add-triage-status-new-default-for-tickets-lacking.md b/nbd/.beans/nbd-yh0v--add-triage-status-new-default-for-tickets-lacking.md deleted file mode 100644 index e6e489a..0000000 --- a/nbd/.beans/nbd-yh0v--add-triage-status-new-default-for-tickets-lacking.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -# nbd-yh0v -title: 'Add triage status: new default for tickets lacking implementation detail' -status: todo -type: feature -priority: normal -created_at: 2026-03-10T23:30:31Z -updated_at: 2026-03-10T23:30:31Z ---- - -## Problem - -New tickets are created with `status=todo`, implying they are ready to work on. But many tickets need further research or implementation details before work can begin. The TODO describes a `triage` status for exactly this case — tickets that need an LLM or human to fill in details before they become `todo`. - -## Status semantics - -- `triage` — the ticket exists but lacks sufficient detail to begin implementation. An LLM or human should flesh out the body and move it to `todo` when ready. -- `triage` is excluded from `nbd ready` and `nbd next` (not actionable yet). -- `triage` is excluded from `nbd list` by default (like `backlog`). -- `triage` is the new **default status** for `nbd create` (replaces `todo`). - -## Changes - -### `src/ticket.rs` - -Add `Triage` variant to `Status`: - -```rust -/// The ticket needs more detail before it can be worked on. -/// -/// A triage ticket should have its body updated with implementation -/// details and then moved to `todo`. -Triage, -``` - -Change the `#[default]` attribute from `Todo` to `Triage`. - -### `src/main.rs` - -- `parse_status`: add `"triage" => Ok(Status::Triage)` and update the error message. -- `Commands::Create`: change the default value for `--status` from `"todo"` to `"triage"`. - - **Note:** If `.nbd/config.toml` support (separate ticket) is implemented first, the default should be settable via config; fall back to `"triage"` if config is absent. -- `cmd_ready` and `cmd_next`: add `Status::Triage` to the exclusion list. -- `cmd_list`: add `Status::Triage` to the default exclusion list. - -### `src/filter.rs` - -- `status_str`: add `Status::Triage => "triage"`. - -### `src/display.rs` - -- `status_str`: add `Status::Triage => "triage"`. -- Column widths: `STATUS` column is currently 13 chars (`"in_progress" + 2`). `"triage"" is 6 chars — no width change needed. - -### `src/graph.rs` - -- `status_str`: add `Status::Triage => "triage"`. - -### `src/claude_md_snippet.md` - -Update the embedded snippet to document the triage workflow: -- `triage` tickets need implementation details added to their body before they can be worked on. -- When creating a ticket that is ready to work on immediately, pass `--status todo` explicitly. -- Default: `nbd create --title "..."` creates a `triage` ticket. - -### `CLAUDE.md` (project-level) - -Update the workflow section to reflect the new default and document when to use `--status todo` vs. leaving the default. - -### `src/tests.rs` - -Add unit tests: -- `Status::Triage` serialises to `"triage"` -- Round-trip deserialisation -- Default `Ticket::new` has `status == Status::Triage` - -### `tests/integration.rs` - -- `nbd create` with no `--status` flag creates a `triage` ticket. -- `nbd create --status todo` creates a `todo` ticket. -- `nbd ready` does not include `triage` tickets. -- `nbd next` does not include `triage` tickets. -- `nbd list` does not include `triage` tickets by default. -- `nbd list --filter status=triage` shows only triage tickets. -- `nbd list --all` includes `triage` tickets. diff --git a/nbd/.beans/nbd-zz62--implement-ticketfilter-module-with-glob-matching.md b/nbd/.beans/nbd-zz62--implement-ticketfilter-module-with-glob-matching.md deleted file mode 100644 index 22a1395..0000000 --- a/nbd/.beans/nbd-zz62--implement-ticketfilter-module-with-glob-matching.md +++ /dev/null @@ -1,135 +0,0 @@ ---- -# nbd-zz62 -title: Implement TicketFilter module with glob matching -status: completed -type: feature -priority: critical -created_at: 2026-03-10T23:30:31Z -updated_at: 2026-03-10T23:30:31Z ---- - -## Summary - -Add a `src/filter.rs` module implementing `TicketFilter`, which parses and applies -`key=value` filter expressions against a list of tickets. This is the foundational -building block for the `--filter` flag on `list`, `ready`, `migrate`, and `next` commands. - -No external crate is needed — glob matching is implemented with a simple hand-rolled -algorithm that handles `*` wildcards. - -## Filter semantics - -- `--filter key=value` can be specified multiple times. -- Keys: `priority`, `type`, `status`, `title`. -- **Different keys are ANDed**: `--filter type=bug --filter status=todo` matches tickets - that are BOTH bugs AND todo. -- **Same key, multiple values are ORed**: `--filter status=todo --filter status=in_progress` - matches tickets with EITHER status. -- Values support `*` as a wildcard anywhere in the pattern: - - `status=*` matches any status - - `title=*command*` matches any title containing "command" - - `priority=7` matches exactly priority 7 (as string comparison) - -## Data structures - -```rust -/// A compiled set of filter expressions applied to tickets. -/// -/// Within the same key, patterns are ORed (any match passes the group). -/// Across different keys, groups are ANDed (all non-empty groups must pass). -pub struct TicketFilter { - /// Glob patterns for `status` (OR within this group). - pub status: Vec, - /// Glob patterns for `ticket_type` (OR within this group). - pub ticket_type: Vec, - /// Glob patterns for `priority` (OR within this group; matched against string repr). - pub priority: Vec, - /// Glob patterns for `title` (OR within this group). - pub title: Vec, -} -``` - -An empty `Vec` for a key means "no filter on that key" — matches everything. - -## API - -```rust -/// Parse a slice of "key=value" strings into a TicketFilter. -/// -/// The key `type` maps to the `ticket_type` field. -/// Returns an error for unknown keys or malformed expressions (no '='). -pub fn parse_filters(args: &[String]) -> crate::store::Result - -impl TicketFilter { - /// Returns true when all non-empty filter groups match this ticket. - /// An empty TicketFilter always returns true. - pub fn matches(&self, ticket: &crate::ticket::Ticket) -> bool; - - /// Returns true when no filter groups are set (no-op filter). - pub fn is_empty(&self) -> bool; - - /// Returns true when the user has provided at least one status pattern. - /// Used by cmd_list to detect whether to apply the implicit done-exclusion. - pub fn has_status_filter(&self) -> bool; -} -``` - -## Glob algorithm (no external crate) - -Implement a private `fn glob_matches(pattern: &str, value: &str) -> bool`: - -1. If pattern contains no `*`, require exact equality. -2. Split pattern on `*` into segments. -3. If pattern does NOT start with `*`, value must start with `segments[0]`. -4. If pattern does NOT end with `*`, value must end with `segments[last]`. -5. For each remaining segment, find it in the remaining suffix of value (left to right). - Advance past the match and continue. If any segment is not found, return false. - -This handles: `*`, `foo`, `foo*`, `*foo`, `*foo*`, `foo*bar`, `*foo*bar*`. - -Case sensitivity: -- `status` and `type` patterns: case-insensitive (compare lowercase). -- `title` and `priority` patterns: case-sensitive. - -## matches() logic - -``` -fn matches(&self, ticket) -> bool { - (self.status.is_empty() || self.status.iter().any(|p| glob_matches(p, status_str(&ticket.status)))) - && (self.ticket_type.is_empty() || self.ticket_type.iter().any(|p| glob_matches(p, ticket_type_str(&ticket.ticket_type)))) - && (self.priority.is_empty() || self.priority.iter().any(|p| glob_matches(p, &ticket.priority.to_string()))) - && (self.title.is_empty() || self.title.iter().any(|p| glob_matches(p, &ticket.title))) -} -``` - -Use `status_str` and `ticket_type_str` equivalents (can be private functions in filter.rs -or call into display, or duplicate the small match arms). - -## Module registration - -Add `mod filter;` to `src/main.rs` (or move to `lib.rs` if the project ever gains one). -The module is `pub(crate)`. - -## Files touched - -- `src/filter.rs` — new module -- `src/main.rs` — add `mod filter;` -- `src/tests.rs` — unit tests - -## Unit tests to write (src/tests.rs) - -- `parse_filters` rejects unknown keys with a descriptive error. -- `parse_filters` rejects a string with no `=`. -- `parse_filters` with `key=value=more` treats everything after first `=` as the value. -- `glob_matches("*", "anything")` → true. -- `glob_matches("*", "")` → true. -- `glob_matches("todo", "todo")` → true; `glob_matches("todo", "done")` → false. -- `glob_matches("*command*", "add command here")` → true. -- `glob_matches("*command*", "no match")` → false. -- `glob_matches("in_*", "in_progress")` → true. -- `glob_matches("in_*", "todo")` → false. -- `TicketFilter::matches` — two different keys AND correctly (both must match). -- `TicketFilter::matches` — same key OR correctly (either matches). -- `TicketFilter::matches` — empty filter matches everything. -- `TicketFilter::is_empty` — true when no filters, false when any filter set. -- `TicketFilter::has_status_filter` — true iff status vec is non-empty. diff --git a/nbd/.claude/settings.json b/nbd/.claude/settings.json deleted file mode 100644 index c97511a..0000000 --- a/nbd/.claude/settings.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(cargo:*)", - "Bash(git:*)", - "Bash(ls:*)", - "Edit", - "Write" - ] - } -} diff --git a/nbd/.claude/skills/triage/SKILL.md b/nbd/.claude/skills/triage/SKILL.md deleted file mode 100644 index 37777c3..0000000 --- a/nbd/.claude/skills/triage/SKILL.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -name: triage -description: triage issues ---- - -* Read @docs @src @tests @PLAN.md @README.md and @CLAUDE.md for context before starting. -* Deeply understand each item before planning. -* Create `beans` beans for each item including your plan, breaking the problem down into smaller beans where it makes sense. -* Include relevant source files, documentation, and context about each bean so the work has the highest probability of success without additional context being required. -* If a file was passed to this skill, remove items from the file which now have a bean. -* Commit these beans in the `.beans` directory with a concise summary of each bean, probably using the title and bean IDs, and the file passed to this skill if one was passed. -* Do not implement any tickets. diff --git a/nbd/.claude/skills/work/SKILL.md b/nbd/.claude/skills/work/SKILL.md deleted file mode 100644 index dfdbc88..0000000 --- a/nbd/.claude/skills/work/SKILL.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -name: work -description: work on the highest priority thing ---- - -* If a ticket ID or description of a ticket is provided, look for that ticket. -* If no ticket/description is provided, use `beans list --json --ready` to choose the highest-priority unblocked bean to work on. -* Thoroughly investigate the relevant parts of the codebase to ensure you understand the problem and how to implement the solution as describe in the ticket. -* Implement the plan in the selected ticket. -* Once complete validate changes with cargo fmt, cargo check, cargo clippy, and cargo test. -* Iteratively fix warnings and errors then repeat the validation steps until everything is good. -* Update the ticket with the new status. -* Create additional tickets based on work that may have come up during this work. -* Add or Update docs including README.md and CLAUDE.md if relevant. -* Commit changes with a concise message and reference the ticket ID. diff --git a/nbd/CLAUDE.md b/nbd/CLAUDE.md deleted file mode 100644 index 558a9dd..0000000 --- a/nbd/CLAUDE.md +++ /dev/null @@ -1,184 +0,0 @@ -@../CLAUDE.md - -# CLAUDE.md — nbd - - -All commands in this file are run from the `nbd/` directory. - - - - -`nbd` is a CLI tool for managing work tickets, primarily targeted at agent workflows. It is an independent Rust crate within the `vibed` mono-repo. - - - - - -## Project Structure - -``` -nbd/ -├── CLAUDE.md -├── PLAN.md # implementation plan and post-MVP roadmap -├── Cargo.toml -├── Cargo.lock -├── src/ -│ ├── main.rs # CLI entry point; clap subcommand dispatch -│ ├── ticket.rs # Ticket struct, Status and TicketType enums -│ ├── store.rs # file I/O, directory traversal, CRUD -│ ├── display.rs # tabular and JSON output formatting -│ └── tests.rs # unit tests -├── tests/ -│ └── integration.rs # integration tests using tempdir -├── docs/ -│ ├── PLANNING.md # development phases and work logs -│ └── ARCHITECTURE.md # component overview and interactions -└── README.md -``` - - - - - -## Tech Stack - -- **Language:** Rust (edition 2021) -- **Async runtime:** `async-std` -- **CLI parsing:** `clap` (derive feature) -- **Serialization:** `serde` + `serde_json` -- **Test utilities:** `tempfile` (dev-dependency) - - - - - -## Data Model - -Tickets are stored as `.json` files in `.nbd/tickets/{id}.json`, where `id` is a 6-character hex string (e.g. `a3f9c2`). The `.nbd/` root is found by traversing up from cwd, like `git` finds `.git/`. - -``` -Ticket { - id: String // 6-char hex - title: String - body: String - priority: u8 // 0..=10, default 5 - status: Status // Todo | InProgress | Done | Closed | Archived | Backlog, default Todo - dependencies: Vec // Vec of ticket IDs, default [] - ticket_type: TicketType // Project | Feature | Task | Bug, default Task -} -``` - - - - - -## CLI Interface - -```sh -nbd init [--json] - -nbd create --title "..." [--body "..."] [--priority 5] [--status todo|in_progress|done|closed|archived|backlog] - [--type task] [--deps id1,id2] [--json] - -nbd read [--json] - -nbd list [--json] - -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. - - - - - -## Module Responsibilities - -| Module | Responsibility | -|---|---| -| `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, 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 beans - -Use `beans` to track tasks for work on this project. - -```sh -beans init # run once; creates .beans/ -``` - -### Workflow - -Always pass `--json` to every command. Use `jq` to parse output when needed. - -**Before starting work:** Create a bean. - -```sh -beans create --json "Add partial ID matching" --type feature --priority high --status todo -``` - -**When starting a task:** Update its status. - -```sh -beans update --json --status in-progress -``` - -**When done:** Mark it complete. - -```sh -beans update --json --status completed -``` - -**To see all beans ready to start:** - -```sh -beans list --json --ready -``` - -**To see all beans:** - -```sh -beans list --json -``` - -**To view a specific bean:** - -```sh -beans show --json -``` - -### Guidelines - -- **Always use `--json`.** It gives structured, unambiguous output on every command. -- Create beans *before* starting non-trivial tasks, not after. -- Use `--blocked-by ` to express blockers — beans that must be done first. -- `--priority` choices: `critical`, `high`, `normal`, `low`, `deferred`. -- `--type` choices: `milestone`, `epic`, `feature`, `task`, `bug`. - - - - - -## Testing - -- **Unit tests:** `src/tests.rs` — test each module in isolation; use `tempfile` for any file I/O -- **Integration tests:** `tests/integration.rs` — test full command flows (create → read → list → update) against a real temp directory; test directory traversal by running from a subdirectory - -Target 80%+ code coverage. - - diff --git a/nbd/Cargo.lock b/nbd/Cargo.lock deleted file mode 100644 index e1a8145..0000000 --- a/nbd/Cargo.lock +++ /dev/null @@ -1,2465 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy 0.8.39", -] - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "anstream" -version = "0.6.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" - -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "axum" -version = "0.6.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" -dependencies = [ - "async-trait", - "axum-core", - "bitflags 1.3.2", - "bytes", - "futures-util", - "http", - "http-body", - "hyper", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "sync_wrapper", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "axum-core" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http", - "http-body", - "mime", - "rustversion", - "tower-layer", - "tower-service", -] - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - -[[package]] -name = "bindgen" -version = "0.66.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b84e06fc203107bfbad243f4aba2af864eb7db3b1cf46ea0a023b0b433d2a7" -dependencies = [ - "bitflags 2.11.0", - "cexpr", - "clang-sys", - "lazy_static", - "lazycell", - "log", - "peeking_take_while", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash", - "shlex", - "syn", - "which", -] - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" - -[[package]] -name = "block-padding" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -dependencies = [ - "serde", -] - -[[package]] -name = "cbc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher", -] - -[[package]] -name = "cc" -version = "1.2.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cexpr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "ciborium" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" -dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", -] - -[[package]] -name = "ciborium-io" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" - -[[package]] -name = "ciborium-ll" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" -dependencies = [ - "ciborium-io", - "half", -] - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - -[[package]] -name = "clang-sys" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" -dependencies = [ - "glob", - "libc", - "libloading", -] - -[[package]] -name = "clap" -version = "4.5.60" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.5.60" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.5.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" - -[[package]] -name = "colorchoice" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "fallible-iterator" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" - -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "getrandom" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", - "wasip3", -] - -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - -[[package]] -name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http", - "indexmap 2.13.0", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy 0.8.39", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", - "allocator-api2", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - -[[package]] -name = "hashlink" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" -dependencies = [ - "hashbrown 0.14.5", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http", - "pin-project-lite", -] - -[[package]] -name = "http-range-header" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "add0ab9360ddbd88cfeb3bd9574a1d85cfdfa14db10b3e21d3700dbc4328758f" - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "399c78f9338483cb7e630c8474b07268983c6bd5acee012e4211f9f7bb21b070" -dependencies = [ - "futures-util", - "http", - "hyper", - "log", - "rustls", - "rustls-native-certs", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "webpki-roots 0.26.11", -] - -[[package]] -name = "hyper-timeout" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" -dependencies = [ - "hyper", - "pin-project-lite", - "tokio", - "tokio-io-timeout", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", -] - -[[package]] -name = "indexmap" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" -dependencies = [ - "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", -] - -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "block-padding", - "generic-array", -] - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "js-sys" -version = "0.3.88" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7e709f3e3d22866f9c25b3aff01af289b18422cc8b4262fb19103ee80fe513d" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "lazycell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "libc" -version = "0.2.182" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" - -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link", -] - -[[package]] -name = "libsql" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe18646e4ef8db446bc3e3f5fb96131483203bc5f4998ff149f79a067530c01c" -dependencies = [ - "anyhow", - "async-stream", - "async-trait", - "base64", - "bincode", - "bitflags 2.11.0", - "bytes", - "fallible-iterator 0.3.0", - "futures", - "http", - "hyper", - "hyper-rustls", - "libsql-hrana", - "libsql-sqlite3-parser", - "libsql-sys", - "libsql_replication", - "parking_lot", - "serde", - "serde_json", - "thiserror", - "tokio", - "tokio-stream", - "tokio-util", - "tonic", - "tonic-web", - "tower", - "tower-http", - "tracing", - "uuid", - "zerocopy 0.7.35", -] - -[[package]] -name = "libsql-ffi" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f2a50a585a1184a43621a9133b7702ba5cb7a87ca5e704056b19d8005de6faf" -dependencies = [ - "bindgen", - "cc", -] - -[[package]] -name = "libsql-hrana" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aeaf5d19e365465e1c23d687a28c805d7462531b3f619f0ba49d3cf369890a3e" -dependencies = [ - "base64", - "bytes", - "prost", - "serde", -] - -[[package]] -name = "libsql-rusqlite" -version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae65c66088dcd309abbd5617ae046abac2a2ee0a7fdada5127353bd68e0a27ea" -dependencies = [ - "bitflags 2.11.0", - "fallible-iterator 0.2.0", - "fallible-streaming-iterator", - "hashlink", - "libsql-ffi", - "smallvec", -] - -[[package]] -name = "libsql-sqlite3-parser" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15a90128c708356af8f7d767c9ac2946692c9112b4f74f07b99a01a60680e413" -dependencies = [ - "bitflags 2.11.0", - "cc", - "fallible-iterator 0.3.0", - "indexmap 2.13.0", - "log", - "memchr", - "phf", - "phf_codegen", - "phf_shared", - "uncased", -] - -[[package]] -name = "libsql-sys" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c05b61c226781d6f5e26e3e7364617f19c0c1d5332035802e9229d6024cec05" -dependencies = [ - "bytes", - "libsql-ffi", - "libsql-rusqlite", - "once_cell", - "tracing", - "zerocopy 0.7.35", -] - -[[package]] -name = "libsql_replication" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf40c4c2c01462da758272976de0a23d19b4e9c714db08efecf262d896655b5" -dependencies = [ - "aes", - "async-stream", - "async-trait", - "bytes", - "cbc", - "libsql-rusqlite", - "libsql-sys", - "parking_lot", - "prost", - "serde", - "thiserror", - "tokio", - "tokio-stream", - "tokio-util", - "tonic", - "tracing", - "uuid", - "zerocopy 0.7.35", -] - -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - -[[package]] -name = "linux-raw-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "mio" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "nbd" -version = "0.1.0" -dependencies = [ - "ciborium", - "clap", - "libsql", - "serde", - "serde_json", - "tempfile", - "tokio", - "toml", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "peeking_take_while" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_shared", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared", - "rand", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", - "uncased", -] - -[[package]] -name = "pin-project" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy 0.8.39", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "prost" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" -dependencies = [ - "bytes", - "prost-derive", -] - -[[package]] -name = "prost-derive" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" -dependencies = [ - "anyhow", - "itertools", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "quote" -version = "1.0.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.11.0", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustix" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" -dependencies = [ - "bitflags 2.11.0", - "errno", - "libc", - "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" -dependencies = [ - "log", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" -dependencies = [ - "openssl-probe", - "rustls-pemfile", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.102.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "schannel" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.11.0", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "siphasher" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "socket2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" -dependencies = [ - "libc", - "windows-sys 0.60.2", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - -[[package]] -name = "tempfile" -version = "3.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" -dependencies = [ - "fastrand", - "getrandom 0.4.1", - "once_cell", - "rustix 1.1.3", - "windows-sys 0.61.2", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio" -version = "1.49.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2 0.6.2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-io-timeout" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bd86198d9ee903fedd2f9a2e72014287c0d9167e4ae43b5853007205dda1b76" -dependencies = [ - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-macros" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-rustls" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" -dependencies = [ - "rustls", - "rustls-pki-types", - "tokio", -] - -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap 2.13.0", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", - "winnow", -] - -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - -[[package]] -name = "tonic" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76c4eb7a4e9ef9d4763600161f12f5070b92a578e1b634db88a6887844c91a13" -dependencies = [ - "async-stream", - "async-trait", - "axum", - "base64", - "bytes", - "h2", - "http", - "http-body", - "hyper", - "hyper-timeout", - "percent-encoding", - "pin-project", - "prost", - "tokio", - "tokio-stream", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tonic-web" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc3b0e1cedbf19fdfb78ef3d672cb9928e0a91a9cb4629cc0c916e8cff8aaaa1" -dependencies = [ - "base64", - "bytes", - "http", - "http-body", - "hyper", - "pin-project", - "tokio-stream", - "tonic", - "tower-http", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" -dependencies = [ - "futures-core", - "futures-util", - "indexmap 1.9.3", - "pin-project", - "pin-project-lite", - "rand", - "slab", - "tokio", - "tokio-util", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c5bb1d698276a2443e5ecfabc1008bf15a36c12e6a7176e7bf089ea9131140" -dependencies = [ - "bitflags 2.11.0", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-range-header", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "uncased" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" -dependencies = [ - "version_check", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "uuid" -version = "1.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" -dependencies = [ - "getrandom 0.4.1", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec1adf1535672f5b7824f817792b1afd731d7e843d2d04ec8f27e8cb51edd8ac" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19e638317c08b21663aed4d2b9a2091450548954695ff4efa75bff5fa546b3b1" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c64760850114d03d5f65457e96fc988f11f01d38fbaa51b254e4ab5809102af" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60eecd4fe26177cfa3339eb00b4a36445889ba3ad37080c2429879718e20ca41" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.13.0", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.0", - "hashbrown 0.15.5", - "indexmap 2.13.0", - "semver", -] - -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.6", -] - -[[package]] -name = "webpki-roots" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "which" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" -dependencies = [ - "either", - "home", - "once_cell", - "rustix 0.38.44", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "winnow" -version = "0.7.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" -dependencies = [ - "memchr", -] - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap 2.13.0", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.0", - "indexmap 2.13.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.13.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "zerocopy" -version = "0.7.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" -dependencies = [ - "byteorder", - "zerocopy-derive 0.7.35", -] - -[[package]] -name = "zerocopy" -version = "0.8.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" -dependencies = [ - "zerocopy-derive 0.8.39", -] - -[[package]] -name = "zerocopy-derive" -version = "0.7.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/nbd/Cargo.toml b/nbd/Cargo.toml deleted file mode 100644 index 1cc6cef..0000000 --- a/nbd/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "nbd" -version = "0.1.0" -edition = "2021" -description = "CLI tool for managing work tickets, targeted at agent workflows" -license = "MIT OR Apache-2.0" - -[[bin]] -name = "nbd" -path = "src/main.rs" - -[dependencies] -clap = { version = "4", features = ["derive"] } -tokio = { version = "1", features = ["full"] } -libsql = "0.6" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -toml = "0.8" -ciborium = "0.2" - -[dev-dependencies] -tempfile = "3" - -[profile.release] -opt-level = "z" -lto = true -strip = true -codegen-units = 1 diff --git a/nbd/PLAN.md b/nbd/PLAN.md deleted file mode 100644 index b626663..0000000 --- a/nbd/PLAN.md +++ /dev/null @@ -1,179 +0,0 @@ -# nbd — Implementation Plan - -## Overview - -`nbd` is a CLI tool for managing work tickets, primarily targeted at agent workflows. It stores tickets as JSON files in `.nbd/tickets/` in the project root (found by traversing up from cwd, like git). - -**Crate location:** `vibed/nbd/` -**Language:** Rust (edition 2021) -**Key crates:** `clap`, `async-std`, `serde` / `serde_json`, `tempfile` (tests) - ---- - -## Phase 1: Crate Scaffold - -Set up the crate structure with no logic yet. - -- [x] Create `nbd/Cargo.toml` with dependencies: `clap` (derive feature), `async-std`, `serde`, `serde_json`; dev-dependencies: `tempfile` -- [x] Add `[profile.release]` optimizations (`opt-level = "z"`, `lto = true`, `strip = true`, `codegen-units = 1`) -- [x] Create `nbd/src/main.rs` — `async-std` entry point, placeholder `fn main()` -- [x] Create `nbd/src/ticket.rs` — empty module -- [x] Create `nbd/src/store.rs` — empty module -- [x] Create `nbd/src/display.rs` — empty module -- [x] Create `nbd/src/tests.rs` — empty test module -- [x] Create `nbd/tests/integration.rs` — empty integration test file -- [x] Verify: `cargo check` passes - ---- - -## Phase 2: Data Model (`ticket.rs`) - -Define the core types. - -- [x] Define `Status` enum: `Todo`, `InProgress`, `Done` (default: `Todo`) - - Derive: `Serialize`, `Deserialize`, `Debug`, `Clone`, `PartialEq` - - Serialize to/from lowercase strings (`"todo"`, `"in_progress"`, `"done"`) -- [x] Define `TicketType` enum: `Project`, `Feature`, `Task`, `Bug` (default: `Task`) - - Derive same traits - - Serialize to/from lowercase strings -- [x] Define `Ticket` struct: - ``` - id: String // 6-char hex, e.g. "a3f9c2" - title: String - body: String - priority: u8 // 0..=10, default 5 - status: Status - dependencies: Vec // Vec of ticket IDs - ticket_type: TicketType // field name avoids keyword collision - ``` - - Derive: `Serialize`, `Deserialize`, `Debug`, `Clone` -- [x] Implement `Ticket::new(id, title)` constructor with all defaults -- [x] Implement priority validation (error if > 10) -- [x] Implement ID generation: 3 random bytes → 6 hex chars (use `std::collections::hash_map::RandomState` or similar; no external crate needed for MVP) -- [x] Unit tests: serialization roundtrip, priority validation, ID format (6 hex chars, unique across N calls) - ---- - -## Phase 3: Storage (`store.rs`) - -File I/O and directory traversal using `async-std`. - -- [x] Implement `find_nbd_root() -> Result`: walk from cwd upward until `.nbd/` is found; error with helpful message if not found -- [x] Implement `find_nbd_root_from(start: &Path) -> Result`: testable variant that accepts a starting path -- [x] Implement `tickets_dir(root: &Path) -> PathBuf`: returns `root/.nbd/tickets/` -- [x] Implement `ensure_tickets_dir(root: &Path) -> Result<()>`: creates `.nbd/tickets/` if missing (used only by `create`) -- [x] Implement `ticket_path(root: &Path, id: &str) -> PathBuf`: returns `.nbd/tickets/{id}.json` -- [x] Implement `write_ticket(root: &Path, ticket: &Ticket) -> Result<()>`: serialize to JSON, write file -- [x] Implement `read_ticket(root: &Path, id: &str) -> Result`: read file, deserialize; error if not found -- [x] Implement `list_tickets(root: &Path) -> Result>`: read all `*.json` from tickets dir, deserialize all, sort by priority descending -- [x] Unit tests: roundtrip write/read with tempdir, list returns all tickets, traversal finds `.nbd/` in grandparent dir - ---- - -## Phase 4: Display (`display.rs`) - -Output formatting. - -- [x] Implement `print_ticket(ticket: &Ticket)`: full tabular display - ``` - ID: a3f9c2 - Title: Fix login bug - Body: Users cannot log in with email addresses containing + - Priority: 8 - Status: in_progress - Type: bug - Dependencies: b7d41e, c9e823 - ``` -- [x] Implement `print_ticket_json(ticket: &Ticket)`: pretty-printed JSON to stdout -- [x] Implement `print_list(tickets: &[Ticket])`: short table - ``` - ID PRI TYPE STATUS TITLE - a3f9c2 8 bug in_progress Fix login bug - b7d41e 5 task todo Add rate limiting - ``` -- [x] Implement `print_list_json(tickets: &[Ticket])`: JSON array to stdout -- [x] Added `format_ticket`, `format_ticket_json`, `format_list`, `format_list_json` internal functions for testability -- [x] Unit tests: table output contains expected field values, JSON output is valid - ---- - -## Phase 5: CLI Commands (`main.rs`) - -Wire up `clap` subcommands to storage and display. - -- [x] Define CLI structure with `clap` derive: - - Global flag: `--json` (all commands) - - Subcommand `create`: `--title` (required), `--body`, `--priority`, `--status`, `--type`, `--deps` (comma-separated IDs) - - Subcommand `read`: positional `` - - Subcommand `list`: no args - - Subcommand `update`: positional ``, same optional flags as `create` -- [x] Implement `cmd_create`: generate ID, validate deps exist, write ticket, print -- [x] Implement `cmd_read`: find ticket by ID, print -- [x] Implement `cmd_list`: list all tickets, print -- [x] Implement `cmd_update`: read existing ticket, merge only provided flags, write, print -- [x] Added `parse_status`, `parse_ticket_type`, `parse_deps`, `validate_deps` helpers -- [x] Fixed clippy warning in `store.rs`: `map_or(false, ...)` → `is_some_and(...)` -- [x] Integration tests (tempdir): create → read roundtrip, list shows created tickets, update merges correctly, traversal test (run from subdir), error on unknown ID, JSON flag tests, dep replacement test - ---- - -## Phase 6: Documentation & Validation - -- [x] Write `nbd/README.md`: what, how, run, test, license, Claude Code disclaimer -- [x] Write `nbd/docs/PLANNING.md`: this plan + work log -- [x] Write `nbd/docs/ARCHITECTURE.md`: module overview and interactions -- [x] Run validation in order: `cargo fmt`, `cargo check`, `cargo clippy`, `cargo test` -- [x] Verified end-to-end: - ```sh - cargo run -- create --title "Test ticket" --priority 7 --type bug - cargo run -- list - cargo run -- read - cargo run -- update --status in_progress - cargo run -- list --json - ``` - ---- - -## Post-MVP - -The following features are planned but excluded from the MVP: - -- `nbd init` — explicit initialization command -- `nbd ready` — list tickets with no blockers -- `nbd archive` — mark tickets as Closed -- `nbd update` git-diff style +/- output -- Partial ID matching (`nbd read a3f` finding `a3f9c2`) -- SQLite cache for performance -- Nix flake for depending on `nbd` in other projects - -### Multiple File Format Support - -`nbd` will support multiple ticket file formats selectable via `--ftype=[json|md|toml|jsonb]` on `create` and `update`. - -**Format detection** is by file extension: `.json`, `.md`, `.toml`, `.jsonb`. The store layer detects format from the file extension when reading; no format metadata is stored separately. - -**Markdown format** (`.md`): The file body is the ticket `body` field. Metadata is stored as frontmatter: -- `---` delimiter → YAML frontmatter -- `+++` delimiter → TOML frontmatter - -Example (YAML frontmatter): -``` ---- -id: a3f9c2 -title: Fix login bug -priority: 8 -status: in_progress -ticket_type: bug -dependencies: [b7d41e] ---- - -Long-form body text goes here. Supports full markdown. -``` - -**TOML format** (`.toml`): All fields in a flat TOML file; `body` is a multi-line string. - -**Binary JSON** (`.jsonb`): Compact binary-encoded JSON (e.g. via CBOR or BSON; crate TBD at implementation time). - -**Conversion via `nbd update --ftype`**: Reads the ticket in its current format, writes it in the new format, and deletes the old file. The ID and all field values are preserved. - -**Crates to evaluate:** `serde_yaml`, `toml`, `ciborium` (CBOR) or `bson`. diff --git a/nbd/README.md b/nbd/README.md deleted file mode 100644 index ffbce66..0000000 --- a/nbd/README.md +++ /dev/null @@ -1,268 +0,0 @@ -# nbd - -A CLI tool for managing work tickets, primarily 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 — just like `git` finds `.git/`. - -## How it works - -Each ticket is a JSON file named `{id}.json`, where `id` is a unique -6-character lowercase hex string (e.g. `a3f9c2`). Tickets carry: - -| Field | Type | Default | -|---|---|---| -| `id` | 6-char hex string | auto-generated | -| `title` | string | *(required)* | -| `body` | string | `""` | -| `priority` | integer 0–10 | `5` | -| `status` | `todo` \| `in_progress` \| `done` \| `closed` \| `archived` \| `backlog` | `todo` | -| `ticket_type` | `project` \| `feature` \| `task` \| `bug` | `task` | -| `dependencies` | list of ticket IDs | `[]` | - -Tickets can be stored in multiple formats, selected at creation time with -`--ftype`: - -| Format | Extension | Description | -|---|---|---| -| `json` | `.json` | Pretty-printed JSON (default) | -| `md` | `.md` | Markdown body with TOML frontmatter | -| `toml` | `.toml` | TOML | -| `jsonb` | `.jsonb` | CBOR binary | - -All commands accept `--json` for machine-readable output. - -## Usage - -### Initialise - -Create the ticket store in your project root: - -```sh -nbd init -``` - -Analogous to `git init` — safe to run multiple times. - -### Create a ticket - -```sh -nbd create --title "Fix login bug" --priority 8 --type bug -nbd create --title "Add rate limiting" --body "Protect public endpoints" --deps a3f9c2 -nbd create --title "Long-form spec" --body "# Section\n\nDetails..." --ftype md -nbd create --title "Config ticket" --ftype toml -``` - -### Read a ticket - -```sh -nbd read a3f9c2 -nbd read a3f9c2 --json -``` - -### List all tickets - -By default, `done`, `closed`, `archived`, and `backlog` tickets are excluded. - -```sh -nbd list # todo + in_progress only (done/closed/archived/backlog excluded) -nbd list --all # all tickets including done, closed, archived, and backlog -nbd list --filter status=* # all tickets (alias for --all via filter) -nbd list --filter status=done # only completed tickets -nbd list --filter status=archived # only archived tickets (set by nbd archive) -nbd list --filter status=closed # only closed/cancelled tickets -nbd list --filter status=backlog # only backlog tickets -nbd list --filter type=bug # non-done, non-closed, non-archived, non-backlog bug tickets -nbd list --json -``` - -### Archive a ticket - -Soft-delete a ticket by setting its status to `archived`. The file is preserved -on disk but hidden from normal listings. - -```sh -nbd archive a3f9c2 -nbd archive a3f9c2 --json -``` - -`nbd archive` is shorthand for `nbd update --status archived`. Archived -tickets count as resolved for dependency purposes — any ticket that depends on -an archived ticket becomes unblocked. - -The `closed` status is distinct from `archived`: use `closed` (via -`nbd update --status closed`) for tickets that will not be completed -(cancelled, superseded, won't-fix). Both `archived` and `closed` are excluded -from normal listings and both count as resolved for dependencies. - -### Update a ticket - -Only the flags you supply are changed; all other fields retain their current -values. Use `--ftype` to convert to a different storage format (the old file -is removed automatically). - -```sh -nbd update a3f9c2 --status in_progress -nbd update a3f9c2 --priority 9 --type bug -nbd update a3f9c2 --ftype md # convert to markdown format -``` - -### Find the next ticket to work on - -Returns the single highest-priority ticket that is ready to work on — not done -and with all dependencies completed: - -```sh -nbd next -nbd next --json -nbd next --filter type=bug # highest-priority ready bug -nbd next --filter priority=9 # highest-priority ready ticket at priority 9 -``` - -Exits 0 even when no ready ticket exists (`{"next": null}` in JSON mode). - -### Find all actionable tickets - -List all tickets that are ready to work on — not done and with all dependencies -completed: - -```sh -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 `*` 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: - -```sh -nbd claude-md # raw markdown, suitable for redirect -nbd claude-md >> CLAUDE.md # append to your project's CLAUDE.md -nbd claude-md --json # machine-readable {"snippet": "..."} -``` - -The snippet is embedded in the binary at compile time, so it is always in sync -with the installed version of `nbd`. - -### Migrate ticket files - -Re-serialise all ticket files through the current schema. Use this after -upgrading `nbd` to remove stale fields, add new fields with defaults, and -normalise formatting. - -```sh -nbd migrate -nbd migrate --dry-run # preview changes without writing -nbd migrate --json # machine-readable summary -``` - -## Installation - -### Nix (recommended) - -Run directly without installing, using the bundled Nix flake: - -```sh -nix run github:elijah/vibed?dir=nbd -- --help -``` - -Or add to a Nix devShell: - -```nix -{ - inputs.nbd.url = "github:elijah/vibed?dir=nbd"; - # then use nbd.packages.${system}.nbd in your devShell packages -} -``` - -Build locally from the `nbd/` directory: - -```sh -nix build # builds the nbd binary -nix run -- init # run nbd init via nix run -``` - -### Cargo - -```sh -cargo install --path . -``` - -## Running - -```sh -# From the nbd/ directory -cargo run -- --version # prints e.g. nbd 0.1.0+7e311d6 -cargo run -- init -cargo run -- create --title "Test ticket" --priority 7 --type bug -cargo run -- list -cargo run -- list --all -cargo run -- ready -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 -``` - -## Testing - -```sh -cargo test -``` - -Unit tests live in `src/tests.rs`. Integration tests (full command flows against -a temporary directory) live in `tests/integration.rs`. - -## Development - -Run these commands in order before committing: - -```sh -cargo fmt -cargo check -cargo clippy -cargo test -``` - -## License - -Dual-licensed under [Apache License, Version 2.0](../LICENSE-APACHE) and -[MIT License](../LICENSE-MIT), consistent with the rest of the `vibed` mono-repo. - ---- - -*This software was written with [Claude Code](https://claude.com/claude-code) -using the claude-sonnet-4-6 model.* diff --git a/nbd/TODO.md b/nbd/TODO.md deleted file mode 100644 index 4a72c81..0000000 --- a/nbd/TODO.md +++ /dev/null @@ -1 +0,0 @@ -# Work to be ticketed diff --git a/nbd/build.rs b/nbd/build.rs deleted file mode 100644 index bd494da..0000000 --- a/nbd/build.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! Build script for `nbd`. -//! -//! Captures the short git SHA at compile time and emits it as the -//! `GIT_SHORT_SHA` environment variable, making it available via -//! `env!("GIT_SHORT_SHA")` in the crate source. -//! -//! Falls back to `"unknown"` when git is unavailable (e.g. a clean Nix -//! sandbox build where `.git/` is not present). - -fn main() { - // Capture the short git SHA at build time. - let sha = std::process::Command::new("git") - .args(["rev-parse", "--short", "HEAD"]) - .output() - .ok() - .and_then(|o| { - if o.status.success() { - Some(o.stdout) - } else { - None - } - }) - .and_then(|b| String::from_utf8(b).ok()) - .map(|s| s.trim().to_string()) - .unwrap_or_else(|| "unknown".to_string()); - - println!("cargo:rustc-env=GIT_SHORT_SHA={sha}"); - // Re-run whenever HEAD or any ref changes (new commits, branch switches). - println!("cargo:rerun-if-changed=.git/HEAD"); - println!("cargo:rerun-if-changed=.git/refs"); -} diff --git a/nbd/docs/ARCHITECTURE.md b/nbd/docs/ARCHITECTURE.md deleted file mode 100644 index df52fca..0000000 --- a/nbd/docs/ARCHITECTURE.md +++ /dev/null @@ -1,127 +0,0 @@ -# nbd — Architecture - -## Overview - -`nbd` is a single-binary CLI tool with no background service. All state lives in -`.nbd/tickets/` inside the project directory tree. The binary is structured as -four modules, each with a single responsibility: - -``` -src/ -├── main.rs CLI definition and command dispatch -├── ticket.rs Data model, ID generation, priority validation -├── store.rs File I/O, directory traversal, CRUD -└── display.rs Human-readable and JSON output formatting -``` - -## Module Responsibilities - -### `main.rs` — CLI entry point - -Owns the `clap` struct definitions (`Cli`, `Commands`) and the top-level `main` -function. Delegates all business logic to command handlers (`cmd_create`, -`cmd_read`, `cmd_list`, `cmd_update`). The handlers are thin: they call helpers -from `store`, `ticket`, and `display`, then return. - -Parsing helpers (`parse_status`, `parse_ticket_type`, `parse_deps`) live here -because they translate raw CLI strings into typed values used only by the command -handlers. - -### `ticket.rs` — Data model - -Defines the canonical in-memory representation of a ticket: - -- `Ticket` struct — all fields -- `Status` enum — `Todo | InProgress | Done` -- `TicketType` enum — `Project | Feature | Task | Bug` - -Provides two free functions: - -- `generate_id() -> String` — uses `RandomState` (seeded with OS entropy) to - produce a 6-character lowercase hex string. No external randomness crate needed. -- `validate_priority(u8) -> Result<(), String>` — enforces the 0–10 range. - -The enums derive `serde`'s `Serialize`/`Deserialize` with `#[serde(rename_all)]` -so the JSON representation uses lowercase strings (`"in_progress"`, `"bug"`, …). - -### `store.rs` — File I/O - -All filesystem operations are in this module. Uses `async-std` for async I/O -throughout. - -**Directory discovery:** - -``` -find_nbd_root() - └─ find_nbd_root_from(cwd) - Walk parent dirs until .nbd/ is found -``` - -`find_nbd_root_from` accepts an explicit starting path to make it testable -without changing the process's working directory. - -**CRUD functions:** - -| Function | Operation | -|---|---| -| `ensure_tickets_dir(root)` | Create `.nbd/tickets/` if missing | -| `write_ticket(root, ticket)` | Serialise → pretty JSON → write file | -| `read_ticket(root, id)` | Read file → deserialise; friendly error if not found | -| `list_tickets(root)` | Read all `*.json` → sort by priority desc | - -**Error type:** `store::Result` is aliased to -`Result>`, allowing `?` to propagate both -`io::Error` and `serde_json::Error` without explicit wrapping. - -### `display.rs` — Output formatting - -Two output modes: - -- **Tabular** — human-readable key–value block (single ticket) or column-aligned - table (list). Column widths are compile-time constants. -- **JSON** — delegates to `serde_json::to_string_pretty`. - -Each public surface is split into a `format_*` function (returns `String`, -testable) and a `print_*` function (writes to stdout via `println!`). - -## Data Flow - -``` -CLI args (clap) - │ - ▼ - dispatch() [main.rs] - │ - ├── parse_* helpers ──► typed values - │ - ├── store::find_nbd_root() ──► PathBuf - ├── store::read_ticket() ──► Ticket - ├── store::list_tickets() ──► Vec - ├── store::write_ticket() ──► () - │ - └── display::print_ticket() ──► stdout - display::print_list() ──► stdout -``` - -## Storage Layout - -``` -/ -└── .nbd/ - └── tickets/ - ├── a3f9c2.json - ├── b7d41e.json - └── ... -``` - -Each file is a pretty-printed JSON object with the fields of `Ticket`. File name -equals the ticket's `id` field with a `.json` extension. - -## Testing Strategy - -- **Unit tests** (`src/tests.rs`): exercise `ticket.rs`, `store.rs`, and - `display.rs` in isolation using `tempfile::TempDir` for any file I/O. -- **Integration tests** (`tests/integration.rs`): drive full command flows - (create → read → list → update) through `cmd_*` functions against a temporary - directory. Include a directory-traversal test that invokes commands from a - nested subdirectory. diff --git a/nbd/docs/PLANNING.md b/nbd/docs/PLANNING.md deleted file mode 100644 index 78127f9..0000000 --- a/nbd/docs/PLANNING.md +++ /dev/null @@ -1,79 +0,0 @@ -# nbd — Planning & Work Log - -## Development Phases - -### Phase 1: Crate Scaffold ✅ - -Set up the crate structure with no logic yet. - -- Created `Cargo.toml` with `clap` (derive), `async-std`, `serde`, `serde_json`; dev-dep `tempfile` -- Added `[profile.release]` optimisations (`opt-level = "z"`, `lto`, `strip`, `codegen-units = 1`) -- Created `src/main.rs` — `async-std` entry point with placeholder `main` -- Created empty modules: `ticket.rs`, `store.rs`, `display.rs`, `tests.rs` -- Created `tests/integration.rs` -- Verified `cargo check` passed - -### Phase 2: Data Model (`ticket.rs`) ✅ - -- Defined `Status` enum (`Todo`, `InProgress`, `Done`; default `Todo`) - - Serialises to lowercase snake_case: `"todo"`, `"in_progress"`, `"done"` -- Defined `TicketType` enum (`Project`, `Feature`, `Task`, `Bug`; default `Task`) - - Serialises to lowercase: `"project"`, `"feature"`, `"task"`, `"bug"` -- Defined `Ticket` struct with all required fields -- Implemented `Ticket::new(id, title)` with defaults -- Implemented `validate_priority(priority)` — errors if > 10 -- Implemented `generate_id()` — 3 random bytes → 6 hex chars via `RandomState` -- Unit tests: serialisation roundtrip, priority validation, ID format and uniqueness - -### Phase 3: Storage (`store.rs`) ✅ - -- Implemented `find_nbd_root_from(start)` — walks up from `start` to find `.nbd/` -- Implemented `find_nbd_root()` — starts from `std::env::current_dir()` -- Implemented `tickets_dir(root)` — pure path computation -- Implemented `ensure_tickets_dir(root)` — creates `.nbd/tickets/` if missing -- Implemented `ticket_path(root, id)` — pure path computation -- Implemented `write_ticket(root, ticket)` — serialise and write JSON file -- Implemented `read_ticket(root, id)` — read and deserialise; descriptive error if not found -- Implemented `list_tickets(root)` — read all `*.json`, sort by priority descending -- Unit tests: write/read roundtrip, list returns all tickets, traversal from grandparent dir - -### Phase 4: Display (`display.rs`) ✅ - -- Implemented `format_ticket(ticket)` / `print_ticket(ticket)` — full tabular view -- Implemented `format_ticket_json(ticket)` / `print_ticket_json(ticket)` — pretty JSON -- Implemented `format_list(tickets)` / `print_list(tickets)` — short summary table -- Implemented `format_list_json(tickets)` / `print_list_json(tickets)` — JSON array -- `format_*` functions return `String` for testability; `print_*` write to stdout -- Unit tests: table output contains expected field values, JSON is valid - -### Phase 5: CLI Commands (`main.rs`) ✅ - -- Defined `Cli` struct with global `--json` flag and `Commands` enum -- Subcommands: `create`, `read`, `list`, `update` -- Implemented `cmd_create`, `cmd_read`, `cmd_list`, `cmd_update` -- Added `parse_status`, `parse_ticket_type`, `parse_deps`, `validate_deps` helpers -- Fixed clippy warning: `map_or(false, ...)` → `is_some_and(...)` -- Integration tests: create→read roundtrip, list, update merge, traversal from subdir, - error on unknown ID, `--json` flag, dep replacement - -### Phase 6: Documentation & Validation ✅ - -- Wrote `README.md` — what, how, usage, testing, license, Claude Code disclaimer -- Wrote `docs/PLANNING.md` (this file) — phases and work log -- Wrote `docs/ARCHITECTURE.md` — module overview and interactions -- Ran `cargo fmt`, `cargo check`, `cargo clippy`, `cargo test` — all clean - ---- - -## Post-MVP Roadmap - -See [`PLAN.md`](../PLAN.md) for the full list of planned post-MVP features, including: - -- `nbd init` — explicit initialisation command -- `nbd ready` — list tickets with no blockers -- `nbd archive` — mark tickets as Closed -- `nbd update` git-diff-style output -- Partial ID matching -- SQLite cache -- Nix flake -- Multiple file format support (`.md`, `.toml`, `.jsonb`) diff --git a/nbd/flake.lock b/nbd/flake.lock deleted file mode 100644 index 244e7d5..0000000 --- a/nbd/flake.lock +++ /dev/null @@ -1,48 +0,0 @@ -{ - "nodes": { - "nixpkgs": { - "locked": { - "lastModified": 1771369470, - "narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "0182a361324364ae3f436a63005877674cf45efb", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "root": { - "inputs": { - "nixpkgs": "nixpkgs", - "rust-overlay": "rust-overlay" - } - }, - "rust-overlay": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1771816254, - "narHash": "sha256-vkp3iTF6QmHMvL+34DI93IiMPjS2lqcMlA1fl7nXVsQ=", - "owner": "oxalica", - "repo": "rust-overlay", - "rev": "085bdbf5dde5477538e4c87d1684b6c6df56c0ad", - "type": "github" - }, - "original": { - "owner": "oxalica", - "repo": "rust-overlay", - "type": "github" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/nbd/flake.nix b/nbd/flake.nix deleted file mode 100644 index 1f07581..0000000 --- a/nbd/flake.nix +++ /dev/null @@ -1,98 +0,0 @@ -{ - description = "nbd — CLI tool for managing work tickets, targeted at agent workflows"; - - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - rust-overlay = { - url = "github:oxalica/rust-overlay"; - inputs.nixpkgs.follows = "nixpkgs"; - }; - }; - - outputs = - { - self, - nixpkgs, - rust-overlay, - }: - let - systems = [ - "x86_64-linux" - "aarch64-linux" - "x86_64-darwin" - "aarch64-darwin" - ]; - - forAllSystems = - f: - nixpkgs.lib.genAttrs systems ( - system: - f { - inherit system; - pkgs = import nixpkgs { - inherit system; - overlays = [ rust-overlay.overlays.default ]; - }; - } - ); - in - { - packages = forAllSystems ( - { pkgs, system }: - { - # The nbd CLI package, built with rustPlatform.buildRustPackage. - nbd = pkgs.rustPlatform.buildRustPackage { - pname = "nbd"; - version = "0.1.0"; - - src = ./.; - - cargoLock = { - lockFile = ./Cargo.lock; - }; - - meta = { - description = "CLI tool for managing work tickets, targeted at agent workflows"; - license = with pkgs.lib.licenses; [ - asl20 - mit - ]; - mainProgram = "nbd"; - }; - }; - - # Make nbd the default package so `nix build` works without specifying an attribute. - default = self.packages.${system}.nbd; - } - ); - - apps = forAllSystems ( - { system, ... }: - { - # Run nbd directly with `nix run .#nbd`. - nbd = { - type = "app"; - program = "${self.packages.${system}.nbd}/bin/nbd"; - }; - - # Make nbd the default app so `nix run` works without specifying an attribute. - default = self.apps.${system}.nbd; - } - ); - - devShells = forAllSystems ( - { pkgs, system }: - { - # Development shell with stable Rust toolchain and nbd binary. - default = pkgs.mkShell { - packages = [ - # Stable Rust toolchain: compiler, cargo, rustfmt, clippy. - pkgs.rust-bin.stable.latest.default - # The built nbd binary, available as `nbd` in the shell. - self.packages.${system}.nbd - ]; - }; - } - ); - }; -} diff --git a/nbd/src/claude_md_snippet.md b/nbd/src/claude_md_snippet.md deleted file mode 100644 index 5bfc88b..0000000 --- a/nbd/src/claude_md_snippet.md +++ /dev/null @@ -1,11 +0,0 @@ -## Task Tracking with beans - -This project uses **beans** for issue tracking. Run `beans prime` to get the full usage guide. - -```sh -# Initialise (run once per project directory) -beans init - -# Get full usage guide and instructions for this project -beans prime -``` diff --git a/nbd/src/display.rs b/nbd/src/display.rs deleted file mode 100644 index dce4477..0000000 --- a/nbd/src/display.rs +++ /dev/null @@ -1,473 +0,0 @@ -//! Output formatting for tickets. -//! -//! Provides functions for rendering tickets and ticket lists as human-readable -//! tables or machine-readable JSON, depending on the caller's preference. -//! -//! Each `print_*` function writes directly to stdout and is intended for use -//! in command handlers. The corresponding `format_*` functions return a -//! `String` and are provided primarily for testing and composition. - -use std::collections::HashSet; - -use serde::Serialize; - -use crate::graph::TicketGraph; -use crate::store::MigrateReport; -use crate::ticket::{Status, Ticket, TicketType}; - -// ── Column widths for the summary list table ───────────────────────────────── - -/// Width of the ID column (6-char hex + padding). -const COL_ID: usize = 9; -/// Width of the priority column. -const COL_PRI: usize = 5; -/// Width of the ticket-type column ("project" = 7 chars, +2 padding). -const COL_TYPE: usize = 9; -/// Width of the status column ("in_progress" = 11 chars, +2 padding). -const COL_STATUS: usize = 13; -/// Width of each label in the diff view ("dependencies:" = 13 + 1 space). -const LABEL_WIDTH: usize = 14; - -// ── Internal helpers ────────────────────────────────────────────────────────── - -/// Ticket metadata serialised into the TOML frontmatter block for display. -/// -/// Mirrors the on-disk `MarkdownFrontmatter` in `store.rs` but adds `id` as -/// the first field so that human-readable output is self-contained. -#[derive(Serialize)] -struct DisplayFrontmatter<'a> { - id: &'a str, - title: &'a str, - priority: u8, - status: &'a Status, - ticket_type: &'a TicketType, - dependencies: &'a [String], -} - -/// Return the canonical display string for a [`Status`] variant. -/// -/// The strings match the serde serialisation: `"todo"`, `"in_progress"`, -/// `"done"`, etc. -fn status_str(status: &Status) -> &'static str { - match status { - Status::Todo => "todo", - Status::InProgress => "in_progress", - Status::Done => "done", - Status::Closed => "closed", - Status::Archived => "archived", - Status::Backlog => "backlog", - } -} - -/// Return the canonical display string for a [`TicketType`] variant. -/// -/// The strings match the serde serialisation: `"project"`, `"feature"`, -/// `"task"`, `"bug"`. -fn ticket_type_str(ticket_type: &TicketType) -> &'static str { - match ticket_type { - TicketType::Project => "project", - TicketType::Feature => "feature", - TicketType::Task => "task", - TicketType::Bug => "bug", - } -} - -// ── Public formatting functions ─────────────────────────────────────────────── - -/// Format a single ticket as a TOML-frontmatter markdown document. -/// -/// The output mirrors the `.md` file format used on disk, with `id` added as -/// the first frontmatter key so the output is self-contained. The ticket body -/// follows the closing `+++` delimiter. -/// -/// ```text -/// +++ -/// id = "a3f9c2" -/// title = "Fix login bug" -/// priority = 8 -/// status = "in_progress" -/// ticket_type = "bug" -/// dependencies = ["b7d41e", "c9e823"] -/// +++ -/// Users cannot log in with email addresses containing + -/// ``` -pub fn format_ticket(ticket: &Ticket) -> String { - let fm = DisplayFrontmatter { - id: &ticket.id, - title: &ticket.title, - priority: ticket.priority, - status: &ticket.status, - ticket_type: &ticket.ticket_type, - dependencies: &ticket.dependencies, - }; - let toml_str = toml::to_string(&fm).expect("frontmatter serialisation must not fail"); - format!("+++\n{toml_str}+++\n{}", ticket.body) -} - -/// Print a full tabular representation of a single ticket to stdout. -pub fn print_ticket(ticket: &Ticket) { - println!("{}", format_ticket(ticket)); -} - -/// Serialise a ticket as a [`serde_json::Value`], explicitly inserting the -/// `id` field at the front. -/// -/// [`crate::ticket::Ticket::id`] is annotated with `#[serde(skip)]` so that -/// the stored `.json` files do not duplicate the filename stem. However, -/// CLI `--json` output should include `id` so that consumers have all the -/// information in one object. This helper re-inserts it into the serialised -/// value. -pub(crate) fn ticket_to_json_value(ticket: &Ticket) -> serde_json::Value { - let mut value = serde_json::to_value(ticket).expect("ticket serialisation must not fail"); - // Re-insert id at the front of the object for ergonomic CLI output. - if let serde_json::Value::Object(ref mut map) = value { - // `serde_json::Map` preserves insertion order; insert id first by - // rebuilding the map with id prepended. - let old_map = std::mem::take(map); - map.insert( - "id".to_string(), - serde_json::Value::String(ticket.id.clone()), - ); - map.extend(old_map); - } - value -} - -/// Format a single ticket as a pretty-printed JSON object. -/// -/// The output is suitable for piping or machine consumption. The `id` field -/// is included even though it is not stored in the ticket's JSON file. -pub fn format_ticket_json(ticket: &Ticket) -> String { - let value = ticket_to_json_value(ticket); - serde_json::to_string_pretty(&value).expect("ticket serialisation must not fail") -} - -/// Print a single ticket as pretty-printed JSON to stdout. -pub fn print_ticket_json(ticket: &Ticket) { - println!("{}", format_ticket_json(ticket)); -} - -/// Format a slice of tickets as a short summary table with a header row. -/// -/// Columns are `ID`, `PRI`, `TYPE`, `STATUS`, and `TITLE`, each left-aligned -/// to a fixed width. Rows are presented in the order given — callers are -/// responsible for sorting before passing the slice. An empty slice still -/// produces the header row. -/// -/// ```text -/// ID PRI TYPE STATUS TITLE -/// a3f9c2 8 bug in_progress Fix login bug -/// b7d41e 5 task todo Add rate limiting -/// ``` -pub fn format_list(tickets: &[Ticket]) -> String { - let mut out = format!( - "{: String { - let values: Vec = tickets.iter().map(ticket_to_json_value).collect(); - serde_json::to_string_pretty(&values).expect("ticket list serialisation must not fail") -} - -/// Print a ticket list as a JSON array to stdout. -pub fn print_list_json(tickets: &[Ticket]) { - println!("{}", format_list_json(tickets)); -} - -/// Format a [`MigrateReport`] as a human-readable summary. -/// -/// ```text -/// Migrated 3 tickets. -/// Current 5 tickets (already up to date). -/// Errors 1 ticket could not be migrated: -/// bad_ticket.json: trailing comma at line 4 -/// ``` -pub fn format_migrate_report(report: &MigrateReport) -> String { - let mut out = format!( - "Migrated {} ticket{}.\nCurrent {} ticket{} (already up to date).", - report.updated, - if report.updated == 1 { "" } else { "s" }, - report.already_current, - if report.already_current == 1 { "" } else { "s" }, - ); - if report.skipped > 0 { - out.push_str(&format!( - "\nSkipped {} ticket{} (did not match filter).", - report.skipped, - if report.skipped == 1 { "" } else { "s" }, - )); - } - if !report.errors.is_empty() { - out.push_str(&format!( - "\nErrors {} ticket{} could not be migrated:", - report.errors.len(), - if report.errors.len() == 1 { "" } else { "s" }, - )); - for (filename, msg) in &report.errors { - out.push_str(&format!("\n {filename}: {msg}")); - } - } - out -} - -/// Print a [`MigrateReport`] as a human-readable summary to stdout. -pub fn print_migrate_report(report: &MigrateReport) { - println!("{}", format_migrate_report(report)); -} - -/// Format a [`MigrateReport`] as a JSON object. -/// -/// The JSON has three keys: `updated`, `already_current`, and `errors`. -/// `errors` is an array of objects with `filename` and `message` fields. -pub fn format_migrate_report_json(report: &MigrateReport) -> String { - let errors: Vec = report - .errors - .iter() - .map(|(filename, msg)| { - serde_json::json!({ - "filename": filename, - "message": msg, - }) - }) - .collect(); - let value = serde_json::json!({ - "updated": report.updated, - "already_current": report.already_current, - "skipped": report.skipped, - "errors": errors, - }); - serde_json::to_string_pretty(&value).expect("migrate report serialisation must not fail") -} - -/// Print a [`MigrateReport`] as a JSON object to stdout. -pub fn print_migrate_report_json(report: &MigrateReport) { - println!("{}", format_migrate_report_json(report)); -} - -/// Format a git-diff-style `- old / + new` summary of what changed between -/// two [`Ticket`] snapshots. -/// -/// Each field that differs produces two lines: -/// -/// ```text -/// - status: todo -/// + status: in_progress -/// ``` -/// -/// Fields compared: `title`, `body`, `priority`, `status`, `ticket_type`, and -/// `dependencies`. The `id` field is never shown because it cannot change. -/// -/// Returns `"(no changes)"` when every compared field is identical. -pub fn format_diff(old: &Ticket, new: &Ticket) -> String { - let mut lines: Vec = Vec::new(); - - macro_rules! diff_field { - ($label:expr, $old_val:expr, $new_val:expr) => {{ - let old_s: String = $old_val; - let new_s: String = $new_val; - if old_s != new_s { - lines.push(format!("- {:) -> 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); - } - out -} - -/// Print the full dependency forest to stdout. -pub fn print_graph(graph: &TicketGraph<'_>) { - println!("{}", format_graph(graph)); -} - -/// Format the subtree rooted at `root_id` as an ASCII tree string. -/// -/// Renders `root_id` and every ticket it transitively depends on -/// (via dependency edges), using the same box-drawing format as -/// [`format_graph`]. Returns an empty string when `root_id` is not in the -/// graph. -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); - } - out -} - -/// Print the subtree rooted at `root_id` to stdout. -pub fn print_subtree(graph: &TicketGraph<'_>, root_id: &str) { - println!("{}", format_subtree(graph, root_id)); -} - -// ── Internal graph helpers ──────────────────────────────────────────────────── - -/// Recursively render a single node and its dependencies into `out`. -/// -/// 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 `*` and -/// the recursion stops, preventing infinite loops in cyclic or shared data. -fn render_node( - graph: &TicketGraph<'_>, - id: &str, - prefix: &str, - connector: &str, - child_base: &str, - visited: &mut HashSet, - out: &mut String, -) { - // Already-visited detection: this node appears elsewhere in the tree. - if visited.contains(id) { - append_line(out, &format!("{prefix}{connector}{id} *")); - return; - } - visited.insert(id.to_string()); - - // Extract the node data we need, cloning so we can drop the borrow before - // recursing (the recursive call needs a mutable `visited`). - let (status_s, title, dependencies) = match graph.get_node(id) { - Some(node) => { - let s = status_str(&node.ticket.status); - let t = node.ticket.title.clone(); - let d: Vec = node.dependencies.iter().map(|s| s.to_string()).collect(); - (s, t, d) - } - None => return, - }; - - append_line( - out, - &format!("{prefix}{connector}{id} [{status_s}] {title}"), - ); - - let n = dependencies.len(); - for (i, dep_id) in dependencies.iter().enumerate() { - let is_last = i == n - 1; - // The child's connector on its own line. - let child_connector = if is_last { "└── " } else { "├── " }; - // 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, - ); - } -} - -/// Append `line` to `out`, preceded by a newline if `out` is non-empty. -fn append_line(out: &mut String, line: &str) { - if !out.is_empty() { - out.push('\n'); - } - out.push_str(line); -} diff --git a/nbd/src/filter.rs b/nbd/src/filter.rs deleted file mode 100644 index ac84dd2..0000000 --- a/nbd/src/filter.rs +++ /dev/null @@ -1,267 +0,0 @@ -//! Ticket filtering with glob-pattern support. -//! -//! [`TicketFilter`] holds per-field glob patterns compiled from `key=value` -//! expressions. Patterns within the same field are ORed; patterns across -//! different fields are ANDed. -//! -//! # Example -//! -//! ```rust,ignore -//! let args = vec!["status=todo".to_string(), "type=bug".to_string()]; -//! let filter = parse_filters(&args).unwrap(); -//! assert!(filter.matches(&some_todo_bug_ticket)); -//! ``` - -use crate::store::Result; -use crate::ticket::{Status, Ticket, TicketType}; - -// ── String helpers ───────────────────────────────────────────────────────── - -/// Return the canonical lowercase display string for a [`Status`] variant. -fn status_str(status: &Status) -> &'static str { - match status { - Status::Todo => "todo", - Status::InProgress => "in_progress", - Status::Done => "done", - Status::Closed => "closed", - Status::Archived => "archived", - Status::Backlog => "backlog", - } -} - -/// Return the canonical lowercase display string for a [`TicketType`] variant. -fn ticket_type_str(ticket_type: &TicketType) -> &'static str { - match ticket_type { - TicketType::Project => "project", - TicketType::Feature => "feature", - TicketType::Task => "task", - TicketType::Bug => "bug", - } -} - -// ── Glob matching ───────────────────────────────────────────────────────── - -/// Return `true` when `value` matches the glob `pattern`. -/// -/// `*` is the only supported wildcard and it matches any sequence of -/// characters, including the empty sequence. All other characters are -/// matched literally. -/// -/// The algorithm: -/// 1. If `pattern` contains no `*`, require exact equality. -/// 2. Split `pattern` on `*` into segments. -/// 3. If `pattern` does **not** start with `*`, `value` must start with the -/// first segment; advance past it. -/// 4. If `pattern` does **not** end with `*`, `value` must end with the last -/// segment; trim it. -/// 5. For each remaining middle segment, find it left-to-right in the -/// remaining string, advancing past the match. If any segment is absent, -/// return `false`. -/// -/// Handles: `*`, `foo`, `foo*`, `*foo`, `*foo*`, `foo*bar`, `*foo*bar*`. -pub(crate) fn glob_matches(pattern: &str, value: &str) -> bool { - if !pattern.contains('*') { - return pattern == value; - } - - let segments: Vec<&str> = pattern.split('*').collect(); - let n = segments.len(); - let mut remaining = value; - - // Anchored prefix: when pattern does not start with '*', segments[0] is - // non-empty and value must begin with it. - let start_idx = if !segments[0].is_empty() { - match remaining.strip_prefix(segments[0]) { - Some(rest) => { - remaining = rest; - 1 - } - None => return false, - } - } else { - 1 // skip the empty string before the leading '*' - }; - - // Anchored suffix: when pattern does not end with '*', segments[n-1] is - // non-empty and the remaining string must end with it. - let end_idx = if !segments[n - 1].is_empty() { - match remaining.strip_suffix(segments[n - 1]) { - Some(prefix) => { - remaining = prefix; - n - 1 - } - None => return false, - } - } else { - n - 1 // skip the empty string after the trailing '*' - }; - - // Find each middle segment left-to-right inside the remaining substring. - for seg in &segments[start_idx..end_idx] { - if seg.is_empty() { - continue; // consecutive '*'s impose no additional constraint - } - match remaining.find(seg) { - Some(pos) => remaining = &remaining[pos + seg.len()..], - None => return false, - } - } - - true -} - -// ── TicketFilter ────────────────────────────────────────────────────────── - -/// A compiled set of filter expressions applied to tickets. -/// -/// Within the same key, patterns are ORed (any match passes the group). -/// Across different keys, groups are ANDed (all non-empty groups must pass). -/// -/// An empty [`TicketFilter`] (all `Vec`s empty) matches every ticket. -/// -/// Construct with [`parse_filters`]. -#[derive(Debug, Default, Clone)] -pub struct TicketFilter { - /// Glob patterns matched against the ticket's `status` field (OR group). - /// Matching is case-insensitive. - pub status: Vec, - /// Glob patterns matched against the ticket's `ticket_type` field (OR group). - /// Matching is case-insensitive. - pub ticket_type: Vec, - /// Glob patterns matched against the string representation of `priority` - /// (OR group). Matching is case-sensitive. - pub priority: Vec, - /// Glob patterns matched against the ticket's `title` field (OR group). - /// Matching is case-sensitive. - pub title: Vec, -} - -impl TicketFilter { - /// Returns `true` when all non-empty filter groups match `ticket`. - /// - /// An empty [`TicketFilter`] always returns `true`. - pub fn matches(&self, ticket: &Ticket) -> bool { - let status_val = status_str(&ticket.status); - let type_val = ticket_type_str(&ticket.ticket_type); - let priority_val = ticket.priority.to_string(); - - // status and ticket_type comparisons are case-insensitive: lower-case - // the pattern before matching against the already-lowercase value. - (self.status.is_empty() - || self - .status - .iter() - .any(|p| glob_matches(&p.to_lowercase(), status_val))) - && (self.ticket_type.is_empty() - || self - .ticket_type - .iter() - .any(|p| glob_matches(&p.to_lowercase(), type_val))) - // priority and title comparisons are case-sensitive. - && (self.priority.is_empty() - || self - .priority - .iter() - .any(|p| glob_matches(p, &priority_val))) - && (self.title.is_empty() - || self - .title - .iter() - .any(|p| glob_matches(p, &ticket.title))) - } - - /// Returns `true` when no filter groups are set (no-op filter). - #[allow(dead_code)] - pub fn is_empty(&self) -> bool { - self.status.is_empty() - && self.ticket_type.is_empty() - && self.priority.is_empty() - && self.title.is_empty() - } - - /// Returns `true` when the caller has provided at least one status pattern. - /// - /// Used by `cmd_list` to detect whether to apply the implicit - /// done-exclusion heuristic. - pub fn has_status_filter(&self) -> bool { - !self.status.is_empty() - } - - /// Returns `true` if the ticket's status matches any of the status patterns. - /// - /// Only meaningful when [`has_status_filter`] returns `true`. Matching - /// is case-insensitive. - /// - /// [`has_status_filter`]: TicketFilter::has_status_filter - pub fn matches_status(&self, ticket: &Ticket) -> bool { - let status_val = status_str(&ticket.status); - self.status - .iter() - .any(|p| glob_matches(&p.to_lowercase(), status_val)) - } - - /// Returns `true` if the ticket matches all non-status filter groups - /// (type, priority, title). The status group is excluded so callers can - /// handle it separately. - /// - /// An empty filter always returns `true`. - pub fn matches_except_status(&self, ticket: &Ticket) -> bool { - let type_val = ticket_type_str(&ticket.ticket_type); - let priority_val = ticket.priority.to_string(); - - (self.ticket_type.is_empty() - || self - .ticket_type - .iter() - .any(|p| glob_matches(&p.to_lowercase(), type_val))) - && (self.priority.is_empty() - || self.priority.iter().any(|p| glob_matches(p, &priority_val))) - && (self.title.is_empty() || self.title.iter().any(|p| glob_matches(p, &ticket.title))) - } -} - -// ── Parsing ─────────────────────────────────────────────────────────────── - -/// Parse a slice of `"key=value"` strings into a [`TicketFilter`]. -/// -/// The key `type` maps to the `ticket_type` field. Everything after the -/// **first** `=` is treated as the value, so `key=value=more` is valid and -/// produces the value `"value=more"`. -/// -/// # Errors -/// -/// Returns an error for: -/// - A string that contains no `=` character. -/// - A key that is not one of `status`, `type`, `priority`, or `title`. -/// -/// # Examples -/// -/// ```rust,ignore -/// let args = vec!["status=todo".to_string(), "status=in_progress".to_string()]; -/// let filter = parse_filters(&args).unwrap(); -/// assert_eq!(filter.status.len(), 2); -/// ``` -pub fn parse_filters(args: &[String]) -> Result { - let mut filter = TicketFilter::default(); - - for arg in args { - let (key, value) = arg - .split_once('=') - .ok_or_else(|| format!("invalid filter '{arg}': expected 'key=value' format"))?; - - match key { - "status" => filter.status.push(value.to_string()), - "type" => filter.ticket_type.push(value.to_string()), - "priority" => filter.priority.push(value.to_string()), - "title" => filter.title.push(value.to_string()), - other => { - return Err(format!( - "unknown filter key '{other}'; expected one of: status, type, priority, title" - ) - .into()) - } - } - } - - Ok(filter) -} diff --git a/nbd/src/graph.rs b/nbd/src/graph.rs deleted file mode 100644 index 8f32488..0000000 --- a/nbd/src/graph.rs +++ /dev/null @@ -1,287 +0,0 @@ -//! Ticket dependency graph computation. -//! -//! Builds a directed graph from a flat list of tickets, tracking both forward -//! (dependency) and reverse (dependent) edges. Used by [`crate::display`] to -//! render an ASCII tree and by the `graph` CLI command for JSON output. -//! -//! ## Edge semantics -//! -//! If ticket B lists ticket A in its `dependencies`, then: -//! - A is a **dependency** of B (A must be done before B) -//! - B is a **dependent** of A (B is waiting on A) -//! -//! The ASCII tree is rendered with **roots** (tickets that no other ticket -//! depends on — the top-level goals) at the top and their **dependencies** -//! indented below — showing "what does this ticket need to be done first?". -//! -//! JSON edges use `{"from": , "to": }` — i.e. the -//! ticket that is waiting is `from` and the prerequisite ticket is `to`. - -use std::collections::{HashMap, HashSet}; - -use crate::ticket::{Status, Ticket}; - -// ── Internal helper ─────────────────────────────────────────────────────────── - -/// Return the canonical display string for a [`Status`] variant. -fn status_str(status: &Status) -> &'static str { - match status { - Status::Todo => "todo", - Status::InProgress => "in_progress", - Status::Done => "done", - Status::Closed => "closed", - Status::Archived => "archived", - Status::Backlog => "backlog", - } -} - -// ── Public types ────────────────────────────────────────────────────────────── - -/// A single node in the dependency graph. -/// -/// Holds a reference to the source [`Ticket`] and the IDs of both its -/// in-graph dependencies (forward edges) and its dependents (reverse edges). -pub struct GraphNode<'a> { - /// The ticket this node represents. - pub ticket: &'a Ticket, - /// IDs of in-graph tickets that this ticket depends on (forward edges). - /// - /// Only tickets present in the graph are listed; dangling references in - /// [`Ticket::dependencies`] are silently ignored. - /// - /// These are the visual "children" in the ASCII dependency tree — the - /// prerequisites that must be completed before this ticket. - pub dependencies: Vec<&'a str>, - /// IDs of tickets that list this ticket as a dependency (reverse edges). - pub dependents: Vec<&'a str>, -} - -/// A directed dependency graph built from a flat list of tickets. -/// -/// Build with [`TicketGraph::build`]. Roots (top-level goals) are returned by -/// [`TicketGraph::roots`]. Use [`TicketGraph::subtree`] to extract the IDs -/// reachable from a specific ticket via its dependencies, and -/// [`TicketGraph::to_json_value`] for machine-readable output. -pub struct TicketGraph<'a> { - /// Nodes keyed by ticket ID. - nodes: HashMap<&'a str, GraphNode<'a>>, - /// Ticket IDs in the order they were inserted, for stable iteration. - ids: Vec<&'a str>, -} - -impl<'a> TicketGraph<'a> { - /// Build a graph from a slice of tickets. - /// - /// Each ticket in `tickets` becomes a node. Forward edges (`dependencies`) - /// and reverse edges (`dependents`) are both populated. References to IDs - /// not present in `tickets` are silently ignored. - /// - /// # Example - /// - /// ```rust,ignore - /// let graph = TicketGraph::build(&tickets); - /// for root in graph.roots() { - /// println!("{} – {}", root.id, root.title); - /// } - /// ``` - pub fn build(tickets: &'a [Ticket]) -> Self { - let mut nodes: HashMap<&'a str, GraphNode<'a>> = HashMap::with_capacity(tickets.len()); - let mut ids: Vec<&'a str> = Vec::with_capacity(tickets.len()); - - // First pass: create a node for every ticket. - for ticket in tickets { - let id: &'a str = ticket.id.as_str(); - nodes.insert( - id, - GraphNode { - ticket, - dependencies: Vec::new(), - dependents: Vec::new(), - }, - ); - ids.push(id); - } - - // Second pass: collect edges (avoids simultaneous mutable borrows). - // Each edge is (dependent_id, dependency_id). - let mut edges: Vec<(&'a str, &'a str)> = Vec::new(); - for ticket in tickets { - let ticket_id: &'a str = ticket.id.as_str(); - for dep_id_owned in &ticket.dependencies { - let dep_id: &str = dep_id_owned.as_str(); - // Only add an edge if the dependency exists in this graph. - if let Some((&stored_dep_id, _)) = nodes.get_key_value(dep_id) { - edges.push((ticket_id, stored_dep_id)); - } - } - } - - // Apply collected edges to both sides of each node. - for (dependent_id, dependency_id) in edges { - if let Some(node) = nodes.get_mut(dependent_id) { - node.dependencies.push(dependency_id); - } - if let Some(node) = nodes.get_mut(dependency_id) { - node.dependents.push(dependent_id); - } - } - - TicketGraph { nodes, ids } - } - - /// Return tickets that no other ticket depends on, sorted by priority descending. - /// - /// These are the top-level goals for the ASCII dependency tree renderer. - /// A ticket is a root when its `dependents` list is empty — nothing in the - /// graph is waiting on it, so it represents an end goal rather than a - /// prerequisite. - pub fn roots(&self) -> Vec<&'a Ticket> { - let mut roots: Vec<&'a Ticket> = self - .ids - .iter() - .filter_map(|id| self.nodes.get(id)) - .filter(|node| node.dependents.is_empty()) - .map(|node| node.ticket) - .collect(); - roots.sort_by(|a, b| b.priority.cmp(&a.priority)); - roots - } - - /// Return the node for a ticket ID, or `None` if not present in the graph. - pub fn get_node(&self, id: &str) -> Option<&GraphNode<'a>> { - self.nodes.get(id) - } - - /// Return all ticket IDs reachable from `root_id` via dependency edges, - /// in depth-first order, including `root_id` itself. - /// - /// "Reachable via dependencies" means: `root_id`, plus every ticket that - /// `root_id` depends on, plus every ticket those depend on, and so on. - /// This answers "what tickets does `root_id` need (directly or - /// transitively)?". - /// - /// Cycles are handled by a visited set — each ID appears at most once. - /// Returns an empty `Vec` when `root_id` is not in the graph. - pub fn subtree(&self, root_id: &str) -> Vec<&'a str> { - let mut result: Vec<&'a str> = Vec::new(); - let mut visited: HashSet<&'a str> = HashSet::new(); - if let Some((&stored_id, _)) = self.nodes.get_key_value(root_id) { - dfs_subtree(&self.nodes, stored_id, &mut visited, &mut result); - } - result - } - - /// Serialise the full graph as a JSON object with `nodes` and `edges`. - /// - /// Each node includes `id`, `title`, `status`, `priority`, and - /// `dependencies` (only in-graph dependency IDs). - /// - /// Each edge is `{"from": , "to": }` — meaning - /// the ticket identified by `from` depends on (must wait for) `to`. - pub fn to_json_value(&self) -> serde_json::Value { - let nodes: Vec = self - .ids - .iter() - .filter_map(|id| self.nodes.get(id)) - .map(|node| { - serde_json::json!({ - "id": node.ticket.id, - "title": node.ticket.title, - "status": status_str(&node.ticket.status), - "priority": node.ticket.priority, - "dependencies": node.dependencies, - }) - }) - .collect(); - - // Edges point from the dependent (waiting) ticket to the dependency (prerequisite). - let edges: Vec = self - .ids - .iter() - .filter_map(|id| self.nodes.get(id)) - .flat_map(|node| { - let from = node.ticket.id.as_str(); - node.dependencies - .iter() - .map(move |&to| serde_json::json!({ "from": from, "to": to })) - }) - .collect(); - - serde_json::json!({ "nodes": nodes, "edges": edges }) - } - - /// Serialise the subtree rooted at `root_id` as a JSON object. - /// - /// Same structure as [`to_json_value`] but limited to nodes and edges - /// within the subtree reachable from `root_id` via dependency edges. - /// Returns an empty `{"nodes":[],"edges":[]}` object when `root_id` is - /// not in the graph. - pub fn to_subtree_json_value(&self, root_id: &str) -> serde_json::Value { - let reachable: HashSet<&'a str> = self.subtree(root_id).into_iter().collect(); - - let nodes: Vec = self - .ids - .iter() - .filter(|&&id| reachable.contains(id)) - .filter_map(|id| self.nodes.get(id)) - .map(|node| { - let deps: Vec<&str> = node - .dependencies - .iter() - .copied() - .filter(|&d| reachable.contains(d)) - .collect(); - serde_json::json!({ - "id": node.ticket.id, - "title": node.ticket.title, - "status": status_str(&node.ticket.status), - "priority": node.ticket.priority, - "dependencies": deps, - }) - }) - .collect(); - - let edges: Vec = self - .ids - .iter() - .filter(|&&id| reachable.contains(id)) - .filter_map(|id| self.nodes.get(id)) - .flat_map(|node| { - let from = node.ticket.id.as_str(); - node.dependencies - .iter() - .copied() - .filter(|&to| reachable.contains(to)) - .map(move |to| serde_json::json!({ "from": from, "to": to })) - }) - .collect(); - - serde_json::json!({ "nodes": nodes, "edges": edges }) - } -} - -// ── Private helpers ─────────────────────────────────────────────────────────── - -/// Depth-first traversal following `dependencies` edges. -/// -/// Visits `id` and recursively visits each of its dependencies (tickets that -/// `id` depends on). A `visited` set prevents revisiting nodes, making the -/// function safe even when the data contains dependency cycles. -fn dfs_subtree<'a>( - nodes: &HashMap<&'a str, GraphNode<'a>>, - id: &'a str, - visited: &mut HashSet<&'a str>, - result: &mut Vec<&'a str>, -) { - if !visited.insert(id) { - return; - } - result.push(id); - if let Some(node) = nodes.get(id) { - // Clone the list to avoid holding an immutable borrow while we recurse. - let dependencies: Vec<&'a str> = node.dependencies.clone(); - for dep_id in dependencies { - dfs_subtree(nodes, dep_id, visited, result); - } - } -} diff --git a/nbd/src/main.rs b/nbd/src/main.rs deleted file mode 100644 index 331bf7f..0000000 --- a/nbd/src/main.rs +++ /dev/null @@ -1,938 +0,0 @@ -//! `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 filter; -mod graph; -mod store; -mod ticket; - -/// The CLAUDE.md snippet embedded at compile time from `src/claude_md_snippet.md`. -/// -/// Projects adopting `nbd` can append this to their own CLAUDE.md: -/// -/// ```sh -/// nbd claude-md >> CLAUDE.md -/// ``` -const CLAUDE_MD_SNIPPET: &str = include_str!("claude_md_snippet.md"); - -/// Full version string embedded at compile time: `"X.Y.Z+shortsha"`. -/// -/// The semver comes from `Cargo.toml` via `CARGO_PKG_VERSION`; the short SHA -/// is injected by `build.rs` via `GIT_SHORT_SHA`. -const VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "+", env!("GIT_SHORT_SHA")); - -#[cfg(test)] -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, - list_tickets_cached, migrate_tickets, read_ticket, resolve_id, write_ticket, FileFormat, -}; -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", version = VERSION)] -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, - - /// File format to use for storage: `json`, `md`, `toml`, or `jsonb` (default: `json`). - #[arg(long = "ftype", default_value = "json")] - ftype: String, - }, - - /// Print a single ticket by ID. - Read { - /// The 6-character hex ticket ID to look up. - id: String, - }, - - /// List tickets sorted by priority (highest first). - /// - /// By default, tickets with status `done`, `closed`, `archived`, or - /// `backlog` are excluded. Pass `--all` to include all tickets, or - /// `--filter status=*` to override only the status exclusion. - List { - /// Filter tickets by field: repeatable `key=value` pairs. - /// - /// Keys: `status`, `type`, `priority`, `title`. - /// Different keys are ANDed; the same key with multiple values is ORed. - /// Values support glob wildcards: `title=*login*`. - /// - /// If no `status` filter is provided, tickets with status `done`, - /// `closed`, `archived`, or `backlog` are excluded automatically. - /// Provide `--filter status=*` to override, or use `--all`. - #[arg(long = "filter", value_name = "KEY=VALUE")] - filter: Vec, - - /// Show all tickets regardless of status, including `done`, `closed`, - /// `archived`, and `backlog`. - /// - /// Equivalent to `--filter status=*` but takes precedence over any - /// `--filter` arguments when both are supplied. - #[arg(long)] - all: bool, - }, - - /// 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. - /// - /// With an optional ``, restricts results to the dependency subtree of - /// that ticket — only ready tickets that `` depends on (directly or - /// transitively) are returned. The scoping ticket itself is excluded. - Ready { - /// Optional ticket ID or unique prefix to scope results to its dependency subtree. - /// - /// When provided, only ready tickets within the subtree that `` - /// depends on (directly or transitively) are returned. The ticket - /// identified by `` itself is never included in the results. - id: Option, - - /// Filter ready tickets by field: repeatable `key=value` pairs. - /// - /// Applied after the ready check — narrows within already-ready tickets. - /// Keys: `status`, `type`, `priority`, `title`. Values support globs. - #[arg(long = "filter", value_name = "KEY=VALUE")] - filter: Vec, - }, - - /// Choose the highest-priority ticket that is ready to work on. - /// - /// A ticket is ready when its status is not `done` and every ticket it - /// depends on has status `done`. Returns the single highest-priority - /// ready ticket, optionally narrowed by `--filter KEY=VALUE`. - /// - /// With an optional ``, restricts results to the dependency subtree of - /// that ticket — only the highest-priority ready ticket that `` depends - /// on (directly or transitively) is returned. The scoping ticket itself is - /// excluded. - /// - /// Exits 0 even when no ready ticket exists. - Next { - /// Optional ticket ID or unique prefix to scope results to its dependency subtree. - /// - /// When provided, only the highest-priority ready ticket within the - /// subtree that `` depends on (directly or transitively) is - /// returned. The ticket identified by `` itself is never returned. - id: Option, - - /// Filter ready tickets: key=value pairs (repeatable). - /// AND between different keys, OR within same key. - #[arg(long = "filter", value_name = "KEY=VALUE")] - filter: Vec, - }, - - /// 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, - - /// Only migrate tickets matching these filters: repeatable `key=value` pairs. - /// - /// Non-matching tickets are counted as skipped in the report. - /// Keys: `status`, `type`, `priority`, `title`. Values support globs. - #[arg(long = "filter", value_name = "KEY=VALUE")] - filter: Vec, - }, - - /// Archive a ticket by setting its status to `archived`. - /// - /// The ticket is preserved on disk but excluded from normal `nbd list` - /// output. Archived tickets count as resolved for dependency purposes. - /// Use `nbd list --all` or `--filter status=archived` to view them. - /// - /// This is syntactic sugar for `nbd update --status archived`. - Archive { - /// The 6-character hex ticket ID to archive. - id: String, - }, - - /// Print a CLAUDE.md snippet for adopting nbd in a project. - /// - /// The snippet is baked into the binary at compile time and covers - /// the nbd workflow, core commands, and usage guidelines. - /// - /// Append the snippet to your project's CLAUDE.md: - /// - /// ```sh - /// nbd claude-md >> CLAUDE.md - /// ``` - /// - /// With `--json`, outputs `{"snippet": "..."}` for programmatic use. - /// 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 - /// 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, - - /// Convert to a different file format: `json`, `md`, `toml`, or `jsonb`. - /// - /// When specified, the ticket is re-serialised in the new format and - /// the old file is removed if the extension differs. - #[arg(long = "ftype")] - ftype: Option, - }, -} - -// ── Entry point ─────────────────────────────────────────────────────────────── - -#[tokio::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, - ftype, - } => { - cmd_create( - title, - body, - priority, - status, - ticket_type, - deps, - ftype, - cli.json, - ) - .await - } - - Commands::Init => cmd_init(cli.json).await, - - Commands::Next { id, filter } => cmd_next(id, filter, cli.json).await, - - Commands::Ready { id, filter } => cmd_ready(id, filter, cli.json).await, - - Commands::Migrate { dry_run, filter } => cmd_migrate(filter, dry_run, cli.json).await, - - Commands::Archive { id } => cmd_archive(id, cli.json).await, - - 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, - - Commands::Update { - id, - title, - body, - priority, - status, - ticket_type, - deps, - ftype, - } => { - cmd_update( - id, - title, - body, - priority, - status, - ticket_type, - deps, - ftype, - cli.json, - ) - .await - } - } -} - -// ── Parsing helpers ─────────────────────────────────────────────────────────── - -/// Parse a [`Status`] from its lowercase string representation. -/// -/// Accepts `"todo"`, `"in_progress"`, `"done"`, `"closed"`, `"archived"`, -/// and `"backlog"`. -/// -/// # 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), - "closed" => Ok(Status::Closed), - "archived" => Ok(Status::Archived), - "backlog" => Ok(Status::Backlog), - other => Err(format!( - "unknown status '{other}'; expected 'todo', 'in_progress', 'done', 'closed', 'archived', or 'backlog'" - ) - .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()), - } -} - -/// Parse a [`FileFormat`] from its string representation. -/// -/// Accepts `"json"`, `"md"`, `"toml"`, and `"jsonb"`. -/// -/// # Errors -/// -/// Returns an error if `s` does not match a known format. -fn parse_file_format(s: &str) -> store::Result { - FileFormat::from_str(s).ok_or_else(|| { - format!("unknown file format '{s}'; expected 'json', 'md', 'toml', or 'jsonb'").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. -/// -/// When `scope_id` is `Some`, results are restricted to the dependency subtree -/// of the identified ticket — only ready tickets that the scoping ticket depends -/// on (directly or transitively) are returned. The scoping ticket itself is -/// excluded from the results. -/// -/// `filter_args` are applied after the ready check, narrowing the results. -async fn cmd_ready( - scope_id: Option, - filter_args: Vec, - json: bool, -) -> store::Result<()> { - let filter = crate::filter::parse_filters(&filter_args)?; - let root = find_nbd_root()?; - let all = list_tickets_cached(&root).await?; - - // Build the set of IDs that are resolved (done, closed, or archived). - // Both closed and archived tickets count as resolved for dependency purposes. - let done_ids: std::collections::HashSet<&str> = all - .iter() - .filter(|t| { - t.status == crate::ticket::Status::Done - || t.status == crate::ticket::Status::Closed - || t.status == crate::ticket::Status::Archived - }) - .map(|t| t.id.as_str()) - .collect(); - - // If a scope ID was provided, resolve it and build the dependency subtree. - // The candidate pool is restricted to tickets within that subtree (excluding - // the scoping ticket itself). - let scope_subtree: Option> = match scope_id { - Some(raw) => { - let resolved = resolve_id(&root, &raw).await?; - let graph = TicketGraph::build(&all); - // subtree() includes the root itself; exclude it from candidates. - let ids: std::collections::HashSet = graph - .subtree(&resolved) - .into_iter() - .filter(|&id| id != resolved.as_str()) - .map(|id| id.to_string()) - .collect(); - Some(ids) - } - None => None, - }; - - let ready: Vec<&crate::ticket::Ticket> = all - .iter() - .filter(|t| { - // If a subtree scope was set, only include tickets in that scope. - if let Some(ref subtree) = scope_subtree { - if !subtree.contains(&t.id) { - return false; - } - } - t.status != crate::ticket::Status::Done - && t.status != crate::ticket::Status::Closed - && t.status != crate::ticket::Status::Archived - && t.status != crate::ticket::Status::Backlog - && t.dependencies - .iter() - .all(|dep| done_ids.contains(dep.as_str())) - && filter.matches(t) - }) - .collect(); - - if json { - display::print_list_json(&ready.into_iter().cloned().collect::>()); - } else { - display::print_list(&ready.into_iter().cloned().collect::>()); - } - - Ok(()) -} - -/// Select the single highest-priority ticket that is ready to work on. -/// -/// Uses the same readiness definition as [`cmd_ready`]: status not `done` and -/// every dependency has status `done`. Missing dependency IDs make a ticket -/// **not** ready. -/// -/// When `scope_id` is `Some`, results are restricted to the dependency subtree -/// of the identified ticket — only the highest-priority ready ticket that the -/// scoping ticket depends on (directly or transitively) is returned. The scoping -/// ticket itself is excluded from the results. -/// -/// With `--json`, outputs `{"next": {...ticket...}}` when a ticket is found or -/// `{"next": null}` when none are ready, so callers always receive an object -/// with a `"next"` key. -async fn cmd_next( - scope_id: Option, - filter_args: Vec, - json: bool, -) -> store::Result<()> { - let filter = crate::filter::parse_filters(&filter_args)?; - let root = find_nbd_root()?; - let all = list_tickets_cached(&root).await?; // sorted by priority desc - - let done_ids: std::collections::HashSet<&str> = all - .iter() - .filter(|t| { - t.status == crate::ticket::Status::Done - || t.status == crate::ticket::Status::Closed - || t.status == crate::ticket::Status::Archived - }) - .map(|t| t.id.as_str()) - .collect(); - - // If a scope ID was provided, resolve it and build the dependency subtree. - // The candidate pool is restricted to tickets within that subtree (excluding - // the scoping ticket itself). - let scope_subtree: Option> = match scope_id { - Some(raw) => { - let resolved = resolve_id(&root, &raw).await?; - let graph = TicketGraph::build(&all); - // subtree() includes the root itself; exclude it from candidates. - let ids: std::collections::HashSet = graph - .subtree(&resolved) - .into_iter() - .filter(|&id| id != resolved.as_str()) - .map(|id| id.to_string()) - .collect(); - Some(ids) - } - None => None, - }; - - let next = all.iter().find(|t| { - // If a subtree scope was set, only include tickets in that scope. - if let Some(ref subtree) = scope_subtree { - if !subtree.contains(&t.id) { - return false; - } - } - t.status != crate::ticket::Status::Done - && t.status != crate::ticket::Status::Closed - && t.status != crate::ticket::Status::Archived - && t.status != crate::ticket::Status::Backlog - && t.dependencies - .iter() - .all(|dep| done_ids.contains(dep.as_str())) - && filter.matches(t) - }); - - if json { - match next { - Some(ticket) => { - let value = serde_json::json!({ - "next": display::ticket_to_json_value(ticket) - }); - println!("{}", serde_json::to_string_pretty(&value)?); - } - None => println!("{}", serde_json::json!({"next": null})), - } - } else { - match next { - Some(ticket) => display::print_ticket(ticket), - None => println!("No ready tickets."), - } - } - - 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. -/// -/// `filter_args` restrict which tickets are candidates; non-matching tickets -/// are counted as skipped in the report. -async fn cmd_migrate(filter_args: Vec, dry_run: bool, json: bool) -> store::Result<()> { - let filter = crate::filter::parse_filters(&filter_args)?; - let root = find_nbd_root()?; - let report = migrate_tickets(&root, dry_run, &filter).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}.{ext}` using the chosen format. -#[allow(clippy::too_many_arguments)] -async fn cmd_create( - title: String, - body: String, - priority: u8, - status: String, - ticket_type: String, - deps: Option, - ftype: String, - json: bool, -) -> store::Result<()> { - validate_priority(priority) - .map_err(|e| -> Box { e.into() })?; - - let format = parse_file_format(&ftype)?; - 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, format).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 tickets sorted by priority and print them. -/// -/// `filter_args` are optional `key=value` expressions that narrow the output. -/// `all` bypasses the default done/closed/archived/backlog exclusion. -/// -/// **Default behaviour:** tickets with status [`Status::Done`], -/// [`Status::Closed`], [`Status::Archived`], or [`Status::Backlog`] are -/// excluded unless the caller provides at least one `status=…` filter argument -/// or passes `--all`. Pass `--filter status=*` or `--all` to see every ticket. -async fn cmd_list(filter_args: Vec, all: bool, json: bool) -> store::Result<()> { - let filter = crate::filter::parse_filters(&filter_args)?; - let root = find_nbd_root()?; - let tickets: Vec = list_tickets_cached(&root) - .await? - .into_iter() - .filter(|t| { - if all { - // --all: skip status exclusions; apply every other filter. - filter.matches(t) - } else { - // If no status filter was provided, exclude done, closed, archived, and backlog tickets by default. - let status_ok = if filter.has_status_filter() { - filter.matches_status(t) - } else { - t.status != Status::Done - && t.status != Status::Closed - && t.status != Status::Archived - && t.status != Status::Backlog - }; - status_ok && filter.matches_except_status(t) - } - }) - .collect(); - - if json { - display::print_list_json(&tickets); - } else { - display::print_list(&tickets); - } - - Ok(()) -} - -/// Archive a ticket by setting its status to [`Status::Archived`] and printing it. -/// -/// The ticket is preserved on disk but excluded from normal `nbd list` output. -/// Archived tickets count as resolved for dependency purposes, unblocking any -/// dependents. The file is re-written in its existing format. This is syntactic -/// sugar for `nbd update --status archived`. -async fn cmd_archive(id: String, json: bool) -> store::Result<()> { - let root = find_nbd_root()?; - let id = resolve_id(&root, &id).await?; - let existing_path = find_ticket_path(&root, &id).await?; - let format = detect_format(&existing_path); - let mut ticket = read_ticket(&root, &id).await?; - ticket.status = Status::Archived; - write_ticket(&root, &ticket, format).await?; - - if json { - display::print_ticket_json(&ticket); - } else { - display::print_ticket(&ticket); - } - - Ok(()) -} - -/// Print the embedded CLAUDE.md snippet to stdout. -/// -/// With `json = true`, wraps the snippet in `{"snippet": "..."}` for -/// programmatic consumption. Without `--json`, emits the raw markdown so the -/// caller can redirect it directly into a CLAUDE.md file. -/// -/// This command never calls [`find_nbd_root`] — it works even when no `.nbd/` -/// store exists. -fn cmd_claude_md(json: bool) -> store::Result<()> { - if json { - let value = serde_json::json!({ "snippet": CLAUDE_MD_SNIPPET }); - println!("{}", serde_json::to_string_pretty(&value)?); - } else { - print!("{CLAUDE_MD_SNIPPET}"); - } - 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 -/// fields keep their current values. `id` may be a full 6-character ID or a -/// unique prefix. -/// -/// When `ftype` is supplied and differs from the existing format, the ticket -/// is written in the new format and the old file is removed. -#[allow(clippy::too_many_arguments)] -async fn cmd_update( - id: String, - title: Option, - body: Option, - priority: Option, - status: Option, - ticket_type: Option, - deps: Option, - ftype: Option, - json: bool, -) -> store::Result<()> { - let root = find_nbd_root()?; - let id = resolve_id(&root, &id).await?; - - // Detect the existing file's format so we can preserve or replace it. - let existing_path = find_ticket_path(&root, &id).await?; - let old_format = detect_format(&existing_path); - - let mut ticket = read_ticket(&root, &id).await?; - let old = ticket.clone(); - - 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; - } - - let new_format = match ftype { - Some(ref s) => parse_file_format(s)?, - None => old_format, - }; - - write_ticket(&root, &ticket, new_format).await?; - - // Remove the old file when the format changed (different extension = different path). - if new_format != old_format { - tokio::fs::remove_file(&existing_path).await?; - } - - if json { - display::print_ticket_json(&ticket); - } else { - display::print_diff(&old, &ticket); - } - - Ok(()) -} diff --git a/nbd/src/store.rs b/nbd/src/store.rs deleted file mode 100644 index 175f6cd..0000000 --- a/nbd/src/store.rs +++ /dev/null @@ -1,837 +0,0 @@ -//! File I/O and directory traversal for ticket storage. -//! -//! Tickets are stored in `.nbd/tickets/` relative to the project root. -//! Each ticket is a single file named `{id}.{ext}`, where the extension -//! depends on the [`FileFormat`]: -//! -//! | Format | Extension | Description | -//! |---|---|---| -//! | [`FileFormat::Json`] | `.json` | Pretty-printed JSON (default) | -//! | [`FileFormat::Markdown`] | `.md` | Markdown body with TOML frontmatter | -//! | [`FileFormat::Toml`] | `.toml` | TOML | -//! | [`FileFormat::Jsonb`] | `.jsonb` | CBOR binary | -//! -//! The root is discovered by walking up from the current working directory -//! until a `.nbd/` directory is found, mirroring how `git` locates `.git/`. - -use std::path::{Path, PathBuf}; - -use serde::{Deserialize, Serialize}; -use tokio::fs; - -use crate::filter::TicketFilter; -use crate::ticket::{Status, Ticket, TicketType}; - -/// Convenience alias for fallible operations in this module. -/// -/// The error type is a heap-allocated trait object so that both `io::Error` -/// and `serde_json::Error` (and any other `std::error::Error` implementor) -/// can be returned with `?` without additional wrapping. -pub type Result = std::result::Result>; - -/// Convert a string message into a boxed error, with an unambiguous type. -fn msg_err(s: String) -> Box { - s.into() -} - -// ── FileFormat ──────────────────────────────────────────────────────────────── - -/// The on-disk serialisation format for a ticket file. -/// -/// The format is determined by the file extension when reading, and must be -/// supplied explicitly when writing a new file. `Json` is the default. -/// -/// # Examples -/// -/// ``` -/// use nbd::store::FileFormat; -/// assert_eq!(FileFormat::Json.extension(), "json"); -/// assert_eq!(FileFormat::Markdown.extension(), "md"); -/// ``` -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum FileFormat { - /// Pretty-printed JSON (`.json`). The original and default format. - #[default] - Json, - /// Markdown file with TOML frontmatter (`.md`). - /// - /// The ticket body is stored as the file content after the closing `+++` - /// delimiter; all other fields live in the TOML frontmatter block. - Markdown, - /// TOML (`.toml`). All ticket fields except `id` are stored as TOML. - Toml, - /// CBOR binary encoding (`.jsonb`). Compact binary alternative to JSON. - Jsonb, -} - -impl FileFormat { - /// The file extension associated with this format (without a leading dot). - pub fn extension(self) -> &'static str { - match self { - FileFormat::Json => "json", - FileFormat::Markdown => "md", - FileFormat::Toml => "toml", - FileFormat::Jsonb => "jsonb", - } - } - - /// Parse a [`FileFormat`] from a user-supplied string. - /// - /// Accepts `"json"`, `"md"`, `"toml"`, and `"jsonb"`. - /// Returns `None` for unrecognised values. - pub fn from_str(s: &str) -> Option { - match s { - "json" => Some(FileFormat::Json), - "md" => Some(FileFormat::Markdown), - "toml" => Some(FileFormat::Toml), - "jsonb" => Some(FileFormat::Jsonb), - _ => None, - } - } -} - -impl std::fmt::Display for FileFormat { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.extension()) - } -} - -/// File extensions recognised as ticket files, tried in this order on reads. -const KNOWN_EXTENSIONS: &[&str] = &["json", "md", "toml", "jsonb"]; - -/// Detect the [`FileFormat`] from a file path's extension. -/// -/// Falls back to [`FileFormat::Json`] for absent or unrecognised extensions. -pub fn detect_format(path: &Path) -> FileFormat { - match path.extension().and_then(|e| e.to_str()) { - Some("json") => FileFormat::Json, - Some("md") => FileFormat::Markdown, - Some("toml") => FileFormat::Toml, - Some("jsonb") => FileFormat::Jsonb, - _ => FileFormat::Json, - } -} - -// ── Markdown frontmatter helper ─────────────────────────────────────────────── - -/// Ticket metadata stored in TOML frontmatter for `.md` files. -/// -/// The `body` field is intentionally absent here: in the markdown format the -/// body is the file content that follows the closing `+++` delimiter. -#[derive(Serialize, Deserialize)] -struct MarkdownFrontmatter { - title: String, - priority: u8, - status: Status, - ticket_type: TicketType, - dependencies: Vec, -} - -// ── Serialisation helpers ───────────────────────────────────────────────────── - -/// Serialise `ticket` to a pretty-printed JSON string. -fn serialize_json(ticket: &Ticket) -> Result { - Ok(serde_json::to_string_pretty(ticket)?) -} - -/// Serialise `ticket` to a TOML string. -fn serialize_toml(ticket: &Ticket) -> Result { - Ok(toml::to_string(ticket)?) -} - -/// Serialise `ticket` to a markdown document with TOML frontmatter. -/// -/// Format: -/// ```text -/// +++ -/// title = "..." -/// priority = 5 -/// ... -/// +++ -/// Body content here. -/// ``` -fn serialize_markdown(ticket: &Ticket) -> Result { - let fm = MarkdownFrontmatter { - title: ticket.title.clone(), - priority: ticket.priority, - status: ticket.status.clone(), - ticket_type: ticket.ticket_type.clone(), - dependencies: ticket.dependencies.clone(), - }; - let toml_str = toml::to_string(&fm)?; - Ok(format!("+++\n{toml_str}+++\n{}", ticket.body)) -} - -/// Serialise `ticket` to CBOR binary. -fn serialize_jsonb(ticket: &Ticket) -> Result> { - let mut buf = Vec::new(); - ciborium::ser::into_writer(ticket, &mut buf) - .map_err(|e| msg_err(format!("CBOR serialization error: {e}")))?; - Ok(buf) -} - -// ── Deserialisation helpers ─────────────────────────────────────────────────── - -/// Deserialise a ticket from JSON bytes. -fn deserialize_json(bytes: &[u8]) -> Result { - Ok(serde_json::from_slice(bytes)?) -} - -/// Deserialise a ticket from TOML bytes. -fn deserialize_toml(bytes: &[u8]) -> Result { - let s = std::str::from_utf8(bytes)?; - Ok(toml::from_str(s)?) -} - -/// Deserialise a ticket from a markdown document with TOML frontmatter. -/// -/// The document must begin with `+++\n`, contain a closing `\n+++\n`, and -/// have TOML key-value pairs between the two delimiters. Everything after the -/// closing delimiter becomes the ticket body. -fn deserialize_markdown(bytes: &[u8]) -> Result { - let content = std::str::from_utf8(bytes)?; - - let after_open = content - .strip_prefix("+++\n") - .ok_or("markdown ticket must start with '+++\\n'")?; - - let (fm_str, body) = after_open - .split_once("\n+++\n") - .ok_or("markdown ticket is missing a closing '+++' delimiter")?; - - let fm: MarkdownFrontmatter = toml::from_str(fm_str)?; - - Ok(Ticket { - id: String::new(), - title: fm.title, - body: body.to_string(), - priority: fm.priority, - status: fm.status, - ticket_type: fm.ticket_type, - dependencies: fm.dependencies, - }) -} - -/// Deserialise a ticket from CBOR binary bytes. -fn deserialize_jsonb(bytes: &[u8]) -> Result { - ciborium::de::from_reader(bytes) - .map_err(|e| msg_err(format!("CBOR deserialization error: {e}"))) -} - -/// Deserialise `bytes` using the detected `format`. -fn deserialize_by_format(bytes: &[u8], format: FileFormat) -> Result { - match format { - FileFormat::Json => deserialize_json(bytes), - FileFormat::Markdown => deserialize_markdown(bytes), - FileFormat::Toml => deserialize_toml(bytes), - FileFormat::Jsonb => deserialize_jsonb(bytes), - } -} - -// ── Directory helpers ───────────────────────────────────────────────────────── - -/// Walk upward from `start` until a directory containing `.nbd/` is found. -/// -/// Returns the first ancestor path (inclusive of `start`) that contains a -/// `.nbd/` subdirectory, or an error if the filesystem root is reached without -/// finding one. -/// -/// This is the low-level, testable variant. Most callers should use -/// [`find_nbd_root`], which starts from the current working directory. -/// -/// # Errors -/// -/// Returns an error if no `.nbd/` directory exists in `start` or any of its -/// ancestors. -pub fn find_nbd_root_from(start: &Path) -> Result { - let mut dir = start; - loop { - if dir.join(".nbd").is_dir() { - return Ok(dir.to_path_buf()); - } - match dir.parent() { - Some(parent) => dir = parent, - None => break, - } - } - Err( - "could not find .nbd/ directory; create `.nbd/tickets/` in your project root to initialise" - .into(), - ) -} - -/// Walk upward from the current working directory until a `.nbd/` directory is -/// found. -/// -/// This is the primary entry point used by all CLI commands. Internally -/// delegates to [`find_nbd_root_from`]. -/// -/// # Errors -/// -/// Returns an error if the current working directory cannot be determined, or -/// if no `.nbd/` directory exists in it or any of its ancestors. -pub fn find_nbd_root() -> Result { - let cwd = std::env::current_dir()?; - find_nbd_root_from(&cwd) -} - -/// Return the path to the tickets directory within a project root. -/// -/// This is a pure path computation — it does not check whether the directory -/// exists. -pub fn tickets_dir(root: &Path) -> PathBuf { - root.join(".nbd").join("tickets") -} - -/// Create `.nbd/tickets/` under `root` if it does not already exist. -/// -/// All intermediate directories (including `.nbd/`) are created as needed. -/// This should only be called by the `create` command; other commands can -/// assume the directory already exists. -/// -/// # Errors -/// -/// Propagates any I/O error returned by the filesystem. -pub async fn ensure_tickets_dir(root: &Path) -> Result<()> { - let dir = tickets_dir(root); - fs::create_dir_all(dir).await?; - Ok(()) -} - -// ── Path helpers ────────────────────────────────────────────────────────────── - -/// Return the path to a specific ticket file for the given `format`. -/// -/// This is a pure path computation — it does not check whether the file -/// exists. -pub fn ticket_path(root: &Path, id: &str, format: FileFormat) -> PathBuf { - tickets_dir(root).join(format!("{id}.{}", format.extension())) -} - -/// Find and return the actual on-disk path of a ticket, trying each known -/// extension in order. -/// -/// # Errors -/// -/// Returns an error if no file with a known extension exists for `id`. -pub async fn find_ticket_path(root: &Path, id: &str) -> Result { - let dir = tickets_dir(root); - for ext in KNOWN_EXTENSIONS { - let path = dir.join(format!("{id}.{ext}")); - if path.is_file() { - return Ok(path); - } - } - Err(format!("ticket '{id}' not found").into()) -} - -// ── CRUD operations ─────────────────────────────────────────────────────────── - -/// Serialise `ticket` and write it to `.nbd/tickets/{id}.{ext}` using -/// `format` to determine both the extension and the serialisation. -/// -/// Overwrites any existing file at the same path. The tickets directory must -/// already exist; call [`ensure_tickets_dir`] before calling this for a new -/// ticket. -/// -/// # Errors -/// -/// Returns an error if serialisation fails or if the file cannot be written. -pub async fn write_ticket(root: &Path, ticket: &Ticket, format: FileFormat) -> Result<()> { - let path = ticket_path(root, &ticket.id, format); - match format { - FileFormat::Json => { - let json = serialize_json(ticket)?; - fs::write(path, json).await?; - } - FileFormat::Markdown => { - let content = serialize_markdown(ticket)?; - fs::write(path, content).await?; - } - FileFormat::Toml => { - let content = serialize_toml(ticket)?; - fs::write(path, content).await?; - } - FileFormat::Jsonb => { - let bytes = serialize_jsonb(ticket)?; - fs::write(path, bytes).await?; - } - } - Ok(()) -} - -/// Resolve a full 6-character ticket ID from an exact ID or a unique prefix. -/// -/// If `id_or_prefix` is already an exact match for a ticket on disk (in any -/// supported format), it is returned unchanged. Otherwise all ticket filenames -/// whose stem starts with `id_or_prefix` are collected and: -/// -/// - **0 matches** → error: `"no ticket found matching '{prefix}'"` -/// - **1 match** → the full 6-character ID is returned -/// - **2+ matches** → error: `"ambiguous prefix '{prefix}' matches: {id1}, {id2}, ..."` -/// -/// # Errors -/// -/// Returns an error if no match is found, the prefix is ambiguous, or the -/// tickets directory cannot be read. -pub async fn resolve_id(root: &Path, id_or_prefix: &str) -> Result { - let dir = tickets_dir(root); - - // Fast path: if exactly 6 chars, check all known extensions. - if id_or_prefix.len() == 6 { - for ext in KNOWN_EXTENSIONS { - if dir.join(format!("{id_or_prefix}.{ext}")).is_file() { - return Ok(id_or_prefix.to_string()); - } - } - } - - // Scan directory for prefix matches across all known formats. - if !dir.is_dir() { - return Err(format!("no ticket found matching '{id_or_prefix}'").into()); - } - - let mut entries = fs::read_dir(&dir).await?; - let mut matches: Vec = Vec::new(); - - while let Some(entry) = entries.next_entry().await? { - // Construct a std::path::PathBuf so it's compatible with Path helpers. - let path: PathBuf = dir.join(entry.file_name()); - let ext = path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or_default(); - if !KNOWN_EXTENSIONS.contains(&ext) { - continue; - } - if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { - if stem.starts_with(id_or_prefix) && !matches.contains(&stem.to_string()) { - matches.push(stem.to_string()); - } - } - } - - match matches.len() { - 0 => Err(format!("no ticket found matching '{id_or_prefix}'").into()), - 1 => Ok(matches.remove(0)), - _ => { - matches.sort(); - Err(format!( - "ambiguous prefix '{id_or_prefix}' matches: {}", - matches.join(", ") - ) - .into()) - } - } -} - -/// Read and deserialise the ticket with the given `id` from disk. -/// -/// Extensions are tried in the order defined by [`KNOWN_EXTENSIONS`]. The -/// first matching file is used. The `id` is not stored inside the file; it -/// is injected from the `id` parameter after deserialisation, making the -/// filename stem the authoritative source of truth. -/// -/// # Errors -/// -/// Returns a descriptive error message if no file is found for `id`. -/// Propagates any other I/O or deserialisation error unchanged. -pub async fn read_ticket(root: &Path, id: &str) -> Result { - let dir = tickets_dir(root); - for ext in KNOWN_EXTENSIONS { - let path = dir.join(format!("{id}.{ext}")); - match fs::read(&path).await { - Ok(bytes) => { - let format = detect_format(&path); - let mut ticket = deserialize_by_format(&bytes, format)?; - ticket.id = id.to_string(); - return Ok(ticket); - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, - Err(e) => return Err(e.into()), - } - } - Err(format!("ticket '{id}' not found").into()) -} - -/// Report produced by [`migrate_tickets`]. -/// -/// Summarises how many ticket files were updated, were already current, -/// were skipped by the filter, or could not be parsed. -#[derive(Debug)] -pub struct MigrateReport { - /// Number of files that were re-serialised (had stale content). - pub updated: usize, - /// Number of files that were already in the current schema format. - pub already_current: usize, - /// Number of files excluded by the caller-supplied [`TicketFilter`]. - pub skipped: usize, - /// Files that could not be deserialised. Each entry is `(filename, error)`. - pub errors: Vec<(String, String)>, -} - -/// Re-serialise every ticket file through the current serde schema. -/// -/// For each ticket file in `.nbd/tickets/` (any supported format): -/// - Deserialise into [`Ticket`], injecting the `id` from the filename stem. -/// - If the ticket does not match `filter`, count it as skipped and continue. -/// - Re-serialise in the same format. -/// - If the bytes differ, write the new content (unless `dry_run` is `true`). -/// - If the bytes are identical, count the file as already current. -/// - If deserialisation fails, record the error and leave the file untouched. -/// -/// When `dry_run` is `true`, no files are written; the report describes what -/// *would* have changed. The function always returns `Ok` — individual file -/// errors are collected in [`MigrateReport::errors`] rather than aborting early. -/// -/// # Errors -/// -/// Returns an error only if the tickets directory itself cannot be read. -pub async fn migrate_tickets( - root: &Path, - dry_run: bool, - filter: &TicketFilter, -) -> Result { - let dir = tickets_dir(root); - let mut report = MigrateReport { - updated: 0, - already_current: 0, - skipped: 0, - errors: Vec::new(), - }; - - if !dir.is_dir() { - return Ok(report); - } - - let mut entries = fs::read_dir(&dir).await?; - while let Some(entry) = entries.next_entry().await? { - // Construct a std::path::PathBuf so it's compatible with Path helpers. - let path: PathBuf = dir.join(entry.file_name()); - - let ext = path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or_default(); - if !KNOWN_EXTENSIONS.contains(&ext) { - continue; - } - - let filename = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("") - .to_string(); - let stem = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_string(); - - let raw = match fs::read(&path).await { - Ok(b) => b, - Err(e) => { - report.errors.push((filename, e.to_string())); - continue; - } - }; - - let format = detect_format(&path); - let mut ticket: Ticket = match deserialize_by_format(&raw, format) { - Ok(t) => t, - Err(e) => { - report.errors.push((filename, e.to_string())); - continue; - } - }; - ticket.id = stem; - - // Apply caller-supplied filter; skip non-matching tickets. - if !filter.matches(&ticket) { - report.skipped += 1; - continue; - } - - // Re-serialise in the same format to normalise the schema. - let new_bytes: Vec = match format { - FileFormat::Json => match serialize_json(&ticket) { - Ok(s) => s.into_bytes(), - Err(e) => { - report.errors.push((filename, e.to_string())); - continue; - } - }, - FileFormat::Markdown => match serialize_markdown(&ticket) { - Ok(s) => s.into_bytes(), - Err(e) => { - report.errors.push((filename, e.to_string())); - continue; - } - }, - FileFormat::Toml => match serialize_toml(&ticket) { - Ok(s) => s.into_bytes(), - Err(e) => { - report.errors.push((filename, e.to_string())); - continue; - } - }, - FileFormat::Jsonb => match serialize_jsonb(&ticket) { - Ok(b) => b, - Err(e) => { - report.errors.push((filename, e.to_string())); - continue; - } - }, - }; - - if raw == new_bytes { - report.already_current += 1; - } else if dry_run { - report.updated += 1; - } else { - if let Err(e) = fs::write(&path, &new_bytes).await { - report.errors.push((filename, e.to_string())); - continue; - } - report.updated += 1; - } - } - - Ok(report) -} - -/// Read every ticket file in the tickets directory and return them sorted by -/// priority descending (highest priority first). -/// -/// All supported file formats (`.json`, `.md`, `.toml`, `.jsonb`) are scanned. -/// If the tickets directory does not exist, an empty `Vec` is returned. -/// -/// # Errors -/// -/// Returns an error if the directory listing fails, if any ticket file cannot -/// be read, or if any ticket's content cannot be deserialised. -pub async fn list_tickets(root: &Path) -> Result> { - let dir = tickets_dir(root); - - if !dir.is_dir() { - return Ok(Vec::new()); - } - - let mut entries = fs::read_dir(&dir).await?; - let mut tickets = Vec::new(); - // Guard against the same logical ticket appearing in multiple formats. - let mut seen_ids: std::collections::HashSet = std::collections::HashSet::new(); - - while let Some(entry) = entries.next_entry().await? { - // Construct a std::path::PathBuf so it's compatible with Path helpers. - let path: PathBuf = dir.join(entry.file_name()); - let ext = path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or_default(); - if !KNOWN_EXTENSIONS.contains(&ext) { - continue; - } - let stem = path - .file_stem() - .and_then(|s| s.to_str()) - .ok_or("ticket filename has no valid stem")? - .to_string(); - if !seen_ids.insert(stem.clone()) { - // Same ID already loaded from a different-extension file; skip. - continue; - } - let bytes = fs::read(&path).await?; - let format = detect_format(&path); - let mut ticket = deserialize_by_format(&bytes, format)?; - ticket.id = stem; - tickets.push(ticket); - } - - // Highest priority value first; ties preserve filesystem order. - tickets.sort_by(|a, b| b.priority.cmp(&a.priority)); - Ok(tickets) -} - -// ── Turso (libsql) cache ────────────────────────────────────────────────────── - -/// Open (or create) the libsql cache database at `.nbd/cache.db`. -/// -/// Runs the schema migration on every open so the table exists when needed. -/// The returned [`libsql::Connection`] is ready for queries. -/// -/// # Errors -/// -/// Returns an error if the database cannot be opened or the migration fails. -async fn open_cache(root: &Path) -> Result { - let db_path = root.join(".nbd").join("cache.db"); - let db = libsql::Builder::new_local(db_path) - .build() - .await - .map_err(|e| msg_err(format!("libsql open error: {e}")))?; - let conn = db - .connect() - .map_err(|e| msg_err(format!("libsql connect error: {e}")))?; - conn.execute( - "CREATE TABLE IF NOT EXISTS tickets ( - id TEXT PRIMARY KEY, - json TEXT NOT NULL, - mtime INTEGER NOT NULL - )", - (), - ) - .await - .map_err(|e| msg_err(format!("libsql migrate error: {e}")))?; - Ok(conn) -} - -/// Like [`list_tickets`] but uses a libsql cache in `.nbd/cache.db` to skip -/// re-reading files whose modification time has not changed. -/// -/// ## Cache strategy -/// -/// 1. Scan `.nbd/tickets/` for files and their mtimes. -/// 2. For each file: if the cache row exists and the mtime matches, deserialise -/// the cached JSON; otherwise read the file, parse it, and upsert the cache. -/// 3. Delete rows for IDs that no longer exist on disk. -/// 4. Return tickets sorted by priority descending. -/// -/// Falls back to [`list_tickets`] if the cache cannot be opened or any cache -/// operation fails, so the caller always gets a result. -/// -/// # Errors -/// -/// Only returns an error if [`list_tickets`] itself fails (the fallback path). -pub async fn list_tickets_cached(root: &Path) -> Result> { - match list_tickets_cached_inner(root).await { - Ok(tickets) => Ok(tickets), - Err(_) => list_tickets(root).await, - } -} - -/// Inner implementation of the cached list; errors cause a fallback to the -/// uncached path in [`list_tickets_cached`]. -async fn list_tickets_cached_inner(root: &Path) -> Result> { - let dir = tickets_dir(root); - - if !dir.is_dir() { - return Ok(Vec::new()); - } - - let conn = open_cache(root).await?; - - // Scan the directory, collecting (id, path, mtime_secs) for each ticket file. - let mut on_disk: Vec<(String, PathBuf, i64)> = Vec::new(); - let mut read_dir = fs::read_dir(&dir).await?; - while let Some(entry) = read_dir.next_entry().await? { - let path: PathBuf = dir.join(entry.file_name()); - let ext = path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or_default(); - if !KNOWN_EXTENSIONS.contains(&ext) { - continue; - } - let stem = match path.file_stem().and_then(|s| s.to_str()) { - Some(s) => s.to_string(), - None => continue, - }; - let meta = fs::metadata(&path).await?; - let mtime = meta - .modified() - .ok() - .and_then(|t| { - t.duration_since(std::time::UNIX_EPOCH) - .ok() - .map(|d| d.as_millis() as i64) - }) - .unwrap_or(0); - on_disk.push((stem, path, mtime)); - } - - // Deduplicate: keep only the first occurrence of each ID (same order as list_tickets). - let mut seen_ids: std::collections::HashSet = std::collections::HashSet::new(); - on_disk.retain(|(id, _, _)| seen_ids.insert(id.clone())); - - let disk_ids: std::collections::HashSet = - on_disk.iter().map(|(id, _, _)| id.clone()).collect(); - - let mut tickets: Vec = Vec::with_capacity(on_disk.len()); - - for (id, path, mtime) in &on_disk { - // Query the cache for this id. - let cached_json: Option = { - let mut rows = conn - .query( - "SELECT json FROM tickets WHERE id = ?1 AND mtime = ?2", - libsql::params![id.as_str(), *mtime], - ) - .await - .map_err(|e| msg_err(format!("cache query error: {e}")))?; - if let Some(row) = rows - .next() - .await - .map_err(|e| msg_err(format!("cache row error: {e}")))? - { - Some( - row.get::(0) - .map_err(|e| msg_err(format!("cache column error: {e}")))?, - ) - } else { - None - } - }; - - let mut ticket = if let Some(json) = cached_json { - serde_json::from_str::(&json) - .map_err(|e| msg_err(format!("cache deserialise error: {e}")))? - } else { - // Cache miss: read file and upsert. - let bytes = fs::read(path).await?; - let format = detect_format(path); - let t = deserialize_by_format(&bytes, format)?; - // Store normalised JSON in the cache. - let json = serde_json::to_string(&t)?; - conn.execute( - "INSERT OR REPLACE INTO tickets (id, json, mtime) VALUES (?1, ?2, ?3)", - libsql::params![id.as_str(), json.as_str(), *mtime], - ) - .await - .map_err(|e| msg_err(format!("cache upsert error: {e}")))?; - t - }; - ticket.id = id.clone(); - tickets.push(ticket); - } - - // Remove stale rows for IDs no longer on disk. - // We fetch all cached IDs and delete anything not in `disk_ids`. - let mut rows = conn - .query("SELECT id FROM tickets", ()) - .await - .map_err(|e| msg_err(format!("cache scan error: {e}")))?; - let mut stale: Vec = Vec::new(); - while let Some(row) = rows - .next() - .await - .map_err(|e| msg_err(format!("cache row error: {e}")))? - { - let cached_id: String = row - .get::(0) - .map_err(|e| msg_err(format!("cache column error: {e}")))?; - if !disk_ids.contains(&cached_id) { - stale.push(cached_id); - } - } - for id in stale { - conn.execute( - "DELETE FROM tickets WHERE id = ?1", - libsql::params![id.as_str()], - ) - .await - .map_err(|e| msg_err(format!("cache delete error: {e}")))?; - } - - tickets.sort_by(|a, b| b.priority.cmp(&a.priority)); - Ok(tickets) -} diff --git a/nbd/src/tests.rs b/nbd/src/tests.rs deleted file mode 100644 index 8de8823..0000000 --- a/nbd/src/tests.rs +++ /dev/null @@ -1,1698 +0,0 @@ -//! Unit tests for all `nbd` modules. -//! -//! Each module's behaviour is tested in isolation here. File I/O tests use -//! temporary directories provided by the `tempfile` crate so they leave no -//! state behind. - -// ── ticket module ──────────────────────────────────────────────────────────── - -/// Tests for [`crate::ticket`]. -mod ticket { - use crate::ticket::{generate_id, validate_priority, Status, Ticket, TicketType}; - - /// `Ticket::new` produces a ticket with the expected id, title, and defaults. - #[test] - fn new_sets_defaults() { - let t = Ticket::new("a3f9c2".to_string(), "Fix login bug".to_string()); - assert_eq!(t.id, "a3f9c2"); - assert_eq!(t.title, "Fix login bug"); - assert_eq!(t.body, ""); - assert_eq!(t.priority, 5); - assert_eq!(t.status, Status::Todo); - assert!(t.dependencies.is_empty()); - assert_eq!(t.ticket_type, TicketType::Task); - } - - /// A `Ticket` serialises to JSON and deserialises back with all fields intact - /// except `id`, which is not included in JSON (it is injected from the filename - /// by the store layer). - #[test] - fn ticket_roundtrip() { - let original = Ticket { - id: "b7d41e".to_string(), - title: "Add rate limiting".to_string(), - body: "Limit to 100 req/s".to_string(), - priority: 8, - status: Status::InProgress, - dependencies: vec!["a3f9c2".to_string()], - ticket_type: TicketType::Feature, - }; - - let json = serde_json::to_string(&original).expect("serialisation failed"); - - // id is skipped — the JSON must not contain it. - let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); - assert!( - parsed.get("id").is_none(), - "serialised JSON must not contain 'id', got: {json}" - ); - - let restored: Ticket = serde_json::from_str(&json).expect("deserialisation failed"); - - // id is populated by the store from the filename, so after a raw JSON - // roundtrip it will be the default empty string. - assert_eq!( - restored.id, "", - "id should be empty after raw JSON roundtrip" - ); - assert_eq!(restored.title, original.title); - assert_eq!(restored.body, original.body); - assert_eq!(restored.priority, original.priority); - assert_eq!(restored.status, original.status); - assert_eq!(restored.dependencies, original.dependencies); - assert_eq!(restored.ticket_type, original.ticket_type); - } - - /// `Status` variants serialise to the expected lowercase snake_case strings. - #[test] - fn status_serialises_to_snake_case() { - assert_eq!(serde_json::to_string(&Status::Todo).unwrap(), "\"todo\""); - assert_eq!( - serde_json::to_string(&Status::InProgress).unwrap(), - "\"in_progress\"" - ); - assert_eq!(serde_json::to_string(&Status::Done).unwrap(), "\"done\""); - assert_eq!( - serde_json::to_string(&Status::Closed).unwrap(), - "\"closed\"" - ); - assert_eq!( - serde_json::to_string(&Status::Archived).unwrap(), - "\"archived\"" - ); - assert_eq!( - serde_json::to_string(&Status::Backlog).unwrap(), - "\"backlog\"" - ); - } - - /// `Status` deserialises correctly from lowercase snake_case strings. - #[test] - fn status_deserialises_from_snake_case() { - assert_eq!( - serde_json::from_str::("\"todo\"").unwrap(), - Status::Todo - ); - assert_eq!( - serde_json::from_str::("\"in_progress\"").unwrap(), - Status::InProgress - ); - assert_eq!( - serde_json::from_str::("\"done\"").unwrap(), - Status::Done - ); - assert_eq!( - serde_json::from_str::("\"closed\"").unwrap(), - Status::Closed - ); - assert_eq!( - serde_json::from_str::("\"archived\"").unwrap(), - Status::Archived - ); - assert_eq!( - serde_json::from_str::("\"backlog\"").unwrap(), - Status::Backlog - ); - } - - /// `TicketType` variants serialise to the expected lowercase strings. - #[test] - fn ticket_type_serialises_to_lowercase() { - assert_eq!( - serde_json::to_string(&TicketType::Project).unwrap(), - "\"project\"" - ); - assert_eq!( - serde_json::to_string(&TicketType::Feature).unwrap(), - "\"feature\"" - ); - assert_eq!( - serde_json::to_string(&TicketType::Task).unwrap(), - "\"task\"" - ); - assert_eq!(serde_json::to_string(&TicketType::Bug).unwrap(), "\"bug\""); - } - - /// `TicketType` deserialises correctly from lowercase strings. - #[test] - fn ticket_type_deserialises_from_lowercase() { - assert_eq!( - serde_json::from_str::("\"project\"").unwrap(), - TicketType::Project - ); - assert_eq!( - serde_json::from_str::("\"bug\"").unwrap(), - TicketType::Bug - ); - } - - /// `validate_priority` accepts values 0–10 and rejects values above 10. - #[test] - fn priority_validation() { - assert!(validate_priority(0).is_ok()); - assert!(validate_priority(5).is_ok()); - assert!(validate_priority(10).is_ok()); - assert!(validate_priority(11).is_err()); - assert!(validate_priority(255).is_err()); - } - - /// `generate_id` returns a 6-character lowercase hex string. - #[test] - fn generated_id_is_six_hex_chars() { - let id = generate_id(); - assert_eq!(id.len(), 6, "id length must be 6, got {id:?}"); - assert!( - id.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')), - "id must be lowercase hex, got {id:?}" - ); - } - - /// Calling `generate_id` multiple times produces distinct values. - #[test] - fn generated_ids_are_unique() { - let ids: Vec = (0..20).map(|_| generate_id()).collect(); - let unique: std::collections::HashSet<&String> = ids.iter().collect(); - // Allow at most 1 collision across 20 draws from a 16M space. - assert!( - unique.len() >= 19, - "too many collisions among generated IDs: {ids:?}" - ); - } -} - -// ── store module ────────────────────────────────────────────────────────────── - -/// Tests for [`crate::store`]. -mod store { - use std::path::Path; - - use crate::store::{ - ensure_tickets_dir, find_nbd_root_from, list_tickets, read_ticket, resolve_id, ticket_path, - tickets_dir, write_ticket, FileFormat, - }; - use crate::ticket::{Status, Ticket, TicketType}; - - /// Helper: create a temporary directory with `.nbd/tickets/` already set up. - async fn setup_store() -> (tempfile::TempDir, std::path::PathBuf) { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().to_path_buf(); - ensure_tickets_dir(&root).await.unwrap(); - (tmp, root) - } - - /// Writing a ticket and reading it back produces an identical value. - #[tokio::test] - async fn write_and_read_roundtrip() { - let (tmp, root) = setup_store().await; - let ticket = Ticket { - id: "a3f9c2".to_string(), - title: "Fix login bug".to_string(), - body: "Users cannot log in with email addresses containing +".to_string(), - priority: 8, - status: Status::InProgress, - dependencies: vec!["b7d41e".to_string()], - ticket_type: TicketType::Bug, - }; - - write_ticket(&root, &ticket, FileFormat::Json) - .await - .unwrap(); - let restored = read_ticket(&root, "a3f9c2").await.unwrap(); - - assert_eq!(restored.id, ticket.id); - assert_eq!(restored.title, ticket.title); - assert_eq!(restored.body, ticket.body); - assert_eq!(restored.priority, ticket.priority); - assert_eq!(restored.status, ticket.status); - assert_eq!(restored.dependencies, ticket.dependencies); - assert_eq!(restored.ticket_type, ticket.ticket_type); - drop(tmp); - } - - /// `write_ticket` does not include the `id` key in the JSON file. - #[tokio::test] - async fn write_ticket_omits_id_from_json() { - let (tmp, root) = setup_store().await; - let ticket = Ticket::new("c0ffee".to_string(), "Check JSON".to_string()); - write_ticket(&root, &ticket, FileFormat::Json) - .await - .unwrap(); - - let path = ticket_path(&root, "c0ffee", FileFormat::Json); - let contents = tokio::fs::read_to_string(&path).await.unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap(); - assert!( - parsed.get("id").is_none(), - "written JSON must not contain the 'id' key, got: {contents}" - ); - drop(tmp); - } - - /// `read_ticket` injects the id from its parameter even when the file has no `id` field. - #[tokio::test] - async fn read_ticket_injects_id_from_parameter() { - let (tmp, root) = setup_store().await; - - // Write a JSON file that has no "id" key (the new format). - let json = r#"{"title":"No id field","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#; - let dir = tickets_dir(&root); - tokio::fs::write(dir.join("abcdef.json"), json) - .await - .unwrap(); - - let ticket = read_ticket(&root, "abcdef").await.unwrap(); - assert_eq!(ticket.id, "abcdef"); - assert_eq!(ticket.title, "No id field"); - drop(tmp); - } - - /// `read_ticket` ignores any `id` key present in the JSON body (old format), - /// and instead uses the id passed as the parameter. - #[tokio::test] - async fn read_ticket_ignores_id_in_json_body() { - let (tmp, root) = setup_store().await; - - // Simulate an old-format file that still has "id" in the JSON body. - let json = r#"{"id":"wrongid","title":"Old format","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#; - let dir = tickets_dir(&root); - tokio::fs::write(dir.join("aabbcc.json"), json) - .await - .unwrap(); - - let ticket = read_ticket(&root, "aabbcc").await.unwrap(); - assert_eq!( - ticket.id, "aabbcc", - "id should come from the filename parameter, not the JSON body" - ); - drop(tmp); - } - - /// `list_tickets` injects the correct id from each filename stem. - #[tokio::test] - async fn list_tickets_injects_id_from_filename() { - let (tmp, root) = setup_store().await; - - let mut t1 = Ticket::new("id1111".to_string(), "First".to_string()); - t1.priority = 7; - let mut t2 = Ticket::new("id2222".to_string(), "Second".to_string()); - t2.priority = 3; - write_ticket(&root, &t1, FileFormat::Json).await.unwrap(); - write_ticket(&root, &t2, FileFormat::Json).await.unwrap(); - - let tickets = list_tickets(&root).await.unwrap(); - assert_eq!(tickets.len(), 2); - // Sorted highest priority first. - assert_eq!(tickets[0].id, "id1111"); - assert_eq!(tickets[1].id, "id2222"); - drop(tmp); - } - - /// Reading a non-existent ticket produces an error that mentions the ID. - #[tokio::test] - async fn read_missing_ticket_errors() { - let (tmp, root) = setup_store().await; - let result = read_ticket(&root, "ffffff").await; - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("ffffff"), - "error message should mention the ticket ID, got: {msg}" - ); - drop(tmp); - } - - /// `list_tickets` returns all written tickets sorted by priority descending. - #[tokio::test] - async fn list_returns_all_sorted_by_priority() { - let (tmp, root) = setup_store().await; - - let mut low = Ticket::new("id0001".to_string(), "Low priority".to_string()); - low.priority = 2; - let mut high = Ticket::new("id0002".to_string(), "High priority".to_string()); - high.priority = 9; - let mut mid = Ticket::new("id0003".to_string(), "Mid priority".to_string()); - mid.priority = 5; - - write_ticket(&root, &low, FileFormat::Json).await.unwrap(); - write_ticket(&root, &high, FileFormat::Json).await.unwrap(); - write_ticket(&root, &mid, FileFormat::Json).await.unwrap(); - - let tickets = list_tickets(&root).await.unwrap(); - assert_eq!(tickets.len(), 3); - assert_eq!( - tickets[0].priority, 9, - "first ticket should have highest priority" - ); - assert_eq!(tickets[1].priority, 5); - assert_eq!( - tickets[2].priority, 2, - "last ticket should have lowest priority" - ); - drop(tmp); - } - - /// `resolve_id` returns the full ID when given an exact 6-char match. - #[tokio::test] - async fn resolve_id_exact_match() { - let (tmp, root) = setup_store().await; - let ticket = Ticket::new("a3f9c2".to_string(), "Exact".to_string()); - write_ticket(&root, &ticket, FileFormat::Json) - .await - .unwrap(); - - let resolved = resolve_id(&root, "a3f9c2").await.unwrap(); - assert_eq!(resolved, "a3f9c2"); - drop(tmp); - } - - /// `resolve_id` resolves a unique 3-char prefix to the full ID. - #[tokio::test] - async fn resolve_id_prefix_match() { - let (tmp, root) = setup_store().await; - let ticket = Ticket::new("a3f9c2".to_string(), "Prefix".to_string()); - write_ticket(&root, &ticket, FileFormat::Json) - .await - .unwrap(); - - let resolved = resolve_id(&root, "a3f").await.unwrap(); - assert_eq!(resolved, "a3f9c2"); - drop(tmp); - } - - /// `resolve_id` returns an error for an unknown prefix. - #[tokio::test] - async fn resolve_id_not_found() { - let (tmp, root) = setup_store().await; - let result = resolve_id(&root, "zzz").await; - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("zzz"), - "error should mention the prefix, got: {msg}" - ); - drop(tmp); - } - - /// `resolve_id` returns an error listing all matches for an ambiguous prefix. - #[tokio::test] - async fn resolve_id_ambiguous_prefix() { - let (tmp, root) = setup_store().await; - let t1 = Ticket::new("aabbcc".to_string(), "First".to_string()); - let t2 = Ticket::new("aaddee".to_string(), "Second".to_string()); - write_ticket(&root, &t1, FileFormat::Json).await.unwrap(); - write_ticket(&root, &t2, FileFormat::Json).await.unwrap(); - - let result = resolve_id(&root, "aa").await; - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("ambiguous"), - "error should say ambiguous, got: {msg}" - ); - assert!( - msg.contains("aabbcc"), - "error should list first match, got: {msg}" - ); - assert!( - msg.contains("aaddee"), - "error should list second match, got: {msg}" - ); - drop(tmp); - } - - /// `list_tickets` returns an empty vec when the tickets directory is absent. - #[tokio::test] - async fn list_empty_when_no_tickets_dir() { - let tmp = tempfile::tempdir().unwrap(); - // Create `.nbd/` but not `.nbd/tickets/`. - std::fs::create_dir(tmp.path().join(".nbd")).unwrap(); - let root = tmp.path().to_path_buf(); - - let tickets = list_tickets(&root).await.unwrap(); - assert!(tickets.is_empty()); - drop(tmp); - } - - /// `find_nbd_root_from` finds `.nbd/` located in a grandparent directory. - #[test] - fn traversal_finds_nbd_in_grandparent() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - - // Place `.nbd/` at the temp root. - std::fs::create_dir(root.join(".nbd")).unwrap(); - - // Start traversal from a deeply nested subdirectory. - let grandchild = root.join("a").join("b"); - std::fs::create_dir_all(&grandchild).unwrap(); - - let found = find_nbd_root_from(&grandchild).unwrap(); - assert_eq!(found, root); - drop(tmp); - } - - /// `find_nbd_root_from` returns an error when no `.nbd/` directory exists. - #[test] - fn traversal_errors_when_no_nbd_dir() { - let tmp = tempfile::tempdir().unwrap(); - let result = find_nbd_root_from(tmp.path()); - assert!(result.is_err()); - drop(tmp); - } - - /// Writing in TOML format and reading back produces an identical ticket. - #[tokio::test] - async fn write_and_read_roundtrip_toml() { - let (tmp, root) = setup_store().await; - let ticket = Ticket { - id: "aa1122".to_string(), - title: "TOML ticket".to_string(), - body: "Body text for TOML".to_string(), - priority: 7, - status: crate::ticket::Status::InProgress, - dependencies: vec!["bb2233".to_string()], - ticket_type: crate::ticket::TicketType::Feature, - }; - write_ticket(&root, &ticket, FileFormat::Toml) - .await - .unwrap(); - - // The `.toml` file must exist; the `.json` file must not. - assert!(ticket_path(&root, "aa1122", FileFormat::Toml).is_file()); - assert!(!ticket_path(&root, "aa1122", FileFormat::Json).is_file()); - - let restored = read_ticket(&root, "aa1122").await.unwrap(); - assert_eq!(restored.id, ticket.id); - assert_eq!(restored.title, ticket.title); - assert_eq!(restored.body, ticket.body); - assert_eq!(restored.priority, ticket.priority); - assert_eq!(restored.status, ticket.status); - assert_eq!(restored.dependencies, ticket.dependencies); - assert_eq!(restored.ticket_type, ticket.ticket_type); - drop(tmp); - } - - /// Writing in Markdown format and reading back produces an identical ticket. - #[tokio::test] - async fn write_and_read_roundtrip_markdown() { - let (tmp, root) = setup_store().await; - let ticket = Ticket { - id: "cc3344".to_string(), - title: "Markdown ticket".to_string(), - body: "# Header\n\nBody paragraph.".to_string(), - priority: 6, - status: crate::ticket::Status::Todo, - dependencies: vec![], - ticket_type: crate::ticket::TicketType::Bug, - }; - write_ticket(&root, &ticket, FileFormat::Markdown) - .await - .unwrap(); - - assert!(ticket_path(&root, "cc3344", FileFormat::Markdown).is_file()); - assert!(!ticket_path(&root, "cc3344", FileFormat::Json).is_file()); - - let restored = read_ticket(&root, "cc3344").await.unwrap(); - assert_eq!(restored.id, ticket.id); - assert_eq!(restored.title, ticket.title); - assert_eq!(restored.body, ticket.body); - assert_eq!(restored.priority, ticket.priority); - assert_eq!(restored.status, ticket.status); - assert_eq!(restored.dependencies, ticket.dependencies); - assert_eq!(restored.ticket_type, ticket.ticket_type); - drop(tmp); - } - - /// Writing in CBOR format and reading back produces an identical ticket. - #[tokio::test] - async fn write_and_read_roundtrip_jsonb() { - let (tmp, root) = setup_store().await; - let ticket = Ticket { - id: "ee5566".to_string(), - title: "CBOR ticket".to_string(), - body: "Binary body".to_string(), - priority: 4, - status: crate::ticket::Status::Done, - dependencies: vec!["ff6677".to_string(), "aa0011".to_string()], - ticket_type: crate::ticket::TicketType::Task, - }; - write_ticket(&root, &ticket, FileFormat::Jsonb) - .await - .unwrap(); - - assert!(ticket_path(&root, "ee5566", FileFormat::Jsonb).is_file()); - assert!(!ticket_path(&root, "ee5566", FileFormat::Json).is_file()); - - let restored = read_ticket(&root, "ee5566").await.unwrap(); - assert_eq!(restored.id, ticket.id); - assert_eq!(restored.title, ticket.title); - assert_eq!(restored.body, ticket.body); - assert_eq!(restored.priority, ticket.priority); - assert_eq!(restored.status, ticket.status); - assert_eq!(restored.dependencies, ticket.dependencies); - assert_eq!(restored.ticket_type, ticket.ticket_type); - drop(tmp); - } - - /// `ticket_path` returns the correct path for each file format. - #[test] - fn ticket_path_is_correct() { - let root = Path::new("/tmp/project"); - assert_eq!( - ticket_path(root, "a3f9c2", FileFormat::Json), - Path::new("/tmp/project/.nbd/tickets/a3f9c2.json") - ); - assert_eq!( - ticket_path(root, "a3f9c2", FileFormat::Markdown), - Path::new("/tmp/project/.nbd/tickets/a3f9c2.md") - ); - assert_eq!( - ticket_path(root, "a3f9c2", FileFormat::Toml), - Path::new("/tmp/project/.nbd/tickets/a3f9c2.toml") - ); - assert_eq!( - ticket_path(root, "a3f9c2", FileFormat::Jsonb), - Path::new("/tmp/project/.nbd/tickets/a3f9c2.jsonb") - ); - } - - /// `tickets_dir` returns the expected `.nbd/tickets/` path. - #[test] - fn tickets_dir_is_correct() { - let root = Path::new("/tmp/project"); - let dir = tickets_dir(root); - assert_eq!(dir, Path::new("/tmp/project/.nbd/tickets")); - } -} - -// ── migrate ─────────────────────────────────────────────────────────────────── - -/// Tests for [`crate::store::migrate_tickets`]. -mod migrate { - use crate::filter::TicketFilter; - use crate::store::{ - ensure_tickets_dir, migrate_tickets, tickets_dir, write_ticket, FileFormat, - }; - use crate::ticket::Ticket; - - async fn setup_store() -> (tempfile::TempDir, std::path::PathBuf) { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().to_path_buf(); - ensure_tickets_dir(&root).await.unwrap(); - (tmp, root) - } - - /// `migrate_tickets` rewrites old-format files that contain a stale `"id"` key. - #[tokio::test] - async fn rewrites_old_format_with_id_field() { - let (tmp, root) = setup_store().await; - let dir = tickets_dir(&root); - // Write a file with the legacy "id" key. - let old_json = r#"{"id":"aabbcc","title":"Old","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#; - tokio::fs::write(dir.join("aabbcc.json"), old_json) - .await - .unwrap(); - - let report = migrate_tickets(&root, false, &TicketFilter::default()) - .await - .unwrap(); - assert_eq!(report.updated, 1); - assert_eq!(report.already_current, 0); - assert!(report.errors.is_empty()); - - // Verify the file no longer contains the "id" key. - let contents = tokio::fs::read_to_string(dir.join("aabbcc.json")) - .await - .unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap(); - assert!( - parsed.get("id").is_none(), - "migrated file must not contain 'id', got: {contents}" - ); - drop(tmp); - } - - /// `migrate_tickets` on a store with already-current files returns `updated: 0`. - #[tokio::test] - async fn already_current_files_not_rewritten() { - let (tmp, root) = setup_store().await; - let t1 = Ticket::new("id0001".to_string(), "First".to_string()); - let t2 = Ticket::new("id0002".to_string(), "Second".to_string()); - write_ticket(&root, &t1, FileFormat::Json).await.unwrap(); - write_ticket(&root, &t2, FileFormat::Json).await.unwrap(); - - let report = migrate_tickets(&root, false, &TicketFilter::default()) - .await - .unwrap(); - assert_eq!(report.updated, 0); - assert_eq!(report.already_current, 2); - assert!(report.errors.is_empty()); - drop(tmp); - } - - /// `migrate_tickets` with `dry_run: true` does not write files. - #[tokio::test] - async fn dry_run_does_not_write() { - let (tmp, root) = setup_store().await; - let dir = tickets_dir(&root); - let old_json = r#"{"id":"ccddee","title":"DryRun","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#; - tokio::fs::write(dir.join("ccddee.json"), old_json) - .await - .unwrap(); - - let report = migrate_tickets(&root, true, &TicketFilter::default()) - .await - .unwrap(); - assert_eq!( - report.updated, 1, - "dry_run should still count as would-update" - ); - assert!(report.errors.is_empty()); - - // File must remain unchanged. - let contents = tokio::fs::read_to_string(dir.join("ccddee.json")) - .await - .unwrap(); - assert_eq!( - contents, old_json, - "dry_run must not modify the file on disk" - ); - drop(tmp); - } - - /// Invalid JSON files are counted in errors and left untouched. - #[tokio::test] - async fn invalid_json_counted_in_errors() { - let (tmp, root) = setup_store().await; - let dir = tickets_dir(&root); - let bad_json = b"{ this is not valid json }"; - tokio::fs::write(dir.join("badbad.json"), bad_json) - .await - .unwrap(); - - let report = migrate_tickets(&root, false, &TicketFilter::default()) - .await - .unwrap(); - assert_eq!(report.errors.len(), 1); - assert!(report.errors[0].0.contains("badbad.json")); - - // File must remain unchanged. - let contents = tokio::fs::read(&dir.join("badbad.json")).await.unwrap(); - assert_eq!(contents.as_slice(), bad_json); - drop(tmp); - } - - /// `migrate_tickets` on an empty store returns an empty report. - #[tokio::test] - async fn empty_store_returns_empty_report() { - let (tmp, root) = setup_store().await; - let report = migrate_tickets(&root, false, &TicketFilter::default()) - .await - .unwrap(); - assert_eq!(report.updated, 0); - assert_eq!(report.already_current, 0); - assert!(report.errors.is_empty()); - drop(tmp); - } - - /// `migrate_tickets` returns an empty report when the tickets directory does not exist. - #[tokio::test] - async fn no_tickets_dir_returns_empty_report() { - let tmp = tempfile::tempdir().unwrap(); - // Create `.nbd/` but not `.nbd/tickets/`. - std::fs::create_dir(tmp.path().join(".nbd")).unwrap(); - let root = tmp.path().to_path_buf(); - - let report = migrate_tickets(&root, false, &TicketFilter::default()) - .await - .unwrap(); - assert_eq!(report.updated, 0); - assert_eq!(report.already_current, 0); - assert!(report.errors.is_empty()); - drop(tmp); - } -} - -// ── filter module ──────────────────────────────────────────────────────────── - -/// Tests for [`crate::filter`]. -mod filter { - use crate::filter::{glob_matches, parse_filters, TicketFilter}; - use crate::ticket::{Status, Ticket, TicketType}; - - fn make_ticket(status: Status, ticket_type: TicketType, priority: u8, title: &str) -> Ticket { - let mut t = Ticket::new("aabbcc".to_string(), title.to_string()); - t.status = status; - t.ticket_type = ticket_type; - t.priority = priority; - t - } - - // ── parse_filters ────────────────────────────────────────────────────── - - /// `parse_filters` returns an error for a string that contains no `=`. - #[test] - fn parse_filters_rejects_no_equals() { - let args = vec!["statusbad".to_string()]; - let result = parse_filters(&args); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("key=value"), - "error should mention expected format, got: {msg}" - ); - } - - /// `parse_filters` returns an error for an unknown key. - #[test] - fn parse_filters_rejects_unknown_key() { - let args = vec!["colour=red".to_string()]; - let result = parse_filters(&args); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("colour"), - "error should mention the unknown key, got: {msg}" - ); - } - - /// `parse_filters` treats everything after the first `=` as the value. - #[test] - fn parse_filters_value_contains_equals() { - let args = vec!["title=foo=bar".to_string()]; - let filter = parse_filters(&args).expect("should succeed"); - assert_eq!(filter.title, ["foo=bar"]); - } - - /// `parse_filters` correctly populates the `status` and `ticket_type` vecs. - #[test] - fn parse_filters_basic() { - let args = vec!["status=todo".to_string(), "type=bug".to_string()]; - let filter = parse_filters(&args).expect("should succeed"); - assert_eq!(filter.status, ["todo"]); - assert_eq!(filter.ticket_type, ["bug"]); - assert!(filter.priority.is_empty()); - assert!(filter.title.is_empty()); - } - - // ── glob_matches ────────────────────────────────────────────────────── - - /// `*` matches any non-empty string. - #[test] - fn glob_star_matches_anything() { - assert!(glob_matches("*", "anything")); - } - - /// `*` matches the empty string. - #[test] - fn glob_star_matches_empty() { - assert!(glob_matches("*", "")); - } - - /// An exact pattern matches identical input. - #[test] - fn glob_exact_match() { - assert!(glob_matches("todo", "todo")); - } - - /// An exact pattern does not match a different string. - #[test] - fn glob_exact_no_match() { - assert!(!glob_matches("todo", "done")); - } - - /// `*command*` matches a string that contains "command". - #[test] - fn glob_contains_match() { - assert!(glob_matches("*command*", "add command here")); - } - - /// `*command*` does not match a string that lacks "command". - #[test] - fn glob_contains_no_match() { - assert!(!glob_matches("*command*", "no match")); - } - - /// `in_*` matches a string that starts with "in_". - #[test] - fn glob_prefix_match() { - assert!(glob_matches("in_*", "in_progress")); - } - - /// `in_*` does not match a string that does not start with "in_". - #[test] - fn glob_prefix_no_match() { - assert!(!glob_matches("in_*", "todo")); - } - - // ── TicketFilter::matches ───────────────────────────────────────────── - - /// An empty filter matches every ticket. - #[test] - fn empty_filter_matches_everything() { - let filter = TicketFilter::default(); - let ticket = make_ticket(Status::Todo, TicketType::Bug, 5, "Any title"); - assert!(filter.matches(&ticket)); - } - - /// Different keys are ANDed: both must match. - #[test] - fn different_keys_are_anded() { - // Filter: type=bug AND status=todo - let args = vec!["type=bug".to_string(), "status=todo".to_string()]; - let filter = parse_filters(&args).unwrap(); - - // Matches: bug + todo - let ticket = make_ticket(Status::Todo, TicketType::Bug, 5, "A bug"); - assert!(filter.matches(&ticket)); - - // Doesn't match: feature + todo (wrong type) - let ticket2 = make_ticket(Status::Todo, TicketType::Feature, 5, "A feature"); - assert!(!filter.matches(&ticket2)); - - // Doesn't match: bug + done (wrong status) - let ticket3 = make_ticket(Status::Done, TicketType::Bug, 5, "Done bug"); - assert!(!filter.matches(&ticket3)); - } - - /// Same key with multiple values are ORed: either match passes. - #[test] - fn same_key_is_ored() { - // Filter: status=todo OR status=in_progress - let args = vec!["status=todo".to_string(), "status=in_progress".to_string()]; - let filter = parse_filters(&args).unwrap(); - - let todo_ticket = make_ticket(Status::Todo, TicketType::Task, 5, "Todo"); - let inprogress_ticket = make_ticket(Status::InProgress, TicketType::Task, 5, "In progress"); - let done_ticket = make_ticket(Status::Done, TicketType::Task, 5, "Done"); - - assert!(filter.matches(&todo_ticket)); - assert!(filter.matches(&inprogress_ticket)); - assert!(!filter.matches(&done_ticket)); - } - - // ── TicketFilter::is_empty ───────────────────────────────────────────── - - /// `is_empty` returns `true` for a default filter. - #[test] - fn is_empty_true_when_no_filters() { - let filter = TicketFilter::default(); - assert!(filter.is_empty()); - } - - /// `is_empty` returns `false` when any filter is set. - #[test] - fn is_empty_false_when_filter_set() { - let mut filter = TicketFilter::default(); - filter.status.push("todo".to_string()); - assert!(!filter.is_empty()); - } - - // ── TicketFilter::has_status_filter ─────────────────────────────────── - - /// `has_status_filter` returns `false` for a default filter. - #[test] - fn has_status_filter_false_by_default() { - let filter = TicketFilter::default(); - assert!(!filter.has_status_filter()); - } - - /// `has_status_filter` returns `true` when a status pattern is present. - #[test] - fn has_status_filter_true_when_status_set() { - let args = vec!["status=todo".to_string()]; - let filter = parse_filters(&args).unwrap(); - assert!(filter.has_status_filter()); - } - - /// `has_status_filter` returns `false` when only non-status filters are set. - #[test] - fn has_status_filter_false_when_only_type_set() { - let args = vec!["type=bug".to_string()]; - let filter = parse_filters(&args).unwrap(); - assert!(!filter.has_status_filter()); - } - - // ── TicketFilter::matches_status ────────────────────────────────────── - - /// `matches_status` returns `true` when the ticket's status matches a pattern. - #[test] - fn matches_status_matches_exact() { - let args = vec!["status=todo".to_string()]; - let filter = parse_filters(&args).unwrap(); - let ticket = make_ticket(Status::Todo, TicketType::Task, 5, "A ticket"); - assert!(filter.matches_status(&ticket)); - } - - /// `matches_status` returns `false` when the ticket's status does not match. - #[test] - fn matches_status_no_match() { - let args = vec!["status=done".to_string()]; - let filter = parse_filters(&args).unwrap(); - let ticket = make_ticket(Status::Todo, TicketType::Task, 5, "A ticket"); - assert!(!filter.matches_status(&ticket)); - } - - /// `matches_status` with `status=*` matches any status including `closed` and `backlog`. - #[test] - fn matches_status_wildcard_matches_all() { - let args = vec!["status=*".to_string()]; - let filter = parse_filters(&args).unwrap(); - assert!(filter.matches_status(&make_ticket(Status::Todo, TicketType::Task, 5, "T"))); - assert!(filter.matches_status(&make_ticket(Status::InProgress, TicketType::Task, 5, "T"))); - assert!(filter.matches_status(&make_ticket(Status::Done, TicketType::Task, 5, "T"))); - assert!(filter.matches_status(&make_ticket(Status::Closed, TicketType::Task, 5, "T"))); - assert!(filter.matches_status(&make_ticket(Status::Backlog, TicketType::Task, 5, "T"))); - } - - /// `matches_status` with `status=backlog` matches only backlog tickets. - #[test] - fn matches_status_backlog_pattern() { - let args = vec!["status=backlog".to_string()]; - let filter = parse_filters(&args).unwrap(); - assert!(!filter.matches_status(&make_ticket(Status::Todo, TicketType::Task, 5, "T"))); - assert!(!filter.matches_status(&make_ticket(Status::InProgress, TicketType::Task, 5, "T"))); - assert!(!filter.matches_status(&make_ticket(Status::Done, TicketType::Task, 5, "T"))); - assert!(!filter.matches_status(&make_ticket(Status::Closed, TicketType::Task, 5, "T"))); - assert!(filter.matches_status(&make_ticket(Status::Backlog, TicketType::Task, 5, "T"))); - } - - /// `matches_status` with `status=closed` matches only closed tickets. - #[test] - fn matches_status_closed_pattern() { - let args = vec!["status=closed".to_string()]; - let filter = parse_filters(&args).unwrap(); - assert!(!filter.matches_status(&make_ticket(Status::Todo, TicketType::Task, 5, "T"))); - assert!(!filter.matches_status(&make_ticket(Status::InProgress, TicketType::Task, 5, "T"))); - assert!(!filter.matches_status(&make_ticket(Status::Done, TicketType::Task, 5, "T"))); - assert!(filter.matches_status(&make_ticket(Status::Closed, TicketType::Task, 5, "T"))); - } - - /// `matches_status` ORs multiple status patterns. - #[test] - fn matches_status_ored_patterns() { - let args = vec!["status=todo".to_string(), "status=in_progress".to_string()]; - let filter = parse_filters(&args).unwrap(); - assert!(filter.matches_status(&make_ticket(Status::Todo, TicketType::Task, 5, "T"))); - assert!(filter.matches_status(&make_ticket(Status::InProgress, TicketType::Task, 5, "T"))); - assert!(!filter.matches_status(&make_ticket(Status::Done, TicketType::Task, 5, "T"))); - } - - // ── TicketFilter::matches_except_status ─────────────────────────────── - - /// `matches_except_status` with an empty filter always returns `true`. - #[test] - fn matches_except_status_empty_filter_always_true() { - let filter = TicketFilter::default(); - let ticket = make_ticket(Status::Done, TicketType::Bug, 5, "Any"); - assert!(filter.matches_except_status(&ticket)); - } - - /// `matches_except_status` matches on type while ignoring status. - #[test] - fn matches_except_status_checks_type_not_status() { - let args = vec!["type=bug".to_string()]; - let filter = parse_filters(&args).unwrap(); - - // Done bug: type matches even though status is done. - let done_bug = make_ticket(Status::Done, TicketType::Bug, 5, "Bug"); - assert!(filter.matches_except_status(&done_bug)); - - // Todo task: type does not match. - let todo_task = make_ticket(Status::Todo, TicketType::Task, 5, "Task"); - assert!(!filter.matches_except_status(&todo_task)); - } - - /// `matches_except_status` a status-only filter always returns `true`. - #[test] - fn matches_except_status_status_only_filter_returns_true() { - let args = vec!["status=done".to_string()]; - let filter = parse_filters(&args).unwrap(); - // matches_except_status ignores the status group. - let todo_ticket = make_ticket(Status::Todo, TicketType::Task, 5, "T"); - assert!(filter.matches_except_status(&todo_ticket)); - } -} - -// ── graph module ────────────────────────────────────────────────────────────── - -/// Tests for [`crate::graph`]. -mod graph { - use crate::graph::TicketGraph; - use crate::ticket::Ticket; - - /// Build a ticket with a given ID, title, and dependency list. - fn make_ticket(id: &str, deps: &[&str]) -> Ticket { - let mut t = Ticket::new(id.to_string(), format!("Ticket {id}")); - t.dependencies = deps.iter().map(|d| d.to_string()).collect(); - t - } - - /// `build` on an empty slice produces a graph with no nodes. - #[test] - fn build_empty() { - let graph = TicketGraph::build(&[]); - assert!(graph.roots().is_empty()); - assert!(graph.subtree("anything").is_empty()); - } - - /// Two tickets with no dependencies are both roots. - #[test] - fn roots_no_deps() { - let tickets = vec![make_ticket("aaaaaa", &[]), make_ticket("bbbbbb", &[])]; - let graph = TicketGraph::build(&tickets); - let roots = graph.roots(); - assert_eq!(roots.len(), 2); - let ids: Vec<&str> = roots.iter().map(|t| t.id.as_str()).collect(); - assert!(ids.contains(&"aaaaaa")); - assert!(ids.contains(&"bbbbbb")); - } - - /// When B depends on A, only B is a root (B is the goal; A is a prerequisite). - #[test] - fn roots_with_chain() { - let a = make_ticket("aaaaaa", &[]); - let b = make_ticket("bbbbbb", &["aaaaaa"]); - let tickets = vec![a, b]; - let graph = TicketGraph::build(&tickets); - let roots = graph.roots(); - assert_eq!(roots.len(), 1); - assert_eq!(roots[0].id, "bbbbbb"); - } - - /// Roots are sorted by priority descending. - #[test] - fn roots_sorted_by_priority() { - let mut lo = make_ticket("aaaaaa", &[]); - lo.priority = 2; - let mut hi = make_ticket("bbbbbb", &[]); - hi.priority = 8; - let tickets = vec![lo, hi]; - let graph = TicketGraph::build(&tickets); - let roots = graph.roots(); - assert_eq!(roots[0].id, "bbbbbb", "highest priority root first"); - assert_eq!(roots[1].id, "aaaaaa"); - } - - /// `subtree` on a linear chain A → B → C (B depends on A, C depends on B) - /// returns all three IDs when starting from C (the top-level goal). - #[test] - fn subtree_linear_chain() { - let a = make_ticket("aaaaaa", &[]); - let b = make_ticket("bbbbbb", &["aaaaaa"]); - let c = make_ticket("cccccc", &["bbbbbb"]); - let tickets = vec![a, b, c]; - let graph = TicketGraph::build(&tickets); - - let sub = graph.subtree("cccccc"); - assert_eq!(sub.len(), 3, "subtree should include all three tickets"); - assert!(sub.contains(&"aaaaaa")); - assert!(sub.contains(&"bbbbbb")); - assert!(sub.contains(&"cccccc")); - } - - /// `subtree` on a leaf node (no dependencies) returns just that ID. - #[test] - fn subtree_leaf() { - let a = make_ticket("aaaaaa", &[]); - let b = make_ticket("bbbbbb", &["aaaaaa"]); - let tickets = vec![a, b]; - let graph = TicketGraph::build(&tickets); - - let sub = graph.subtree("aaaaaa"); - assert_eq!(sub, vec!["aaaaaa"]); - } - - /// `subtree` on an unknown ID returns an empty vec. - #[test] - fn subtree_unknown_id() { - let tickets = vec![make_ticket("aaaaaa", &[])]; - let graph = TicketGraph::build(&tickets); - assert!(graph.subtree("ffffff").is_empty()); - } - - /// `subtree` does not infinite-loop when the data contains a cycle. - #[test] - fn subtree_cycle_safe() { - let mut a = make_ticket("aaaaaa", &["bbbbbb"]); - a.dependencies = vec!["bbbbbb".to_string()]; - let mut b = make_ticket("bbbbbb", &["aaaaaa"]); - b.dependencies = vec!["aaaaaa".to_string()]; - let tickets = vec![a, b]; - let graph = TicketGraph::build(&tickets); - - // Must not hang; both IDs should appear exactly once. - let sub = graph.subtree("aaaaaa"); - assert!(sub.contains(&"aaaaaa")); - assert!(sub.contains(&"bbbbbb")); - assert_eq!(sub.len(), 2, "each ID should appear exactly once"); - } - - /// `to_json_value` includes all tickets as nodes and all in-graph edges. - #[test] - fn to_json_value_nodes_and_edges() { - let a = make_ticket("aaaaaa", &[]); - let b = make_ticket("bbbbbb", &["aaaaaa"]); - let c = make_ticket("cccccc", &["aaaaaa"]); - let tickets = vec![a, b, c]; - let graph = TicketGraph::build(&tickets); - - let json = graph.to_json_value(); - let nodes = json["nodes"].as_array().unwrap(); - let edges = json["edges"].as_array().unwrap(); - - assert_eq!(nodes.len(), 3, "all three tickets should be nodes"); - assert_eq!(edges.len(), 2, "two edges: aaaaaa→bbbbbb and aaaaaa→cccccc"); - - let node_ids: Vec<&str> = nodes.iter().map(|n| n["id"].as_str().unwrap()).collect(); - assert!(node_ids.contains(&"aaaaaa")); - assert!(node_ids.contains(&"bbbbbb")); - assert!(node_ids.contains(&"cccccc")); - - // Every edge should have "to" (dependency/prerequisite) = "aaaaaa". - for edge in edges { - assert_eq!(edge["to"].as_str().unwrap(), "aaaaaa"); - } - } - - /// `to_json_value` on an empty graph returns empty `nodes` and `edges`. - #[test] - fn to_json_value_empty() { - let graph = TicketGraph::build(&[]); - let json = graph.to_json_value(); - assert!(json["nodes"].as_array().unwrap().is_empty()); - assert!(json["edges"].as_array().unwrap().is_empty()); - } - - /// `to_subtree_json_value` limits nodes and edges to the reachable subtree. - #[test] - fn to_subtree_json_value_scoped() { - let a = make_ticket("aaaaaa", &[]); - let b = make_ticket("bbbbbb", &["aaaaaa"]); // depends on a - let c = make_ticket("cccccc", &[]); // unrelated - let tickets = vec![a, b, c]; - let graph = TicketGraph::build(&tickets); - - // Starting from bbbbbb (the goal): subtree includes bbbbbb + its dependency aaaaaa. - let json = graph.to_subtree_json_value("bbbbbb"); - let nodes = json["nodes"].as_array().unwrap(); - let node_ids: Vec<&str> = nodes.iter().map(|n| n["id"].as_str().unwrap()).collect(); - - assert!(node_ids.contains(&"bbbbbb"), "root should be included"); - assert!( - node_ids.contains(&"aaaaaa"), - "dependency should be included" - ); - assert!( - !node_ids.contains(&"cccccc"), - "unrelated ticket should be excluded" - ); - } - - /// Dangling dependency references (IDs not in the graph) are silently ignored. - #[test] - fn dangling_deps_ignored() { - let mut a = make_ticket("aaaaaa", &[]); - // "ffffff" does not exist in the graph. - a.dependencies = vec!["ffffff".to_string()]; - let tickets = vec![a]; - let graph = TicketGraph::build(&tickets); - - // aaaaaa has no in-graph dependencies, so it should be a root. - let roots = graph.roots(); - assert_eq!(roots.len(), 1); - assert_eq!(roots[0].id, "aaaaaa"); - - // The JSON should show an empty dependencies list. - let json = graph.to_json_value(); - let deps = json["nodes"][0]["dependencies"].as_array().unwrap(); - assert!(deps.is_empty()); - } -} - -// ── display graph rendering ─────────────────────────────────────────────────── - -/// Tests for the graph rendering functions in [`crate::display`]. -mod display_graph { - use crate::display::{format_graph, format_subtree}; - use crate::graph::TicketGraph; - use crate::ticket::Ticket; - - fn make_ticket(id: &str, deps: &[&str]) -> Ticket { - let mut t = Ticket::new(id.to_string(), format!("Ticket {id}")); - t.dependencies = deps.iter().map(|d| d.to_string()).collect(); - t - } - - /// A single ticket with no deps renders as one line containing its ID, - /// status, and title, with no box-drawing characters. - #[test] - fn single_ticket_no_deps() { - let tickets = vec![make_ticket("aaaaaa", &[])]; - let graph = TicketGraph::build(&tickets); - let out = format_graph(&graph); - assert!(out.contains("aaaaaa"), "should contain ID"); - assert!(out.contains("[todo]"), "should contain status"); - assert!(out.contains("Ticket aaaaaa"), "should contain title"); - assert!(!out.contains("├──"), "should have no branch connectors"); - assert!(!out.contains("└──"), "should have no branch connectors"); - } - - /// A two-ticket chain (B depends on A) renders B at the top level (the goal) - /// and A indented below it with `└──` (the prerequisite). - #[test] - fn two_ticket_chain() { - let a = make_ticket("aaaaaa", &[]); - let b = make_ticket("bbbbbb", &["aaaaaa"]); - let tickets = vec![a, b]; - let graph = TicketGraph::build(&tickets); - let out = format_graph(&graph); - - // B should appear before A (B is the goal, A is the prerequisite). - let pos_a = out.find("aaaaaa").expect("aaaaaa should appear"); - let pos_b = out.find("bbbbbb").expect("bbbbbb should appear"); - assert!( - pos_b < pos_a, - "goal (bbbbbb) should appear before prerequisite (aaaaaa)" - ); - - // A's line should use the └── connector. - assert!(out.contains("└──"), "last (only) child should use └──"); - assert!(!out.contains("├──"), "only child should not use ├──"); - } - - /// When a goal depends on two prerequisites, the first uses `├──` and the last `└──`. - #[test] - fn branching_goal() { - let a = make_ticket("aaaaaa", &[]); - let b = make_ticket("bbbbbb", &[]); - // cccccc depends on both aaaaaa and bbbbbb, so it renders with two children. - let c = make_ticket("cccccc", &["aaaaaa", "bbbbbb"]); - let tickets = vec![a, b, c]; - let graph = TicketGraph::build(&tickets); - let out = format_graph(&graph); - - assert!(out.contains("├──"), "non-last child should use ├──"); - assert!(out.contains("└──"), "last child should use └──"); - } - - /// `format_subtree` for a goal includes that goal and its dependencies, - /// not unrelated tickets. - #[test] - fn subtree_excludes_unrelated() { - let a = make_ticket("aaaaaa", &[]); - let b = make_ticket("bbbbbb", &["aaaaaa"]); - let c = make_ticket("cccccc", &[]); // unrelated - let tickets = vec![a, b, c]; - let graph = TicketGraph::build(&tickets); - - // Starting from bbbbbb (the goal): renders bbbbbb and its dependency aaaaaa. - let out = format_subtree(&graph, "bbbbbb"); - assert!(out.contains("bbbbbb"), "goal should be present"); - assert!(out.contains("aaaaaa"), "dependency should be present"); - assert!(!out.contains("cccccc"), "unrelated ticket should be absent"); - } - - /// `format_subtree` on an unknown ID returns an empty string. - #[test] - fn subtree_unknown_id_empty() { - let tickets = vec![make_ticket("aaaaaa", &[])]; - let graph = TicketGraph::build(&tickets); - assert!(format_subtree(&graph, "ffffff").is_empty()); - } - - /// When the data contains a cycle, the repeated node is marked with `*` - /// and the output is finite (no infinite loop). - /// - /// A pure cycle (A depends on B, B depends on A) has no roots, so we use - /// `format_subtree` to trigger rendering from one of the cyclic nodes. - #[test] - fn cycle_labelled() { - let mut a = make_ticket("aaaaaa", &[]); - a.dependencies = vec!["bbbbbb".to_string()]; - let mut b = make_ticket("bbbbbb", &[]); - b.dependencies = vec!["aaaaaa".to_string()]; - let tickets = vec![a, b]; - let graph = TicketGraph::build(&tickets); - // format_subtree drives rendering from "aaaaaa"; when it tries to - // revisit "aaaaaa" via bbbbbb's dependency edge, it should mark with *. - let out = format_subtree(&graph, "aaaaaa"); - assert!( - out.contains(" *"), - "repeated node should be marked with *: {out}" - ); - } - - /// An empty graph renders as an empty string. - #[test] - fn empty_graph() { - let graph = TicketGraph::build(&[]); - assert!(format_graph(&graph).is_empty()); - } -} - -// ── display module ──────────────────────────────────────────────────────────── - -/// Tests for [`crate::display`]. -mod display { - use crate::display::{ - format_diff, format_list, format_list_json, format_migrate_report, - format_migrate_report_json, format_ticket, format_ticket_json, - }; - use crate::store::MigrateReport; - use crate::ticket::{Status, Ticket, TicketType}; - - /// Build a fully-populated ticket for use in display tests. - fn sample_ticket() -> Ticket { - Ticket { - id: "a3f9c2".to_string(), - title: "Fix login bug".to_string(), - body: "Users cannot log in with email addresses containing +".to_string(), - priority: 8, - status: Status::InProgress, - dependencies: vec!["b7d41e".to_string(), "c9e823".to_string()], - ticket_type: TicketType::Bug, - } - } - - /// `format_ticket` includes all field values in its output. - #[test] - fn format_ticket_contains_all_fields() { - let t = sample_ticket(); - let output = format_ticket(&t); - assert!(output.contains("a3f9c2"), "should contain ID"); - assert!(output.contains("Fix login bug"), "should contain title"); - assert!( - output.contains("Users cannot log in"), - "should contain body" - ); - assert!(output.contains('8'), "should contain priority"); - assert!(output.contains("in_progress"), "should contain status"); - assert!(output.contains("bug"), "should contain ticket type"); - assert!(output.contains("b7d41e"), "should contain first dependency"); - assert!( - output.contains("c9e823"), - "should contain second dependency" - ); - } - - /// `format_ticket` renders dependency IDs as a TOML array. - #[test] - fn format_ticket_joins_dependencies() { - let t = sample_ticket(); - let output = format_ticket(&t); - assert!( - output.contains("b7d41e") && output.contains("c9e823"), - "dependencies should appear in frontmatter: {output}" - ); - // TOML array format: dependencies = ["b7d41e", "c9e823"] - assert!( - output.contains("dependencies"), - "dependencies key should appear: {output}" - ); - } - - /// `format_ticket` renders a `dependencies` key even when there are none. - #[test] - fn format_ticket_empty_dependencies() { - let t = Ticket::new("aaaaaa".to_string(), "No deps".to_string()); - let output = format_ticket(&t); - assert!( - output.contains("dependencies"), - "dependencies key should always appear in frontmatter" - ); - } - - /// `format_ticket_json` produces valid, parseable JSON containing key fields. - /// - /// Even though [`crate::ticket::Ticket::id`] is annotated with `#[serde(skip)]` - /// (to avoid storing it in files), the display layer re-injects `id` so that - /// CLI `--json` output is self-contained for machine consumers. - #[test] - fn format_ticket_json_is_valid_json() { - let t = sample_ticket(); - let output = format_ticket_json(&t); - let parsed: serde_json::Value = - serde_json::from_str(&output).expect("output should be valid JSON"); - assert_eq!(parsed["id"], "a3f9c2"); - assert_eq!(parsed["title"], "Fix login bug"); - assert_eq!(parsed["priority"], 8); - assert_eq!(parsed["status"], "in_progress"); - assert_eq!(parsed["ticket_type"], "bug"); - } - - /// `format_list` includes a header row with all column names. - #[test] - fn format_list_shows_header() { - let output = format_list(&[]); - assert!(output.contains("ID"), "header should contain ID"); - assert!(output.contains("PRI"), "header should contain PRI"); - assert!(output.contains("TYPE"), "header should contain TYPE"); - assert!(output.contains("STATUS"), "header should contain STATUS"); - assert!(output.contains("TITLE"), "header should contain TITLE"); - } - - /// `format_list` includes every ticket's key fields. - #[test] - fn format_list_contains_all_tickets() { - let mut t1 = Ticket::new("id0001".to_string(), "First ticket".to_string()); - t1.priority = 9; - let mut t2 = Ticket::new("id0002".to_string(), "Second ticket".to_string()); - t2.priority = 3; - let tickets = vec![t1, t2]; - - let output = format_list(&tickets); - assert!(output.contains("id0001"), "should contain first ID"); - assert!( - output.contains("First ticket"), - "should contain first title" - ); - assert!(output.contains("id0002"), "should contain second ID"); - assert!( - output.contains("Second ticket"), - "should contain second title" - ); - } - - /// `format_list` renders the correct status and type strings. - #[test] - fn format_list_renders_status_and_type_strings() { - let t = sample_ticket(); // status: in_progress, type: bug - let output = format_list(&[t]); - assert!( - output.contains("in_progress"), - "should render status string" - ); - assert!(output.contains("bug"), "should render type string"); - } - - /// `format_list_json` produces a valid JSON array with one object per ticket, - /// with `id` explicitly included in each object for machine consumers. - #[test] - fn format_list_json_is_valid_json_array() { - let tickets = vec![ - Ticket::new("id0001".to_string(), "First".to_string()), - Ticket::new("id0002".to_string(), "Second".to_string()), - ]; - let output = format_list_json(&tickets); - let parsed: serde_json::Value = - serde_json::from_str(&output).expect("output should be valid JSON"); - assert!(parsed.is_array(), "output should be a JSON array"); - assert_eq!(parsed.as_array().unwrap().len(), 2); - assert_eq!(parsed[0]["id"], "id0001"); - assert_eq!(parsed[1]["id"], "id0002"); - } - - /// `format_list_json` returns an empty JSON array for an empty slice. - #[test] - fn format_list_json_empty_slice() { - let output = format_list_json(&[]); - let parsed: serde_json::Value = - serde_json::from_str(&output).expect("output should be valid JSON"); - assert!(parsed.is_array()); - assert!(parsed.as_array().unwrap().is_empty()); - } - - /// `format_migrate_report` includes counts for all three categories. - #[test] - fn format_migrate_report_contains_counts() { - let report = MigrateReport { - updated: 3, - already_current: 5, - skipped: 0, - errors: vec![("bad.json".to_string(), "parse error".to_string())], - }; - let output = format_migrate_report(&report); - assert!(output.contains('3'), "should mention updated count"); - assert!(output.contains('5'), "should mention already_current count"); - assert!(output.contains("bad.json"), "should mention error filename"); - assert!( - output.contains("parse error"), - "should mention error message" - ); - } - - /// `format_migrate_report` with no errors omits the Errors line. - #[test] - fn format_migrate_report_no_errors() { - let report = MigrateReport { - updated: 1, - already_current: 2, - skipped: 0, - errors: vec![], - }; - let output = format_migrate_report(&report); - assert!( - !output.contains("Errors"), - "should not show Errors when none" - ); - } - - /// `format_migrate_report` shows a Skipped line when `skipped > 0`. - #[test] - fn format_migrate_report_shows_skipped_when_nonzero() { - let report = MigrateReport { - updated: 1, - already_current: 2, - skipped: 3, - errors: vec![], - }; - let output = format_migrate_report(&report); - assert!( - output.contains("Skipped"), - "should show Skipped line when skipped > 0" - ); - assert!(output.contains('3'), "should include skipped count"); - } - - /// `format_migrate_report` omits the Skipped line when `skipped == 0`. - #[test] - fn format_migrate_report_omits_skipped_when_zero() { - let report = MigrateReport { - updated: 1, - already_current: 2, - skipped: 0, - errors: vec![], - }; - let output = format_migrate_report(&report); - assert!( - !output.contains("Skipped"), - "should not show Skipped when skipped == 0" - ); - } - - /// `format_migrate_report_json` includes a `skipped` key. - #[test] - fn format_migrate_report_json_includes_skipped() { - let report = MigrateReport { - updated: 1, - already_current: 2, - skipped: 4, - errors: vec![], - }; - let output = format_migrate_report_json(&report); - let parsed: serde_json::Value = - serde_json::from_str(&output).expect("output should be valid JSON"); - assert_eq!(parsed["skipped"], 4, "JSON should contain skipped count"); - } - - /// `format_migrate_report_json` produces valid JSON with the expected keys. - #[test] - fn format_migrate_report_json_is_valid() { - let report = MigrateReport { - updated: 2, - already_current: 4, - skipped: 0, - errors: vec![("err.json".to_string(), "bad".to_string())], - }; - let output = format_migrate_report_json(&report); - let parsed: serde_json::Value = - serde_json::from_str(&output).expect("output should be valid JSON"); - assert_eq!(parsed["updated"], 2); - assert_eq!(parsed["already_current"], 4); - assert!(parsed["errors"].is_array()); - assert_eq!(parsed["errors"].as_array().unwrap().len(), 1); - assert_eq!(parsed["errors"][0]["filename"], "err.json"); - assert_eq!(parsed["errors"][0]["message"], "bad"); - } - - // ── format_diff ─────────────────────────────────────────────────────────── - - /// `format_diff` returns `"(no changes)"` when old and new are identical. - #[test] - fn format_diff_no_changes() { - let t = sample_ticket(); - let output = format_diff(&t, &t); - assert_eq!(output, "(no changes)"); - } - - /// `format_diff` shows `- old` and `+ new` lines only for changed fields. - #[test] - fn format_diff_shows_changed_fields_only() { - let old = sample_ticket(); // status: in_progress - let mut new = sample_ticket(); - new.status = Status::Done; - let output = format_diff(&old, &new); - assert!( - output.contains("- status:"), - "should show old status line: {output}" - ); - assert!( - output.contains("+ status:"), - "should show new status line: {output}" - ); - assert!( - output.contains("in_progress"), - "should show old status value: {output}" - ); - assert!( - output.contains("done"), - "should show new status value: {output}" - ); - // Unchanged fields must not appear. - assert!( - !output.contains("title:"), - "unchanged title should not appear: {output}" - ); - assert!( - !output.contains("priority:"), - "unchanged priority should not appear: {output}" - ); - } - - /// `format_diff` shows multiple changed fields when several differ. - #[test] - fn format_diff_multiple_changed_fields() { - let old = sample_ticket(); - let mut new = sample_ticket(); - new.status = Status::Done; - new.priority = 3; - let output = format_diff(&old, &new); - assert!(output.contains("status:"), "status diff should appear"); - assert!(output.contains("priority:"), "priority diff should appear"); - // title unchanged — must not appear. - assert!( - !output.contains("title:"), - "unchanged title should not appear: {output}" - ); - } - - /// `format_diff` renders dependency changes as comma-separated lists. - #[test] - fn format_diff_dependencies_comma_separated() { - let mut old = sample_ticket(); // deps: b7d41e, c9e823 - let mut new = sample_ticket(); - old.dependencies = vec!["aaaaaa".to_string()]; - new.dependencies = vec!["bbbbbb".to_string(), "cccccc".to_string()]; - let output = format_diff(&old, &new); - assert!( - output.contains("dependencies:"), - "dependencies diff should appear: {output}" - ); - assert!(output.contains("aaaaaa"), "old dep should appear: {output}"); - assert!( - output.contains("bbbbbb, cccccc"), - "new deps should be comma-separated: {output}" - ); - } -} diff --git a/nbd/src/ticket.rs b/nbd/src/ticket.rs deleted file mode 100644 index e171ece..0000000 --- a/nbd/src/ticket.rs +++ /dev/null @@ -1,165 +0,0 @@ -//! Core ticket data model. -//! -//! Defines the [`Ticket`] struct along with the [`Status`] and [`TicketType`] -//! enums that describe a ticket's lifecycle state and category. -//! -//! ID generation ([`generate_id`]) and priority validation -//! ([`validate_priority`]) are also provided here. - -use std::collections::hash_map::RandomState; -use std::hash::{BuildHasher, Hasher}; - -use serde::{Deserialize, Serialize}; - -/// The lifecycle status of a ticket. -/// -/// Serializes to/from lowercase snake_case strings so that JSON files are -/// human-readable: `"todo"`, `"in_progress"`, `"done"`, `"closed"`, -/// `"archived"`, `"backlog"`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] -#[serde(rename_all = "snake_case")] -pub enum Status { - /// The ticket has not been started yet. - #[default] - Todo, - /// The ticket is actively being worked on. - InProgress, - /// The ticket has been completed. - Done, - /// The ticket will not be completed (cancelled, superseded, won't-fix). - /// - /// Closed tickets count as resolved for dependency purposes — a dependent - /// ticket whose dependency is `closed` becomes unblocked. They are - /// excluded from normal `nbd list` output. - /// - /// Use `nbd update --status closed` to set this status, or pass - /// `--filter status=closed` (or `--all`) to make them visible in listings. - Closed, - /// The ticket was completed and soft-deleted from the active view. - /// - /// Set by `nbd archive `. Archived tickets count as resolved for - /// dependency purposes — a dependent ticket whose dependency is `archived` - /// becomes unblocked. They are excluded from normal `nbd list` output. - /// - /// Use `--filter status=archived` or `--all` to make them visible again. - Archived, - /// The ticket is created but intentionally deferred. - /// - /// Backlog tickets are excluded from `nbd list`, `nbd ready`, and - /// `nbd next` by default. Unlike `done`, `closed`, and `archived`, they - /// do **not** count as resolved for dependency purposes — a dependent - /// ticket whose dependency is `backlog` is still blocked. - /// - /// Use `--filter status=backlog` or `--all` to make them visible. - Backlog, -} - -/// The category of a ticket. -/// -/// Serializes to/from lowercase strings: `"project"`, `"feature"`, `"task"`, -/// `"bug"`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] -#[serde(rename_all = "lowercase")] -pub enum TicketType { - /// A high-level grouping of related work. - Project, - /// A new capability or user-facing behaviour to be added. - Feature, - /// A discrete, self-contained unit of work. - #[default] - Task, - /// A defect or unintended behaviour to be fixed. - Bug, -} - -/// A single work ticket. -/// -/// Tickets are identified by a 6-character lowercase hex string and stored as -/// JSON files at `.nbd/tickets/{id}.json` relative to the project root. -/// -/// The `id` field is **not** stored in the JSON file — the filename stem is -/// the sole source of truth. After deserialising, callers in [`crate::store`] -/// inject the correct id from the filename. -/// -/// Use [`Ticket::new`] to create a ticket with sensible defaults, then -/// customise individual fields before persisting with `store::write_ticket`. -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct Ticket { - /// Unique 6-character lowercase hex identifier, e.g. `"a3f9c2"`. - /// - /// Not serialised to JSON — the filename stem is the canonical source of - /// truth and this field is populated by [`crate::store`] at read time. - #[serde(skip)] - pub id: String, - /// Short, human-readable summary of the work to be done. - pub title: String, - /// Optional long-form description. Empty string when not provided. - pub body: String, - /// Relative importance on a scale of 0–10, inclusive. Default is `5`. - /// Higher values indicate greater urgency or value. - pub priority: u8, - /// Current lifecycle phase of the ticket. - pub status: Status, - /// IDs of tickets that must be completed before this one can start. - pub dependencies: Vec, - /// Broad category of the work described by this ticket. - pub ticket_type: TicketType, -} - -impl Ticket { - /// Create a new ticket with the given `id` and `title`. - /// - /// All remaining fields are initialised to their defaults: - /// - `body` — empty string - /// - `priority` — `5` - /// - `status` — [`Status::Todo`] - /// - `dependencies` — empty `Vec` - /// - `ticket_type` — [`TicketType::Task`] - pub fn new(id: String, title: String) -> Self { - Ticket { - id, - title, - body: String::new(), - priority: 5, - status: Status::default(), - dependencies: Vec::new(), - ticket_type: TicketType::default(), - } - } -} - -/// Validate that `priority` is within the allowed range of `0..=10`. -/// -/// Returns `Ok(())` when valid, or an `Err` with a descriptive message when -/// the value exceeds `10`. -pub fn validate_priority(priority: u8) -> Result<(), String> { - if priority > 10 { - Err(format!( - "priority must be between 0 and 10 inclusive, got {priority}" - )) - } else { - Ok(()) - } -} - -/// Generate a random 6-character lowercase hex ticket ID. -/// -/// Internally uses [`RandomState`], which is seeded with OS randomness on -/// every construction, to produce 3 pseudo-random bytes that are formatted as -/// a 6-character hex string. -/// -/// The result is always exactly 6 characters and contains only the characters -/// `0–9` and `a–f`. -pub fn generate_id() -> String { - // RandomState::new() is seeded with OS randomness on each call, so the - // resulting hasher produces different output every time even with the - // same input data. - let state = RandomState::new(); - let mut hasher = state.build_hasher(); - // Write a fixed byte so the hasher runs through its full compression - // function rather than relying solely on the finalisation step. - hasher.write_u8(0); - let hash = hasher.finish(); - // Mask to 24 bits (3 bytes) → 6 hex characters. - format!("{:06x}", hash & 0x00FF_FFFF) -} diff --git a/nbd/tests/integration.rs b/nbd/tests/integration.rs deleted file mode 100644 index 8859742..0000000 --- a/nbd/tests/integration.rs +++ /dev/null @@ -1,2401 +0,0 @@ -//! Integration tests for `nbd`. -//! -//! Tests full command flows (create → read → list → update) against a real -//! temporary directory and verifies that directory traversal correctly locates -//! `.nbd/` when the binary is run from a nested subdirectory. - -use std::fs; -use std::path::{Path, PathBuf}; -use tempfile::TempDir; - -// ── Test environment helper ─────────────────────────────────────────────────── - -/// A temporary project environment with `.nbd/tickets/` already initialised. -struct TestEnv { - /// Keep `TempDir` alive — dropping it would delete the directory. - _tmp: TempDir, - /// The project root (the directory that contains `.nbd/`). - pub root: PathBuf, -} - -impl TestEnv { - /// Create a fresh temporary environment with `.nbd/tickets/` ready. - fn new() -> Self { - let tmp = tempfile::tempdir().expect("failed to create tempdir"); - let root = tmp.path().to_path_buf(); - fs::create_dir_all(root.join(".nbd").join("tickets")) - .expect("failed to create .nbd/tickets/"); - TestEnv { _tmp: tmp, root } - } - - /// Spawn `nbd` with `args`, using `dir` as the working directory. - fn run_from(&self, dir: &Path, args: &[&str]) -> std::process::Output { - std::process::Command::new(env!("CARGO_BIN_EXE_nbd")) - .args(args) - .current_dir(dir) - .output() - .expect("failed to spawn nbd") - } - - /// Spawn `nbd` with `args` from the project root. - fn run(&self, args: &[&str]) -> std::process::Output { - self.run_from(&self.root, args) - } - - /// Run `nbd create --json` with the given extra arguments and return the - /// ticket ID extracted from JSON stdout. - fn create(&self, extra_args: &[&str]) -> String { - let mut args = vec!["create"]; - args.extend_from_slice(extra_args); - args.push("--json"); - let output = self.run(&args); - assert!( - output.status.success(), - "create failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = - serde_json::from_str(&stdout).expect("create --json output should be valid JSON"); - parsed["id"] - .as_str() - .expect("create --json output should have 'id' field") - .to_string() - } -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -/// `create` then `read` produces a ticket with the expected field values. -#[test] -fn create_and_read_roundtrip() { - let env = TestEnv::new(); - - let id = env.create(&[ - "--title", - "Fix login bug", - "--body", - "Users cannot log in with email addresses containing +", - "--priority", - "8", - "--type", - "bug", - ]); - - let read = env.run(&["read", &id]); - assert!( - read.status.success(), - "read failed: {}", - String::from_utf8_lossy(&read.stderr) - ); - let stdout = String::from_utf8(read.stdout).unwrap(); - assert!(stdout.contains(&id), "output should contain the ID"); - assert!( - stdout.contains("Fix login bug"), - "output should contain the title" - ); - assert!( - stdout.contains("Users cannot log in"), - "output should contain the body" - ); - assert!(stdout.contains('8'), "output should contain the priority"); - assert!(stdout.contains("bug"), "output should contain the type"); -} - -/// `list` displays all previously created tickets. -#[test] -fn list_shows_created_tickets() { - let env = TestEnv::new(); - - env.create(&["--title", "First ticket"]); - env.create(&["--title", "Second ticket"]); - - let list = env.run(&["list"]); - assert!(list.status.success()); - let stdout = String::from_utf8(list.stdout).unwrap(); - assert!( - stdout.contains("First ticket"), - "list should contain first ticket" - ); - assert!( - stdout.contains("Second ticket"), - "list should contain second ticket" - ); -} - -/// `update` changes only the specified fields; others are preserved. -#[test] -fn update_merges_correctly() { - let env = TestEnv::new(); - - let id = env.create(&[ - "--title", - "Original title", - "--priority", - "5", - "--type", - "task", - ]); - - // Update only the status; title and priority should be unchanged. - let update = env.run(&["update", &id, "--status", "in_progress"]); - assert!( - update.status.success(), - "update failed: {}", - String::from_utf8_lossy(&update.stderr) - ); - - let read = env.run(&["read", &id]); - let stdout = String::from_utf8(read.stdout).unwrap(); - assert!( - stdout.contains("in_progress"), - "status should be updated to in_progress" - ); - assert!( - stdout.contains("Original title"), - "title should be unchanged" - ); - assert!(stdout.contains('5'), "priority should be unchanged"); -} - -/// `read` works when executed from a nested subdirectory (traversal test). -#[test] -fn traversal_from_subdir() { - let env = TestEnv::new(); - - let id = env.create(&["--title", "Traversal test"]); - - // Create a nested subdirectory two levels deep. - let subdir = env.root.join("sub").join("dir"); - fs::create_dir_all(&subdir).unwrap(); - - let read = env.run_from(&subdir, &["read", &id]); - assert!( - read.status.success(), - "traversal failed: {}", - String::from_utf8_lossy(&read.stderr) - ); - let stdout = String::from_utf8(read.stdout).unwrap(); - assert!( - stdout.contains("Traversal test"), - "output should contain the title" - ); -} - -/// `read` with an unknown ID exits non-zero and mentions the ID in stderr. -#[test] -fn error_on_unknown_id() { - let env = TestEnv::new(); - - let result = env.run(&["read", "ffffff"]); - assert!( - !result.status.success(), - "reading an unknown ID should exit non-zero" - ); - let stderr = String::from_utf8(result.stderr).unwrap(); - assert!( - stderr.contains("ffffff"), - "error message should mention the ticket ID, got: {stderr}" - ); -} - -/// `create --json` outputs valid JSON containing the correct field values. -#[test] -fn create_with_json_flag() { - let env = TestEnv::new(); - - let output = env.run(&[ - "create", - "--title", - "JSON test", - "--priority", - "7", - "--json", - ]); - assert!(output.status.success()); - - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = - serde_json::from_str(&stdout).expect("--json output should be valid JSON"); - assert_eq!(parsed["title"], "JSON test"); - assert_eq!(parsed["priority"], 7); -} - -/// `list --json` outputs a valid JSON array with one object per ticket. -#[test] -fn list_with_json_flag() { - let env = TestEnv::new(); - - env.create(&["--title", "First"]); - env.create(&["--title", "Second"]); - - let output = env.run(&["list", "--json"]); - assert!(output.status.success()); - - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = - serde_json::from_str(&stdout).expect("--json list output should be valid JSON"); - assert!(parsed.is_array(), "output should be a JSON array"); - assert_eq!( - parsed.as_array().unwrap().len(), - 2, - "array should contain exactly two tickets" - ); -} - -/// `create` writes a JSON file that does NOT contain an `"id"` key. -#[test] -fn created_file_omits_id_field() { - let env = TestEnv::new(); - - let id = env.create(&["--title", "ID key check"]); - - // Read the raw file bytes and check the JSON. - let ticket_file = env - .root - .join(".nbd") - .join("tickets") - .join(format!("{id}.json")); - let contents = fs::read_to_string(&ticket_file).expect("ticket file should exist"); - let parsed: serde_json::Value = - serde_json::from_str(&contents).expect("ticket file should be valid JSON"); - assert!( - parsed.get("id").is_none(), - "written ticket file must not contain an 'id' key, got: {contents}" - ); -} - -/// `read --json` outputs the correct id even though the file has no `id` field. -#[test] -fn read_json_contains_correct_id() { - let env = TestEnv::new(); - - let id = env.create(&["--title", "ID injection check"]); - - let output = env.run(&["read", &id, "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = - serde_json::from_str(&stdout).expect("--json read output should be valid JSON"); - assert_eq!( - parsed["id"], id, - "read --json output must contain the correct id" - ); -} - -/// `migrate` re-writes old-format files that contain a stale `"id"` key. -#[test] -fn migrate_rewrites_old_format_files() { - let env = TestEnv::new(); - - // Manually write an old-format ticket file with "id" in the JSON body. - let old_json = r#"{"id":"abcdef","title":"Old format ticket","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#; - let ticket_file = env.root.join(".nbd").join("tickets").join("abcdef.json"); - fs::write(&ticket_file, old_json).unwrap(); - - let output = env.run(&["migrate"]); - assert!( - output.status.success(), - "migrate failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - - // Verify the file no longer contains the "id" key. - let contents = fs::read_to_string(&ticket_file).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap(); - assert!( - parsed.get("id").is_none(), - "migrated file must not contain 'id', got: {contents}" - ); -} - -/// `migrate --dry-run` does not modify files. -#[test] -fn migrate_dry_run_does_not_write() { - let env = TestEnv::new(); - - let old_json = r#"{"id":"112233","title":"Dry run test","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#; - let ticket_file = env.root.join(".nbd").join("tickets").join("112233.json"); - fs::write(&ticket_file, old_json).unwrap(); - - let output = env.run(&["migrate", "--dry-run"]); - assert!(output.status.success()); - - // File must remain unchanged. - let contents = fs::read_to_string(&ticket_file).unwrap(); - assert_eq!(contents, old_json, "dry-run must not modify files"); -} - -/// `migrate` exits zero even when some ticket files cannot be parsed. -#[test] -fn migrate_exits_zero_on_parse_errors() { - let env = TestEnv::new(); - - let bad_json = b"{ not valid json at all }"; - let ticket_file = env.root.join(".nbd").join("tickets").join("badbad.json"); - fs::write(&ticket_file, bad_json).unwrap(); - - let output = env.run(&["migrate"]); - assert!( - output.status.success(), - "migrate should exit zero even with parse errors" - ); - - // File must remain unchanged. - let contents = fs::read(&ticket_file).unwrap(); - assert_eq!( - contents.as_slice(), - bad_json, - "errored file must be left unchanged" - ); -} - -/// `migrate --json` outputs valid JSON with the expected keys. -#[test] -fn migrate_with_json_flag() { - let env = TestEnv::new(); - - // Write one old-format and one valid ticket. - env.create(&["--title", "Normal ticket"]); - let old_json = r#"{"id":"xxyyzz","title":"Legacy","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#; - let ticket_file = env.root.join(".nbd").join("tickets").join("xxyyzz.json"); - fs::write(&ticket_file, old_json).unwrap(); - - let output = env.run(&["migrate", "--json"]); - assert!(output.status.success()); - - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = - serde_json::from_str(&stdout).expect("--json output should be valid JSON"); - assert!( - parsed.get("updated").is_some(), - "JSON should have 'updated' key" - ); - assert!( - parsed.get("already_current").is_some(), - "JSON should have 'already_current' key" - ); - assert!( - parsed.get("errors").is_some(), - "JSON should have 'errors' key" - ); - assert_eq!(parsed["updated"], 1, "one file should be migrated"); - assert_eq!(parsed["already_current"], 1, "one file should be current"); -} - -/// `read` accepts a unique prefix instead of a full 6-char ID. -#[test] -fn read_with_prefix() { - let env = TestEnv::new(); - let id = env.create(&["--title", "Prefix read test"]); - - // Use a 3-char prefix. - let prefix = &id[..3]; - let read = env.run(&["read", prefix]); - assert!( - read.status.success(), - "read with prefix failed: {}", - String::from_utf8_lossy(&read.stderr) - ); - let stdout = String::from_utf8(read.stdout).unwrap(); - assert!( - stdout.contains("Prefix read test"), - "output should contain the title" - ); -} - -/// `update` accepts a unique prefix instead of a full 6-char ID. -#[test] -fn update_with_prefix() { - let env = TestEnv::new(); - let id = env.create(&["--title", "Prefix update test"]); - - let prefix = &id[..3]; - let update = env.run(&["update", prefix, "--status", "done"]); - assert!( - update.status.success(), - "update with prefix failed: {}", - String::from_utf8_lossy(&update.stderr) - ); - - let read = env.run(&["read", &id, "--json"]); - let stdout = String::from_utf8(read.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - assert_eq!(parsed["status"], "done", "status should be updated to done"); -} - -/// An ambiguous prefix exits non-zero with an informative message. -#[test] -fn ambiguous_prefix_exits_nonzero() { - let env = TestEnv::new(); - - // We can't reliably generate two IDs that share a 3-char prefix, but we - // can manually write two ticket files whose names share a prefix. - let ticket_dir = env.root.join(".nbd").join("tickets"); - let json = r#"{"title":"A","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#; - fs::write(ticket_dir.join("ff0001.json"), json).unwrap(); - fs::write(ticket_dir.join("ff0002.json"), json).unwrap(); - - let result = env.run(&["read", "ff0"]); - assert!( - !result.status.success(), - "ambiguous prefix should exit non-zero" - ); - let stderr = String::from_utf8(result.stderr).unwrap(); - assert!( - stderr.contains("ambiguous"), - "error should say ambiguous, got: {stderr}" - ); -} - -/// `nbd init` creates `.nbd/tickets/` in a fresh directory. -#[test] -fn init_creates_tickets_dir() { - let tmp = tempfile::tempdir().expect("failed to create tempdir"); - let root = tmp.path(); - - let output = std::process::Command::new(env!("CARGO_BIN_EXE_nbd")) - .args(["init"]) - .current_dir(root) - .output() - .expect("failed to spawn nbd"); - - assert!( - output.status.success(), - "init failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert!( - root.join(".nbd").join("tickets").is_dir(), - ".nbd/tickets/ should be created" - ); -} - -/// `nbd init` is idempotent — running it twice succeeds. -#[test] -fn init_is_idempotent() { - let tmp = tempfile::tempdir().expect("failed to create tempdir"); - let root = tmp.path(); - - for _ in 0..2 { - let output = std::process::Command::new(env!("CARGO_BIN_EXE_nbd")) - .args(["init"]) - .current_dir(root) - .output() - .expect("failed to spawn nbd"); - assert!( - output.status.success(), - "init failed on repeat: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - assert!(root.join(".nbd").join("tickets").is_dir()); -} - -/// `nbd init --json` outputs valid JSON with a `root` field. -#[test] -fn init_with_json_flag() { - let tmp = tempfile::tempdir().expect("failed to create tempdir"); - let root = tmp.path(); - - let output = std::process::Command::new(env!("CARGO_BIN_EXE_nbd")) - .args(["init", "--json"]) - .current_dir(root) - .output() - .expect("failed to spawn nbd"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = - serde_json::from_str(&stdout).expect("--json output should be valid JSON"); - assert!( - parsed.get("root").is_some(), - "JSON should have 'root' field, got: {stdout}" - ); -} - -/// `nbd ready` returns only unblocked, non-done tickets. -#[test] -fn ready_returns_unblocked_tickets() { - let env = TestEnv::new(); - - // A: no deps, todo → ready - let a = env.create(&["--title", "Ticket A"]); - // B: depends on A, todo → not ready - env.run(&["create", "--title", "Ticket B", "--deps", &a, "--json"]); - // C: no deps, done → not ready (already done) - let c = env.create(&["--title", "Ticket C"]); - env.run(&["update", &c, "--status", "done"]); - - let output = env.run(&["ready", "--json"]); - assert!( - output.status.success(), - "ready failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("should be valid JSON"); - let arr = parsed.as_array().expect("should be array"); - assert_eq!(arr.len(), 1, "only A should be ready"); - assert_eq!(arr[0]["title"], "Ticket A"); -} - -/// `nbd ready` includes ticket B after its dependency A is marked done. -#[test] -fn ready_updates_after_dep_done() { - let env = TestEnv::new(); - - let a = env.create(&["--title", "Dep A"]); - env.run(&["create", "--title", "Blocked B", "--deps", &a, "--json"]); - - // Before marking A done: only A is ready. - let before = env.run(&["ready", "--json"]); - let before_str = String::from_utf8(before.stdout).unwrap(); - let before_parsed: serde_json::Value = serde_json::from_str(&before_str).unwrap(); - assert_eq!(before_parsed.as_array().unwrap().len(), 1); - - // Mark A done. - env.run(&["update", &a, "--status", "done"]); - - // Now B is ready. - let after = env.run(&["ready", "--json"]); - let after_str = String::from_utf8(after.stdout).unwrap(); - let after_parsed: serde_json::Value = serde_json::from_str(&after_str).unwrap(); - let arr = after_parsed.as_array().unwrap(); - assert_eq!(arr.len(), 1, "only B should be ready now"); - assert_eq!(arr[0]["title"], "Blocked B"); -} - -/// `nbd ready` returns an empty array when no tickets are actionable. -#[test] -fn ready_empty_when_all_done() { - let env = TestEnv::new(); - - let id = env.create(&["--title", "Finished"]); - env.run(&["update", &id, "--status", "done"]); - - let output = env.run(&["ready", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - assert_eq!( - parsed.as_array().unwrap().len(), - 0, - "no ready tickets when all are done" - ); -} - -/// `update --deps` replaces the dependency list. -#[test] -fn update_deps_replaces_list() { - let env = TestEnv::new(); - - // Create two tickets to use as deps. - let dep1 = env.create(&["--title", "Dep one"]); - let dep2 = env.create(&["--title", "Dep two"]); - let main_id = env.create(&["--title", "Main ticket"]); - - // Add dep1 as a dependency. - let update = env.run(&["update", &main_id, "--deps", &dep1]); - assert!( - update.status.success(), - "update with deps failed: {}", - String::from_utf8_lossy(&update.stderr) - ); - - // Replace with dep2. - let update2 = env.run(&["update", &main_id, "--deps", &dep2]); - assert!(update2.status.success()); - - let read = env.run(&["read", &main_id, "--json"]); - let stdout = String::from_utf8(read.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let deps = parsed["dependencies"].as_array().unwrap(); - assert_eq!(deps.len(), 1, "should have exactly one dependency"); - assert_eq!(deps[0], dep2, "dependency should be dep2"); -} - -// ── --filter tests ──────────────────────────────────────────────────────────── - -/// `nbd list --filter type=bug` shows only bug tickets. -#[test] -fn list_filter_by_type() { - let env = TestEnv::new(); - - env.create(&["--title", "Bug ticket", "--type", "bug"]); - env.create(&["--title", "Task ticket", "--type", "task"]); - - let output = env.run(&["list", "--filter", "type=bug", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!(arr.len(), 1, "only the bug ticket should appear"); - assert_eq!(arr[0]["ticket_type"], "bug"); -} - -/// `nbd list --filter status=in_progress` shows only in_progress tickets. -#[test] -fn list_filter_by_status() { - let env = TestEnv::new(); - - let id = env.create(&["--title", "Active ticket"]); - env.create(&["--title", "Todo ticket"]); - env.run(&["update", &id, "--status", "in_progress"]); - - let output = env.run(&["list", "--filter", "status=in_progress", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!(arr.len(), 1, "only in_progress ticket should appear"); - assert_eq!(arr[0]["status"], "in_progress"); -} - -/// Same key repeated is ORed: `--filter status=todo --filter status=in_progress` -/// shows both todo and in_progress tickets. -#[test] -fn list_filter_same_key_ored() { - let env = TestEnv::new(); - - let id = env.create(&["--title", "Active"]); - env.create(&["--title", "Todo"]); - let done_id = env.create(&["--title", "Done"]); - env.run(&["update", &id, "--status", "in_progress"]); - env.run(&["update", &done_id, "--status", "done"]); - - let output = env.run(&[ - "list", - "--filter", - "status=todo", - "--filter", - "status=in_progress", - "--json", - ]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!( - arr.len(), - 2, - "todo and in_progress should appear; done excluded" - ); - let statuses: Vec<&str> = arr.iter().map(|v| v["status"].as_str().unwrap()).collect(); - assert!(statuses.contains(&"todo"), "todo should be in results"); - assert!( - statuses.contains(&"in_progress"), - "in_progress should be in results" - ); -} - -/// Different keys are ANDed: `--filter type=bug --filter status=todo` shows -/// only bug tickets with status todo. -#[test] -fn list_filter_different_keys_anded() { - let env = TestEnv::new(); - - env.create(&["--title", "Bug todo", "--type", "bug"]); - let bug_active = env.create(&["--title", "Bug active", "--type", "bug"]); - env.create(&["--title", "Task todo", "--type", "task"]); - env.run(&["update", &bug_active, "--status", "in_progress"]); - - let output = env.run(&[ - "list", - "--filter", - "type=bug", - "--filter", - "status=todo", - "--json", - ]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!(arr.len(), 1, "only bug+todo should appear"); - assert_eq!(arr[0]["title"], "Bug todo"); -} - -/// `nbd list --filter title=*login*` matches by glob on the title field. -#[test] -fn list_filter_title_glob() { - let env = TestEnv::new(); - - env.create(&["--title", "Fix login button"]); - env.create(&["--title", "Add rate limiting"]); - - let output = env.run(&["list", "--filter", "title=*login*", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!(arr.len(), 1, "only the login ticket should match"); - assert_eq!(arr[0]["title"], "Fix login button"); -} - -/// `nbd list --filter status=*` wildcard matches all statuses. -#[test] -fn list_filter_status_wildcard() { - let env = TestEnv::new(); - - let id = env.create(&["--title", "A"]); - env.create(&["--title", "B"]); - env.run(&["update", &id, "--status", "done"]); - - let output = env.run(&["list", "--filter", "status=*", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!(arr.len(), 2, "wildcard should match all statuses"); -} - -/// `nbd list --filter badformat` (no `=`) exits non-zero. -#[test] -fn list_filter_bad_format_exits_nonzero() { - let env = TestEnv::new(); - - let output = env.run(&["list", "--filter", "badformat"]); - assert!( - !output.status.success(), - "missing '=' in filter should exit non-zero" - ); -} - -/// `nbd list --filter unknown=foo` (unknown key) exits non-zero. -#[test] -fn list_filter_unknown_key_exits_nonzero() { - let env = TestEnv::new(); - - let output = env.run(&["list", "--filter", "colour=red"]); - assert!( - !output.status.success(), - "unknown filter key should exit non-zero" - ); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!( - stderr.contains("colour"), - "error should mention the unknown key, got: {stderr}" - ); -} - -/// `nbd ready --filter type=bug` returns only ready bug tickets. -#[test] -fn ready_filter_by_type() { - let env = TestEnv::new(); - - env.create(&["--title", "Ready bug", "--type", "bug"]); - env.create(&["--title", "Ready task", "--type", "task"]); - - let output = env.run(&["ready", "--filter", "type=bug", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!(arr.len(), 1, "only the bug ticket should appear in ready"); - assert_eq!(arr[0]["ticket_type"], "bug"); -} - -/// `nbd ready --filter priority=8` returns only ready tickets with priority 8. -#[test] -fn ready_filter_by_priority() { - let env = TestEnv::new(); - - env.create(&["--title", "High prio", "--priority", "8"]); - env.create(&["--title", "Normal prio", "--priority", "5"]); - - let output = env.run(&["ready", "--filter", "priority=8", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!(arr.len(), 1, "only priority-8 ticket should appear"); - assert_eq!(arr[0]["priority"], 8); -} - -/// `nbd migrate --filter status=todo --dry-run --json` reports skipped count -/// for tickets that do not match the filter. -#[test] -fn migrate_filter_skipped_in_json() { - let env = TestEnv::new(); - - env.create(&["--title", "Todo ticket"]); - let id2 = env.create(&["--title", "Active ticket"]); - env.run(&["update", &id2, "--status", "in_progress"]); - - let output = env.run(&["migrate", "--filter", "status=todo", "--dry-run", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = - serde_json::from_str(&stdout).expect("migrate --json should produce valid JSON"); - assert!( - parsed.get("skipped").is_some(), - "JSON should have 'skipped' key" - ); - assert_eq!( - parsed["skipped"], 1, - "one ticket (in_progress) should be skipped" - ); -} - -/// `nbd migrate --json` always includes a `skipped` key (even when zero). -#[test] -fn migrate_json_always_has_skipped_key() { - let env = TestEnv::new(); - - env.create(&["--title", "Some ticket"]); - - let output = env.run(&["migrate", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - assert!( - parsed.get("skipped").is_some(), - "JSON should always contain 'skipped' key" - ); - assert_eq!(parsed["skipped"], 0); -} - -/// `nbd ready --filter badformat` exits non-zero. -#[test] -fn ready_filter_bad_format_exits_nonzero() { - let env = TestEnv::new(); - - let output = env.run(&["ready", "--filter", "noequals"]); - assert!( - !output.status.success(), - "bad filter format on ready should exit non-zero" - ); -} - -// ── done-exclusion default behaviour ────────────────────────────────────────── - -/// `nbd list` without any filter excludes done tickets. -#[test] -fn list_excludes_done_by_default() { - let env = TestEnv::new(); - - let todo_id = env.create(&["--title", "Todo ticket"]); - let done_id = env.create(&["--title", "Done ticket"]); - env.run(&["update", &done_id, "--status", "done"]); - - let output = env.run(&["list", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!(arr.len(), 1, "only the todo ticket should appear"); - assert_eq!(arr[0]["id"], todo_id); -} - -/// `nbd list --filter status=done` shows only done tickets. -#[test] -fn list_filter_status_done_shows_only_done() { - let env = TestEnv::new(); - - env.create(&["--title", "Todo ticket"]); - let done_id = env.create(&["--title", "Done ticket"]); - env.run(&["update", &done_id, "--status", "done"]); - - let output = env.run(&["list", "--filter", "status=done", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!(arr.len(), 1, "only the done ticket should appear"); - assert_eq!(arr[0]["id"], done_id); -} - -/// `nbd list --filter status=*` includes done tickets. -#[test] -fn list_filter_status_wildcard_includes_done() { - let env = TestEnv::new(); - - env.create(&["--title", "Todo ticket"]); - let done_id = env.create(&["--title", "Done ticket"]); - env.run(&["update", &done_id, "--status", "done"]); - - let output = env.run(&["list", "--filter", "status=*", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!(arr.len(), 2, "both tickets should appear with status=*"); -} - -/// `nbd list --filter type=bug` excludes done bug tickets. -#[test] -fn list_filter_type_still_excludes_done() { - let env = TestEnv::new(); - - env.create(&["--title", "Bug todo", "--type", "bug"]); - let done_bug = env.create(&["--title", "Bug done", "--type", "bug"]); - env.create(&["--title", "Task todo", "--type", "task"]); - env.run(&["update", &done_bug, "--status", "done"]); - - let output = env.run(&["list", "--filter", "type=bug", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!(arr.len(), 1, "only non-done bug ticket should appear"); - assert_eq!(arr[0]["title"], "Bug todo"); -} - -/// `nbd list --filter type=bug --filter status=*` includes all bug tickets. -#[test] -fn list_filter_type_and_status_wildcard_includes_done_bugs() { - let env = TestEnv::new(); - - env.create(&["--title", "Bug todo", "--type", "bug"]); - let done_bug = env.create(&["--title", "Bug done", "--type", "bug"]); - env.run(&["update", &done_bug, "--status", "done"]); - - let output = env.run(&[ - "list", "--filter", "type=bug", "--filter", "status=*", "--json", - ]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!( - arr.len(), - 2, - "both bug tickets should appear when status=* is set" - ); -} - -// ── nbd archive tests ───────────────────────────────────────────────────────── - -/// `nbd archive ` sets the ticket status to `archived`. -#[test] -fn archive_sets_status_archived() { - let env = TestEnv::new(); - - let id = env.create(&["--title", "Archive me"]); - - let output = env.run(&["archive", &id, "--json"]); - assert!( - output.status.success(), - "archive failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("should be valid JSON"); - assert_eq!( - parsed["status"], "archived", - "archive should set status to archived" - ); -} - -/// `nbd list` does not show archived tickets by default. -#[test] -fn list_excludes_archived_by_default() { - let env = TestEnv::new(); - - let todo_id = env.create(&["--title", "Active ticket"]); - let archived_id = env.create(&["--title", "Archived ticket"]); - env.run(&["archive", &archived_id]); - - let output = env.run(&["list", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!(arr.len(), 1, "only the active ticket should appear"); - assert_eq!(arr[0]["id"], todo_id); -} - -/// `nbd list` does not show closed tickets by default. -#[test] -fn list_excludes_closed_by_default() { - let env = TestEnv::new(); - - let todo_id = env.create(&["--title", "Active ticket"]); - let closed_id = env.create(&["--title", "Closed ticket"]); - env.run(&["update", &closed_id, "--status", "closed"]); - - let output = env.run(&["list", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!(arr.len(), 1, "only the active ticket should appear"); - assert_eq!(arr[0]["id"], todo_id); -} - -/// `nbd list --all` includes archived and closed tickets. -#[test] -fn list_all_includes_archived_and_closed() { - let env = TestEnv::new(); - - env.create(&["--title", "Active ticket"]); - let archived_id = env.create(&["--title", "Archived ticket"]); - env.run(&["archive", &archived_id]); - let closed_id = env.create(&["--title", "Closed ticket"]); - env.run(&["update", &closed_id, "--status", "closed"]); - - let output = env.run(&["list", "--all", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!( - arr.len(), - 3, - "--all should show all tickets including archived and closed" - ); -} - -/// `nbd list --filter status=archived` shows only archived tickets. -#[test] -fn list_filter_status_archived_shows_only_archived() { - let env = TestEnv::new(); - - env.create(&["--title", "Active ticket"]); - let archived_id = env.create(&["--title", "Archived ticket"]); - env.run(&["archive", &archived_id]); - - let output = env.run(&["list", "--filter", "status=archived", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!(arr.len(), 1, "only the archived ticket should appear"); - assert_eq!(arr[0]["id"], archived_id); -} - -/// `nbd list --filter status=closed` shows only closed tickets. -#[test] -fn list_filter_status_closed_shows_only_closed() { - let env = TestEnv::new(); - - env.create(&["--title", "Active ticket"]); - let closed_id = env.create(&["--title", "Closed ticket"]); - env.run(&["update", &closed_id, "--status", "closed"]); - - let output = env.run(&["list", "--filter", "status=closed", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!(arr.len(), 1, "only the closed ticket should appear"); - assert_eq!(arr[0]["id"], closed_id); -} - -/// `nbd archive` with a prefix ID resolves and archives the ticket. -#[test] -fn archive_accepts_prefix() { - let env = TestEnv::new(); - - let id = env.create(&["--title", "Prefix archive"]); - let prefix = &id[..3]; - - let output = env.run(&["archive", prefix, "--json"]); - assert!( - output.status.success(), - "archive with prefix failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - assert_eq!(parsed["status"], "archived"); -} - -/// An archived dependency unblocks dependent tickets (archived counts as resolved). -#[test] -fn archived_dep_unblocks_dependent() { - let env = TestEnv::new(); - - let dep = env.create(&["--title", "Dep ticket"]); - env.run(&["create", "--title", "Dependent", "--deps", &dep, "--json"]); - - // Archive the dependency. - env.run(&["archive", &dep]); - - // The dependent should now be ready. - let output = env.run(&["ready", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!( - arr.len(), - 1, - "dependent should be ready after dep is archived" - ); - assert_eq!(arr[0]["title"], "Dependent"); -} - -/// A closed dependency unblocks dependent tickets (closed counts as resolved). -#[test] -fn closed_dep_unblocks_dependent() { - let env = TestEnv::new(); - - let dep = env.create(&["--title", "Dep ticket"]); - env.run(&["create", "--title", "Dependent", "--deps", &dep, "--json"]); - - // Close (won't-fix) the dependency. - env.run(&["update", &dep, "--status", "closed"]); - - // The dependent should now be ready. - let output = env.run(&["ready", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!( - arr.len(), - 1, - "dependent should be ready after dep is closed" - ); - assert_eq!(arr[0]["title"], "Dependent"); -} - -/// `nbd ready` does not include archived tickets. -#[test] -fn ready_excludes_archived_tickets() { - let env = TestEnv::new(); - - let id = env.create(&["--title", "Will be archived"]); - env.run(&["archive", &id]); - - let output = env.run(&["ready", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!(arr.len(), 0, "archived tickets should not appear in ready"); -} - -/// `nbd ready` does not include closed tickets. -#[test] -fn ready_excludes_closed_tickets() { - let env = TestEnv::new(); - - let id = env.create(&["--title", "Will be closed"]); - env.run(&["update", &id, "--status", "closed"]); - - let output = env.run(&["ready", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!(arr.len(), 0, "closed tickets should not appear in ready"); -} - -// ── nbd next tests ──────────────────────────────────────────────────────────── - -/// `nbd next --json` returns the highest-priority ready ticket. -/// -/// Setup: A (pri 5, no deps), B (pri 8, dep A → blocked), C (pri 7, no deps). -/// Expected: C is the highest-priority ready ticket (B is blocked by A). -#[test] -fn next_returns_highest_priority_ready() { - let env = TestEnv::new(); - - let a = env.create(&["--title", "Ticket A", "--priority", "5"]); - env.run(&[ - "create", - "--title", - "Ticket B", - "--priority", - "8", - "--deps", - &a, - "--json", - ]); - env.create(&["--title", "Ticket C", "--priority", "7"]); - - let output = env.run(&["next", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = - serde_json::from_str(&stdout).expect("next --json should be valid JSON"); - assert!(parsed.get("next").is_some(), "should have 'next' key"); - let ticket = &parsed["next"]; - assert!(!ticket.is_null(), "next should not be null"); - assert_eq!( - ticket["title"], "Ticket C", - "C has the highest priority among ready tickets" - ); -} - -/// After marking the dependency done, a previously blocked ticket becomes next. -#[test] -fn next_updates_after_dep_done() { - let env = TestEnv::new(); - - let a = env.create(&["--title", "Dep A", "--priority", "5"]); - env.run(&[ - "create", - "--title", - "Blocked B", - "--priority", - "8", - "--deps", - &a, - "--json", - ]); - - // Before A is done: A is next (priority 5, the only ready ticket). - let before = env.run(&["next", "--json"]); - let before_str = String::from_utf8(before.stdout).unwrap(); - let before_parsed: serde_json::Value = serde_json::from_str(&before_str).unwrap(); - assert_eq!(before_parsed["next"]["title"], "Dep A"); - - // Mark A done. - env.run(&["update", &a, "--status", "done"]); - - // Now B (priority 8) is next. - let after = env.run(&["next", "--json"]); - let after_str = String::from_utf8(after.stdout).unwrap(); - let after_parsed: serde_json::Value = serde_json::from_str(&after_str).unwrap(); - assert_eq!(after_parsed["next"]["title"], "Blocked B"); -} - -/// When all tickets are done, `nbd next --json` returns `{"next": null}`. -#[test] -fn next_null_when_all_done() { - let env = TestEnv::new(); - - let id = env.create(&["--title", "Only ticket"]); - env.run(&["update", &id, "--status", "done"]); - - let output = env.run(&["next", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - assert!( - parsed["next"].is_null(), - "next should be null when all are done" - ); -} - -/// `nbd next` without `--json` prints "No ready tickets." and exits 0 when nothing is ready. -#[test] -fn next_no_json_prints_message_when_empty() { - let env = TestEnv::new(); - - let id = env.create(&["--title", "Finished"]); - env.run(&["update", &id, "--status", "done"]); - - let output = env.run(&["next"]); - assert!( - output.status.success(), - "should exit 0 even with no ready tickets" - ); - let stdout = String::from_utf8(output.stdout).unwrap(); - assert!( - stdout.contains("No ready tickets."), - "should print 'No ready tickets.', got: {stdout}" - ); -} - -/// `nbd next --filter type=bug --json` returns the highest-priority ready bug, -/// even when a higher-priority non-bug ticket exists. -#[test] -fn next_filter_by_type() { - let env = TestEnv::new(); - - // Task with priority 9 (highest overall). - env.create(&["--title", "High task", "--priority", "9", "--type", "task"]); - // Bug with priority 8. - env.create(&["--title", "High bug", "--priority", "8", "--type", "bug"]); - - let output = env.run(&["next", "--filter", "type=bug", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - assert_eq!( - parsed["next"]["ticket_type"], "bug", - "filter should restrict to bug tickets" - ); - assert_eq!(parsed["next"]["title"], "High bug"); -} - -/// `nbd next --json` output object always contains an `"id"` field. -#[test] -fn next_json_includes_id_field() { - let env = TestEnv::new(); - - env.create(&["--title", "Has ID"]); - - let output = env.run(&["next", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let ticket = &parsed["next"]; - assert!(!ticket.is_null(), "should find a ready ticket"); - assert!( - ticket.get("id").is_some(), - "ticket object should contain 'id' field" - ); - let id = ticket["id"].as_str().unwrap(); - assert_eq!(id.len(), 6, "id should be 6 characters"); -} - -// ── --ftype format tests ────────────────────────────────────────────────────── - -/// `nbd create --ftype md` writes a `.md` file; `nbd read` finds and parses it. -#[test] -fn create_ftype_md_writes_md_file() { - let env = TestEnv::new(); - let output = env.run(&[ - "create", - "--title", - "Markdown ticket", - "--ftype", - "md", - "--json", - ]); - assert!( - output.status.success(), - "create --ftype md failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("should be valid JSON"); - let id = parsed["id"].as_str().unwrap().to_string(); - - // The .md file must exist; no .json should be created. - assert!( - env.root - .join(".nbd") - .join("tickets") - .join(format!("{id}.md")) - .is_file(), - ".md file should exist" - ); - assert!( - !env.root - .join(".nbd") - .join("tickets") - .join(format!("{id}.json")) - .is_file(), - ".json file should NOT exist" - ); - - // `nbd read` should find the ticket via auto-detection. - let read = env.run(&["read", &id, "--json"]); - assert!(read.status.success(), "read should succeed for .md ticket"); - let read_out: serde_json::Value = - serde_json::from_str(&String::from_utf8(read.stdout).unwrap()).unwrap(); - assert_eq!(read_out["title"], "Markdown ticket"); -} - -/// `nbd create --ftype toml` writes a `.toml` file readable by `nbd read`. -#[test] -fn create_ftype_toml_writes_toml_file() { - let env = TestEnv::new(); - let output = env.run(&[ - "create", - "--title", - "TOML ticket", - "--ftype", - "toml", - "--json", - ]); - assert!( - output.status.success(), - "create --ftype toml failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let id = parsed["id"].as_str().unwrap().to_string(); - - assert!( - env.root - .join(".nbd") - .join("tickets") - .join(format!("{id}.toml")) - .is_file(), - ".toml file should exist" - ); - - let read = env.run(&["read", &id, "--json"]); - assert!(read.status.success()); - let read_out: serde_json::Value = - serde_json::from_str(&String::from_utf8(read.stdout).unwrap()).unwrap(); - assert_eq!(read_out["title"], "TOML ticket"); -} - -/// `nbd create --ftype jsonb` writes a `.jsonb` file readable by `nbd read`. -#[test] -fn create_ftype_jsonb_writes_jsonb_file() { - let env = TestEnv::new(); - let output = env.run(&[ - "create", - "--title", - "CBOR ticket", - "--ftype", - "jsonb", - "--json", - ]); - assert!( - output.status.success(), - "create --ftype jsonb failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let id = parsed["id"].as_str().unwrap().to_string(); - - assert!( - env.root - .join(".nbd") - .join("tickets") - .join(format!("{id}.jsonb")) - .is_file(), - ".jsonb file should exist" - ); - - let read = env.run(&["read", &id, "--json"]); - assert!(read.status.success()); - let read_out: serde_json::Value = - serde_json::from_str(&String::from_utf8(read.stdout).unwrap()).unwrap(); - assert_eq!(read_out["title"], "CBOR ticket"); -} - -/// `nbd list` shows tickets in all formats. -#[test] -fn list_shows_mixed_format_tickets() { - let env = TestEnv::new(); - - env.create(&["--title", "JSON ticket"]); - env.run(&["create", "--title", "MD ticket", "--ftype", "md", "--json"]); - env.run(&[ - "create", - "--title", - "TOML ticket", - "--ftype", - "toml", - "--json", - ]); - - let output = env.run(&["list", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!(arr.len(), 3, "list should show all three tickets"); - let titles: Vec<&str> = arr.iter().map(|v| v["title"].as_str().unwrap()).collect(); - assert!(titles.contains(&"JSON ticket")); - assert!(titles.contains(&"MD ticket")); - assert!(titles.contains(&"TOML ticket")); -} - -/// `nbd update --ftype toml` converts a JSON ticket to TOML and removes -/// the old `.json` file. -#[test] -fn update_ftype_converts_format_and_removes_old_file() { - let env = TestEnv::new(); - let id = env.create(&["--title", "Convert me"]); - - // Confirm the .json file exists before conversion. - let json_path = env - .root - .join(".nbd") - .join("tickets") - .join(format!("{id}.json")); - assert!(json_path.is_file(), ".json should exist initially"); - - // Convert to TOML. - let update = env.run(&["update", &id, "--ftype", "toml", "--json"]); - assert!( - update.status.success(), - "update --ftype toml failed: {}", - String::from_utf8_lossy(&update.stderr) - ); - - // Old .json must be gone; .toml must exist. - assert!(!json_path.is_file(), "old .json should be removed"); - let toml_path = env - .root - .join(".nbd") - .join("tickets") - .join(format!("{id}.toml")); - assert!(toml_path.is_file(), "new .toml should exist"); - - // The ticket should still be readable. - let read = env.run(&["read", &id, "--json"]); - assert!(read.status.success()); - let read_out: serde_json::Value = - serde_json::from_str(&String::from_utf8(read.stdout).unwrap()).unwrap(); - assert_eq!(read_out["title"], "Convert me"); -} - -/// `nbd update` without `--ftype` preserves the original format. -#[test] -fn update_without_ftype_preserves_format() { - let env = TestEnv::new(); - let output = env.run(&[ - "create", - "--title", - "Stay TOML", - "--ftype", - "toml", - "--json", - ]); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let id = parsed["id"].as_str().unwrap().to_string(); - - // Update status only — no --ftype. - let update = env.run(&["update", &id, "--status", "in_progress"]); - assert!(update.status.success()); - - // The .toml file should still exist (not converted to .json). - let toml_path = env - .root - .join(".nbd") - .join("tickets") - .join(format!("{id}.toml")); - assert!(toml_path.is_file(), ".toml should still exist after update"); - let json_path = env - .root - .join(".nbd") - .join("tickets") - .join(format!("{id}.json")); - assert!(!json_path.is_file(), ".json should not appear after update"); -} - -/// Markdown ticket body is preserved through a read/write cycle. -#[test] -fn markdown_body_roundtrip() { - let env = TestEnv::new(); - let body = "## Overview\n\nThis ticket has a **markdown** body."; - let output = env.run(&[ - "create", - "--title", - "MD body test", - "--body", - body, - "--ftype", - "md", - "--json", - ]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let id = parsed["id"].as_str().unwrap().to_string(); - - let read = env.run(&["read", &id, "--json"]); - assert!(read.status.success()); - let read_out: serde_json::Value = - serde_json::from_str(&String::from_utf8(read.stdout).unwrap()).unwrap(); - assert_eq!(read_out["body"].as_str().unwrap(), body); -} - -/// `nbd create --ftype badformat` exits non-zero with a helpful message. -#[test] -fn create_unknown_ftype_exits_nonzero() { - let env = TestEnv::new(); - let output = env.run(&["create", "--title", "Bad", "--ftype", "xml"]); - assert!( - !output.status.success(), - "unknown ftype should exit non-zero" - ); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!( - stderr.contains("xml") || stderr.contains("format"), - "error should mention format, got: {stderr}" - ); -} - -// ── nbd claude-md tests ─────────────────────────────────────────────────────── - -/// `nbd claude-md` exits zero and stdout is non-empty. -#[test] -fn claude_md_exits_zero_with_output() { - let tmp = tempfile::tempdir().expect("failed to create tempdir"); - let output = std::process::Command::new(env!("CARGO_BIN_EXE_nbd")) - .args(["claude-md"]) - .current_dir(tmp.path()) - .output() - .expect("failed to spawn nbd"); - - assert!( - output.status.success(), - "claude-md should exit zero, stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert!( - !output.stdout.is_empty(), - "claude-md stdout should be non-empty" - ); -} - -/// `nbd claude-md` stdout contains key strings (`nbd`, `--json`). -#[test] -fn claude_md_contains_key_content() { - let tmp = tempfile::tempdir().expect("failed to create tempdir"); - let output = std::process::Command::new(env!("CARGO_BIN_EXE_nbd")) - .args(["claude-md"]) - .current_dir(tmp.path()) - .output() - .expect("failed to spawn nbd"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - assert!( - stdout.contains("nbd"), - "output should mention 'nbd', got: {stdout}" - ); - assert!( - stdout.contains("--json"), - "output should mention '--json', got: {stdout}" - ); -} - -/// `nbd claude-md --json` exits zero and stdout is valid JSON with a `snippet` key. -#[test] -fn claude_md_json_flag_produces_valid_json() { - let tmp = tempfile::tempdir().expect("failed to create tempdir"); - let output = std::process::Command::new(env!("CARGO_BIN_EXE_nbd")) - .args(["claude-md", "--json"]) - .current_dir(tmp.path()) - .output() - .expect("failed to spawn nbd"); - - assert!( - output.status.success(), - "claude-md --json should exit zero, stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = - serde_json::from_str(&stdout).expect("claude-md --json output should be valid JSON"); - assert!( - parsed.get("snippet").is_some(), - "JSON should have a 'snippet' key, got: {stdout}" - ); - let snippet = parsed["snippet"].as_str().unwrap(); - assert!(!snippet.is_empty(), "snippet value should be non-empty"); -} - -/// `nbd claude-md` works even from a directory with no `.nbd/` store. -#[test] -fn claude_md_works_without_nbd_store() { - let tmp = tempfile::tempdir().expect("failed to create tempdir"); - // Deliberately do NOT create a .nbd/ directory. - let output = std::process::Command::new(env!("CARGO_BIN_EXE_nbd")) - .args(["claude-md"]) - .current_dir(tmp.path()) - .output() - .expect("failed to spawn nbd"); - - assert!( - output.status.success(), - "claude-md should succeed even without a .nbd/ store, stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert!( - !output.stdout.is_empty(), - "claude-md should produce output even without .nbd/" - ); -} - -/// `nbd next --filter priority=99 --json` returns `{"next": null}` when no -/// ticket has the requested priority. -#[test] -fn next_filter_no_match_returns_null() { - let env = TestEnv::new(); - - env.create(&["--title", "Normal ticket", "--priority", "5"]); - - let output = env.run(&["next", "--filter", "priority=99", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - assert!( - parsed["next"].is_null(), - "no ticket has priority 99 so next should be null" - ); -} - -// ── graph tests ─────────────────────────────────────────────────────────────── - -/// `nbd graph` on an empty store exits 0 and produces an empty (or -/// whitespace-only) line. -#[test] -fn graph_empty_store() { - let env = TestEnv::new(); - let output = env.run(&["graph"]); - assert!( - output.status.success(), - "graph on empty store should exit 0: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -/// `nbd graph` with two independent tickets (no deps) shows both IDs with no -/// box-drawing indentation on either line. -#[test] -fn graph_all_no_deps() { - let env = TestEnv::new(); - let id_a = env.create(&["--title", "Alpha"]); - let id_b = env.create(&["--title", "Beta"]); - - let output = env.run(&["graph"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - - assert!(stdout.contains(&id_a), "output should contain id_a"); - assert!(stdout.contains(&id_b), "output should contain id_b"); - // Neither ticket should have a connector (they are both roots). - let line_a = stdout.lines().find(|l| l.contains(&id_a)).unwrap(); - let line_b = stdout.lines().find(|l| l.contains(&id_b)).unwrap(); - assert!( - !line_a.contains("├──") && !line_a.contains("└──"), - "root ticket should have no connector: {line_a}" - ); - assert!( - !line_b.contains("├──") && !line_b.contains("└──"), - "root ticket should have no connector: {line_b}" - ); -} - -/// When B depends on A, `nbd graph` shows B at the top level (the goal) and A -/// indented below it with `└──` (the prerequisite). -#[test] -fn graph_chain() { - let env = TestEnv::new(); - let id_a = env.create(&["--title", "Alpha"]); - let id_b = env.create(&["--title", "Beta", "--deps", &id_a]); - - let output = env.run(&["graph"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - - // B should appear before A (B is the goal, A is the prerequisite). - let pos_a = stdout.find(&id_a).expect("id_a not found"); - let pos_b = stdout.find(&id_b).expect("id_b not found"); - assert!( - pos_b < pos_a, - "goal (B) should appear before prerequisite (A)" - ); - - // A's line should use `└──`. - let line_a = stdout.lines().find(|l| l.contains(&id_a)).unwrap(); - assert!( - line_a.contains("└──"), - "prerequisite should use └── connector: {line_a}" - ); -} - -/// `nbd graph ` for ticket B (which depends on A) shows B and its -/// dependency A, but not unrelated tickets. -#[test] -fn graph_single_ticket_subtree() { - let env = TestEnv::new(); - let id_a = env.create(&["--title", "Alpha"]); - let id_b = env.create(&["--title", "Beta", "--deps", &id_a]); - let id_c = env.create(&["--title", "Gamma"]); // unrelated - - let output = env.run(&["graph", &id_b]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - - assert!(stdout.contains(&id_b), "goal should be in subtree"); - assert!(stdout.contains(&id_a), "dependency should be in subtree"); - assert!( - !stdout.contains(&id_c), - "unrelated ticket should be absent: {stdout}" - ); -} - -/// `nbd graph --json` produces valid JSON with `nodes` and `edges` arrays. -#[test] -fn graph_json_output() { - let env = TestEnv::new(); - let id_a = env.create(&["--title", "Alpha"]); - let id_b = env.create(&["--title", "Beta", "--deps", &id_a]); - - let output = env.run(&["graph", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = - serde_json::from_str(&stdout).expect("--json should produce valid JSON"); - - let nodes = parsed["nodes"] - .as_array() - .expect("nodes should be an array"); - let edges = parsed["edges"] - .as_array() - .expect("edges should be an array"); - - let node_ids: Vec<&str> = nodes.iter().map(|n| n["id"].as_str().unwrap()).collect(); - assert!( - node_ids.contains(&id_a.as_str()), - "nodes should include id_a" - ); - assert!( - node_ids.contains(&id_b.as_str()), - "nodes should include id_b" - ); - assert_eq!( - edges.len(), - 1, - "should have exactly one edge (B depends on A)" - ); - assert_eq!(edges[0]["from"].as_str().unwrap(), id_b); - assert_eq!(edges[0]["to"].as_str().unwrap(), id_a); -} - -/// `nbd graph --json` returns only nodes and edges reachable from -/// via dependency edges (the goal and its transitive prerequisites). -#[test] -fn graph_json_subtree() { - let env = TestEnv::new(); - let id_a = env.create(&["--title", "Alpha"]); - let id_b = env.create(&["--title", "Beta", "--deps", &id_a]); - let id_c = env.create(&["--title", "Gamma"]); // unrelated - - // Starting from id_b (the goal): subtree includes id_b and its dependency id_a. - let output = env.run(&["graph", &id_b, "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - - let nodes = parsed["nodes"].as_array().unwrap(); - let node_ids: Vec<&str> = nodes.iter().map(|n| n["id"].as_str().unwrap()).collect(); - assert!(node_ids.contains(&id_b.as_str())); - assert!(node_ids.contains(&id_a.as_str())); - assert!( - !node_ids.contains(&id_c.as_str()), - "unrelated ticket should be excluded from JSON subtree" - ); -} - -/// `nbd graph --filter type=bug` only includes bug tickets and their dependents. -#[test] -fn graph_filter() { - let env = TestEnv::new(); - let id_bug = env.create(&["--title", "A bug", "--type", "bug"]); - let id_task = env.create(&["--title", "A task", "--type", "task"]); // unrelated - - let output = env.run(&["graph", "--filter", "type=bug"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - - assert!(stdout.contains(&id_bug), "bug ticket should be visible"); - assert!( - !stdout.contains(&id_task), - "task ticket should be excluded: {stdout}" - ); -} - -/// `nbd graph <3-char-prefix>` resolves to the correct ticket using prefix -/// matching. -#[test] -fn graph_partial_id() { - let env = TestEnv::new(); - let id_a = env.create(&["--title", "Alpha"]); - let prefix = &id_a[..3]; - - let output = env.run(&["graph", prefix]); - assert!( - output.status.success(), - "graph with prefix should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8(output.stdout).unwrap(); - assert!( - stdout.contains(&id_a), - "resolved ticket should appear in output" - ); -} - -// ── nbd backlog status tests ────────────────────────────────────────────────── - -/// `nbd create --status backlog` creates a ticket with status `backlog`. -#[test] -fn create_with_backlog_status() { - let env = TestEnv::new(); - - let output = env.run(&[ - "create", - "--title", - "Deferred task", - "--status", - "backlog", - "--json", - ]); - assert!( - output.status.success(), - "create --status backlog failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("should be valid JSON"); - assert_eq!(parsed["status"], "backlog", "status should be backlog"); -} - -/// `nbd list` does not show backlog tickets by default. -#[test] -fn list_excludes_backlog_by_default() { - let env = TestEnv::new(); - - let active_id = env.create(&["--title", "Active ticket"]); - env.run(&[ - "create", - "--title", - "Backlog ticket", - "--status", - "backlog", - "--json", - ]); - - let output = env.run(&["list", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!(arr.len(), 1, "only the active ticket should appear"); - assert_eq!(arr[0]["id"], active_id); -} - -/// `nbd list --all` includes backlog tickets. -#[test] -fn list_all_includes_backlog() { - let env = TestEnv::new(); - - env.create(&["--title", "Active ticket"]); - env.run(&[ - "create", - "--title", - "Backlog ticket", - "--status", - "backlog", - "--json", - ]); - - let output = env.run(&["list", "--all", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!( - arr.len(), - 2, - "--all should show all tickets including backlog" - ); -} - -/// `nbd list --filter status=backlog` shows only backlog tickets. -#[test] -fn list_filter_status_backlog_shows_only_backlog() { - let env = TestEnv::new(); - - env.create(&["--title", "Active ticket"]); - let backlog_output = env.run(&[ - "create", - "--title", - "Backlog ticket", - "--status", - "backlog", - "--json", - ]); - let backlog_stdout = String::from_utf8(backlog_output.stdout).unwrap(); - let backlog_parsed: serde_json::Value = serde_json::from_str(&backlog_stdout).unwrap(); - let backlog_id = backlog_parsed["id"].as_str().unwrap().to_string(); - - let output = env.run(&["list", "--filter", "status=backlog", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!(arr.len(), 1, "only the backlog ticket should appear"); - assert_eq!(arr[0]["id"], backlog_id); -} - -/// `nbd ready` does not include backlog tickets. -#[test] -fn ready_excludes_backlog_tickets() { - let env = TestEnv::new(); - - env.run(&[ - "create", - "--title", - "Backlog item", - "--status", - "backlog", - "--json", - ]); - - let output = env.run(&["ready", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!(arr.len(), 0, "backlog tickets should not appear in ready"); -} - -/// A backlog dependency still blocks a dependent ticket (backlog is NOT resolved). -#[test] -fn backlog_dep_blocks_dependent() { - let env = TestEnv::new(); - - let dep_output = env.run(&[ - "create", - "--title", - "Backlog dep", - "--status", - "backlog", - "--json", - ]); - let dep_stdout = String::from_utf8(dep_output.stdout).unwrap(); - let dep_parsed: serde_json::Value = serde_json::from_str(&dep_stdout).unwrap(); - let dep_id = dep_parsed["id"].as_str().unwrap().to_string(); - - env.run(&[ - "create", - "--title", - "Dependent", - "--deps", - &dep_id, - "--json", - ]); - - // The dependent should NOT be ready because its dep is backlog (not resolved). - let output = env.run(&["ready", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!( - arr.len(), - 0, - "dependent should be blocked when dependency is backlog" - ); -} - -/// `nbd next --json` does not return backlog tickets. -#[test] -fn next_excludes_backlog_tickets() { - let env = TestEnv::new(); - - env.run(&[ - "create", - "--title", - "Backlog item", - "--status", - "backlog", - "--json", - ]); - - let output = env.run(&["next", "--json"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - assert!( - parsed["next"].is_null(), - "backlog ticket should not appear as next: {stdout}" - ); -} - -// ── update diff output tests ────────────────────────────────────────────────── - -/// `nbd update --status in_progress` (no `--json`) prints `- status:` and -/// `+ status:` lines showing what changed. -#[test] -fn update_no_json_prints_diff() { - let env = TestEnv::new(); - let id = env.create(&["--title", "Diff test"]); - - let output = env.run(&["update", &id, "--status", "in_progress"]); - assert!( - output.status.success(), - "update failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8(output.stdout).unwrap(); - assert!( - stdout.contains("- status:"), - "should show old status line: {stdout}" - ); - assert!( - stdout.contains("+ status:"), - "should show new status line: {stdout}" - ); - assert!(stdout.contains("todo"), "should show old value: {stdout}"); - assert!( - stdout.contains("in_progress"), - "should show new value: {stdout}" - ); -} - -/// `nbd update --json` still prints full JSON (no diff). -#[test] -fn update_with_json_flag_prints_full_ticket() { - let env = TestEnv::new(); - let id = env.create(&["--title", "JSON update test"]); - - let output = env.run(&["update", &id, "--status", "in_progress", "--json"]); - assert!( - output.status.success(), - "update --json failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8(output.stdout).unwrap(); - // Must be valid JSON containing the updated ticket. - let parsed: serde_json::Value = - serde_json::from_str(&stdout).expect("--json output should be valid JSON"); - assert_eq!(parsed["status"], "in_progress"); - assert_eq!(parsed["title"], "JSON update test"); - // Should NOT contain diff markers. - assert!( - !stdout.contains("- status:"), - "JSON output should not contain diff markers: {stdout}" - ); -} - -/// `nbd update` with no changes prints `(no changes)`. -#[test] -fn update_no_changes_prints_no_changes() { - let env = TestEnv::new(); - let id = env.create(&["--title", "Unchanged"]); - - // Update with no field changes (supply same status). - let output = env.run(&["update", &id, "--status", "todo"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - assert!( - stdout.contains("(no changes)"), - "should print '(no changes)' when nothing changed: {stdout}" - ); -} - -// ── nbd --version tests ─────────────────────────────────────────────────────── - -/// `nbd --version` exits 0 and stdout contains the semver and a git SHA. -#[test] -fn version_flag_exits_zero_with_semver() { - let tmp = tempfile::tempdir().expect("tempdir"); - let output = std::process::Command::new(env!("CARGO_BIN_EXE_nbd")) - .arg("--version") - .current_dir(tmp.path()) - .output() - .expect("failed to spawn nbd"); - - assert!( - output.status.success(), - "--version should exit 0, stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8(output.stdout).unwrap(); - // Should contain the package version (semver). - assert!( - stdout.contains("0.1.0"), - "--version should include semver: {stdout}" - ); - // Should contain a '+' separator between semver and git SHA. - assert!( - stdout.contains('+'), - "--version should contain '+' separator: {stdout}" - ); -} - -// ── Scoped next / ready tests ───────────────────────────────────────────────── - -/// `nbd next ` returns the highest-priority ready dep within the subtree, -/// not the scoping ticket itself and not any unrelated ticket. -/// -/// Graph: P → [A, B], A → [C]. C is done. So: -/// - A is ready (C is done) -/// - B is ready (no deps) -/// - P is blocked (A and B not done) -/// `nbd next P` with A at priority 8 and B at priority 3 should return A. -#[test] -fn test_next_scoped_by_id() { - let env = TestEnv::new(); - - // Create leaf ticket C (done). - let c_id = env.create(&["--title", "C", "--priority", "5", "--type", "task"]); - env.run(&["update", &c_id, "--status", "done"]); - - // Create A (depends on C, priority 8). - let a_id = env.create(&[ - "--title", - "A", - "--priority", - "8", - "--type", - "task", - "--deps", - &c_id, - ]); - - // Create B (no deps, priority 3). - let b_id = env.create(&["--title", "B", "--priority", "3", "--type", "task"]); - - // Create unrelated ticket U that should never appear. - env.create(&["--title", "Unrelated", "--priority", "10", "--type", "task"]); - - // Create P (project, depends on A and B). - let deps = format!("{a_id},{b_id}"); - let p_id = env.create(&[ - "--title", - "P", - "--priority", - "5", - "--type", - "project", - "--deps", - &deps, - ]); - - // `nbd next P --json` should return A (highest-priority ready dep of P). - let output = env.run(&["next", &p_id, "--json"]); - assert!( - output.status.success(), - "next scoped failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = - serde_json::from_str(&stdout).expect("--json output should be valid JSON"); - - let next_id = parsed["next"]["id"] - .as_str() - .expect("next should be non-null"); - assert_eq!( - next_id, a_id, - "next scoped to P should return A, got {next_id}" - ); - - // P itself and Unrelated must not appear. - assert_ne!(next_id, p_id, "scoping ticket must not be returned"); -} - -/// `nbd ready ` returns all ready deps within the subtree of ``. -/// -/// Graph: P → [A, B], A → [C]. C is done. B has no deps. -/// - A is ready (C done) -/// - B is ready (no deps) -/// `nbd ready P` should return exactly [A, B]. -#[test] -fn test_ready_scoped_by_id() { - let env = TestEnv::new(); - - let c_id = env.create(&["--title", "C", "--priority", "5"]); - env.run(&["update", &c_id, "--status", "done"]); - - let a_id = env.create(&["--title", "A", "--priority", "7", "--deps", &c_id]); - let b_id = env.create(&["--title", "B", "--priority", "4"]); - - // Unrelated ticket with high priority — must not appear. - env.create(&["--title", "Unrelated", "--priority", "10"]); - - let deps = format!("{a_id},{b_id}"); - let p_id = env.create(&["--title", "P", "--type", "project", "--deps", &deps]); - - let output = env.run(&["ready", &p_id, "--json"]); - assert!( - output.status.success(), - "ready scoped failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = - serde_json::from_str(&stdout).expect("--json output should be valid JSON"); - - let arr = parsed - .as_array() - .expect("ready --json should return an array"); - let ids: Vec<&str> = arr.iter().map(|v| v["id"].as_str().unwrap()).collect(); - - assert!( - ids.contains(&a_id.as_str()), - "A should be in scoped ready list" - ); - assert!( - ids.contains(&b_id.as_str()), - "B should be in scoped ready list" - ); - assert!( - !ids.contains(&p_id.as_str()), - "scoping ticket P must not appear in results" - ); - // Unrelated should not appear. - assert_eq!( - ids.len(), - 2, - "exactly A and B should be ready in subtree of P, got: {ids:?}" - ); -} - -/// `nbd next ` returns `null` when all deps of the scoping ticket are done. -#[test] -fn test_next_scoped_no_ready() { - let env = TestEnv::new(); - - let a_id = env.create(&["--title", "A", "--priority", "8"]); - let b_id = env.create(&["--title", "B", "--priority", "5"]); - env.run(&["update", &a_id, "--status", "done"]); - env.run(&["update", &b_id, "--status", "done"]); - - let deps = format!("{a_id},{b_id}"); - let p_id = env.create(&["--title", "P", "--type", "project", "--deps", &deps]); - - let output = env.run(&["next", &p_id, "--json"]); - assert!( - output.status.success(), - "next scoped (no ready) failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = - serde_json::from_str(&stdout).expect("--json output should be valid JSON"); - - assert!( - parsed["next"].is_null(), - "next should be null when all deps are done, got: {}", - parsed["next"] - ); -} - -/// `nbd ready --filter` narrows within the scoped subtree. -/// -/// Graph: P → [A (bug), B (task)]. Both ready. -/// `nbd ready P --filter type=bug` should return only A. -#[test] -fn test_ready_scoped_with_filter() { - let env = TestEnv::new(); - - let a_id = env.create(&["--title", "A", "--priority", "6", "--type", "bug"]); - let b_id = env.create(&["--title", "B", "--priority", "6", "--type", "task"]); - - // Unrelated bug — must not appear even though it matches the filter. - env.create(&[ - "--title", - "Unrelated bug", - "--priority", - "9", - "--type", - "bug", - ]); - - let deps = format!("{a_id},{b_id}"); - let p_id = env.create(&["--title", "P", "--type", "project", "--deps", &deps]); - - let output = env.run(&["ready", &p_id, "--filter", "type=bug", "--json"]); - assert!( - output.status.success(), - "ready scoped+filtered failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8(output.stdout).unwrap(); - let parsed: serde_json::Value = - serde_json::from_str(&stdout).expect("--json output should be valid JSON"); - - let arr = parsed - .as_array() - .expect("ready --json should return an array"); - let ids: Vec<&str> = arr.iter().map(|v| v["id"].as_str().unwrap()).collect(); - - assert_eq!(ids.len(), 1, "only A (bug) should match; got: {ids:?}"); - assert_eq!(ids[0], a_id, "matched ticket should be A"); -}