diff --git a/nbd/.nbd/tickets/1939a7.json b/nbd/.nbd/tickets/1939a7.json index 0b2d078..cc62f5e 100644 --- a/nbd/.nbd/tickets/1939a7.json +++ b/nbd/.nbd/tickets/1939a7.json @@ -1,9 +1,8 @@ { - "id": "1939a7", "title": "nbd archive command and Closed status", "body": "Add `Status::Closed` (serialised as `\"closed\"`) and a convenience `nbd archive ` command that sets it.\n\n## Motivation\n\n`done` tickets clutter `nbd list`. `closed` provides a soft-delete: the ticket is preserved on disk but excluded from normal listings by default.\n\n## Approach\n\n### ticket.rs\n- Add `Closed` variant to `Status` enum (after `Done`).\n- `#[serde(rename_all = \"snake_case\")]` already handles serialisation → `\"closed\"`.\n\n### main.rs\n- Update `parse_status` to accept `\"closed\"`.\n- Update `status_str` in `display.rs` to map `Status::Closed` → `\"closed\"`.\n- Add `Archive` variant to `Commands`:\n ```\n Archive { id: String }\n ```\n- Implement `cmd_archive(id, json)`: read ticket → set status to `Closed` → write → print.\n This is syntactic sugar for `nbd update --status closed`.\n\n### display.rs\n- Add `\"closed\"` to `status_str` match arm.\n\n### list filtering\n- `nbd list` currently shows all tickets. After this change, it should by default **hide** `Closed` tickets.\n- Add a `--all` flag to `nbd list` to show all tickets including closed ones.\n- Update `list_tickets` or filter at the command handler level. Prefer filtering in `cmd_list` to keep `list_tickets` generic.\n\n## Tests\n- Unit test: `Status::Closed` serialises to `\"closed\"` and back.\n- Integration test: `nbd archive ` sets status to `closed`.\n- Integration test: `nbd list` does not show closed tickets.\n- Integration test: `nbd list --all` shows closed tickets.\n\n## Files touched\n- `src/ticket.rs` — add `Closed` variant\n- `src/main.rs` — `Archive` command, `parse_status` update, `--all` flag on `list`\n- `src/display.rs` — `status_str` update\n- `src/tests.rs` — unit tests\n- `tests/integration.rs` — integration tests\n- `README.md` — document archive and --all", "priority": 6, - "status": "todo", + "status": "done", "dependencies": [], "ticket_type": "feature" } \ No newline at end of file diff --git a/nbd/README.md b/nbd/README.md index f69034b..15c7ef3 100644 --- a/nbd/README.md +++ b/nbd/README.md @@ -17,7 +17,7 @@ Each ticket is a JSON file named `{id}.json`, where `id` is a unique | `title` | string | *(required)* | | `body` | string | `""` | | `priority` | integer 0–10 | `5` | -| `status` | `todo` \| `in_progress` \| `done` | `todo` | +| `status` | `todo` \| `in_progress` \| `done` \| `closed` | `todo` | | `ticket_type` | `project` \| `feature` \| `task` \| `bug` | `task` | | `dependencies` | list of ticket IDs | `[]` | @@ -51,16 +51,32 @@ nbd read a3f9c2 --json ### List all tickets -By default, `done` tickets are excluded. +By default, `done` and `closed` tickets are excluded. ```sh -nbd list # todo + in_progress only (done excluded) -nbd list --filter status=* # all tickets including done +nbd list # todo + in_progress only (done + closed excluded) +nbd list --all # all tickets including done and closed +nbd list --filter status=* # all tickets (alias for --all via filter) nbd list --filter status=done # only completed tickets -nbd list --filter type=bug # non-done bug tickets +nbd list --filter status=closed # only archived tickets +nbd list --filter type=bug # non-done, non-closed bug tickets nbd list --json ``` +### Archive a ticket + +Soft-delete a ticket by setting its status to `closed`. The file is preserved +on disk but hidden from normal listings. + +```sh +nbd archive a3f9c2 +nbd archive a3f9c2 --json +``` + +`nbd archive` is shorthand for `nbd update --status closed`. Archived +tickets still count as resolved for dependency purposes — any ticket that +depends on an archived ticket becomes unblocked. + ### Update a ticket Only the flags you supply are changed; all other fields retain their current @@ -114,9 +130,11 @@ nbd migrate --json # machine-readable summary cargo run -- init cargo run -- create --title "Test ticket" --priority 7 --type bug cargo run -- list +cargo run -- list --all cargo run -- ready cargo run -- read cargo run -- update --status in_progress +cargo run -- archive cargo run -- list --json ``` diff --git a/nbd/src/display.rs b/nbd/src/display.rs index d5562d9..176476d 100644 --- a/nbd/src/display.rs +++ b/nbd/src/display.rs @@ -34,6 +34,7 @@ fn status_str(status: &Status) -> &'static str { Status::Todo => "todo", Status::InProgress => "in_progress", Status::Done => "done", + Status::Closed => "closed", } } diff --git a/nbd/src/filter.rs b/nbd/src/filter.rs index 42baee6..1675e69 100644 --- a/nbd/src/filter.rs +++ b/nbd/src/filter.rs @@ -23,6 +23,7 @@ fn status_str(status: &Status) -> &'static str { Status::Todo => "todo", Status::InProgress => "in_progress", Status::Done => "done", + Status::Closed => "closed", } } diff --git a/nbd/src/main.rs b/nbd/src/main.rs index 12157cf..82a91a0 100644 --- a/nbd/src/main.rs +++ b/nbd/src/main.rs @@ -76,9 +76,9 @@ enum Commands { /// 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. + /// By default, tickets with status `done` or `closed` are excluded. Pass + /// `--all` to include all tickets, or `--filter status=*` to override only + /// the status exclusion. List { /// Filter tickets by field: repeatable `key=value` pairs. /// @@ -86,10 +86,18 @@ enum Commands { /// Different keys are ANDed; the same key with multiple values is ORed. /// Values support glob wildcards: `title=*login*`. /// - /// If no `status` filter is provided, tickets with status `done` are - /// excluded automatically. Provide `--filter status=*` to override. + /// If no `status` filter is provided, tickets with status `done` or + /// `closed` are excluded automatically. Provide `--filter status=*` + /// to override, or use `--all`. #[arg(long = "filter", value_name = "KEY=VALUE")] filter: Vec, + + /// Show all tickets regardless of status, including `done` and `closed`. + /// + /// Equivalent to `--filter status=*` but takes precedence over any + /// `--filter` arguments when both are supplied. + #[arg(long)] + all: bool, }, /// Initialise a new `.nbd/tickets/` store in the current directory. @@ -143,6 +151,18 @@ enum Commands { filter: Vec, }, + /// Archive a ticket by setting its status to `closed`. + /// + /// The ticket is preserved on disk but excluded from normal `nbd list` + /// output. Use `nbd list --all` or `--filter status=closed` to view + /// archived tickets. + /// + /// This is syntactic sugar for `nbd update --status closed`. + Archive { + /// The 6-character hex ticket ID to archive. + id: String, + }, + /// Update fields of an existing ticket and print the result. /// /// Only the flags you supply are changed; all other fields retain their @@ -208,9 +228,11 @@ async fn dispatch(cli: Cli) -> store::Result<()> { Commands::Migrate { dry_run, filter } => cmd_migrate(filter, dry_run, cli.json).await, + Commands::Archive { id } => cmd_archive(id, cli.json).await, + Commands::Read { id } => cmd_read(id, cli.json).await, - Commands::List { filter } => cmd_list(filter, cli.json).await, + Commands::List { filter, all } => cmd_list(filter, all, cli.json).await, Commands::Update { id, @@ -240,7 +262,7 @@ async fn dispatch(cli: Cli) -> store::Result<()> { /// Parse a [`Status`] from its lowercase string representation. /// -/// Accepts `"todo"`, `"in_progress"`, and `"done"`. +/// Accepts `"todo"`, `"in_progress"`, `"done"`, and `"closed"`. /// /// # Errors /// @@ -250,8 +272,9 @@ fn parse_status(s: &str) -> store::Result { "todo" => Ok(Status::Todo), "in_progress" => Ok(Status::InProgress), "done" => Ok(Status::Done), + "closed" => Ok(Status::Closed), other => Err(format!( - "unknown status '{other}'; expected 'todo', 'in_progress', or 'done'" + "unknown status '{other}'; expected 'todo', 'in_progress', 'done', or 'closed'" ) .into()), } @@ -347,10 +370,13 @@ async fn cmd_ready(filter_args: Vec, json: bool) -> store::Result<()> { let root = find_nbd_root()?; let all = list_tickets(&root).await?; - // Build the set of IDs that are fully done. + // Build the set of IDs that are resolved (done or closed). + // Closed tickets count as resolved for dependency purposes. let done_ids: std::collections::HashSet<&str> = all .iter() - .filter(|t| t.status == crate::ticket::Status::Done) + .filter(|t| { + t.status == crate::ticket::Status::Done || t.status == crate::ticket::Status::Closed + }) .map(|t| t.id.as_str()) .collect(); @@ -358,6 +384,7 @@ async fn cmd_ready(filter_args: Vec, json: bool) -> store::Result<()> { .iter() .filter(|t| { t.status != crate::ticket::Status::Done + && t.status != crate::ticket::Status::Closed && t.dependencies .iter() .all(|dep| done_ids.contains(dep.as_str())) @@ -390,12 +417,15 @@ async fn cmd_next(filter_args: Vec, json: bool) -> store::Result<()> { let done_ids: std::collections::HashSet<&str> = all .iter() - .filter(|t| t.status == crate::ticket::Status::Done) + .filter(|t| { + t.status == crate::ticket::Status::Done || t.status == crate::ticket::Status::Closed + }) .map(|t| t.id.as_str()) .collect(); let next = all.iter().find(|t| { t.status != crate::ticket::Status::Done + && t.status != crate::ticket::Status::Closed && t.dependencies .iter() .all(|dep| done_ids.contains(dep.as_str())) @@ -503,24 +533,31 @@ async fn cmd_read(id: String, json: bool) -> store::Result<()> { /// List tickets sorted by priority and print them. /// /// `filter_args` are optional `key=value` expressions that narrow the output. +/// `all` bypasses the default done/closed exclusion. /// -/// **Default behaviour:** tickets with status [`Status::Done`] are excluded -/// unless the caller provides at least one `status=…` filter argument. -/// Pass `--filter status=*` to see all tickets including done ones. -async fn cmd_list(filter_args: Vec, json: bool) -> store::Result<()> { +/// **Default behaviour:** tickets with status [`Status::Done`] or +/// [`Status::Closed`] are excluded unless the caller provides at least one +/// `status=…` filter argument or passes `--all`. +/// Pass `--filter status=*` or `--all` to see every ticket. +async fn cmd_list(filter_args: Vec, all: bool, json: bool) -> store::Result<()> { let filter = crate::filter::parse_filters(&filter_args)?; let root = find_nbd_root()?; let tickets: Vec = list_tickets(&root) .await? .into_iter() .filter(|t| { - // If no status filter was provided, exclude done tickets by default. - let status_ok = if filter.has_status_filter() { - filter.matches_status(t) + if all { + // --all: skip status exclusions; apply every other filter. + filter.matches(t) } else { - t.status != Status::Done - }; - status_ok && filter.matches_except_status(t) + // If no status filter was provided, exclude done and closed tickets by default. + let status_ok = if filter.has_status_filter() { + filter.matches_status(t) + } else { + t.status != Status::Done && t.status != Status::Closed + }; + status_ok && filter.matches_except_status(t) + } }) .collect(); @@ -533,6 +570,26 @@ async fn cmd_list(filter_args: Vec, json: bool) -> store::Result<()> { Ok(()) } +/// Archive a ticket by setting its status to [`Status::Closed`] and printing it. +/// +/// The ticket is preserved on disk but excluded from normal `nbd list` output. +/// This is syntactic sugar for `nbd update --status closed`. +async fn cmd_archive(id: String, json: bool) -> store::Result<()> { + let root = find_nbd_root()?; + let id = resolve_id(&root, &id).await?; + let mut ticket = read_ticket(&root, &id).await?; + ticket.status = Status::Closed; + write_ticket(&root, &ticket).await?; + + if json { + display::print_ticket_json(&ticket); + } else { + display::print_ticket(&ticket); + } + + Ok(()) +} + /// Update the specified fields of an existing ticket, persist it, and print it. /// /// Only the flags explicitly passed on the command line are applied; all other diff --git a/nbd/src/tests.rs b/nbd/src/tests.rs index e1a2461..68c1b6d 100644 --- a/nbd/src/tests.rs +++ b/nbd/src/tests.rs @@ -72,6 +72,10 @@ mod ticket { "\"in_progress\"" ); assert_eq!(serde_json::to_string(&Status::Done).unwrap(), "\"done\""); + assert_eq!( + serde_json::to_string(&Status::Closed).unwrap(), + "\"closed\"" + ); } /// `Status` deserialises correctly from lowercase snake_case strings. @@ -89,6 +93,10 @@ mod ticket { serde_json::from_str::("\"done\"").unwrap(), Status::Done ); + assert_eq!( + serde_json::from_str::("\"closed\"").unwrap(), + Status::Closed + ); } /// `TicketType` variants serialise to the expected lowercase strings. @@ -811,7 +819,7 @@ mod filter { assert!(!filter.matches_status(&ticket)); } - /// `matches_status` with `status=*` matches any status. + /// `matches_status` with `status=*` matches any status including `closed`. #[test] fn matches_status_wildcard_matches_all() { let args = vec!["status=*".to_string()]; @@ -819,6 +827,18 @@ mod filter { assert!(filter.matches_status(&make_ticket(Status::Todo, TicketType::Task, 5, "T"))); assert!(filter.matches_status(&make_ticket(Status::InProgress, TicketType::Task, 5, "T"))); assert!(filter.matches_status(&make_ticket(Status::Done, TicketType::Task, 5, "T"))); + assert!(filter.matches_status(&make_ticket(Status::Closed, TicketType::Task, 5, "T"))); + } + + /// `matches_status` with `status=closed` matches only closed tickets. + #[test] + fn matches_status_closed_pattern() { + let args = vec!["status=closed".to_string()]; + let filter = parse_filters(&args).unwrap(); + assert!(!filter.matches_status(&make_ticket(Status::Todo, TicketType::Task, 5, "T"))); + assert!(!filter.matches_status(&make_ticket(Status::InProgress, TicketType::Task, 5, "T"))); + assert!(!filter.matches_status(&make_ticket(Status::Done, TicketType::Task, 5, "T"))); + assert!(filter.matches_status(&make_ticket(Status::Closed, TicketType::Task, 5, "T"))); } /// `matches_status` ORs multiple status patterns. diff --git a/nbd/src/ticket.rs b/nbd/src/ticket.rs index a3d17fe..0be2712 100644 --- a/nbd/src/ticket.rs +++ b/nbd/src/ticket.rs @@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize}; /// The lifecycle status of a ticket. /// /// Serializes to/from lowercase snake_case strings so that JSON files are -/// human-readable: `"todo"`, `"in_progress"`, `"done"`. +/// human-readable: `"todo"`, `"in_progress"`, `"done"`, `"closed"`. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] #[serde(rename_all = "snake_case")] pub enum Status { @@ -25,6 +25,12 @@ pub enum Status { InProgress, /// The ticket has been completed. Done, + /// The ticket has been archived (soft-deleted). + /// + /// Closed tickets are preserved on disk but excluded from normal listings. + /// Use `nbd archive ` to close a ticket, or pass `--filter status=closed` + /// (or `--all`) to make them visible again. + Closed, } /// The category of a ticket. diff --git a/nbd/tests/integration.rs b/nbd/tests/integration.rs index 7efb2ff..57800e7 100644 --- a/nbd/tests/integration.rs +++ b/nbd/tests/integration.rs @@ -964,6 +964,146 @@ fn list_filter_type_and_status_wildcard_includes_done_bugs() { ); } +// ── nbd archive tests ───────────────────────────────────────────────────────── + +/// `nbd archive ` sets the ticket status to `closed`. +#[test] +fn archive_sets_status_closed() { + let env = TestEnv::new(); + + let id = env.create(&["--title", "Archive me"]); + + let output = env.run(&["archive", &id, "--json"]); + assert!( + output.status.success(), + "archive failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("should be valid JSON"); + assert_eq!( + parsed["status"], "closed", + "archive should set status to closed" + ); +} + +/// `nbd list` does not show closed tickets by default. +#[test] +fn list_excludes_closed_by_default() { + let env = TestEnv::new(); + + let todo_id = env.create(&["--title", "Active ticket"]); + let closed_id = env.create(&["--title", "Closed ticket"]); + env.run(&["archive", &closed_id]); + + let output = env.run(&["list", "--json"]); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let arr = parsed.as_array().unwrap(); + assert_eq!(arr.len(), 1, "only the active ticket should appear"); + assert_eq!(arr[0]["id"], todo_id); +} + +/// `nbd list --all` includes closed tickets. +#[test] +fn list_all_includes_closed() { + let env = TestEnv::new(); + + env.create(&["--title", "Active ticket"]); + let closed_id = env.create(&["--title", "Closed ticket"]); + env.run(&["archive", &closed_id]); + + let output = env.run(&["list", "--all", "--json"]); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let arr = parsed.as_array().unwrap(); + assert_eq!( + arr.len(), + 2, + "--all should show all tickets including closed" + ); +} + +/// `nbd list --filter status=closed` shows only closed tickets. +#[test] +fn list_filter_status_closed_shows_only_closed() { + let env = TestEnv::new(); + + env.create(&["--title", "Active ticket"]); + let closed_id = env.create(&["--title", "Closed ticket"]); + env.run(&["archive", &closed_id]); + + let output = env.run(&["list", "--filter", "status=closed", "--json"]); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let arr = parsed.as_array().unwrap(); + assert_eq!(arr.len(), 1, "only the closed ticket should appear"); + assert_eq!(arr[0]["id"], closed_id); +} + +/// `nbd archive` with a prefix ID resolves and archives the ticket. +#[test] +fn archive_accepts_prefix() { + let env = TestEnv::new(); + + let id = env.create(&["--title", "Prefix archive"]); + let prefix = &id[..3]; + + let output = env.run(&["archive", prefix, "--json"]); + assert!( + output.status.success(), + "archive with prefix failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(parsed["status"], "closed"); +} + +/// A closed dependency unblocks dependent tickets (closed counts as resolved). +#[test] +fn closed_dep_unblocks_dependent() { + let env = TestEnv::new(); + + let dep = env.create(&["--title", "Dep ticket"]); + env.run(&["create", "--title", "Dependent", "--deps", &dep, "--json"]); + + // Archive (close) the dependency. + env.run(&["archive", &dep]); + + // The dependent should now be ready. + let output = env.run(&["ready", "--json"]); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let arr = parsed.as_array().unwrap(); + assert_eq!( + arr.len(), + 1, + "dependent should be ready after dep is closed" + ); + assert_eq!(arr[0]["title"], "Dependent"); +} + +/// `nbd ready` does not include closed tickets. +#[test] +fn ready_excludes_closed_tickets() { + let env = TestEnv::new(); + + let id = env.create(&["--title", "Will be closed"]); + env.run(&["archive", &id]); + + let output = env.run(&["ready", "--json"]); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let arr = parsed.as_array().unwrap(); + assert_eq!(arr.len(), 0, "closed tickets should not appear in ready"); +} + // ── nbd next tests ──────────────────────────────────────────────────────────── /// `nbd next --json` returns the highest-priority ready ticket.