From d6d2cd91d585c26d1a1769e2a91b4729dc933ecd Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Sun, 22 Feb 2026 15:15:52 -0800 Subject: [PATCH] feat(nbd): exclude done tickets from nbd list by default [92e45b] nbd list now hides done tickets unless the caller explicitly provides a status filter. Pass --filter status=* to see all tickets or --filter status=done to see only completed ones. - Add matches_status and matches_except_status to TicketFilter - Update cmd_list to apply implicit done-exclusion when no status filter given - Update List command help text - Add unit tests for new filter methods - Add integration tests for the new default behaviour - Update README usage section --- nbd/README.md | 7 ++- nbd/src/filter.rs | 33 +++++++++++++- nbd/src/main.rs | 25 +++++++++-- nbd/src/tests.rs | 75 +++++++++++++++++++++++++++++++ nbd/tests/integration.rs | 97 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 232 insertions(+), 5 deletions(-) diff --git a/nbd/README.md b/nbd/README.md index 7bb868a..b5f8b25 100644 --- a/nbd/README.md +++ b/nbd/README.md @@ -51,8 +51,13 @@ nbd read a3f9c2 --json ### List all tickets +By default, `done` tickets are excluded. + ```sh -nbd list +nbd list # todo + in_progress only (done excluded) +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 ``` diff --git a/nbd/src/filter.rs b/nbd/src/filter.rs index d46f6b6..42baee6 100644 --- a/nbd/src/filter.rs +++ b/nbd/src/filter.rs @@ -180,10 +180,41 @@ impl TicketFilter { /// /// Used by `cmd_list` to detect whether to apply the implicit /// done-exclusion heuristic. - #[allow(dead_code)] 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 ─────────────────────────────────────────────────────────────── diff --git a/nbd/src/main.rs b/nbd/src/main.rs index db036ed..a16e041 100644 --- a/nbd/src/main.rs +++ b/nbd/src/main.rs @@ -74,13 +74,20 @@ enum Commands { id: String, }, - /// List all tickets sorted by priority (highest first). + /// 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 { /// Filter tickets by field: repeatable `key=value` pairs. /// /// Keys: `status`, `type`, `priority`, `title`. /// Different keys are ANDed; the same key with multiple values is ORed. /// Values support glob wildcards: `title=*login*`. + /// + /// If no `status` filter is provided, tickets with status `done` are + /// excluded automatically. Provide `--filter status=*` to override. #[arg(long = "filter", value_name = "KEY=VALUE")] filter: Vec, }, @@ -429,16 +436,28 @@ async fn cmd_read(id: String, json: bool) -> store::Result<()> { Ok(()) } -/// List all tickets sorted by priority and print them. +/// List tickets sorted by priority and print them. /// /// `filter_args` are optional `key=value` expressions that narrow the output. +/// +/// **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<()> { let filter = crate::filter::parse_filters(&filter_args)?; let root = find_nbd_root()?; let tickets: Vec = list_tickets(&root) .await? .into_iter() - .filter(|t| filter.matches(t)) + .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) + } else { + t.status != Status::Done + }; + status_ok && filter.matches_except_status(t) + }) .collect(); if json { diff --git a/nbd/src/tests.rs b/nbd/src/tests.rs index e0c288d..e1a2461 100644 --- a/nbd/src/tests.rs +++ b/nbd/src/tests.rs @@ -790,6 +790,81 @@ mod filter { let filter = parse_filters(&args).unwrap(); assert!(!filter.has_status_filter()); } + + // ── TicketFilter::matches_status ────────────────────────────────────── + + /// `matches_status` returns `true` when the ticket's status matches a pattern. + #[test] + fn matches_status_matches_exact() { + let args = vec!["status=todo".to_string()]; + let filter = parse_filters(&args).unwrap(); + let ticket = make_ticket(Status::Todo, TicketType::Task, 5, "A ticket"); + assert!(filter.matches_status(&ticket)); + } + + /// `matches_status` returns `false` when the ticket's status does not match. + #[test] + fn matches_status_no_match() { + let args = vec!["status=done".to_string()]; + let filter = parse_filters(&args).unwrap(); + let ticket = make_ticket(Status::Todo, TicketType::Task, 5, "A ticket"); + assert!(!filter.matches_status(&ticket)); + } + + /// `matches_status` with `status=*` matches any status. + #[test] + fn matches_status_wildcard_matches_all() { + let args = vec!["status=*".to_string()]; + let filter = parse_filters(&args).unwrap(); + assert!(filter.matches_status(&make_ticket(Status::Todo, TicketType::Task, 5, "T"))); + assert!(filter.matches_status(&make_ticket(Status::InProgress, TicketType::Task, 5, "T"))); + assert!(filter.matches_status(&make_ticket(Status::Done, TicketType::Task, 5, "T"))); + } + + /// `matches_status` ORs multiple status patterns. + #[test] + fn matches_status_ored_patterns() { + let args = vec!["status=todo".to_string(), "status=in_progress".to_string()]; + let filter = parse_filters(&args).unwrap(); + assert!(filter.matches_status(&make_ticket(Status::Todo, TicketType::Task, 5, "T"))); + assert!(filter.matches_status(&make_ticket(Status::InProgress, TicketType::Task, 5, "T"))); + assert!(!filter.matches_status(&make_ticket(Status::Done, TicketType::Task, 5, "T"))); + } + + // ── TicketFilter::matches_except_status ─────────────────────────────── + + /// `matches_except_status` with an empty filter always returns `true`. + #[test] + fn matches_except_status_empty_filter_always_true() { + let filter = TicketFilter::default(); + let ticket = make_ticket(Status::Done, TicketType::Bug, 5, "Any"); + assert!(filter.matches_except_status(&ticket)); + } + + /// `matches_except_status` matches on type while ignoring status. + #[test] + fn matches_except_status_checks_type_not_status() { + let args = vec!["type=bug".to_string()]; + let filter = parse_filters(&args).unwrap(); + + // Done bug: type matches even though status is done. + let done_bug = make_ticket(Status::Done, TicketType::Bug, 5, "Bug"); + assert!(filter.matches_except_status(&done_bug)); + + // Todo task: type does not match. + let todo_task = make_ticket(Status::Todo, TicketType::Task, 5, "Task"); + assert!(!filter.matches_except_status(&todo_task)); + } + + /// `matches_except_status` a status-only filter always returns `true`. + #[test] + fn matches_except_status_status_only_filter_returns_true() { + let args = vec!["status=done".to_string()]; + let filter = parse_filters(&args).unwrap(); + // matches_except_status ignores the status group. + let todo_ticket = make_ticket(Status::Todo, TicketType::Task, 5, "T"); + assert!(filter.matches_except_status(&todo_ticket)); + } } // ── display module ──────────────────────────────────────────────────────────── diff --git a/nbd/tests/integration.rs b/nbd/tests/integration.rs index cec9a2f..c36e54c 100644 --- a/nbd/tests/integration.rs +++ b/nbd/tests/integration.rs @@ -866,3 +866,100 @@ fn ready_filter_bad_format_exits_nonzero() { "bad filter format on ready should exit non-zero" ); } + +// ── done-exclusion default behaviour ────────────────────────────────────────── + +/// `nbd list` without any filter excludes done tickets. +#[test] +fn list_excludes_done_by_default() { + let env = TestEnv::new(); + + let todo_id = env.create(&["--title", "Todo ticket"]); + let done_id = env.create(&["--title", "Done ticket"]); + env.run(&["update", &done_id, "--status", "done"]); + + let output = env.run(&["list", "--json"]); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let arr = parsed.as_array().unwrap(); + assert_eq!(arr.len(), 1, "only the todo ticket should appear"); + assert_eq!(arr[0]["id"], todo_id); +} + +/// `nbd list --filter status=done` shows only done tickets. +#[test] +fn list_filter_status_done_shows_only_done() { + let env = TestEnv::new(); + + env.create(&["--title", "Todo ticket"]); + let done_id = env.create(&["--title", "Done ticket"]); + env.run(&["update", &done_id, "--status", "done"]); + + let output = env.run(&["list", "--filter", "status=done", "--json"]); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let arr = parsed.as_array().unwrap(); + assert_eq!(arr.len(), 1, "only the done ticket should appear"); + assert_eq!(arr[0]["id"], done_id); +} + +/// `nbd list --filter status=*` includes done tickets. +#[test] +fn list_filter_status_wildcard_includes_done() { + let env = TestEnv::new(); + + env.create(&["--title", "Todo ticket"]); + let done_id = env.create(&["--title", "Done ticket"]); + env.run(&["update", &done_id, "--status", "done"]); + + let output = env.run(&["list", "--filter", "status=*", "--json"]); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let arr = parsed.as_array().unwrap(); + assert_eq!(arr.len(), 2, "both tickets should appear with status=*"); +} + +/// `nbd list --filter type=bug` excludes done bug tickets. +#[test] +fn list_filter_type_still_excludes_done() { + let env = TestEnv::new(); + + env.create(&["--title", "Bug todo", "--type", "bug"]); + let done_bug = env.create(&["--title", "Bug done", "--type", "bug"]); + env.create(&["--title", "Task todo", "--type", "task"]); + env.run(&["update", &done_bug, "--status", "done"]); + + let output = env.run(&["list", "--filter", "type=bug", "--json"]); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let arr = parsed.as_array().unwrap(); + assert_eq!(arr.len(), 1, "only non-done bug ticket should appear"); + assert_eq!(arr[0]["title"], "Bug todo"); +} + +/// `nbd list --filter type=bug --filter status=*` includes all bug tickets. +#[test] +fn list_filter_type_and_status_wildcard_includes_done_bugs() { + let env = TestEnv::new(); + + env.create(&["--title", "Bug todo", "--type", "bug"]); + let done_bug = env.create(&["--title", "Bug done", "--type", "bug"]); + env.run(&["update", &done_bug, "--status", "done"]); + + let output = env.run(&[ + "list", "--filter", "type=bug", "--filter", "status=*", "--json", + ]); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let arr = parsed.as_array().unwrap(); + assert_eq!( + arr.len(), + 2, + "both bug tickets should appear when status=* is set" + ); +}