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.

5.2 KiB

+++ title = "Implement TicketFilter module with glob matching" priority = 8 status = "done" ticket_type = "feature" dependencies = [] +++

Summary

Add a src/filter.rs module implementing TicketFilter, which parses and applies key=value filter expressions against a list of tickets. This is the foundational building block for the --filter flag on list, ready, migrate, and next commands.

No external crate is needed — glob matching is implemented with a simple hand-rolled algorithm that handles * wildcards.

Filter semantics

  • --filter key=value can be specified multiple times.
  • Keys: priority, type, status, title.
  • Different keys are ANDed: --filter type=bug --filter status=todo matches tickets that are BOTH bugs AND todo.
  • Same key, multiple values are ORed: --filter status=todo --filter status=in_progress matches tickets with EITHER status.
  • Values support * as a wildcard anywhere in the pattern:
    • status=* matches any status
    • title=*command* matches any title containing "command"
    • priority=7 matches exactly priority 7 (as string comparison)

Data structures

/// 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).
pub struct TicketFilter {
    /// Glob patterns for `status` (OR within this group).
    pub status: Vec<String>,
    /// Glob patterns for `ticket_type` (OR within this group).
    pub ticket_type: Vec<String>,
    /// Glob patterns for `priority` (OR within this group; matched against string repr).
    pub priority: Vec<String>,
    /// Glob patterns for `title` (OR within this group).
    pub title: Vec<String>,
}

An empty Vec for a key means "no filter on that key" — matches everything.

API

/// Parse a slice of "key=value" strings into a TicketFilter.
///
/// The key `type` maps to the `ticket_type` field.
/// Returns an error for unknown keys or malformed expressions (no '=').
pub fn parse_filters(args: &[String]) -> crate::store::Result<TicketFilter>

impl TicketFilter {
    /// Returns true when all non-empty filter groups match this ticket.
    /// An empty TicketFilter always returns true.
    pub fn matches(&self, ticket: &crate::ticket::Ticket) -> bool;

    /// Returns true when no filter groups are set (no-op filter).
    pub fn is_empty(&self) -> bool;

    /// Returns true when the user has provided at least one status pattern.
    /// Used by cmd_list to detect whether to apply the implicit done-exclusion.
    pub fn has_status_filter(&self) -> bool;
}

Glob algorithm (no external crate)

Implement a private fn glob_matches(pattern: &str, value: &str) -> bool:

  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 segments[0].
  4. If pattern does NOT end with *, value must end with segments[last].
  5. For each remaining segment, find it in the remaining suffix of value (left to right). Advance past the match and continue. If any segment is not found, return false.

This handles: *, foo, foo*, *foo, *foo*, foo*bar, *foo*bar*.

Case sensitivity:

  • status and type patterns: case-insensitive (compare lowercase).
  • title and priority patterns: case-sensitive.

matches() logic

fn matches(&self, ticket) -> bool {
    (self.status.is_empty()       || self.status.iter().any(|p| glob_matches(p, status_str(&ticket.status))))
    && (self.ticket_type.is_empty() || self.ticket_type.iter().any(|p| glob_matches(p, ticket_type_str(&ticket.ticket_type))))
    && (self.priority.is_empty()    || self.priority.iter().any(|p| glob_matches(p, &ticket.priority.to_string())))
    && (self.title.is_empty()       || self.title.iter().any(|p| glob_matches(p, &ticket.title)))
}

Use status_str and ticket_type_str equivalents (can be private functions in filter.rs or call into display, or duplicate the small match arms).

Module registration

Add mod filter; to src/main.rs (or move to lib.rs if the project ever gains one). The module is pub(crate).

Files touched

  • src/filter.rs — new module
  • src/main.rs — add mod filter;
  • src/tests.rs — unit tests

Unit tests to write (src/tests.rs)

  • parse_filters rejects unknown keys with a descriptive error.
  • parse_filters rejects a string with no =.
  • parse_filters with key=value=more treats everything after first = as the value.
  • glob_matches("*", "anything") → true.
  • glob_matches("*", "") → true.
  • glob_matches("todo", "todo") → true; glob_matches("todo", "done") → false.
  • glob_matches("*command*", "add command here") → true.
  • glob_matches("*command*", "no match") → false.
  • glob_matches("in_*", "in_progress") → true.
  • glob_matches("in_*", "todo") → false.
  • TicketFilter::matches — two different keys AND correctly (both must match).
  • TicketFilter::matches — same key OR correctly (either matches).
  • TicketFilter::matches — empty filter matches everything.
  • TicketFilter::is_empty — true when no filters, false when any filter set.
  • TicketFilter::has_status_filter — true iff status vec is non-empty.