//! 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", Status::Closed => "closed", Status::Archived => "archived", Status::Backlog => "backlog", } } /// 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). #[allow(dead_code)] 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() } /// 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 ─────────────────────────────────────────────────────────────── /// 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) }