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