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
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)
|
||||
}
|
||||
Loading…
Reference in New Issue