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}