diff --git a/nbd/src/filter.rs b/nbd/src/filter.rs new file mode 100644 index 0000000..7032bca --- /dev/null +++ b/nbd/src/filter.rs @@ -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, + /// Glob patterns matched against the ticket's `ticket_type` field (OR group). + /// Matching is case-insensitive. + pub ticket_type: Vec, + /// Glob patterns matched against the string representation of `priority` + /// (OR group). Matching is case-sensitive. + pub priority: Vec, + /// Glob patterns matched against the ticket's `title` field (OR group). + /// Matching is case-sensitive. + pub title: Vec, +} + +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 { + 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) +} diff --git a/nbd/src/main.rs b/nbd/src/main.rs index 8cc1c5e..dafd651 100644 --- a/nbd/src/main.rs +++ b/nbd/src/main.rs @@ -5,6 +5,7 @@ //! [`display`]. mod display; +mod filter; mod store; mod ticket; diff --git a/nbd/src/tests.rs b/nbd/src/tests.rs index e6cc624..a962cb9 100644 --- a/nbd/src/tests.rs +++ b/nbd/src/tests.rs @@ -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 ──────────────────────────────────────────────────────────── /// Tests for [`crate::display`].