tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
#[allow(clippy::wildcard_imports)]
use super::*;

/// Apply the query grammar to a haystack.
///
/// Bare terms (anything that is not a `key:value` token or the bare `overdue`
/// keyword) must appear in the haystack as a **case-insensitive substring** —
/// the same literal rule [`tix search`](crate::commands::search) uses over the
/// same corpus, so the board's `/` search and the CLI agree on what "matches".
///
/// This replaced the old subsequence (`fuzzy_score`) rule for bare terms (tix
/// #1498): a subsequence is far too permissive over a whole issue file, where
/// `greedy` is a subsequence of nearly every issue because `g`, `r`, `e`, `e`,
/// `d`, `y` all occur in order somewhere. Substring matching keeps a query
/// about a word about that word. `fuzzy_score` remains the help overlay's
/// fallback, where the rows are short and a literal miss is worth a second pass.
pub fn query_matches(query: &str, haystack: &str) -> bool {
    let mut bare_terms = Vec::new();
    let hay = haystack.to_lowercase();
    for token in query.split_whitespace() {
        if token.eq_ignore_ascii_case("overdue") {
            if !is_overdue_haystack(haystack) {
                return false;
            }
            continue;
        }
        if let Some((key, value)) = parse_token(token) {
            let value = value.to_lowercase();
            let ok = match key.to_lowercase().as_str() {
                // A **pre-existing defect, found while adding `category`** (WP08).
                //
                // `StructuredFilter` has carried `assignee`, `label`, `status` and
                // `priority` since the filter grammar landed, and `evaluate` matches
                // all four — so the TUI's filter modal has always filtered on them.
                // The **token** grammar had no arm for any of them, and the `_ => false`
                // below turned each into a filter that matched nothing:
                //
                //     tix list --filter status:shipped    # → empty table, exit 0
                //     tix list --filter assignee:alice     # → empty table, exit 0
                //
                // Silent, total, and on the four most obvious criteria. A user typing
                // the documented `--filter` had no way to tell a filter from a typo, and
                // the empty table is indistinguishable from a correct filter with no
                // matches.
                //
                // Substring, and lowercased, because that is exactly what `evaluate`
                // does for these four — the module's stated rule, and the one
                // `StructuredFilter`'s own tests pin. The two grammars now agree
                // criterion for criterion instead of the token grammar silently
                // supporting less than the modal does.
                //
                // `label` matches the rendered `labels: [a, b]` list by substring,
                // which is also `evaluate`'s rule, and the one `contracts/filtering.md`
                // §3's stricter "contains-in-labels" spelling would need a re-parse to
                // improve on. Not changed here: one behaviour, two grammars.
                "assignee" | "label" | "status" | "priority" | "cycle" | "milestone"
                | "reviewer" => hay.contains(&value),
                "start" | "due" => hay.contains(&value),
                // Exact, for the same reason `evaluate` is: a number must not
                // match a longer number that contains it. The value here is already
                // a **resolved** number — see `resolve_parent_criterion`.
                "parent" => field_value(haystack, "parent") == Some(value.as_str()),
                // A **category key**, not a field value: an issue's file contains no
                // `category` line, so a substring test here would match the word
                // "category" wherever it appears in a title or body, and would
                // match `completed` against any issue that happens to use the word.
                // This arm exists only to make the refusal **explicit and total**
                // rather than accidental: a store-free matcher cannot judge it, and
                // [`matches_issue`] is the entry point that does, with the
                // criterion carried in the [`StructuredFilter`] that
                // [`resolve_filter_criteria`] fills. Reaching this arm with a real
                // `category:` token therefore means a caller skipped one of those
                // two — and returning `false` for every issue turns that mistake into
                // an empty result a user can see, instead of an unfiltered result
                // they cannot.
                "category" => false,
                // The `scope:` twin of the `category` refusal above, and the
                // same contract: the columns a scope selects are a config fact
                // this store-free matcher does not have, and
                // [`resolve_filter_criteria`] is what lifts the token out before
                // it ever gets here. Reaching this arm with a real `scope:` token
                // means a caller skipped that step — see [`matches_issue`] — and
                // returning `false` for every issue turns the mistake into an
                // empty result a user can see instead of an unfiltered one they
                // cannot. (WP02 T010.)
                "scope" => false,
                // The `archived:` twin of the `category`/`scope` refusals above
                // (issue #1492): the archived flag lives on the filename, not in
                // the bytes, so this store-free matcher cannot judge it and
                // `resolve_filter_criteria` lifts the token into the structured
                // filter instead. A caller that skipped that step gets an empty
                // result rather than an unfiltered one.
                "archived" => false,
                // The `project:` twin of the refusals above (WP06 T026/T027). The
                // association list is a YAML **block** in the frontmatter, so this
                // store-free matcher has no scalar to compare: a substring test
                // would match an id the body merely mentions, and `p-1` against
                // `p-12`. The criterion travels in the [`StructuredFilter`] that
                // [`resolve_filter_criteria`] fills, and is judged by
                // [`StructuredFilter::project_matches`], which has the issue.
                // Reaching this arm with a real `project:` token means a caller
                // skipped that step, and returning `false` turns the mistake into
                // an empty result a user can see rather than an unfiltered one they
                // cannot.
                "project" => false,
                // The `epic:` twin of the `project:` refusal above (WP06 T029). The
                // reference list is a YAML **block** in the frontmatter, so this
                // store-free matcher has no scalar to compare: a substring test would
                // match an id the body merely mentions, and `e-1` against `e-12`. The
                // criterion travels in the [`StructuredFilter`] that
                // [`resolve_filter_criteria`] fills, and is judged by
                // [`StructuredFilter::epic_matches`], which has the issue. Reaching
                // this arm with a real `epic:` token means a caller skipped that step,
                // and returning `false` turns the mistake into an empty result a user
                // can see rather than an unfiltered one they cannot.
                "epic" => false,
                "sort" => matches!(
                    value.as_str(),
                    "created"
                        | "priority"
                        | "status"
                        | "cycle"
                        | "milestone"
                        | "due"
                        | "start"
                        | "reviewer"
                        // WP06 T028: sorting by project is what makes the project
                        // partition contiguous and in declaration order. A control
                        // token this grammar did not list would veto **every** issue
                        // and the board would render empty rather than sorted.
                        //
                        // WP09 INTEGRATION ITEM: the token grammar accepting a key
                        // is not the same as a consumer implementing it. Only the
                        // TUI board honours `project` here. `src/commands/list.rs`
                        // (its sort table, and the group whitelist at
                        // src/commands/list.rs:557) and `src/commands/status.rs`
                        // (its group whitelist at src/commands/status.rs:171) still
                        // filter on `cycle | milestone` and ignore `project`
                        // silently, where an unknown token used to be refused by
                        // this arm. Both are WP04's files and are not edited here.
                        | "project"
                        // WP06 T029: the epic partition's sort key, the exact
                        // analogue of `project` above.
                        | "epic"
                ),
                // The same WP09 integration item as the `sort` arm above:
                // `src/commands/list.rs:557` and `src/commands/status.rs:171`
                // whitelist the group key to `cycle | milestone`.
                "group" => matches!(value.as_str(), "cycle" | "milestone" | "project" | "epic"),
                "overdue" if value == "true" => is_overdue_haystack(haystack),
                _ => false,
            };
            if !ok {
                return false;
            }
        } else {
            bare_terms.push(token);
        }
    }
    let bare = bare_terms.join(" ").to_lowercase();
    bare.is_empty() || hay.contains(&bare)
}

