You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

268 lines
9.9 KiB
Rust

//! 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<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).
#[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<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)
}