chore: remove nbd project
nbd has been superseded by beans for issue tracking. All tickets were migrated to .beans/ in the previous commits. Remove the nbd/ project directory, its flake input, and all references. - Remove nbd/ (source, tests, docs, beans, skills, flake) - Remove nbd flake input and nbd package from devShell in flake.nix - Update flake.lock to drop nbd and its transitive inputs - Remove nbd entry from PROJECTS.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
83d5abde8a
commit
b335009a21
@ -1,6 +0,0 @@
|
|||||||
beans:
|
|
||||||
path: .beans
|
|
||||||
prefix: nbd-
|
|
||||||
id_length: 4
|
|
||||||
default_status: todo
|
|
||||||
default_type: task
|
|
||||||
@ -1,184 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-08jg
|
|
||||||
title: Add nbd next subcommand
|
|
||||||
status: completed
|
|
||||||
type: feature
|
|
||||||
priority: high
|
|
||||||
created_at: 2026-03-10T23:30:31Z
|
|
||||||
updated_at: 2026-03-10T23:30:32Z
|
|
||||||
blocked_by:
|
|
||||||
- nbd-p9na
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Add `nbd next` subcommand that selects the single highest-priority ticket that is
|
|
||||||
ready to work on. Supports `--filter` for additional narrowing within the ready set.
|
|
||||||
|
|
||||||
Depends on: `--filter` wired into CLI (ticket 887344). Implicitly depends on the
|
|
||||||
TicketFilter module (ticket c2a024) through that.
|
|
||||||
|
|
||||||
## Definition of "ready"
|
|
||||||
|
|
||||||
Same semantics as `nbd ready`:
|
|
||||||
- `status \!= done`
|
|
||||||
- Every ID in `ticket.dependencies` belongs to a ticket with `status == done`.
|
|
||||||
- Missing dependency IDs are treated conservatively: the ticket is NOT ready.
|
|
||||||
|
|
||||||
## Behaviour
|
|
||||||
|
|
||||||
1. Load all tickets with `list_tickets(&root)` (already sorted by priority desc).
|
|
||||||
2. Build `done_ids` set (same as `cmd_ready`).
|
|
||||||
3. Find the first ticket (highest priority) where:
|
|
||||||
- status \!= done
|
|
||||||
- all dependencies are in done_ids
|
|
||||||
- `filter.matches(t)` (if a filter was provided)
|
|
||||||
4. If found: display the single ticket.
|
|
||||||
5. If not found: print a "no ready tickets" message. Exit 0 (not an error).
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
Without `--json`:
|
|
||||||
- Found: print the ticket in the same tabular format as `nbd read` (via `display::print_ticket`).
|
|
||||||
- Not found: `No ready tickets.`
|
|
||||||
|
|
||||||
With `--json`:
|
|
||||||
- Found: `{"next": { ...ticket fields including id... }}`
|
|
||||||
- Not found: `{"next": null}`
|
|
||||||
|
|
||||||
Wrapping in `{"next": ...}` (rather than bare ticket or bare null) makes the JSON
|
|
||||||
unambiguously parseable by consumers — they always get an object with a `next` key.
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
Add to `Commands` enum:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// Choose the highest-priority ticket that is ready to work on.
|
|
||||||
///
|
|
||||||
/// A ticket is ready when its status is not `done` and every ticket it
|
|
||||||
/// depends on has status `done`. Returns the single highest-priority
|
|
||||||
/// ready ticket, optionally narrowed by `--filter KEY=VALUE`.
|
|
||||||
///
|
|
||||||
/// Exits 0 even when no ready ticket exists.
|
|
||||||
Next {
|
|
||||||
/// Filter ready tickets: key=value pairs (repeatable).
|
|
||||||
/// AND between different keys, OR within same key.
|
|
||||||
#[arg(long = "filter", value_name = "KEY=VALUE")]
|
|
||||||
filter: Vec<String>,
|
|
||||||
},
|
|
||||||
```
|
|
||||||
|
|
||||||
Add dispatch arm:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
Commands::Next { filter } => cmd_next(filter, cli.json).await,
|
|
||||||
```
|
|
||||||
|
|
||||||
Implement `cmd_next`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
async fn cmd_next(filter_args: Vec<String>, 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).
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-17dp
|
|
||||||
title: ASCII graph rendering in display.rs
|
|
||||||
status: completed
|
|
||||||
type: feature
|
|
||||||
priority: normal
|
|
||||||
created_at: 2026-03-10T23:30:31Z
|
|
||||||
updated_at: 2026-03-10T23:30:32Z
|
|
||||||
blocked_by:
|
|
||||||
- nbd-csdh
|
|
||||||
---
|
|
||||||
|
|
||||||
Add `format_graph` and `print_graph` to `src/display.rs` to render a ticket dependency DAG as an ASCII tree.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
The `nbd graph` command needs to convert the `TicketGraph` data structure (from `src/graph.rs`) into a human-readable ASCII representation. Rendering belongs in `display.rs` next to `format_list` and `format_ticket`.
|
|
||||||
|
|
||||||
## Output format
|
|
||||||
|
|
||||||
The graph is a forest of trees. Each root ticket (no in-graph dependencies) starts at column 0. Its dependents (tickets that depend on it) are indented below it using box-drawing characters.
|
|
||||||
|
|
||||||
```
|
|
||||||
a3f9c2 [todo] Fix login bug
|
|
||||||
├── b7d41e [todo] Add rate limiting
|
|
||||||
│ └── c9e823 [in_progress] Write tests
|
|
||||||
└── d1f302 [done] Update docs
|
|
||||||
e4a781 [todo] New feature (no deps)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Node format per line:**
|
|
||||||
```
|
|
||||||
{prefix}{id} [{status}] {title}
|
|
||||||
```
|
|
||||||
|
|
||||||
Where `{prefix}` is built from indentation characters (`│ `, `├── `, `└── `).
|
|
||||||
|
|
||||||
**Blocked indicator (optional):** Consider marking tickets that have unresolved (non-done/closed) dependencies with a `[blocked]` tag or `!` prefix so the graph visually distinguishes ready from blocked tickets.
|
|
||||||
|
|
||||||
## Signatures
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// Render the full dependency forest as an ASCII tree string.
|
|
||||||
pub fn format_graph(graph: &TicketGraph) -> String
|
|
||||||
|
|
||||||
/// Print the full dependency forest.
|
|
||||||
pub fn print_graph(graph: &TicketGraph)
|
|
||||||
|
|
||||||
/// Render the subtree rooted at `root_id` as an ASCII tree string.
|
|
||||||
pub fn format_subtree(graph: &TicketGraph, root_id: &str) -> String
|
|
||||||
|
|
||||||
/// Print the subtree rooted at `root_id`.
|
|
||||||
pub fn print_subtree(graph: &TicketGraph, root_id: &str)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implementation notes
|
|
||||||
|
|
||||||
- Use a recursive helper that tracks a `prefix: String` carrying the accumulated indentation characters.
|
|
||||||
- For each node's children (its dependents in the graph), iterate:
|
|
||||||
- If not the last child: prefix extension is `│ `; connector is `├── `.
|
|
||||||
- If the last child: prefix extension is ` `; connector is `└── `.
|
|
||||||
- Cycle guard: track a `visited: HashSet<&str>` across the recursion; if a node ID is already visited, render `{prefix}{connector}{id} [cycle]` and stop descending.
|
|
||||||
- Status is shown as the serde string: `todo`, `in_progress`, `done`, `closed`.
|
|
||||||
|
|
||||||
## Files touched
|
|
||||||
- `src/display.rs` — `format_graph`, `print_graph`, `format_subtree`, `print_subtree`
|
|
||||||
|
|
||||||
## Tests (unit, in `src/tests.rs`)
|
|
||||||
- `format_graph` on a single ticket with no deps produces a single line.
|
|
||||||
- `format_graph` on a two-ticket chain shows the child indented with `└──`.
|
|
||||||
- `format_graph` with a branching parent shows `├──` for all but the last child and `└──` for the last.
|
|
||||||
- `format_subtree` only shows the specified root's subtree.
|
|
||||||
- A cycle in the graph does not cause infinite recursion; the repeated node is labelled `[cycle]`.
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-2i1e
|
|
||||||
title: nbd claude-md command
|
|
||||||
status: completed
|
|
||||||
type: feature
|
|
||||||
priority: normal
|
|
||||||
created_at: 2026-03-10T23:30:31Z
|
|
||||||
updated_at: 2026-03-10T23:30:31Z
|
|
||||||
---
|
|
||||||
|
|
||||||
Add `nbd claude-md` subcommand that prints a ready-to-paste CLAUDE.md snippet for adopting `nbd` in any project. The snippet content is maintained as a source file and baked into the binary at compile time via `include_str!`.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
Every project that wants to use `nbd` needs the same boilerplate in its CLAUDE.md: what `nbd` is, how to invoke it, the create/update/done workflow, and the key guidelines. Without this command, that block has to be written by hand and tends to drift out of date as `nbd` evolves. By owning the canonical snippet in the `nbd` source tree and embedding it into the binary, the snippet stays in sync with the tool automatically — projects just run `nbd claude-md >> CLAUDE.md` when they adopt or upgrade `nbd`.
|
|
||||||
|
|
||||||
## Snippet file
|
|
||||||
|
|
||||||
Create `src/claude_md_snippet.md`. This is a plain markdown file, checked into the repository, that contains the CLAUDE.md section any adopting project should paste in. It should cover:
|
|
||||||
|
|
||||||
- One-sentence description of `nbd`.
|
|
||||||
- Initialisation (`nbd init`).
|
|
||||||
- The four core commands with examples (`create`, `list`, `read`, `update`).
|
|
||||||
- The `nbd ready` command for finding unblocked tickets.
|
|
||||||
- The workflow (create before starting → set in_progress → set done).
|
|
||||||
- Guidelines: always `--json`, priority scale, type choices, dep usage.
|
|
||||||
|
|
||||||
The file is written for a project where `nbd` is installed in PATH (i.e. not via `cargo run`). It should be self-contained — no references to the `nbd` crate internals.
|
|
||||||
|
|
||||||
## Binary embedding
|
|
||||||
|
|
||||||
In `main.rs`, embed the snippet at compile time:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
const CLAUDE_MD_SNIPPET: &str = include_str!("claude_md_snippet.md");
|
|
||||||
```
|
|
||||||
|
|
||||||
`include_str!` resolves paths relative to the source file (`src/`), so this looks for `src/claude_md_snippet.md`. Cargo rebuilds the binary automatically when the file changes.
|
|
||||||
|
|
||||||
## Command implementation
|
|
||||||
|
|
||||||
Add `ClaudeMd` variant to `Commands` in `main.rs`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// Print a CLAUDE.md snippet for adopting nbd in a project.
|
|
||||||
ClaudeMd,
|
|
||||||
```
|
|
||||||
|
|
||||||
The `--json` global flag applies:
|
|
||||||
- Without `--json`: `print!("{CLAUDE_MD_SNIPPET}")` — raw markdown, suitable for redirect (`nbd claude-md >> CLAUDE.md`).
|
|
||||||
- With `--json`: output `{"snippet": "<escaped content>"}` — 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
|
|
||||||
@ -1,138 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-4w8z
|
|
||||||
title: Exclude done tickets from nbd list by default
|
|
||||||
status: completed
|
|
||||||
type: feature
|
|
||||||
priority: high
|
|
||||||
created_at: 2026-03-10T23:30:30Z
|
|
||||||
updated_at: 2026-03-10T23:30:32Z
|
|
||||||
blocked_by:
|
|
||||||
- nbd-p9na
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Change `nbd list` to hide `done` tickets by default. Users must explicitly opt in
|
|
||||||
via `--filter status=done` or `--filter status=*` to see completed tickets.
|
|
||||||
|
|
||||||
This is a **breaking change in default behavior**.
|
|
||||||
|
|
||||||
Depends on: `--filter` wired into `list` (ticket 887344).
|
|
||||||
|
|
||||||
## Current behaviour
|
|
||||||
|
|
||||||
`nbd list` shows ALL tickets regardless of status.
|
|
||||||
|
|
||||||
## New behaviour
|
|
||||||
|
|
||||||
| Command | Shows |
|
|
||||||
|---|---|
|
|
||||||
| `nbd list` | todo + in_progress only (done excluded) |
|
|
||||||
| `nbd list --filter status=done` | done only |
|
|
||||||
| `nbd list --filter status=*` | all tickets (all statuses) |
|
|
||||||
| `nbd list --filter status=todo --filter status=in_progress` | todo and in_progress |
|
|
||||||
| `nbd list --filter type=bug` | non-done bug tickets (done still excluded) |
|
|
||||||
| `nbd list --filter type=bug --filter status=*` | all bug tickets including done |
|
|
||||||
|
|
||||||
The key rule: **if the user provides no `status` filter key, done tickets are excluded**.
|
|
||||||
If the user provides any `--filter status=...` argument, the explicit status filter
|
|
||||||
is used as-is with no implicit exclusion.
|
|
||||||
|
|
||||||
## Implementation (src/main.rs)
|
|
||||||
|
|
||||||
In `cmd_list`, after parsing the filter and loading tickets, apply the logic:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
let filter = filter::parse_filters(&filter_args)?;
|
|
||||||
let tickets = list_tickets(&root).await?;
|
|
||||||
|
|
||||||
let tickets: Vec<Ticket> = 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<String>,
|
|
||||||
},
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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
|
|
||||||
@ -1,95 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-56ho
|
|
||||||
title: Tests for nbd graph command
|
|
||||||
status: completed
|
|
||||||
type: task
|
|
||||||
priority: normal
|
|
||||||
created_at: 2026-03-10T23:30:31Z
|
|
||||||
updated_at: 2026-03-10T23:30:32Z
|
|
||||||
blocked_by:
|
|
||||||
- nbd-d9dh
|
|
||||||
---
|
|
||||||
|
|
||||||
Add comprehensive unit and integration tests for the `nbd graph` command introduced across tickets `9c9ebe`, `e14172`, and `9ad11f`.
|
|
||||||
|
|
||||||
## Unit tests (src/tests.rs)
|
|
||||||
|
|
||||||
These test `graph.rs` and the rendering helpers in `display.rs` in isolation using in-memory `Ticket` values (no temp files needed).
|
|
||||||
|
|
||||||
### graph.rs tests
|
|
||||||
|
|
||||||
```
|
|
||||||
test_graph_build_empty
|
|
||||||
TicketGraph::build(&[]) has no nodes.
|
|
||||||
|
|
||||||
test_graph_roots_no_deps
|
|
||||||
Two tickets with no dependencies → both appear in roots().
|
|
||||||
|
|
||||||
test_graph_roots_with_chain
|
|
||||||
Ticket B depends on A → only A is a root.
|
|
||||||
|
|
||||||
test_graph_subtree_linear
|
|
||||||
A → B → C; subtree("a") returns ["a", "b", "c"] (DFS order).
|
|
||||||
|
|
||||||
test_graph_subtree_single
|
|
||||||
subtree of a leaf returns just that ID.
|
|
||||||
|
|
||||||
test_graph_subtree_cycle
|
|
||||||
A.deps = [B], B.deps = [A] → subtree("a") returns both without panic/infinite loop.
|
|
||||||
|
|
||||||
test_graph_to_json_nodes_and_edges
|
|
||||||
Three tickets with two edges → JSON "nodes" has 3 entries, "edges" has 2.
|
|
||||||
```
|
|
||||||
|
|
||||||
### display.rs tests
|
|
||||||
|
|
||||||
```
|
|
||||||
test_format_graph_single_ticket
|
|
||||||
One ticket, no deps → output contains the ID, status, and title on one line; no connector chars.
|
|
||||||
|
|
||||||
test_format_graph_two_ticket_chain
|
|
||||||
A → B; output has A at col 0 and B indented with "└──".
|
|
||||||
|
|
||||||
test_format_graph_branching
|
|
||||||
A has two dependents B and C; B's line uses "├──" and C's uses "└──".
|
|
||||||
|
|
||||||
test_format_subtree_scope
|
|
||||||
A → B, C (unrelated root); format_subtree(graph, "a") does not contain C's ID.
|
|
||||||
|
|
||||||
test_format_graph_cycle_label
|
|
||||||
Cycle present → output contains "[cycle]" and does not repeat infinitely.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integration tests (tests/integration.rs)
|
|
||||||
|
|
||||||
Use a real temp directory set up with `cargo run -- init` and tickets created via `cmd_create`.
|
|
||||||
|
|
||||||
```
|
|
||||||
test_graph_empty_store
|
|
||||||
`nbd graph` on an empty store produces an empty line (or at least exits 0).
|
|
||||||
|
|
||||||
test_graph_all_no_deps
|
|
||||||
Create two tickets without deps; `nbd graph` output contains both IDs with no indentation.
|
|
||||||
|
|
||||||
test_graph_chain
|
|
||||||
Create A (no deps) and B (--deps A); `nbd graph` output shows A at top level and B indented.
|
|
||||||
|
|
||||||
test_graph_single_ticket
|
|
||||||
`nbd graph <id>` for A returns only A and its subtree, not unrelated tickets.
|
|
||||||
|
|
||||||
test_graph_json_output
|
|
||||||
`nbd graph --json` parses as valid JSON with "nodes" and "edges" arrays.
|
|
||||||
|
|
||||||
test_graph_json_subtree
|
|
||||||
`nbd graph <id> --json` returns JSON whose "nodes" array contains only reachable tickets.
|
|
||||||
|
|
||||||
test_graph_filter
|
|
||||||
`nbd graph --filter type=bug` only shows bug tickets and their reachable deps.
|
|
||||||
|
|
||||||
test_graph_partial_id
|
|
||||||
`nbd graph <3-char-prefix>` resolves to the correct ticket (prefix resolution).
|
|
||||||
```
|
|
||||||
|
|
||||||
## Files touched
|
|
||||||
- `src/tests.rs` — unit tests for `TicketGraph` and `format_graph` / `format_subtree`
|
|
||||||
- `tests/integration.rs` — CLI-level integration tests
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-6j0q
|
|
||||||
title: Print tickets in markdown format instead of key-value table
|
|
||||||
status: completed
|
|
||||||
type: feature
|
|
||||||
priority: normal
|
|
||||||
created_at: 2026-03-10T23:30:30Z
|
|
||||||
updated_at: 2026-03-10T23:30:30Z
|
|
||||||
---
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
When a ticket is printed to stdout (e.g. by `nbd read`, `nbd create`, `nbd archive`, `nbd next`, `nbd update` without `--json`), it should be rendered as markdown with TOML frontmatter — the same format used by `--ftype md` files on disk — rather than the current key-value table.
|
|
||||||
|
|
||||||
`--json` output is unaffected.
|
|
||||||
|
|
||||||
## Current output (`nbd read <id>`)
|
|
||||||
|
|
||||||
```
|
|
||||||
ID: a3f9c2
|
|
||||||
Title: Fix login bug
|
|
||||||
Body: Users cannot log in with email +
|
|
||||||
Priority: 8
|
|
||||||
Status: in_progress
|
|
||||||
Type: bug
|
|
||||||
Dependencies: b7d41e, c9e823
|
|
||||||
```
|
|
||||||
|
|
||||||
## Target output
|
|
||||||
|
|
||||||
```
|
|
||||||
+++
|
|
||||||
title = "Fix login bug"
|
|
||||||
priority = 8
|
|
||||||
status = "in_progress"
|
|
||||||
ticket_type = "bug"
|
|
||||||
dependencies = ["b7d41e", "c9e823"]
|
|
||||||
+++
|
|
||||||
Users cannot log in with email +
|
|
||||||
```
|
|
||||||
|
|
||||||
The `id` field is not in the frontmatter on disk (filename is the source of truth), but for display purposes it should appear as a comment or additional line. Recommended approach: add `id = "a3f9c2"` as the **first key** in the frontmatter so it's immediately visible:
|
|
||||||
|
|
||||||
```
|
|
||||||
+++
|
|
||||||
id = "a3f9c2"
|
|
||||||
title = "Fix login bug"
|
|
||||||
priority = 8
|
|
||||||
status = "in_progress"
|
|
||||||
ticket_type = "bug"
|
|
||||||
dependencies = ["b7d41e", "c9e823"]
|
|
||||||
+++
|
|
||||||
Users cannot log in with email +
|
|
||||||
```
|
|
||||||
|
|
||||||
## Files to change
|
|
||||||
|
|
||||||
### `src/display.rs`
|
|
||||||
|
|
||||||
Replace `format_ticket` with a markdown-rendering implementation. The simplest approach reuses `serialize_markdown` from `src/store.rs`, adding `id` to the frontmatter output.
|
|
||||||
|
|
||||||
Option A (preferred): expose `serialize_markdown` from `store.rs` as `pub(crate)` and call it from `display.rs`, prepending the `id` line to the TOML frontmatter block.
|
|
||||||
|
|
||||||
Option B: duplicate the logic in `display.rs` as a display-specific formatter that includes `id`.
|
|
||||||
|
|
||||||
The `print_ticket` function should call the new formatter.
|
|
||||||
|
|
||||||
### `src/tests.rs`
|
|
||||||
|
|
||||||
Update any unit tests for `format_ticket` that check the old key-value table format. They should now expect TOML frontmatter.
|
|
||||||
|
|
||||||
### `tests/integration.rs`
|
|
||||||
|
|
||||||
Update any integration tests that check the plain-text output of `nbd read`, `nbd create`, etc.
|
|
||||||
|
|
||||||
## Commands affected
|
|
||||||
|
|
||||||
Any command that calls `display::print_ticket`:
|
|
||||||
- `nbd create` (non-JSON path)
|
|
||||||
- `nbd read` (non-JSON path)
|
|
||||||
- `nbd archive` (non-JSON path)
|
|
||||||
- `nbd next` (non-JSON path, single ticket)
|
|
||||||
- `nbd update` currently calls `print_diff`, so it is **not** affected
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cargo fmt && cargo check && cargo clippy && cargo test
|
|
||||||
cargo run -- create --title "Test ticket" --body "Some body text" --priority 7 --type bug
|
|
||||||
# Output should be:
|
|
||||||
# +++
|
|
||||||
# id = "<id>"
|
|
||||||
# title = "Test ticket"
|
|
||||||
# priority = 7
|
|
||||||
# status = "todo"
|
|
||||||
# ticket_type = "bug"
|
|
||||||
# dependencies = []
|
|
||||||
# +++
|
|
||||||
# Some body text
|
|
||||||
```
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-7cab
|
|
||||||
title: Add backlog status to tickets
|
|
||||||
status: completed
|
|
||||||
type: feature
|
|
||||||
priority: normal
|
|
||||||
created_at: 2026-03-10T23:30:31Z
|
|
||||||
updated_at: 2026-03-10T23:30:31Z
|
|
||||||
---
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Add a `backlog` status variant so tickets can be created without immediately surfacing in the active work queue. Tickets in `backlog` are created but intentionally deferred; they should not appear in `nbd list`, `nbd ready`, or `nbd next` by default.
|
|
||||||
|
|
||||||
## Semantics
|
|
||||||
|
|
||||||
- `backlog` = created, but not yet ready to be worked on (intentionally deferred)
|
|
||||||
- Default status for new tickets remains `todo`
|
|
||||||
- `backlog` tickets are excluded from `nbd list` (same as `done` and `closed`)
|
|
||||||
- `backlog` tickets are excluded from `nbd ready` and `nbd next`
|
|
||||||
- `backlog` tickets do **not** count as resolved for dependency purposes (unlike `done` and `closed`) — a dependency on a `backlog` ticket still blocks
|
|
||||||
- `backlog` tickets are visible with `--all`, `--filter status=backlog`, or `--filter status=*`
|
|
||||||
|
|
||||||
## Files to change
|
|
||||||
|
|
||||||
### `src/ticket.rs`
|
|
||||||
|
|
||||||
Add `Backlog` variant to the `Status` enum:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub enum Status {
|
|
||||||
#[default]
|
|
||||||
Todo,
|
|
||||||
InProgress,
|
|
||||||
Done,
|
|
||||||
Closed,
|
|
||||||
Backlog, // new
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Serialises as `"backlog"`.
|
|
||||||
|
|
||||||
### `src/main.rs`
|
|
||||||
|
|
||||||
- `parse_status`: add `"backlog" => Ok(Status::Backlog)` arm and update the error message
|
|
||||||
- `cmd_list`: exclude `Status::Backlog` in the default (no `--all`, no `status=` filter) case, alongside `Done` and `Closed`
|
|
||||||
- `cmd_ready`: exclude `Status::Backlog` from the ready set (a backlog ticket is never ready)
|
|
||||||
- `cmd_next`: same exclusion as `cmd_ready`
|
|
||||||
|
|
||||||
### `src/display.rs`
|
|
||||||
|
|
||||||
- `status_str`: add `Status::Backlog => "backlog"` arm
|
|
||||||
|
|
||||||
### `src/graph.rs`
|
|
||||||
|
|
||||||
- `status_str` (internal helper, duplicated): add `Status::Backlog => "backlog"` arm
|
|
||||||
|
|
||||||
### README.md
|
|
||||||
|
|
||||||
- Update the Status table to include `backlog`
|
|
||||||
- Update `nbd list` usage examples to mention that `backlog` is also hidden by default
|
|
||||||
|
|
||||||
### CLAUDE.md
|
|
||||||
|
|
||||||
- Update CLI Interface block to show `backlog` as a valid status value
|
|
||||||
|
|
||||||
### `src/tests.rs`
|
|
||||||
|
|
||||||
- Add unit test: a ticket with `Status::Backlog` is excluded from the ready list and is visible under `--all`
|
|
||||||
|
|
||||||
### `tests/integration.rs`
|
|
||||||
|
|
||||||
- Add integration test: create a ticket with `--status backlog`, confirm it does not appear in plain `nbd list` or `nbd ready`, and does appear in `nbd list --all`
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cargo fmt && cargo check && cargo clippy && cargo test
|
|
||||||
cargo run -- create --title "Backlog item" --status backlog --json
|
|
||||||
cargo run -- list --json # should NOT appear
|
|
||||||
cargo run -- list --all --json # should appear
|
|
||||||
cargo run -- ready --json # should NOT appear
|
|
||||||
```
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-8yn8
|
|
||||||
title: Add status convenience sub-commands (open, start, complete, close)
|
|
||||||
status: todo
|
|
||||||
type: feature
|
|
||||||
priority: normal
|
|
||||||
created_at: 2026-03-10T23:30:31Z
|
|
||||||
updated_at: 2026-03-10T23:30:31Z
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
`nbd update <id> --status <x>` is verbose for the most common lifecycle transitions. `nbd archive` already exists as a top-level convenience — the same pattern should apply to all transitions.
|
|
||||||
|
|
||||||
## Sub-commands to add
|
|
||||||
|
|
||||||
| Command | Status set |
|
|
||||||
|---|---|
|
|
||||||
| `nbd open <id>` | `todo` |
|
|
||||||
| `nbd start <id>` | `in_progress` |
|
|
||||||
| `nbd complete <id>` | `done` |
|
|
||||||
| `nbd close <id>` | `closed` |
|
|
||||||
|
|
||||||
(`nbd archive` already exists — do not duplicate it.)
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
**`src/main.rs`**
|
|
||||||
|
|
||||||
Add four variants to the `Commands` enum, following the existing `Archive` pattern (line 183):
|
|
||||||
|
|
||||||
```rust
|
|
||||||
Open { id: String },
|
|
||||||
Start { id: String },
|
|
||||||
Complete { id: String },
|
|
||||||
Close { id: String },
|
|
||||||
```
|
|
||||||
|
|
||||||
Add handler functions `cmd_open`, `cmd_start`, `cmd_complete`, `cmd_close`. Each handler should:
|
|
||||||
1. Call `resolve_id` (supports partial IDs)
|
|
||||||
2. `find_ticket_path` + `detect_format` to preserve existing file format
|
|
||||||
3. `read_ticket`
|
|
||||||
4. Set `ticket.status` to the target status
|
|
||||||
5. `write_ticket` in the same format
|
|
||||||
6. Print the ticket (tabular or JSON via `--json`)
|
|
||||||
|
|
||||||
Wire up in `dispatch()` following the same pattern as `Commands::Archive`.
|
|
||||||
|
|
||||||
**`tests/integration.rs`**
|
|
||||||
|
|
||||||
Add integration tests for each command analogous to the existing archive test.
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-95l6
|
|
||||||
title: nbd init command
|
|
||||||
status: completed
|
|
||||||
type: feature
|
|
||||||
priority: high
|
|
||||||
created_at: 2026-03-10T23:30:30Z
|
|
||||||
updated_at: 2026-03-10T23:30:30Z
|
|
||||||
---
|
|
||||||
|
|
||||||
Add an explicit `nbd init` subcommand that creates `.nbd/tickets/` in the current working directory, analogous to `git init`.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
Currently users must run `mkdir -p .nbd/tickets` manually before first use. This is a friction point — especially for first-time users and agent workflows bootstrapping a new project.
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
|
|
||||||
1. Add `Init` variant to the `Commands` enum in `main.rs`.
|
|
||||||
2. Implement `cmd_init(json: bool) -> store::Result<()>`:
|
|
||||||
- Get cwd via `std::env::current_dir()`.
|
|
||||||
- Call `store::ensure_tickets_dir(&cwd)` (already exists, idempotent).
|
|
||||||
- Print confirmation: `initialised .nbd/tickets/ in <path>` (or JSON: `{"root": "<path>"}`).
|
|
||||||
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
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-9mxu
|
|
||||||
title: Add list status sub-commands (list todo, list backlog, etc.)
|
|
||||||
status: todo
|
|
||||||
type: feature
|
|
||||||
priority: low
|
|
||||||
created_at: 2026-03-10T23:30:30Z
|
|
||||||
updated_at: 2026-03-10T23:30:30Z
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
Filtering by status requires the verbose `--filter status=X`. Common patterns like listing only backlog or only completed tickets should have ergonomic shortcuts.
|
|
||||||
|
|
||||||
## Sub-commands to add (positional arg to `list`)
|
|
||||||
|
|
||||||
Accept an optional positional `<status>` argument to `nbd list`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
nbd list backlog # equivalent to: nbd list --filter status=backlog
|
|
||||||
nbd list closed # equivalent to: nbd list --filter status=closed
|
|
||||||
nbd list completed # equivalent to: nbd list --filter status=done
|
|
||||||
nbd list todo # equivalent to: nbd list --filter status=todo
|
|
||||||
nbd list in_progress # equivalent to: nbd list --filter status=in_progress
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: `completed` is an alias for `done` (avoids the awkward `nbd list done`).
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
**`src/main.rs`** — `Commands::List`
|
|
||||||
|
|
||||||
Add an optional positional argument `status` to the `List` variant:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
List {
|
|
||||||
status: Option<String>, // new: positional shorthand
|
|
||||||
filter: Vec<String>,
|
|
||||||
all: bool,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
In `cmd_list`, when `status` is `Some(s)`:
|
|
||||||
- Map `"completed"" → "done"`, others pass through
|
|
||||||
- Treat it as if the caller had passed `--filter status=<s>`
|
|
||||||
- The explicit `--filter` and `--all` flags should still override as today
|
|
||||||
|
|
||||||
If both `status` and `--filter status=...` are given, merge them (OR behaviour within the status group, consistent with `TicketFilter`).
|
|
||||||
|
|
||||||
**`tests/integration.rs`**
|
|
||||||
|
|
||||||
Add tests for each status shorthand.
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-9vrn
|
|
||||||
title: Add .nbd/config.toml for per-project defaults
|
|
||||||
status: todo
|
|
||||||
type: feature
|
|
||||||
priority: normal
|
|
||||||
created_at: 2026-03-10T23:30:30Z
|
|
||||||
updated_at: 2026-03-10T23:30:30Z
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
All defaults (output format, file type, default status) are hard-coded in the CLI. Users in a project that always uses `--json` or always creates `md`-format tickets must pass these flags repeatedly. A per-project config file would let them set these once.
|
|
||||||
|
|
||||||
## Config file
|
|
||||||
|
|
||||||
Location: `.nbd/config.toml` (inside the `.nbd/` root, alongside `tickets/` and `cache.db`).
|
|
||||||
|
|
||||||
Format (all keys optional — missing keys fall back to compiled defaults):
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[nbd]
|
|
||||||
json = false # default: false — use tabular output
|
|
||||||
ftype = "json" # default: "json" — ticket storage format
|
|
||||||
status = "todo" # default: "todo" — initial status for new tickets
|
|
||||||
```
|
|
||||||
|
|
||||||
## Precedence (highest to lowest)
|
|
||||||
|
|
||||||
1. Explicit CLI flag (e.g., `--json`, `--ftype md`)
|
|
||||||
2. Environment variable (not in scope for this ticket)
|
|
||||||
3. `.nbd/config.toml`
|
|
||||||
4. Compiled-in default
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
**`src/store.rs`** (or a new `src/config.rs`)
|
|
||||||
|
|
||||||
Add a `NbdConfig` struct and a `load_config(root: &Path) -> NbdConfig` function:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct NbdConfig {
|
|
||||||
pub json: bool,
|
|
||||||
pub ftype: FileFormat,
|
|
||||||
pub status: Status,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for NbdConfig { /* compiled-in defaults */ }
|
|
||||||
|
|
||||||
pub fn load_config(root: &Path) -> NbdConfig { /* read .nbd/config.toml, fall back to Default */ }
|
|
||||||
```
|
|
||||||
|
|
||||||
Parse with the `toml` crate (already in `Cargo.toml`). Errors in the config file should produce a helpful message to stderr and fall back to defaults rather than aborting.
|
|
||||||
|
|
||||||
**`src/main.rs`** — `dispatch()`
|
|
||||||
|
|
||||||
After `find_nbd_root()`, call `load_config`. Merge config values with CLI flags:
|
|
||||||
|
|
||||||
- `cli.json = cli.json || config.json` (CLI flag wins)
|
|
||||||
- For `--ftype`: if the user did not pass `--ftype`, use `config.ftype`
|
|
||||||
- For `--status` in `create`: if the user did not pass `--status`, use `config.status`
|
|
||||||
|
|
||||||
**`src/main.rs`** — `cmd_init`
|
|
||||||
|
|
||||||
After creating the tickets directory, write `.nbd/config.toml` with default values if it does not already exist.
|
|
||||||
|
|
||||||
**`tests/integration.rs`**
|
|
||||||
|
|
||||||
- Test that a config with `json = true` causes tabular commands to emit JSON.
|
|
||||||
- Test that a config with `ftype = "md"` causes `create` to write `.md` files.
|
|
||||||
- Test that explicit CLI flags override config values.
|
|
||||||
- Test idempotency of `nbd init` (does not overwrite existing config).
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
None. Can be implemented independently of other tickets.
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-csdh
|
|
||||||
title: Add graph computation module (src/graph.rs)
|
|
||||||
status: completed
|
|
||||||
type: feature
|
|
||||||
priority: normal
|
|
||||||
created_at: 2026-03-10T23:30:31Z
|
|
||||||
updated_at: 2026-03-10T23:30:31Z
|
|
||||||
---
|
|
||||||
|
|
||||||
Implement `src/graph.rs` — a module that builds a directed dependency graph from a flat list of tickets and provides the data structures needed by the ASCII renderer and JSON output.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
The `nbd graph` command (see CLI ticket) needs to traverse `Ticket.dependencies` edges to produce an ordered, tree-structured representation of the dependency DAG. This module isolates that logic from I/O and rendering.
|
|
||||||
|
|
||||||
## Data structures
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// A node in the dependency graph.
|
|
||||||
pub struct GraphNode<'a> {
|
|
||||||
pub ticket: &'a Ticket,
|
|
||||||
/// Direct dependents (tickets that list this ticket as a dependency).
|
|
||||||
pub dependents: Vec<&'a str>,
|
|
||||||
/// Direct dependencies (tickets this ticket depends on).
|
|
||||||
pub dependencies: Vec<&'a str>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A directed dependency graph built from a flat list of tickets.
|
|
||||||
pub struct TicketGraph<'a> {
|
|
||||||
nodes: IndexMap<&'a str, GraphNode<'a>>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Functions to implement
|
|
||||||
|
|
||||||
### `TicketGraph::build(tickets: &[Ticket]) -> TicketGraph`
|
|
||||||
- Constructs `nodes` map keyed by ticket ID.
|
|
||||||
- Iterates `ticket.dependencies` to populate both `dependencies` (forward) and `dependents` (reverse) edges.
|
|
||||||
- IDs in `dependencies` that do not correspond to a known ticket are silently ignored (dangling references are tolerated).
|
|
||||||
|
|
||||||
### `TicketGraph::roots(&self) -> Vec<&Ticket>`
|
|
||||||
- Returns tickets with no dependencies (or whose dependencies are all outside the graph).
|
|
||||||
- Sorted by priority descending (same ordering as `list_tickets`).
|
|
||||||
- These become the starting points for the recursive ASCII tree renderer.
|
|
||||||
|
|
||||||
### `TicketGraph::subtree(&self, root_id: &str) -> Vec<&str>`
|
|
||||||
- Returns all ticket IDs reachable from `root_id` by following `dependencies` edges in depth-first order, including `root_id` itself.
|
|
||||||
- Cycles: track visited set; stop recursion when an ID has been visited. This makes the function safe even if the data contains cycles.
|
|
||||||
|
|
||||||
### `TicketGraph::to_json_value(&self) -> serde_json::Value`
|
|
||||||
- Returns an object like:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nodes": [
|
|
||||||
{"id": "a3f9c2", "title": "...", "status": "todo", "dependencies": ["b7d41e"]},
|
|
||||||
...
|
|
||||||
],
|
|
||||||
"edges": [
|
|
||||||
{"from": "a3f9c2", "to": "b7d41e"},
|
|
||||||
...
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Crate dependencies
|
|
||||||
|
|
||||||
No new crates needed. If `IndexMap` insertion-order is useful, `indexmap` can be added — but a `HashMap` with a separate sorted `Vec<&str>` of keys also works. Prefer whatever avoids adding a new crate dependency.
|
|
||||||
|
|
||||||
## Files touched
|
|
||||||
- `src/graph.rs` — new file, public module
|
|
||||||
- `src/main.rs` — `mod graph;` declaration
|
|
||||||
|
|
||||||
## Tests (unit, in `src/tests.rs`)
|
|
||||||
- `build` with an empty slice returns an empty graph.
|
|
||||||
- `roots` returns only tickets with no in-graph dependencies.
|
|
||||||
- `subtree` returns the correct set of IDs for a linear chain.
|
|
||||||
- `subtree` does not infinite-loop when the data contains a cycle.
|
|
||||||
- `to_json_value` contains all expected IDs in `nodes` and all edges in `edges`.
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-d9dh
|
|
||||||
title: Add 'nbd graph' CLI subcommand
|
|
||||||
status: completed
|
|
||||||
type: feature
|
|
||||||
priority: normal
|
|
||||||
created_at: 2026-03-10T23:30:31Z
|
|
||||||
updated_at: 2026-03-10T23:30:32Z
|
|
||||||
blocked_by:
|
|
||||||
- nbd-csdh
|
|
||||||
- nbd-17dp
|
|
||||||
---
|
|
||||||
|
|
||||||
Wire up the `graph` subcommand in `src/main.rs` to expose the ASCII dependency graph and JSON output.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
With `src/graph.rs` and the rendering functions in `display.rs` implemented, this ticket connects them to the CLI so users (and agents) can invoke `nbd graph`.
|
|
||||||
|
|
||||||
## CLI interface
|
|
||||||
|
|
||||||
```sh
|
|
||||||
nbd graph # full dependency forest (all tickets)
|
|
||||||
nbd graph <id> # subtree rooted at the given ticket ID (or unique prefix)
|
|
||||||
nbd graph --json # machine-readable adjacency list (all tickets)
|
|
||||||
nbd graph <id> --json # machine-readable subtree
|
|
||||||
nbd graph --filter type=bug # forest of only bug tickets and their deps
|
|
||||||
```
|
|
||||||
|
|
||||||
## Clap definition (add to `Commands` enum in main.rs)
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// Draw an ASCII dependency graph of all tickets, or a subtree rooted at a
|
|
||||||
/// specific ticket.
|
|
||||||
///
|
|
||||||
/// Without an ID, renders every ticket as a dependency forest (roots first,
|
|
||||||
/// dependencies indented below). With an ID, renders only the subtree reachable
|
|
||||||
/// from that ticket via its dependencies.
|
|
||||||
Graph {
|
|
||||||
/// Optional ticket ID or unique prefix to use as the graph root.
|
|
||||||
///
|
|
||||||
/// When omitted, the full dependency forest is rendered.
|
|
||||||
id: Option<String>,
|
|
||||||
|
|
||||||
/// Filter tickets by field before building the graph: repeatable `key=value` pairs.
|
|
||||||
///
|
|
||||||
/// Applied to the full ticket list before graph construction.
|
|
||||||
/// Keys: `status`, `type`, `priority`, `title`. Values support globs.
|
|
||||||
#[arg(long = "filter", value_name = "KEY=VALUE")]
|
|
||||||
filter: Vec<String>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Handler: `cmd_graph`
|
|
||||||
|
|
||||||
```rust
|
|
||||||
async fn cmd_graph(id: Option<String>, filter_args: Vec<String>, json: bool) -> store::Result<()>
|
|
||||||
```
|
|
||||||
|
|
||||||
Steps:
|
|
||||||
1. `find_nbd_root()` and `list_tickets(&root).await?`.
|
|
||||||
2. Apply `parse_filters(&filter_args)?` to narrow the ticket list.
|
|
||||||
3. `let graph = TicketGraph::build(&tickets);`
|
|
||||||
4. If `id` is `Some`:
|
|
||||||
a. Resolve `id` via `resolve_id(&root, &id).await?`.
|
|
||||||
b. If `json`: print `graph.to_subtree_json_value(&id)` (or similar — see graph.rs ticket for the JSON shape).
|
|
||||||
c. Else: `display::print_subtree(&graph, &id)`.
|
|
||||||
5. If `id` is `None`:
|
|
||||||
a. If `json`: print `graph.to_json_value()`.
|
|
||||||
b. Else: `display::print_graph(&graph)`.
|
|
||||||
|
|
||||||
## JSON output shapes
|
|
||||||
|
|
||||||
**Full graph (`nbd graph --json`):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nodes": [
|
|
||||||
{"id": "a3f9c2", "title": "Fix login bug", "status": "todo", "priority": 8, "dependencies": ["b7d41e"]},
|
|
||||||
...
|
|
||||||
],
|
|
||||||
"edges": [
|
|
||||||
{"from": "a3f9c2", "to": "b7d41e"},
|
|
||||||
...
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Subtree (`nbd graph <id> --json`):**
|
|
||||||
Same shape as full graph but only including nodes and edges reachable from `<id>`.
|
|
||||||
|
|
||||||
## Files touched
|
|
||||||
- `src/main.rs` — `Commands::Graph` variant, `cmd_graph`, dispatch in `dispatch()`
|
|
||||||
|
|
||||||
## Depends on
|
|
||||||
- `9c9ebe` — graph computation module
|
|
||||||
- `e14172` — ASCII rendering functions
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-fgwx
|
|
||||||
title: nbd update diff output
|
|
||||||
status: completed
|
|
||||||
type: feature
|
|
||||||
priority: normal
|
|
||||||
created_at: 2026-03-10T23:30:30Z
|
|
||||||
updated_at: 2026-03-10T23:30:30Z
|
|
||||||
---
|
|
||||||
|
|
||||||
Show a git-diff-style +/- summary of what changed when `nbd update` is run without `--json`.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
Currently `nbd update` prints the full ticket after the change, making it hard to see at a glance what actually changed. A diff view — showing only changed fields — is more informative.
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
|
|
||||||
### display.rs
|
|
||||||
Add `format_diff(old: &Ticket, new: &Ticket) -> String`:
|
|
||||||
- Compare each field between `old` and `new`.
|
|
||||||
- For each field that changed, emit two lines:
|
|
||||||
```
|
|
||||||
- status: todo
|
|
||||||
+ status: in_progress
|
|
||||||
```
|
|
||||||
- If no fields changed, emit `(no changes)`.
|
|
||||||
- Fields compared: `title`, `body`, `priority`, `status`, `ticket_type`, `dependencies`.
|
|
||||||
- `id` is never shown (it cannot change).
|
|
||||||
- Label width matches `format_ticket` (LABEL_WIDTH).
|
|
||||||
|
|
||||||
Add `print_diff(old: &Ticket, new: &Ticket)` that calls `println!("{}" format_diff(...))`.
|
|
||||||
|
|
||||||
### main.rs
|
|
||||||
In `cmd_update`:
|
|
||||||
- Before applying changes: `let old = ticket.clone();`
|
|
||||||
- After `write_ticket`:
|
|
||||||
- If `json`: current behaviour (print new ticket as JSON).
|
|
||||||
- Else: `display::print_diff(&old, &ticket)`.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
Unit tests in `src/tests.rs`:
|
|
||||||
- `format_diff` shows changed fields only.
|
|
||||||
- `format_diff` with identical tickets outputs `(no changes)`.
|
|
||||||
- Changed dependencies are shown as comma-separated lists on each line.
|
|
||||||
|
|
||||||
Integration test:
|
|
||||||
- `nbd update <id> --status in_progress` (no `--json`) prints `- status:` and `+ status:` lines.
|
|
||||||
- `nbd update <id> --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
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-flbj
|
|
||||||
title: Update claude-md snippet to show --json on all commands
|
|
||||||
status: completed
|
|
||||||
type: task
|
|
||||||
priority: low
|
|
||||||
created_at: 2026-03-10T23:30:31Z
|
|
||||||
updated_at: 2026-03-10T23:30:31Z
|
|
||||||
---
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
The `nbd claude-md` command emits `src/claude_md_snippet.md` verbatim. The current snippet shows core commands without `--json`, contradicting the guideline at the bottom that says to always pass it.
|
|
||||||
|
|
||||||
## What to change
|
|
||||||
|
|
||||||
In `src/claude_md_snippet.md`, update the **Core commands** section so every example includes `--json`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# Create a new ticket (use --ftype md for a human-readable body)
|
|
||||||
nbd create --title "Add OAuth login" --type feature --priority 7 --ftype md --json
|
|
||||||
|
|
||||||
# List all open tickets (sorted by priority)
|
|
||||||
nbd list --json
|
|
||||||
|
|
||||||
# Read a specific ticket
|
|
||||||
nbd read <id> --json
|
|
||||||
|
|
||||||
# Update a ticket
|
|
||||||
nbd update <id> --status in_progress --json
|
|
||||||
nbd update <id> --status done --json
|
|
||||||
```
|
|
||||||
|
|
||||||
Also update the **Workflow** numbered list to use `--json` in every command:
|
|
||||||
|
|
||||||
```
|
|
||||||
1. **Before starting** — create a ticket: `nbd create --title "..." --ftype md --json`
|
|
||||||
2. **When starting** — mark it in progress: `nbd update <id> --status in_progress --json`
|
|
||||||
3. **When done** — mark it complete: `nbd update <id> --status done --json`
|
|
||||||
```
|
|
||||||
|
|
||||||
The `nbd ready` and `nbd next` sections should also include `--json`.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
- `src/claude_md_snippet.md` — the only file that needs changing
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cargo run -- claude-md | grep -c '\-\-json'
|
|
||||||
```
|
|
||||||
|
|
||||||
The count should go up after the change. All existing tests should still pass.
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-i0fc
|
|
||||||
title: nbd ready command
|
|
||||||
status: completed
|
|
||||||
type: feature
|
|
||||||
priority: high
|
|
||||||
created_at: 2026-03-10T23:30:31Z
|
|
||||||
updated_at: 2026-03-10T23:30:31Z
|
|
||||||
---
|
|
||||||
|
|
||||||
Add `nbd ready` subcommand that lists tickets which are actionable right now: not yet done and with all dependencies completed.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
Agent workflows need to know which tickets are unblocked. `nbd list` shows everything; `nbd ready` narrows to what can actually be started immediately.
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
|
|
||||||
1. Add `Ready` variant to `Commands` enum in `main.rs`.
|
|
||||||
2. Implement `cmd_ready(json: bool)`:
|
|
||||||
a. `list_tickets(root)` to fetch all tickets.
|
|
||||||
b. Build a set of IDs for tickets with `status == Status::Done`.
|
|
||||||
c. Filter to tickets where:
|
|
||||||
- `ticket.status != Status::Done` (not already finished)
|
|
||||||
- All IDs in `ticket.dependencies` are in the done-set (or the dep doesn't exist — treat missing deps as unresolved, not ready).
|
|
||||||
d. Print the filtered slice using existing `display::print_list` / `print_list_json`.
|
|
||||||
3. No new store or display functions needed — reuse existing.
|
|
||||||
|
|
||||||
## Edge cases
|
|
||||||
- A ticket with no dependencies and status `todo` → ready.
|
|
||||||
- A ticket whose dep is `in_progress` → NOT ready.
|
|
||||||
- Missing dep ID → NOT ready (treat conservatively).
|
|
||||||
- Empty store → returns empty list (not an error).
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
Unit-style integration tests:
|
|
||||||
- Three tickets: A (no deps, todo), B (dep A, todo), C (no deps, done). `nbd ready` should return only A.
|
|
||||||
- After marking A done, `nbd ready` should return B.
|
|
||||||
- `nbd ready --json` returns a JSON array of the ready tickets.
|
|
||||||
|
|
||||||
## Files touched
|
|
||||||
- `src/main.rs` — new `Ready` variant and `cmd_ready` handler
|
|
||||||
- `tests/integration.rs` — new integration tests
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-jc1v
|
|
||||||
title: 'Split archive/closed: archive=done, closed=cancelled'
|
|
||||||
status: completed
|
|
||||||
type: bug
|
|
||||||
priority: normal
|
|
||||||
created_at: 2026-03-10T23:30:31Z
|
|
||||||
updated_at: 2026-03-10T23:30:31Z
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
Currently `nbd archive` sets a ticket's status to `closed`. This conflates two distinct intents:
|
|
||||||
|
|
||||||
- A ticket that **was completed** and is being retired from the active list
|
|
||||||
- A ticket that **will not be completed** (e.g. cancelled, superseded, won't-fix)
|
|
||||||
|
|
||||||
## Desired semantics
|
|
||||||
|
|
||||||
| Status | Meaning | Resolved (unblocks deps)? | Visible in `nbd list` by default? |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `archived` | Completed and soft-deleted from view | Yes | No |
|
|
||||||
| `closed` | Won't be completed (cancelled/won't-fix) | Yes (avoids deadlock) | No |
|
|
||||||
|
|
||||||
Both statuses resolve dependencies, so a ticket depending on an `archived` or `closed` ticket becomes unblocked. This matches current behaviour for `closed`.
|
|
||||||
|
|
||||||
## Changes required
|
|
||||||
|
|
||||||
### `src/ticket.rs`
|
|
||||||
|
|
||||||
Add `Archived` variant to `Status`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub enum Status {
|
|
||||||
#[default]
|
|
||||||
Todo,
|
|
||||||
InProgress,
|
|
||||||
Done,
|
|
||||||
Closed,
|
|
||||||
Archived, // new
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Serialises as `"archived"`.
|
|
||||||
|
|
||||||
### `src/main.rs`
|
|
||||||
|
|
||||||
- `parse_status`: add `"archived" => Ok(Status::Archived)` arm and update error message
|
|
||||||
- `cmd_archive`: change `ticket.status = Status::Closed` → `ticket.status = Status::Archived`
|
|
||||||
- `cmd_list`: exclude `Status::Archived` alongside `Done` and `Closed` in the default filter path
|
|
||||||
- `cmd_ready` / `cmd_next`: treat `Status::Archived` as resolved (count it in `done_ids`)
|
|
||||||
- Update the `Archive` command docstring to say it sets status to `archived` not `closed`
|
|
||||||
|
|
||||||
### `src/display.rs`
|
|
||||||
|
|
||||||
- `status_str`: add `Status::Archived => "archived"` arm
|
|
||||||
|
|
||||||
### `src/graph.rs`
|
|
||||||
|
|
||||||
- `status_str` (internal helper): add `Status::Archived => "archived"` arm
|
|
||||||
|
|
||||||
### README.md
|
|
||||||
|
|
||||||
- Update the Status table to show `archived` as a valid status
|
|
||||||
- Update `nbd archive` description to say it sets status to `archived`
|
|
||||||
- Update the note about `archived` vs `closed` semantics
|
|
||||||
|
|
||||||
### CLAUDE.md
|
|
||||||
|
|
||||||
- Update CLI Interface block to include `archived` in valid status values
|
|
||||||
- Update `nbd archive` description
|
|
||||||
|
|
||||||
### `src/tests.rs` / `tests/integration.rs`
|
|
||||||
|
|
||||||
- Update any test that checks `status == "closed"` after `nbd archive` → expect `"archived"` instead
|
|
||||||
- Add test: `nbd archive <id>` produces `"archived"` status
|
|
||||||
- Add test: a ticket with `status=closed` (manually set) is also excluded from list by default and treated as resolved
|
|
||||||
|
|
||||||
## Migration concern
|
|
||||||
|
|
||||||
Existing tickets on disk that have `status: "closed"` set by previous `nbd archive` calls will retain `closed` status. This is acceptable — `closed` remains a valid status. Users who want to distinguish can `nbd update <id> --status archived` on historical tickets, or run `nbd migrate` once the schema is updated.
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cargo fmt && cargo check && cargo clippy && cargo test
|
|
||||||
cargo run -- create --title "Test archive" --json | jq -r '.id' | xargs -I{} cargo run -- archive {} --json
|
|
||||||
# → status should be "archived"
|
|
||||||
cargo run -- create --title "Cancelled ticket" --json | jq -r '.id' | xargs -I{} cargo run -- update {} --status closed --json
|
|
||||||
# → status should be "closed"
|
|
||||||
```
|
|
||||||
@ -1,105 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-kq6o
|
|
||||||
title: Scope nbd next and nbd ready by dependency subtree
|
|
||||||
status: completed
|
|
||||||
type: feature
|
|
||||||
priority: normal
|
|
||||||
created_at: 2026-03-10T23:30:30Z
|
|
||||||
updated_at: 2026-03-10T23:30:30Z
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Add an optional positional `<id>` argument to `nbd next` and `nbd ready` that scopes
|
|
||||||
the results to the dependency subtree of the given ticket.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
When a project has many tickets, you often want to focus on tickets that directly
|
|
||||||
unblock a specific goal ticket. Currently `nbd next` and `nbd ready` operate over
|
|
||||||
the entire ticket store, with no way to narrow by project/feature scope except
|
|
||||||
through `--filter` expressions that don't understand the dependency graph.
|
|
||||||
|
|
||||||
## Desired behaviour
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# Highest-priority ready ticket that is a dependency of abc123 (directly or transitively)
|
|
||||||
nbd next abc123
|
|
||||||
nbd next abc123 --json
|
|
||||||
|
|
||||||
# All ready tickets that are dependencies of abc123 (directly or transitively)
|
|
||||||
nbd ready abc123
|
|
||||||
nbd ready abc123 --json
|
|
||||||
|
|
||||||
# Both commands still accept --filter alongside the scoping ID
|
|
||||||
nbd next abc123 --filter type=bug
|
|
||||||
nbd ready abc123 --filter priority=8
|
|
||||||
```
|
|
||||||
|
|
||||||
The root ticket itself (abc123) is *not* included in the results — only its
|
|
||||||
dependencies (the tickets it is waiting on).
|
|
||||||
|
|
||||||
## Implementation plan
|
|
||||||
|
|
||||||
### 1. CLI changes (`src/main.rs`)
|
|
||||||
|
|
||||||
In `Commands::Next` and `Commands::Ready`, add an optional positional argument:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
id: Option<String>, // Optional ticket ID or prefix to scope results
|
|
||||||
```
|
|
||||||
|
|
||||||
Update `dispatch` to pass the new argument to `cmd_next` and `cmd_ready`.
|
|
||||||
|
|
||||||
Update the CLI doc-comments for both subcommands to mention the scoping
|
|
||||||
behaviour.
|
|
||||||
|
|
||||||
### 2. Logic changes (`src/main.rs`)
|
|
||||||
|
|
||||||
In `cmd_next` and `cmd_ready`, after loading all tickets, if `scope_id` is
|
|
||||||
`Some`:
|
|
||||||
|
|
||||||
1. Resolve the prefix to a full ID using `resolve_id`.
|
|
||||||
2. Build a `TicketGraph` from all tickets (the full list, not filtered).
|
|
||||||
3. Call `graph.subtree(&resolved_id)` to get the set of all dependency IDs
|
|
||||||
reachable from the root (this already excludes the root itself when it is
|
|
||||||
not in its own dependency list).
|
|
||||||
4. Convert the subtree IDs into a `HashSet`.
|
|
||||||
5. Restrict the ready/next candidate pool to tickets whose ID is in that set
|
|
||||||
*and* whose ID is not the scoping ticket itself (the root should never be
|
|
||||||
returned as a "what to do next" result when it is the scope target).
|
|
||||||
|
|
||||||
`TicketGraph::subtree` is already implemented in `src/graph.rs` and handles
|
|
||||||
cycles correctly via a visited set.
|
|
||||||
|
|
||||||
### 3. Test coverage (`tests/integration.rs`)
|
|
||||||
|
|
||||||
Add integration tests (using `tempdir`):
|
|
||||||
|
|
||||||
- **`test_next_scoped_by_id`**: create a project ticket P with deps [A, B],
|
|
||||||
where A has dep [C]. Mark C as done. Verify `nbd next P` returns A (the
|
|
||||||
highest-priority ready dep of P), not P itself or any unrelated ticket.
|
|
||||||
- **`test_ready_scoped_by_id`**: same setup; verify `nbd ready P` returns
|
|
||||||
[A] (B is blocked because... actually B has no deps so it's also ready).
|
|
||||||
Adjust setup so exactly the expected set is returned.
|
|
||||||
- **`test_next_scoped_no_ready`**: all deps of P are done; verify `nbd next P`
|
|
||||||
returns no ticket (`{"next": null}` in JSON mode).
|
|
||||||
- **`test_ready_scoped_with_filter`**: verify `--filter` still narrows within
|
|
||||||
the scoped set.
|
|
||||||
|
|
||||||
### 4. Relevant files
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|---|---|
|
|
||||||
| `src/main.rs:135-156` | `Commands::Ready` and `Commands::Next` — add `id: Option<String>` |
|
|
||||||
| `src/main.rs:308-310` | `dispatch` arms for `Next` and `Ready` — pass new arg |
|
|
||||||
| `src/main.rs:473-511` | `cmd_ready` — add subtree scoping logic |
|
|
||||||
| `src/main.rs:522-566` | `cmd_next` — add subtree scoping logic |
|
|
||||||
| `src/graph.rs:165-172` | `TicketGraph::subtree` — already correct, no change needed |
|
|
||||||
| `tests/integration.rs` | Add 4 new scoped-by-id tests |
|
|
||||||
|
|
||||||
### 5. Validation
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cargo fmt && cargo check && cargo clippy && cargo test
|
|
||||||
```
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-l38k
|
|
||||||
title: 'Fix graph orientation: show goals at root, prerequisites as leaves'
|
|
||||||
status: completed
|
|
||||||
type: bug
|
|
||||||
priority: high
|
|
||||||
created_at: 2026-03-10T23:30:30Z
|
|
||||||
updated_at: 2026-03-10T23:30:30Z
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
The dependency graph is inverted. Currently:
|
|
||||||
- **Roots** = tickets with no dependencies (foundation/leaf-level work)
|
|
||||||
- **Children** = dependents of the root (tickets blocked by it)
|
|
||||||
|
|
||||||
This means the graph reads bottom-up: you see "what blocks what" rather than "what needs what".
|
|
||||||
|
|
||||||
## Desired behavior
|
|
||||||
|
|
||||||
If ticket A depends on B, C, and D:
|
|
||||||
- `nbd graph` should show A at the top with B, C, D indented as children (leaves)
|
|
||||||
- Roots = tickets with **no dependents** (nobody depends on them — these are the top-level goals)
|
|
||||||
- Children = the **dependencies** of the current node (prerequisites)
|
|
||||||
|
|
||||||
This makes the graph read naturally: goals at the top, work to be done first at the bottom.
|
|
||||||
|
|
||||||
## Files to change
|
|
||||||
|
|
||||||
### `src/graph.rs`
|
|
||||||
|
|
||||||
- `roots()`: change predicate from `node.dependencies.is_empty()` to `node.dependents.is_empty()`
|
|
||||||
- `subtree()` + `dfs_subtree()`: currently traverses `dependents` edges; should traverse `dependencies` edges so subtree(A) returns A + what A depends on (recursively)
|
|
||||||
- `to_json_value()` and `to_subtree_json_value()`: edge direction in JSON output should be `{"from": dependent_id, "to": dependency_id}` (A→B means A depends on B), or reconsider the `from`/`to` labels in light of the new orientation
|
|
||||||
- Update all doc comments to reflect the new semantics
|
|
||||||
|
|
||||||
### `src/display.rs`
|
|
||||||
|
|
||||||
- `render_node()`: iterate `node.dependencies` as children (not `node.dependents`)
|
|
||||||
- Update doc comments for `format_graph()` and `format_subtree()`
|
|
||||||
|
|
||||||
### `src/graph.rs` module-level doc comment
|
|
||||||
|
|
||||||
The edge semantics paragraph should be updated: "A is a dependency of B" means A appears as a child in the tree of B.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
Update any graph-related tests in `src/tests.rs` and `tests/integration.rs` that assert the current (inverted) traversal order.
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-lins
|
|
||||||
title: 'Fix nbd graph <id>: show ancestry path through ticket, not just subtree'
|
|
||||||
status: todo
|
|
||||||
type: bug
|
|
||||||
priority: normal
|
|
||||||
created_at: 2026-03-10T23:30:29Z
|
|
||||||
updated_at: 2026-03-10T23:30:31Z
|
|
||||||
blocked_by:
|
|
||||||
- nbd-l38k
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
Currently `nbd graph <id>` renders the subtree **below** the given ticket via dependent edges (tickets blocked by it). After the graph orientation fix (see ticket 668150), `nbd graph <id>` will render the dependency subtree **below** the given ticket.
|
|
||||||
|
|
||||||
But the desired behavior for `nbd graph B` (where A depends on B, C, and D) is more specific:
|
|
||||||
|
|
||||||
> `nbd graph B` should graph up to the root level, down to B, and the rest of the way down from B, but not include siblings like C and D.
|
|
||||||
|
|
||||||
## Desired behavior
|
|
||||||
|
|
||||||
```
|
|
||||||
A [todo] High-level goal
|
|
||||||
└── B [todo] Implement feature B
|
|
||||||
└── E [todo] Write unit tests for B
|
|
||||||
```
|
|
||||||
|
|
||||||
- A is shown because it depends on B (ancestor)
|
|
||||||
- C and D are NOT shown (they are sibling dependencies of A, not on the path to B)
|
|
||||||
- E is shown because B depends on E (B's own dependency subtree)
|
|
||||||
|
|
||||||
## Algorithm
|
|
||||||
|
|
||||||
1. **Ancestors of B**: traverse the `dependents` edges upward (who depends on B, and who depends on those, etc.) — collecting all ancestor tickets
|
|
||||||
2. **Filtered ancestor tree**: when rendering ancestors, only show the branch that leads toward B — i.e., at each ancestor, only the child that is on the path to B is shown (not siblings like C and D)
|
|
||||||
3. **B's dependency subtree**: show B and all its dependencies recursively (as per the corrected subtree logic from ticket 668150)
|
|
||||||
|
|
||||||
## Implementation approach
|
|
||||||
|
|
||||||
In `src/graph.rs`:
|
|
||||||
- Add `ancestors(target_id: &str) -> Vec<&str>`: collect all IDs that transitively depend on `target_id` (follow `dependents` edges upward)
|
|
||||||
- Add `ancestry_path_subtree(target_id: &str) -> ???`: returns the combined view — ancestor tree filtered to the path, plus target's dependency subtree
|
|
||||||
|
|
||||||
In `src/display.rs`:
|
|
||||||
- New or updated `format_subtree()` to render the ancestry+subtree combined view
|
|
||||||
- The ancestry portion must filter children to only show the branch toward the target (not all dependents of each ancestor)
|
|
||||||
|
|
||||||
In `src/main.rs`:
|
|
||||||
- Update `cmd_graph()` to use the new combined view when an ID is provided
|
|
||||||
|
|
||||||
## Edge cases
|
|
||||||
|
|
||||||
- If B has no dependents (it is itself a root), just show B's dependency subtree (no ancestor section)
|
|
||||||
- If B appears in multiple dependency chains, show all paths to it
|
|
||||||
- Cycle detection still applies (mark revisited nodes with `*`)
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
Add integration tests in `tests/integration.rs`:
|
|
||||||
- Create tickets A, B, C, D, E with A depending on B/C/D, and B depending on E
|
|
||||||
- Assert `nbd graph B` output contains A and E but not C and D
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-m9q2
|
|
||||||
title: 'nbd init: add cache.db to .nbd/.gitignore'
|
|
||||||
status: todo
|
|
||||||
type: task
|
|
||||||
priority: normal
|
|
||||||
created_at: 2026-03-10T23:30:30Z
|
|
||||||
updated_at: 2026-03-10T23:30:30Z
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
`.nbd/cache.db` is a Turso/libsql SQLite cache file created automatically by `list_tickets_cached`. It should never be committed to git. Currently `nbd init` does not create a `.gitignore` to exclude it.
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
**`src/main.rs`** — `cmd_init`
|
|
||||||
|
|
||||||
After `ensure_tickets_dir` succeeds, write (or append to) `.nbd/.gitignore`:
|
|
||||||
|
|
||||||
```
|
|
||||||
cache.db
|
|
||||||
```
|
|
||||||
|
|
||||||
Use idempotent logic: read the file if it exists, check whether `cache.db` is already listed, and only append/create if it is absent. This keeps `nbd init` safe to run multiple times.
|
|
||||||
|
|
||||||
Suggested helper (can be inline in `cmd_init`):
|
|
||||||
1. Read `.nbd/.gitignore` if it exists.
|
|
||||||
2. If `cache.db` is not a line in the file, append `cache.db\n`.
|
|
||||||
3. If the file does not exist, create it with `cache.db\n`.
|
|
||||||
|
|
||||||
The JSON output for `--json` should include a `gitignore` key indicating the path that was created/updated (or unchanged).
|
|
||||||
|
|
||||||
**`tests/integration.rs`**
|
|
||||||
|
|
||||||
Add a test that runs `nbd init` and asserts `.nbd/.gitignore` contains `cache.db`. Run `nbd init` a second time and assert the file is unchanged (idempotent).
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-mkkm
|
|
||||||
title: Add --xml output format that wraps each ticket section in XML tags
|
|
||||||
status: todo
|
|
||||||
type: feature
|
|
||||||
priority: low
|
|
||||||
created_at: 2026-03-10T23:30:30Z
|
|
||||||
updated_at: 2026-03-10T23:30:30Z
|
|
||||||
---
|
|
||||||
|
|
||||||
## Background
|
|
||||||
|
|
||||||
From `nbd/TODO.md`:
|
|
||||||
|
|
||||||
> Add a `--xml` output format that prints tickets with XML around each section to make it easier to parse metadata.
|
|
||||||
|
|
||||||
Unlike `--json` (which is a complete, structured parse), the XML format adds lightweight tags around each metadata field in the human-readable output. This makes it trivial for scripts to extract specific fields using simple text tools (e.g., `grep`, `sed`, XPath) without pulling in a full JSON parser.
|
|
||||||
|
|
||||||
## Intended XML format
|
|
||||||
|
|
||||||
For a single ticket (`nbd read <id> --xml`):
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<ticket>
|
|
||||||
<id>a3f9c2</id>
|
|
||||||
<title>Fix login bug</title>
|
|
||||||
<priority>8</priority>
|
|
||||||
<status>in_progress</status>
|
|
||||||
<type>bug</type>
|
|
||||||
<dependencies>
|
|
||||||
<dep>b7d41e</dep>
|
|
||||||
<dep>c9e823</dep>
|
|
||||||
</dependencies>
|
|
||||||
<body>Users cannot log in with email addresses containing +</body>
|
|
||||||
</ticket>
|
|
||||||
```
|
|
||||||
|
|
||||||
For a list (`nbd list --xml`):
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<tickets>
|
|
||||||
<ticket>...</ticket>
|
|
||||||
<ticket>...</ticket>
|
|
||||||
</tickets>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
### `src/main.rs`
|
|
||||||
|
|
||||||
Add a global `--xml` flag to the `Cli` struct, parallel to `--json`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// Output XML instead of a human-readable table.
|
|
||||||
#[arg(long, global = true)]
|
|
||||||
xml: bool,
|
|
||||||
```
|
|
||||||
|
|
||||||
Precedence: if both `--json` and `--xml` are supplied, `--json` wins (or return an error — TBD, but JSON-first is simpler).
|
|
||||||
|
|
||||||
Pass `cli.xml` through `dispatch` to every command handler, alongside `cli.json`.
|
|
||||||
|
|
||||||
Each command handler signature gains an `xml: bool` parameter. When `xml` is true and `json` is false, call the new `display::print_ticket_xml` / `display::print_list_xml` functions.
|
|
||||||
|
|
||||||
### `src/display.rs`
|
|
||||||
|
|
||||||
Add:
|
|
||||||
|
|
||||||
- `format_ticket_xml(ticket: &Ticket) -> String` — serialises a single ticket as XML
|
|
||||||
- `print_ticket_xml(ticket: &Ticket)` — wraps `format_ticket_xml` + `println!`
|
|
||||||
- `format_list_xml(tickets: &[Ticket]) -> String` — wraps list in `<tickets>`
|
|
||||||
- `print_list_xml(tickets: &[Ticket])`
|
|
||||||
|
|
||||||
XML escaping: at minimum, escape `&`, `<`, `>`, `"`, `'` in field values. Use a small helper `xml_escape(s: &str) -> String` rather than pulling in an XML crate.
|
|
||||||
|
|
||||||
For `dependencies`: render each as a `<dep>` child element.
|
|
||||||
|
|
||||||
### `src/tests.rs`
|
|
||||||
|
|
||||||
Add unit tests:
|
|
||||||
- `format_ticket_xml` with a ticket that has special characters in the title and body (`&`, `<`, `>`)
|
|
||||||
- `format_ticket_xml` with empty dependencies
|
|
||||||
- `format_ticket_xml` with multiple dependencies
|
|
||||||
- `format_list_xml` with zero and multiple tickets
|
|
||||||
|
|
||||||
### `tests/integration.rs`
|
|
||||||
|
|
||||||
Add integration tests:
|
|
||||||
- `nbd read <id> --xml` returns valid XML containing the ticket's fields
|
|
||||||
- `nbd list --xml` returns valid XML wrapping multiple tickets
|
|
||||||
- `nbd create ... --xml` returns the created ticket as XML
|
|
||||||
- `nbd update ... --xml` returns the updated ticket as XML
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
All commands that support `--json` should also support `--xml`:
|
|
||||||
- `nbd create`
|
|
||||||
- `nbd read`
|
|
||||||
- `nbd list`
|
|
||||||
- `nbd update`
|
|
||||||
- `nbd ready`
|
|
||||||
- `nbd next`
|
|
||||||
- `nbd archive`
|
|
||||||
- `nbd migrate`
|
|
||||||
- `nbd graph` (the JSON graph format has a defined structure; XML should mirror it)
|
|
||||||
- `nbd claude-md` (wrap snippet in `<snippet>` tag)
|
|
||||||
- `nbd init` (wrap root path in `<init>`)
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-n2xz
|
|
||||||
title: 'Investigate: user-configurable type and status strings with lifecycle phases'
|
|
||||||
status: draft
|
|
||||||
type: task
|
|
||||||
priority: low
|
|
||||||
created_at: 2026-03-10T23:30:29Z
|
|
||||||
updated_at: 2026-03-10T23:30:29Z
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
`type` and `status` are currently hard-coded Rust enums. Users cannot add custom values (e.g., `status = "review"` or `type = "spike"`) without modifying the codebase. But certain status values (`done`, `archived`, `closed`) have special semantics (excluded from `list`/`ready`/`next`; counted as resolved for deps). Making these extensible requires a design that lets users declare which values are "pre-work", "in-work", and "post-work".
|
|
||||||
|
|
||||||
## Design questions
|
|
||||||
|
|
||||||
### Lifecycle phases (proposed)
|
|
||||||
|
|
||||||
Instead of hard-coding which statuses are excluded, introduce a three-phase model:
|
|
||||||
|
|
||||||
| Phase | Examples | Behaviour |
|
|
||||||
|---|---|---|
|
|
||||||
| `pre` | `triage`, `backlog` | Excluded from `list`, `ready`, `next`. Not resolved. |
|
|
||||||
| `during` | `todo`, `in_progress`, `review` | Included in `list`, `ready`, `next`. Not resolved. |
|
|
||||||
| `post` | `done`, `archived`, `closed` | Excluded from `list`, `ready`, `next`. Counted as resolved. |
|
|
||||||
|
|
||||||
Users would configure phases in `.nbd/config.toml`:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[nbd.status]
|
|
||||||
pre = ["triage", "backlog"]
|
|
||||||
during = ["todo", "in_progress", "review", "qa"]
|
|
||||||
post = ["done", "archived", "closed"]
|
|
||||||
default = "triage"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Type extensibility
|
|
||||||
|
|
||||||
Allow `type` to be any string. Keep built-in types (`project`, `feature`, `task`, `bug`) but do not reject unknown values; validate only that the string is non-empty.
|
|
||||||
|
|
||||||
## Questions to answer
|
|
||||||
|
|
||||||
1. Is TOML config the right place for lifecycle phase definitions, or should there be a separate schema file?
|
|
||||||
2. How does serialisation/deserialisation change? `Status` can no longer be an enum if it is user-defined.
|
|
||||||
3. What is the migration path for existing tickets serialised with the current enum values?
|
|
||||||
4. Are there cases where a single status should belong to multiple phases (e.g., `review` might be both `during` and gating some post-processing)?
|
|
||||||
5. What is the minimum viable change? Could we start by making status a `String` internally while keeping the built-in values and their semantics, then layer config-driven phases on top?
|
|
||||||
|
|
||||||
## Expected output
|
|
||||||
|
|
||||||
Create one or more follow-up tickets with a concrete implementation plan and migration strategy.
|
|
||||||
@ -1,129 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-no88
|
|
||||||
title: Add --version flag with X.Y.Z+GitSha format
|
|
||||||
status: completed
|
|
||||||
type: feature
|
|
||||||
priority: normal
|
|
||||||
created_at: 2026-03-10T23:30:31Z
|
|
||||||
updated_at: 2026-03-10T23:30:31Z
|
|
||||||
---
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Add a `--version` / `-V` flag to `nbd` that prints the version in the format:
|
|
||||||
|
|
||||||
```
|
|
||||||
0.1.0+7e311d6
|
|
||||||
```
|
|
||||||
|
|
||||||
where `0.1.0` comes from `Cargo.toml` and `7e311d6` is the short git SHA of the commit the binary was built from, embedded at compile time via `build.rs`.
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
Allows agents and users to confirm exactly which build of `nbd` is running without inspecting the binary separately.
|
|
||||||
|
|
||||||
## Implementation plan
|
|
||||||
|
|
||||||
### 1. Create `build.rs` at the crate root
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn main() {
|
|
||||||
// Capture the short git SHA at build time.
|
|
||||||
// Falls back to "unknown" when git is unavailable (e.g. CI without repo).
|
|
||||||
let sha = std::process::Command::new("git")
|
|
||||||
.args(["rev-parse", "--short", "HEAD"])
|
|
||||||
.output()
|
|
||||||
.ok()
|
|
||||||
.and_then(|o| if o.status.success() { Some(o.stdout) } else { None })
|
|
||||||
.and_then(|b| String::from_utf8(b).ok())
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
|
||||||
|
|
||||||
println\!("cargo:rustc-env=GIT_SHORT_SHA={sha}");
|
|
||||||
// Re-run whenever HEAD changes (new commits).
|
|
||||||
println\!("cargo:rerun-if-changed=.git/HEAD");
|
|
||||||
println\!("cargo:rerun-if-changed=.git/refs");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Key points:
|
|
||||||
- Uses `std::process::Command` — no extra build dependencies.
|
|
||||||
- Graceful fallback to `"unknown"` when git is absent (clean Nix sandbox builds may not have `.git/`).
|
|
||||||
- `rerun-if-changed` directives ensure the SHA is refreshed on every commit without forcing a full rebuild every run.
|
|
||||||
|
|
||||||
### 2. Add a `VERSION` constant in `src/main.rs`
|
|
||||||
|
|
||||||
Add near the top of `main.rs` after the existing `CLAUDE_MD_SNIPPET` constant:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// Full version string embedded at compile time: `"X.Y.Z+shortsha"`.
|
|
||||||
const VERSION: &str = concat\!(env\!("CARGO_PKG_VERSION"), "+", env\!("GIT_SHORT_SHA"));
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Wire `VERSION` into the clap `#[command(...)]` attribute
|
|
||||||
|
|
||||||
Change:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[command(name = "nbd", about = "Manage work tickets for agent workflows")]
|
|
||||||
```
|
|
||||||
|
|
||||||
to:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[command(name = "nbd", about = "Manage work tickets for agent workflows", version = VERSION)]
|
|
||||||
```
|
|
||||||
|
|
||||||
clap automatically handles `-V` / `--version` when `version` is set: it prints the string and exits 0. No manual subcommand or dispatch code needed.
|
|
||||||
|
|
||||||
### 4. Update `README.md`
|
|
||||||
|
|
||||||
Add a brief note in the **Usage** section or **Installation** section:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
nbd --version # prints e.g. 0.1.0+7e311d6
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Add an integration test in `tests/integration.rs`
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// `nbd --version` exits 0 and stdout contains the semver.
|
|
||||||
#[test]
|
|
||||||
fn version_flag_exits_zero_with_semver() {
|
|
||||||
let tmp = tempfile::tempdir().expect("tempdir");
|
|
||||||
let output = std::process::Command::new(env\!("CARGO_BIN_EXE_nbd"))
|
|
||||||
.arg("--version")
|
|
||||||
.current_dir(tmp.path())
|
|
||||||
.output()
|
|
||||||
.expect("spawn nbd");
|
|
||||||
assert\!(output.status.success(), "--version should exit 0");
|
|
||||||
let stdout = String::from_utf8(output.stdout).unwrap();
|
|
||||||
// Should contain the package version (semver).
|
|
||||||
assert\!(stdout.contains("0.1.0"), "--version should include semver: {stdout}");
|
|
||||||
// Should contain a '+' separator and at least one hex char after it.
|
|
||||||
assert\!(stdout.contains('+'), "--version should contain '+': {stdout}");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Files to change
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|---|---|
|
|
||||||
| `build.rs` | **Create** — emit `GIT_SHORT_SHA` env var |
|
|
||||||
| `src/main.rs` | Add `VERSION` const; add `version = VERSION` to `#[command]` |
|
|
||||||
| `README.md` | Add `nbd --version` example |
|
|
||||||
| `tests/integration.rs` | Add `version_flag_exits_zero_with_semver` test |
|
|
||||||
|
|
||||||
## Edge cases
|
|
||||||
|
|
||||||
- **No git available (Nix sandbox):** `build.rs` falls back to `"unknown"`, producing `0.1.0+unknown`. This is acceptable for packaged builds where the version is already pinned by the derivation.
|
|
||||||
- **clap version output format:** clap 4 prints `nbd X.Y.Z+sha` (prefixed by the binary name) to stdout, then exits 0. This is standard behaviour — the test should check `stdout.contains("0.1.0")` rather than an exact match.
|
|
||||||
- **No `--json` support for `--version`:** clap intercepts `--version` before dispatch reaches the `--json` handling. This is acceptable; version output is always plain text.
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cargo fmt && cargo check && cargo clippy && cargo test
|
|
||||||
cargo run -- --version
|
|
||||||
# Expected output: nbd 0.1.0+<sha>
|
|
||||||
```
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-o3k8
|
|
||||||
title: Remove id field from ticket JSON body
|
|
||||||
status: completed
|
|
||||||
type: task
|
|
||||||
priority: critical
|
|
||||||
created_at: 2026-03-10T23:30:31Z
|
|
||||||
updated_at: 2026-03-10T23:30:31Z
|
|
||||||
---
|
|
||||||
|
|
||||||
The ticket ID is already encoded in the filename (`a3f9c2.json`). Storing it redundantly inside the JSON body creates a potential consistency hazard (the two could disagree) and wastes space. The filename should be the sole source of truth for the ID.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
- Eliminates a redundancy: filename stem already IS the id.
|
|
||||||
- Removes the risk of id mismatch (e.g. if a file is renamed manually).
|
|
||||||
- Simplifies the JSON schema — consumers only need the body fields, not a duplicated key.
|
|
||||||
|
|
||||||
## Serde approach
|
|
||||||
|
|
||||||
In `ticket.rs`, annotate the `id` field with `#[serde(skip)]`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
||||||
pub struct Ticket {
|
|
||||||
#[serde(skip)]
|
|
||||||
pub id: String,
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`#[serde(skip)]` means:
|
|
||||||
- **Serialise:** the `id` field is omitted from JSON output entirely.
|
|
||||||
- **Deserialise:** the field is not read from JSON; it is initialised with `String::default()` (empty string) and must be set manually after deserialization.
|
|
||||||
|
|
||||||
Because serde ignores unknown fields by default, **existing files with `"id": "..."` in the JSON body continue to deserialise without error** — the field is simply discarded. This means the change is backwards-compatible for reads; existing stores work immediately. A separate `nbd migrate` command (see companion ticket) cleans up the stale `id` field from disk.
|
|
||||||
|
|
||||||
## store.rs changes
|
|
||||||
|
|
||||||
`read_ticket(root, id)` — inject id from the `id` parameter after deserialising:
|
|
||||||
```rust
|
|
||||||
let mut ticket: Ticket = serde_json::from_slice(&bytes)?;
|
|
||||||
ticket.id = id.to_string(); // authoritative source: the filename
|
|
||||||
Ok(ticket)
|
|
||||||
```
|
|
||||||
|
|
||||||
`list_tickets(root)` — inject id from each file's stem:
|
|
||||||
```rust
|
|
||||||
let stem = path.file_stem().and_then(|s| s.to_str()).ok_or("invalid filename")?;
|
|
||||||
let mut ticket: Ticket = serde_json::from_slice(&bytes)?;
|
|
||||||
ticket.id = stem.to_string();
|
|
||||||
tickets.push(ticket);
|
|
||||||
```
|
|
||||||
|
|
||||||
`write_ticket` — no change needed; `#[serde(skip)]` already prevents `id` from being written.
|
|
||||||
|
|
||||||
`ticket_path` — no change; it already takes `id: &str` as a separate parameter.
|
|
||||||
|
|
||||||
## Impact on other tickets
|
|
||||||
|
|
||||||
- The `nbd migrate` companion ticket (see deps) provides the command to scrub the old `id` field from existing files.
|
|
||||||
- Partial ID matching (`resolve_id`) is unaffected — it works on filenames, not JSON content.
|
|
||||||
- All display, list, and read commands continue to work; `ticket.id` is populated from the filename in every read path.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
Unit tests (`src/tests.rs`):
|
|
||||||
- `write_ticket` output does NOT contain the `"id"` key.
|
|
||||||
- `read_ticket` with a file that has NO `id` field correctly sets `ticket.id` from the parameter.
|
|
||||||
- `read_ticket` with an old-format file (has `"id"` in JSON) still sets `ticket.id` from the parameter (ignores JSON value).
|
|
||||||
- `list_tickets` injects correct ids from filenames for all tickets.
|
|
||||||
- Serialisation roundtrip: write then read, id is preserved via filename not JSON.
|
|
||||||
|
|
||||||
Integration tests (`tests/integration.rs`):
|
|
||||||
- `nbd create` output (tabular and `--json`) contains the correct ID.
|
|
||||||
- The created `.json` file on disk does NOT contain the `"id"` key.
|
|
||||||
- `nbd read <id>` 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
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-oziy
|
|
||||||
title: Turso cache for list performance
|
|
||||||
status: completed
|
|
||||||
type: feature
|
|
||||||
priority: low
|
|
||||||
created_at: 2026-03-10T23:30:30Z
|
|
||||||
updated_at: 2026-03-10T23:30:30Z
|
|
||||||
---
|
|
||||||
|
|
||||||
Add a Turso (libsql) cache in `.nbd/cache.db` to accelerate `nbd list` and `nbd ready` for large ticket stores.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
`list_tickets` currently does O(n) file reads on every call. For stores with hundreds of tickets this is measurably slow. A Turso/libsql cache avoids re-reading unchanged files by comparing file modification times (mtimes).
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
|
|
||||||
### Runtime migration: async-std → tokio
|
|
||||||
|
|
||||||
libsql requires tokio. Migrate the entire crate:
|
|
||||||
|
|
||||||
- `Cargo.toml`: remove `async-std`, add `tokio = { version = "1", features = ["full"] }` and `libsql = "0.6"`
|
|
||||||
- `src/main.rs`: `#[async_std::main]` → `#[tokio::main]`, `async_std::fs::remove_file` → `tokio::fs::remove_file`
|
|
||||||
- `src/store.rs`: `use async_std::fs` → `use tokio::fs`, `use async_std::prelude::*` removed (tokio ReadDir uses `.next_entry().await` not stream iteration)
|
|
||||||
- `src/tests.rs`: `#[async_std::test]` → `#[tokio::test]`, `async_std::fs` → `tokio::fs`
|
|
||||||
|
|
||||||
### Crate dependency
|
|
||||||
|
|
||||||
```toml
|
|
||||||
tokio = { version = "1", features = ["full"] }
|
|
||||||
libsql = "0.6"
|
|
||||||
```
|
|
||||||
|
|
||||||
### store.rs additions
|
|
||||||
|
|
||||||
New async function `open_cache(root: &Path) -> Result<libsql::Connection>`:
|
|
||||||
- Opens (or creates) `.nbd/cache.db` via `libsql::Builder::new_local(path).build().await?.connect()`.
|
|
||||||
- Runs migration: `CREATE TABLE IF NOT EXISTS tickets (id TEXT PRIMARY KEY, json TEXT NOT NULL, mtime INTEGER NOT NULL)`.
|
|
||||||
|
|
||||||
New function `list_tickets_cached(root: &Path) -> Result<Vec<Ticket>>`:
|
|
||||||
1. Scan directory for files + mtimes (using tokio::fs::read_dir + metadata).
|
|
||||||
2. Open cache DB.
|
|
||||||
3. For each file: query cache for matching (id, mtime); if hit, deserialise from cached JSON; if miss, read file, parse, upsert to DB.
|
|
||||||
4. Delete DB rows for IDs no longer on disk.
|
|
||||||
5. Return sorted by priority desc.
|
|
||||||
|
|
||||||
Fall back to `list_tickets` on any cache error.
|
|
||||||
|
|
||||||
### main.rs
|
|
||||||
|
|
||||||
`cmd_list`, `cmd_ready`, `cmd_next` use `list_tickets_cached` instead of `list_tickets`.
|
|
||||||
|
|
||||||
## Files to change
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|---|---|
|
|
||||||
| `Cargo.toml` | Replace async-std with tokio, add libsql |
|
|
||||||
| `src/store.rs` | async_std → tokio fs API, add cache functions |
|
|
||||||
| `src/main.rs` | async_std → tokio main/fs |
|
|
||||||
| `src/tests.rs` | async_std → tokio test/fs |
|
|
||||||
| `tests/integration.rs` | Add cache smoke test |
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cargo fmt && cargo check && cargo clippy && cargo test
|
|
||||||
cargo run -- list # should create .nbd/cache.db on first run
|
|
||||||
cargo run -- list # second run uses cache
|
|
||||||
```
|
|
||||||
@ -1,155 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-p9na
|
|
||||||
title: Wire --filter flag into list, ready, and migrate commands
|
|
||||||
status: completed
|
|
||||||
type: feature
|
|
||||||
priority: critical
|
|
||||||
created_at: 2026-03-10T23:30:30Z
|
|
||||||
updated_at: 2026-03-10T23:30:32Z
|
|
||||||
blocked_by:
|
|
||||||
- nbd-zz62
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Add `--filter KEY=VALUE` (repeatable) to the `list`, `ready`, and `migrate` CLI commands.
|
|
||||||
Parse filter arguments into a `TicketFilter` and apply it in each command handler.
|
|
||||||
|
|
||||||
Depends on: TicketFilter module (ticket c2a024).
|
|
||||||
|
|
||||||
## CLI changes (src/main.rs)
|
|
||||||
|
|
||||||
Add to `Commands::List`, `Commands::Ready`, and `Commands::Migrate` variants:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// Filter tickets: key=value pairs (repeatable).
|
|
||||||
/// Keys: priority, type, status, title.
|
|
||||||
/// Different keys are ANDed; same key with multiple values is ORed.
|
|
||||||
/// Values support glob wildcards: status=* matches all statuses.
|
|
||||||
#[arg(long = "filter", value_name = "KEY=VALUE")]
|
|
||||||
filter: Vec<String>,
|
|
||||||
```
|
|
||||||
|
|
||||||
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<Ticket> = 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<Ticket> = 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<MigrateReport>
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-pq2x
|
|
||||||
title: nbd archive command and Closed status
|
|
||||||
status: completed
|
|
||||||
type: feature
|
|
||||||
priority: normal
|
|
||||||
created_at: 2026-03-10T23:30:29Z
|
|
||||||
updated_at: 2026-03-10T23:30:29Z
|
|
||||||
---
|
|
||||||
|
|
||||||
Add `Status::Closed` (serialised as `"closed"`) and a convenience `nbd archive <id>` 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 <id> --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 <id>` 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
|
|
||||||
@ -1,116 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-q2d1
|
|
||||||
title: nbd migrate command
|
|
||||||
status: completed
|
|
||||||
type: feature
|
|
||||||
priority: critical
|
|
||||||
created_at: 2026-03-10T23:30:29Z
|
|
||||||
updated_at: 2026-03-10T23:30:31Z
|
|
||||||
blocked_by:
|
|
||||||
- nbd-o3k8
|
|
||||||
---
|
|
||||||
|
|
||||||
Add `nbd migrate` to bring all ticket files on disk into conformance with the current schema. This is the standard mechanism for handling any schema change — field removals, field additions, renames, or type changes.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
Schema changes (like removing `id` from the JSON body, adding new fields with defaults, or renaming a field) leave existing ticket files in an old format. `nbd migrate` re-serialises every ticket through the current serde schema, which:
|
|
||||||
- **Removes** fields that no longer exist in `Ticket` (they are ignored on deserialise, then absent on re-serialise).
|
|
||||||
- **Adds** new fields with their `#[serde(default)]` values.
|
|
||||||
- **Normalises** any formatting differences (e.g. key order, whitespace).
|
|
||||||
|
|
||||||
The current immediate use case is scrubbing the `"id"` key from all existing `.json` files after the id-from-filename schema change.
|
|
||||||
|
|
||||||
## Design principles
|
|
||||||
|
|
||||||
- **Idempotent.** Running `nbd migrate` on an already-current store is a no-op (files are re-written identically).
|
|
||||||
- **Non-destructive.** A failure on one ticket does not abort the rest; errors are collected and reported at the end.
|
|
||||||
- **Source of truth unchanged.** If a ticket cannot be parsed, it is left on disk as-is and reported as an error.
|
|
||||||
- **Dry-run available.** `--dry-run` prints what would change without writing.
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
|
|
||||||
### main.rs
|
|
||||||
|
|
||||||
Add `Migrate` variant to `Commands`:
|
|
||||||
```
|
|
||||||
Migrate {
|
|
||||||
/// Print changes without writing them.
|
|
||||||
#[arg(long)]
|
|
||||||
dry_run: bool,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Implement `cmd_migrate(dry_run: bool) -> store::Result<()>`:
|
|
||||||
1. `find_nbd_root()`
|
|
||||||
2. Call `store::migrate_tickets(&root, dry_run).await`
|
|
||||||
3. Print a summary: `Migrated N tickets (M errors)`.
|
|
||||||
|
|
||||||
### store.rs
|
|
||||||
|
|
||||||
Add `migrate_tickets(root: &Path, dry_run: bool) -> Result<MigrateReport>`:
|
|
||||||
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<String>` 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`
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-ql0c
|
|
||||||
title: Add next <type> filtered sub-commands (next bug, next feature, next task)
|
|
||||||
status: todo
|
|
||||||
type: feature
|
|
||||||
priority: low
|
|
||||||
created_at: 2026-03-10T23:30:30Z
|
|
||||||
updated_at: 2026-03-10T23:30:30Z
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
`nbd next --filter type=bug` is verbose. When an engineer wants the highest-priority ready bug, they should be able to say `nbd next bug`.
|
|
||||||
|
|
||||||
## Sub-commands to add (positional arg to `next`)
|
|
||||||
|
|
||||||
Accept an optional positional `<type>` argument to `nbd next`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
nbd next feature # equivalent to: nbd next --filter type=feature
|
|
||||||
nbd next task # equivalent to: nbd next --filter type=task
|
|
||||||
nbd next bug # equivalent to: nbd next --filter type=bug
|
|
||||||
nbd next project # equivalent to: nbd next --filter type=project
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
**`src/main.rs`** — `Commands::Next`
|
|
||||||
|
|
||||||
Add an optional positional argument `ticket_type` to the `Next` variant:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
Next {
|
|
||||||
ticket_type: Option<String>, // new: positional shorthand
|
|
||||||
filter: Vec<String>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
In `cmd_next`, when `ticket_type` is `Some(t)`:
|
|
||||||
- Validate it is one of `project`, `feature`, `task`, `bug` (return an error otherwise)
|
|
||||||
- Prepend `format!("type={t}")` to the effective filter list before calling `parse_filters`
|
|
||||||
|
|
||||||
Explicit `--filter type=...` values are ORed with the positional shorthand, consistent with `TicketFilter` behaviour.
|
|
||||||
|
|
||||||
**`tests/integration.rs`**
|
|
||||||
|
|
||||||
Add tests verifying that `next bug` returns only bug-type ready tickets and that `next` with no argument still works as before.
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-rulc
|
|
||||||
title: Partial ID matching
|
|
||||||
status: completed
|
|
||||||
type: feature
|
|
||||||
priority: critical
|
|
||||||
created_at: 2026-03-10T23:30:31Z
|
|
||||||
updated_at: 2026-03-10T23:30:31Z
|
|
||||||
---
|
|
||||||
|
|
||||||
Allow `nbd read`, `nbd update`, and dependency resolution to accept a prefix of a ticket ID (e.g. `nbd read a3f` resolves to `a3f9c2`).
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
6-character hex IDs are tedious to type in full. Prefix matching — like git's short-SHA resolution — significantly improves interactive ergonomics and makes agent-generated commands shorter.
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
|
|
||||||
Add `resolve_id(root: &Path, id_or_prefix: &str) -> Result<String>` 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
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-s16w
|
|
||||||
title: Multiple file format support (md, toml, jsonb)
|
|
||||||
status: completed
|
|
||||||
type: feature
|
|
||||||
priority: normal
|
|
||||||
created_at: 2026-03-10T23:30:30Z
|
|
||||||
updated_at: 2026-03-10T23:30:30Z
|
|
||||||
---
|
|
||||||
|
|
||||||
Add `--ftype` flag to `create` and `update` to write tickets in markdown, TOML, or binary JSON (CBOR) in addition to the existing JSON format. Format is detected from file extension on read.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
Markdown format lets agents write long-form ticket bodies with full markdown syntax, and makes tickets human-readable in a file browser. TOML is a natural config format. CBOR offers compact binary storage.
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
|
|
||||||
### New crate dependencies (Cargo.toml)
|
|
||||||
Evaluate and add:
|
|
||||||
- `toml` — TOML serialisation (likely `toml = "0.8"`)
|
|
||||||
- `serde_yml` or `serde_yaml` — YAML frontmatter (for `.md` files)
|
|
||||||
- `ciborium` — CBOR binary JSON (`.jsonb`)
|
|
||||||
|
|
||||||
### ticket.rs
|
|
||||||
No changes needed — `Ticket` already derives `Serialize`/`Deserialize`.
|
|
||||||
|
|
||||||
### store.rs
|
|
||||||
New enum `FileFormat { Json, Markdown, Toml, Jsonb }`.
|
|
||||||
|
|
||||||
New function `detect_format(path: &Path) -> FileFormat`:
|
|
||||||
- `.json` → `Json`
|
|
||||||
- `.md` → `Markdown`
|
|
||||||
- `.toml` → `Toml`
|
|
||||||
- `.jsonb` → `Jsonb`
|
|
||||||
- Unknown → `Json` (fallback)
|
|
||||||
|
|
||||||
Update `ticket_path(root, id, format)` to use the format-appropriate extension. This is a breaking change to the function signature — update all callers.
|
|
||||||
|
|
||||||
Update `read_ticket(root, id)`:
|
|
||||||
1. Try each known extension in order until a file is found.
|
|
||||||
2. Read the file and dispatch to the format-appropriate deserialiser.
|
|
||||||
|
|
||||||
Serialisation helpers (private):
|
|
||||||
- `serialize_json(ticket) -> String`
|
|
||||||
- `serialize_toml(ticket) -> String`
|
|
||||||
- `serialize_markdown(ticket) -> String` — TOML frontmatter (`+++` delimiters) with body as file content
|
|
||||||
- `serialize_jsonb(ticket) -> Vec<u8>`
|
|
||||||
|
|
||||||
Deserialization helpers (private):
|
|
||||||
- `deserialize_markdown(bytes) -> Result<Ticket>` — 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 <id> --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
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-urlz
|
|
||||||
title: 'Investigate: filtering tickets by project / stream of work'
|
|
||||||
status: draft
|
|
||||||
type: task
|
|
||||||
priority: low
|
|
||||||
created_at: 2026-03-10T23:30:30Z
|
|
||||||
updated_at: 2026-03-10T23:30:30Z
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
When multiple streams of work coexist (e.g., refactoring vs. new feature), there is no way to select tickets for one stream only. `nbd next` and `nbd list` operate across all tickets.
|
|
||||||
|
|
||||||
## Questions to answer
|
|
||||||
|
|
||||||
1. Does the existing `project`-type ticket + `deps` mechanism serve this need? Could a project ticket act as a grouping node, and filtering by `--filter type=project` or by the project ticket's subtree (`nbd graph <project-id> --json`) provide what is needed?
|
|
||||||
|
|
||||||
2. Are there cases where a ticket belongs to multiple projects? If so, a single-parent `deps` tree cannot represent the relationship.
|
|
||||||
|
|
||||||
3. Is a dedicated `project` or `label` field (multi-valued) preferable? What are the trade-offs?
|
|
||||||
|
|
||||||
4. How does this interact with `nbd ready` and `nbd next`? Would a `--project <id>` flag on these commands be sufficient?
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
|
|
||||||
Investigate by:
|
|
||||||
1. Manually modelling two parallel workstreams using `project`-type tickets and `deps`.
|
|
||||||
2. Evaluating whether `nbd graph <project-id> --json` provides enough to extract a project-scoped ticket list.
|
|
||||||
3. Writing up findings and creating actionable implementation tickets.
|
|
||||||
|
|
||||||
## Expected output
|
|
||||||
|
|
||||||
Create one or more follow-up tickets with a concrete implementation plan, or close this ticket with a rationale if the existing tools are sufficient.
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-wbf7
|
|
||||||
title: Nix flake for nbd
|
|
||||||
status: completed
|
|
||||||
type: task
|
|
||||||
priority: low
|
|
||||||
created_at: 2026-03-10T23:30:30Z
|
|
||||||
updated_at: 2026-03-10T23:30:30Z
|
|
||||||
---
|
|
||||||
|
|
||||||
Add `nbd/flake.nix` so that `nbd` can be consumed as a Nix package and used as a CLI tool in other projects in the mono-repo.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
Other `vibed` services and external projects should be able to include `nbd` as a dev dependency via Nix, getting a pinned, reproducible binary without needing Cargo installed.
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
|
|
||||||
### nbd/flake.nix
|
|
||||||
|
|
||||||
Follow the pattern from the repo root `flake.nix`. The service flake should:
|
|
||||||
1. Inherit the base flake's inputs (nixpkgs, rust-overlay, etc.) or declare its own.
|
|
||||||
2. Define a `packages.default` attribute that builds the `nbd` crate with `rustPlatform.buildRustPackage`.
|
|
||||||
3. Define a `devShells.default` that includes the `nbd` binary and standard Rust tooling (rustfmt, clippy, cargo).
|
|
||||||
4. Optionally expose an `apps.default` for `nix run .#nbd`.
|
|
||||||
|
|
||||||
### Cargo.lock
|
|
||||||
Ensure `Cargo.lock` is committed (it already is) — required for reproducible Nix builds.
|
|
||||||
|
|
||||||
### cargoHash / cargoSha256
|
|
||||||
The `buildRustPackage` derivation requires a `cargoHash` (or `cargoSha256`). Use the correct fetcher approach (vendored deps or `fetchCargoTarball`).
|
|
||||||
|
|
||||||
## Steps
|
|
||||||
1. Read the root `flake.nix` to understand the base structure.
|
|
||||||
2. Write `nbd/flake.nix` following the same conventions.
|
|
||||||
3. Run `nix build .#nbd` from the `nbd/` directory to verify it builds.
|
|
||||||
4. Run `nix run .#nbd -- --help` to verify the binary works.
|
|
||||||
|
|
||||||
## Files touched
|
|
||||||
- `nbd/flake.nix` — new file
|
|
||||||
- `README.md` — add `nix run` usage section
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-wi74
|
|
||||||
title: '''VERSION file: compile into binary via include_str\! and add nbd version subcommand'''
|
|
||||||
status: todo
|
|
||||||
type: feature
|
|
||||||
priority: normal
|
|
||||||
created_at: 2026-03-10T23:30:30Z
|
|
||||||
updated_at: 2026-03-10T23:30:30Z
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
The current version string is assembled in `main.rs` from `CARGO_PKG_VERSION` (Cargo.toml) and `GIT_SHORT_SHA` (build.rs). There is no `nbd version` subcommand — only `--version`. And the `/work` skill does not bump any version on completion.
|
|
||||||
|
|
||||||
## Changes required
|
|
||||||
|
|
||||||
### 1. Create `VERSION` file
|
|
||||||
|
|
||||||
Create a `VERSION` file at the crate root (`nbd/VERSION`) containing the initial version:
|
|
||||||
|
|
||||||
```
|
|
||||||
0.1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Use `VERSION` in the binary
|
|
||||||
|
|
||||||
In `src/main.rs`, replace `CARGO_PKG_VERSION` with an `include_str\!` of the `VERSION` file (trimmed):
|
|
||||||
|
|
||||||
```rust
|
|
||||||
const VERSION: &str = concat\!(
|
|
||||||
env\!("CARGO_PKG_VERSION"), // keep for crate metadata
|
|
||||||
"+",
|
|
||||||
env\!("GIT_SHORT_SHA"),
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
Actually, use `include_str\!("../VERSION")` trimmed and concatenated with the git SHA:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
const VERSION_FILE: &str = include_str\!("../VERSION");
|
|
||||||
const VERSION: &str = /* VERSION_FILE.trim() + "+" + GIT_SHORT_SHA at runtime or via concat */ ;
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: `include_str\!` and `concat\!` cannot trim at compile time. Use a `build.rs` approach: read `VERSION` in `build.rs`, trim it, and emit it via `cargo:rustc-env=NBD_VERSION=...`. Then:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
const VERSION: &str = concat\!(env\!("NBD_VERSION"), "+", env\!("GIT_SHORT_SHA"));
|
|
||||||
```
|
|
||||||
|
|
||||||
Update `build.rs` to read `VERSION` and emit `NBD_VERSION`.
|
|
||||||
|
|
||||||
### 3. Add `nbd version` subcommand
|
|
||||||
|
|
||||||
Add a `Version` variant to the `Commands` enum:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// Print the nbd version string and exit.
|
|
||||||
Version,
|
|
||||||
```
|
|
||||||
|
|
||||||
Handler prints the same string as `--version`. With `--json`, output `{"version": "X.Y.Z+sha"}`.
|
|
||||||
|
|
||||||
### 4. Update `/work` skill
|
|
||||||
|
|
||||||
Edit `.claude/skills/work/SKILL.md` to add a version-bump step after marking a ticket complete:
|
|
||||||
|
|
||||||
- Breaking changes (major rework, API change) → bump major: `X.0.0`
|
|
||||||
- Features (`ticket_type == feature`) → bump minor: `X.Y.0`
|
|
||||||
- Tasks and bugs (`ticket_type == task | bug`) → bump patch: `X.Y.Z`
|
|
||||||
|
|
||||||
The skill should read `VERSION`, parse the semver, increment the appropriate component, and write it back. Also update `Cargo.toml` `version` to match (keep in sync).
|
|
||||||
|
|
||||||
## Relevant files
|
|
||||||
|
|
||||||
- `nbd/VERSION` (new)
|
|
||||||
- `nbd/build.rs` (update: emit `NBD_VERSION`)
|
|
||||||
- `nbd/src/main.rs` (update: use `NBD_VERSION`, add `Version` subcommand)
|
|
||||||
- `nbd/Cargo.toml` (keep version in sync with `VERSION` file)
|
|
||||||
- `.claude/skills/work/SKILL.md` (update: add version-bump step)
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-y4nv
|
|
||||||
title: Change graph cycle marker from [cycle] to *
|
|
||||||
status: completed
|
|
||||||
type: task
|
|
||||||
priority: low
|
|
||||||
created_at: 2026-03-10T23:30:30Z
|
|
||||||
updated_at: 2026-03-10T23:30:30Z
|
|
||||||
---
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
When `nbd graph` renders a node that has already been visited (a node appearing in multiple branches of the tree), it currently labels the repeat occurrence as `[cycle]`. This label is misleading — the node isn't truly in a cycle, it's simply appearing twice in the tree because it's depended on from multiple places. Change the marker to `*` to indicate "this ticket appears elsewhere in the tree".
|
|
||||||
|
|
||||||
## Current output
|
|
||||||
|
|
||||||
```
|
|
||||||
a3f9c2 [todo] Fix login bug
|
|
||||||
├── b7d41e [in_progress] Add rate limiting
|
|
||||||
│ └── c9e823 [todo] Write tests
|
|
||||||
└── c9e823 [cycle]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Target output
|
|
||||||
|
|
||||||
```
|
|
||||||
a3f9c2 [todo] Fix login bug
|
|
||||||
├── b7d41e [in_progress] Add rate limiting
|
|
||||||
│ └── c9e823 [todo] Write tests
|
|
||||||
└── c9e823 *
|
|
||||||
```
|
|
||||||
|
|
||||||
## Files to change
|
|
||||||
|
|
||||||
### `src/display.rs`
|
|
||||||
|
|
||||||
In the `render_node` function (around line 409), change the cycle rendering line from:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
append_line(out, &format!("{prefix}{connector}{id} [cycle]"));
|
|
||||||
```
|
|
||||||
|
|
||||||
to:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
append_line(out, &format!("{prefix}{connector}{id} *"));
|
|
||||||
```
|
|
||||||
|
|
||||||
### README.md
|
|
||||||
|
|
||||||
Update the `nbd graph` section example to replace `[cycle]` with `*` in the documentation.
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cargo fmt && cargo check && cargo clippy && cargo test
|
|
||||||
# Create two tickets that share a dependency, then graph them
|
|
||||||
cargo run -- graph
|
|
||||||
```
|
|
||||||
|
|
||||||
Any test that checks for `[cycle]` in graph output needs updating to expect `*` instead.
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-yh0v
|
|
||||||
title: 'Add triage status: new default for tickets lacking implementation detail'
|
|
||||||
status: todo
|
|
||||||
type: feature
|
|
||||||
priority: normal
|
|
||||||
created_at: 2026-03-10T23:30:31Z
|
|
||||||
updated_at: 2026-03-10T23:30:31Z
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
New tickets are created with `status=todo`, implying they are ready to work on. But many tickets need further research or implementation details before work can begin. The TODO describes a `triage` status for exactly this case — tickets that need an LLM or human to fill in details before they become `todo`.
|
|
||||||
|
|
||||||
## Status semantics
|
|
||||||
|
|
||||||
- `triage` — the ticket exists but lacks sufficient detail to begin implementation. An LLM or human should flesh out the body and move it to `todo` when ready.
|
|
||||||
- `triage` is excluded from `nbd ready` and `nbd next` (not actionable yet).
|
|
||||||
- `triage` is excluded from `nbd list` by default (like `backlog`).
|
|
||||||
- `triage` is the new **default status** for `nbd create` (replaces `todo`).
|
|
||||||
|
|
||||||
## Changes
|
|
||||||
|
|
||||||
### `src/ticket.rs`
|
|
||||||
|
|
||||||
Add `Triage` variant to `Status`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// The ticket needs more detail before it can be worked on.
|
|
||||||
///
|
|
||||||
/// A triage ticket should have its body updated with implementation
|
|
||||||
/// details and then moved to `todo`.
|
|
||||||
Triage,
|
|
||||||
```
|
|
||||||
|
|
||||||
Change the `#[default]` attribute from `Todo` to `Triage`.
|
|
||||||
|
|
||||||
### `src/main.rs`
|
|
||||||
|
|
||||||
- `parse_status`: add `"triage" => Ok(Status::Triage)` and update the error message.
|
|
||||||
- `Commands::Create`: change the default value for `--status` from `"todo"` to `"triage"`.
|
|
||||||
- **Note:** If `.nbd/config.toml` support (separate ticket) is implemented first, the default should be settable via config; fall back to `"triage"` if config is absent.
|
|
||||||
- `cmd_ready` and `cmd_next`: add `Status::Triage` to the exclusion list.
|
|
||||||
- `cmd_list`: add `Status::Triage` to the default exclusion list.
|
|
||||||
|
|
||||||
### `src/filter.rs`
|
|
||||||
|
|
||||||
- `status_str`: add `Status::Triage => "triage"`.
|
|
||||||
|
|
||||||
### `src/display.rs`
|
|
||||||
|
|
||||||
- `status_str`: add `Status::Triage => "triage"`.
|
|
||||||
- Column widths: `STATUS` column is currently 13 chars (`"in_progress" + 2`). `"triage"" is 6 chars — no width change needed.
|
|
||||||
|
|
||||||
### `src/graph.rs`
|
|
||||||
|
|
||||||
- `status_str`: add `Status::Triage => "triage"`.
|
|
||||||
|
|
||||||
### `src/claude_md_snippet.md`
|
|
||||||
|
|
||||||
Update the embedded snippet to document the triage workflow:
|
|
||||||
- `triage` tickets need implementation details added to their body before they can be worked on.
|
|
||||||
- When creating a ticket that is ready to work on immediately, pass `--status todo` explicitly.
|
|
||||||
- Default: `nbd create --title "..."` creates a `triage` ticket.
|
|
||||||
|
|
||||||
### `CLAUDE.md` (project-level)
|
|
||||||
|
|
||||||
Update the workflow section to reflect the new default and document when to use `--status todo` vs. leaving the default.
|
|
||||||
|
|
||||||
### `src/tests.rs`
|
|
||||||
|
|
||||||
Add unit tests:
|
|
||||||
- `Status::Triage` serialises to `"triage"`
|
|
||||||
- Round-trip deserialisation
|
|
||||||
- Default `Ticket::new` has `status == Status::Triage`
|
|
||||||
|
|
||||||
### `tests/integration.rs`
|
|
||||||
|
|
||||||
- `nbd create` with no `--status` flag creates a `triage` ticket.
|
|
||||||
- `nbd create --status todo` creates a `todo` ticket.
|
|
||||||
- `nbd ready` does not include `triage` tickets.
|
|
||||||
- `nbd next` does not include `triage` tickets.
|
|
||||||
- `nbd list` does not include `triage` tickets by default.
|
|
||||||
- `nbd list --filter status=triage` shows only triage tickets.
|
|
||||||
- `nbd list --all` includes `triage` tickets.
|
|
||||||
@ -1,135 +0,0 @@
|
|||||||
---
|
|
||||||
# nbd-zz62
|
|
||||||
title: Implement TicketFilter module with glob matching
|
|
||||||
status: completed
|
|
||||||
type: feature
|
|
||||||
priority: critical
|
|
||||||
created_at: 2026-03-10T23:30:31Z
|
|
||||||
updated_at: 2026-03-10T23:30:31Z
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Add a `src/filter.rs` module implementing `TicketFilter`, which parses and applies
|
|
||||||
`key=value` filter expressions against a list of tickets. This is the foundational
|
|
||||||
building block for the `--filter` flag on `list`, `ready`, `migrate`, and `next` commands.
|
|
||||||
|
|
||||||
No external crate is needed — glob matching is implemented with a simple hand-rolled
|
|
||||||
algorithm that handles `*` wildcards.
|
|
||||||
|
|
||||||
## Filter semantics
|
|
||||||
|
|
||||||
- `--filter key=value` can be specified multiple times.
|
|
||||||
- Keys: `priority`, `type`, `status`, `title`.
|
|
||||||
- **Different keys are ANDed**: `--filter type=bug --filter status=todo` matches tickets
|
|
||||||
that are BOTH bugs AND todo.
|
|
||||||
- **Same key, multiple values are ORed**: `--filter status=todo --filter status=in_progress`
|
|
||||||
matches tickets with EITHER status.
|
|
||||||
- Values support `*` as a wildcard anywhere in the pattern:
|
|
||||||
- `status=*` matches any status
|
|
||||||
- `title=*command*` matches any title containing "command"
|
|
||||||
- `priority=7` matches exactly priority 7 (as string comparison)
|
|
||||||
|
|
||||||
## Data structures
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// A compiled set of filter expressions applied to tickets.
|
|
||||||
///
|
|
||||||
/// Within the same key, patterns are ORed (any match passes the group).
|
|
||||||
/// Across different keys, groups are ANDed (all non-empty groups must pass).
|
|
||||||
pub struct TicketFilter {
|
|
||||||
/// Glob patterns for `status` (OR within this group).
|
|
||||||
pub status: Vec<String>,
|
|
||||||
/// Glob patterns for `ticket_type` (OR within this group).
|
|
||||||
pub ticket_type: Vec<String>,
|
|
||||||
/// Glob patterns for `priority` (OR within this group; matched against string repr).
|
|
||||||
pub priority: Vec<String>,
|
|
||||||
/// Glob patterns for `title` (OR within this group).
|
|
||||||
pub title: Vec<String>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
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<TicketFilter>
|
|
||||||
|
|
||||||
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.
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(cargo:*)",
|
|
||||||
"Bash(git:*)",
|
|
||||||
"Bash(ls:*)",
|
|
||||||
"Edit",
|
|
||||||
"Write"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
---
|
|
||||||
name: triage
|
|
||||||
description: triage issues
|
|
||||||
---
|
|
||||||
|
|
||||||
* Read @docs @src @tests @PLAN.md @README.md and @CLAUDE.md for context before starting.
|
|
||||||
* Deeply understand each item before planning.
|
|
||||||
* Create `beans` beans for each item including your plan, breaking the problem down into smaller beans where it makes sense.
|
|
||||||
* Include relevant source files, documentation, and context about each bean so the work has the highest probability of success without additional context being required.
|
|
||||||
* If a file was passed to this skill, remove items from the file which now have a bean.
|
|
||||||
* Commit these beans in the `.beans` directory with a concise summary of each bean, probably using the title and bean IDs, and the file passed to this skill if one was passed.
|
|
||||||
* Do not implement any tickets.
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
---
|
|
||||||
name: work
|
|
||||||
description: work on the highest priority thing
|
|
||||||
---
|
|
||||||
|
|
||||||
* If a ticket ID or description of a ticket is provided, look for that ticket.
|
|
||||||
* If no ticket/description is provided, use `beans list --json --ready` to choose the highest-priority unblocked bean to work on.
|
|
||||||
* Thoroughly investigate the relevant parts of the codebase to ensure you understand the problem and how to implement the solution as describe in the ticket.
|
|
||||||
* Implement the plan in the selected ticket.
|
|
||||||
* Once complete validate changes with cargo fmt, cargo check, cargo clippy, and cargo test.
|
|
||||||
* Iteratively fix warnings and errors then repeat the validation steps until everything is good.
|
|
||||||
* Update the ticket with the new status.
|
|
||||||
* Create additional tickets based on work that may have come up during this work.
|
|
||||||
* Add or Update docs including README.md and CLAUDE.md if relevant.
|
|
||||||
* Commit changes with a concise message and reference the ticket ID.
|
|
||||||
@ -1,184 +0,0 @@
|
|||||||
@../CLAUDE.md
|
|
||||||
|
|
||||||
# CLAUDE.md — nbd
|
|
||||||
|
|
||||||
<working-directory>
|
|
||||||
All commands in this file are run from the `nbd/` directory.
|
|
||||||
</working-directory>
|
|
||||||
|
|
||||||
<project-overview>
|
|
||||||
|
|
||||||
`nbd` is a CLI tool for managing work tickets, primarily targeted at agent workflows. It is an independent Rust crate within the `vibed` mono-repo.
|
|
||||||
|
|
||||||
</project-overview>
|
|
||||||
|
|
||||||
<project-structure>
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
nbd/
|
|
||||||
├── CLAUDE.md
|
|
||||||
├── PLAN.md # implementation plan and post-MVP roadmap
|
|
||||||
├── Cargo.toml
|
|
||||||
├── Cargo.lock
|
|
||||||
├── src/
|
|
||||||
│ ├── main.rs # CLI entry point; clap subcommand dispatch
|
|
||||||
│ ├── ticket.rs # Ticket struct, Status and TicketType enums
|
|
||||||
│ ├── store.rs # file I/O, directory traversal, CRUD
|
|
||||||
│ ├── display.rs # tabular and JSON output formatting
|
|
||||||
│ └── tests.rs # unit tests
|
|
||||||
├── tests/
|
|
||||||
│ └── integration.rs # integration tests using tempdir
|
|
||||||
├── docs/
|
|
||||||
│ ├── PLANNING.md # development phases and work logs
|
|
||||||
│ └── ARCHITECTURE.md # component overview and interactions
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
</project-structure>
|
|
||||||
|
|
||||||
<tech-stack>
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
- **Language:** Rust (edition 2021)
|
|
||||||
- **Async runtime:** `async-std`
|
|
||||||
- **CLI parsing:** `clap` (derive feature)
|
|
||||||
- **Serialization:** `serde` + `serde_json`
|
|
||||||
- **Test utilities:** `tempfile` (dev-dependency)
|
|
||||||
|
|
||||||
</tech-stack>
|
|
||||||
|
|
||||||
<data-model>
|
|
||||||
|
|
||||||
## Data Model
|
|
||||||
|
|
||||||
Tickets are stored as `.json` files in `.nbd/tickets/{id}.json`, where `id` is a 6-character hex string (e.g. `a3f9c2`). The `.nbd/` root is found by traversing up from cwd, like `git` finds `.git/`.
|
|
||||||
|
|
||||||
```
|
|
||||||
Ticket {
|
|
||||||
id: String // 6-char hex
|
|
||||||
title: String
|
|
||||||
body: String
|
|
||||||
priority: u8 // 0..=10, default 5
|
|
||||||
status: Status // Todo | InProgress | Done | Closed | Archived | Backlog, default Todo
|
|
||||||
dependencies: Vec<String> // Vec of ticket IDs, default []
|
|
||||||
ticket_type: TicketType // Project | Feature | Task | Bug, default Task
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
</data-model>
|
|
||||||
|
|
||||||
<cli-interface>
|
|
||||||
|
|
||||||
## CLI Interface
|
|
||||||
|
|
||||||
```sh
|
|
||||||
nbd init [--json]
|
|
||||||
|
|
||||||
nbd create --title "..." [--body "..."] [--priority 5] [--status todo|in_progress|done|closed|archived|backlog]
|
|
||||||
[--type task] [--deps id1,id2] [--json]
|
|
||||||
|
|
||||||
nbd read <id> [--json]
|
|
||||||
|
|
||||||
nbd list [--json]
|
|
||||||
|
|
||||||
nbd ready [--json]
|
|
||||||
|
|
||||||
nbd update <id> [--title "..."] [--body "..."] [--priority N]
|
|
||||||
[--status ...] [--type ...] [--deps ...] [--json]
|
|
||||||
|
|
||||||
nbd graph [<id>] [--filter KEY=VALUE ...] [--json]
|
|
||||||
```
|
|
||||||
|
|
||||||
`--json` is available on all commands for machine-readable output.
|
|
||||||
|
|
||||||
</cli-interface>
|
|
||||||
|
|
||||||
<module-responsibilities>
|
|
||||||
|
|
||||||
## Module Responsibilities
|
|
||||||
|
|
||||||
| Module | Responsibility |
|
|
||||||
|---|---|
|
|
||||||
| `main.rs` | `clap` CLI definition and subcommand dispatch only |
|
|
||||||
| `ticket.rs` | `Ticket` struct, enums, ID generation, validation |
|
|
||||||
| `store.rs` | directory traversal, file read/write, CRUD operations |
|
|
||||||
| `display.rs` | tabular formatting, JSON output, ASCII graph rendering |
|
|
||||||
| `graph.rs` | dependency graph computation (`TicketGraph`, edges, subtrees) |
|
|
||||||
| `filter.rs` | glob-pattern ticket filtering |
|
|
||||||
| `tests.rs` | unit tests for all modules |
|
|
||||||
|
|
||||||
</module-responsibilities>
|
|
||||||
|
|
||||||
<task-tracking>
|
|
||||||
|
|
||||||
## Task Tracking with beans
|
|
||||||
|
|
||||||
Use `beans` to track tasks for work on this project.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
beans init # run once; creates .beans/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Workflow
|
|
||||||
|
|
||||||
Always pass `--json` to every command. Use `jq` to parse output when needed.
|
|
||||||
|
|
||||||
**Before starting work:** Create a bean.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
beans create --json "Add partial ID matching" --type feature --priority high --status todo
|
|
||||||
```
|
|
||||||
|
|
||||||
**When starting a task:** Update its status.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
beans update --json <id> --status in-progress
|
|
||||||
```
|
|
||||||
|
|
||||||
**When done:** Mark it complete.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
beans update --json <id> --status completed
|
|
||||||
```
|
|
||||||
|
|
||||||
**To see all beans ready to start:**
|
|
||||||
|
|
||||||
```sh
|
|
||||||
beans list --json --ready
|
|
||||||
```
|
|
||||||
|
|
||||||
**To see all beans:**
|
|
||||||
|
|
||||||
```sh
|
|
||||||
beans list --json
|
|
||||||
```
|
|
||||||
|
|
||||||
**To view a specific bean:**
|
|
||||||
|
|
||||||
```sh
|
|
||||||
beans show --json <id>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Guidelines
|
|
||||||
|
|
||||||
- **Always use `--json`.** It gives structured, unambiguous output on every command.
|
|
||||||
- Create beans *before* starting non-trivial tasks, not after.
|
|
||||||
- Use `--blocked-by <id>` to express blockers — beans that must be done first.
|
|
||||||
- `--priority` choices: `critical`, `high`, `normal`, `low`, `deferred`.
|
|
||||||
- `--type` choices: `milestone`, `epic`, `feature`, `task`, `bug`.
|
|
||||||
|
|
||||||
</task-tracking>
|
|
||||||
|
|
||||||
<testing>
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- **Unit tests:** `src/tests.rs` — test each module in isolation; use `tempfile` for any file I/O
|
|
||||||
- **Integration tests:** `tests/integration.rs` — test full command flows (create → read → list → update) against a real temp directory; test directory traversal by running from a subdirectory
|
|
||||||
|
|
||||||
Target 80%+ code coverage.
|
|
||||||
|
|
||||||
</testing>
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,28 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "nbd"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
description = "CLI tool for managing work tickets, targeted at agent workflows"
|
|
||||||
license = "MIT OR Apache-2.0"
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "nbd"
|
|
||||||
path = "src/main.rs"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
clap = { version = "4", features = ["derive"] }
|
|
||||||
tokio = { version = "1", features = ["full"] }
|
|
||||||
libsql = "0.6"
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_json = "1"
|
|
||||||
toml = "0.8"
|
|
||||||
ciborium = "0.2"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tempfile = "3"
|
|
||||||
|
|
||||||
[profile.release]
|
|
||||||
opt-level = "z"
|
|
||||||
lto = true
|
|
||||||
strip = true
|
|
||||||
codegen-units = 1
|
|
||||||
@ -1,179 +0,0 @@
|
|||||||
# nbd — Implementation Plan
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
`nbd` is a CLI tool for managing work tickets, primarily targeted at agent workflows. It stores tickets as JSON files in `.nbd/tickets/` in the project root (found by traversing up from cwd, like git).
|
|
||||||
|
|
||||||
**Crate location:** `vibed/nbd/`
|
|
||||||
**Language:** Rust (edition 2021)
|
|
||||||
**Key crates:** `clap`, `async-std`, `serde` / `serde_json`, `tempfile` (tests)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1: Crate Scaffold
|
|
||||||
|
|
||||||
Set up the crate structure with no logic yet.
|
|
||||||
|
|
||||||
- [x] Create `nbd/Cargo.toml` with dependencies: `clap` (derive feature), `async-std`, `serde`, `serde_json`; dev-dependencies: `tempfile`
|
|
||||||
- [x] Add `[profile.release]` optimizations (`opt-level = "z"`, `lto = true`, `strip = true`, `codegen-units = 1`)
|
|
||||||
- [x] Create `nbd/src/main.rs` — `async-std` entry point, placeholder `fn main()`
|
|
||||||
- [x] Create `nbd/src/ticket.rs` — empty module
|
|
||||||
- [x] Create `nbd/src/store.rs` — empty module
|
|
||||||
- [x] Create `nbd/src/display.rs` — empty module
|
|
||||||
- [x] Create `nbd/src/tests.rs` — empty test module
|
|
||||||
- [x] Create `nbd/tests/integration.rs` — empty integration test file
|
|
||||||
- [x] Verify: `cargo check` passes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: Data Model (`ticket.rs`)
|
|
||||||
|
|
||||||
Define the core types.
|
|
||||||
|
|
||||||
- [x] Define `Status` enum: `Todo`, `InProgress`, `Done` (default: `Todo`)
|
|
||||||
- Derive: `Serialize`, `Deserialize`, `Debug`, `Clone`, `PartialEq`
|
|
||||||
- Serialize to/from lowercase strings (`"todo"`, `"in_progress"`, `"done"`)
|
|
||||||
- [x] Define `TicketType` enum: `Project`, `Feature`, `Task`, `Bug` (default: `Task`)
|
|
||||||
- Derive same traits
|
|
||||||
- Serialize to/from lowercase strings
|
|
||||||
- [x] Define `Ticket` struct:
|
|
||||||
```
|
|
||||||
id: String // 6-char hex, e.g. "a3f9c2"
|
|
||||||
title: String
|
|
||||||
body: String
|
|
||||||
priority: u8 // 0..=10, default 5
|
|
||||||
status: Status
|
|
||||||
dependencies: Vec<String> // Vec of ticket IDs
|
|
||||||
ticket_type: TicketType // field name avoids keyword collision
|
|
||||||
```
|
|
||||||
- Derive: `Serialize`, `Deserialize`, `Debug`, `Clone`
|
|
||||||
- [x] Implement `Ticket::new(id, title)` constructor with all defaults
|
|
||||||
- [x] Implement priority validation (error if > 10)
|
|
||||||
- [x] Implement ID generation: 3 random bytes → 6 hex chars (use `std::collections::hash_map::RandomState` or similar; no external crate needed for MVP)
|
|
||||||
- [x] Unit tests: serialization roundtrip, priority validation, ID format (6 hex chars, unique across N calls)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3: Storage (`store.rs`)
|
|
||||||
|
|
||||||
File I/O and directory traversal using `async-std`.
|
|
||||||
|
|
||||||
- [x] Implement `find_nbd_root() -> Result<PathBuf>`: walk from cwd upward until `.nbd/` is found; error with helpful message if not found
|
|
||||||
- [x] Implement `find_nbd_root_from(start: &Path) -> Result<PathBuf>`: testable variant that accepts a starting path
|
|
||||||
- [x] Implement `tickets_dir(root: &Path) -> PathBuf`: returns `root/.nbd/tickets/`
|
|
||||||
- [x] Implement `ensure_tickets_dir(root: &Path) -> Result<()>`: creates `.nbd/tickets/` if missing (used only by `create`)
|
|
||||||
- [x] Implement `ticket_path(root: &Path, id: &str) -> PathBuf`: returns `.nbd/tickets/{id}.json`
|
|
||||||
- [x] Implement `write_ticket(root: &Path, ticket: &Ticket) -> Result<()>`: serialize to JSON, write file
|
|
||||||
- [x] Implement `read_ticket(root: &Path, id: &str) -> Result<Ticket>`: read file, deserialize; error if not found
|
|
||||||
- [x] Implement `list_tickets(root: &Path) -> Result<Vec<Ticket>>`: read all `*.json` from tickets dir, deserialize all, sort by priority descending
|
|
||||||
- [x] Unit tests: roundtrip write/read with tempdir, list returns all tickets, traversal finds `.nbd/` in grandparent dir
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4: Display (`display.rs`)
|
|
||||||
|
|
||||||
Output formatting.
|
|
||||||
|
|
||||||
- [x] Implement `print_ticket(ticket: &Ticket)`: full tabular display
|
|
||||||
```
|
|
||||||
ID: a3f9c2
|
|
||||||
Title: Fix login bug
|
|
||||||
Body: Users cannot log in with email addresses containing +
|
|
||||||
Priority: 8
|
|
||||||
Status: in_progress
|
|
||||||
Type: bug
|
|
||||||
Dependencies: b7d41e, c9e823
|
|
||||||
```
|
|
||||||
- [x] Implement `print_ticket_json(ticket: &Ticket)`: pretty-printed JSON to stdout
|
|
||||||
- [x] Implement `print_list(tickets: &[Ticket])`: short table
|
|
||||||
```
|
|
||||||
ID PRI TYPE STATUS TITLE
|
|
||||||
a3f9c2 8 bug in_progress Fix login bug
|
|
||||||
b7d41e 5 task todo Add rate limiting
|
|
||||||
```
|
|
||||||
- [x] Implement `print_list_json(tickets: &[Ticket])`: JSON array to stdout
|
|
||||||
- [x] Added `format_ticket`, `format_ticket_json`, `format_list`, `format_list_json` internal functions for testability
|
|
||||||
- [x] Unit tests: table output contains expected field values, JSON output is valid
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5: CLI Commands (`main.rs`)
|
|
||||||
|
|
||||||
Wire up `clap` subcommands to storage and display.
|
|
||||||
|
|
||||||
- [x] Define CLI structure with `clap` derive:
|
|
||||||
- Global flag: `--json` (all commands)
|
|
||||||
- Subcommand `create`: `--title` (required), `--body`, `--priority`, `--status`, `--type`, `--deps` (comma-separated IDs)
|
|
||||||
- Subcommand `read`: positional `<id>`
|
|
||||||
- Subcommand `list`: no args
|
|
||||||
- Subcommand `update`: positional `<id>`, same optional flags as `create`
|
|
||||||
- [x] Implement `cmd_create`: generate ID, validate deps exist, write ticket, print
|
|
||||||
- [x] Implement `cmd_read`: find ticket by ID, print
|
|
||||||
- [x] Implement `cmd_list`: list all tickets, print
|
|
||||||
- [x] Implement `cmd_update`: read existing ticket, merge only provided flags, write, print
|
|
||||||
- [x] Added `parse_status`, `parse_ticket_type`, `parse_deps`, `validate_deps` helpers
|
|
||||||
- [x] Fixed clippy warning in `store.rs`: `map_or(false, ...)` → `is_some_and(...)`
|
|
||||||
- [x] Integration tests (tempdir): create → read roundtrip, list shows created tickets, update merges correctly, traversal test (run from subdir), error on unknown ID, JSON flag tests, dep replacement test
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6: Documentation & Validation
|
|
||||||
|
|
||||||
- [x] Write `nbd/README.md`: what, how, run, test, license, Claude Code disclaimer
|
|
||||||
- [x] Write `nbd/docs/PLANNING.md`: this plan + work log
|
|
||||||
- [x] Write `nbd/docs/ARCHITECTURE.md`: module overview and interactions
|
|
||||||
- [x] Run validation in order: `cargo fmt`, `cargo check`, `cargo clippy`, `cargo test`
|
|
||||||
- [x] Verified end-to-end:
|
|
||||||
```sh
|
|
||||||
cargo run -- create --title "Test ticket" --priority 7 --type bug
|
|
||||||
cargo run -- list
|
|
||||||
cargo run -- read <id>
|
|
||||||
cargo run -- update <id> --status in_progress
|
|
||||||
cargo run -- list --json
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Post-MVP
|
|
||||||
|
|
||||||
The following features are planned but excluded from the MVP:
|
|
||||||
|
|
||||||
- `nbd init` — explicit initialization command
|
|
||||||
- `nbd ready` — list tickets with no blockers
|
|
||||||
- `nbd archive` — mark tickets as Closed
|
|
||||||
- `nbd update` git-diff style +/- output
|
|
||||||
- Partial ID matching (`nbd read a3f` finding `a3f9c2`)
|
|
||||||
- SQLite cache for performance
|
|
||||||
- Nix flake for depending on `nbd` in other projects
|
|
||||||
|
|
||||||
### Multiple File Format Support
|
|
||||||
|
|
||||||
`nbd` will support multiple ticket file formats selectable via `--ftype=[json|md|toml|jsonb]` on `create` and `update`.
|
|
||||||
|
|
||||||
**Format detection** is by file extension: `.json`, `.md`, `.toml`, `.jsonb`. The store layer detects format from the file extension when reading; no format metadata is stored separately.
|
|
||||||
|
|
||||||
**Markdown format** (`.md`): The file body is the ticket `body` field. Metadata is stored as frontmatter:
|
|
||||||
- `---` delimiter → YAML frontmatter
|
|
||||||
- `+++` delimiter → TOML frontmatter
|
|
||||||
|
|
||||||
Example (YAML frontmatter):
|
|
||||||
```
|
|
||||||
---
|
|
||||||
id: a3f9c2
|
|
||||||
title: Fix login bug
|
|
||||||
priority: 8
|
|
||||||
status: in_progress
|
|
||||||
ticket_type: bug
|
|
||||||
dependencies: [b7d41e]
|
|
||||||
---
|
|
||||||
|
|
||||||
Long-form body text goes here. Supports full markdown.
|
|
||||||
```
|
|
||||||
|
|
||||||
**TOML format** (`.toml`): All fields in a flat TOML file; `body` is a multi-line string.
|
|
||||||
|
|
||||||
**Binary JSON** (`.jsonb`): Compact binary-encoded JSON (e.g. via CBOR or BSON; crate TBD at implementation time).
|
|
||||||
|
|
||||||
**Conversion via `nbd update --ftype`**: Reads the ticket in its current format, writes it in the new format, and deletes the old file. The ID and all field values are preserved.
|
|
||||||
|
|
||||||
**Crates to evaluate:** `serde_yaml`, `toml`, `ciborium` (CBOR) or `bson`.
|
|
||||||
@ -1 +0,0 @@
|
|||||||
# Work to be ticketed
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
//! Build script for `nbd`.
|
|
||||||
//!
|
|
||||||
//! Captures the short git SHA at compile time and emits it as the
|
|
||||||
//! `GIT_SHORT_SHA` environment variable, making it available via
|
|
||||||
//! `env!("GIT_SHORT_SHA")` in the crate source.
|
|
||||||
//!
|
|
||||||
//! Falls back to `"unknown"` when git is unavailable (e.g. a clean Nix
|
|
||||||
//! sandbox build where `.git/` is not present).
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
// Capture the short git SHA at build time.
|
|
||||||
let sha = std::process::Command::new("git")
|
|
||||||
.args(["rev-parse", "--short", "HEAD"])
|
|
||||||
.output()
|
|
||||||
.ok()
|
|
||||||
.and_then(|o| {
|
|
||||||
if o.status.success() {
|
|
||||||
Some(o.stdout)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.and_then(|b| String::from_utf8(b).ok())
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
|
||||||
|
|
||||||
println!("cargo:rustc-env=GIT_SHORT_SHA={sha}");
|
|
||||||
// Re-run whenever HEAD or any ref changes (new commits, branch switches).
|
|
||||||
println!("cargo:rerun-if-changed=.git/HEAD");
|
|
||||||
println!("cargo:rerun-if-changed=.git/refs");
|
|
||||||
}
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
# nbd — Planning & Work Log
|
|
||||||
|
|
||||||
## Development Phases
|
|
||||||
|
|
||||||
### Phase 1: Crate Scaffold ✅
|
|
||||||
|
|
||||||
Set up the crate structure with no logic yet.
|
|
||||||
|
|
||||||
- Created `Cargo.toml` with `clap` (derive), `async-std`, `serde`, `serde_json`; dev-dep `tempfile`
|
|
||||||
- Added `[profile.release]` optimisations (`opt-level = "z"`, `lto`, `strip`, `codegen-units = 1`)
|
|
||||||
- Created `src/main.rs` — `async-std` entry point with placeholder `main`
|
|
||||||
- Created empty modules: `ticket.rs`, `store.rs`, `display.rs`, `tests.rs`
|
|
||||||
- Created `tests/integration.rs`
|
|
||||||
- Verified `cargo check` passed
|
|
||||||
|
|
||||||
### Phase 2: Data Model (`ticket.rs`) ✅
|
|
||||||
|
|
||||||
- Defined `Status` enum (`Todo`, `InProgress`, `Done`; default `Todo`)
|
|
||||||
- Serialises to lowercase snake_case: `"todo"`, `"in_progress"`, `"done"`
|
|
||||||
- Defined `TicketType` enum (`Project`, `Feature`, `Task`, `Bug`; default `Task`)
|
|
||||||
- Serialises to lowercase: `"project"`, `"feature"`, `"task"`, `"bug"`
|
|
||||||
- Defined `Ticket` struct with all required fields
|
|
||||||
- Implemented `Ticket::new(id, title)` with defaults
|
|
||||||
- Implemented `validate_priority(priority)` — errors if > 10
|
|
||||||
- Implemented `generate_id()` — 3 random bytes → 6 hex chars via `RandomState`
|
|
||||||
- Unit tests: serialisation roundtrip, priority validation, ID format and uniqueness
|
|
||||||
|
|
||||||
### Phase 3: Storage (`store.rs`) ✅
|
|
||||||
|
|
||||||
- Implemented `find_nbd_root_from(start)` — walks up from `start` to find `.nbd/`
|
|
||||||
- Implemented `find_nbd_root()` — starts from `std::env::current_dir()`
|
|
||||||
- Implemented `tickets_dir(root)` — pure path computation
|
|
||||||
- Implemented `ensure_tickets_dir(root)` — creates `.nbd/tickets/` if missing
|
|
||||||
- Implemented `ticket_path(root, id)` — pure path computation
|
|
||||||
- Implemented `write_ticket(root, ticket)` — serialise and write JSON file
|
|
||||||
- Implemented `read_ticket(root, id)` — read and deserialise; descriptive error if not found
|
|
||||||
- Implemented `list_tickets(root)` — read all `*.json`, sort by priority descending
|
|
||||||
- Unit tests: write/read roundtrip, list returns all tickets, traversal from grandparent dir
|
|
||||||
|
|
||||||
### Phase 4: Display (`display.rs`) ✅
|
|
||||||
|
|
||||||
- Implemented `format_ticket(ticket)` / `print_ticket(ticket)` — full tabular view
|
|
||||||
- Implemented `format_ticket_json(ticket)` / `print_ticket_json(ticket)` — pretty JSON
|
|
||||||
- Implemented `format_list(tickets)` / `print_list(tickets)` — short summary table
|
|
||||||
- Implemented `format_list_json(tickets)` / `print_list_json(tickets)` — JSON array
|
|
||||||
- `format_*` functions return `String` for testability; `print_*` write to stdout
|
|
||||||
- Unit tests: table output contains expected field values, JSON is valid
|
|
||||||
|
|
||||||
### Phase 5: CLI Commands (`main.rs`) ✅
|
|
||||||
|
|
||||||
- Defined `Cli` struct with global `--json` flag and `Commands` enum
|
|
||||||
- Subcommands: `create`, `read`, `list`, `update`
|
|
||||||
- Implemented `cmd_create`, `cmd_read`, `cmd_list`, `cmd_update`
|
|
||||||
- Added `parse_status`, `parse_ticket_type`, `parse_deps`, `validate_deps` helpers
|
|
||||||
- Fixed clippy warning: `map_or(false, ...)` → `is_some_and(...)`
|
|
||||||
- Integration tests: create→read roundtrip, list, update merge, traversal from subdir,
|
|
||||||
error on unknown ID, `--json` flag, dep replacement
|
|
||||||
|
|
||||||
### Phase 6: Documentation & Validation ✅
|
|
||||||
|
|
||||||
- Wrote `README.md` — what, how, usage, testing, license, Claude Code disclaimer
|
|
||||||
- Wrote `docs/PLANNING.md` (this file) — phases and work log
|
|
||||||
- Wrote `docs/ARCHITECTURE.md` — module overview and interactions
|
|
||||||
- Ran `cargo fmt`, `cargo check`, `cargo clippy`, `cargo test` — all clean
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Post-MVP Roadmap
|
|
||||||
|
|
||||||
See [`PLAN.md`](../PLAN.md) for the full list of planned post-MVP features, including:
|
|
||||||
|
|
||||||
- `nbd init` — explicit initialisation command
|
|
||||||
- `nbd ready` — list tickets with no blockers
|
|
||||||
- `nbd archive` — mark tickets as Closed
|
|
||||||
- `nbd update` git-diff-style output
|
|
||||||
- Partial ID matching
|
|
||||||
- SQLite cache
|
|
||||||
- Nix flake
|
|
||||||
- Multiple file format support (`.md`, `.toml`, `.jsonb`)
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
{
|
|
||||||
"nodes": {
|
|
||||||
"nixpkgs": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1771369470,
|
|
||||||
"narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "0182a361324364ae3f436a63005877674cf45efb",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"ref": "nixos-unstable",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": {
|
|
||||||
"inputs": {
|
|
||||||
"nixpkgs": "nixpkgs",
|
|
||||||
"rust-overlay": "rust-overlay"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"rust-overlay": {
|
|
||||||
"inputs": {
|
|
||||||
"nixpkgs": [
|
|
||||||
"nixpkgs"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1771816254,
|
|
||||||
"narHash": "sha256-vkp3iTF6QmHMvL+34DI93IiMPjS2lqcMlA1fl7nXVsQ=",
|
|
||||||
"owner": "oxalica",
|
|
||||||
"repo": "rust-overlay",
|
|
||||||
"rev": "085bdbf5dde5477538e4c87d1684b6c6df56c0ad",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "oxalica",
|
|
||||||
"repo": "rust-overlay",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": "root",
|
|
||||||
"version": 7
|
|
||||||
}
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
{
|
|
||||||
description = "nbd — CLI tool for managing work tickets, targeted at agent workflows";
|
|
||||||
|
|
||||||
inputs = {
|
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
|
||||||
rust-overlay = {
|
|
||||||
url = "github:oxalica/rust-overlay";
|
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
outputs =
|
|
||||||
{
|
|
||||||
self,
|
|
||||||
nixpkgs,
|
|
||||||
rust-overlay,
|
|
||||||
}:
|
|
||||||
let
|
|
||||||
systems = [
|
|
||||||
"x86_64-linux"
|
|
||||||
"aarch64-linux"
|
|
||||||
"x86_64-darwin"
|
|
||||||
"aarch64-darwin"
|
|
||||||
];
|
|
||||||
|
|
||||||
forAllSystems =
|
|
||||||
f:
|
|
||||||
nixpkgs.lib.genAttrs systems (
|
|
||||||
system:
|
|
||||||
f {
|
|
||||||
inherit system;
|
|
||||||
pkgs = import nixpkgs {
|
|
||||||
inherit system;
|
|
||||||
overlays = [ rust-overlay.overlays.default ];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
in
|
|
||||||
{
|
|
||||||
packages = forAllSystems (
|
|
||||||
{ pkgs, system }:
|
|
||||||
{
|
|
||||||
# The nbd CLI package, built with rustPlatform.buildRustPackage.
|
|
||||||
nbd = pkgs.rustPlatform.buildRustPackage {
|
|
||||||
pname = "nbd";
|
|
||||||
version = "0.1.0";
|
|
||||||
|
|
||||||
src = ./.;
|
|
||||||
|
|
||||||
cargoLock = {
|
|
||||||
lockFile = ./Cargo.lock;
|
|
||||||
};
|
|
||||||
|
|
||||||
meta = {
|
|
||||||
description = "CLI tool for managing work tickets, targeted at agent workflows";
|
|
||||||
license = with pkgs.lib.licenses; [
|
|
||||||
asl20
|
|
||||||
mit
|
|
||||||
];
|
|
||||||
mainProgram = "nbd";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
# Make nbd the default package so `nix build` works without specifying an attribute.
|
|
||||||
default = self.packages.${system}.nbd;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
apps = forAllSystems (
|
|
||||||
{ system, ... }:
|
|
||||||
{
|
|
||||||
# Run nbd directly with `nix run .#nbd`.
|
|
||||||
nbd = {
|
|
||||||
type = "app";
|
|
||||||
program = "${self.packages.${system}.nbd}/bin/nbd";
|
|
||||||
};
|
|
||||||
|
|
||||||
# Make nbd the default app so `nix run` works without specifying an attribute.
|
|
||||||
default = self.apps.${system}.nbd;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
devShells = forAllSystems (
|
|
||||||
{ pkgs, system }:
|
|
||||||
{
|
|
||||||
# Development shell with stable Rust toolchain and nbd binary.
|
|
||||||
default = pkgs.mkShell {
|
|
||||||
packages = [
|
|
||||||
# Stable Rust toolchain: compiler, cargo, rustfmt, clippy.
|
|
||||||
pkgs.rust-bin.stable.latest.default
|
|
||||||
# The built nbd binary, available as `nbd` in the shell.
|
|
||||||
self.packages.${system}.nbd
|
|
||||||
];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
## Task Tracking with beans
|
|
||||||
|
|
||||||
This project uses **beans** for issue tracking. Run `beans prime` to get the full usage guide.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# Initialise (run once per project directory)
|
|
||||||
beans init
|
|
||||||
|
|
||||||
# Get full usage guide and instructions for this project
|
|
||||||
beans prime
|
|
||||||
```
|
|
||||||
@ -1,473 +0,0 @@
|
|||||||
//! Output formatting for tickets.
|
|
||||||
//!
|
|
||||||
//! Provides functions for rendering tickets and ticket lists as human-readable
|
|
||||||
//! tables or machine-readable JSON, depending on the caller's preference.
|
|
||||||
//!
|
|
||||||
//! Each `print_*` function writes directly to stdout and is intended for use
|
|
||||||
//! in command handlers. The corresponding `format_*` functions return a
|
|
||||||
//! `String` and are provided primarily for testing and composition.
|
|
||||||
|
|
||||||
use std::collections::HashSet;
|
|
||||||
|
|
||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
use crate::graph::TicketGraph;
|
|
||||||
use crate::store::MigrateReport;
|
|
||||||
use crate::ticket::{Status, Ticket, TicketType};
|
|
||||||
|
|
||||||
// ── Column widths for the summary list table ─────────────────────────────────
|
|
||||||
|
|
||||||
/// Width of the ID column (6-char hex + padding).
|
|
||||||
const COL_ID: usize = 9;
|
|
||||||
/// Width of the priority column.
|
|
||||||
const COL_PRI: usize = 5;
|
|
||||||
/// Width of the ticket-type column ("project" = 7 chars, +2 padding).
|
|
||||||
const COL_TYPE: usize = 9;
|
|
||||||
/// Width of the status column ("in_progress" = 11 chars, +2 padding).
|
|
||||||
const COL_STATUS: usize = 13;
|
|
||||||
/// Width of each label in the diff view ("dependencies:" = 13 + 1 space).
|
|
||||||
const LABEL_WIDTH: usize = 14;
|
|
||||||
|
|
||||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Ticket metadata serialised into the TOML frontmatter block for display.
|
|
||||||
///
|
|
||||||
/// Mirrors the on-disk `MarkdownFrontmatter` in `store.rs` but adds `id` as
|
|
||||||
/// the first field so that human-readable output is self-contained.
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct DisplayFrontmatter<'a> {
|
|
||||||
id: &'a str,
|
|
||||||
title: &'a str,
|
|
||||||
priority: u8,
|
|
||||||
status: &'a Status,
|
|
||||||
ticket_type: &'a TicketType,
|
|
||||||
dependencies: &'a [String],
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the canonical display string for a [`Status`] variant.
|
|
||||||
///
|
|
||||||
/// The strings match the serde serialisation: `"todo"`, `"in_progress"`,
|
|
||||||
/// `"done"`, etc.
|
|
||||||
fn status_str(status: &Status) -> &'static str {
|
|
||||||
match status {
|
|
||||||
Status::Todo => "todo",
|
|
||||||
Status::InProgress => "in_progress",
|
|
||||||
Status::Done => "done",
|
|
||||||
Status::Closed => "closed",
|
|
||||||
Status::Archived => "archived",
|
|
||||||
Status::Backlog => "backlog",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the canonical display string for a [`TicketType`] variant.
|
|
||||||
///
|
|
||||||
/// The strings match the serde serialisation: `"project"`, `"feature"`,
|
|
||||||
/// `"task"`, `"bug"`.
|
|
||||||
fn ticket_type_str(ticket_type: &TicketType) -> &'static str {
|
|
||||||
match ticket_type {
|
|
||||||
TicketType::Project => "project",
|
|
||||||
TicketType::Feature => "feature",
|
|
||||||
TicketType::Task => "task",
|
|
||||||
TicketType::Bug => "bug",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Public formatting functions ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Format a single ticket as a TOML-frontmatter markdown document.
|
|
||||||
///
|
|
||||||
/// The output mirrors the `.md` file format used on disk, with `id` added as
|
|
||||||
/// the first frontmatter key so the output is self-contained. The ticket body
|
|
||||||
/// follows the closing `+++` delimiter.
|
|
||||||
///
|
|
||||||
/// ```text
|
|
||||||
/// +++
|
|
||||||
/// id = "a3f9c2"
|
|
||||||
/// title = "Fix login bug"
|
|
||||||
/// priority = 8
|
|
||||||
/// status = "in_progress"
|
|
||||||
/// ticket_type = "bug"
|
|
||||||
/// dependencies = ["b7d41e", "c9e823"]
|
|
||||||
/// +++
|
|
||||||
/// Users cannot log in with email addresses containing +
|
|
||||||
/// ```
|
|
||||||
pub fn format_ticket(ticket: &Ticket) -> String {
|
|
||||||
let fm = DisplayFrontmatter {
|
|
||||||
id: &ticket.id,
|
|
||||||
title: &ticket.title,
|
|
||||||
priority: ticket.priority,
|
|
||||||
status: &ticket.status,
|
|
||||||
ticket_type: &ticket.ticket_type,
|
|
||||||
dependencies: &ticket.dependencies,
|
|
||||||
};
|
|
||||||
let toml_str = toml::to_string(&fm).expect("frontmatter serialisation must not fail");
|
|
||||||
format!("+++\n{toml_str}+++\n{}", ticket.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Print a full tabular representation of a single ticket to stdout.
|
|
||||||
pub fn print_ticket(ticket: &Ticket) {
|
|
||||||
println!("{}", format_ticket(ticket));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serialise a ticket as a [`serde_json::Value`], explicitly inserting the
|
|
||||||
/// `id` field at the front.
|
|
||||||
///
|
|
||||||
/// [`crate::ticket::Ticket::id`] is annotated with `#[serde(skip)]` so that
|
|
||||||
/// the stored `.json` files do not duplicate the filename stem. However,
|
|
||||||
/// CLI `--json` output should include `id` so that consumers have all the
|
|
||||||
/// information in one object. This helper re-inserts it into the serialised
|
|
||||||
/// value.
|
|
||||||
pub(crate) fn ticket_to_json_value(ticket: &Ticket) -> serde_json::Value {
|
|
||||||
let mut value = serde_json::to_value(ticket).expect("ticket serialisation must not fail");
|
|
||||||
// Re-insert id at the front of the object for ergonomic CLI output.
|
|
||||||
if let serde_json::Value::Object(ref mut map) = value {
|
|
||||||
// `serde_json::Map` preserves insertion order; insert id first by
|
|
||||||
// rebuilding the map with id prepended.
|
|
||||||
let old_map = std::mem::take(map);
|
|
||||||
map.insert(
|
|
||||||
"id".to_string(),
|
|
||||||
serde_json::Value::String(ticket.id.clone()),
|
|
||||||
);
|
|
||||||
map.extend(old_map);
|
|
||||||
}
|
|
||||||
value
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format a single ticket as a pretty-printed JSON object.
|
|
||||||
///
|
|
||||||
/// The output is suitable for piping or machine consumption. The `id` field
|
|
||||||
/// is included even though it is not stored in the ticket's JSON file.
|
|
||||||
pub fn format_ticket_json(ticket: &Ticket) -> String {
|
|
||||||
let value = ticket_to_json_value(ticket);
|
|
||||||
serde_json::to_string_pretty(&value).expect("ticket serialisation must not fail")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Print a single ticket as pretty-printed JSON to stdout.
|
|
||||||
pub fn print_ticket_json(ticket: &Ticket) {
|
|
||||||
println!("{}", format_ticket_json(ticket));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format a slice of tickets as a short summary table with a header row.
|
|
||||||
///
|
|
||||||
/// Columns are `ID`, `PRI`, `TYPE`, `STATUS`, and `TITLE`, each left-aligned
|
|
||||||
/// to a fixed width. Rows are presented in the order given — callers are
|
|
||||||
/// responsible for sorting before passing the slice. An empty slice still
|
|
||||||
/// produces the header row.
|
|
||||||
///
|
|
||||||
/// ```text
|
|
||||||
/// ID PRI TYPE STATUS TITLE
|
|
||||||
/// a3f9c2 8 bug in_progress Fix login bug
|
|
||||||
/// b7d41e 5 task todo Add rate limiting
|
|
||||||
/// ```
|
|
||||||
pub fn format_list(tickets: &[Ticket]) -> String {
|
|
||||||
let mut out = format!(
|
|
||||||
"{:<width_id$}{:<width_pri$}{:<width_type$}{:<width_status$}{}",
|
|
||||||
"ID",
|
|
||||||
"PRI",
|
|
||||||
"TYPE",
|
|
||||||
"STATUS",
|
|
||||||
"TITLE",
|
|
||||||
width_id = COL_ID,
|
|
||||||
width_pri = COL_PRI,
|
|
||||||
width_type = COL_TYPE,
|
|
||||||
width_status = COL_STATUS,
|
|
||||||
);
|
|
||||||
|
|
||||||
for ticket in tickets {
|
|
||||||
out.push('\n');
|
|
||||||
out.push_str(&format!(
|
|
||||||
"{:<width_id$}{:<width_pri$}{:<width_type$}{:<width_status$}{}",
|
|
||||||
ticket.id,
|
|
||||||
ticket.priority,
|
|
||||||
ticket_type_str(&ticket.ticket_type),
|
|
||||||
status_str(&ticket.status),
|
|
||||||
ticket.title,
|
|
||||||
width_id = COL_ID,
|
|
||||||
width_pri = COL_PRI,
|
|
||||||
width_type = COL_TYPE,
|
|
||||||
width_status = COL_STATUS,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Print a short summary table of multiple tickets to stdout.
|
|
||||||
pub fn print_list(tickets: &[Ticket]) {
|
|
||||||
println!("{}", format_list(tickets));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format a slice of tickets as a pretty-printed JSON array.
|
|
||||||
///
|
|
||||||
/// Each object includes an `id` field even though it is not stored in the
|
|
||||||
/// individual ticket files on disk.
|
|
||||||
pub fn format_list_json(tickets: &[Ticket]) -> String {
|
|
||||||
let values: Vec<serde_json::Value> = tickets.iter().map(ticket_to_json_value).collect();
|
|
||||||
serde_json::to_string_pretty(&values).expect("ticket list serialisation must not fail")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Print a ticket list as a JSON array to stdout.
|
|
||||||
pub fn print_list_json(tickets: &[Ticket]) {
|
|
||||||
println!("{}", format_list_json(tickets));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format a [`MigrateReport`] as a human-readable summary.
|
|
||||||
///
|
|
||||||
/// ```text
|
|
||||||
/// Migrated 3 tickets.
|
|
||||||
/// Current 5 tickets (already up to date).
|
|
||||||
/// Errors 1 ticket could not be migrated:
|
|
||||||
/// bad_ticket.json: trailing comma at line 4
|
|
||||||
/// ```
|
|
||||||
pub fn format_migrate_report(report: &MigrateReport) -> String {
|
|
||||||
let mut out = format!(
|
|
||||||
"Migrated {} ticket{}.\nCurrent {} ticket{} (already up to date).",
|
|
||||||
report.updated,
|
|
||||||
if report.updated == 1 { "" } else { "s" },
|
|
||||||
report.already_current,
|
|
||||||
if report.already_current == 1 { "" } else { "s" },
|
|
||||||
);
|
|
||||||
if report.skipped > 0 {
|
|
||||||
out.push_str(&format!(
|
|
||||||
"\nSkipped {} ticket{} (did not match filter).",
|
|
||||||
report.skipped,
|
|
||||||
if report.skipped == 1 { "" } else { "s" },
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if !report.errors.is_empty() {
|
|
||||||
out.push_str(&format!(
|
|
||||||
"\nErrors {} ticket{} could not be migrated:",
|
|
||||||
report.errors.len(),
|
|
||||||
if report.errors.len() == 1 { "" } else { "s" },
|
|
||||||
));
|
|
||||||
for (filename, msg) in &report.errors {
|
|
||||||
out.push_str(&format!("\n {filename}: {msg}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Print a [`MigrateReport`] as a human-readable summary to stdout.
|
|
||||||
pub fn print_migrate_report(report: &MigrateReport) {
|
|
||||||
println!("{}", format_migrate_report(report));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format a [`MigrateReport`] as a JSON object.
|
|
||||||
///
|
|
||||||
/// The JSON has three keys: `updated`, `already_current`, and `errors`.
|
|
||||||
/// `errors` is an array of objects with `filename` and `message` fields.
|
|
||||||
pub fn format_migrate_report_json(report: &MigrateReport) -> String {
|
|
||||||
let errors: Vec<serde_json::Value> = report
|
|
||||||
.errors
|
|
||||||
.iter()
|
|
||||||
.map(|(filename, msg)| {
|
|
||||||
serde_json::json!({
|
|
||||||
"filename": filename,
|
|
||||||
"message": msg,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let value = serde_json::json!({
|
|
||||||
"updated": report.updated,
|
|
||||||
"already_current": report.already_current,
|
|
||||||
"skipped": report.skipped,
|
|
||||||
"errors": errors,
|
|
||||||
});
|
|
||||||
serde_json::to_string_pretty(&value).expect("migrate report serialisation must not fail")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Print a [`MigrateReport`] as a JSON object to stdout.
|
|
||||||
pub fn print_migrate_report_json(report: &MigrateReport) {
|
|
||||||
println!("{}", format_migrate_report_json(report));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format a git-diff-style `- old / + new` summary of what changed between
|
|
||||||
/// two [`Ticket`] snapshots.
|
|
||||||
///
|
|
||||||
/// Each field that differs produces two lines:
|
|
||||||
///
|
|
||||||
/// ```text
|
|
||||||
/// - status: todo
|
|
||||||
/// + status: in_progress
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// Fields compared: `title`, `body`, `priority`, `status`, `ticket_type`, and
|
|
||||||
/// `dependencies`. The `id` field is never shown because it cannot change.
|
|
||||||
///
|
|
||||||
/// Returns `"(no changes)"` when every compared field is identical.
|
|
||||||
pub fn format_diff(old: &Ticket, new: &Ticket) -> String {
|
|
||||||
let mut lines: Vec<String> = Vec::new();
|
|
||||||
|
|
||||||
macro_rules! diff_field {
|
|
||||||
($label:expr, $old_val:expr, $new_val:expr) => {{
|
|
||||||
let old_s: String = $old_val;
|
|
||||||
let new_s: String = $new_val;
|
|
||||||
if old_s != new_s {
|
|
||||||
lines.push(format!("- {:<LABEL_WIDTH$}{}", $label, old_s));
|
|
||||||
lines.push(format!("+ {:<LABEL_WIDTH$}{}", $label, new_s));
|
|
||||||
}
|
|
||||||
}};
|
|
||||||
}
|
|
||||||
|
|
||||||
diff_field!("title:", old.title.clone(), new.title.clone());
|
|
||||||
diff_field!("body:", old.body.clone(), new.body.clone());
|
|
||||||
diff_field!(
|
|
||||||
"priority:",
|
|
||||||
old.priority.to_string(),
|
|
||||||
new.priority.to_string()
|
|
||||||
);
|
|
||||||
diff_field!(
|
|
||||||
"status:",
|
|
||||||
status_str(&old.status).to_string(),
|
|
||||||
status_str(&new.status).to_string()
|
|
||||||
);
|
|
||||||
diff_field!(
|
|
||||||
"type:",
|
|
||||||
ticket_type_str(&old.ticket_type).to_string(),
|
|
||||||
ticket_type_str(&new.ticket_type).to_string()
|
|
||||||
);
|
|
||||||
diff_field!(
|
|
||||||
"dependencies:",
|
|
||||||
old.dependencies.join(", "),
|
|
||||||
new.dependencies.join(", ")
|
|
||||||
);
|
|
||||||
|
|
||||||
if lines.is_empty() {
|
|
||||||
"(no changes)".to_string()
|
|
||||||
} else {
|
|
||||||
lines.join("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Print a git-diff-style summary of what changed between two tickets to stdout.
|
|
||||||
pub fn print_diff(old: &Ticket, new: &Ticket) {
|
|
||||||
println!("{}", format_diff(old, new));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Graph rendering ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Format the full dependency forest as an ASCII tree string.
|
|
||||||
///
|
|
||||||
/// Roots (tickets that no other ticket depends on — top-level goals) appear
|
|
||||||
/// at column 0, sorted by priority descending. Each root's dependencies
|
|
||||||
/// (prerequisites it needs completed first) are indented below using
|
|
||||||
/// box-drawing characters:
|
|
||||||
///
|
|
||||||
/// ```text
|
|
||||||
/// a3f9c2 [todo] Fix login bug
|
|
||||||
/// ├── b7d41e [in_progress] Add rate limiting
|
|
||||||
/// │ └── c9e823 [todo] Write tests
|
|
||||||
/// └── d1f302 [done] Update docs
|
|
||||||
/// e4a781 [todo] New feature (no prereqs)
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// Nodes that appear in multiple subtrees are marked `*` on subsequent
|
|
||||||
/// occurrences rather than looping forever.
|
|
||||||
pub fn format_graph(graph: &TicketGraph<'_>) -> String {
|
|
||||||
let mut out = String::new();
|
|
||||||
let mut visited: HashSet<String> = HashSet::new();
|
|
||||||
for root in graph.roots() {
|
|
||||||
render_node(graph, &root.id, "", "", "", &mut visited, &mut out);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Print the full dependency forest to stdout.
|
|
||||||
pub fn print_graph(graph: &TicketGraph<'_>) {
|
|
||||||
println!("{}", format_graph(graph));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format the subtree rooted at `root_id` as an ASCII tree string.
|
|
||||||
///
|
|
||||||
/// Renders `root_id` and every ticket it transitively depends on
|
|
||||||
/// (via dependency edges), using the same box-drawing format as
|
|
||||||
/// [`format_graph`]. Returns an empty string when `root_id` is not in the
|
|
||||||
/// graph.
|
|
||||||
pub fn format_subtree(graph: &TicketGraph<'_>, root_id: &str) -> String {
|
|
||||||
let mut out = String::new();
|
|
||||||
let mut visited: HashSet<String> = HashSet::new();
|
|
||||||
if graph.get_node(root_id).is_some() {
|
|
||||||
render_node(graph, root_id, "", "", "", &mut visited, &mut out);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Print the subtree rooted at `root_id` to stdout.
|
|
||||||
pub fn print_subtree(graph: &TicketGraph<'_>, root_id: &str) {
|
|
||||||
println!("{}", format_subtree(graph, root_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Internal graph helpers ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Recursively render a single node and its dependencies into `out`.
|
|
||||||
///
|
|
||||||
/// Parameters:
|
|
||||||
/// - `prefix` — the indentation printed before `connector` on this node's line.
|
|
||||||
/// This is the `child_base` that the parent passed down.
|
|
||||||
/// - `connector` — the box-drawing connector for this node (`""` for roots,
|
|
||||||
/// `"├── "` or `"└── "` for children).
|
|
||||||
/// - `child_base` — the base prefix handed to this node's *children* (it
|
|
||||||
/// becomes their `prefix`). It accumulates one more level of `│ ` or
|
|
||||||
/// ` ` with each descent.
|
|
||||||
///
|
|
||||||
/// When a node ID is already in `visited`, it is rendered as `*` and
|
|
||||||
/// the recursion stops, preventing infinite loops in cyclic or shared data.
|
|
||||||
fn render_node(
|
|
||||||
graph: &TicketGraph<'_>,
|
|
||||||
id: &str,
|
|
||||||
prefix: &str,
|
|
||||||
connector: &str,
|
|
||||||
child_base: &str,
|
|
||||||
visited: &mut HashSet<String>,
|
|
||||||
out: &mut String,
|
|
||||||
) {
|
|
||||||
// Already-visited detection: this node appears elsewhere in the tree.
|
|
||||||
if visited.contains(id) {
|
|
||||||
append_line(out, &format!("{prefix}{connector}{id} *"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
visited.insert(id.to_string());
|
|
||||||
|
|
||||||
// Extract the node data we need, cloning so we can drop the borrow before
|
|
||||||
// recursing (the recursive call needs a mutable `visited`).
|
|
||||||
let (status_s, title, dependencies) = match graph.get_node(id) {
|
|
||||||
Some(node) => {
|
|
||||||
let s = status_str(&node.ticket.status);
|
|
||||||
let t = node.ticket.title.clone();
|
|
||||||
let d: Vec<String> = node.dependencies.iter().map(|s| s.to_string()).collect();
|
|
||||||
(s, t, d)
|
|
||||||
}
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
append_line(
|
|
||||||
out,
|
|
||||||
&format!("{prefix}{connector}{id} [{status_s}] {title}"),
|
|
||||||
);
|
|
||||||
|
|
||||||
let n = dependencies.len();
|
|
||||||
for (i, dep_id) in dependencies.iter().enumerate() {
|
|
||||||
let is_last = i == n - 1;
|
|
||||||
// The child's connector on its own line.
|
|
||||||
let child_connector = if is_last { "└── " } else { "├── " };
|
|
||||||
// The grandchildren's base: extend child_base by one more level.
|
|
||||||
let grandchild_base = format!("{child_base}{}", if is_last { " " } else { "│ " });
|
|
||||||
render_node(
|
|
||||||
graph,
|
|
||||||
dep_id,
|
|
||||||
child_base,
|
|
||||||
child_connector,
|
|
||||||
&grandchild_base,
|
|
||||||
visited,
|
|
||||||
out,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Append `line` to `out`, preceded by a newline if `out` is non-empty.
|
|
||||||
fn append_line(out: &mut String, line: &str) {
|
|
||||||
if !out.is_empty() {
|
|
||||||
out.push('\n');
|
|
||||||
}
|
|
||||||
out.push_str(line);
|
|
||||||
}
|
|
||||||
@ -1,267 +0,0 @@
|
|||||||
//! Ticket filtering with glob-pattern support.
|
|
||||||
//!
|
|
||||||
//! [`TicketFilter`] holds per-field glob patterns compiled from `key=value`
|
|
||||||
//! expressions. Patterns within the same field are ORed; patterns across
|
|
||||||
//! different fields are ANDed.
|
|
||||||
//!
|
|
||||||
//! # Example
|
|
||||||
//!
|
|
||||||
//! ```rust,ignore
|
|
||||||
//! let args = vec!["status=todo".to_string(), "type=bug".to_string()];
|
|
||||||
//! let filter = parse_filters(&args).unwrap();
|
|
||||||
//! assert!(filter.matches(&some_todo_bug_ticket));
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
use crate::store::Result;
|
|
||||||
use crate::ticket::{Status, Ticket, TicketType};
|
|
||||||
|
|
||||||
// ── String helpers ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Return the canonical lowercase display string for a [`Status`] variant.
|
|
||||||
fn status_str(status: &Status) -> &'static str {
|
|
||||||
match status {
|
|
||||||
Status::Todo => "todo",
|
|
||||||
Status::InProgress => "in_progress",
|
|
||||||
Status::Done => "done",
|
|
||||||
Status::Closed => "closed",
|
|
||||||
Status::Archived => "archived",
|
|
||||||
Status::Backlog => "backlog",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the canonical lowercase display string for a [`TicketType`] variant.
|
|
||||||
fn ticket_type_str(ticket_type: &TicketType) -> &'static str {
|
|
||||||
match ticket_type {
|
|
||||||
TicketType::Project => "project",
|
|
||||||
TicketType::Feature => "feature",
|
|
||||||
TicketType::Task => "task",
|
|
||||||
TicketType::Bug => "bug",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Glob matching ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Return `true` when `value` matches the glob `pattern`.
|
|
||||||
///
|
|
||||||
/// `*` is the only supported wildcard and it matches any sequence of
|
|
||||||
/// characters, including the empty sequence. All other characters are
|
|
||||||
/// matched literally.
|
|
||||||
///
|
|
||||||
/// The algorithm:
|
|
||||||
/// 1. If `pattern` contains no `*`, require exact equality.
|
|
||||||
/// 2. Split `pattern` on `*` into segments.
|
|
||||||
/// 3. If `pattern` does **not** start with `*`, `value` must start with the
|
|
||||||
/// first segment; advance past it.
|
|
||||||
/// 4. If `pattern` does **not** end with `*`, `value` must end with the last
|
|
||||||
/// segment; trim it.
|
|
||||||
/// 5. For each remaining middle segment, find it left-to-right in the
|
|
||||||
/// remaining string, advancing past the match. If any segment is absent,
|
|
||||||
/// return `false`.
|
|
||||||
///
|
|
||||||
/// Handles: `*`, `foo`, `foo*`, `*foo`, `*foo*`, `foo*bar`, `*foo*bar*`.
|
|
||||||
pub(crate) fn glob_matches(pattern: &str, value: &str) -> bool {
|
|
||||||
if !pattern.contains('*') {
|
|
||||||
return pattern == value;
|
|
||||||
}
|
|
||||||
|
|
||||||
let segments: Vec<&str> = pattern.split('*').collect();
|
|
||||||
let n = segments.len();
|
|
||||||
let mut remaining = value;
|
|
||||||
|
|
||||||
// Anchored prefix: when pattern does not start with '*', segments[0] is
|
|
||||||
// non-empty and value must begin with it.
|
|
||||||
let start_idx = if !segments[0].is_empty() {
|
|
||||||
match remaining.strip_prefix(segments[0]) {
|
|
||||||
Some(rest) => {
|
|
||||||
remaining = rest;
|
|
||||||
1
|
|
||||||
}
|
|
||||||
None => return false,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
1 // skip the empty string before the leading '*'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Anchored suffix: when pattern does not end with '*', segments[n-1] is
|
|
||||||
// non-empty and the remaining string must end with it.
|
|
||||||
let end_idx = if !segments[n - 1].is_empty() {
|
|
||||||
match remaining.strip_suffix(segments[n - 1]) {
|
|
||||||
Some(prefix) => {
|
|
||||||
remaining = prefix;
|
|
||||||
n - 1
|
|
||||||
}
|
|
||||||
None => return false,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
n - 1 // skip the empty string after the trailing '*'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Find each middle segment left-to-right inside the remaining substring.
|
|
||||||
for seg in &segments[start_idx..end_idx] {
|
|
||||||
if seg.is_empty() {
|
|
||||||
continue; // consecutive '*'s impose no additional constraint
|
|
||||||
}
|
|
||||||
match remaining.find(seg) {
|
|
||||||
Some(pos) => remaining = &remaining[pos + seg.len()..],
|
|
||||||
None => return false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── TicketFilter ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// A compiled set of filter expressions applied to tickets.
|
|
||||||
///
|
|
||||||
/// Within the same key, patterns are ORed (any match passes the group).
|
|
||||||
/// Across different keys, groups are ANDed (all non-empty groups must pass).
|
|
||||||
///
|
|
||||||
/// An empty [`TicketFilter`] (all `Vec`s empty) matches every ticket.
|
|
||||||
///
|
|
||||||
/// Construct with [`parse_filters`].
|
|
||||||
#[derive(Debug, Default, Clone)]
|
|
||||||
pub struct TicketFilter {
|
|
||||||
/// Glob patterns matched against the ticket's `status` field (OR group).
|
|
||||||
/// Matching is case-insensitive.
|
|
||||||
pub status: Vec<String>,
|
|
||||||
/// Glob patterns matched against the ticket's `ticket_type` field (OR group).
|
|
||||||
/// Matching is case-insensitive.
|
|
||||||
pub ticket_type: Vec<String>,
|
|
||||||
/// Glob patterns matched against the string representation of `priority`
|
|
||||||
/// (OR group). Matching is case-sensitive.
|
|
||||||
pub priority: Vec<String>,
|
|
||||||
/// Glob patterns matched against the ticket's `title` field (OR group).
|
|
||||||
/// Matching is case-sensitive.
|
|
||||||
pub title: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TicketFilter {
|
|
||||||
/// Returns `true` when all non-empty filter groups match `ticket`.
|
|
||||||
///
|
|
||||||
/// An empty [`TicketFilter`] always returns `true`.
|
|
||||||
pub fn matches(&self, ticket: &Ticket) -> bool {
|
|
||||||
let status_val = status_str(&ticket.status);
|
|
||||||
let type_val = ticket_type_str(&ticket.ticket_type);
|
|
||||||
let priority_val = ticket.priority.to_string();
|
|
||||||
|
|
||||||
// status and ticket_type comparisons are case-insensitive: lower-case
|
|
||||||
// the pattern before matching against the already-lowercase value.
|
|
||||||
(self.status.is_empty()
|
|
||||||
|| self
|
|
||||||
.status
|
|
||||||
.iter()
|
|
||||||
.any(|p| glob_matches(&p.to_lowercase(), status_val)))
|
|
||||||
&& (self.ticket_type.is_empty()
|
|
||||||
|| self
|
|
||||||
.ticket_type
|
|
||||||
.iter()
|
|
||||||
.any(|p| glob_matches(&p.to_lowercase(), type_val)))
|
|
||||||
// priority and title comparisons are case-sensitive.
|
|
||||||
&& (self.priority.is_empty()
|
|
||||||
|| self
|
|
||||||
.priority
|
|
||||||
.iter()
|
|
||||||
.any(|p| glob_matches(p, &priority_val)))
|
|
||||||
&& (self.title.is_empty()
|
|
||||||
|| self
|
|
||||||
.title
|
|
||||||
.iter()
|
|
||||||
.any(|p| glob_matches(p, &ticket.title)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns `true` when no filter groups are set (no-op filter).
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.status.is_empty()
|
|
||||||
&& self.ticket_type.is_empty()
|
|
||||||
&& self.priority.is_empty()
|
|
||||||
&& self.title.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns `true` when the caller has provided at least one status pattern.
|
|
||||||
///
|
|
||||||
/// Used by `cmd_list` to detect whether to apply the implicit
|
|
||||||
/// done-exclusion heuristic.
|
|
||||||
pub fn has_status_filter(&self) -> bool {
|
|
||||||
!self.status.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns `true` if the ticket's status matches any of the status patterns.
|
|
||||||
///
|
|
||||||
/// Only meaningful when [`has_status_filter`] returns `true`. Matching
|
|
||||||
/// is case-insensitive.
|
|
||||||
///
|
|
||||||
/// [`has_status_filter`]: TicketFilter::has_status_filter
|
|
||||||
pub fn matches_status(&self, ticket: &Ticket) -> bool {
|
|
||||||
let status_val = status_str(&ticket.status);
|
|
||||||
self.status
|
|
||||||
.iter()
|
|
||||||
.any(|p| glob_matches(&p.to_lowercase(), status_val))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns `true` if the ticket matches all non-status filter groups
|
|
||||||
/// (type, priority, title). The status group is excluded so callers can
|
|
||||||
/// handle it separately.
|
|
||||||
///
|
|
||||||
/// An empty filter always returns `true`.
|
|
||||||
pub fn matches_except_status(&self, ticket: &Ticket) -> bool {
|
|
||||||
let type_val = ticket_type_str(&ticket.ticket_type);
|
|
||||||
let priority_val = ticket.priority.to_string();
|
|
||||||
|
|
||||||
(self.ticket_type.is_empty()
|
|
||||||
|| self
|
|
||||||
.ticket_type
|
|
||||||
.iter()
|
|
||||||
.any(|p| glob_matches(&p.to_lowercase(), type_val)))
|
|
||||||
&& (self.priority.is_empty()
|
|
||||||
|| self.priority.iter().any(|p| glob_matches(p, &priority_val)))
|
|
||||||
&& (self.title.is_empty() || self.title.iter().any(|p| glob_matches(p, &ticket.title)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Parsing ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Parse a slice of `"key=value"` strings into a [`TicketFilter`].
|
|
||||||
///
|
|
||||||
/// The key `type` maps to the `ticket_type` field. Everything after the
|
|
||||||
/// **first** `=` is treated as the value, so `key=value=more` is valid and
|
|
||||||
/// produces the value `"value=more"`.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns an error for:
|
|
||||||
/// - A string that contains no `=` character.
|
|
||||||
/// - A key that is not one of `status`, `type`, `priority`, or `title`.
|
|
||||||
///
|
|
||||||
/// # Examples
|
|
||||||
///
|
|
||||||
/// ```rust,ignore
|
|
||||||
/// let args = vec!["status=todo".to_string(), "status=in_progress".to_string()];
|
|
||||||
/// let filter = parse_filters(&args).unwrap();
|
|
||||||
/// assert_eq!(filter.status.len(), 2);
|
|
||||||
/// ```
|
|
||||||
pub fn parse_filters(args: &[String]) -> Result<TicketFilter> {
|
|
||||||
let mut filter = TicketFilter::default();
|
|
||||||
|
|
||||||
for arg in args {
|
|
||||||
let (key, value) = arg
|
|
||||||
.split_once('=')
|
|
||||||
.ok_or_else(|| format!("invalid filter '{arg}': expected 'key=value' format"))?;
|
|
||||||
|
|
||||||
match key {
|
|
||||||
"status" => filter.status.push(value.to_string()),
|
|
||||||
"type" => filter.ticket_type.push(value.to_string()),
|
|
||||||
"priority" => filter.priority.push(value.to_string()),
|
|
||||||
"title" => filter.title.push(value.to_string()),
|
|
||||||
other => {
|
|
||||||
return Err(format!(
|
|
||||||
"unknown filter key '{other}'; expected one of: status, type, priority, title"
|
|
||||||
)
|
|
||||||
.into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(filter)
|
|
||||||
}
|
|
||||||
@ -1,837 +0,0 @@
|
|||||||
//! File I/O and directory traversal for ticket storage.
|
|
||||||
//!
|
|
||||||
//! Tickets are stored in `.nbd/tickets/` relative to the project root.
|
|
||||||
//! Each ticket is a single file named `{id}.{ext}`, where the extension
|
|
||||||
//! depends on the [`FileFormat`]:
|
|
||||||
//!
|
|
||||||
//! | Format | Extension | Description |
|
|
||||||
//! |---|---|---|
|
|
||||||
//! | [`FileFormat::Json`] | `.json` | Pretty-printed JSON (default) |
|
|
||||||
//! | [`FileFormat::Markdown`] | `.md` | Markdown body with TOML frontmatter |
|
|
||||||
//! | [`FileFormat::Toml`] | `.toml` | TOML |
|
|
||||||
//! | [`FileFormat::Jsonb`] | `.jsonb` | CBOR binary |
|
|
||||||
//!
|
|
||||||
//! The root is discovered by walking up from the current working directory
|
|
||||||
//! until a `.nbd/` directory is found, mirroring how `git` locates `.git/`.
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use tokio::fs;
|
|
||||||
|
|
||||||
use crate::filter::TicketFilter;
|
|
||||||
use crate::ticket::{Status, Ticket, TicketType};
|
|
||||||
|
|
||||||
/// Convenience alias for fallible operations in this module.
|
|
||||||
///
|
|
||||||
/// The error type is a heap-allocated trait object so that both `io::Error`
|
|
||||||
/// and `serde_json::Error` (and any other `std::error::Error` implementor)
|
|
||||||
/// can be returned with `?` without additional wrapping.
|
|
||||||
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
|
|
||||||
|
|
||||||
/// Convert a string message into a boxed error, with an unambiguous type.
|
|
||||||
fn msg_err(s: String) -> Box<dyn std::error::Error + Send + Sync> {
|
|
||||||
s.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── FileFormat ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// The on-disk serialisation format for a ticket file.
|
|
||||||
///
|
|
||||||
/// The format is determined by the file extension when reading, and must be
|
|
||||||
/// supplied explicitly when writing a new file. `Json` is the default.
|
|
||||||
///
|
|
||||||
/// # Examples
|
|
||||||
///
|
|
||||||
/// ```
|
|
||||||
/// use nbd::store::FileFormat;
|
|
||||||
/// assert_eq!(FileFormat::Json.extension(), "json");
|
|
||||||
/// assert_eq!(FileFormat::Markdown.extension(), "md");
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub enum FileFormat {
|
|
||||||
/// Pretty-printed JSON (`.json`). The original and default format.
|
|
||||||
#[default]
|
|
||||||
Json,
|
|
||||||
/// Markdown file with TOML frontmatter (`.md`).
|
|
||||||
///
|
|
||||||
/// The ticket body is stored as the file content after the closing `+++`
|
|
||||||
/// delimiter; all other fields live in the TOML frontmatter block.
|
|
||||||
Markdown,
|
|
||||||
/// TOML (`.toml`). All ticket fields except `id` are stored as TOML.
|
|
||||||
Toml,
|
|
||||||
/// CBOR binary encoding (`.jsonb`). Compact binary alternative to JSON.
|
|
||||||
Jsonb,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FileFormat {
|
|
||||||
/// The file extension associated with this format (without a leading dot).
|
|
||||||
pub fn extension(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
FileFormat::Json => "json",
|
|
||||||
FileFormat::Markdown => "md",
|
|
||||||
FileFormat::Toml => "toml",
|
|
||||||
FileFormat::Jsonb => "jsonb",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a [`FileFormat`] from a user-supplied string.
|
|
||||||
///
|
|
||||||
/// Accepts `"json"`, `"md"`, `"toml"`, and `"jsonb"`.
|
|
||||||
/// Returns `None` for unrecognised values.
|
|
||||||
pub fn from_str(s: &str) -> Option<Self> {
|
|
||||||
match s {
|
|
||||||
"json" => Some(FileFormat::Json),
|
|
||||||
"md" => Some(FileFormat::Markdown),
|
|
||||||
"toml" => Some(FileFormat::Toml),
|
|
||||||
"jsonb" => Some(FileFormat::Jsonb),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for FileFormat {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.write_str(self.extension())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// File extensions recognised as ticket files, tried in this order on reads.
|
|
||||||
const KNOWN_EXTENSIONS: &[&str] = &["json", "md", "toml", "jsonb"];
|
|
||||||
|
|
||||||
/// Detect the [`FileFormat`] from a file path's extension.
|
|
||||||
///
|
|
||||||
/// Falls back to [`FileFormat::Json`] for absent or unrecognised extensions.
|
|
||||||
pub fn detect_format(path: &Path) -> FileFormat {
|
|
||||||
match path.extension().and_then(|e| e.to_str()) {
|
|
||||||
Some("json") => FileFormat::Json,
|
|
||||||
Some("md") => FileFormat::Markdown,
|
|
||||||
Some("toml") => FileFormat::Toml,
|
|
||||||
Some("jsonb") => FileFormat::Jsonb,
|
|
||||||
_ => FileFormat::Json,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Markdown frontmatter helper ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Ticket metadata stored in TOML frontmatter for `.md` files.
|
|
||||||
///
|
|
||||||
/// The `body` field is intentionally absent here: in the markdown format the
|
|
||||||
/// body is the file content that follows the closing `+++` delimiter.
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
struct MarkdownFrontmatter {
|
|
||||||
title: String,
|
|
||||||
priority: u8,
|
|
||||||
status: Status,
|
|
||||||
ticket_type: TicketType,
|
|
||||||
dependencies: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Serialisation helpers ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Serialise `ticket` to a pretty-printed JSON string.
|
|
||||||
fn serialize_json(ticket: &Ticket) -> Result<String> {
|
|
||||||
Ok(serde_json::to_string_pretty(ticket)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serialise `ticket` to a TOML string.
|
|
||||||
fn serialize_toml(ticket: &Ticket) -> Result<String> {
|
|
||||||
Ok(toml::to_string(ticket)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serialise `ticket` to a markdown document with TOML frontmatter.
|
|
||||||
///
|
|
||||||
/// Format:
|
|
||||||
/// ```text
|
|
||||||
/// +++
|
|
||||||
/// title = "..."
|
|
||||||
/// priority = 5
|
|
||||||
/// ...
|
|
||||||
/// +++
|
|
||||||
/// Body content here.
|
|
||||||
/// ```
|
|
||||||
fn serialize_markdown(ticket: &Ticket) -> Result<String> {
|
|
||||||
let fm = MarkdownFrontmatter {
|
|
||||||
title: ticket.title.clone(),
|
|
||||||
priority: ticket.priority,
|
|
||||||
status: ticket.status.clone(),
|
|
||||||
ticket_type: ticket.ticket_type.clone(),
|
|
||||||
dependencies: ticket.dependencies.clone(),
|
|
||||||
};
|
|
||||||
let toml_str = toml::to_string(&fm)?;
|
|
||||||
Ok(format!("+++\n{toml_str}+++\n{}", ticket.body))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serialise `ticket` to CBOR binary.
|
|
||||||
fn serialize_jsonb(ticket: &Ticket) -> Result<Vec<u8>> {
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
ciborium::ser::into_writer(ticket, &mut buf)
|
|
||||||
.map_err(|e| msg_err(format!("CBOR serialization error: {e}")))?;
|
|
||||||
Ok(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Deserialisation helpers ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Deserialise a ticket from JSON bytes.
|
|
||||||
fn deserialize_json(bytes: &[u8]) -> Result<Ticket> {
|
|
||||||
Ok(serde_json::from_slice(bytes)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deserialise a ticket from TOML bytes.
|
|
||||||
fn deserialize_toml(bytes: &[u8]) -> Result<Ticket> {
|
|
||||||
let s = std::str::from_utf8(bytes)?;
|
|
||||||
Ok(toml::from_str(s)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deserialise a ticket from a markdown document with TOML frontmatter.
|
|
||||||
///
|
|
||||||
/// The document must begin with `+++\n`, contain a closing `\n+++\n`, and
|
|
||||||
/// have TOML key-value pairs between the two delimiters. Everything after the
|
|
||||||
/// closing delimiter becomes the ticket body.
|
|
||||||
fn deserialize_markdown(bytes: &[u8]) -> Result<Ticket> {
|
|
||||||
let content = std::str::from_utf8(bytes)?;
|
|
||||||
|
|
||||||
let after_open = content
|
|
||||||
.strip_prefix("+++\n")
|
|
||||||
.ok_or("markdown ticket must start with '+++\\n'")?;
|
|
||||||
|
|
||||||
let (fm_str, body) = after_open
|
|
||||||
.split_once("\n+++\n")
|
|
||||||
.ok_or("markdown ticket is missing a closing '+++' delimiter")?;
|
|
||||||
|
|
||||||
let fm: MarkdownFrontmatter = toml::from_str(fm_str)?;
|
|
||||||
|
|
||||||
Ok(Ticket {
|
|
||||||
id: String::new(),
|
|
||||||
title: fm.title,
|
|
||||||
body: body.to_string(),
|
|
||||||
priority: fm.priority,
|
|
||||||
status: fm.status,
|
|
||||||
ticket_type: fm.ticket_type,
|
|
||||||
dependencies: fm.dependencies,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deserialise a ticket from CBOR binary bytes.
|
|
||||||
fn deserialize_jsonb(bytes: &[u8]) -> Result<Ticket> {
|
|
||||||
ciborium::de::from_reader(bytes)
|
|
||||||
.map_err(|e| msg_err(format!("CBOR deserialization error: {e}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deserialise `bytes` using the detected `format`.
|
|
||||||
fn deserialize_by_format(bytes: &[u8], format: FileFormat) -> Result<Ticket> {
|
|
||||||
match format {
|
|
||||||
FileFormat::Json => deserialize_json(bytes),
|
|
||||||
FileFormat::Markdown => deserialize_markdown(bytes),
|
|
||||||
FileFormat::Toml => deserialize_toml(bytes),
|
|
||||||
FileFormat::Jsonb => deserialize_jsonb(bytes),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Directory helpers ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Walk upward from `start` until a directory containing `.nbd/` is found.
|
|
||||||
///
|
|
||||||
/// Returns the first ancestor path (inclusive of `start`) that contains a
|
|
||||||
/// `.nbd/` subdirectory, or an error if the filesystem root is reached without
|
|
||||||
/// finding one.
|
|
||||||
///
|
|
||||||
/// This is the low-level, testable variant. Most callers should use
|
|
||||||
/// [`find_nbd_root`], which starts from the current working directory.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns an error if no `.nbd/` directory exists in `start` or any of its
|
|
||||||
/// ancestors.
|
|
||||||
pub fn find_nbd_root_from(start: &Path) -> Result<PathBuf> {
|
|
||||||
let mut dir = start;
|
|
||||||
loop {
|
|
||||||
if dir.join(".nbd").is_dir() {
|
|
||||||
return Ok(dir.to_path_buf());
|
|
||||||
}
|
|
||||||
match dir.parent() {
|
|
||||||
Some(parent) => dir = parent,
|
|
||||||
None => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(
|
|
||||||
"could not find .nbd/ directory; create `.nbd/tickets/` in your project root to initialise"
|
|
||||||
.into(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Walk upward from the current working directory until a `.nbd/` directory is
|
|
||||||
/// found.
|
|
||||||
///
|
|
||||||
/// This is the primary entry point used by all CLI commands. Internally
|
|
||||||
/// delegates to [`find_nbd_root_from`].
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns an error if the current working directory cannot be determined, or
|
|
||||||
/// if no `.nbd/` directory exists in it or any of its ancestors.
|
|
||||||
pub fn find_nbd_root() -> Result<PathBuf> {
|
|
||||||
let cwd = std::env::current_dir()?;
|
|
||||||
find_nbd_root_from(&cwd)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the path to the tickets directory within a project root.
|
|
||||||
///
|
|
||||||
/// This is a pure path computation — it does not check whether the directory
|
|
||||||
/// exists.
|
|
||||||
pub fn tickets_dir(root: &Path) -> PathBuf {
|
|
||||||
root.join(".nbd").join("tickets")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create `.nbd/tickets/` under `root` if it does not already exist.
|
|
||||||
///
|
|
||||||
/// All intermediate directories (including `.nbd/`) are created as needed.
|
|
||||||
/// This should only be called by the `create` command; other commands can
|
|
||||||
/// assume the directory already exists.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Propagates any I/O error returned by the filesystem.
|
|
||||||
pub async fn ensure_tickets_dir(root: &Path) -> Result<()> {
|
|
||||||
let dir = tickets_dir(root);
|
|
||||||
fs::create_dir_all(dir).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Path helpers ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Return the path to a specific ticket file for the given `format`.
|
|
||||||
///
|
|
||||||
/// This is a pure path computation — it does not check whether the file
|
|
||||||
/// exists.
|
|
||||||
pub fn ticket_path(root: &Path, id: &str, format: FileFormat) -> PathBuf {
|
|
||||||
tickets_dir(root).join(format!("{id}.{}", format.extension()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find and return the actual on-disk path of a ticket, trying each known
|
|
||||||
/// extension in order.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns an error if no file with a known extension exists for `id`.
|
|
||||||
pub async fn find_ticket_path(root: &Path, id: &str) -> Result<PathBuf> {
|
|
||||||
let dir = tickets_dir(root);
|
|
||||||
for ext in KNOWN_EXTENSIONS {
|
|
||||||
let path = dir.join(format!("{id}.{ext}"));
|
|
||||||
if path.is_file() {
|
|
||||||
return Ok(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(format!("ticket '{id}' not found").into())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CRUD operations ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Serialise `ticket` and write it to `.nbd/tickets/{id}.{ext}` using
|
|
||||||
/// `format` to determine both the extension and the serialisation.
|
|
||||||
///
|
|
||||||
/// Overwrites any existing file at the same path. The tickets directory must
|
|
||||||
/// already exist; call [`ensure_tickets_dir`] before calling this for a new
|
|
||||||
/// ticket.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns an error if serialisation fails or if the file cannot be written.
|
|
||||||
pub async fn write_ticket(root: &Path, ticket: &Ticket, format: FileFormat) -> Result<()> {
|
|
||||||
let path = ticket_path(root, &ticket.id, format);
|
|
||||||
match format {
|
|
||||||
FileFormat::Json => {
|
|
||||||
let json = serialize_json(ticket)?;
|
|
||||||
fs::write(path, json).await?;
|
|
||||||
}
|
|
||||||
FileFormat::Markdown => {
|
|
||||||
let content = serialize_markdown(ticket)?;
|
|
||||||
fs::write(path, content).await?;
|
|
||||||
}
|
|
||||||
FileFormat::Toml => {
|
|
||||||
let content = serialize_toml(ticket)?;
|
|
||||||
fs::write(path, content).await?;
|
|
||||||
}
|
|
||||||
FileFormat::Jsonb => {
|
|
||||||
let bytes = serialize_jsonb(ticket)?;
|
|
||||||
fs::write(path, bytes).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve a full 6-character ticket ID from an exact ID or a unique prefix.
|
|
||||||
///
|
|
||||||
/// If `id_or_prefix` is already an exact match for a ticket on disk (in any
|
|
||||||
/// supported format), it is returned unchanged. Otherwise all ticket filenames
|
|
||||||
/// whose stem starts with `id_or_prefix` are collected and:
|
|
||||||
///
|
|
||||||
/// - **0 matches** → error: `"no ticket found matching '{prefix}'"`
|
|
||||||
/// - **1 match** → the full 6-character ID is returned
|
|
||||||
/// - **2+ matches** → error: `"ambiguous prefix '{prefix}' matches: {id1}, {id2}, ..."`
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns an error if no match is found, the prefix is ambiguous, or the
|
|
||||||
/// tickets directory cannot be read.
|
|
||||||
pub async fn resolve_id(root: &Path, id_or_prefix: &str) -> Result<String> {
|
|
||||||
let dir = tickets_dir(root);
|
|
||||||
|
|
||||||
// Fast path: if exactly 6 chars, check all known extensions.
|
|
||||||
if id_or_prefix.len() == 6 {
|
|
||||||
for ext in KNOWN_EXTENSIONS {
|
|
||||||
if dir.join(format!("{id_or_prefix}.{ext}")).is_file() {
|
|
||||||
return Ok(id_or_prefix.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scan directory for prefix matches across all known formats.
|
|
||||||
if !dir.is_dir() {
|
|
||||||
return Err(format!("no ticket found matching '{id_or_prefix}'").into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut entries = fs::read_dir(&dir).await?;
|
|
||||||
let mut matches: Vec<String> = Vec::new();
|
|
||||||
|
|
||||||
while let Some(entry) = entries.next_entry().await? {
|
|
||||||
// Construct a std::path::PathBuf so it's compatible with Path helpers.
|
|
||||||
let path: PathBuf = dir.join(entry.file_name());
|
|
||||||
let ext = path
|
|
||||||
.extension()
|
|
||||||
.and_then(|e| e.to_str())
|
|
||||||
.unwrap_or_default();
|
|
||||||
if !KNOWN_EXTENSIONS.contains(&ext) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
|
||||||
if stem.starts_with(id_or_prefix) && !matches.contains(&stem.to_string()) {
|
|
||||||
matches.push(stem.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match matches.len() {
|
|
||||||
0 => Err(format!("no ticket found matching '{id_or_prefix}'").into()),
|
|
||||||
1 => Ok(matches.remove(0)),
|
|
||||||
_ => {
|
|
||||||
matches.sort();
|
|
||||||
Err(format!(
|
|
||||||
"ambiguous prefix '{id_or_prefix}' matches: {}",
|
|
||||||
matches.join(", ")
|
|
||||||
)
|
|
||||||
.into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read and deserialise the ticket with the given `id` from disk.
|
|
||||||
///
|
|
||||||
/// Extensions are tried in the order defined by [`KNOWN_EXTENSIONS`]. The
|
|
||||||
/// first matching file is used. The `id` is not stored inside the file; it
|
|
||||||
/// is injected from the `id` parameter after deserialisation, making the
|
|
||||||
/// filename stem the authoritative source of truth.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns a descriptive error message if no file is found for `id`.
|
|
||||||
/// Propagates any other I/O or deserialisation error unchanged.
|
|
||||||
pub async fn read_ticket(root: &Path, id: &str) -> Result<Ticket> {
|
|
||||||
let dir = tickets_dir(root);
|
|
||||||
for ext in KNOWN_EXTENSIONS {
|
|
||||||
let path = dir.join(format!("{id}.{ext}"));
|
|
||||||
match fs::read(&path).await {
|
|
||||||
Ok(bytes) => {
|
|
||||||
let format = detect_format(&path);
|
|
||||||
let mut ticket = deserialize_by_format(&bytes, format)?;
|
|
||||||
ticket.id = id.to_string();
|
|
||||||
return Ok(ticket);
|
|
||||||
}
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
|
|
||||||
Err(e) => return Err(e.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(format!("ticket '{id}' not found").into())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Report produced by [`migrate_tickets`].
|
|
||||||
///
|
|
||||||
/// Summarises how many ticket files were updated, were already current,
|
|
||||||
/// were skipped by the filter, or could not be parsed.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct MigrateReport {
|
|
||||||
/// Number of files that were re-serialised (had stale content).
|
|
||||||
pub updated: usize,
|
|
||||||
/// Number of files that were already in the current schema format.
|
|
||||||
pub already_current: usize,
|
|
||||||
/// Number of files excluded by the caller-supplied [`TicketFilter`].
|
|
||||||
pub skipped: usize,
|
|
||||||
/// Files that could not be deserialised. Each entry is `(filename, error)`.
|
|
||||||
pub errors: Vec<(String, String)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Re-serialise every ticket file through the current serde schema.
|
|
||||||
///
|
|
||||||
/// For each ticket file in `.nbd/tickets/` (any supported format):
|
|
||||||
/// - Deserialise into [`Ticket`], injecting the `id` from the filename stem.
|
|
||||||
/// - If the ticket does not match `filter`, count it as skipped and continue.
|
|
||||||
/// - Re-serialise in the same format.
|
|
||||||
/// - If the bytes differ, write the new content (unless `dry_run` is `true`).
|
|
||||||
/// - If the bytes are identical, count the file as already current.
|
|
||||||
/// - If deserialisation fails, record the error and leave the file untouched.
|
|
||||||
///
|
|
||||||
/// When `dry_run` is `true`, no files are written; the report describes what
|
|
||||||
/// *would* have changed. The function always returns `Ok` — individual file
|
|
||||||
/// errors are collected in [`MigrateReport::errors`] rather than aborting early.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns an error only if the tickets directory itself cannot be read.
|
|
||||||
pub async fn migrate_tickets(
|
|
||||||
root: &Path,
|
|
||||||
dry_run: bool,
|
|
||||||
filter: &TicketFilter,
|
|
||||||
) -> Result<MigrateReport> {
|
|
||||||
let dir = tickets_dir(root);
|
|
||||||
let mut report = MigrateReport {
|
|
||||||
updated: 0,
|
|
||||||
already_current: 0,
|
|
||||||
skipped: 0,
|
|
||||||
errors: Vec::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if !dir.is_dir() {
|
|
||||||
return Ok(report);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut entries = fs::read_dir(&dir).await?;
|
|
||||||
while let Some(entry) = entries.next_entry().await? {
|
|
||||||
// Construct a std::path::PathBuf so it's compatible with Path helpers.
|
|
||||||
let path: PathBuf = dir.join(entry.file_name());
|
|
||||||
|
|
||||||
let ext = path
|
|
||||||
.extension()
|
|
||||||
.and_then(|e| e.to_str())
|
|
||||||
.unwrap_or_default();
|
|
||||||
if !KNOWN_EXTENSIONS.contains(&ext) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let filename = path
|
|
||||||
.file_name()
|
|
||||||
.and_then(|n| n.to_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
let stem = path
|
|
||||||
.file_stem()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let raw = match fs::read(&path).await {
|
|
||||||
Ok(b) => b,
|
|
||||||
Err(e) => {
|
|
||||||
report.errors.push((filename, e.to_string()));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let format = detect_format(&path);
|
|
||||||
let mut ticket: Ticket = match deserialize_by_format(&raw, format) {
|
|
||||||
Ok(t) => t,
|
|
||||||
Err(e) => {
|
|
||||||
report.errors.push((filename, e.to_string()));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
ticket.id = stem;
|
|
||||||
|
|
||||||
// Apply caller-supplied filter; skip non-matching tickets.
|
|
||||||
if !filter.matches(&ticket) {
|
|
||||||
report.skipped += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-serialise in the same format to normalise the schema.
|
|
||||||
let new_bytes: Vec<u8> = match format {
|
|
||||||
FileFormat::Json => match serialize_json(&ticket) {
|
|
||||||
Ok(s) => s.into_bytes(),
|
|
||||||
Err(e) => {
|
|
||||||
report.errors.push((filename, e.to_string()));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
FileFormat::Markdown => match serialize_markdown(&ticket) {
|
|
||||||
Ok(s) => s.into_bytes(),
|
|
||||||
Err(e) => {
|
|
||||||
report.errors.push((filename, e.to_string()));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
FileFormat::Toml => match serialize_toml(&ticket) {
|
|
||||||
Ok(s) => s.into_bytes(),
|
|
||||||
Err(e) => {
|
|
||||||
report.errors.push((filename, e.to_string()));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
FileFormat::Jsonb => match serialize_jsonb(&ticket) {
|
|
||||||
Ok(b) => b,
|
|
||||||
Err(e) => {
|
|
||||||
report.errors.push((filename, e.to_string()));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if raw == new_bytes {
|
|
||||||
report.already_current += 1;
|
|
||||||
} else if dry_run {
|
|
||||||
report.updated += 1;
|
|
||||||
} else {
|
|
||||||
if let Err(e) = fs::write(&path, &new_bytes).await {
|
|
||||||
report.errors.push((filename, e.to_string()));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
report.updated += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(report)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read every ticket file in the tickets directory and return them sorted by
|
|
||||||
/// priority descending (highest priority first).
|
|
||||||
///
|
|
||||||
/// All supported file formats (`.json`, `.md`, `.toml`, `.jsonb`) are scanned.
|
|
||||||
/// If the tickets directory does not exist, an empty `Vec` is returned.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns an error if the directory listing fails, if any ticket file cannot
|
|
||||||
/// be read, or if any ticket's content cannot be deserialised.
|
|
||||||
pub async fn list_tickets(root: &Path) -> Result<Vec<Ticket>> {
|
|
||||||
let dir = tickets_dir(root);
|
|
||||||
|
|
||||||
if !dir.is_dir() {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut entries = fs::read_dir(&dir).await?;
|
|
||||||
let mut tickets = Vec::new();
|
|
||||||
// Guard against the same logical ticket appearing in multiple formats.
|
|
||||||
let mut seen_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
||||||
|
|
||||||
while let Some(entry) = entries.next_entry().await? {
|
|
||||||
// Construct a std::path::PathBuf so it's compatible with Path helpers.
|
|
||||||
let path: PathBuf = dir.join(entry.file_name());
|
|
||||||
let ext = path
|
|
||||||
.extension()
|
|
||||||
.and_then(|e| e.to_str())
|
|
||||||
.unwrap_or_default();
|
|
||||||
if !KNOWN_EXTENSIONS.contains(&ext) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let stem = path
|
|
||||||
.file_stem()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.ok_or("ticket filename has no valid stem")?
|
|
||||||
.to_string();
|
|
||||||
if !seen_ids.insert(stem.clone()) {
|
|
||||||
// Same ID already loaded from a different-extension file; skip.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let bytes = fs::read(&path).await?;
|
|
||||||
let format = detect_format(&path);
|
|
||||||
let mut ticket = deserialize_by_format(&bytes, format)?;
|
|
||||||
ticket.id = stem;
|
|
||||||
tickets.push(ticket);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Highest priority value first; ties preserve filesystem order.
|
|
||||||
tickets.sort_by(|a, b| b.priority.cmp(&a.priority));
|
|
||||||
Ok(tickets)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Turso (libsql) cache ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Open (or create) the libsql cache database at `.nbd/cache.db`.
|
|
||||||
///
|
|
||||||
/// Runs the schema migration on every open so the table exists when needed.
|
|
||||||
/// The returned [`libsql::Connection`] is ready for queries.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns an error if the database cannot be opened or the migration fails.
|
|
||||||
async fn open_cache(root: &Path) -> Result<libsql::Connection> {
|
|
||||||
let db_path = root.join(".nbd").join("cache.db");
|
|
||||||
let db = libsql::Builder::new_local(db_path)
|
|
||||||
.build()
|
|
||||||
.await
|
|
||||||
.map_err(|e| msg_err(format!("libsql open error: {e}")))?;
|
|
||||||
let conn = db
|
|
||||||
.connect()
|
|
||||||
.map_err(|e| msg_err(format!("libsql connect error: {e}")))?;
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS tickets (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
json TEXT NOT NULL,
|
|
||||||
mtime INTEGER NOT NULL
|
|
||||||
)",
|
|
||||||
(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| msg_err(format!("libsql migrate error: {e}")))?;
|
|
||||||
Ok(conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Like [`list_tickets`] but uses a libsql cache in `.nbd/cache.db` to skip
|
|
||||||
/// re-reading files whose modification time has not changed.
|
|
||||||
///
|
|
||||||
/// ## Cache strategy
|
|
||||||
///
|
|
||||||
/// 1. Scan `.nbd/tickets/` for files and their mtimes.
|
|
||||||
/// 2. For each file: if the cache row exists and the mtime matches, deserialise
|
|
||||||
/// the cached JSON; otherwise read the file, parse it, and upsert the cache.
|
|
||||||
/// 3. Delete rows for IDs that no longer exist on disk.
|
|
||||||
/// 4. Return tickets sorted by priority descending.
|
|
||||||
///
|
|
||||||
/// Falls back to [`list_tickets`] if the cache cannot be opened or any cache
|
|
||||||
/// operation fails, so the caller always gets a result.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Only returns an error if [`list_tickets`] itself fails (the fallback path).
|
|
||||||
pub async fn list_tickets_cached(root: &Path) -> Result<Vec<Ticket>> {
|
|
||||||
match list_tickets_cached_inner(root).await {
|
|
||||||
Ok(tickets) => Ok(tickets),
|
|
||||||
Err(_) => list_tickets(root).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inner implementation of the cached list; errors cause a fallback to the
|
|
||||||
/// uncached path in [`list_tickets_cached`].
|
|
||||||
async fn list_tickets_cached_inner(root: &Path) -> Result<Vec<Ticket>> {
|
|
||||||
let dir = tickets_dir(root);
|
|
||||||
|
|
||||||
if !dir.is_dir() {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
let conn = open_cache(root).await?;
|
|
||||||
|
|
||||||
// Scan the directory, collecting (id, path, mtime_secs) for each ticket file.
|
|
||||||
let mut on_disk: Vec<(String, PathBuf, i64)> = Vec::new();
|
|
||||||
let mut read_dir = fs::read_dir(&dir).await?;
|
|
||||||
while let Some(entry) = read_dir.next_entry().await? {
|
|
||||||
let path: PathBuf = dir.join(entry.file_name());
|
|
||||||
let ext = path
|
|
||||||
.extension()
|
|
||||||
.and_then(|e| e.to_str())
|
|
||||||
.unwrap_or_default();
|
|
||||||
if !KNOWN_EXTENSIONS.contains(&ext) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let stem = match path.file_stem().and_then(|s| s.to_str()) {
|
|
||||||
Some(s) => s.to_string(),
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
let meta = fs::metadata(&path).await?;
|
|
||||||
let mtime = meta
|
|
||||||
.modified()
|
|
||||||
.ok()
|
|
||||||
.and_then(|t| {
|
|
||||||
t.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.ok()
|
|
||||||
.map(|d| d.as_millis() as i64)
|
|
||||||
})
|
|
||||||
.unwrap_or(0);
|
|
||||||
on_disk.push((stem, path, mtime));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deduplicate: keep only the first occurrence of each ID (same order as list_tickets).
|
|
||||||
let mut seen_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
||||||
on_disk.retain(|(id, _, _)| seen_ids.insert(id.clone()));
|
|
||||||
|
|
||||||
let disk_ids: std::collections::HashSet<String> =
|
|
||||||
on_disk.iter().map(|(id, _, _)| id.clone()).collect();
|
|
||||||
|
|
||||||
let mut tickets: Vec<Ticket> = Vec::with_capacity(on_disk.len());
|
|
||||||
|
|
||||||
for (id, path, mtime) in &on_disk {
|
|
||||||
// Query the cache for this id.
|
|
||||||
let cached_json: Option<String> = {
|
|
||||||
let mut rows = conn
|
|
||||||
.query(
|
|
||||||
"SELECT json FROM tickets WHERE id = ?1 AND mtime = ?2",
|
|
||||||
libsql::params![id.as_str(), *mtime],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| msg_err(format!("cache query error: {e}")))?;
|
|
||||||
if let Some(row) = rows
|
|
||||||
.next()
|
|
||||||
.await
|
|
||||||
.map_err(|e| msg_err(format!("cache row error: {e}")))?
|
|
||||||
{
|
|
||||||
Some(
|
|
||||||
row.get::<String>(0)
|
|
||||||
.map_err(|e| msg_err(format!("cache column error: {e}")))?,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut ticket = if let Some(json) = cached_json {
|
|
||||||
serde_json::from_str::<Ticket>(&json)
|
|
||||||
.map_err(|e| msg_err(format!("cache deserialise error: {e}")))?
|
|
||||||
} else {
|
|
||||||
// Cache miss: read file and upsert.
|
|
||||||
let bytes = fs::read(path).await?;
|
|
||||||
let format = detect_format(path);
|
|
||||||
let t = deserialize_by_format(&bytes, format)?;
|
|
||||||
// Store normalised JSON in the cache.
|
|
||||||
let json = serde_json::to_string(&t)?;
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR REPLACE INTO tickets (id, json, mtime) VALUES (?1, ?2, ?3)",
|
|
||||||
libsql::params![id.as_str(), json.as_str(), *mtime],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| msg_err(format!("cache upsert error: {e}")))?;
|
|
||||||
t
|
|
||||||
};
|
|
||||||
ticket.id = id.clone();
|
|
||||||
tickets.push(ticket);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove stale rows for IDs no longer on disk.
|
|
||||||
// We fetch all cached IDs and delete anything not in `disk_ids`.
|
|
||||||
let mut rows = conn
|
|
||||||
.query("SELECT id FROM tickets", ())
|
|
||||||
.await
|
|
||||||
.map_err(|e| msg_err(format!("cache scan error: {e}")))?;
|
|
||||||
let mut stale: Vec<String> = Vec::new();
|
|
||||||
while let Some(row) = rows
|
|
||||||
.next()
|
|
||||||
.await
|
|
||||||
.map_err(|e| msg_err(format!("cache row error: {e}")))?
|
|
||||||
{
|
|
||||||
let cached_id: String = row
|
|
||||||
.get::<String>(0)
|
|
||||||
.map_err(|e| msg_err(format!("cache column error: {e}")))?;
|
|
||||||
if !disk_ids.contains(&cached_id) {
|
|
||||||
stale.push(cached_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for id in stale {
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM tickets WHERE id = ?1",
|
|
||||||
libsql::params![id.as_str()],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| msg_err(format!("cache delete error: {e}")))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
tickets.sort_by(|a, b| b.priority.cmp(&a.priority));
|
|
||||||
Ok(tickets)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue