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
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. |