diff --git a/nbd/.nbd/tickets/0f51af.json b/nbd/.nbd/tickets/0f51af.json new file mode 100644 index 0000000..e5073ac --- /dev/null +++ b/nbd/.nbd/tickets/0f51af.json @@ -0,0 +1,11 @@ +{ + "id": "0f51af", + "title": "nbd migrate command", + "body": "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.\n\n## Motivation\n\nSchema 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:\n- **Removes** fields that no longer exist in `Ticket` (they are ignored on deserialise, then absent on re-serialise).\n- **Adds** new fields with their `#[serde(default)]` values.\n- **Normalises** any formatting differences (e.g. key order, whitespace).\n\nThe current immediate use case is scrubbing the `\"id\"` key from all existing `.json` files after the id-from-filename schema change.\n\n## Design principles\n\n- **Idempotent.** Running `nbd migrate` on an already-current store is a no-op (files are re-written identically).\n- **Non-destructive.** A failure on one ticket does not abort the rest; errors are collected and reported at the end.\n- **Source of truth unchanged.** If a ticket cannot be parsed, it is left on disk as-is and reported as an error.\n- **Dry-run available.** `--dry-run` prints what would change without writing.\n\n## Approach\n\n### main.rs\n\nAdd `Migrate` variant to `Commands`:\n```\nMigrate {\n /// Print changes without writing them.\n #[arg(long)]\n dry_run: bool,\n}\n```\n\nImplement `cmd_migrate(dry_run: bool) -> store::Result<()>`:\n1. `find_nbd_root()`\n2. Call `store::migrate_tickets(&root, dry_run).await`\n3. Print a summary: `Migrated N tickets (M errors)`.\n\n### store.rs\n\nAdd `migrate_tickets(root: &Path, dry_run: bool) -> Result`:\n1. `fs::read_dir(tickets_dir(root))`\n2. For each `*.json`, `*.md`, `*.toml`, `*.jsonb` file (all supported formats — the ones that exist now and future ones added by the multi-format feature):\n a. Read the raw bytes.\n b. Attempt to deserialise into current `Ticket`, injecting `id` from filename.\n c. Re-serialise to the current schema (same format as the original file's extension).\n d. Compare raw bytes. If unchanged, skip (count as already-current).\n e. If changed and `dry_run`: print `would update {filename}`, do not write.\n f. If changed and not `dry_run`: write the new bytes to the same path.\n g. If deserialise fails: record the error, leave the file untouched.\n3. Return `MigrateReport { updated: usize, already_current: usize, errors: Vec<(String, String)> }`.\n\n```rust\npub struct MigrateReport {\n pub updated: usize,\n pub already_current: usize,\n pub errors: Vec<(String, String)>, // (filename, error message)\n}\n```\n\n### display.rs\n\nAdd `print_migrate_report(report: &MigrateReport)`:\n```\nMigrated 3 tickets.\nCurrent 5 tickets (already up to date).\nErrors 1 ticket could not be migrated:\n bad_ticket.json: trailing comma at line 4\n```\n\nWhen `--json`, serialise `MigrateReport` directly (derive `Serialize`).\n\n## How schema changes use this\n\nFor **field removal** (e.g. removing `id` from JSON):\n- Old files have `\"id\": \"...\"` → on deserialise, serde ignores it (unknown field).\n- Re-serialise → `id` is absent (since `#[serde(skip)]`).\n- File bytes differ → `migrate` rewrites.\n\nFor **field addition** (e.g. adding `tags: Vec` later):\n- New field in `Ticket` gets `#[serde(default)]`.\n- Old files lack `tags` → deserialise gives `vec![]`.\n- Re-serialise → `\"tags\": []` is written.\n- File bytes differ → `migrate` rewrites.\n\n## Tests\n\nUnit tests (`src/tests.rs`):\n- `migrate_tickets` on a store with old-format files (containing `\"id\"`) rewrites them without `id`.\n- `migrate_tickets` on an already-current store returns `updated: 0`, `already_current: N`.\n- `migrate_tickets --dry-run` does not modify files on disk.\n- A file with invalid JSON is counted in `errors` and left unchanged.\n\nIntegration tests (`tests/integration.rs`):\n- Create tickets with old code (inject `id` manually into JSON), run `nbd migrate`, verify `id` is gone from files.\n- `nbd migrate --dry-run` reports changes but does not modify files.\n- `nbd migrate` exits zero even when some tickets error (but prints error summary).\n- `nbd migrate --json` outputs a valid JSON object with `updated`, `already_current`, `errors` fields.\n\n## Files touched\n- `src/main.rs` — `Migrate` command, `cmd_migrate`\n- `src/store.rs` — `migrate_tickets`, `MigrateReport`\n- `src/display.rs` — `print_migrate_report`\n- `src/tests.rs` — unit tests\n- `tests/integration.rs` — integration tests\n- `README.md` — document `nbd migrate`", + "priority": 9, + "status": "todo", + "dependencies": [ + "d1634a" + ], + "ticket_type": "feature" +} \ No newline at end of file diff --git a/nbd/.nbd/tickets/1939a7.json b/nbd/.nbd/tickets/1939a7.json new file mode 100644 index 0000000..0b2d078 --- /dev/null +++ b/nbd/.nbd/tickets/1939a7.json @@ -0,0 +1,9 @@ +{ + "id": "1939a7", + "title": "nbd archive command and Closed status", + "body": "Add `Status::Closed` (serialised as `\"closed\"`) and a convenience `nbd archive ` command that sets it.\n\n## Motivation\n\n`done` tickets clutter `nbd list`. `closed` provides a soft-delete: the ticket is preserved on disk but excluded from normal listings by default.\n\n## Approach\n\n### ticket.rs\n- Add `Closed` variant to `Status` enum (after `Done`).\n- `#[serde(rename_all = \"snake_case\")]` already handles serialisation → `\"closed\"`.\n\n### main.rs\n- Update `parse_status` to accept `\"closed\"`.\n- Update `status_str` in `display.rs` to map `Status::Closed` → `\"closed\"`.\n- Add `Archive` variant to `Commands`:\n ```\n Archive { id: String }\n ```\n- Implement `cmd_archive(id, json)`: read ticket → set status to `Closed` → write → print.\n This is syntactic sugar for `nbd update --status closed`.\n\n### display.rs\n- Add `\"closed\"` to `status_str` match arm.\n\n### list filtering\n- `nbd list` currently shows all tickets. After this change, it should by default **hide** `Closed` tickets.\n- Add a `--all` flag to `nbd list` to show all tickets including closed ones.\n- Update `list_tickets` or filter at the command handler level. Prefer filtering in `cmd_list` to keep `list_tickets` generic.\n\n## Tests\n- Unit test: `Status::Closed` serialises to `\"closed\"` and back.\n- Integration test: `nbd archive ` sets status to `closed`.\n- Integration test: `nbd list` does not show closed tickets.\n- Integration test: `nbd list --all` shows closed tickets.\n\n## Files touched\n- `src/ticket.rs` — add `Closed` variant\n- `src/main.rs` — `Archive` command, `parse_status` update, `--all` flag on `list`\n- `src/display.rs` — `status_str` update\n- `src/tests.rs` — unit tests\n- `tests/integration.rs` — integration tests\n- `README.md` — document archive and --all", + "priority": 6, + "status": "todo", + "dependencies": [], + "ticket_type": "feature" +} \ No newline at end of file diff --git a/nbd/.nbd/tickets/460caf.json b/nbd/.nbd/tickets/460caf.json new file mode 100644 index 0000000..f89c549 --- /dev/null +++ b/nbd/.nbd/tickets/460caf.json @@ -0,0 +1,9 @@ +{ + "id": "460caf", + "title": "Multiple file format support (md, toml, jsonb)", + "body": "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.\n\n## Motivation\n\nMarkdown 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.\n\n## Approach\n\n### New crate dependencies (Cargo.toml)\nEvaluate and add:\n- `toml` — TOML serialisation (likely `toml = \"0.8\"`)\n- `serde_yml` or `serde_yaml` — YAML frontmatter (for `.md` files)\n- `ciborium` — CBOR binary JSON (`.jsonb`)\n\n### ticket.rs\nNo changes needed — `Ticket` already derives `Serialize`/`Deserialize`.\n\n### store.rs\nNew enum `FileFormat { Json, Markdown, Toml, Jsonb }`.\n\nNew function `detect_format(path: &Path) -> FileFormat`:\n- `.json` → `Json`\n- `.md` → `Markdown`\n- `.toml` → `Toml`\n- `.jsonb` → `Jsonb`\n- Unknown → `Json` (fallback)\n\nUpdate `ticket_path(root, id, format)` to use the format-appropriate extension. This is a breaking change to the function signature — update all callers.\n\nUpdate `read_ticket(root, id)`:\n1. Try each known extension in order until a file is found.\n2. Read the file and dispatch to the format-appropriate deserialiser.\n\nSerialisation helpers (private):\n- `serialize_json(ticket) -> String`\n- `serialize_toml(ticket) -> String`\n- `serialize_markdown(ticket) -> String` — TOML frontmatter (`+++` delimiters) with body as file content\n- `serialize_jsonb(ticket) -> Vec`\n\nDeserialization helpers (private):\n- `deserialize_markdown(bytes) -> Result` — parse frontmatter + body\n\nUpdate `list_tickets` to scan for `*.json`, `*.md`, `*.toml`, `*.jsonb` files.\n\nUpdate `write_ticket` to accept `format: FileFormat` and write in the appropriate format.\n\n### main.rs\nAdd `--ftype [json|md|toml|jsonb]` option (default `json`) to `create` and `update`.\nConversion on `update --ftype`: read old file, write new format, delete old file (if extension changed).\n\n## Markdown format (TOML frontmatter)\n```\n+++\nid = \"a3f9c2\"\ntitle = \"Fix login bug\"\npriority = 8\nstatus = \"in_progress\"\nticket_type = \"bug\"\ndependencies = [\"b7d41e\"]\n+++\n\nLong-form body text goes here. Supports full markdown.\n```\n\n## Tests\n- Unit tests: roundtrip each format (JSON already tested).\n- Integration tests: `nbd create --ftype md` creates a `.md` file; `nbd read` finds and parses it.\n- Integration test: `nbd update --ftype toml` converts format and removes old file.\n\n## Files touched\n- `Cargo.toml` — new dependencies\n- `src/store.rs` — format detection, multi-format read/write, updated `list_tickets`\n- `src/main.rs` — `--ftype` flags\n- `src/tests.rs` — format roundtrip tests\n- `tests/integration.rs` — format integration tests\n- `README.md` — document `--ftype`\n- `docs/ARCHITECTURE.md` — update storage layout section", + "priority": 5, + "status": "todo", + "dependencies": [], + "ticket_type": "feature" +} \ No newline at end of file diff --git a/nbd/.nbd/tickets/4d2359.json b/nbd/.nbd/tickets/4d2359.json new file mode 100644 index 0000000..d4df528 --- /dev/null +++ b/nbd/.nbd/tickets/4d2359.json @@ -0,0 +1,9 @@ +{ + "id": "4d2359", + "title": "nbd init command", + "body": "Add an explicit `nbd init` subcommand that creates `.nbd/tickets/` in the current working directory, analogous to `git init`.\n\n## Motivation\n\nCurrently 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.\n\n## Approach\n\n1. Add `Init` variant to the `Commands` enum in `main.rs`.\n2. Implement `cmd_init(json: bool) -> store::Result<()>`:\n - Get cwd via `std::env::current_dir()`.\n - Call `store::ensure_tickets_dir(&cwd)` (already exists, idempotent).\n - Print confirmation: `initialised .nbd/tickets/ in ` (or JSON: `{\"root\": \"\"}`).\n3. No changes to `store.rs` needed — `ensure_tickets_dir` already uses `create_dir_all` (idempotent).\n4. The command should NOT require `.nbd/` to already exist (i.e. do NOT call `find_nbd_root` here — use cwd directly).\n\n## Tests\n\n- Integration test: run `nbd init` in a fresh tempdir, verify `.nbd/tickets/` is created.\n- Integration test: run `nbd init` twice in the same dir — succeeds both times (idempotent).\n- Integration test: `nbd init --json` outputs valid JSON with a `root` field.\n\n## Files touched\n- `src/main.rs` — new `Init` variant and `cmd_init` handler\n- `tests/integration.rs` — new integration tests\n- `README.md` — update Initialise section", + "priority": 7, + "status": "todo", + "dependencies": [], + "ticket_type": "feature" +} \ No newline at end of file diff --git a/nbd/.nbd/tickets/5f1495.json b/nbd/.nbd/tickets/5f1495.json new file mode 100644 index 0000000..39452a7 --- /dev/null +++ b/nbd/.nbd/tickets/5f1495.json @@ -0,0 +1,9 @@ +{ + "id": "5f1495", + "title": "nbd update diff output", + "body": "Show a git-diff-style +/- summary of what changed when `nbd update` is run without `--json`.\n\n## Motivation\n\nCurrently `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.\n\n## Approach\n\n### display.rs\nAdd `format_diff(old: &Ticket, new: &Ticket) -> String`:\n- Compare each field between `old` and `new`.\n- For each field that changed, emit two lines:\n ```\n - status: todo\n + status: in_progress\n ```\n- If no fields changed, emit `(no changes)`.\n- Fields compared: `title`, `body`, `priority`, `status`, `ticket_type`, `dependencies`.\n- `id` is never shown (it cannot change).\n- Label width matches `format_ticket` (LABEL_WIDTH).\n\nAdd `print_diff(old: &Ticket, new: &Ticket)` that calls `println!(\"{}\" format_diff(...))`.\n\n### main.rs\nIn `cmd_update`:\n- Before applying changes: `let old = ticket.clone();`\n- After `write_ticket`:\n - If `json`: current behaviour (print new ticket as JSON).\n - Else: `display::print_diff(&old, &ticket)`.\n\n## Tests\n\nUnit tests in `src/tests.rs`:\n- `format_diff` shows changed fields only.\n- `format_diff` with identical tickets outputs `(no changes)`.\n- Changed dependencies are shown as comma-separated lists on each line.\n\nIntegration test:\n- `nbd update --status in_progress` (no `--json`) prints `- status:` and `+ status:` lines.\n- `nbd update --json` still prints full JSON (no diff).\n\n## Files touched\n- `src/display.rs` — `format_diff`, `print_diff`\n- `src/main.rs` — `cmd_update` uses `print_diff`\n- `src/tests.rs` — unit tests for `format_diff`\n- `tests/integration.rs` — integration tests", + "priority": 5, + "status": "todo", + "dependencies": [], + "ticket_type": "feature" +} \ No newline at end of file diff --git a/nbd/.nbd/tickets/6e4239.json b/nbd/.nbd/tickets/6e4239.json new file mode 100644 index 0000000..1d0439e --- /dev/null +++ b/nbd/.nbd/tickets/6e4239.json @@ -0,0 +1,9 @@ +{ + "id": "6e4239", + "title": "Nix flake for nbd", + "body": "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.\n\n## Motivation\n\nOther `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.\n\n## Approach\n\n### nbd/flake.nix\n\nFollow the pattern from the repo root `flake.nix`. The service flake should:\n1. Inherit the base flake's inputs (nixpkgs, rust-overlay, etc.) or declare its own.\n2. Define a `packages.default` attribute that builds the `nbd` crate with `rustPlatform.buildRustPackage`.\n3. Define a `devShells.default` that includes the `nbd` binary and standard Rust tooling (rustfmt, clippy, cargo).\n4. Optionally expose an `apps.default` for `nix run .#nbd`.\n\n### Cargo.lock\nEnsure `Cargo.lock` is committed (it already is) — required for reproducible Nix builds.\n\n### cargoHash / cargoSha256\nThe `buildRustPackage` derivation requires a `cargoHash` (or `cargoSha256`). Use the correct fetcher approach (vendored deps or `fetchCargoTarball`).\n\n## Steps\n1. Read the root `flake.nix` to understand the base structure.\n2. Write `nbd/flake.nix` following the same conventions.\n3. Run `nix build .#nbd` from the `nbd/` directory to verify it builds.\n4. Run `nix run .#nbd -- --help` to verify the binary works.\n\n## Files touched\n- `nbd/flake.nix` — new file\n- `README.md` — add `nix run` usage section", + "priority": 4, + "status": "todo", + "dependencies": [], + "ticket_type": "task" +} \ No newline at end of file diff --git a/nbd/.nbd/tickets/833807.json b/nbd/.nbd/tickets/833807.json new file mode 100644 index 0000000..72b20be --- /dev/null +++ b/nbd/.nbd/tickets/833807.json @@ -0,0 +1,9 @@ +{ + "id": "833807", + "title": "SQLite cache for list performance", + "body": "Add an optional SQLite cache in `.nbd/cache.db` to accelerate `nbd list` and `nbd ready` for large ticket stores.\n\n## Motivation\n\n`list_tickets` currently does O(n) file reads on every call. For stores with hundreds of tickets this is measurably slow. A SQLite cache avoids re-reading unchanged files by comparing file modification times (mtimes).\n\n## Approach\n\n### Crate dependency\nAdd `sqlx` with the `sqlite` and `runtime-async-std` features to `Cargo.toml`.\n\n### store.rs additions\nNew async function `open_cache(root: &Path) -> Result`:\n- Opens (or creates) `.nbd/cache.db`.\n- Runs a migration: `CREATE TABLE IF NOT EXISTS tickets (id TEXT PRIMARY KEY, json TEXT NOT NULL, mtime INTEGER NOT NULL)`.\n\nNew function `list_tickets_cached(root: &Path) -> Result>`:\n1. Open cache.\n2. Read directory listing to get file names and mtimes.\n3. For each file: if the DB has a row with matching mtime, use cached JSON; otherwise read file, parse, insert/update row.\n4. Delete DB rows for IDs no longer on disk.\n5. Return deserialized tickets sorted by priority desc.\n\nKeep existing `list_tickets` as the non-cached fallback. Consider making `cmd_list` and `cmd_ready` use `list_tickets_cached` when available.\n\n### Migration strategy\n- The cache is always optional. If `cache.db` can't be opened, fall back to `list_tickets` (log a warning to stderr).\n- The cache is never the source of truth — the JSON files are. The cache is always reconstructable by deleting `.nbd/cache.db`.\n\n## Decision point\nDecide whether to enable the cache unconditionally or gate it behind a flag (`--cache` / `NBD_CACHE=1`). Recommendation: enable by default once the feature is stable.\n\n## Tests\n- Unit test: cache hit returns same data as direct file read.\n- Unit test: cache miss (mtime changed) re-reads the file.\n- Unit test: deleted ticket is evicted from cache.\n- Performance test (optional): benchmark 1000-ticket list with and without cache.\n\n## Files touched\n- `Cargo.toml` — add `sqlx`\n- `src/store.rs` — `open_cache`, `list_tickets_cached`\n- `src/tests.rs` — cache unit tests\n- `docs/ARCHITECTURE.md` — document the cache layer", + "priority": 3, + "status": "todo", + "dependencies": [], + "ticket_type": "feature" +} \ No newline at end of file diff --git a/nbd/.nbd/tickets/c9d551.json b/nbd/.nbd/tickets/c9d551.json new file mode 100644 index 0000000..cc877d6 --- /dev/null +++ b/nbd/.nbd/tickets/c9d551.json @@ -0,0 +1,9 @@ +{ + "id": "c9d551", + "title": "Partial ID matching", + "body": "Allow `nbd read`, `nbd update`, and dependency resolution to accept a prefix of a ticket ID (e.g. `nbd read a3f` resolves to `a3f9c2`).\n\n## Motivation\n\n6-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.\n\n## Approach\n\nAdd `resolve_id(root: &Path, id_or_prefix: &str) -> Result` in `store.rs`:\n1. If `id_or_prefix` is exactly 6 characters, try `read_ticket` as-is (fast path, existing behaviour).\n2. Otherwise (or if not found), scan `.nbd/tickets/` for files whose stem starts with `id_or_prefix`.\n3. Collect all matches.\n - 0 matches → error: `\"no ticket found matching '{prefix}'\"`\n - 1 match → return the full ID\n - 2+ matches → error: `\"ambiguous prefix '{prefix}' matches: {id1}, {id2}, ...\"`\n\nUse `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.\n\n## Tests\n\nUnit tests in `src/tests.rs`:\n- Exact 6-char match still works.\n- 3-char prefix resolves correctly.\n- Ambiguous prefix returns an error listing all matching IDs.\n- Unknown prefix returns a not-found error.\n\nIntegration tests in `tests/integration.rs`:\n- `nbd read <3-char-prefix>` resolves and prints the ticket.\n- `nbd update <3-char-prefix> --status done` succeeds.\n- Ambiguous prefix exits non-zero with an informative message.\n\n## Files touched\n- `src/store.rs` — new `resolve_id` function\n- `src/main.rs` — `cmd_read`, `cmd_update`, `validate_deps` use `resolve_id`\n- `src/tests.rs` — unit tests\n- `tests/integration.rs` — integration tests", + "priority": 8, + "status": "todo", + "dependencies": [], + "ticket_type": "feature" +} \ No newline at end of file diff --git a/nbd/.nbd/tickets/d1634a.json b/nbd/.nbd/tickets/d1634a.json new file mode 100644 index 0000000..827d0d5 --- /dev/null +++ b/nbd/.nbd/tickets/d1634a.json @@ -0,0 +1,8 @@ +{ + "title": "Remove id field from ticket JSON body", + "body": "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.\n\n## Motivation\n\n- Eliminates a redundancy: filename stem already IS the id.\n- Removes the risk of id mismatch (e.g. if a file is renamed manually).\n- Simplifies the JSON schema — consumers only need the body fields, not a duplicated key.\n\n## Serde approach\n\nIn `ticket.rs`, annotate the `id` field with `#[serde(skip)]`:\n\n```rust\n#[derive(Serialize, Deserialize, Debug, Clone)]\npub struct Ticket {\n #[serde(skip)]\n pub id: String,\n // ...\n}\n```\n\n`#[serde(skip)]` means:\n- **Serialise:** the `id` field is omitted from JSON output entirely.\n- **Deserialise:** the field is not read from JSON; it is initialised with `String::default()` (empty string) and must be set manually after deserialization.\n\nBecause 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.\n\n## store.rs changes\n\n`read_ticket(root, id)` — inject id from the `id` parameter after deserialising:\n```rust\nlet mut ticket: Ticket = serde_json::from_slice(&bytes)?;\nticket.id = id.to_string(); // authoritative source: the filename\nOk(ticket)\n```\n\n`list_tickets(root)` — inject id from each file's stem:\n```rust\nlet stem = path.file_stem().and_then(|s| s.to_str()).ok_or(\"invalid filename\")?;\nlet mut ticket: Ticket = serde_json::from_slice(&bytes)?;\nticket.id = stem.to_string();\ntickets.push(ticket);\n```\n\n`write_ticket` — no change needed; `#[serde(skip)]` already prevents `id` from being written.\n\n`ticket_path` — no change; it already takes `id: &str` as a separate parameter.\n\n## Impact on other tickets\n\n- The `nbd migrate` companion ticket (see deps) provides the command to scrub the old `id` field from existing files.\n- Partial ID matching (`resolve_id`) is unaffected — it works on filenames, not JSON content.\n- All display, list, and read commands continue to work; `ticket.id` is populated from the filename in every read path.\n\n## Tests\n\nUnit tests (`src/tests.rs`):\n- `write_ticket` output does NOT contain the `\"id\"` key.\n- `read_ticket` with a file that has NO `id` field correctly sets `ticket.id` from the parameter.\n- `read_ticket` with an old-format file (has `\"id\"` in JSON) still sets `ticket.id` from the parameter (ignores JSON value).\n- `list_tickets` injects correct ids from filenames for all tickets.\n- Serialisation roundtrip: write then read, id is preserved via filename not JSON.\n\nIntegration tests (`tests/integration.rs`):\n- `nbd create` output (tabular and `--json`) contains the correct ID.\n- The created `.json` file on disk does NOT contain the `\"id\"` key.\n- `nbd read ` displays the correct ID.\n\n## Files touched\n- `src/ticket.rs` — add `#[serde(skip)]` to `id`\n- `src/store.rs` — `read_ticket` and `list_tickets` inject id from filename\n- `src/tests.rs` — update and add unit tests\n- `tests/integration.rs` — add assertion that written files lack `\"id\"` key", + "priority": 9, + "status": "done", + "dependencies": [], + "ticket_type": "task" +} \ No newline at end of file diff --git a/nbd/.nbd/tickets/e1968f.json b/nbd/.nbd/tickets/e1968f.json new file mode 100644 index 0000000..7005095 --- /dev/null +++ b/nbd/.nbd/tickets/e1968f.json @@ -0,0 +1,9 @@ +{ + "id": "e1968f", + "title": "nbd ready command", + "body": "Add `nbd ready` subcommand that lists tickets which are actionable right now: not yet done and with all dependencies completed.\n\n## Motivation\n\nAgent workflows need to know which tickets are unblocked. `nbd list` shows everything; `nbd ready` narrows to what can actually be started immediately.\n\n## Approach\n\n1. Add `Ready` variant to `Commands` enum in `main.rs`.\n2. Implement `cmd_ready(json: bool)`:\n a. `list_tickets(root)` to fetch all tickets.\n b. Build a set of IDs for tickets with `status == Status::Done`.\n c. Filter to tickets where:\n - `ticket.status != Status::Done` (not already finished)\n - All IDs in `ticket.dependencies` are in the done-set (or the dep doesn't exist — treat missing deps as unresolved, not ready).\n d. Print the filtered slice using existing `display::print_list` / `print_list_json`.\n3. No new store or display functions needed — reuse existing.\n\n## Edge cases\n- A ticket with no dependencies and status `todo` → ready.\n- A ticket whose dep is `in_progress` → NOT ready.\n- Missing dep ID → NOT ready (treat conservatively).\n- Empty store → returns empty list (not an error).\n\n## Tests\n\nUnit-style integration tests:\n- Three tickets: A (no deps, todo), B (dep A, todo), C (no deps, done). `nbd ready` should return only A.\n- After marking A done, `nbd ready` should return B.\n- `nbd ready --json` returns a JSON array of the ready tickets.\n\n## Files touched\n- `src/main.rs` — new `Ready` variant and `cmd_ready` handler\n- `tests/integration.rs` — new integration tests", + "priority": 7, + "status": "todo", + "dependencies": [], + "ticket_type": "feature" +} \ No newline at end of file diff --git a/nbd/.nbd/tickets/fc444f.json b/nbd/.nbd/tickets/fc444f.json new file mode 100644 index 0000000..16a8121 --- /dev/null +++ b/nbd/.nbd/tickets/fc444f.json @@ -0,0 +1,9 @@ +{ + "id": "fc444f", + "title": "nbd claude-md command", + "body": "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!`.\n\n## Motivation\n\nEvery 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`.\n\n## Snippet file\n\nCreate `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:\n\n- One-sentence description of `nbd`.\n- Initialisation (`nbd init`).\n- The four core commands with examples (`create`, `list`, `read`, `update`).\n- The `nbd ready` command for finding unblocked tickets.\n- The workflow (create before starting → set in_progress → set done).\n- Guidelines: always `--json`, priority scale, type choices, dep usage.\n\nThe 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.\n\n## Binary embedding\n\nIn `main.rs`, embed the snippet at compile time:\n\n```rust\nconst CLAUDE_MD_SNIPPET: &str = include_str!(\"claude_md_snippet.md\");\n```\n\n`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.\n\n## Command implementation\n\nAdd `ClaudeMd` variant to `Commands` in `main.rs`:\n\n```rust\n/// Print a CLAUDE.md snippet for adopting nbd in a project.\nClaudeMd,\n```\n\nThe `--json` global flag applies:\n- Without `--json`: `print!(\"{CLAUDE_MD_SNIPPET}\")` — raw markdown, suitable for redirect (`nbd claude-md >> CLAUDE.md`).\n- With `--json`: output `{\"snippet\": \"\"}` — for programmatic consumption.\n\nNo store access needed — this command is pure output from the embedded constant. It does not call `find_nbd_root()`.\n\n## No display.rs changes needed\n\nThe output is a single `print!` call in the command handler. No tabular formatting.\n\n## Tests\n\nIntegration tests (`tests/integration.rs`):\n- `nbd claude-md` exits zero and stdout is non-empty.\n- stdout contains key strings (`\"nbd\"`, `\"CLAUDE.md\"` or similar section header, `\"--json\"`).\n- `nbd claude-md --json` exits zero and stdout is valid JSON with a `\"snippet\"` key whose value is a non-empty string.\n- `nbd claude-md` works even when run from a directory with no `.nbd/` (no `find_nbd_root` call).\n\n## Files touched\n- `src/claude_md_snippet.md` — new file; the canonical snippet content\n- `src/main.rs` — `include_str!` constant, `ClaudeMd` command variant and handler\n- `tests/integration.rs` — integration tests\n- `README.md` — mention `nbd claude-md` in the Usage section", + "priority": 6, + "status": "todo", + "dependencies": [], + "ticket_type": "feature" +} \ No newline at end of file diff --git a/nbd/src/display.rs b/nbd/src/display.rs index a12a1bc..0d43b43 100644 --- a/nbd/src/display.rs +++ b/nbd/src/display.rs @@ -89,11 +89,37 @@ 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. +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 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 { - serde_json::to_string_pretty(ticket).expect("ticket serialisation must not fail") + 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. @@ -152,8 +178,12 @@ pub fn print_list(tickets: &[Ticket]) { } /// Format a slice of tickets as a pretty-printed JSON array. +/// +/// Each object includes an `id` field even though it is not stored in the +/// individual ticket files on disk. pub fn format_list_json(tickets: &[Ticket]) -> String { - serde_json::to_string_pretty(tickets).expect("ticket list serialisation must not fail") + 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. diff --git a/nbd/src/store.rs b/nbd/src/store.rs index e13f665..a2d5f85 100644 --- a/nbd/src/store.rs +++ b/nbd/src/store.rs @@ -115,6 +115,10 @@ pub async fn write_ticket(root: &Path, ticket: &Ticket) -> Result<()> { /// Read and deserialise the ticket with the given `id` from disk. /// +/// The `id` is not stored inside the JSON 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 the ticket file is not found. @@ -122,7 +126,11 @@ pub async fn write_ticket(root: &Path, ticket: &Ticket) -> Result<()> { pub async fn read_ticket(root: &Path, id: &str) -> Result { let path = ticket_path(root, id); match fs::read(&path).await { - Ok(bytes) => Ok(serde_json::from_slice(&bytes)?), + Ok(bytes) => { + let mut ticket: Ticket = serde_json::from_slice(&bytes)?; + ticket.id = id.to_string(); + Ok(ticket) + } Err(e) if e.kind() == std::io::ErrorKind::NotFound => { Err(format!("ticket '{id}' not found").into()) } @@ -155,8 +163,14 @@ pub async fn list_tickets(root: &Path) -> Result> { let entry = entry?; let path = entry.path(); if path.extension().is_some_and(|ext| ext == "json") { + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .ok_or("ticket filename has no valid stem")? + .to_string(); let bytes = fs::read(&path).await?; - let ticket: Ticket = serde_json::from_slice(&bytes)?; + let mut ticket: Ticket = serde_json::from_slice(&bytes)?; + ticket.id = stem; tickets.push(ticket); } } diff --git a/nbd/src/tests.rs b/nbd/src/tests.rs index 2a65705..2b81917 100644 --- a/nbd/src/tests.rs +++ b/nbd/src/tests.rs @@ -23,7 +23,9 @@ mod ticket { assert_eq!(t.ticket_type, TicketType::Task); } - /// A `Ticket` serialises to JSON and deserialises back to an identical value. + /// 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 { @@ -37,9 +39,22 @@ mod ticket { }; 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"); - assert_eq!(restored.id, original.id); + // 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); @@ -188,6 +203,82 @@ mod store { drop(tmp); } + /// `write_ticket` does not include the `id` key in the JSON file. + #[async_std::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).await.unwrap(); + + let path = ticket_path(&root, "c0ffee"); + let contents = async_std::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. + #[async_std::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); + async_std::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. + #[async_std::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); + async_std::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. + #[async_std::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).await.unwrap(); + write_ticket(&root, &t2).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. #[async_std::test] async fn read_missing_ticket_errors() { @@ -353,6 +444,10 @@ mod display { } /// `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(); @@ -411,7 +506,8 @@ mod display { assert!(output.contains("bug"), "should render type string"); } - /// `format_list_json` produces a valid JSON array with one object per ticket. + /// `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![ diff --git a/nbd/src/ticket.rs b/nbd/src/ticket.rs index 6951a14..a3d17fe 100644 --- a/nbd/src/ticket.rs +++ b/nbd/src/ticket.rs @@ -50,11 +50,19 @@ pub enum TicketType { /// 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, diff --git a/nbd/tests/integration.rs b/nbd/tests/integration.rs index 92dcf7a..42501a3 100644 --- a/nbd/tests/integration.rs +++ b/nbd/tests/integration.rs @@ -242,6 +242,46 @@ fn list_with_json_flag() { ); } +/// `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" + ); +} + /// `update --deps` replaces the dependency list. #[test] fn update_deps_replaces_list() {