Skip to main content

tix/
search.rs

1//! Shared search corpus and matchers for `tix search` (CLI, WP04) and the
2//! `/` fuzzy filter (TUI, WP05).
3//!
4//! Both consumers search **exactly the same corpus**: the complete serialized
5//! markdown of every issue (`Issue::to_file_contents()`, i.e. frontmatter +
6//! body + comments). A single engine here keeps the CLI and TUI behavior from
7//! drifting (C-005, FR-012):
8//!
9//! - [`Corpus::build`] snapshots every issue's full file text.
10//! - [`search_substring`] is the case-insensitive single-occurrence matcher
11//!   the CLI uses, returning matched-line context.
12//! - [`fuzzy_score`] is the deterministic subsequence scorer the TUI uses to
13//!   rank filter results on each keystroke.
14//!
15//! Nothing here touches the `-assets/` directories (C-005): binary contents
16//! are never indexed, and [`Corpus::build`] inherits that from
17//! [`Store::list`], which already excludes non-`.md` files and `-assets` dirs.
18
19use serde::{Deserialize, Serialize};
20
21use crate::error::Result;
22use crate::model::Issue;
23use crate::storage::Store;
24
25/// The corpus: one `Issue` per file (in [`Store::list`] order) and the
26/// byte-for-byte serialized file text matching it.
27pub struct Corpus {
28    pub issues: Vec<Issue>,
29    pub texts: Vec<String>,
30}
31
32impl Corpus {
33    /// Snapshot the full serialized text of every issue under `store`.
34    ///
35    /// `texts[i]` is exactly `issues[i].to_file_contents()`, so the corpus is
36    /// byte-identical to the on-disk files (modulo whatever the serializer
37    /// itself produces). Files are read once here, through [`Store::list`];
38    /// consumers never re-read them (C-005/NFR-007 keep the corpus a single,
39    /// stable source).
40    pub fn build(store: &Store) -> Result<Corpus> {
41        let issues = store.list()?;
42        let texts = issues
43            .iter()
44            .map(|issue| issue.to_file_contents())
45            .collect();
46        Ok(Corpus { issues, texts })
47    }
48
49    /// Index of the issue with `number` `n`, if present (stable, linear scan).
50    pub fn by_number(&self, n: u32) -> Option<usize> {
51        self.issues.iter().position(|issue| issue.number == n)
52    }
53}
54
55/// One substring search hit: the issue and every corpus line that contains
56/// the matched query (context for the CLI result block, NFR-007).
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct SearchHit {
59    pub issue: Issue,
60    pub matched_lines: Vec<String>,
61}
62
63/// Case-insensitive substring search over the corpus.
64///
65/// For each corpus text containing `query` (case-insensitive), every line
66/// containing the match is collected verbatim as matched-line context. Hits
67/// are ordered by issue `number` ascending (stable, NFR-007).
68///
69/// An empty query matches **nothing** by contract: the CLI refuses it via
70/// clap, and the TUI treats an empty filter as "no filter" — callers decide,
71/// this function returns an empty vec so they never see a surprise.
72pub fn search_substring(corpus: &Corpus, query: &str) -> Vec<SearchHit> {
73    if query.is_empty() {
74        return Vec::new();
75    }
76    let needle = query.to_lowercase();
77    let mut hits: Vec<SearchHit> = Vec::new();
78    for (issue, text) in corpus.issues.iter().zip(corpus.texts.iter()) {
79        let hay = text.to_lowercase();
80        if !hay.contains(&needle) {
81            continue;
82        }
83        let matched_lines: Vec<String> = text
84            .lines()
85            .filter(|line| line.to_lowercase().contains(&needle))
86            .map(str::to_string)
87            .collect();
88        hits.push(SearchHit {
89            issue: issue.clone(),
90            matched_lines,
91        });
92    }
93    hits.sort_by_key(|hit| hit.issue.number);
94    hits
95}
96
97/// Deterministic fuzzy subsequence score: higher is a better match, `None`
98/// when `query` is not a subsequence of `haystack` (case-insensitive).
99///
100/// The scorer is the fzf/vim-style "fast substring" heuristic, kept simple
101/// and pure (no normalization, no unicode tables, no dependencies):
102///
103/// - every matched character contributes a presence bonus plus an
104///   earlier-position bonus (`1000 - position`), so matches near the start of
105///   the text outrank those buried deep in it;
106/// - adjacent matched characters (contiguous runs) get a run bonus;
107/// - a match that starts at the very beginning gets a prefix bonus;
108/// - an exact match (equal strings, case-insensitive) dominates.
109///
110/// An empty query is an implicit match with score `Some(0)` — the TUI treats
111/// it as "no filter". Deterministic and fast enough for > 500 issues per
112/// keystroke (verified by the perf tripwire test).
113pub fn fuzzy_score(query: &str, haystack: &str) -> Option<u32> {
114    let q = query.to_lowercase();
115    let h = haystack.to_lowercase();
116    if q.is_empty() {
117        return Some(0);
118    }
119    if q == h {
120        return Some(EXACT_MATCH_SCORE);
121    }
122    let positions = greedy_subsequence_positions(&q, &h)?;
123    let mut score = 0u32;
124    let mut prev: Option<usize> = None;
125    for pos in &positions {
126        score += 100;
127        score += (1000 - (*pos).min(1000)) as u32;
128        if prev.is_some_and(|p| *pos == p + 1) {
129            score += RUN_BONUS;
130        }
131        prev = Some(*pos);
132    }
133    if positions.first() == Some(&0) {
134        score += PREFIX_BONUS;
135    }
136    Some(score)
137}
138
139/// Bonus for a contiguous pair of matched characters.
140const RUN_BONUS: u32 = 150;
141/// Bonus when the first matched character is the first character of the text.
142const PREFIX_BONUS: u32 = 500;
143/// Score for an exact (case-insensitive) equality — dominates any partial.
144const EXACT_MATCH_SCORE: u32 = 50_000;
145
146/// Greedy leftmost subsequence match, case-insensitive. Returns the haystack
147/// positions of `query`'s characters in order, or `None` when `query` is not
148/// a subsequence of `haystack`.
149fn greedy_subsequence_positions(query: &str, haystack: &str) -> Option<Vec<usize>> {
150    let q: Vec<char> = query.chars().collect();
151    if q.is_empty() {
152        return Some(Vec::new());
153    }
154    let h: Vec<char> = haystack.chars().collect();
155    let mut positions = Vec::with_capacity(q.len());
156    let mut hi = 0usize;
157    /* tolerate differing-cased surrogates the same way the lowercase scan does */
158    for qc in &q {
159        let found = h[hi..]
160            .iter()
161            .position(|hc| *hc == *qc)
162            .map(|off| off + hi)?;
163        positions.push(found);
164        hi = found + 1;
165    }
166    Some(positions)
167}
168
169// ---------------------------------------------------------------------------
170// Structured filtering (FR-007..FR-013)
171// ---------------------------------------------------------------------------
172
173/// A selectable structured-filter criterion.
174///
175/// [`FilterField::ALL`] is the single ordered field list the filter modal
176/// renders its rows from (risk: field/modal drift) and [`StructuredFilter`]
177/// evaluates over — adding a criterion (FR-011) is one more `Option<String>`
178/// field on [`StructuredFilter`] plus one variant / match arm here.
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum FilterField {
181    Assignee,
182    Label,
183    Status,
184    Priority,
185}
186
187impl FilterField {
188    /// The criteria in modal row order (research D-05).
189    pub const ALL: [FilterField; 4] = [
190        FilterField::Assignee,
191        FilterField::Label,
192        FilterField::Status,
193        FilterField::Priority,
194    ];
195
196    pub fn label(self) -> &'static str {
197        match self {
198            FilterField::Assignee => "Assignee",
199            FilterField::Label => "Label",
200            FilterField::Status => "Status",
201            FilterField::Priority => "Priority",
202        }
203    }
204
205    /// The serialized frontmatter key each criterion matches against
206    /// (`Issue::to_file_contents` emits these). Used by diagnostics.
207    pub fn key(self) -> &'static str {
208        match self {
209            FilterField::Assignee => "assignee",
210            FilterField::Label => "labels",
211            FilterField::Status => "status",
212            FilterField::Priority => "priority",
213        }
214    }
215
216    /// Row index of this criterion in [`FilterField::ALL`].
217    pub fn row(self) -> usize {
218        FilterField::ALL
219            .iter()
220            .position(|field| *field == self)
221            .expect("FilterField variants are all in ALL")
222    }
223
224    pub fn next(self) -> Self {
225        let all = FilterField::ALL;
226        all[(self.row() + 1) % all.len()]
227    }
228
229    pub fn prev(self) -> Self {
230        let all = FilterField::ALL;
231        all[(self.row() + all.len() - 1) % all.len()]
232    }
233
234    /// The criterion value when set *and* non-empty. An empty string is
235    /// treated as "no criterion" everywhere (canonical invariant), so the
236    /// filter modal can edit a slot without a live-match surprise.
237    pub fn value(self, filter: &StructuredFilter) -> Option<&str> {
238        let value = match self {
239            FilterField::Assignee => filter.assignee.as_deref(),
240            FilterField::Label => filter.label.as_deref(),
241            FilterField::Status => filter.status.as_deref(),
242            FilterField::Priority => filter.priority.as_deref(),
243        };
244        value.filter(|s| !s.is_empty())
245    }
246
247    /// Mutable access to the criterion buffer, materializing an empty one
248    /// when the criterion is unset (the filter modal types into it and
249    /// `StructuredFilter::normalize` promotes empty back to `None`).
250    pub fn value_mut(self, filter: &mut StructuredFilter) -> &mut String {
251        match self {
252            FilterField::Assignee => filter.assignee.get_or_insert_default(),
253            FilterField::Label => filter.label.get_or_insert_default(),
254            FilterField::Status => filter.status.get_or_insert_default(),
255            FilterField::Priority => filter.priority.get_or_insert_default(),
256        }
257    }
258
259    /// Clear the criterion back to `None`.
260    pub fn unset(self, filter: &mut StructuredFilter) {
261        match self {
262            FilterField::Assignee => filter.assignee = None,
263            FilterField::Label => filter.label = None,
264            FilterField::Status => filter.status = None,
265            FilterField::Priority => filter.priority = None,
266        }
267    }
268}
269
270/// Typed, extensible structured filter (FR-007..FR-010, FR-011).
271///
272/// Every criterion is optional; an `None`/empty criterion narrows nothing.
273/// Criteria match **case-insensitively** against the serialized corpus text
274/// (the same haystack [`fuzzy_score`] runs over), following the existing
275/// fuzzy matcher's `to_lowercase()` behavior — this keeps the predicate a
276/// pure, config-independent function of the corpus (WP02 runs in parallel
277/// with WP01 for exactly that reason).
278#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
279pub struct StructuredFilter {
280    pub assignee: Option<String>,
281    pub label: Option<String>,
282    pub status: Option<String>,
283    pub priority: Option<String>,
284}
285
286impl StructuredFilter {
287    /// The active (set, non-empty) criteria, in [`FilterField::ALL`] order.
288    pub fn criteria(&self) -> impl Iterator<Item = (FilterField, &str)> {
289        FilterField::ALL
290            .into_iter()
291            .filter_map(move |field| field.value(self).map(|value| (field, value)))
292    }
293
294    /// True when at least one criterion is active. When false the board must
295    /// render unchanged (no criteria → pass-through).
296    pub fn has_active_criteria(&self) -> bool {
297        self.criteria().next().is_some()
298    }
299
300    /// AND predicate over the serialized corpus text (FR-012, C-002).
301    ///
302    /// Applies *every* active criterion; the first miss short-circuits the
303    /// entry to `false`. The caller (WP03's `build_filtered`) invokes this
304    /// once per corpus entry, giving **one linear O(entries) pass** (NFR-001)
305    /// — `haystack` is lowercased once and each active criterion is a single
306    /// substring `contains` on that pre-lowered text, with no nested
307    /// field×corpus loops.
308    ///
309    /// Matching is case-insensitive and substring-based, consistently across
310    /// assignee/label/status/priority, exactly as WP02's prompt specifies
311    /// (T009). The stricter per-criterion "equals"/"contains-in-labels" rules
312    /// in `contracts/filtering.md` would require re-parsing the issue; on the
313    /// serialized corpus the substring rule is the single consistent gate.
314    pub fn evaluate(&self, haystack: &str) -> bool {
315        let hay = haystack.to_lowercase();
316        for (_, value) in self.criteria() {
317            if !hay.contains(&value.to_lowercase()) {
318                return false;
319            }
320        }
321        true
322    }
323
324    /// Trim every criterion; values that trim to empty become `None` (the
325    /// canonical "unset" state). Used by the filter modal's commit path.
326    pub fn normalize(&mut self) {
327        for field in FilterField::ALL {
328            let empty = field.value_mut(self).trim().is_empty();
329            if empty {
330                field.unset(self);
331            } else {
332                let value = field.value_mut(self);
333                let trimmed = value.trim().to_string();
334                *value = trimmed;
335            }
336        }
337    }
338}
339
340impl std::fmt::Display for StructuredFilter {
341    /// Diagnostics for the hint bar: `assignee:alice, status:todo`, or
342    /// `(no active criteria)`.
343    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
344        let mut criteria = self.criteria();
345        let Some((field, value)) = criteria.next() else {
346            return write!(f, "(no active criteria)");
347        };
348        write!(f, "{}:{value}", field.key())?;
349        for (field, value) in criteria {
350            write!(f, ", {}:{value}", field.key())?;
351        }
352        Ok(())
353    }
354}
355
356/// Composite predicate: the `/` fuzzy text layer **and** the structured
357/// filter both apply (FR-013).
358///
359/// - empty `query` → structured filter only;
360/// - empty structured filter → text query only;
361/// - both active → an entry must satisfy **both** layers.
362///
363/// Zero matches is a valid board, never an error (consumers render empty
364/// columns).
365pub fn matches(query: &str, filter: &StructuredFilter, haystack: &str) -> bool {
366    let text_matches = query.is_empty() || fuzzy_score(query, haystack).is_some();
367    text_matches && filter.evaluate(haystack)
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::model::{Issue, Priority, Status};
374    use std::fs;
375
376    // ----------------------------------------------------------------------
377    // T007 — corpus builder
378    // ----------------------------------------------------------------------
379
380    fn make_store() -> (tempfile::TempDir, Store) {
381        let tmp = tempfile::TempDir::new().unwrap();
382        let dir = tmp.path().join("issues");
383        fs::create_dir_all(&dir).unwrap();
384        let config_path = tmp.path().join("tix.toml");
385        let config = crate::config::Config {
386            issues_dir: dir.clone(),
387            ..crate::config::Config::default()
388        };
389        config.save(&config_path).unwrap();
390        let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
391        let store = Store {
392            issues_dir: dir,
393            config,
394        };
395        (tmp, store)
396    }
397
398    fn sample_issue(number: u32, title: &str, extra_body: &str) -> Issue {
399        let mut issue = Issue {
400            number,
401            title: title.to_string(),
402            status: Status::default(),
403            labels: vec!["urgent".to_string()],
404            priority: Priority::default(),
405            assignee: Some("alice".to_string()),
406            estimate: None,
407            commit: None,
408            gitlab_id: None,
409            linear_id: None,
410            mission: None,
411            task: None,
412            created: "2026-01-01".to_string(),
413            updated: "2026-01-01".to_string(),
414            filename: String::new(),
415            body: extra_body.to_string(),
416        };
417        // Serialized frontmatter carries the title; `filename` is memory-only
418        // and never written to disk, so it only needs to be non-empty here.
419        issue.filename = format!("alice/{}-{}.md", "2026-01-01", crate::slug::slugify(title));
420        issue
421    }
422
423    fn write_issue(store: &Store, name: &str, issue: &Issue) {
424        fs::write(store.issues_dir.join(name), issue.to_file_contents()).unwrap();
425    }
426
427    #[test]
428    fn corpus_texts_match_issue_to_file_contents_byte_for_byte() {
429        let (_tmp, store) = make_store();
430        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
431        let a = sample_issue(1, "Login bug", "Need to reproduce the redirect loop.");
432        write_issue(&store, "alice/2026-01-01-login-bug.md", &a);
433        let b = sample_issue(
434            2,
435            "Export csv",
436            "## Comments\n\n### alice (2026-01-01)\n\nSort order is wrong.\n",
437        );
438        write_issue(&store, "alice/2026-01-01-export-csv.md", &b);
439
440        let corpus = Corpus::build(&store).unwrap();
441        assert_eq!(corpus.issues.len(), 2);
442        // Corpus texts are byte-for-byte the serialized issues, matched by
443        // stable number lookup regardless of store order.
444        let idx_a = corpus.by_number(1).unwrap();
445        let idx_b = corpus.by_number(2).unwrap();
446        assert_eq!(corpus.texts[idx_a], a.to_file_contents());
447        assert_eq!(corpus.texts[idx_b], b.to_file_contents());
448        assert_eq!(corpus.by_number(99), None);
449    }
450
451    #[test]
452    fn corpus_never_indexes_asset_dirs() {
453        let (_tmp, store) = make_store();
454        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
455        let a = sample_issue(1, "Login bug", "body");
456        write_issue(&store, "alice/2026-01-01-login-bug.md", &a);
457        // A `.md` straggler inside an `-assets` dir and a real binary asset.
458        let assets = store.issues_dir.join("alice/login-bug-assets");
459        fs::create_dir_all(&assets).unwrap();
460        fs::write(assets.join("notes.md"), "# not an issue\n").unwrap();
461        fs::write(assets.join("diagram.png"), b"\x89PNG").unwrap();
462
463        let corpus = Corpus::build(&store).unwrap();
464        assert_eq!(corpus.issues.len(), 1);
465        let evil_found = corpus.texts.iter().any(|t| t.contains("not an issue"));
466        assert!(
467            !evil_found,
468            "asset-dir markdown must not leak into the corpus"
469        );
470    }
471
472    // ----------------------------------------------------------------------
473    // T008 — substring matcher + matched context
474    // ----------------------------------------------------------------------
475
476    fn two_issue_corpus() -> Corpus {
477        let (_tmp, store) = make_store();
478        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
479        let a = sample_issue(1, "Login bug", "The redirect URL is broken.");
480        write_issue(&store, "alice/2026-01-01-login-bug.md", &a);
481        let b = sample_issue(
482            2,
483            "Export csv",
484            "## Comments\n\n### alice (2026-01-01)\n\nHandle empty rows.\n",
485        );
486        write_issue(&store, "alice/2026-01-01-export-csv.md", &b);
487        Corpus::build(&store).unwrap()
488    }
489
490    #[test]
491    fn substring_matches_frontmatter() {
492        let corpus = two_issue_corpus();
493        let hits = search_substring(&corpus, "Login bug");
494        assert_eq!(hits.len(), 1);
495        assert_eq!(hits[0].issue.number, 1);
496        assert!(
497            hits[0]
498                .matched_lines
499                .iter()
500                .any(|l| l.contains("Login bug")),
501            "frontmatter title line must appear as context: {hits:?}"
502        );
503    }
504
505    #[test]
506    fn substring_matches_comment_text_only() {
507        let corpus = two_issue_corpus();
508        let hits = search_substring(&corpus, "empty rows");
509        assert_eq!(hits.len(), 1);
510        assert_eq!(hits[0].issue.number, 2);
511        assert!(
512            hits[0]
513                .matched_lines
514                .iter()
515                .any(|l| l.contains("empty rows")),
516            "comment line must appear as context: {hits:?}"
517        );
518    }
519
520    #[test]
521    fn substring_matches_label() {
522        let corpus = two_issue_corpus();
523        let hits = search_substring(&corpus, "urgent");
524        assert_eq!(hits.len(), 2, "both issues carry the `urgent` label");
525        assert_eq!(
526            hits.iter().map(|h| h.issue.number).collect::<Vec<_>>(),
527            vec![1, 2],
528            "stable number-ascending order (NFR-007)"
529        );
530    }
531
532    #[test]
533    fn substring_is_case_insensitive() {
534        let corpus = two_issue_corpus();
535        assert_eq!(search_substring(&corpus, "LOGIN BUG").len(), 1);
536        assert_eq!(search_substring(&corpus, "login bug").len(), 1);
537    }
538
539    #[test]
540    fn substring_orders_by_number_ascending() {
541        let (_tmp, store) = make_store();
542        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
543        let hi = sample_issue(9, "Zed bug", "The redirect loop");
544        let lo = sample_issue(3, "Alpha", "The redirect loop, too");
545        write_issue(&store, "alice/2026-01-01-zed-bug.md", &hi);
546        write_issue(&store, "alice/2026-01-01-alpha.md", &lo);
547        let corpus = Corpus::build(&store).unwrap();
548        let hits = search_substring(&corpus, "redirect loop");
549        assert_eq!(
550            hits.iter().map(|h| h.issue.number).collect::<Vec<_>>(),
551            vec![3, 9],
552            "hits sorted by number regardless of file order"
553        );
554    }
555
556    #[test]
557    fn substring_zero_matches_is_empty() {
558        let corpus = two_issue_corpus();
559        assert!(search_substring(&corpus, "nope-nothing-here").is_empty());
560    }
561
562    #[test]
563    fn substring_empty_query_is_empty() {
564        let corpus = two_issue_corpus();
565        assert!(search_substring(&corpus, "").is_empty());
566    }
567
568    // ----------------------------------------------------------------------
569    // T009 — fuzzy subsequence scorer
570    // ----------------------------------------------------------------------
571
572    #[test]
573    fn fuzzy_exact_match_scores_higher_than_partial() {
574        let exact = fuzzy_score("login bug", "login bug").unwrap();
575        let partial = fuzzy_score("login bug", "debug the login bug report").unwrap();
576        assert!(exact > partial);
577    }
578
579    #[test]
580    fn fuzzy_contiguous_beats_spread() {
581        let contiguous = fuzzy_score("ab", "xyab").unwrap();
582        let spread = fuzzy_score("ab", "xayb").unwrap();
583        assert!(contiguous > spread);
584    }
585
586    #[test]
587    fn fuzzy_prefix_beats_mid_string() {
588        let prefix = fuzzy_score("ab", "abxy").unwrap();
589        let mid = fuzzy_score("ab", "xaby").unwrap();
590        assert!(prefix > mid);
591    }
592
593    #[test]
594    fn fuzzy_non_subsequence_is_none() {
595        assert_eq!(fuzzy_score("abc", "aac"), None);
596        assert_eq!(fuzzy_score("zzz", "haystack"), None);
597    }
598
599    #[test]
600    fn fuzzy_is_case_insensitive() {
601        assert_eq!(
602            fuzzy_score("LOGIN", "login").unwrap(),
603            fuzzy_score("login", "login").unwrap()
604        );
605        assert_eq!(
606            fuzzy_score("aBc", "xAbC").unwrap(),
607            fuzzy_score("abc", "xabc").unwrap()
608        );
609    }
610
611    #[test]
612    fn fuzzy_empty_query_scores_zero() {
613        assert_eq!(fuzzy_score("", "anything"), Some(0));
614    }
615
616    #[test]
617    fn fuzzy_earlier_positions_beat_later() {
618        let early = fuzzy_score("ab", "abcxyz").unwrap();
619        let late = fuzzy_score("ab", "xyzabc").unwrap();
620        assert!(early > late);
621    }
622
623    #[test]
624    fn fuzzy_perf_tripwire_500_issue_sweep_stays_fast() {
625        // Synthetic 500-issue corpus, each ~1KB of prose — modelled on what
626        // a `to_file_contents()` body actually looks like. The scorer runs
627        // against each issue's text once per keystroke, so this is the real
628        // hot path (C-005: no asset-dir binary reads).
629        let issues: Vec<String> = (0..500u32)
630            .map(|i| {
631                format!(
632                    "---\nnumber: {i}\ntitle: \"synthetic issue {i}\"\npriority: medium\n---\n\n\
633                     A long body about feature {i} that mentions refunds, retries, idempotency keys, \
634                     and a blocking redirect loop. The assigned developer needs to investigate the \
635                     flaky test and prepare a hotfix for the staging deploy.\n\n\
636                     ## Comments\n\n### alice (2026-01-01)\n\nNotes for issue {i}.\n"
637                )
638            })
639            .collect();
640        // Score 10 representative queries against the full corpus (each call
641        // = one issue text per keystroke).
642        let queries = [
643            "issue",
644            "transient race",
645            "payment",
646            "hotfix 3",
647            "aAa",
648            "zzz",
649            "issue 499",
650            "staging",
651            "deploy",
652            "ref",
653        ];
654        let start = std::time::Instant::now();
655        for q in &queries {
656            for issue in &issues {
657                let _ = fuzzy_score(q, issue);
658            }
659        }
660        let elapsed = start.elapsed();
661        // 5000 fuzzy_score calls (10 queries × 500 issues); generous 500ms
662        // bound. On modern hardware this runs in < 10ms.
663        assert!(
664            elapsed < std::time::Duration::from_millis(500),
665            "500-issue corpus re-filter must stay under 500ms, took {elapsed:?}"
666        );
667    }
668
669    // ----------------------------------------------------------------------
670    // WP02 phase 0 — T008/T009/T010: StructuredFilter, evaluate, matches
671    // ----------------------------------------------------------------------
672
673    #[test]
674    fn filter_default_has_no_active_criteria() {
675        let filter = StructuredFilter::default();
676        assert!(!filter.has_active_criteria());
677        assert_eq!(filter.criteria().count(), 0);
678        assert_eq!(filter.to_string(), "(no active criteria)");
679    }
680
681    #[test]
682    fn filter_criteria_lists_only_active_in_all_order() {
683        let filter = StructuredFilter {
684            label: Some("urgent".to_string()),
685            assignee: Some(String::new()),
686            status: Some("todo".to_string()),
687            priority: None,
688        };
689        let criteria: Vec<(FilterField, &str)> = filter.criteria().collect();
690        assert_eq!(
691            criteria,
692            vec![
693                (FilterField::Label, "urgent"),
694                (FilterField::Status, "todo")
695            ]
696        );
697    }
698
699    #[test]
700    fn filter_evaluate_is_case_insensitive_substring() {
701        let filter = StructuredFilter {
702            status: Some("TODO".to_string()),
703            ..Default::default()
704        };
705        assert!(filter.evaluate("---\nstatus: todo\n---\n"));
706        assert!(filter.evaluate("---\nstatus: Todo\n---\n"));
707        assert!(!filter.evaluate("---\nstatus: done\n---\n"));
708        // Substring semantics on the serialized text (T009): a status token
709        // must appear anywhere in the haystack, not only as a standalone field.
710        assert!(filter.evaluate("todo"));
711    }
712
713    #[test]
714    fn filter_evaluate_requires_all_active_criteria_and() {
715        let haystack = "---\nstatus: todo\nlabels: [urgent]\npriority: high\nassignee: alice\n/**/";
716        let all = StructuredFilter {
717            assignee: Some("alice".to_string()),
718            label: Some("urgent".to_string()),
719            status: Some("todo".to_string()),
720            priority: Some("high".to_string()),
721        };
722        assert!(all.evaluate(haystack), "all four present => match");
723        // Same filter, haystack missing one criterion => no match (AND).
724        assert!(!all.evaluate("status: todo labels: [urgent] priority: high"));
725        assert!(
726            !all.evaluate(
727                "---\nstatus: todo\nlabels: [urgent]\npriority: medium\nassignee: alice\n"
728            )
729        );
730        assert!(
731            !all.evaluate("---\nstatus: todo\nlabels: [other]\npriority: high\nassignee: alice\n")
732        );
733    }
734
735    #[test]
736    fn filter_empty_string_criterion_counts_as_unset() {
737        let filter = StructuredFilter {
738            assignee: Some(String::new()),
739            label: Some("urgent".to_string()),
740            ..Default::default()
741        };
742        assert!(
743            filter.evaluate("labels: [urgent]"),
744            "empty assignee excluded"
745        );
746        assert!(filter.has_active_criteria());
747    }
748
749    #[test]
750    fn filter_no_criteria_matches_everything() {
751        let filter = StructuredFilter::default();
752        assert!(filter.evaluate("anything at all"));
753        assert!(filter.evaluate(""));
754    }
755
756    #[test]
757    fn filter_display_renders_key_value_pairs() {
758        let filter = StructuredFilter {
759            assignee: Some("alice".to_string()),
760            status: Some("todo".to_string()),
761            ..Default::default()
762        };
763        assert_eq!(filter.to_string(), "assignee:alice, status:todo");
764    }
765
766    #[test]
767    fn filter_normalize_trims_and_drops_empty() {
768        let mut filter = StructuredFilter {
769            assignee: Some("  ".to_string()),
770            label: Some(" urgent ".to_string()),
771            ..Default::default()
772        };
773        filter.normalize();
774        assert_eq!(
775            filter,
776            StructuredFilter {
777                assignee: None,
778                label: Some("urgent".to_string()),
779                ..Default::default()
780            }
781        );
782    }
783
784    #[test]
785    fn filter_round_trips_through_toml() {
786        let filter = StructuredFilter {
787            assignee: Some("alice".to_string()),
788            label: Some("urgent".to_string()),
789            status: Some("todo".to_string()),
790            priority: Some("high".to_string()),
791        };
792        let text = toml::to_string(&filter).unwrap();
793        let back: StructuredFilter = toml::from_str(&text).unwrap();
794        assert_eq!(back, filter);
795    }
796
797    #[test]
798    fn matches_composes_fuzzy_and_structured() {
799        let filter = StructuredFilter {
800            label: Some("urgent".to_string()),
801            ..Default::default()
802        };
803        let haystack_1 = "---\ntitle: \"Login bug\"\nlabels: [urgent]\n---\n";
804        let haystack_2 = "---\ntitle: \"Export csv\"\nlabels: [urgent]\n---\n";
805
806        // Both layers must hold when both are active (FR-013).
807        assert!(matches("Login", &filter, haystack_1));
808        assert!(
809            !matches("Login", &filter, haystack_2),
810            "label matches but text does not"
811        );
812        assert!(
813            !matches("zzz", &filter, haystack_1),
814            "text matches but label does not"
815        );
816
817        // Empty query => structured filter only.
818        assert!(matches("", &filter, haystack_1));
819        assert!(!matches("", &filter, "labels: [critical]\n"));
820
821        // Default filter => fuzzy text only.
822        let default = StructuredFilter::default();
823        assert!(matches("Login", &default, haystack_1));
824        assert!(!matches("zzz", &default, haystack_1));
825
826        // Empty layers both => always true.
827        assert!(matches("", &default, "anything"));
828    }
829
830    #[test]
831    fn matches_against_real_corpus() {
832        let corpus = two_issue_corpus();
833        let filter = StructuredFilter {
834            label: Some("urgent".to_string()),
835            status: Some("todo".to_string()),
836            ..Default::default()
837        };
838        let matches_by = |query: &str| {
839            corpus
840                .issues
841                .iter()
842                .zip(&corpus.texts)
843                .filter(|(_, text)| matches(query, &filter, text))
844                .map(|(issue, _)| issue.number)
845                .collect::<std::collections::BTreeSet<u32>>()
846                .into_iter()
847                .collect::<Vec<_>>()
848        };
849        // Both issues carry `urgent` label + todo status: filter-only
850        // returns both; fuzzy text narrows: "Login bug" -> issue 1 only,
851        // "empty rows" -> issue 2 only, miss -> empty.
852        assert_eq!(matches_by(""), vec![1, 2]);
853        assert_eq!(matches_by("Login bug"), vec![1]);
854        assert_eq!(matches_by("empty rows"), vec![2]);
855        assert_eq!(matches_by("zzz-no-hit"), Vec::<u32>::new());
856    }
857
858    #[test]
859    fn filter_evaluate_perf_tripwire_500_issue_sweep_stays_fast() {
860        // Same synthetic corpus as the fuzzy tripwire; `evaluate` runs once
861        // per entry per re-filter (NFR-001, C-002). With 4 active criteria
862        // each evaluate is a handful of lowercases + contains calls.
863        let filter = StructuredFilter {
864            assignee: Some("alice".to_string()),
865            label: Some("urgent".to_string()),
866            status: Some("todo".to_string()),
867            priority: Some("medium".to_string()),
868        };
869        let issues: Vec<String> = (0..500u32)
870            .map(|i| {
871                format!(
872                    "---\nnumber: {i}\ntitle: \"synthetic issue {i}\"\nstatus: todo\nlabels: [urgent]\npriority: medium\nassignee: alice\n---\n\n\
873                     A long body about feature {i}.\n"
874                )
875            })
876            .collect();
877        let start = std::time::Instant::now();
878        for haystack in &issues {
879            assert!(filter.evaluate(haystack));
880        }
881        let elapsed = start.elapsed();
882        assert!(
883            elapsed < std::time::Duration::from_millis(500),
884            "500-issue structured re-filter must stay under 500ms, took {elapsed:?}"
885        );
886    }
887}