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.
8 lines
5.4 KiB
JSON
8 lines
5.4 KiB
JSON
{
|
|
"title": "Implement TicketFilter module with glob matching",
|
|
"body": "## Summary\n\nAdd a `src/filter.rs` module implementing `TicketFilter`, which parses and applies\n`key=value` filter expressions against a list of tickets. This is the foundational\nbuilding block for the `--filter` flag on `list`, `ready`, `migrate`, and `next` commands.\n\nNo external crate is needed — glob matching is implemented with a simple hand-rolled\nalgorithm that handles `*` wildcards.\n\n## Filter semantics\n\n- `--filter key=value` can be specified multiple times.\n- Keys: `priority`, `type`, `status`, `title`.\n- **Different keys are ANDed**: `--filter type=bug --filter status=todo` matches tickets\n that are BOTH bugs AND todo.\n- **Same key, multiple values are ORed**: `--filter status=todo --filter status=in_progress`\n matches tickets with EITHER status.\n- Values support `*` as a wildcard anywhere in the pattern:\n - `status=*` matches any status\n - `title=*command*` matches any title containing \"command\"\n - `priority=7` matches exactly priority 7 (as string comparison)\n\n## Data structures\n\n```rust\n/// A compiled set of filter expressions applied to tickets.\n///\n/// Within the same key, patterns are ORed (any match passes the group).\n/// Across different keys, groups are ANDed (all non-empty groups must pass).\npub struct TicketFilter {\n /// Glob patterns for `status` (OR within this group).\n pub status: Vec<String>,\n /// Glob patterns for `ticket_type` (OR within this group).\n pub ticket_type: Vec<String>,\n /// Glob patterns for `priority` (OR within this group; matched against string repr).\n pub priority: Vec<String>,\n /// Glob patterns for `title` (OR within this group).\n pub title: Vec<String>,\n}\n```\n\nAn empty `Vec` for a key means \"no filter on that key\" — matches everything.\n\n## API\n\n```rust\n/// Parse a slice of \"key=value\" strings into a TicketFilter.\n///\n/// The key `type` maps to the `ticket_type` field.\n/// Returns an error for unknown keys or malformed expressions (no '=').\npub fn parse_filters(args: &[String]) -> crate::store::Result<TicketFilter>\n\nimpl TicketFilter {\n /// Returns true when all non-empty filter groups match this ticket.\n /// An empty TicketFilter always returns true.\n pub fn matches(&self, ticket: &crate::ticket::Ticket) -> bool;\n\n /// Returns true when no filter groups are set (no-op filter).\n pub fn is_empty(&self) -> bool;\n\n /// Returns true when the user has provided at least one status pattern.\n /// Used by cmd_list to detect whether to apply the implicit done-exclusion.\n pub fn has_status_filter(&self) -> bool;\n}\n```\n\n## Glob algorithm (no external crate)\n\nImplement a private `fn glob_matches(pattern: &str, value: &str) -> bool`:\n\n1. If pattern contains no `*`, require exact equality.\n2. Split pattern on `*` into segments.\n3. If pattern does NOT start with `*`, value must start with `segments[0]`.\n4. If pattern does NOT end with `*`, value must end with `segments[last]`.\n5. For each remaining segment, find it in the remaining suffix of value (left to right).\n Advance past the match and continue. If any segment is not found, return false.\n\nThis handles: `*`, `foo`, `foo*`, `*foo`, `*foo*`, `foo*bar`, `*foo*bar*`.\n\nCase sensitivity:\n- `status` and `type` patterns: case-insensitive (compare lowercase).\n- `title` and `priority` patterns: case-sensitive.\n\n## matches() logic\n\n```\nfn matches(&self, ticket) -> bool {\n (self.status.is_empty() || self.status.iter().any(|p| glob_matches(p, status_str(&ticket.status))))\n && (self.ticket_type.is_empty() || self.ticket_type.iter().any(|p| glob_matches(p, ticket_type_str(&ticket.ticket_type))))\n && (self.priority.is_empty() || self.priority.iter().any(|p| glob_matches(p, &ticket.priority.to_string())))\n && (self.title.is_empty() || self.title.iter().any(|p| glob_matches(p, &ticket.title)))\n}\n```\n\nUse `status_str` and `ticket_type_str` equivalents (can be private functions in filter.rs\nor call into display, or duplicate the small match arms).\n\n## Module registration\n\nAdd `mod filter;` to `src/main.rs` (or move to `lib.rs` if the project ever gains one).\nThe module is `pub(crate)`.\n\n## Files touched\n\n- `src/filter.rs` — new module\n- `src/main.rs` — add `mod filter;`\n- `src/tests.rs` — unit tests\n\n## Unit tests to write (src/tests.rs)\n\n- `parse_filters` rejects unknown keys with a descriptive error.\n- `parse_filters` rejects a string with no `=`.\n- `parse_filters` with `key=value=more` treats everything after first `=` as the value.\n- `glob_matches(\"*\", \"anything\")` → true.\n- `glob_matches(\"*\", \"\")` → true.\n- `glob_matches(\"todo\", \"todo\")` → true; `glob_matches(\"todo\", \"done\")` → false.\n- `glob_matches(\"*command*\", \"add command here\")` → true.\n- `glob_matches(\"*command*\", \"no match\")` → false.\n- `glob_matches(\"in_*\", \"in_progress\")` → true.\n- `glob_matches(\"in_*\", \"todo\")` → false.\n- `TicketFilter::matches` — two different keys AND correctly (both must match).\n- `TicketFilter::matches` — same key OR correctly (either matches).\n- `TicketFilter::matches` — empty filter matches everything.\n- `TicketFilter::is_empty` — true when no filters, false when any filter set.\n- `TicketFilter::has_status_filter` — true iff status vec is non-empty.",
|
|
"priority": 8,
|
|
"status": "done",
|
|
"dependencies": [],
|
|
"ticket_type": "feature"
|
|
} |