diff --git a/nbd/.nbd/tickets/0f51af.json b/nbd/.nbd/tickets/0f51af.json deleted file mode 100644 index 7f5e740..0000000 --- a/nbd/.nbd/tickets/0f51af.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "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": "done", - "dependencies": [ - "d1634a" - ], - "ticket_type": "feature" -} \ No newline at end of file diff --git a/nbd/.nbd/tickets/0f51af.md b/nbd/.nbd/tickets/0f51af.md new file mode 100644 index 0000000..4bc3cf1 --- /dev/null +++ b/nbd/.nbd/tickets/0f51af.md @@ -0,0 +1,111 @@ ++++ +title = "nbd migrate command" +priority = 9 +status = "done" +ticket_type = "feature" +dependencies = ["d1634a"] ++++ +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` \ No newline at end of file diff --git a/nbd/.nbd/tickets/1939a7.json b/nbd/.nbd/tickets/1939a7.json deleted file mode 100644 index cc62f5e..0000000 --- a/nbd/.nbd/tickets/1939a7.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "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": "done", - "dependencies": [], - "ticket_type": "feature" -} \ No newline at end of file diff --git a/nbd/.nbd/tickets/1939a7.md b/nbd/.nbd/tickets/1939a7.md new file mode 100644 index 0000000..6a22ee0 --- /dev/null +++ b/nbd/.nbd/tickets/1939a7.md @@ -0,0 +1,50 @@ ++++ +title = "nbd archive command and Closed status" +priority = 6 +status = "done" +ticket_type = "feature" +dependencies = [] ++++ +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 \ No newline at end of file diff --git a/nbd/.nbd/tickets/460caf.json b/nbd/.nbd/tickets/460caf.json deleted file mode 100644 index 33c8626..0000000 --- a/nbd/.nbd/tickets/460caf.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "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": "done", - "dependencies": [], - "ticket_type": "feature" -} \ No newline at end of file diff --git a/nbd/.nbd/tickets/460caf.md b/nbd/.nbd/tickets/460caf.md new file mode 100644 index 0000000..fcf9e44 --- /dev/null +++ b/nbd/.nbd/tickets/460caf.md @@ -0,0 +1,84 @@ ++++ +title = "Multiple file format support (md, toml, jsonb)" +priority = 5 +status = "done" +ticket_type = "feature" +dependencies = [] ++++ +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 \ No newline at end of file diff --git a/nbd/.nbd/tickets/4d2359.json b/nbd/.nbd/tickets/4d2359.json deleted file mode 100644 index 04108b8..0000000 --- a/nbd/.nbd/tickets/4d2359.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "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": "done", - "dependencies": [], - "ticket_type": "feature" -} \ No newline at end of file diff --git a/nbd/.nbd/tickets/4d2359.md b/nbd/.nbd/tickets/4d2359.md new file mode 100644 index 0000000..85dbfa7 --- /dev/null +++ b/nbd/.nbd/tickets/4d2359.md @@ -0,0 +1,33 @@ ++++ +title = "nbd init command" +priority = 7 +status = "done" +ticket_type = "feature" +dependencies = [] ++++ +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 \ No newline at end of file diff --git a/nbd/.nbd/tickets/5f1495.json b/nbd/.nbd/tickets/5f1495.json deleted file mode 100644 index a07ae16..0000000 --- a/nbd/.nbd/tickets/5f1495.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "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/5f1495.md b/nbd/.nbd/tickets/5f1495.md new file mode 100644 index 0000000..f70a163 --- /dev/null +++ b/nbd/.nbd/tickets/5f1495.md @@ -0,0 +1,53 @@ ++++ +title = "nbd update diff output" +priority = 5 +status = "todo" +ticket_type = "feature" +dependencies = [] ++++ +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 \ No newline at end of file diff --git a/nbd/.nbd/tickets/6e4239.json b/nbd/.nbd/tickets/6e4239.json deleted file mode 100644 index ce0cef3..0000000 --- a/nbd/.nbd/tickets/6e4239.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "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/6e4239.md b/nbd/.nbd/tickets/6e4239.md new file mode 100644 index 0000000..647f9fa --- /dev/null +++ b/nbd/.nbd/tickets/6e4239.md @@ -0,0 +1,38 @@ ++++ +title = "Nix flake for nbd" +priority = 4 +status = "todo" +ticket_type = "task" +dependencies = [] ++++ +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 \ No newline at end of file diff --git a/nbd/.nbd/tickets/833807.json b/nbd/.nbd/tickets/833807.json deleted file mode 100644 index 5b3bc53..0000000 --- a/nbd/.nbd/tickets/833807.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "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/833807.md b/nbd/.nbd/tickets/833807.md new file mode 100644 index 0000000..340d9df --- /dev/null +++ b/nbd/.nbd/tickets/833807.md @@ -0,0 +1,50 @@ ++++ +title = "SQLite cache for list performance" +priority = 3 +status = "todo" +ticket_type = "feature" +dependencies = [] ++++ +Add an optional SQLite 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 SQLite cache avoids re-reading unchanged files by comparing file modification times (mtimes). + +## Approach + +### Crate dependency +Add `sqlx` with the `sqlite` and `runtime-async-std` features to `Cargo.toml`. + +### store.rs additions +New async function `open_cache(root: &Path) -> Result`: +- Opens (or creates) `.nbd/cache.db`. +- Runs a 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. Open cache. +2. Read directory listing to get file names and mtimes. +3. For each file: if the DB has a row with matching mtime, use cached JSON; otherwise read file, parse, insert/update row. +4. Delete DB rows for IDs no longer on disk. +5. Return deserialized tickets sorted by priority desc. + +Keep existing `list_tickets` as the non-cached fallback. Consider making `cmd_list` and `cmd_ready` use `list_tickets_cached` when available. + +### Migration strategy +- The cache is always optional. If `cache.db` can't be opened, fall back to `list_tickets` (log a warning to stderr). +- The cache is never the source of truth — the JSON files are. The cache is always reconstructable by deleting `.nbd/cache.db`. + +## Decision point +Decide 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. + +## Tests +- Unit test: cache hit returns same data as direct file read. +- Unit test: cache miss (mtime changed) re-reads the file. +- Unit test: deleted ticket is evicted from cache. +- Performance test (optional): benchmark 1000-ticket list with and without cache. + +## Files touched +- `Cargo.toml` — add `sqlx` +- `src/store.rs` — `open_cache`, `list_tickets_cached` +- `src/tests.rs` — cache unit tests +- `docs/ARCHITECTURE.md` — document the cache layer \ No newline at end of file diff --git a/nbd/.nbd/tickets/887344.json b/nbd/.nbd/tickets/887344.json deleted file mode 100644 index 5646320..0000000 --- a/nbd/.nbd/tickets/887344.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "title": "Wire --filter flag into list, ready, and migrate commands", - "body": "## Summary\n\nAdd `--filter KEY=VALUE` (repeatable) to the `list`, `ready`, and `migrate` CLI commands.\nParse filter arguments into a `TicketFilter` and apply it in each command handler.\n\nDepends on: TicketFilter module (ticket c2a024).\n\n## CLI changes (src/main.rs)\n\nAdd to `Commands::List`, `Commands::Ready`, and `Commands::Migrate` variants:\n\n```rust\n/// Filter tickets: key=value pairs (repeatable).\n/// Keys: priority, type, status, title.\n/// Different keys are ANDed; same key with multiple values is ORed.\n/// Values support glob wildcards: status=* matches all statuses.\n#[arg(long = \"filter\", value_name = \"KEY=VALUE\")]\nfilter: Vec,\n```\n\nUpdate the `dispatch` function to pass filter args through to each handler.\n\n## Handler changes\n\n### cmd_list(filter_args, json)\n\n```rust\nlet filter = filter::parse_filters(&filter_args)?;\nlet tickets: Vec = list_tickets(&root).await?\n .into_iter()\n .filter(|t| filter.matches(t))\n .collect();\n```\n\nNote: the default done-exclusion behaviour (ticket for that is separate) will also\nlive here, layered on top of this filter application.\n\n### cmd_ready(filter_args, json)\n\nApply the user filter AFTER the ready check. The ready check (not done + all deps done)\nis always applied first; the user's filter narrows further within ready tickets.\n\n```rust\nlet filter = filter::parse_filters(&filter_args)?;\n// ... build done_ids as before ...\nlet ready: Vec = all\n .into_iter()\n .filter(|t| {\n t.status \\!= Status::Done\n && t.dependencies.iter().all(|dep| done_ids.contains(dep.as_str()))\n && filter.matches(t)\n })\n .collect();\n```\n\n### cmd_migrate(filter_args, dry_run, json)\n\nFor migrate, the filter selects which tickets are candidates for migration.\nTickets not matching the filter are skipped (counted separately, not treated as errors).\n\nAdd a `skipped` field to `MigrateReport` in `store.rs`:\n\n```rust\npub struct MigrateReport {\n pub updated: usize,\n pub already_current: usize,\n pub skipped: usize, // NEW: tickets excluded by filter\n pub errors: Vec<(String, String)>,\n}\n```\n\nUpdate `migrate_tickets` signature in `store.rs`:\n\n```rust\npub async fn migrate_tickets(\n root: &Path,\n dry_run: bool,\n filter: &TicketFilter,\n) -> Result\n```\n\nInside the per-file loop, after deserialising the ticket, check `filter.matches(&ticket)`.\nIf false: increment `report.skipped` and continue to next file.\n\nUpdate `cmd_migrate` to parse the filter and pass it to `migrate_tickets`.\n\n## display.rs changes\n\nUpdate `format_migrate_report` and `format_migrate_report_json` to include the\n`skipped` count:\n\nHuman format:\n```\nMigrated 3 tickets.\nCurrent 5 tickets (already up to date).\nSkipped 2 tickets (did not match filter).\nErrors 1 ticket could not be migrated:\n bad_ticket.json: trailing comma at line 4\n```\n\nOnly print the \"Skipped\" line when `skipped > 0`.\n\nJSON format: add `\"skipped\": N` key to the existing object.\n\n## files touched\n\n- `src/main.rs` — `filter` fields on List/Ready/Migrate variants, updated dispatch,\n updated cmd_list/cmd_ready/cmd_migrate handlers\n- `src/store.rs` — `MigrateReport::skipped`, `migrate_tickets` gains `filter` param\n- `src/display.rs` — updated `format_migrate_report` and `format_migrate_report_json`\n- `src/tests.rs` — unit tests for updated migrate report formatting\n- `tests/integration.rs` — integration tests\n\n## Integration tests to add (tests/integration.rs)\n\n**list filtering:**\n- Create tickets: 1 bug/todo, 1 task/in_progress, 1 bug/done.\n `nbd list --filter type=bug` shows only the bug tickets (done-exclusion is separate,\n but this test can use non-done bugs).\n- `nbd list --filter status=in_progress` shows only in_progress tickets.\n- `nbd list --filter status=todo --filter status=in_progress` shows both todo and in_progress\n (OR within same key).\n- `nbd list --filter type=bug --filter status=todo` shows only bug+todo tickets\n (AND across keys).\n- `nbd list --filter title=*login*` shows only tickets whose title contains \"login\".\n- `nbd list --filter status=*` matches all statuses (wildcard).\n- `nbd list --filter type=unknown` exits non-zero with an error (unknown key passes through\n as a value, but \"unknown\" does not match any type → empty results, or error? Error on\n unknown key is preferable).\n- `nbd list --filter badformat` (no `=`) exits non-zero with an error.\n\n**ready filtering:**\n- `nbd ready --filter type=bug` returns only ready bug tickets.\n- `nbd ready --filter priority=8` returns only ready tickets with priority 8.\n\n**migrate filtering:**\n- Create two tickets. Run `nbd migrate --filter status=todo --dry-run`.\n Verify `skipped` count in JSON output matches tickets not matching the filter.\n- `nbd migrate --filter status=todo --json` includes `skipped` key.\n\n**error cases:**\n- `--filter` with unknown key exits non-zero.\n- `--filter` with no `=` exits non-zero.", - "priority": 8, - "status": "done", - "dependencies": [ - "c2a024" - ], - "ticket_type": "feature" -} \ No newline at end of file diff --git a/nbd/.nbd/tickets/887344.md b/nbd/.nbd/tickets/887344.md new file mode 100644 index 0000000..6f36efd --- /dev/null +++ b/nbd/.nbd/tickets/887344.md @@ -0,0 +1,150 @@ ++++ +title = "Wire --filter flag into list, ready, and migrate commands" +priority = 8 +status = "done" +ticket_type = "feature" +dependencies = ["c2a024"] ++++ +## 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. \ No newline at end of file diff --git a/nbd/.nbd/tickets/92e45b.json b/nbd/.nbd/tickets/92e45b.json deleted file mode 100644 index 6ec09db..0000000 --- a/nbd/.nbd/tickets/92e45b.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "title": "Exclude done tickets from nbd list by default", - "body": "## Summary\n\nChange `nbd list` to hide `done` tickets by default. Users must explicitly opt in\nvia `--filter status=done` or `--filter status=*` to see completed tickets.\n\nThis is a **breaking change in default behavior**.\n\nDepends on: `--filter` wired into `list` (ticket 887344).\n\n## Current behaviour\n\n`nbd list` shows ALL tickets regardless of status.\n\n## New behaviour\n\n| Command | Shows |\n|---|---|\n| `nbd list` | todo + in_progress only (done excluded) |\n| `nbd list --filter status=done` | done only |\n| `nbd list --filter status=*` | all tickets (all statuses) |\n| `nbd list --filter status=todo --filter status=in_progress` | todo and in_progress |\n| `nbd list --filter type=bug` | non-done bug tickets (done still excluded) |\n| `nbd list --filter type=bug --filter status=*` | all bug tickets including done |\n\nThe key rule: **if the user provides no `status` filter key, done tickets are excluded**.\nIf the user provides any `--filter status=...` argument, the explicit status filter\nis used as-is with no implicit exclusion.\n\n## Implementation (src/main.rs)\n\nIn `cmd_list`, after parsing the filter and loading tickets, apply the logic:\n\n```rust\nlet filter = filter::parse_filters(&filter_args)?;\nlet tickets = list_tickets(&root).await?;\n\nlet tickets: Vec = tickets\n .into_iter()\n .filter(|t| {\n // If no status filter was provided by the user, exclude done tickets.\n let status_ok = if filter.has_status_filter() {\n // User expressed intent about status: use their filter.\n filter.matches_status(t)\n } else {\n // Default: hide done tickets.\n t.status \\!= Status::Done\n };\n\n // Apply remaining filter keys (type, priority, title) regardless.\n status_ok && filter.matches_except_status(t)\n })\n .collect();\n```\n\nThis requires two additional methods on `TicketFilter` (add to `src/filter.rs`):\n\n```rust\n/// Returns true if the ticket's status matches any of the status patterns.\n/// Caller is responsible for only calling this when `has_status_filter()` is true.\npub fn matches_status(&self, ticket: &Ticket) -> bool;\n\n/// Returns true if the ticket matches all non-status filter groups (type, priority, title).\n/// The status group is intentionally excluded so callers can handle it separately.\npub fn matches_except_status(&self, ticket: &Ticket) -> bool;\n```\n\n## CLI help text update\n\nUpdate the `List` variant doc comment:\n\n```rust\n/// List tickets sorted by priority (highest first).\n///\n/// By default, tickets with status `done` are excluded. Use\n/// `--filter status=*` to include all tickets, or\n/// `--filter status=done` to show only completed tickets.\nList {\n #[arg(long = \"filter\", value_name = \"KEY=VALUE\")]\n filter: Vec,\n},\n```\n\n## Existing tests that need updating\n\nThe integration test `list_shows_created_tickets` creates two tickets with default\nstatus (`todo`) and asserts both appear in `nbd list`. This test is unaffected because\nthe default tickets are not done. However, any future test that creates a ticket and\nimmediately lists without marking it done will still work.\n\nCheck: is there any existing test that creates a done ticket and expects it in `nbd list`?\nIf so, update that test to use `--filter status=done` or `--filter status=*`.\n\n## New integration tests to add (tests/integration.rs)\n\n- Create two tickets: one todo, one done. `nbd list` shows only the todo one.\n- Create two tickets: one todo, one done. `nbd list --filter status=done` shows only the done one.\n- Create two tickets: one todo, one done. `nbd list --filter status=*` shows both.\n- Create 3 tickets: bug/todo, bug/done, task/todo.\n `nbd list --filter type=bug` shows only bug/todo (done excluded by default).\n `nbd list --filter type=bug --filter status=*` shows both bug tickets.\n- `nbd list --json` does not include done tickets (verify JSON array length).\n- `nbd list --filter status=* --json` includes done tickets.\n\n## README update\n\nUpdate the \"List all tickets\" section in README.md:\n\n```\n### List all tickets\n\n```sh\nnbd list # excludes done tickets\nnbd list --filter status=* # all tickets including done\nnbd list --filter status=done # only completed tickets\nnbd list --filter type=bug # non-done bug tickets\nnbd list --json\n```\n```\n\n## Files touched\n\n- `src/main.rs` — `cmd_list` implementation and `List` help text\n- `src/filter.rs` — `matches_status`, `matches_except_status` methods\n- `src/tests.rs` — unit tests for new filter methods\n- `tests/integration.rs` — new tests, update any affected existing tests\n- `README.md` — updated usage section", - "priority": 7, - "status": "done", - "dependencies": [ - "887344" - ], - "ticket_type": "feature" -} \ No newline at end of file diff --git a/nbd/.nbd/tickets/92e45b.md b/nbd/.nbd/tickets/92e45b.md new file mode 100644 index 0000000..2c8337a --- /dev/null +++ b/nbd/.nbd/tickets/92e45b.md @@ -0,0 +1,133 @@ ++++ +title = "Exclude done tickets from nbd list by default" +priority = 7 +status = "done" +ticket_type = "feature" +dependencies = ["887344"] ++++ +## 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 \ No newline at end of file diff --git a/nbd/.nbd/tickets/c2a024.json b/nbd/.nbd/tickets/c2a024.json deleted file mode 100644 index 98a1d39..0000000 --- a/nbd/.nbd/tickets/c2a024.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "title": "Implement TicketFilter module with glob matching", - "body": "## Summary\n\nAdd a `src/filter.rs` module implementing `TicketFilter`, which parses and applies\n`key=value` filter expressions against a list of tickets. This is the foundational\nbuilding block for the `--filter` flag on `list`, `ready`, `migrate`, and `next` commands.\n\nNo external crate is needed — glob matching is implemented with a simple hand-rolled\nalgorithm that handles `*` wildcards.\n\n## Filter semantics\n\n- `--filter key=value` can be specified multiple times.\n- Keys: `priority`, `type`, `status`, `title`.\n- **Different keys are ANDed**: `--filter type=bug --filter status=todo` matches tickets\n that are BOTH bugs AND todo.\n- **Same key, multiple values are ORed**: `--filter status=todo --filter status=in_progress`\n matches tickets with EITHER status.\n- Values support `*` as a wildcard anywhere in the pattern:\n - `status=*` matches any status\n - `title=*command*` matches any title containing \"command\"\n - `priority=7` matches exactly priority 7 (as string comparison)\n\n## Data structures\n\n```rust\n/// A compiled set of filter expressions applied to tickets.\n///\n/// Within the same key, patterns are ORed (any match passes the group).\n/// Across different keys, groups are ANDed (all non-empty groups must pass).\npub struct TicketFilter {\n /// Glob patterns for `status` (OR within this group).\n pub status: Vec,\n /// Glob patterns for `ticket_type` (OR within this group).\n pub ticket_type: Vec,\n /// Glob patterns for `priority` (OR within this group; matched against string repr).\n pub priority: Vec,\n /// Glob patterns for `title` (OR within this group).\n pub title: Vec,\n}\n```\n\nAn empty `Vec` for a key means \"no filter on that key\" — matches everything.\n\n## API\n\n```rust\n/// Parse a slice of \"key=value\" strings into a TicketFilter.\n///\n/// The key `type` maps to the `ticket_type` field.\n/// Returns an error for unknown keys or malformed expressions (no '=').\npub fn parse_filters(args: &[String]) -> crate::store::Result\n\nimpl TicketFilter {\n /// Returns true when all non-empty filter groups match this ticket.\n /// An empty TicketFilter always returns true.\n pub fn matches(&self, ticket: &crate::ticket::Ticket) -> bool;\n\n /// Returns true when no filter groups are set (no-op filter).\n pub fn is_empty(&self) -> bool;\n\n /// Returns true when the user has provided at least one status pattern.\n /// Used by cmd_list to detect whether to apply the implicit done-exclusion.\n pub fn has_status_filter(&self) -> bool;\n}\n```\n\n## Glob algorithm (no external crate)\n\nImplement a private `fn glob_matches(pattern: &str, value: &str) -> bool`:\n\n1. If pattern contains no `*`, require exact equality.\n2. Split pattern on `*` into segments.\n3. If pattern does NOT start with `*`, value must start with `segments[0]`.\n4. If pattern does NOT end with `*`, value must end with `segments[last]`.\n5. For each remaining segment, find it in the remaining suffix of value (left to right).\n Advance past the match and continue. If any segment is not found, return false.\n\nThis handles: `*`, `foo`, `foo*`, `*foo`, `*foo*`, `foo*bar`, `*foo*bar*`.\n\nCase sensitivity:\n- `status` and `type` patterns: case-insensitive (compare lowercase).\n- `title` and `priority` patterns: case-sensitive.\n\n## matches() logic\n\n```\nfn matches(&self, ticket) -> bool {\n (self.status.is_empty() || self.status.iter().any(|p| glob_matches(p, status_str(&ticket.status))))\n && (self.ticket_type.is_empty() || self.ticket_type.iter().any(|p| glob_matches(p, ticket_type_str(&ticket.ticket_type))))\n && (self.priority.is_empty() || self.priority.iter().any(|p| glob_matches(p, &ticket.priority.to_string())))\n && (self.title.is_empty() || self.title.iter().any(|p| glob_matches(p, &ticket.title)))\n}\n```\n\nUse `status_str` and `ticket_type_str` equivalents (can be private functions in filter.rs\nor call into display, or duplicate the small match arms).\n\n## Module registration\n\nAdd `mod filter;` to `src/main.rs` (or move to `lib.rs` if the project ever gains one).\nThe module is `pub(crate)`.\n\n## Files touched\n\n- `src/filter.rs` — new module\n- `src/main.rs` — add `mod filter;`\n- `src/tests.rs` — unit tests\n\n## Unit tests to write (src/tests.rs)\n\n- `parse_filters` rejects unknown keys with a descriptive error.\n- `parse_filters` rejects a string with no `=`.\n- `parse_filters` with `key=value=more` treats everything after first `=` as the value.\n- `glob_matches(\"*\", \"anything\")` → true.\n- `glob_matches(\"*\", \"\")` → true.\n- `glob_matches(\"todo\", \"todo\")` → true; `glob_matches(\"todo\", \"done\")` → false.\n- `glob_matches(\"*command*\", \"add command here\")` → true.\n- `glob_matches(\"*command*\", \"no match\")` → false.\n- `glob_matches(\"in_*\", \"in_progress\")` → true.\n- `glob_matches(\"in_*\", \"todo\")` → false.\n- `TicketFilter::matches` — two different keys AND correctly (both must match).\n- `TicketFilter::matches` — same key OR correctly (either matches).\n- `TicketFilter::matches` — empty filter matches everything.\n- `TicketFilter::is_empty` — true when no filters, false when any filter set.\n- `TicketFilter::has_status_filter` — true iff status vec is non-empty.", - "priority": 8, - "status": "done", - "dependencies": [], - "ticket_type": "feature" -} \ No newline at end of file diff --git a/nbd/.nbd/tickets/c2a024.md b/nbd/.nbd/tickets/c2a024.md new file mode 100644 index 0000000..4adf4df --- /dev/null +++ b/nbd/.nbd/tickets/c2a024.md @@ -0,0 +1,132 @@ ++++ +title = "Implement TicketFilter module with glob matching" +priority = 8 +status = "done" +ticket_type = "feature" +dependencies = [] ++++ +## 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. \ No newline at end of file diff --git a/nbd/.nbd/tickets/c9d551.json b/nbd/.nbd/tickets/c9d551.json deleted file mode 100644 index 72a1835..0000000 --- a/nbd/.nbd/tickets/c9d551.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "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": "done", - "dependencies": [], - "ticket_type": "feature" -} \ No newline at end of file diff --git a/nbd/.nbd/tickets/c9d551.md b/nbd/.nbd/tickets/c9d551.md new file mode 100644 index 0000000..4087712 --- /dev/null +++ b/nbd/.nbd/tickets/c9d551.md @@ -0,0 +1,43 @@ ++++ +title = "Partial ID matching" +priority = 8 +status = "done" +ticket_type = "feature" +dependencies = [] ++++ +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 \ No newline at end of file diff --git a/nbd/.nbd/tickets/d1634a.json b/nbd/.nbd/tickets/d1634a.json deleted file mode 100644 index 827d0d5..0000000 --- a/nbd/.nbd/tickets/d1634a.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "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/d1634a.md b/nbd/.nbd/tickets/d1634a.md new file mode 100644 index 0000000..1b2d9f1 --- /dev/null +++ b/nbd/.nbd/tickets/d1634a.md @@ -0,0 +1,80 @@ ++++ +title = "Remove id field from ticket JSON body" +priority = 9 +status = "done" +ticket_type = "task" +dependencies = [] ++++ +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 \ No newline at end of file diff --git a/nbd/.nbd/tickets/e1968f.json b/nbd/.nbd/tickets/e1968f.json deleted file mode 100644 index 7b306d6..0000000 --- a/nbd/.nbd/tickets/e1968f.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "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": "done", - "dependencies": [], - "ticket_type": "feature" -} \ No newline at end of file diff --git a/nbd/.nbd/tickets/e1968f.md b/nbd/.nbd/tickets/e1968f.md new file mode 100644 index 0000000..3076b90 --- /dev/null +++ b/nbd/.nbd/tickets/e1968f.md @@ -0,0 +1,41 @@ ++++ +title = "nbd ready command" +priority = 7 +status = "done" +ticket_type = "feature" +dependencies = [] ++++ +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 \ No newline at end of file diff --git a/nbd/.nbd/tickets/fc444f.json b/nbd/.nbd/tickets/fc444f.json deleted file mode 100644 index 0c51654..0000000 --- a/nbd/.nbd/tickets/fc444f.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "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": "done", - "dependencies": [], - "ticket_type": "feature" -} \ No newline at end of file diff --git a/nbd/.nbd/tickets/fc444f.md b/nbd/.nbd/tickets/fc444f.md new file mode 100644 index 0000000..405e591 --- /dev/null +++ b/nbd/.nbd/tickets/fc444f.md @@ -0,0 +1,68 @@ ++++ +title = "nbd claude-md command" +priority = 6 +status = "done" +ticket_type = "feature" +dependencies = [] ++++ +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 \ No newline at end of file diff --git a/nbd/.nbd/tickets/fc6df4.json b/nbd/.nbd/tickets/fc6df4.json deleted file mode 100644 index 91a94f3..0000000 --- a/nbd/.nbd/tickets/fc6df4.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "title": "Add nbd next subcommand", - "body": "## Summary\n\nAdd `nbd next` subcommand that selects the single highest-priority ticket that is\nready to work on. Supports `--filter` for additional narrowing within the ready set.\n\nDepends on: `--filter` wired into CLI (ticket 887344). Implicitly depends on the\nTicketFilter module (ticket c2a024) through that.\n\n## Definition of \"ready\"\n\nSame semantics as `nbd ready`:\n- `status \\!= done`\n- Every ID in `ticket.dependencies` belongs to a ticket with `status == done`.\n- Missing dependency IDs are treated conservatively: the ticket is NOT ready.\n\n## Behaviour\n\n1. Load all tickets with `list_tickets(&root)` (already sorted by priority desc).\n2. Build `done_ids` set (same as `cmd_ready`).\n3. Find the first ticket (highest priority) where:\n - status \\!= done\n - all dependencies are in done_ids\n - `filter.matches(t)` (if a filter was provided)\n4. If found: display the single ticket.\n5. If not found: print a \"no ready tickets\" message. Exit 0 (not an error).\n\n## Output\n\nWithout `--json`:\n- Found: print the ticket in the same tabular format as `nbd read` (via `display::print_ticket`).\n- Not found: `No ready tickets.`\n\nWith `--json`:\n- Found: `{\"next\": { ...ticket fields including id... }}`\n- Not found: `{\"next\": null}`\n\nWrapping in `{\"next\": ...}` (rather than bare ticket or bare null) makes the JSON\nunambiguously parseable by consumers — they always get an object with a `next` key.\n\n## Implementation\n\nAdd to `Commands` enum:\n\n```rust\n/// Choose the highest-priority ticket that is ready to work on.\n///\n/// A ticket is ready when its status is not `done` and every ticket it\n/// depends on has status `done`. Returns the single highest-priority\n/// ready ticket, optionally narrowed by `--filter KEY=VALUE`.\n///\n/// Exits 0 even when no ready ticket exists.\nNext {\n /// Filter ready tickets: key=value pairs (repeatable).\n /// AND between different keys, OR within same key.\n #[arg(long = \"filter\", value_name = \"KEY=VALUE\")]\n filter: Vec,\n},\n```\n\nAdd dispatch arm:\n\n```rust\nCommands::Next { filter } => cmd_next(filter, cli.json).await,\n```\n\nImplement `cmd_next`:\n\n```rust\nasync fn cmd_next(filter_args: Vec, json: bool) -> store::Result<()> {\n let root = find_nbd_root()?;\n let all = list_tickets(&root).await?; // sorted by priority desc\n let filter = filter::parse_filters(&filter_args)?;\n\n let done_ids: std::collections::HashSet<&str> = all\n .iter()\n .filter(|t| t.status == Status::Done)\n .map(|t| t.id.as_str())\n .collect();\n\n let next = all.iter().find(|t| {\n t.status \\!= Status::Done\n && t.dependencies.iter().all(|dep| done_ids.contains(dep.as_str()))\n && filter.matches(t)\n });\n\n if json {\n match next {\n Some(ticket) => {\n let value = serde_json::json\\!({\n \"next\": display::ticket_to_json_value(ticket)\n });\n println\\!(\"{}\", serde_json::to_string_pretty(&value)?);\n }\n None => println\\!(\"{}\", serde_json::json\\!({\"next\": null})),\n }\n } else {\n match next {\n Some(ticket) => display::print_ticket(ticket),\n None => println\\!(\"No ready tickets.\"),\n }\n }\n\n Ok(())\n}\n```\n\n## display.rs: make ticket_to_json_value pub(crate)\n\n`ticket_to_json_value` is currently private in `display.rs`. It needs to be accessible\nfrom `cmd_next` in `main.rs`. Change its visibility:\n\n```rust\npub(crate) fn ticket_to_json_value(ticket: &Ticket) -> serde_json::Value { ... }\n```\n\nThis is the cleanest approach — it reuses the existing id-injection logic rather than\nduplicating it.\n\n## README update\n\nAdd a `### Find the next ticket to work on` section:\n\n```markdown\n### Find the next ticket to work on\n\nReturns the single highest-priority ticket that is ready to work on — not done\nand with all dependencies completed.\n\n```sh\nnbd next\nnbd next --json\nnbd next --filter type=bug # highest-priority ready bug\nnbd next --filter priority=9 # highest-priority ready ticket with priority 9\n```\n\nExits 0 even when no ready ticket exists.\n```\n\n## CLAUDE.md update\n\nUpdate the \"Workflow\" section to mention `nbd next` as an alternative to `nbd ready`\nwhen the caller just wants to begin the single most important task:\n\n```\n**To get the single best ticket to work on next:**\n\n```sh\ncargo run -- next --json\n```\n```\n\n## Files touched\n\n- `src/main.rs` — `Next` command variant, `cmd_next` handler, dispatch arm\n- `src/display.rs` — `ticket_to_json_value` changed to `pub(crate)`\n- `tests/integration.rs` — integration tests\n- `README.md` — new section for `nbd next`\n- `CLAUDE.md` — update workflow section\n\n## Integration tests to add (tests/integration.rs)\n\n- Three tickets: A (priority 5, no deps), B (priority 8, dep A), C (priority 7, no deps).\n `nbd next --json` returns C (highest priority ready ticket — B is blocked by A).\n- After marking A done: `nbd next --json` returns B (priority 8, now unblocked).\n- With only done tickets: `nbd next --json` returns `{\"next\": null}`.\n- `nbd next` (no `--json`) with no ready tickets prints \"No ready tickets.\" and exits 0.\n- `nbd next --filter type=bug --json`: create a bug and a task, both ready.\n Returns the bug if it's the highest-priority bug, otherwise the highest-priority bug.\n (Create bug priority 8, task priority 9: with filter, should return the bug.)\n- `nbd next --json` returns a JSON object with a `\"next\"` key containing all ticket fields\n including `\"id\"`.\n- `nbd next --filter priority=99 --json` returns `{\"next\": null}` (no ticket has priority 99).", - "priority": 7, - "status": "done", - "dependencies": [ - "887344" - ], - "ticket_type": "feature" -} \ No newline at end of file diff --git a/nbd/.nbd/tickets/fc6df4.md b/nbd/.nbd/tickets/fc6df4.md new file mode 100644 index 0000000..8c4828d --- /dev/null +++ b/nbd/.nbd/tickets/fc6df4.md @@ -0,0 +1,179 @@ ++++ +title = "Add nbd next subcommand" +priority = 7 +status = "done" +ticket_type = "feature" +dependencies = ["887344"] ++++ +## 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). \ No newline at end of file