Skip to main content

tix/
search.rs

1//! Shared search corpus and matchers for `tix search` (CLI, WP04) and the
2//! `/` fuzzy filter (TUI, WP05).
3//!
4//! Both consumers search **exactly the same corpus**: the complete serialized
5//! markdown of every issue (`Issue::to_file_contents()`, i.e. frontmatter +
6//! body + comments). A single engine here keeps the CLI and TUI behavior from
7//! drifting (C-005, FR-012):
8//!
9//! - [`Corpus::build`] snapshots every issue's full file text.
10//! - [`search_substring`] is the case-insensitive single-occurrence matcher
11//!   the CLI uses, returning matched-line context.
12//! - [`fuzzy_score`] is the deterministic subsequence scorer the TUI uses to
13//!   rank filter results on each keystroke.
14//!
15//! Nothing here touches the `-assets/` directories (C-005): binary contents
16//! are never indexed, and [`Corpus::build`] inherits that from
17//! [`Store::list`], which already excludes non-`.md` files and `-assets` dirs.
18
19use serde::{Deserialize, Serialize};
20
21use crate::error::Result;
22use crate::model::Issue;
23use crate::storage::Store;
24
25/// The corpus: one `Issue` per file (in [`Store::list`] order) and the
26/// byte-for-byte serialized file text matching it.
27pub struct Corpus {
28    pub issues: Vec<Issue>,
29    pub texts: Vec<String>,
30}
31
32impl Corpus {
33    /// Snapshot the full serialized text of every issue under `store`.
34    ///
35    /// `texts[i]` is exactly `issues[i].to_file_contents()`, so the corpus is
36    /// byte-identical to the on-disk files (modulo whatever the serializer
37    /// itself produces). Files are read once here, through [`Store::list`];
38    /// consumers never re-read them (C-005/NFR-007 keep the corpus a single,
39    /// stable source).
40    pub fn build(store: &Store) -> Result<Corpus> {
41        Self::build_with(store, false)
42    }
43
44    /// Snapshot the corpus, optionally including archived issues (issue #1492).
45    ///
46    /// `with_archived == false` is the default path and reads
47    /// [`Store::list`], which never opens an archived file. `true` reads
48    /// [`Store::list_all`] so `tix search --with-archived` can match the archived
49    /// set; the archived issues carry `archived == true`.
50    pub fn build_with(store: &Store, with_archived: bool) -> Result<Corpus> {
51        let issues = if with_archived {
52            store.list_all()?
53        } else {
54            store.list()?
55        };
56        let texts = issues
57            .iter()
58            .map(|issue| issue.to_file_contents())
59            .collect();
60        Ok(Corpus { issues, texts })
61    }
62
63    /// Index of the issue with `number` `n`, if present (stable, linear scan).
64    pub fn by_number(&self, n: u32) -> Option<usize> {
65        self.issues.iter().position(|issue| issue.number == n)
66    }
67}
68
69/// One substring search hit: the issue and every corpus line that contains
70/// the matched query (context for the CLI result block, NFR-007).
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct SearchHit {
73    pub issue: Issue,
74    pub matched_lines: Vec<String>,
75}
76
77/// Case-insensitive substring search over the corpus.
78///
79/// For each corpus text containing `query` (case-insensitive), every line
80/// containing the match is collected verbatim as matched-line context. Hits
81/// are ordered by issue `number` ascending (stable, NFR-007).
82///
83/// An empty query matches **nothing** by contract: the CLI refuses it via
84/// clap, and the TUI treats an empty filter as "no filter" — callers decide,
85/// this function returns an empty vec so they never see a surprise.
86pub fn search_substring(corpus: &Corpus, query: &str) -> Vec<SearchHit> {
87    if query.is_empty() {
88        return Vec::new();
89    }
90    let needle = query.to_lowercase();
91    let mut hits: Vec<SearchHit> = Vec::new();
92    for (issue, text) in corpus.issues.iter().zip(corpus.texts.iter()) {
93        let hay = text.to_lowercase();
94        if !hay.contains(&needle) {
95            continue;
96        }
97        let matched_lines: Vec<String> = text
98            .lines()
99            .filter(|line| line.to_lowercase().contains(&needle))
100            .map(str::to_string)
101            .collect();
102        hits.push(SearchHit {
103            issue: issue.clone(),
104            matched_lines,
105        });
106    }
107    hits.sort_by_key(|hit| hit.issue.number);
108    hits
109}
110
111/// Deterministic fuzzy subsequence score: higher is a better match, `None`
112/// when `query` is not a subsequence of `haystack` (case-insensitive).
113///
114/// The scorer is the fzf/vim-style "fast substring" heuristic, kept simple
115/// and pure (no normalization, no unicode tables, no dependencies):
116///
117/// - every matched character contributes a presence bonus plus an
118///   earlier-position bonus (`1000 - position`), so matches near the start of
119///   the text outrank those buried deep in it;
120/// - adjacent matched characters (contiguous runs) get a run bonus;
121/// - a match that starts at the very beginning gets a prefix bonus;
122/// - an exact match (equal strings, case-insensitive) dominates.
123///
124/// An empty query is an implicit match with score `Some(0)` — the TUI treats
125/// it as "no filter". Deterministic and fast enough for > 500 issues per
126/// keystroke (verified by the perf tripwire test).
127pub fn fuzzy_score(query: &str, haystack: &str) -> Option<u32> {
128    let q = query.to_lowercase();
129    let h = haystack.to_lowercase();
130    if q.is_empty() {
131        return Some(0);
132    }
133    if q == h {
134        return Some(EXACT_MATCH_SCORE);
135    }
136    let positions = greedy_subsequence_positions(&q, &h)?;
137    let mut score = 0u32;
138    let mut prev: Option<usize> = None;
139    for pos in &positions {
140        score += 100;
141        score += (1000 - (*pos).min(1000)) as u32;
142        if prev.is_some_and(|p| *pos == p + 1) {
143            score += RUN_BONUS;
144        }
145        prev = Some(*pos);
146    }
147    if positions.first() == Some(&0) {
148        score += PREFIX_BONUS;
149    }
150    Some(score)
151}
152
153/// Bonus for a contiguous pair of matched characters.
154const RUN_BONUS: u32 = 150;
155/// Bonus when the first matched character is the first character of the text.
156const PREFIX_BONUS: u32 = 500;
157/// Score for an exact (case-insensitive) equality — dominates any partial.
158const EXACT_MATCH_SCORE: u32 = 50_000;
159
160/// Greedy leftmost subsequence match, case-insensitive. Returns the haystack
161/// positions of `query`'s characters in order, or `None` when `query` is not
162/// a subsequence of `haystack`.
163fn greedy_subsequence_positions(query: &str, haystack: &str) -> Option<Vec<usize>> {
164    let q: Vec<char> = query.chars().collect();
165    if q.is_empty() {
166        return Some(Vec::new());
167    }
168    let h: Vec<char> = haystack.chars().collect();
169    let mut positions = Vec::with_capacity(q.len());
170    let mut hi = 0usize;
171    /* tolerate differing-cased surrogates the same way the lowercase scan does */
172    for qc in &q {
173        let found = h[hi..]
174            .iter()
175            .position(|hc| *hc == *qc)
176            .map(|off| off + hi)?;
177        positions.push(found);
178        hi = found + 1;
179    }
180    Some(positions)
181}
182
183// ---------------------------------------------------------------------------
184// Structured filtering (FR-007..FR-013)
185// ---------------------------------------------------------------------------
186
187/// A selectable structured-filter criterion.
188///
189/// [`FilterField::ALL`] is the single ordered field list the filter modal
190/// renders its rows from (risk: field/modal drift) and [`StructuredFilter`]
191/// evaluates over — adding a criterion (FR-011) is one more `Option<String>`
192/// field on [`StructuredFilter`] plus one variant / match arm here.
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum FilterField {
195    Assignee,
196    Label,
197    Status,
198    Priority,
199    Cycle,
200    Milestone,
201    Reviewer,
202    StartDate,
203    DueDate,
204    /// An issue's **direct** parent, by selector (WP06 T024).
205    Parent,
206    /// An issue's resolved workflow category, by category key (WP08 T034).
207    ///
208    /// **Derived, not stored.** Unlike the nine above, this criterion matches
209    /// nothing an issue's file contains: there is no `category` frontmatter key
210    /// (research R2), and the value comes from the issue's `status` resolved
211    /// through the board's `category_map`. See [`FilterField::key`] for what
212    /// that means for diagnostics.
213    Category,
214}
215
216impl FilterField {
217    /// The criteria in modal row order (research D-05).
218    ///
219    /// `Parent` is **appended** rather than interleaved: a row's position is a
220    /// user's muscle memory and a TUI test's golden index, and moving eight
221    /// existing rows to make room for a ninth helps nobody (T024 step 2).
222    ///
223    /// `Category` is **appended** after `Parent`, for the same reason T024 step 2
224    /// gave: a row's position is a user's muscle memory and the TUI's golden
225    /// index, and it is also the position a user reaches last. A category is the
226    /// one criterion a user cannot reach from the file itself, so it is the one
227    /// they will look up last.
228    pub const ALL: [FilterField; 11] = [
229        FilterField::Assignee,
230        FilterField::Label,
231        FilterField::Status,
232        FilterField::Priority,
233        FilterField::Cycle,
234        FilterField::Milestone,
235        FilterField::Reviewer,
236        FilterField::StartDate,
237        FilterField::DueDate,
238        FilterField::Parent,
239        FilterField::Category,
240    ];
241
242    pub fn label(self) -> &'static str {
243        match self {
244            FilterField::Assignee => "Assignee",
245            FilterField::Label => "Label",
246            FilterField::Status => "Status",
247            FilterField::Priority => "Priority",
248            FilterField::Cycle => "Cycle",
249            FilterField::Milestone => "Milestone",
250            FilterField::Reviewer => "Reviewer",
251            FilterField::StartDate => "Start date",
252            FilterField::DueDate => "Due date",
253            FilterField::Parent => "Parent",
254            FilterField::Category => "Category",
255        }
256    }
257
258    /// The serialized frontmatter key each criterion matches against
259    /// (`Issue::to_file_contents` emits these). Used by diagnostics.
260    ///
261    /// **For [`FilterField::Category`] this is the token name, not a frontmatter
262    /// key**, because there is no `category` key: the criterion is derived from the
263    /// issue's `status` through the board's `category_map` (research R2). T034
264    /// step 4 asked what to report here and warned that leaving a nonexistent key
265    /// in the diagnostics would be a quiet lie, so the honest spelling is
266    /// `category` — which is exactly what the user types, and what the grammar
267    /// parses — plus [`FilterField::is_derived`] for any surface that has to
268    /// *explain* the criterion rather than merely echo it.
269    ///
270    /// The distinction is not cosmetic. `key()` feeds two things that read very
271    /// differently: a hint bar, where `category:completed` is exactly right, and
272    /// an error, where "no issue has `category:completed`" would send a user
273    /// looking for a field that does not exist. [`is_derived`] is what keeps the
274    /// second from happening.
275    pub fn key(self) -> &'static str {
276        match self {
277            FilterField::Assignee => "assignee",
278            FilterField::Label => "labels",
279            FilterField::Status => "status",
280            FilterField::Priority => "priority",
281            FilterField::Cycle => "cycle",
282            FilterField::Milestone => "milestone",
283            FilterField::Reviewer => "reviewer",
284            FilterField::StartDate => "start_date",
285            FilterField::DueDate => "due_date",
286            // The exact key `Issue` serializes. T024 step 4 asks for a test that
287            // fails if this drifts from the model, because `key()` feeds the
288            // diagnostics: a mismatch makes `tix lint` point at a field that does
289            // not exist, with no error anywhere.
290            FilterField::Parent => "parent",
291            // The **token** name, and the only correct spelling here: the grammar
292            // parses `category:` and the hint bar echoes it. Not a frontmatter key,
293            // and `is_derived` is what stops it being read as one.
294            FilterField::Category => "category",
295        }
296    }
297
298    /// Whether this criterion is **derived** from other data rather than read off
299    /// a frontmatter field (WP08 T034 step 4).
300    ///
301    /// `Category` is the only one. It is derived from `status` through the board's
302    /// `category_map`, so a diagnostic that names it as a field points at a field
303    /// that does not exist. Any surface printing *why* a criterion matched, or why
304    /// it found nothing, must say so through this.
305    pub fn is_derived(self) -> bool {
306        matches!(self, FilterField::Category)
307    }
308
309    /// The criterion as a user should read it in an explanation: `category` on its
310    /// own, or `category (derived from status)` where the extra clause matters.
311    ///
312    /// Split from [`key`] so a caller has to choose. A hint bar that appends
313    /// "(derived from status)" on every keystroke is noise, and an error that
314    /// omits it sends the user hunting for a field; this hands each surface the
315    /// short form and lets the ones that explain pick the long one.
316    pub fn describe(self) -> String {
317        if self.is_derived() {
318            format!("{} (derived from status)", self.key())
319        } else {
320            self.key().to_string()
321        }
322    }
323
324    /// Row index of this criterion in [`FilterField::ALL`].
325    pub fn row(self) -> usize {
326        FilterField::ALL
327            .iter()
328            .position(|field| *field == self)
329            .expect("FilterField variants are all in ALL")
330    }
331
332    pub fn next(self) -> Self {
333        let all = FilterField::ALL;
334        all[(self.row() + 1) % all.len()]
335    }
336
337    pub fn prev(self) -> Self {
338        let all = FilterField::ALL;
339        all[(self.row() + all.len() - 1) % all.len()]
340    }
341
342    /// The criterion value when set *and* non-empty. An empty string is
343    /// treated as "no criterion" everywhere (canonical invariant), so the
344    /// filter modal can edit a slot without a live-match surprise.
345    pub fn value(self, filter: &StructuredFilter) -> Option<&str> {
346        let value = match self {
347            FilterField::Assignee => filter.assignee.as_deref(),
348            FilterField::Label => filter.label.as_deref(),
349            FilterField::Status => filter.status.as_deref(),
350            FilterField::Priority => filter.priority.as_deref(),
351            FilterField::Cycle => filter.cycle.as_deref(),
352            FilterField::Milestone => filter.milestone.as_deref(),
353            FilterField::Reviewer => filter.reviewer.as_deref(),
354            FilterField::StartDate => filter.start_date.as_deref(),
355            FilterField::DueDate => filter.due_date.as_deref(),
356            FilterField::Parent => filter.parent.as_deref(),
357            FilterField::Category => filter.category.as_deref(),
358        };
359        value.filter(|s| !s.is_empty())
360    }
361
362    /// Mutable access to the criterion buffer, materializing an empty one
363    /// when the criterion is unset (the filter modal types into it and
364    /// `StructuredFilter::normalize` promotes empty back to `None`).
365    pub fn value_mut(self, filter: &mut StructuredFilter) -> &mut String {
366        match self {
367            FilterField::Assignee => filter.assignee.get_or_insert_default(),
368            FilterField::Label => filter.label.get_or_insert_default(),
369            FilterField::Status => filter.status.get_or_insert_default(),
370            FilterField::Priority => filter.priority.get_or_insert_default(),
371            FilterField::Cycle => filter.cycle.get_or_insert_default(),
372            FilterField::Milestone => filter.milestone.get_or_insert_default(),
373            FilterField::Reviewer => filter.reviewer.get_or_insert_default(),
374            FilterField::StartDate => filter.start_date.get_or_insert_default(),
375            FilterField::DueDate => filter.due_date.get_or_insert_default(),
376            FilterField::Parent => filter.parent.get_or_insert_default(),
377            // The criterion is typed into the modal and validated when the query is
378            // **run** (see `resolve_category_criterion`), not per keystroke: the
379            // modal edits a buffer, and rejecting a half-typed `comp` because it is
380            // not yet a category would make the row unusable. The service has no
381            // business being called from a text field.
382            FilterField::Category => filter.category.get_or_insert_default(),
383        }
384    }
385
386    /// Clear the criterion back to `None`.
387    pub fn unset(self, filter: &mut StructuredFilter) {
388        match self {
389            FilterField::Assignee => filter.assignee = None,
390            FilterField::Label => filter.label = None,
391            FilterField::Status => filter.status = None,
392            FilterField::Priority => filter.priority = None,
393            FilterField::Cycle => filter.cycle = None,
394            FilterField::Milestone => filter.milestone = None,
395            FilterField::Reviewer => filter.reviewer = None,
396            FilterField::StartDate => filter.start_date = None,
397            FilterField::DueDate => filter.due_date = None,
398            FilterField::Parent => filter.parent = None,
399            FilterField::Category => filter.category = None,
400        }
401    }
402
403    /// Whether this criterion is a **parent reference** rather than a text field.
404    ///
405    /// The distinction drives two things: the free-typing path is bypassed in
406    /// favour of the issue picker (WP06 T026), and `evaluate` compares exactly
407    /// rather than by substring, because a number must not match a longer number
408    /// that merely contains it.
409    pub fn is_reference(self) -> bool {
410        matches!(self, FilterField::Parent)
411    }
412
413    /// Whether this criterion holds a date (picker-only, no free typing).
414    pub fn is_date(self) -> bool {
415        matches!(self, FilterField::StartDate | FilterField::DueDate)
416    }
417}
418
419/// Typed, extensible structured filter (FR-007..FR-010, FR-011).
420///
421/// Every criterion is optional; an `None`/empty criterion narrows nothing.
422/// Criteria match **case-insensitively** against the serialized corpus text
423/// (the same haystack [`fuzzy_score`] runs over), following the existing
424/// fuzzy matcher's `to_lowercase()` behavior — this keeps the predicate a
425/// pure, config-independent function of the corpus (WP02 runs in parallel
426/// with WP01 for exactly that reason).
427#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
428pub struct StructuredFilter {
429    pub assignee: Option<String>,
430    pub label: Option<String>,
431    pub status: Option<String>,
432    pub priority: Option<String>,
433    pub cycle: Option<String>,
434    pub milestone: Option<String>,
435    pub reviewer: Option<String>,
436    pub start_date: Option<String>,
437    pub due_date: Option<String>,
438    pub overdue: Option<bool>,
439    /// The referenced parent, as a **resolved issue number** (WP06 T025).
440    ///
441    /// A number, not the selector the user typed, because matching happens against
442    /// an issue's rendered frontmatter and that carries `parent: 7`. Resolution
443    /// happens once, at the command layer, where the store is available; matching
444    /// then stays a pure string comparison and `query_matches` keeps its
445    /// store-free signature.
446    ///
447    /// Direct children only, and by construction: an issue's `parent:` field *is*
448    /// its direct parent, so a grandchild's file says `parent: 2` and never matches
449    /// a criterion of `parent: 1`. A transitive implementation would have to walk
450    /// the chain explicitly, and this way there is nothing to get wrong.
451    pub parent: Option<String>,
452    /// A workflow **category key** (WP08 T035), never a column name.
453    ///
454    /// A category key rather than a column, because several columns can share one
455    /// category and the whole point of the criterion is to select across that
456    /// boundary: `category:completed` on a board with `shipped`, `done` and
457    /// `released` all complete returns every one of them, where `status:done`
458    /// returns one.
459    ///
460    /// Validated through the service's `from_key` when the query runs
461    /// ([`resolve_category_criterion`]), so this never holds a value the
462    /// vocabulary does not contain. The TUI modal types into it unchecked — see
463    /// [`FilterField::value_mut`] for why that is the right layer to do it at.
464    pub category: Option<String>,
465    /// The columns a `scope:` token selected (WP02 T010).
466    ///
467    /// A set of **column names**, not a name, because the columns are not
468    /// knowable from the query: `scope:triage` means "the columns `[triage]`
469    /// names", and only the config can answer that — the same reason `parent:`
470    /// and `category:` are resolved rather than matched from bytes. Filled by
471    /// [`resolve_filter_criteria`] through
472    /// [`crate::triage::inbox::scope_columns`], and judged by
473    /// [`Self::scope_matches`], which needs the issue the way `category` does.
474    ///
475    /// Deliberately **not** a [`FilterField`]: the filter modal has no row for
476    /// it and no user can type a scope into it, and appending a variant would
477    /// change `FilterField::ALL`'s length — which the modal's row layout, its
478    /// click mapping and its own tests all count. The extensibility promise on
479    /// [`FilterField`] is about criteria a user can enter; this is a resolved
480    /// selection a *token* produces, and `overdue` already shows a
481    /// `StructuredFilter` field that is not a modal row.
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    pub scope: Option<Vec<String>>,
484    /// Whether the issue is **archived** (issue #1492), from an
485    /// `archived:true|false` token.
486    ///
487    /// A **derived** criterion, like `overdue` and `scope`, and for the same
488    /// reason: `archived` is not a frontmatter key. The marker is the leading dot
489    /// of the issue's filename, mirrored onto [`Issue::archived`], so nothing in
490    /// the serialized bytes can answer the question and the token is lifted out
491    /// by [`resolve_filter_criteria`] into this field, then judged by
492    /// [`Self::archived_matches`], which has the issue.
493    ///
494    /// `None` (no token) narrows nothing.
495    #[serde(default, skip_serializing_if = "Option::is_none")]
496    pub archived: Option<bool>,
497}
498
499impl StructuredFilter {
500    /// The active (set, non-empty) criteria, in [`FilterField::ALL`] order.
501    pub fn criteria(&self) -> impl Iterator<Item = (FilterField, &str)> {
502        FilterField::ALL
503            .into_iter()
504            .filter_map(move |field| field.value(self).map(|value| (field, value)))
505    }
506
507    /// True when at least one criterion is active. When false the board must
508    /// render unchanged (no criteria → pass-through).
509    ///
510    /// The `scope` criterion is counted here even though it is not a
511    /// [`FilterField`] and so never appears in [`Self::criteria`]: a query that
512    /// selected rows is active however it selected them, and omitting it would
513    /// make `has_active_criteria` disagree with [`Self::evaluate_issue`] about
514    /// whether the same filter narrowed anything.
515    pub fn has_active_criteria(&self) -> bool {
516        self.criteria().next().is_some() || self.scope.is_some() || self.archived.is_some()
517    }
518
519    /// AND predicate over the serialized corpus text (FR-012, C-002).
520    ///
521    /// Applies *every* active criterion; the first miss short-circuits the
522    /// entry to `false`. The caller (WP03's `build_filtered`) invokes this
523    /// once per corpus entry, giving **one linear O(entries) pass** (NFR-001)
524    /// — `haystack` is lowercased once and each active criterion is a single
525    /// substring `contains` on that pre-lowered text, with no nested
526    /// field×corpus loops.
527    ///
528    /// Matching is case-insensitive and substring-based, consistently across
529    /// assignee/label/status/priority, exactly as WP02's prompt specifies
530    /// (T009). The stricter per-criterion "equals"/"contains-in-labels" rules
531    /// in `contracts/filtering.md` would require re-parsing the issue; on the
532    /// serialized corpus the substring rule is the single consistent gate.
533    pub fn evaluate(&self, haystack: &str) -> bool {
534        let hay = haystack.to_lowercase();
535        // The parent criterion is **not** a substring test, and is handled before
536        // the generic loop rather than inside it.
537        //
538        // Two reasons. First, a substring test over the whole haystack would match
539        // an issue whose *body* mentions the number, or whose `parent: 17` contains
540        // `1` — so `parent:1` would return the children of #17 as well. Second, the
541        // generic loop would also be reached for an issue with **no** `parent:` key
542        // at all, where `hay.contains("1")` is true for any issue whose text
543        // mentions 1. Every other criterion is a text field where substring is the
544        // right semantic; a reference is a value.
545        if let Some(number) = &self.parent
546            && field_value(haystack, "parent") != Some(number.as_str())
547        {
548            return false;
549        }
550        for (field, value) in self.criteria() {
551            // `parent` was already compared exactly above, and `category` cannot be
552            // compared here at all — see `evaluate_issue`.
553            if field.is_reference() || field.is_derived() {
554                continue;
555            }
556            if !hay.contains(&value.to_lowercase()) {
557                return false;
558            }
559        }
560        if let Some(cycle) = &self.cycle
561            && !hay.contains(&cycle.to_lowercase())
562        {
563            return false;
564        }
565        if let Some(milestone) = &self.milestone
566            && !hay.contains(&milestone.to_lowercase())
567        {
568            return false;
569        }
570        if let Some(reviewer) = &self.reviewer
571            && !hay.contains(&reviewer.to_lowercase())
572        {
573            return false;
574        }
575        if let Some(start_date) = &self.start_date
576            && !hay.contains(start_date)
577        {
578            return false;
579        }
580        if let Some(due_date) = &self.due_date
581            && !hay.contains(due_date)
582        {
583            return false;
584        }
585        if self.overdue == Some(true) && !is_overdue_haystack(haystack) {
586            return false;
587        }
588        true
589    }
590
591    /// The full structured predicate for an issue, including the criteria
592    /// [`Self::evaluate`] structurally cannot judge.
593    ///
594    /// WP08 T035. [`evaluate`] takes a haystack and is therefore store-free; the
595    /// category criterion needs the issue's `status` **and** the board's
596    /// `category_map`, so it is judged here instead, where both are available.
597    /// The two halves AND together, exactly as `evaluate` ANDs its own criteria.
598    ///
599    /// `text` is passed in rather than re-serialized from `issue`, because
600    /// `Corpus` already holds the exact bytes every issue was matched against and
601    /// a second `to_file_contents()` per issue per keystroke is measurable work
602    /// for an identical string.
603    pub fn evaluate_issue(
604        &self,
605        issue: &Issue,
606        text: &str,
607        resolution: &crate::workflow::CategoryResolution,
608    ) -> bool {
609        self.evaluate(text)
610            && self.category_matches(issue, resolution)
611            && self.scope_matches(issue)
612            && self.archived_matches(issue)
613    }
614
615    /// Whether `issue`'s archived state matches the `archived:` criterion
616    /// (issue #1492).
617    ///
618    /// Judged here rather than in [`Self::evaluate`] because the archived flag is
619    /// derived from the *filename* and never appears in the haystack; `None`
620    /// narrows nothing, and a set value is an exact `bool` comparison —
621    /// `archived:false` selects the active issues, `archived:true` the archived
622    /// ones.
623    pub fn archived_matches(&self, issue: &Issue) -> bool {
624        match self.archived {
625            None => true,
626            Some(want) => issue.archived == want,
627        }
628    }
629
630    /// Whether `issue` is in one of the columns the `scope:` token selected.
631    ///
632    /// WP02 T010. The same three-way reasoning as [`Self::category_matches`],
633    /// in reverse: a scope is a **membership set**, so an issue either has a
634    /// status in it or does not, and there is no category to default. `None` (no
635    /// token) passes everything — an unset criterion narrows nothing — and a set
636    /// that fails to contain `issue.status` fails closed, because a scope the
637    /// issue is not in is precisely what was asked for.
638    ///
639    /// Judged here rather than in [`Self::evaluate`] for the reason that
640    /// function gives: `evaluate` has a haystack and no issue, and the issue's
641    /// status is not reliably *in* the haystack in a form this can compare —
642    /// `status: triage` is a line of frontmatter, but a scope is a set of
643    /// columns and substring-matching the set against the file would call an
644    /// issue whose title contains `todo` a member of the `todo` column.
645    pub fn scope_matches(&self, issue: &Issue) -> bool {
646        match &self.scope {
647            None => true,
648            Some(columns) => columns.iter().any(|c| issue.status.is(c)),
649        }
650    }
651
652    /// Whether `issue` is in the requested category (WP08 T035).
653    ///
654    /// Three cases, and the third is the one the surfaces already distinguish:
655    ///
656    /// 1. **A mapped column** → its category, from the service's resolution.
657    /// 2. **A configured but unmapped column** → the **safe default**, because
658    ///    that is what it resolves to. T035 step 3 asks for this explicitly, and it
659    ///    is what keeps the criterion consistent with `tix workflow show`, which
660    ///    reports the same default for the same column.
661    /// 3. **A column the board does not configure** → matches **nothing**. There
662    ///    is no category to report: `resolve` is `None`, `tix list --show category`
663    ///    prints `(unmapped)`, and MCP returns `category: null` (WP07). A criterion
664    ///    has to agree with the surfaces that show the same fact, and silently
665    ///    folding this into the safe default would make `category:unstarted` return
666    ///    issues every other surface call unmapped.
667    ///
668    /// The unknown-category case is *not* handled here and cannot be: a value the
669    /// vocabulary does not contain is an error at the command layer
670    /// ([`resolve_category_criterion`]), not a match decision here. A criterion
671    /// that reached this function unchecked compares against a key nothing
672    /// resolves to and therefore matches nothing — the same empty result an
673    /// unknown `status:` gives, which is the established behaviour for a bad value
674    /// in a haystack criterion.
675    pub fn category_matches(
676        &self,
677        issue: &Issue,
678        resolution: &crate::workflow::CategoryResolution,
679    ) -> bool {
680        let Some(want) = self.category.as_deref().filter(|s| !s.is_empty()) else {
681            return true;
682        };
683        match resolution.resolve(issue.status.display()) {
684            Some(actual) => actual.key() == want,
685            // Case 3. `None` only for a column this board does not configure.
686            None => false,
687        }
688    }
689
690    /// Trim every criterion; values that trim to empty become `None` (the
691    /// canonical "unset" state). Used by the filter modal's commit path.
692    pub fn normalize(&mut self) {
693        for field in FilterField::ALL {
694            let empty = field.value_mut(self).trim().is_empty();
695            if empty {
696                field.unset(self);
697            } else {
698                let value = field.value_mut(self);
699                let trimmed = value.trim().to_string();
700                *value = trimmed;
701            }
702        }
703    }
704}
705
706impl std::fmt::Display for StructuredFilter {
707    /// Diagnostics for the hint bar: `assignee:alice, status:todo`, or
708    /// `(no active criteria)`.
709    ///
710    /// Built as a list rather than written field by field, because there are
711    /// now three sources of a part — the [`FilterField`] criteria, an active
712    /// `scope`, and `overdue` — and the first version returned early when the
713    /// *criteria* were empty. A scope-only filter would then have printed
714    /// `(no active criteria)` while actively filtering, which is the hint bar
715    /// telling the user the opposite of what the board is doing.
716    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
717        let mut parts: Vec<String> = self
718            .criteria()
719            .map(|(field, value)| format!("{}:{value}", field.key()))
720            .collect();
721        if let Some(columns) = &self.scope {
722            parts.push(format!("scope:{}", columns.join(",")));
723        }
724        if let Some(archived) = self.archived {
725            parts.push(format!("archived:{archived}"));
726        }
727        if self.overdue == Some(true) {
728            parts.push("overdue:true".to_string());
729        }
730        if parts.is_empty() {
731            return write!(f, "(no active criteria)");
732        }
733        write!(f, "{}", parts.join(", "))
734    }
735}
736
737/// Composite predicate: the `/` fuzzy text layer **and** the structured
738/// filter both apply (FR-013).
739///
740/// - empty `query` → structured filter only;
741/// - empty structured filter → text query only;
742/// - both active → an entry must satisfy **both** layers.
743///
744/// Zero matches is a valid board, never an error (consumers render empty
745/// columns).
746pub fn matches(query: &str, filter: &StructuredFilter, haystack: &str) -> bool {
747    let text_matches = query_matches(query, haystack);
748    text_matches && filter.evaluate(haystack)
749}
750
751/// The full predicate for one corpus entry, including the derived criteria
752/// (WP08 T035, WP02 T010).
753///
754/// **Every** consumer should call this rather than [`matches`]. `matches` cannot
755/// judge a `category:` or `scope:` criterion — both need the issue (and the
756/// board's map, for `category`), and it has neither — and a consumer that calls
757/// it directly would therefore **silently ignore** an active category or scope
758/// criterion: the query would return the unfiltered set and read as "every issue
759/// is in that category/scope". One composition point is what keeps the CLI, the
760/// TUI board, and `tix search` from disagreeing, which is the property the
761/// module header exists to protect (C-005).
762///
763/// `text` is the corpus's own bytes for this entry, so no caller re-serializes.
764pub fn matches_issue(
765    query: &str,
766    filter: &StructuredFilter,
767    issue: &Issue,
768    text: &str,
769    resolution: &crate::workflow::CategoryResolution,
770) -> bool {
771    query_matches(query, text) && filter.evaluate_issue(issue, text, resolution)
772}
773
774/// Resolve a `parent:<selector>` query token to the **number** it names.
775///
776/// Matching happens against an issue's rendered frontmatter, and that carries
777/// `parent: 7` — a number. The user types a *selector*, so something has to close
778/// that gap, and the only place with a store is the command layer. Doing it here,
779/// once, keeps `query_matches` and `evaluate` pure string comparisons and
780/// store-free, which is the property every other criterion already has.
781///
782/// The substitution is textual on the **query**, not on the results: `parent:5` in,
783/// `parent:5` out, with the selector replaced by the number it resolves to. That
784/// means no new plumbing in the matchers, and no second code path for a criterion
785/// that only needs one comparison.
786///
787/// A selector that resolves to nothing is an **error**, not an empty result: a user
788/// who typed `parent:Epicc` has made a typo, and "no issues found" is a different
789/// answer from "there is no such issue". Every other command's selector handling
790/// errors the same way.
791///
792/// Returns the query unchanged when it carries no `parent:` token, so a caller can
793/// use the result unconditionally.
794pub fn resolve_parent_criterion(store: &crate::storage::Store, query: &str) -> Result<String> {
795    let mut out: Vec<String> = Vec::with_capacity(8);
796    let mut touched = false;
797    for token in query.split_whitespace() {
798        let Some((key, value)) = parse_token(token) else {
799            out.push(token.to_string());
800            continue;
801        };
802        if !key.eq_ignore_ascii_case("parent") {
803            out.push(token.to_string());
804            continue;
805        }
806        let target = crate::commands::parse_selector(store, value)?;
807        // The store's own message, propagated as-is. The first version wrapped it in
808        // a fresh `NotFound`, and the user saw `not found: not found: issue matching
809        // …` — the same class of doubled prefix the relationship renderer fixed in
810        // WP03. One message, from one place.
811        let number = store
812            .get(target)
813            .map_err(|_| {
814                crate::error::TixError::NotFound(format!(
815                    "issue matching '{value}' by number, filename or title"
816                ))
817            })?
818            .number;
819        out.push(format!("parent:{number}"));
820        touched = true;
821    }
822    if !touched {
823        return Ok(query.to_string());
824    }
825    Ok(out.join(" "))
826}
827
828/// **Lift** the `category:<value>` criterion out of `query`, validating the value
829/// against the project's closed vocabulary (WP08 T035).
830///
831/// Returns `(query-without-the-token, Some(canonical_key))`. "Lift", not "rewrite":
832/// the token is **removed** and the criterion travels in the
833/// [`StructuredFilter`] instead, because that is the only shape that works. The
834/// first version rewrote `category:Completed` to `category:completed` and left the
835/// token in the string, so [`query_matches`] still met a `category` key, still
836/// refused it, and the filter matched **nothing** — while the canonicalization test
837/// beside it passed. Two green results and an empty board.
838///
839/// The validation this closes is the one a substring matcher cannot do. A bad
840/// `status:` value is a legitimate test that matches nothing; a bad *category* is a
841/// typo in a closed set of at most six words, and "0 issues found" is a silent dead
842/// end where one line names the words that would have worked.
843///
844/// Checked against the **project's** `[categories] order` rather than
845/// [`crate::config::CATEGORY_KEYS`] because `order` may be a **subset**: a project
846/// declaring `["backlog", "active", "completed", "canceled"]` has said its
847/// vocabulary is four words, and accepting `unstarted` would return results from a
848/// category it never declared. The error names that project's list.
849///
850/// `None` when the query carries no `category:` token, so a caller can use the
851/// result unconditionally. Same shape and same reason as
852/// [`resolve_parent_criterion`]: the matchers are pure string comparisons over a
853/// store-free haystack, so anything needing the config is resolved once, in the one
854/// layer that has it.
855fn lift_category_criterion(
856    config: &crate::config::Config,
857    query: &str,
858) -> crate::error::Result<(String, Option<String>)> {
859    let mut out: Vec<String> = Vec::with_capacity(8);
860    let mut category = None;
861    for token in query.split_whitespace() {
862        let Some((key, value)) = parse_token(token) else {
863            out.push(token.to_string());
864            continue;
865        };
866        if !key.eq_ignore_ascii_case("category") {
867            out.push(token.to_string());
868            continue;
869        }
870        let lowered = value.to_lowercase();
871        if !config.categories.contains(&lowered) {
872            return Err(crate::error::TixError::InvalidInput(format!(
873                "unknown category '{value}' — this project's categories are: {}",
874                config.categories.order.join(", ")
875            )));
876        }
877        // `contains` and `from_key` read the same six words, so this cannot fail
878        // after the check above. `from_key` is still what produces the canonical
879        // spelling rather than trusting the user's casing, and it is the same
880        // function `WorkflowCategory::key` is compared against when matching.
881        category = Some(
882            crate::workflow::WorkflowCategory::from_key(&lowered)
883                .expect("categories.order is validated against the global vocabulary at load")
884                .key()
885                .to_string(),
886        );
887        // Dropped, not pushed: `out` is the query the store-free matcher will see.
888    }
889    Ok((out.join(" "), category))
890}
891
892/// Resolve a `scope:<name>` query token to the columns it selects (WP02 T010).
893///
894/// Same shape as [`lift_category_criterion`], and for the same reason: the
895/// columns a scope names are a **config** fact, so a store-free matcher cannot
896/// reach them, and leaving the token in the string would have [`query_matches`]
897/// refuse it and return an empty result that reads as "no matches".
898///
899/// One token rather than a new grammar. `scope:` is the `key:value` shape every
900/// other criterion already uses, resolved through the same entry point, with no
901/// new operator, no list syntax and no precedence — the module's own extension
902/// promise ("adding a criterion is one more field on `StructuredFilter` plus one
903/// match arm"), minus the `FilterField` variant, which would change the modal's
904/// row count (see [`StructuredFilter::scope`]).
905///
906/// A token the project cannot honour is an **error**, not an empty selection:
907/// see [`crate::triage::inbox::scope_columns`].
908///
909/// Returns the query with every `scope:` token removed, so a caller can use the
910/// result unconditionally.
911fn lift_scope_criterion(
912    config: &crate::config::Config,
913    query: &str,
914) -> crate::error::Result<(String, Option<Vec<String>>)> {
915    let mut out: Vec<String> = Vec::with_capacity(8);
916    let mut scope = None;
917    for token in query.split_whitespace() {
918        let Some((key, value)) = parse_token(token) else {
919            out.push(token.to_string());
920            continue;
921        };
922        if !key.eq_ignore_ascii_case("scope") {
923            out.push(token.to_string());
924            continue;
925        }
926        // Validated *and* resolved in one call, so a caller cannot keep a token
927        // it failed to check. The last one wins, as `category:` does.
928        scope = Some(crate::triage::inbox::scope_columns(config, value)?);
929        // Dropped, not pushed: `out` is the query the store-free matcher will see.
930    }
931    Ok((out.join(" "), scope))
932}
933
934/// Resolve a `--filter` / query string into the **pair** a match needs: the
935/// canonicalized query, and the criteria the store-free matchers cannot judge
936/// (WP08 T035).
937///
938/// Every consumer that takes a filter string should call this and hand both parts
939/// to [`matches_issue`]. One function rather than two calls, because the failure
940/// mode of getting it half-right is silent: a query whose `category:` token was
941/// validated but not canonicalized into the structured criterion still matches
942/// **nothing** (the token matcher refuses it), and a query that was canonicalized
943/// but never validated accepts a category this project did not declare. Neither
944/// error is visible in a return value.
945///
946/// `parent:` is resolved here too. It was always resolved at the command layer —
947/// it is the same shape of problem — but in four separate call sites, and the
948/// `category:` work would have made that four more. One entry point means a fifth
949/// surface gets both.
950///
951/// Returns the query unchanged when it carries neither token, so a caller can use
952/// the result unconditionally.
953pub fn resolve_filter_criteria(
954    store: &crate::storage::Store,
955    query: &str,
956) -> Result<(String, StructuredFilter)> {
957    let query = resolve_parent_criterion(store, query)?;
958    // `parent:`'s resolver **rewrites** its token — a selector becomes a number, and
959    // the haystack holds the number. `category:`'s **lifts** its token out. The
960    // asymmetry is not an oversight: the parent criterion is compared against a
961    // rendered frontmatter field, so it can stay in the string, while the category
962    // criterion has no field to compare against and must arrive as a structured
963    // value. Both are resolved here so no call site has to know which is which.
964    // `scope:` is a `category:` in kind — a selection nothing in the file bytes
965    // can answer — and is lifted the same way (WP02 T010).
966    let (query, category) = lift_category_criterion(&store.config, &query)?;
967    let (query, scope) = lift_scope_criterion(&store.config, &query)?;
968    let (query, archived) = lift_archived_criterion(&query)?;
969    Ok((
970        query,
971        StructuredFilter {
972            category,
973            scope,
974            archived,
975            ..StructuredFilter::default()
976        },
977    ))
978}
979
980/// Lift an `archived:true|false` token out of the query into the
981/// [`StructuredFilter::archived`] field (issue #1492).
982///
983/// The archived state is derived from the filename, so it cannot be judged from
984/// the serialized bytes and the token has to arrive as a structured value — the
985/// same shape as `scope:` and `category:`. The last token wins, as both of those
986/// do. A value that is neither `true` nor `false` is refused by name, because
987/// `archived:1` matching nothing would be indistinguishable from a correct filter
988/// with no matches.
989fn lift_archived_criterion(query: &str) -> crate::error::Result<(String, Option<bool>)> {
990    let mut out: Vec<String> = Vec::with_capacity(8);
991    let mut archived = None;
992    for token in query.split_whitespace() {
993        let Some((key, value)) = parse_token(token) else {
994            out.push(token.to_string());
995            continue;
996        };
997        if !key.eq_ignore_ascii_case("archived") {
998            out.push(token.to_string());
999            continue;
1000        }
1001        archived = match value.to_ascii_lowercase().as_str() {
1002            "true" => Some(true),
1003            "false" => Some(false),
1004            other => {
1005                return Err(crate::error::TixError::InvalidInput(format!(
1006                    "invalid archived value '{other}' — use archived:true or archived:false"
1007                )));
1008            }
1009        };
1010        // Dropped, not pushed: `out` is the query the store-free matcher will see.
1011    }
1012    Ok((out.join(" "), archived))
1013}
1014
1015/// Parse a structured query token of the form `key:value`.
1016fn parse_token(token: &str) -> Option<(&str, &str)> {
1017    let (key, value) = token.split_once(':')?;
1018    let value = value.trim();
1019    if key.is_empty() || value.is_empty() {
1020        return None;
1021    }
1022    Some((key, value))
1023}
1024
1025pub fn control_token(query: &str, key: &str) -> Option<String> {
1026    query.split_whitespace().find_map(|token| {
1027        let (k, v) = token.split_once(':')?;
1028        if k.eq_ignore_ascii_case(key) {
1029            Some(v.to_ascii_lowercase())
1030        } else {
1031            None
1032        }
1033    })
1034}
1035
1036/// Apply the new query grammar to a haystack without disturbing the legacy
1037/// fuzzy-substring semantics for bare terms.
1038pub fn query_matches(query: &str, haystack: &str) -> bool {
1039    let mut bare_terms = Vec::new();
1040    let hay = haystack.to_lowercase();
1041    for token in query.split_whitespace() {
1042        if token.eq_ignore_ascii_case("overdue") {
1043            if !is_overdue_haystack(haystack) {
1044                return false;
1045            }
1046            continue;
1047        }
1048        if let Some((key, value)) = parse_token(token) {
1049            let value = value.to_lowercase();
1050            let ok = match key.to_lowercase().as_str() {
1051                // A **pre-existing defect, found while adding `category`** (WP08).
1052                //
1053                // `StructuredFilter` has carried `assignee`, `label`, `status` and
1054                // `priority` since the filter grammar landed, and `evaluate` matches
1055                // all four — so the TUI's filter modal has always filtered on them.
1056                // The **token** grammar had no arm for any of them, and the `_ => false`
1057                // below turned each into a filter that matched nothing:
1058                //
1059                //     tix list --filter status:shipped    # → empty table, exit 0
1060                //     tix list --filter assignee:alice     # → empty table, exit 0
1061                //
1062                // Silent, total, and on the four most obvious criteria. A user typing
1063                // the documented `--filter` had no way to tell a filter from a typo, and
1064                // the empty table is indistinguishable from a correct filter with no
1065                // matches.
1066                //
1067                // Substring, and lowercased, because that is exactly what `evaluate`
1068                // does for these four — the module's stated rule, and the one
1069                // `StructuredFilter`'s own tests pin. The two grammars now agree
1070                // criterion for criterion instead of the token grammar silently
1071                // supporting less than the modal does.
1072                //
1073                // `label` matches the rendered `labels: [a, b]` list by substring,
1074                // which is also `evaluate`'s rule, and the one `contracts/filtering.md`
1075                // §3's stricter "contains-in-labels" spelling would need a re-parse to
1076                // improve on. Not changed here: one behaviour, two grammars.
1077                "assignee" | "label" | "status" | "priority" | "cycle" | "milestone"
1078                | "reviewer" => hay.contains(&value),
1079                "start" | "due" => hay.contains(&value),
1080                // Exact, for the same reason `evaluate` is: a number must not
1081                // match a longer number that contains it. The value here is already
1082                // a **resolved** number — see `resolve_parent_criterion`.
1083                "parent" => field_value(haystack, "parent") == Some(value.as_str()),
1084                // A **category key**, not a field value: an issue's file contains no
1085                // `category` line, so a substring test here would match the word
1086                // "category" wherever it appears in a title or body, and would
1087                // match `completed` against any issue that happens to use the word.
1088                // This arm exists only to make the refusal **explicit and total**
1089                // rather than accidental: a store-free matcher cannot judge it, and
1090                // [`matches_issue`] is the entry point that does, with the
1091                // criterion carried in the [`StructuredFilter`] that
1092                // [`resolve_filter_criteria`] fills. Reaching this arm with a real
1093                // `category:` token therefore means a caller skipped one of those
1094                // two — and returning `false` for every issue turns that mistake into
1095                // an empty result a user can see, instead of an unfiltered result
1096                // they cannot.
1097                "category" => false,
1098                // The `scope:` twin of the `category` refusal above, and the
1099                // same contract: the columns a scope selects are a config fact
1100                // this store-free matcher does not have, and
1101                // [`resolve_filter_criteria`] is what lifts the token out before
1102                // it ever gets here. Reaching this arm with a real `scope:` token
1103                // means a caller skipped that step — see [`matches_issue`] — and
1104                // returning `false` for every issue turns the mistake into an
1105                // empty result a user can see instead of an unfiltered one they
1106                // cannot. (WP02 T010.)
1107                "scope" => false,
1108                // The `archived:` twin of the `category`/`scope` refusals above
1109                // (issue #1492): the archived flag lives on the filename, not in
1110                // the bytes, so this store-free matcher cannot judge it and
1111                // `resolve_filter_criteria` lifts the token into the structured
1112                // filter instead. A caller that skipped that step gets an empty
1113                // result rather than an unfiltered one.
1114                "archived" => false,
1115                "sort" => matches!(
1116                    value.as_str(),
1117                    "created"
1118                        | "priority"
1119                        | "status"
1120                        | "cycle"
1121                        | "milestone"
1122                        | "due"
1123                        | "start"
1124                        | "reviewer"
1125                ),
1126                "group" => matches!(value.as_str(), "cycle" | "milestone"),
1127                "overdue" => {
1128                    if value == "true" {
1129                        is_overdue_haystack(haystack)
1130                    } else {
1131                        false
1132                    }
1133                }
1134                _ => false,
1135            };
1136            if !ok {
1137                return false;
1138            }
1139        } else {
1140            bare_terms.push(token);
1141        }
1142    }
1143    let bare = bare_terms.join(" ");
1144    bare.is_empty() || fuzzy_score(&bare, haystack).is_some()
1145}
1146
1147fn field_value<'a>(haystack: &'a str, key: &str) -> Option<&'a str> {
1148    for line in haystack.lines() {
1149        let prefix = format!("{key}:");
1150        if let Some(rest) = line.strip_prefix(&prefix) {
1151            return Some(rest.trim().trim_matches('"'));
1152        }
1153    }
1154    None
1155}
1156
1157fn is_overdue_haystack(haystack: &str) -> bool {
1158    let status = field_value(haystack, "status").unwrap_or_default();
1159    let due = field_value(haystack, "due_date").unwrap_or_default();
1160    !due.is_empty() && due < crate::model::today().as_str() && status != "done"
1161}
1162
1163/// Classify a bare-number `/` query: `Some(n)` iff `raw` trims to a
1164/// non-empty run of ASCII digits that fits `u32`. Any other input — empty,
1165/// whitespace-only, non-digit characters, mixed (`55bug`), or overflowing
1166/// `u32` — returns `None`, signalling "not a number query" so the caller
1167/// falls back to fuzzy-text behavior (board-filtering FR-02). Leading zeros
1168/// parse normally (`"055"` → `55`); `"0"` is a valid (if typically
1169/// matchless) number query.
1170pub fn number_query(raw: &str) -> Option<u32> {
1171    let trimmed = raw.trim();
1172    if trimmed.is_empty() || !trimmed.bytes().all(|b| b.is_ascii_digit()) {
1173        return None;
1174    }
1175    trimmed.parse::<u32>().ok()
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180    use super::*;
1181    use crate::model::{Issue, Priority, Status};
1182    use std::fs;
1183
1184    // ----------------------------------------------------------------------
1185    // T007 — corpus builder
1186    // ----------------------------------------------------------------------
1187
1188    fn make_store() -> (tempfile::TempDir, Store) {
1189        let tmp = tempfile::TempDir::new().unwrap();
1190        let dir = tmp.path().join("issues");
1191        fs::create_dir_all(&dir).unwrap();
1192        let config_path = tmp.path().join("tix.toml");
1193        let config = crate::config::Config {
1194            issues_dir: dir.clone(),
1195            ..crate::config::Config::default()
1196        };
1197        config.save(&config_path).unwrap();
1198        let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
1199        let store = Store {
1200            issues_dir: dir,
1201            config,
1202        };
1203        (tmp, store)
1204    }
1205
1206    fn sample_issue(number: u32, title: &str, extra_body: &str) -> Issue {
1207        let mut issue = Issue {
1208            number,
1209            title: title.to_string(),
1210            status: Status::default(),
1211            labels: vec!["urgent".to_string()],
1212            priority: Priority::default(),
1213            assignee: Some("alice".to_string()),
1214            estimate: None,
1215            commit: None,
1216            gitlab_id: None,
1217            linear_id: None,
1218            mission: None,
1219            task: None,
1220            relationships: vec![],
1221            files: vec![],
1222            cycles: None,
1223            milestones: None,
1224            reviewer: None,
1225            start_date: None,
1226            due_date: None,
1227            created: "2026-01-01".to_string(),
1228            updated: "2026-01-01".to_string(),
1229            filename: String::new(),
1230            body: extra_body.to_string(),
1231            history: crate::model::History::new(),
1232            has_retired_links_key: false,
1233            parent: None,
1234            extra_frontmatter: std::collections::BTreeMap::new(),
1235            archived: false,
1236            entered_at: None,
1237            source: None,
1238        };
1239        // Serialized frontmatter carries the title; `filename` is memory-only
1240        // and never written to disk, so it only needs to be non-empty here.
1241        issue.filename = format!("alice/{}-{}.md", "2026-01-01", crate::slug::slugify(title));
1242        issue
1243    }
1244
1245    fn write_issue(store: &Store, name: &str, issue: &Issue) {
1246        fs::write(store.issues_dir.join(name), issue.to_file_contents()).unwrap();
1247    }
1248
1249    #[test]
1250    fn corpus_texts_match_issue_to_file_contents_byte_for_byte() {
1251        let (_tmp, store) = make_store();
1252        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1253        let a = sample_issue(1, "Login bug", "Need to reproduce the redirect loop.");
1254        write_issue(&store, "alice/2026-01-01-login-bug.md", &a);
1255        let b = sample_issue(
1256            2,
1257            "Export csv",
1258            "## Comments\n\n### alice (2026-01-01)\n\nSort order is wrong.\n",
1259        );
1260        write_issue(&store, "alice/2026-01-01-export-csv.md", &b);
1261
1262        let corpus = Corpus::build(&store).unwrap();
1263        assert_eq!(corpus.issues.len(), 2);
1264        // Corpus texts are byte-for-byte the serialized issues, matched by
1265        // stable number lookup regardless of store order.
1266        let idx_a = corpus.by_number(1).unwrap();
1267        let idx_b = corpus.by_number(2).unwrap();
1268        assert_eq!(corpus.texts[idx_a], a.to_file_contents());
1269        assert_eq!(corpus.texts[idx_b], b.to_file_contents());
1270        assert_eq!(corpus.by_number(99), None);
1271    }
1272
1273    #[test]
1274    fn corpus_never_indexes_asset_dirs() {
1275        let (_tmp, store) = make_store();
1276        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1277        let a = sample_issue(1, "Login bug", "body");
1278        write_issue(&store, "alice/2026-01-01-login-bug.md", &a);
1279        // A `.md` straggler inside an `-assets` dir and a real binary asset.
1280        let assets = store.issues_dir.join("alice/login-bug-assets");
1281        fs::create_dir_all(&assets).unwrap();
1282        fs::write(assets.join("notes.md"), "# not an issue\n").unwrap();
1283        fs::write(assets.join("diagram.png"), b"\x89PNG").unwrap();
1284
1285        let corpus = Corpus::build(&store).unwrap();
1286        assert_eq!(corpus.issues.len(), 1);
1287        let evil_found = corpus.texts.iter().any(|t| t.contains("not an issue"));
1288        assert!(
1289            !evil_found,
1290            "asset-dir markdown must not leak into the corpus"
1291        );
1292    }
1293
1294    // ----------------------------------------------------------------------
1295    // T008 — substring matcher + matched context
1296    // ----------------------------------------------------------------------
1297
1298    fn two_issue_corpus() -> Corpus {
1299        let (_tmp, store) = make_store();
1300        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1301        let a = sample_issue(1, "Login bug", "The redirect URL is broken.");
1302        write_issue(&store, "alice/2026-01-01-login-bug.md", &a);
1303        let b = sample_issue(
1304            2,
1305            "Export csv",
1306            "## Comments\n\n### alice (2026-01-01)\n\nHandle empty rows.\n",
1307        );
1308        write_issue(&store, "alice/2026-01-01-export-csv.md", &b);
1309        Corpus::build(&store).unwrap()
1310    }
1311
1312    #[test]
1313    fn substring_matches_frontmatter() {
1314        let corpus = two_issue_corpus();
1315        let hits = search_substring(&corpus, "Login bug");
1316        assert_eq!(hits.len(), 1);
1317        assert_eq!(hits[0].issue.number, 1);
1318        assert!(
1319            hits[0]
1320                .matched_lines
1321                .iter()
1322                .any(|l| l.contains("Login bug")),
1323            "frontmatter title line must appear as context: {hits:?}"
1324        );
1325    }
1326
1327    #[test]
1328    fn substring_matches_comment_text_only() {
1329        let corpus = two_issue_corpus();
1330        let hits = search_substring(&corpus, "empty rows");
1331        assert_eq!(hits.len(), 1);
1332        assert_eq!(hits[0].issue.number, 2);
1333        assert!(
1334            hits[0]
1335                .matched_lines
1336                .iter()
1337                .any(|l| l.contains("empty rows")),
1338            "comment line must appear as context: {hits:?}"
1339        );
1340    }
1341
1342    #[test]
1343    fn substring_matches_label() {
1344        let corpus = two_issue_corpus();
1345        let hits = search_substring(&corpus, "urgent");
1346        assert_eq!(hits.len(), 2, "both issues carry the `urgent` label");
1347        assert_eq!(
1348            hits.iter().map(|h| h.issue.number).collect::<Vec<_>>(),
1349            vec![1, 2],
1350            "stable number-ascending order (NFR-007)"
1351        );
1352    }
1353
1354    #[test]
1355    fn substring_is_case_insensitive() {
1356        let corpus = two_issue_corpus();
1357        assert_eq!(search_substring(&corpus, "LOGIN BUG").len(), 1);
1358        assert_eq!(search_substring(&corpus, "login bug").len(), 1);
1359    }
1360
1361    #[test]
1362    fn substring_orders_by_number_ascending() {
1363        let (_tmp, store) = make_store();
1364        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1365        let hi = sample_issue(9, "Zed bug", "The redirect loop");
1366        let lo = sample_issue(3, "Alpha", "The redirect loop, too");
1367        write_issue(&store, "alice/2026-01-01-zed-bug.md", &hi);
1368        write_issue(&store, "alice/2026-01-01-alpha.md", &lo);
1369        let corpus = Corpus::build(&store).unwrap();
1370        let hits = search_substring(&corpus, "redirect loop");
1371        assert_eq!(
1372            hits.iter().map(|h| h.issue.number).collect::<Vec<_>>(),
1373            vec![3, 9],
1374            "hits sorted by number regardless of file order"
1375        );
1376    }
1377
1378    #[test]
1379    fn substring_zero_matches_is_empty() {
1380        let corpus = two_issue_corpus();
1381        assert!(search_substring(&corpus, "nope-nothing-here").is_empty());
1382    }
1383
1384    #[test]
1385    fn substring_empty_query_is_empty() {
1386        let corpus = two_issue_corpus();
1387        assert!(search_substring(&corpus, "").is_empty());
1388    }
1389
1390    // ----------------------------------------------------------------------
1391    // T009 — fuzzy subsequence scorer
1392    // ----------------------------------------------------------------------
1393
1394    #[test]
1395    fn fuzzy_exact_match_scores_higher_than_partial() {
1396        let exact = fuzzy_score("login bug", "login bug").unwrap();
1397        let partial = fuzzy_score("login bug", "debug the login bug report").unwrap();
1398        assert!(exact > partial);
1399    }
1400
1401    #[test]
1402    fn fuzzy_contiguous_beats_spread() {
1403        let contiguous = fuzzy_score("ab", "xyab").unwrap();
1404        let spread = fuzzy_score("ab", "xayb").unwrap();
1405        assert!(contiguous > spread);
1406    }
1407
1408    #[test]
1409    fn fuzzy_prefix_beats_mid_string() {
1410        let prefix = fuzzy_score("ab", "abxy").unwrap();
1411        let mid = fuzzy_score("ab", "xaby").unwrap();
1412        assert!(prefix > mid);
1413    }
1414
1415    #[test]
1416    fn fuzzy_non_subsequence_is_none() {
1417        assert_eq!(fuzzy_score("abc", "aac"), None);
1418        assert_eq!(fuzzy_score("zzz", "haystack"), None);
1419    }
1420
1421    #[test]
1422    fn fuzzy_is_case_insensitive() {
1423        assert_eq!(
1424            fuzzy_score("LOGIN", "login").unwrap(),
1425            fuzzy_score("login", "login").unwrap()
1426        );
1427        assert_eq!(
1428            fuzzy_score("aBc", "xAbC").unwrap(),
1429            fuzzy_score("abc", "xabc").unwrap()
1430        );
1431    }
1432
1433    #[test]
1434    fn fuzzy_empty_query_scores_zero() {
1435        assert_eq!(fuzzy_score("", "anything"), Some(0));
1436    }
1437
1438    #[test]
1439    fn fuzzy_earlier_positions_beat_later() {
1440        let early = fuzzy_score("ab", "abcxyz").unwrap();
1441        let late = fuzzy_score("ab", "xyzabc").unwrap();
1442        assert!(early > late);
1443    }
1444
1445    #[test]
1446    fn fuzzy_perf_tripwire_500_issue_sweep_stays_fast() {
1447        // Synthetic 500-issue corpus, each ~1KB of prose — modelled on what
1448        // a `to_file_contents()` body actually looks like. The scorer runs
1449        // against each issue's text once per keystroke, so this is the real
1450        // hot path (C-005: no asset-dir binary reads).
1451        let issues: Vec<String> = (0..500u32)
1452            .map(|i| {
1453                format!(
1454                    "---\nnumber: {i}\ntitle: \"synthetic issue {i}\"\npriority: medium\n---\n\n\
1455                     A long body about feature {i} that mentions refunds, retries, idempotency keys, \
1456                     and a blocking redirect loop. The assigned developer needs to investigate the \
1457                     flaky test and prepare a hotfix for the staging deploy.\n\n\
1458                     ## Comments\n\n### alice (2026-01-01)\n\nNotes for issue {i}.\n"
1459                )
1460            })
1461            .collect();
1462        // Score 10 representative queries against the full corpus (each call
1463        // = one issue text per keystroke).
1464        let queries = [
1465            "issue",
1466            "transient race",
1467            "payment",
1468            "hotfix 3",
1469            "aAa",
1470            "zzz",
1471            "issue 499",
1472            "staging",
1473            "deploy",
1474            "ref",
1475        ];
1476        let start = std::time::Instant::now();
1477        for q in &queries {
1478            for issue in &issues {
1479                let _ = fuzzy_score(q, issue);
1480            }
1481        }
1482        let elapsed = start.elapsed();
1483        // 5000 fuzzy_score calls (10 queries × 500 issues); generous 500ms
1484        // bound. On modern hardware this runs in < 10ms.
1485        assert!(
1486            elapsed < std::time::Duration::from_millis(500),
1487            "500-issue corpus re-filter must stay under 500ms, took {elapsed:?}"
1488        );
1489    }
1490
1491    // ----------------------------------------------------------------------
1492    // WP02 phase 0 — T008/T009/T010: StructuredFilter, evaluate, matches
1493    // ----------------------------------------------------------------------
1494
1495    #[test]
1496    fn filter_default_has_no_active_criteria() {
1497        let filter = StructuredFilter::default();
1498        assert!(!filter.has_active_criteria());
1499        assert_eq!(filter.criteria().count(), 0);
1500        assert_eq!(filter.to_string(), "(no active criteria)");
1501    }
1502
1503    #[test]
1504    fn filter_criteria_lists_only_active_in_all_order() {
1505        let filter = StructuredFilter {
1506            label: Some("urgent".to_string()),
1507            assignee: Some(String::new()),
1508            status: Some("todo".to_string()),
1509            priority: None,
1510            cycle: None,
1511            milestone: None,
1512            reviewer: None,
1513            start_date: None,
1514            due_date: None,
1515            overdue: None,
1516            parent: None,
1517            category: None,
1518            scope: None,
1519            archived: None,
1520        };
1521        let criteria: Vec<(FilterField, &str)> = filter.criteria().collect();
1522        assert_eq!(
1523            criteria,
1524            vec![
1525                (FilterField::Label, "urgent"),
1526                (FilterField::Status, "todo")
1527            ]
1528        );
1529    }
1530
1531    // Issue #1492 — the `archived:true|false` criterion and the
1532    // `--with-archived` corpus. The criterion is derived (the filename is the
1533    // marker), so it is lifted into the filter and judged against the issue.
1534
1535    #[test]
1536    fn archived_criterion_matches_the_issue_state() {
1537        let mut archived = sample_issue(1, "Old", "");
1538        archived.archived = true;
1539        let active = sample_issue(2, "New", "");
1540
1541        let want_true = StructuredFilter {
1542            archived: Some(true),
1543            ..Default::default()
1544        };
1545        assert!(want_true.archived_matches(&archived));
1546        assert!(!want_true.archived_matches(&active));
1547
1548        let want_false = StructuredFilter {
1549            archived: Some(false),
1550            ..Default::default()
1551        };
1552        assert!(want_false.archived_matches(&active));
1553        assert!(!want_false.archived_matches(&archived));
1554
1555        // Unset narrows nothing.
1556        assert!(StructuredFilter::default().archived_matches(&archived));
1557    }
1558
1559    #[test]
1560    fn archived_token_is_lifted_out_of_the_query() {
1561        let (_tmp, store) = make_store();
1562        let (query, filter) = resolve_filter_criteria(&store, "archived:true status:todo").unwrap();
1563        assert_eq!(filter.archived, Some(true));
1564        assert!(
1565            !query.contains("archived"),
1566            "the token is lifted, not left for the store-free matcher: {query:?}"
1567        );
1568        assert!(filter.has_active_criteria());
1569        assert_eq!(filter.to_string(), "archived:true");
1570
1571        let (_query, filter) = resolve_filter_criteria(&store, "archived:false").unwrap();
1572        assert_eq!(filter.archived, Some(false));
1573    }
1574
1575    #[test]
1576    fn an_invalid_archived_value_is_refused_by_name() {
1577        let (_tmp, store) = make_store();
1578        let err = resolve_filter_criteria(&store, "archived:1").unwrap_err();
1579        assert!(
1580            err.to_string().contains("archived:true"),
1581            "the diagnostic names the accepted spellings: {err}"
1582        );
1583        // And a caller that skips the lift gets an empty result rather than a
1584        // silently-unfiltered one.
1585        assert!(!query_matches("archived:true", "---\nstatus: todo\n---\n"));
1586    }
1587
1588    #[test]
1589    fn corpus_build_with_includes_archived_issues() {
1590        let (_tmp, store) = make_store();
1591        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1592        let active = sample_issue(1, "Active", "body");
1593        write_issue(&store, "alice/2026-01-01-active.md", &active);
1594        let mut old = sample_issue(2, "Old", "body");
1595        old.archived = true;
1596        fs::write(
1597            store.issues_dir.join("alice/.2026-01-01-old.md"),
1598            old.to_file_contents(),
1599        )
1600        .unwrap();
1601
1602        assert_eq!(
1603            Corpus::build(&store).unwrap().issues.len(),
1604            1,
1605            "the default corpus never reads the archived file"
1606        );
1607        let all = Corpus::build_with(&store, true).unwrap();
1608        assert_eq!(all.issues.len(), 2);
1609        assert!(all.issues.iter().any(|issue| issue.archived));
1610    }
1611
1612    #[test]
1613    fn filter_evaluate_is_case_insensitive_substring() {
1614        let filter = StructuredFilter {
1615            status: Some("TODO".to_string()),
1616            ..Default::default()
1617        };
1618        assert!(filter.evaluate("---\nstatus: todo\n---\n"));
1619        assert!(filter.evaluate("---\nstatus: Todo\n---\n"));
1620        assert!(!filter.evaluate("---\nstatus: done\n---\n"));
1621        // Substring semantics on the serialized text (T009): a status token
1622        // must appear anywhere in the haystack, not only as a standalone field.
1623        assert!(filter.evaluate("todo"));
1624    }
1625
1626    #[test]
1627    fn filter_evaluate_requires_all_active_criteria_and() {
1628        let haystack = "---\nstatus: todo\nlabels: [urgent]\npriority: high\nassignee: alice\n/**/";
1629        let all = StructuredFilter {
1630            assignee: Some("alice".to_string()),
1631            label: Some("urgent".to_string()),
1632            status: Some("todo".to_string()),
1633            priority: Some("high".to_string()),
1634            cycle: None,
1635            milestone: None,
1636            reviewer: None,
1637            start_date: None,
1638            due_date: None,
1639            overdue: None,
1640            parent: None,
1641            category: None,
1642            scope: None,
1643            archived: None,
1644        };
1645        assert!(all.evaluate(haystack), "all four present => match");
1646        // Same filter, haystack missing one criterion => no match (AND).
1647        assert!(!all.evaluate("status: todo labels: [urgent] priority: high"));
1648        assert!(
1649            !all.evaluate(
1650                "---\nstatus: todo\nlabels: [urgent]\npriority: medium\nassignee: alice\n"
1651            )
1652        );
1653        assert!(
1654            !all.evaluate("---\nstatus: todo\nlabels: [other]\npriority: high\nassignee: alice\n")
1655        );
1656    }
1657
1658    #[test]
1659    fn filter_empty_string_criterion_counts_as_unset() {
1660        let filter = StructuredFilter {
1661            assignee: Some(String::new()),
1662            label: Some("urgent".to_string()),
1663            ..Default::default()
1664        };
1665        assert!(
1666            filter.evaluate("labels: [urgent]"),
1667            "empty assignee excluded"
1668        );
1669        assert!(filter.has_active_criteria());
1670    }
1671
1672    #[test]
1673    fn filter_no_criteria_matches_everything() {
1674        let filter = StructuredFilter::default();
1675        assert!(filter.evaluate("anything at all"));
1676        assert!(filter.evaluate(""));
1677    }
1678
1679    #[test]
1680    fn filter_display_renders_key_value_pairs() {
1681        let filter = StructuredFilter {
1682            assignee: Some("alice".to_string()),
1683            status: Some("todo".to_string()),
1684            ..Default::default()
1685        };
1686        assert_eq!(filter.to_string(), "assignee:alice, status:todo");
1687    }
1688
1689    // ----------------------------------------------------------------------
1690    // WP02 T010 — the `scope:` criterion
1691    // ----------------------------------------------------------------------
1692
1693    #[test]
1694    fn filter_scope_is_active_even_though_it_is_not_a_modal_row() {
1695        // A scope is a `StructuredFilter` field but not a `FilterField`, so
1696        // `criteria()` — which walks `FilterField::ALL` — cannot see it. Both
1697        // of the things that report "is this filter doing something" have to
1698        // agree that it is, or the hint bar says `(no active criteria)` while
1699        // the board is narrowed.
1700        let filter = StructuredFilter {
1701            scope: Some(vec!["triage".to_string(), "todo".to_string()]),
1702            ..Default::default()
1703        };
1704
1705        assert!(
1706            filter.has_active_criteria(),
1707            "a filter that selects rows is active however it selects them"
1708        );
1709        assert_eq!(
1710            filter.criteria().count(),
1711            0,
1712            "and it still is not a modal row — the TUI's row count is unchanged"
1713        );
1714        assert_eq!(
1715            filter.to_string(),
1716            "scope:triage,todo",
1717            "the hint bar shows the columns it resolved to, not (no active criteria)"
1718        );
1719    }
1720
1721    #[test]
1722    fn filter_display_keeps_criteria_overdue_and_scope_together() {
1723        let filter = StructuredFilter {
1724            assignee: Some("alice".to_string()),
1725            status: Some("todo".to_string()),
1726            overdue: Some(true),
1727            scope: Some(vec!["triage".to_string()]),
1728            ..Default::default()
1729        };
1730        assert_eq!(
1731            filter.to_string(),
1732            "assignee:alice, status:todo, scope:triage, overdue:true",
1733            "all three sources of a part, in the order they have always printed"
1734        );
1735    }
1736
1737    #[test]
1738    fn filter_scope_round_trips_through_toml() {
1739        // `skip_serializing_if` keeps a scope-less filter byte-identical to what
1740        // it was before WP02, and a set one survives the round trip.
1741        let plain = StructuredFilter::default();
1742        assert!(
1743            !toml::to_string(&plain).unwrap().contains("scope"),
1744            "an unset scope writes no key at all"
1745        );
1746
1747        let filter = StructuredFilter {
1748            scope: Some(vec!["triage".to_string()]),
1749            ..Default::default()
1750        };
1751        let back: StructuredFilter = toml::from_str(&toml::to_string(&filter).unwrap()).unwrap();
1752        assert_eq!(back, filter);
1753    }
1754
1755    #[test]
1756    fn filter_scope_criterion_narrows_evaluate_issue_but_its_absence_does_not() {
1757        let config = mapped_config();
1758        let resolution = resolution_for(&config);
1759        let in_box = issue_in(1, "Alpha", "triage");
1760        let outside = issue_in(2, "Bravo", "wip");
1761        let in_text = in_box.to_file_contents();
1762        let out_text = outside.to_file_contents();
1763
1764        let scoped = StructuredFilter {
1765            scope: Some(vec!["triage".to_string()]),
1766            ..Default::default()
1767        };
1768        assert!(
1769            scoped.evaluate_issue(&in_box, &in_text, &resolution),
1770            "a member of the scope passes"
1771        );
1772        assert!(
1773            !scoped.evaluate_issue(&outside, &out_text, &resolution),
1774            "and a non-member fails — the scope is what was asked for"
1775        );
1776
1777        let unset = StructuredFilter::default();
1778        assert!(
1779            unset.evaluate_issue(&outside, &out_text, &resolution),
1780            "an unset criterion narrows nothing (WP02 T010: a default view is untouched)"
1781        );
1782    }
1783
1784    #[test]
1785    fn filter_normalize_trims_and_drops_empty() {
1786        let mut filter = StructuredFilter {
1787            assignee: Some("  ".to_string()),
1788            label: Some(" urgent ".to_string()),
1789            ..Default::default()
1790        };
1791        filter.normalize();
1792        assert_eq!(
1793            filter,
1794            StructuredFilter {
1795                assignee: None,
1796                label: Some("urgent".to_string()),
1797                ..Default::default()
1798            }
1799        );
1800    }
1801
1802    #[test]
1803    fn filter_round_trips_through_toml() {
1804        let filter = StructuredFilter {
1805            assignee: Some("alice".to_string()),
1806            label: Some("urgent".to_string()),
1807            status: Some("todo".to_string()),
1808            priority: Some("high".to_string()),
1809            cycle: None,
1810            milestone: None,
1811            reviewer: None,
1812            start_date: None,
1813            due_date: None,
1814            overdue: None,
1815            parent: None,
1816            category: None,
1817            scope: None,
1818            archived: None,
1819        };
1820        let text = toml::to_string(&filter).unwrap();
1821        let back: StructuredFilter = toml::from_str(&text).unwrap();
1822        assert_eq!(back, filter);
1823    }
1824
1825    #[test]
1826    fn number_query_classifies_bare_numbers() {
1827        assert_eq!(number_query("55"), Some(55));
1828        assert_eq!(number_query(" 55 "), Some(55), "whitespace-padded trims");
1829        assert_eq!(number_query("055"), Some(55), "leading zeros parse");
1830        assert_eq!(number_query("0"), Some(0));
1831    }
1832
1833    #[test]
1834    fn number_query_rejects_non_number_inputs() {
1835        assert_eq!(number_query(""), None, "empty");
1836        assert_eq!(number_query("   "), None, "whitespace-only");
1837        assert_eq!(number_query("login"), None, "non-digit");
1838        assert_eq!(number_query("55bug"), None, "mixed digits + letters");
1839        assert_eq!(
1840            number_query("99999999999999999999"),
1841            None,
1842            "overflows u32 -> falls back to fuzzy"
1843        );
1844        assert_eq!(number_query("-55"), None, "sign not a bare number");
1845    }
1846
1847    #[test]
1848    fn number_query_accepts_u32_max() {
1849        assert_eq!(number_query(&u32::MAX.to_string()), Some(u32::MAX));
1850    }
1851
1852    #[test]
1853    fn matches_composes_fuzzy_and_structured() {
1854        let filter = StructuredFilter {
1855            label: Some("urgent".to_string()),
1856            ..Default::default()
1857        };
1858        let haystack_1 = "---\ntitle: \"Login bug\"\nlabels: [urgent]\n---\n";
1859        let haystack_2 = "---\ntitle: \"Export csv\"\nlabels: [urgent]\n---\n";
1860
1861        // Both layers must hold when both are active (FR-013).
1862        assert!(matches("Login", &filter, haystack_1));
1863        assert!(
1864            !matches("Login", &filter, haystack_2),
1865            "label matches but text does not"
1866        );
1867        assert!(
1868            !matches("zzz", &filter, haystack_1),
1869            "text matches but label does not"
1870        );
1871
1872        // Empty query => structured filter only.
1873        assert!(matches("", &filter, haystack_1));
1874        assert!(!matches("", &filter, "labels: [critical]\n"));
1875
1876        // Default filter => fuzzy text only.
1877        let default = StructuredFilter::default();
1878        assert!(matches("Login", &default, haystack_1));
1879        assert!(!matches("zzz", &default, haystack_1));
1880
1881        // Empty layers both => always true.
1882        assert!(matches("", &default, "anything"));
1883    }
1884
1885    #[test]
1886    fn matches_against_real_corpus() {
1887        let corpus = two_issue_corpus();
1888        let filter = StructuredFilter {
1889            label: Some("urgent".to_string()),
1890            status: Some("todo".to_string()),
1891            ..Default::default()
1892        };
1893        let matches_by = |query: &str| {
1894            corpus
1895                .issues
1896                .iter()
1897                .zip(&corpus.texts)
1898                .filter(|(_, text)| matches(query, &filter, text))
1899                .map(|(issue, _)| issue.number)
1900                .collect::<std::collections::BTreeSet<u32>>()
1901                .into_iter()
1902                .collect::<Vec<_>>()
1903        };
1904        // Both issues carry `urgent` label + todo status: filter-only
1905        // returns both; fuzzy text narrows: "Login bug" -> issue 1 only,
1906        // "empty rows" -> issue 2 only, miss -> empty.
1907        assert_eq!(matches_by(""), vec![1, 2]);
1908        assert_eq!(matches_by("Login bug"), vec![1]);
1909        assert_eq!(matches_by("empty rows"), vec![2]);
1910        assert_eq!(matches_by("zzz-no-hit"), Vec::<u32>::new());
1911    }
1912
1913    #[test]
1914    fn query_matches_supports_token_grammar_and_bare_terms() {
1915        let haystack = "---\ncycle: Cycle 1\nmilestone: Backlog\nreviewer: alice\nstatus: todo\nstart_date: 2026-09-01\ndue_date: 2000-09-10\n---\nlogin bug\n";
1916        assert!(query_matches("cycle:Cycle", haystack));
1917        assert!(query_matches("milestone:Backlog reviewer:alice", haystack));
1918        assert!(query_matches("overdue:true", haystack));
1919        assert!(query_matches("overdue", haystack));
1920        assert!(query_matches("group:cycle sort:due", haystack));
1921        assert!(!query_matches("group:assignee", haystack));
1922        assert!(query_matches("login bug", haystack));
1923        assert!(!query_matches("cycle:Other", haystack));
1924        assert!(!query_matches("missing term", haystack));
1925    }
1926
1927    #[test]
1928    fn matches_uses_query_tokens_for_board_paths() {
1929        let filter = StructuredFilter::default();
1930        let haystack = "---\nstatus: todo\ndue_date: 2000-01-01\ncycle: Cycle 1\n---\nbody\n";
1931        assert!(matches("overdue cycle:Cycle", &filter, haystack));
1932        assert!(!matches("overdue cycle:Other", &filter, haystack));
1933    }
1934
1935    #[test]
1936    fn structured_filter_overdue_uses_due_date_and_status() {
1937        let filter = StructuredFilter {
1938            overdue: Some(true),
1939            ..Default::default()
1940        };
1941        let overdue_haystack = "---\nstatus: todo\ndue_date: 2000-01-01\n---\nbody\n";
1942        let done_haystack = "---\nstatus: done\ndue_date: 2000-01-01\n---\nbody\n";
1943        assert!(filter.evaluate(overdue_haystack));
1944        assert!(!filter.evaluate(done_haystack));
1945    }
1946
1947    #[test]
1948    fn structured_filter_display_includes_new_fields() {
1949        let filter = StructuredFilter {
1950            cycle: Some("Cycle 1".to_string()),
1951            milestone: Some("Backlog".to_string()),
1952            reviewer: Some("alice".to_string()),
1953            start_date: Some("2026-09-01".to_string()),
1954            due_date: Some("2026-09-10".to_string()),
1955            overdue: Some(true),
1956            ..Default::default()
1957        };
1958        assert!(filter.to_string().contains("cycle:Cycle 1"));
1959        assert!(filter.to_string().contains("overdue:true"));
1960    }
1961
1962    #[test]
1963    fn filter_evaluate_perf_tripwire_500_issue_sweep_stays_fast() {
1964        // Same synthetic corpus as the fuzzy tripwire; `evaluate` runs once
1965        // per entry per re-filter (NFR-001, C-002). With 4 active criteria
1966        // each evaluate is a handful of lowercases + contains calls.
1967        let filter = StructuredFilter {
1968            assignee: Some("alice".to_string()),
1969            label: Some("urgent".to_string()),
1970            status: Some("todo".to_string()),
1971            priority: Some("medium".to_string()),
1972            cycle: None,
1973            milestone: None,
1974            reviewer: None,
1975            start_date: None,
1976            due_date: None,
1977            overdue: None,
1978            parent: None,
1979            category: None,
1980            scope: None,
1981            archived: None,
1982        };
1983        let issues: Vec<String> = (0..500u32)
1984            .map(|i| {
1985                format!(
1986                    "---\nnumber: {i}\ntitle: \"synthetic issue {i}\"\nstatus: todo\nlabels: [urgent]\npriority: medium\nassignee: alice\n---\n\n\
1987                     A long body about feature {i}.\n"
1988                )
1989            })
1990            .collect();
1991        let start = std::time::Instant::now();
1992        for haystack in &issues {
1993            assert!(filter.evaluate(haystack));
1994        }
1995        let elapsed = start.elapsed();
1996        assert!(
1997            elapsed < std::time::Duration::from_millis(500),
1998            "500-issue structured re-filter must stay under 500ms, took {elapsed:?}"
1999        );
2000    }
2001
2002    // -----------------------------------------------------------------------
2003    // WP06 / T024 + T025 — the `parent` criterion
2004    // -----------------------------------------------------------------------
2005
2006    #[test]
2007    fn t024_the_parent_key_is_the_key_the_model_serialises() {
2008        // T024 step 4, the guard against the table and the file format drifting
2009        // apart. `key()` feeds the diagnostics, so a mismatch would make `tix
2010        // lint` point at a field that does not exist, with no error anywhere.
2011        let issue = Issue::parse(
2012            "---\nnumber: 1\ntitle: T\nstatus: todo\nlabels: []\npriority: medium\nparent: 7\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nB.",
2013        )
2014        .unwrap();
2015        let fm = issue.frontmatter();
2016        assert!(
2017            fm.contains(&format!("\n{}:", FilterField::Parent.key())),
2018            "`key()` ({}) must be a key the renderer emits:\n{fm}",
2019            FilterField::Parent.key()
2020        );
2021        // And the reverse direction: every criterion's key is a real field, so the
2022        // guard above is not a one-way check that happens to pass.
2023        for field in FilterField::ALL {
2024            let key = field.key();
2025            if key == "cycle" || key == "milestone" {
2026                // These two are **not** the frontmatter keys: the model writes
2027                // `cycles` and `milestones`, and the query grammar uses the
2028                // singular. Pre-existing, and the reason this assertion is scoped
2029                // to `parent` rather than generalised.
2030                continue;
2031            }
2032            let mut probe = issue.clone();
2033            probe.parent = None;
2034            let rendered = probe.frontmatter();
2035            assert!(
2036                rendered.contains(&format!("\n{key}:")) || rendered.contains("\nlabels:"),
2037                "`{key}` is not a key the model emits"
2038            );
2039        }
2040    }
2041
2042    #[test]
2043    fn t024_all_label_and_key_are_consistent_and_the_nine_keep_their_order() {
2044        // `Parent` is **appended**, so the nine existing criteria keep their
2045        // relative order (T024 step 2) and any user's muscle memory, or any golden
2046        // index, still holds.
2047        let expected: [FilterField; 9] = [
2048            FilterField::Assignee,
2049            FilterField::Label,
2050            FilterField::Status,
2051            FilterField::Priority,
2052            FilterField::Cycle,
2053            FilterField::Milestone,
2054            FilterField::Reviewer,
2055            FilterField::StartDate,
2056            FilterField::DueDate,
2057        ];
2058        assert_eq!(
2059            FilterField::ALL[..9],
2060            expected,
2061            "the existing nine criteria must keep their relative order"
2062        );
2063        assert_eq!(FilterField::ALL[9], FilterField::Parent, "appended tenth");
2064        // WP08 T034 step 2: `Category` is appended for the same reason, so `Parent`
2065        // is no longer last. Both are asserted rather than derived, because a test
2066        // that computed the expectation from `ALL` would agree with any order.
2067        assert_eq!(
2068            FilterField::ALL[10],
2069            FilterField::Category,
2070            "and Category is appended eleventh"
2071        );
2072        assert_eq!(FilterField::ALL.len(), 11, "and ALL must be complete");
2073
2074        // Every variant is in ALL exactly once, so `row()` cannot panic and `next()`
2075        // cannot skip one.
2076        for field in FilterField::ALL {
2077            let row = field.row();
2078            assert_eq!(
2079                FilterField::ALL[row],
2080                field,
2081                "{field:?} is not at its own row"
2082            );
2083            assert_eq!(
2084                FilterField::ALL.iter().filter(|f| **f == field).count(),
2085                1,
2086                "{field:?} appears more than once in ALL"
2087            );
2088            assert!(!field.label().is_empty(), "{field:?} has an empty label");
2089            assert!(!field.key().is_empty(), "{field:?} has an empty key");
2090        }
2091    }
2092
2093    #[test]
2094    fn t024_row_navigation_wraps_at_the_new_bounds() {
2095        // The first version of the table wrapped correctly by accident of the
2096        // bounds; with a tenth row the arithmetic is different, so both ends are
2097        // asserted — and WP08 added an eleventh, so the bound moved again.
2098        assert_eq!(FilterField::Assignee.row(), 0, "the first row");
2099        assert_eq!(FilterField::Parent.row(), 9, "not last any more");
2100        assert_eq!(FilterField::Category.row(), 10, "the new last row");
2101        assert_eq!(
2102            FilterField::Category.next(),
2103            FilterField::Assignee,
2104            "the last row wraps to the first"
2105        );
2106        assert_eq!(
2107            FilterField::Assignee.prev(),
2108            FilterField::Category,
2109            "and the first row's predecessor is the new last"
2110        );
2111        // The boundary is `ALL.len()` in both directions, so a twelfth row would
2112        // wrap correctly without either of the assertions above being edited.
2113        assert_eq!(
2114            FilterField::ALL[FilterField::Assignee.row() + FilterField::ALL.len() - 1],
2115            FilterField::Category,
2116            "prev is a mod of len, not of a hard-coded index"
2117        );
2118        // A full lap visits all eleven, once each.
2119        let mut field = FilterField::Assignee;
2120        let mut seen = Vec::new();
2121        for _ in 0..FilterField::ALL.len() {
2122            seen.push(field);
2123            field = field.next();
2124        }
2125        assert_eq!(field, FilterField::Assignee, "a full lap returns home");
2126        seen.sort_by_key(|f| f.row());
2127        assert_eq!(seen, FilterField::ALL.to_vec(), "visiting each once");
2128    }
2129
2130    fn child_file(number: u32, title: &str, parent: Option<u32>) -> String {
2131        let parent_line = parent.map(|p| format!("parent: {p}\n")).unwrap_or_default();
2132        format!(
2133            "---\nnumber: {number}\ntitle: {title}\nstatus: todo\nlabels: []\npriority: medium\n{parent_line}files: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody text.\n"
2134        )
2135    }
2136
2137    #[test]
2138    fn t025_a_parent_criterion_matches_exactly_its_direct_children() {
2139        // #1 has children #2 and #3; #4 is #2's child, and #5 is unrelated.
2140        let files = [
2141            child_file(1, "Parent", None),
2142            child_file(2, "Child A", Some(1)),
2143            child_file(3, "Child B", Some(1)),
2144            child_file(4, "Grandchild", Some(2)),
2145            child_file(5, "Unrelated", None),
2146        ];
2147        let filter = StructuredFilter {
2148            parent: Some("1".to_string()),
2149            ..StructuredFilter::default()
2150        };
2151        let matched: Vec<u32> = files
2152            .iter()
2153            .filter(|f| filter.evaluate(f))
2154            .map(|f| {
2155                f.lines()
2156                    .find_map(|l| l.strip_prefix("number: "))
2157                    .and_then(|n| n.parse().ok())
2158                    .unwrap()
2159            })
2160            .collect();
2161        assert_eq!(
2162            matched,
2163            vec![2, 3],
2164            "direct children only — a grandchild must not match its grandparent"
2165        );
2166    }
2167
2168    #[test]
2169    fn t025_the_criterion_compares_the_number_exactly_not_as_a_substring() {
2170        // The hazard a substring implementation walks into: `parent: 1` would also
2171        // match `parent: 11`, and `parent: 1` would match any issue whose *body*
2172        // mentions 1.
2173        let filter = StructuredFilter {
2174            parent: Some("1".to_string()),
2175            ..StructuredFilter::default()
2176        };
2177        assert!(filter.evaluate(&child_file(2, "A", Some(1))), "exactly 1");
2178        assert!(
2179            !filter.evaluate(&child_file(2, "A", Some(11))),
2180            "11 must not match a criterion of 1"
2181        );
2182        assert!(!filter.evaluate(&child_file(2, "A", Some(10))), "nor 10");
2183        // An issue with no `parent:` key at all must not match, even though its
2184        // number is 1 and its body mentions 1.
2185        let no_parent = "---\nnumber: 1\ntitle: mentions 1\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody with 1 in it.\n";
2186        assert!(
2187            !filter.evaluate(no_parent),
2188            "an issue with no parent key must not match a parent criterion"
2189        );
2190    }
2191
2192    #[test]
2193    fn t025_a_child_of_a_deleted_parent_matches_nothing() {
2194        // T025 step 4. The recorded reference is retained, so the child still says
2195        // `parent: 1`; but a criterion can only be *set* to a number a user named,
2196        // and once #1 is gone no selector resolves to it — so nothing is reachable
2197        // through the query path, which is the outcome the subtask asks for.
2198        let filter = StructuredFilter {
2199            parent: Some("1".to_string()),
2200            ..StructuredFilter::default()
2201        };
2202        let orphan = child_file(2, "Orphan", Some(1));
2203        // The service reports it unresolved, which is the state that matters.
2204        let parsed = Issue::parse(&orphan).unwrap();
2205        let index = crate::hierarchy::HierarchyIndex::build(std::slice::from_ref(&parsed));
2206        assert_eq!(index.unresolved().len(), 1, "the reference is diagnosed");
2207        // And a criterion naming a *different, live* parent does not match it.
2208        let other = StructuredFilter {
2209            parent: Some("7".to_string()),
2210            ..StructuredFilter::default()
2211        };
2212        assert!(
2213            !other.evaluate(&orphan),
2214            "a live, different parent does not match"
2215        );
2216        // The retained reference still matches its own number, which is the honest
2217        // answer: the field says 1, so a filter saying 1 selects it. Documented in
2218        // contracts/cli-surface.md §4 rather than papered over.
2219        assert!(
2220            filter.evaluate(&orphan),
2221            "the recorded reference is retained, so it still matches its own number"
2222        );
2223    }
2224
2225    #[test]
2226    fn t025_the_query_grammar_matches_parent_exactly_too() {
2227        // The `query_matches` path, which is what `tix list --filter` and the TUI's
2228        // text field use — a different entry point from `StructuredFilter::evaluate`.
2229        let child = child_file(2, "A", Some(1));
2230        assert!(query_matches("parent:1", &child), "exactly 1");
2231        // The negative case has to be a fixture whose **other text** contains `11`,
2232        // or a substring implementation is not actually exercised. The first version
2233        // of this test used a plain fixture whose remaining text happens not to
2234        // contain "11" — so reverting the exact comparison to a substring left the
2235        // test green.
2236        let decoy = child_file(2, "Issue 11 notes", Some(1))
2237            .replace("Body text.\n", "Body text. See issue 11 for context.\n");
2238        assert!(
2239            !query_matches("parent:11", &decoy),
2240            "a body mentioning 11 must not match a criterion of 11"
2241        );
2242        assert!(
2243            query_matches("parent:1", &decoy),
2244            "while the real parent still matches"
2245        );
2246        assert!(
2247            query_matches("parent:11", &child_file(2, "A", Some(11))),
2248            "and 11 matches when it is the real parent"
2249        );
2250        // An empty value is **not** a criterion: `parse_token` rejects a token with
2251        // an empty value, so `parent:` falls through to the bare-term path and is
2252        // fuzzy-matched like any other word — which is what every other key does
2253        // too, and is why `parent` needed no special case here.
2254        //
2255        // The first version of this test asserted `!query_matches("parent:", ..)`,
2256        // reading "not a criterion" as "matches nothing". Measured, it matches — as
2257        // `status:` and `due:` do. An intermediate attempt asserted consistency by
2258        // comparing the same query against two different haystacks, which is not a
2259        // consistency property at all and failed for the right reason: the keys are
2260        // not interchangeable on one haystack, because `parent:` and `status:` are
2261        // literally different strings.
2262        //
2263        // Measured, an empty `parent:` **does** correlate with "has a parent",
2264        // because the bare term happens to be the literal string `parent:` and the
2265        // haystack contains it. That is an accident of the fuzzy path, not a
2266        // criterion: the *values* are ignored entirely, so two issues with
2267        // completely different parents both match.
2268        //
2269        // Asserted as a pair, because the first half alone is a misleading
2270        // statement and the second alone is trivially true. Two earlier versions of
2271        // this test asserted the narrowing property above, which is simply not how
2272        // it behaves.
2273        assert!(
2274            query_matches("parent:", &child_file(2, "A", Some(1)))
2275                && query_matches("parent:", &child_file(3, "B", Some(7))),
2276            "an empty value ignores the parent, so any two parents both match"
2277        );
2278        assert!(
2279            !query_matches("parent:", &child_file(4, "C", None)),
2280            "and an issue with no `parent:` key has no literal to match"
2281        );
2282        // And the substring and fuzzy semantics are untouched when a criterion is
2283        // combined with a bare term.
2284        assert!(
2285            query_matches("parent:1 Body", &child),
2286            "a bare term still applies alongside the criterion"
2287        );
2288        assert!(
2289            !query_matches("parent:1 nonsense", &child),
2290            "and can still exclude"
2291        );
2292    }
2293
2294    #[test]
2295    fn t025_an_empty_criterion_behaves_like_the_others() {
2296        // The canonical invariant: an empty string is "no criterion" everywhere, so
2297        // the filter modal can edit a slot without a live-match surprise.
2298        let mut filter = StructuredFilter::default();
2299        assert_eq!(filter.parent, None);
2300        // The mutable accessor is what the filter modal types into, and what
2301        // creates the empty slot (there is no `set()`; `value_mut` is the path).
2302        assert_eq!(
2303            FilterField::Parent.value_mut(&mut filter),
2304            "",
2305            "value_mut() creates the slot empty"
2306        );
2307        assert_eq!(
2308            FilterField::Parent.value(&filter),
2309            None,
2310            "and value() reports no criterion"
2311        );
2312        assert!(
2313            !filter.has_active_criteria(),
2314            "an empty slot is not an active criterion"
2315        );
2316        // Any other active criterion still counts.
2317        filter.assignee = Some("alice".into());
2318        assert!(filter.has_active_criteria());
2319        FilterField::Parent.unset(&mut filter);
2320        assert_eq!(filter.parent, None, "unset clears the slot");
2321    }
2322    // ----------------------------------------------------------------------
2323    // WP08 T034/T035 — the category criterion
2324    // ----------------------------------------------------------------------
2325
2326    /// A board that **declares a `category_map`**, so the criterion has something
2327    /// to resolve against.
2328    ///
2329    /// `triage=backlog, wip=active, review=active, shipped=completed,
2330    /// abandoned=canceled`. Two properties this fixture is chosen for:
2331    ///
2332    /// - `shipped` is complete and `done_column` is `shipped`, so a column
2333    ///   comparison and a category decision *agree* here — which is why a second
2334    ///   fixture below inverts them.
2335    /// - Three columns share `active`, so `category:active` returning three issues
2336    ///   is a claim a `status:` criterion could not make.
2337    fn mapped_config() -> crate::config::Config {
2338        crate::config::Config {
2339            issues_dir: std::path::PathBuf::from("/nonexistent"),
2340            kanban: crate::config::KanbanSection {
2341                configurations: indexmap::IndexMap::from([(
2342                    "default".to_string(),
2343                    crate::config::BoardConfiguration {
2344                        columns: vec![
2345                            "triage".into(),
2346                            "wip".into(),
2347                            "review".into(),
2348                            "shipped".into(),
2349                            "abandoned".into(),
2350                        ],
2351                        done_column: Some("shipped".to_string()),
2352                        category_map: indexmap::IndexMap::from([
2353                            ("triage".to_string(), "backlog".to_string()),
2354                            ("wip".to_string(), "active".to_string()),
2355                            ("review".to_string(), "active".to_string()),
2356                            ("shipped".to_string(), "completed".to_string()),
2357                            ("abandoned".to_string(), "canceled".to_string()),
2358                        ]),
2359                        ..Default::default()
2360                    },
2361                )]),
2362            },
2363            ..crate::config::Config::default()
2364        }
2365    }
2366
2367    fn issue_in(number: u32, title: &str, column: &str) -> Issue {
2368        let mut issue = sample_issue(number, title, "");
2369        issue.status = Status::new(column);
2370        issue
2371    }
2372
2373    fn filter_for(category: Option<&str>) -> StructuredFilter {
2374        StructuredFilter {
2375            category: category.map(str::to_string),
2376            ..StructuredFilter::default()
2377        }
2378    }
2379
2380    fn resolution_for(config: &crate::config::Config) -> crate::workflow::CategoryResolution {
2381        let (_, resolution) = crate::workflow::active_resolution(config);
2382        resolution
2383    }
2384
2385    #[test]
2386    fn t034_key_reports_the_token_and_is_derived() {
2387        // T034 step 4, the design question. `key()` says `category` because that
2388        // is what the grammar parses and what a hint bar should echo; `is_derived`
2389        // is what stops a surface from reading it as a frontmatter key.
2390        assert_eq!(FilterField::Category.key(), "category");
2391        assert!(FilterField::Category.is_derived());
2392        assert_eq!(
2393            FilterField::Category.label(),
2394            "Category",
2395            "in the existing style"
2396        );
2397        // Every other criterion is a real field, so the derived flag singles out
2398        // exactly one — a test that only checked `Category` would pass if
2399        // `is_derived` matched everything.
2400        for field in FilterField::ALL {
2401            assert_eq!(
2402                field.is_derived(),
2403                field == FilterField::Category,
2404                "{field:?} derived-flag is wrong"
2405            );
2406        }
2407        // The two spellings differ **only** for the derived one.
2408        assert_eq!(FilterField::Assignee.describe(), "assignee");
2409        assert_eq!(
2410            FilterField::Category.describe(),
2411            "category (derived from status)",
2412            "an explanation must not send a user looking for a field that does not exist"
2413        );
2414    }
2415
2416    #[test]
2417    fn t035_a_mapped_completed_column_matches_category_completed() {
2418        let config = mapped_config();
2419        let resolution = resolution_for(&config);
2420        let shipped = issue_in(1, "Shipped", "shipped");
2421        let wip = issue_in(2, "Wip", "wip");
2422        let filter = filter_for(Some("completed"));
2423        assert!(
2424            filter.category_matches(&shipped, &resolution),
2425            "a completed column is in the completed category"
2426        );
2427        assert!(
2428            !filter.category_matches(&wip, &resolution),
2429            "and nothing else is"
2430        );
2431    }
2432
2433    #[test]
2434    fn t035_one_category_spans_every_column_mapped_to_it() {
2435        // The reason the criterion is a category and not a column: three columns
2436        // resolve to `active`, and all three must match.
2437        let config = mapped_config();
2438        let resolution = resolution_for(&config);
2439        let filter = filter_for(Some("active"));
2440        for column in ["wip", "review"] {
2441            assert!(
2442                filter.category_matches(&issue_in(1, "A", column), &resolution),
2443                "{column} is active"
2444            );
2445        }
2446        assert!(
2447            !filter.category_matches(&issue_in(1, "A", "triage"), &resolution),
2448            "and triage is not"
2449        );
2450    }
2451
2452    #[test]
2453    fn t035_an_unconfigured_column_matches_no_category() {
2454        // The third case T035 step 3 does not name, and the one the other two
2455        // surfaces already distinguish: `tix list --show category` prints
2456        // `(unmapped)` and MCP returns `category: null` (WP07) for a column the
2457        // board does not configure. Folding it into the safe default would make
2458        // `category:unstarted` return issues every other surface call unmapped.
2459        let config = mapped_config();
2460        let resolution = resolution_for(&config);
2461        let orphan = issue_in(1, "Orphan", "not-a-column");
2462        for category in ["unstarted", "backlog", "active", "completed", "canceled"] {
2463            assert!(
2464                !filter_for(Some(category)).category_matches(&orphan, &resolution),
2465                "an unconfigured column has no category, so `category:{category}` \\
2466                 must not match it"
2467            );
2468        }
2469    }
2470
2471    #[test]
2472    fn t035_a_canceled_column_is_in_canceled_and_not_in_completed() {
2473        // The C-004 rule, made visible at the criterion: a canceled issue is
2474        // terminal and **not** complete, and a criterion that conflated the two
2475        // would put abandoned work in a "done" search.
2476        let config = mapped_config();
2477        let resolution = resolution_for(&config);
2478        let canceled = issue_in(1, "Abandoned", "abandoned");
2479        assert!(filter_for(Some("canceled")).category_matches(&canceled, &resolution));
2480        assert!(!filter_for(Some("completed")).category_matches(&canceled, &resolution));
2481    }
2482
2483    #[test]
2484    fn t035_evaluate_ignores_the_category_criterion_but_evaluate_issue_does_not() {
2485        // `evaluate` is store-free and therefore cannot judge a derived criterion.
2486        // If it *tried*, `hay.contains("completed")` would match an issue whose
2487        // **body** mentions the word — so the test pins both halves: `evaluate`
2488        // does not apply it, and `evaluate_issue` does.
2489        let config = mapped_config();
2490        let resolution = resolution_for(&config);
2491        let filter = filter_for(Some("completed"));
2492        let in_wip = issue_in(1, "Mentioning shipped work", "wip");
2493        let text = format!(
2494            "{}\n\nwe shipped this last quarter\n",
2495            in_wip.to_file_contents()
2496        );
2497        assert!(
2498            filter.evaluate(&text),
2499            "a substring test would wrongly match the body"
2500        );
2501        assert!(
2502            !filter.evaluate_issue(&in_wip, &text, &resolution),
2503            "the real criterion says no"
2504        );
2505        let done = issue_in(2, "Done", "shipped");
2506        let text = done.to_file_contents();
2507        assert!(filter.evaluate_issue(&done, &text, &resolution));
2508    }
2509
2510    #[test]
2511    fn t035_an_unknown_category_is_an_error_not_an_empty_result() {
2512        // T037 step 5, at the layer that can produce the error. Through the
2513        // composed entry point, because that is what every surface calls — a test of
2514        // the inner function would not show that the *surface* rejects.
2515        let (_tmp, store) = store_with(mapped_config());
2516        let err = crate::search::resolve_filter_criteria(&store, "category:finished")
2517            .unwrap_err()
2518            .to_string();
2519        assert!(err.contains("unknown category 'finished'"), "{err}");
2520        // The message names what *would* have worked — from this project's own
2521        // vocabulary, not the global six.
2522        for key in crate::config::CATEGORY_KEYS {
2523            assert!(err.contains(key), "'{key}' missing from: {err}");
2524        }
2525    }
2526
2527    /// A store over an arbitrary `Config`, holding one issue (`Alpha`, in `triage`),
2528    /// for the command-layer resolvers.
2529    ///
2530    /// The issue is there because `resolve_filter_criteria` resolves a
2531    /// `parent:<selector>` token through the store, and a fixture with no issues
2532    /// would fail every query carrying one — so the composed function could not be
2533    /// exercised on the half of its job that is not about categories.
2534    fn store_with(config: crate::config::Config) -> (tempfile::TempDir, Store) {
2535        let tmp = tempfile::TempDir::new().unwrap();
2536        let dir = tmp.path().join("issues");
2537        fs::create_dir_all(dir.join("alice")).unwrap();
2538        let config = crate::config::Config {
2539            issues_dir: dir.clone(),
2540            ..config
2541        };
2542        config.save(&tmp.path().join("tix.toml")).unwrap();
2543        let config =
2544            crate::config::Config::load_with_warning(&tmp.path().join("tix.toml"), |_| {}).unwrap();
2545        fs::write(
2546            dir.join("alice/2026-01-01-alpha.md"),
2547            "---\nnumber: 1\ntitle: Alpha\nstatus: triage\nlabels: []\n\
2548             priority: medium\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n\
2549             ---\n\nbody\n",
2550        )
2551        .unwrap();
2552        let store = Store {
2553            issues_dir: dir,
2554            config,
2555        };
2556        (tmp, store)
2557    }
2558
2559    #[test]
2560    fn t035_a_reduced_project_vocabulary_rejects_a_key_it_did_not_declare() {
2561        // The project's `order` may be a subset of the global six. `unstarted` is
2562        // in the global set but not in this project, so accepting it would return
2563        // results from a category the project never declared.
2564        let mut config = mapped_config();
2565        config.categories.order = vec![
2566            "backlog".to_string(),
2567            "active".to_string(),
2568            "completed".to_string(),
2569        ];
2570        config.categories.default = "active".to_string();
2571        // The board's own map has to fit inside the reduced vocabulary or the config
2572        // is refused at load. That refusal is the **right** behaviour and a
2573        // constraint the fixture has to honour rather than work around: the first
2574        // version kept `abandoned -> canceled`, so the fixture never reached the
2575        // code under test and the failure it reported was about the board, not
2576        // about the criterion.
2577        let board = config.kanban.configurations.get_mut("default").unwrap();
2578        board.category_map.shift_remove("abandoned");
2579        board.columns.retain(|c| c != "abandoned");
2580        let (_tmp, store) = store_with(config);
2581        let err = crate::search::resolve_filter_criteria(&store, "category:unstarted")
2582            .unwrap_err()
2583            .to_string();
2584        assert!(err.contains("unstarted"), "{err}");
2585        assert!(
2586            !err.contains("canceled"),
2587            "a key the project did not declare must not be offered: {err}"
2588        );
2589        assert!(err.contains("backlog, active, completed"), "{err}");
2590        // A declared key still resolves, and is canonicalized.
2591        let (query, criteria) =
2592            crate::search::resolve_filter_criteria(&store, "category:Completed").unwrap();
2593        assert_eq!(criteria.category.as_deref(), Some("completed"));
2594        assert_eq!(query, "", "and the token is lifted out of the query");
2595    }
2596
2597    #[test]
2598    fn t035_a_query_without_a_category_token_is_unchanged() {
2599        let (_tmp, store) = store_with(mapped_config());
2600        for query in ["", "alice", "assignee:alice", "overdue"] {
2601            let (resolved, criteria) =
2602                crate::search::resolve_filter_criteria(&store, query).unwrap();
2603            assert_eq!(resolved, query, "{query:?} must round-trip");
2604            assert_eq!(criteria.category, None, "{query:?} gained a criterion");
2605        }
2606    }
2607
2608    #[test]
2609    fn t035_a_category_token_is_lifted_out_and_canonicalized() {
2610        // The shape that actually works, and the one the first version got wrong.
2611        // It rewrote the token in place, so `query_matches` still met a `category`
2612        // key, still refused it, and every filter came back empty — while this
2613        // test's own assertion (that the value was canonicalized) passed.
2614        let (_tmp, store) = store_with(mapped_config());
2615        let (query, criteria) =
2616            crate::search::resolve_filter_criteria(&store, "category:COMPLETED alpha").unwrap();
2617        assert_eq!(
2618            criteria.category.as_deref(),
2619            Some("completed"),
2620            "canonical, and carried structurally"
2621        );
2622        assert_eq!(
2623            query, "alpha",
2624            "the token is **removed**: a token left in the string meets a key \
2625             `query_matches` refuses, which is an empty result with no error"
2626        );
2627    }
2628
2629    #[test]
2630    fn t035_the_parent_and_category_resolvers_compose() {
2631        // One entry point, so a call site cannot resolve one and forget the other.
2632        // A `parent:` selector becomes a **number** in the query (a rewrite); a
2633        // `category:` value leaves the query entirely (a lift).
2634        let (_tmp, store) = store_with(mapped_config());
2635        let (query, criteria) =
2636            crate::search::resolve_filter_criteria(&store, "category:completed parent:1").unwrap();
2637        assert_eq!(query, "parent:1", "parent stays, category is lifted");
2638        assert_eq!(criteria.category.as_deref(), Some("completed"));
2639    }
2640
2641    #[test]
2642    fn t035_a_query_with_no_criterion_is_unaffected() {
2643        // T035 step 4: existing substring and fuzzy behaviour must not change.
2644        let config = mapped_config();
2645        let resolution = resolution_for(&config);
2646        let issue = issue_in(1, "Alpha", "wip");
2647        let text = issue.to_file_contents();
2648        let empty = StructuredFilter::default();
2649        assert!(crate::search::matches_issue(
2650            "alpha",
2651            &empty,
2652            &issue,
2653            &text,
2654            &resolution
2655        ));
2656        assert!(crate::search::matches_issue(
2657            "",
2658            &empty,
2659            &issue,
2660            &text,
2661            &resolution
2662        ));
2663        assert!(!crate::search::matches_issue(
2664            "zzz",
2665            &empty,
2666            &issue,
2667            &text,
2668            &resolution
2669        ));
2670    }
2671
2672    #[test]
2673    fn t035_matching_reads_the_service_and_never_the_column_name() {
2674        // A board whose `category_map` puts `completed` on `shipped` while
2675        // `done_column` is `done` — so the two disagree. Second time this
2676        // programme has needed such a board: on `mapped_config` they agree, so a
2677        // column comparison and a category decision cannot tell each other apart.
2678        let config = crate::config::Config {
2679            kanban: crate::config::KanbanSection {
2680                configurations: indexmap::IndexMap::from([(
2681                    "default".to_string(),
2682                    crate::config::BoardConfiguration {
2683                        columns: vec!["todo".into(), "shipped".into(), "done".into()],
2684                        done_column: Some("done".to_string()),
2685                        category_map: indexmap::IndexMap::from([
2686                            ("todo".to_string(), "unstarted".to_string()),
2687                            ("shipped".to_string(), "completed".to_string()),
2688                            ("done".to_string(), "active".to_string()),
2689                        ]),
2690                        ..Default::default()
2691                    },
2692                )]),
2693            },
2694            ..crate::config::Config::default()
2695        };
2696        let resolution = resolution_for(&config);
2697        let shipped = issue_in(1, "Shipped", "shipped");
2698        let done = issue_in(2, "Done column", "done");
2699        assert!(
2700            filter_for(Some("completed")).category_matches(&shipped, &resolution),
2701            "the service says shipped is complete"
2702        );
2703        assert!(
2704            !filter_for(Some("completed")).category_matches(&done, &resolution),
2705            "and `done` is not, even though it is the done_column"
2706        );
2707        // The derived criterion still finds `todo` under the safe default.
2708        assert!(
2709            filter_for(Some("unstarted"))
2710                .category_matches(&issue_in(3, "Todo", "todo"), &resolution)
2711        );
2712    }
2713
2714    #[test]
2715    fn t035_the_query_token_matcher_refuses_rather_than_guessing() {
2716        // `query_matches` cannot judge the criterion, so its `category` arm is an
2717        // explicit total refusal. This pins the *consequence*: a caller that
2718        // skipped `matches_issue` gets an empty result a user can see, not an
2719        // unfiltered one they cannot.
2720        let issue = issue_in(1, "Shipped", "shipped");
2721        let text = issue.to_file_contents();
2722        assert!(!query_matches("category:completed", &text));
2723        // And it does not fall through to a substring test on the word.
2724        let mention = issue_in(2, "Mentions completed", "triage");
2725        assert!(
2726            !query_matches("category:completed", &mention.to_file_contents()),
2727            "and never matches the word in a title"
2728        );
2729    }
2730
2731    #[test]
2732    fn t035_two_boards_with_different_column_names_agree() {
2733        // T037 step 4, the intake's first acceptance scenario at the criterion
2734        // layer. Same mapping, different column names: the criterion is about the
2735        // category, so both boards must return the same answer.
2736        let a = mapped_config();
2737        let b = crate::config::Config {
2738            kanban: crate::config::KanbanSection {
2739                configurations: indexmap::IndexMap::from([(
2740                    "default".to_string(),
2741                    crate::config::BoardConfiguration {
2742                        columns: vec![
2743                            "icebox".into(),
2744                            "doing".into(),
2745                            "verifying".into(),
2746                            "released".into(),
2747                            "dropped".into(),
2748                        ],
2749                        done_column: Some("released".to_string()),
2750                        category_map: indexmap::IndexMap::from([
2751                            ("icebox".to_string(), "backlog".to_string()),
2752                            ("doing".to_string(), "active".to_string()),
2753                            ("verifying".to_string(), "active".to_string()),
2754                            ("released".to_string(), "completed".to_string()),
2755                            ("dropped".to_string(), "canceled".to_string()),
2756                        ]),
2757                        ..Default::default()
2758                    },
2759                )]),
2760            },
2761            ..crate::config::Config::default()
2762        };
2763        for (column_a, column_b) in [
2764            ("triage", "icebox"),
2765            ("wip", "doing"),
2766            ("shipped", "released"),
2767            ("abandoned", "dropped"),
2768        ] {
2769            for key in crate::config::CATEGORY_KEYS {
2770                let want = filter_for(Some(key))
2771                    .category_matches(&issue_in(1, "X", column_a), &resolution_for(&a));
2772                let got = filter_for(Some(key))
2773                    .category_matches(&issue_in(1, "X", column_b), &resolution_for(&b));
2774                assert_eq!(
2775                    want, got,
2776                    "{column_a} ({key}) and {column_b} ({key}) disagree"
2777                );
2778            }
2779        }
2780    }
2781}