pub(super) fn field_value<'a>(haystack: &'a str, key: &str) -> Option<&'a str> {
    for line in haystack.lines() {
        let prefix = format!("{key}:");
        if let Some(rest) = line.strip_prefix(&prefix) {
            return Some(rest.trim().trim_matches('"'));
        }
    }
    None
}

pub(super) fn is_overdue_haystack(haystack: &str) -> bool {
    let status = field_value(haystack, "status").unwrap_or_default();
    let due = field_value(haystack, "due_date").unwrap_or_default();
    !due.is_empty() && due < crate::model::today().as_str() && status != "done"
}

/// Classify a bare-number `/` query: `Some(n)` iff `raw` trims to a
/// non-empty run of ASCII digits that fits `u32`. Any other input — empty,
/// whitespace-only, non-digit characters, mixed (`55bug`), or overflowing
/// `u32` — returns `None`, signalling "not a number query" so the caller
/// falls back to fuzzy-text behavior (board-filtering FR-02). Leading zeros
/// parse normally (`"055"` → `55`); `"0"` is a valid (if typically
/// matchless) number query.
pub fn number_query(raw: &str) -> Option<u32> {
    let trimmed = raw.trim();
    if trimmed.is_empty() || !trimmed.bytes().all(|b| b.is_ascii_digit()) {
        return None;
    }
    trimmed.parse::<u32>().ok()
}