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
quotesdb
Elijah Voigt 3 months ago
parent dbab0f466c
commit d6d2cd91d5

@ -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
```

@ -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 ───────────────────────────────────────────────────────────────

@ -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<String>,
},
@ -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<String>, json: bool) -> store::Result<()> {
let filter = crate::filter::parse_filters(&filter_args)?;
let root = find_nbd_root()?;
let tickets: Vec<Ticket> = 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 {

@ -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 ────────────────────────────────────────────────────────────

@ -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"
);
}

Loading…
Cancel
Save