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.

132 lines
5.2 KiB
Markdown

+++
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
```rust
/// 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
```rust
/// 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.