feat(nbd): implement TicketFilter module with glob matching [c2a024]

Add src/filter.rs with TicketFilter struct, parse_filters(), and a
hand-rolled glob_matches() function supporting * wildcards. Different
keys are ANDed; same key, multiple values are ORed. Status and type
matching is case-insensitive; priority and title are case-sensitive.

Adds 21 unit tests covering glob edge cases, AND/OR semantics,
is_empty(), and has_status_filter().
quotesdb
Elijah Voigt 3 months ago
parent 8a970a559b
commit e5cb1fb8e2

@ -0,0 +1,231 @@
//! 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",
}
}
/// 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).
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()
}
}
// ── 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)
}

@ -5,6 +5,7 @@
//! [`display`]. //! [`display`].
mod display; mod display;
mod filter;
mod store; mod store;
mod ticket; mod ticket;

@ -578,6 +578,207 @@ mod migrate {
} }
} }
// ── filter module ────────────────────────────────────────────────────────────
/// Tests for [`crate::filter`].
mod filter {
use crate::filter::{glob_matches, parse_filters, TicketFilter};
use crate::ticket::{Status, Ticket, TicketType};
fn make_ticket(status: Status, ticket_type: TicketType, priority: u8, title: &str) -> Ticket {
let mut t = Ticket::new("aabbcc".to_string(), title.to_string());
t.status = status;
t.ticket_type = ticket_type;
t.priority = priority;
t
}
// ── parse_filters ──────────────────────────────────────────────────────
/// `parse_filters` returns an error for a string that contains no `=`.
#[test]
fn parse_filters_rejects_no_equals() {
let args = vec!["statusbad".to_string()];
let result = parse_filters(&args);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("key=value"),
"error should mention expected format, got: {msg}"
);
}
/// `parse_filters` returns an error for an unknown key.
#[test]
fn parse_filters_rejects_unknown_key() {
let args = vec!["colour=red".to_string()];
let result = parse_filters(&args);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("colour"),
"error should mention the unknown key, got: {msg}"
);
}
/// `parse_filters` treats everything after the first `=` as the value.
#[test]
fn parse_filters_value_contains_equals() {
let args = vec!["title=foo=bar".to_string()];
let filter = parse_filters(&args).expect("should succeed");
assert_eq!(filter.title, ["foo=bar"]);
}
/// `parse_filters` correctly populates the `status` and `ticket_type` vecs.
#[test]
fn parse_filters_basic() {
let args = vec!["status=todo".to_string(), "type=bug".to_string()];
let filter = parse_filters(&args).expect("should succeed");
assert_eq!(filter.status, ["todo"]);
assert_eq!(filter.ticket_type, ["bug"]);
assert!(filter.priority.is_empty());
assert!(filter.title.is_empty());
}
// ── glob_matches ──────────────────────────────────────────────────────
/// `*` matches any non-empty string.
#[test]
fn glob_star_matches_anything() {
assert!(glob_matches("*", "anything"));
}
/// `*` matches the empty string.
#[test]
fn glob_star_matches_empty() {
assert!(glob_matches("*", ""));
}
/// An exact pattern matches identical input.
#[test]
fn glob_exact_match() {
assert!(glob_matches("todo", "todo"));
}
/// An exact pattern does not match a different string.
#[test]
fn glob_exact_no_match() {
assert!(!glob_matches("todo", "done"));
}
/// `*command*` matches a string that contains "command".
#[test]
fn glob_contains_match() {
assert!(glob_matches("*command*", "add command here"));
}
/// `*command*` does not match a string that lacks "command".
#[test]
fn glob_contains_no_match() {
assert!(!glob_matches("*command*", "no match"));
}
/// `in_*` matches a string that starts with "in_".
#[test]
fn glob_prefix_match() {
assert!(glob_matches("in_*", "in_progress"));
}
/// `in_*` does not match a string that does not start with "in_".
#[test]
fn glob_prefix_no_match() {
assert!(!glob_matches("in_*", "todo"));
}
// ── TicketFilter::matches ─────────────────────────────────────────────
/// An empty filter matches every ticket.
#[test]
fn empty_filter_matches_everything() {
let filter = TicketFilter::default();
let ticket = make_ticket(Status::Todo, TicketType::Bug, 5, "Any title");
assert!(filter.matches(&ticket));
}
/// Different keys are ANDed: both must match.
#[test]
fn different_keys_are_anded() {
// Filter: type=bug AND status=todo
let args = vec!["type=bug".to_string(), "status=todo".to_string()];
let filter = parse_filters(&args).unwrap();
// Matches: bug + todo
let ticket = make_ticket(Status::Todo, TicketType::Bug, 5, "A bug");
assert!(filter.matches(&ticket));
// Doesn't match: feature + todo (wrong type)
let ticket2 = make_ticket(Status::Todo, TicketType::Feature, 5, "A feature");
assert!(!filter.matches(&ticket2));
// Doesn't match: bug + done (wrong status)
let ticket3 = make_ticket(Status::Done, TicketType::Bug, 5, "Done bug");
assert!(!filter.matches(&ticket3));
}
/// Same key with multiple values are ORed: either match passes.
#[test]
fn same_key_is_ored() {
// Filter: status=todo OR status=in_progress
let args = vec!["status=todo".to_string(), "status=in_progress".to_string()];
let filter = parse_filters(&args).unwrap();
let todo_ticket = make_ticket(Status::Todo, TicketType::Task, 5, "Todo");
let inprogress_ticket = make_ticket(Status::InProgress, TicketType::Task, 5, "In progress");
let done_ticket = make_ticket(Status::Done, TicketType::Task, 5, "Done");
assert!(filter.matches(&todo_ticket));
assert!(filter.matches(&inprogress_ticket));
assert!(!filter.matches(&done_ticket));
}
// ── TicketFilter::is_empty ─────────────────────────────────────────────
/// `is_empty` returns `true` for a default filter.
#[test]
fn is_empty_true_when_no_filters() {
let filter = TicketFilter::default();
assert!(filter.is_empty());
}
/// `is_empty` returns `false` when any filter is set.
#[test]
fn is_empty_false_when_filter_set() {
let mut filter = TicketFilter::default();
filter.status.push("todo".to_string());
assert!(!filter.is_empty());
}
// ── TicketFilter::has_status_filter ───────────────────────────────────
/// `has_status_filter` returns `false` for a default filter.
#[test]
fn has_status_filter_false_by_default() {
let filter = TicketFilter::default();
assert!(!filter.has_status_filter());
}
/// `has_status_filter` returns `true` when a status pattern is present.
#[test]
fn has_status_filter_true_when_status_set() {
let args = vec!["status=todo".to_string()];
let filter = parse_filters(&args).unwrap();
assert!(filter.has_status_filter());
}
/// `has_status_filter` returns `false` when only non-status filters are set.
#[test]
fn has_status_filter_false_when_only_type_set() {
let args = vec!["type=bug".to_string()];
let filter = parse_filters(&args).unwrap();
assert!(!filter.has_status_filter());
}
}
// ── display module ──────────────────────────────────────────────────────────── // ── display module ────────────────────────────────────────────────────────────
/// Tests for [`crate::display`]. /// Tests for [`crate::display`].

Loading…
Cancel
Save