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/// Classify a bare-number `/` query: `Some(n)` iff `raw` trims to a
371/// non-empty run of ASCII digits that fits `u32`. Any other input — empty,
372/// whitespace-only, non-digit characters, mixed (`55bug`), or overflowing
373/// `u32` — returns `None`, signalling "not a number query" so the caller
374/// falls back to fuzzy-text behavior (board-filtering FR-02). Leading zeros
375/// parse normally (`"055"` → `55`); `"0"` is a valid (if typically
376/// matchless) number query.
377pub fn number_query(raw: &str) -> Option<u32> {
378    let trimmed = raw.trim();
379    if trimmed.is_empty() || !trimmed.bytes().all(|b| b.is_ascii_digit()) {
380        return None;
381    }
382    trimmed.parse::<u32>().ok()
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use crate::model::{Issue, Priority, Status};
389    use std::fs;
390
391    // ----------------------------------------------------------------------
392    // T007 — corpus builder
393    // ----------------------------------------------------------------------
394
395    fn make_store() -> (tempfile::TempDir, Store) {
396        let tmp = tempfile::TempDir::new().unwrap();
397        let dir = tmp.path().join("issues");
398        fs::create_dir_all(&dir).unwrap();
399        let config_path = tmp.path().join("tix.toml");
400        let config = crate::config::Config {
401            issues_dir: dir.clone(),
402            ..crate::config::Config::default()
403        };
404        config.save(&config_path).unwrap();
405        let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
406        let store = Store {
407            issues_dir: dir,
408            config,
409        };
410        (tmp, store)
411    }
412
413    fn sample_issue(number: u32, title: &str, extra_body: &str) -> Issue {
414        let mut issue = Issue {
415            number,
416            title: title.to_string(),
417            status: Status::default(),
418            labels: vec!["urgent".to_string()],
419            priority: Priority::default(),
420            assignee: Some("alice".to_string()),
421            estimate: None,
422            commit: None,
423            gitlab_id: None,
424            linear_id: None,
425            mission: None,
426            task: None,
427            links: vec![],
428            files: vec![],
429            created: "2026-01-01".to_string(),
430            updated: "2026-01-01".to_string(),
431            filename: String::new(),
432            body: extra_body.to_string(),
433        };
434        // Serialized frontmatter carries the title; `filename` is memory-only
435        // and never written to disk, so it only needs to be non-empty here.
436        issue.filename = format!("alice/{}-{}.md", "2026-01-01", crate::slug::slugify(title));
437        issue
438    }
439
440    fn write_issue(store: &Store, name: &str, issue: &Issue) {
441        fs::write(store.issues_dir.join(name), issue.to_file_contents()).unwrap();
442    }
443
444    #[test]
445    fn corpus_texts_match_issue_to_file_contents_byte_for_byte() {
446        let (_tmp, store) = make_store();
447        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
448        let a = sample_issue(1, "Login bug", "Need to reproduce the redirect loop.");
449        write_issue(&store, "alice/2026-01-01-login-bug.md", &a);
450        let b = sample_issue(
451            2,
452            "Export csv",
453            "## Comments\n\n### alice (2026-01-01)\n\nSort order is wrong.\n",
454        );
455        write_issue(&store, "alice/2026-01-01-export-csv.md", &b);
456
457        let corpus = Corpus::build(&store).unwrap();
458        assert_eq!(corpus.issues.len(), 2);
459        // Corpus texts are byte-for-byte the serialized issues, matched by
460        // stable number lookup regardless of store order.
461        let idx_a = corpus.by_number(1).unwrap();
462        let idx_b = corpus.by_number(2).unwrap();
463        assert_eq!(corpus.texts[idx_a], a.to_file_contents());
464        assert_eq!(corpus.texts[idx_b], b.to_file_contents());
465        assert_eq!(corpus.by_number(99), None);
466    }
467
468    #[test]
469    fn corpus_never_indexes_asset_dirs() {
470        let (_tmp, store) = make_store();
471        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
472        let a = sample_issue(1, "Login bug", "body");
473        write_issue(&store, "alice/2026-01-01-login-bug.md", &a);
474        // A `.md` straggler inside an `-assets` dir and a real binary asset.
475        let assets = store.issues_dir.join("alice/login-bug-assets");
476        fs::create_dir_all(&assets).unwrap();
477        fs::write(assets.join("notes.md"), "# not an issue\n").unwrap();
478        fs::write(assets.join("diagram.png"), b"\x89PNG").unwrap();
479
480        let corpus = Corpus::build(&store).unwrap();
481        assert_eq!(corpus.issues.len(), 1);
482        let evil_found = corpus.texts.iter().any(|t| t.contains("not an issue"));
483        assert!(
484            !evil_found,
485            "asset-dir markdown must not leak into the corpus"
486        );
487    }
488
489    // ----------------------------------------------------------------------
490    // T008 — substring matcher + matched context
491    // ----------------------------------------------------------------------
492
493    fn two_issue_corpus() -> Corpus {
494        let (_tmp, store) = make_store();
495        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
496        let a = sample_issue(1, "Login bug", "The redirect URL is broken.");
497        write_issue(&store, "alice/2026-01-01-login-bug.md", &a);
498        let b = sample_issue(
499            2,
500            "Export csv",
501            "## Comments\n\n### alice (2026-01-01)\n\nHandle empty rows.\n",
502        );
503        write_issue(&store, "alice/2026-01-01-export-csv.md", &b);
504        Corpus::build(&store).unwrap()
505    }
506
507    #[test]
508    fn substring_matches_frontmatter() {
509        let corpus = two_issue_corpus();
510        let hits = search_substring(&corpus, "Login bug");
511        assert_eq!(hits.len(), 1);
512        assert_eq!(hits[0].issue.number, 1);
513        assert!(
514            hits[0]
515                .matched_lines
516                .iter()
517                .any(|l| l.contains("Login bug")),
518            "frontmatter title line must appear as context: {hits:?}"
519        );
520    }
521
522    #[test]
523    fn substring_matches_comment_text_only() {
524        let corpus = two_issue_corpus();
525        let hits = search_substring(&corpus, "empty rows");
526        assert_eq!(hits.len(), 1);
527        assert_eq!(hits[0].issue.number, 2);
528        assert!(
529            hits[0]
530                .matched_lines
531                .iter()
532                .any(|l| l.contains("empty rows")),
533            "comment line must appear as context: {hits:?}"
534        );
535    }
536
537    #[test]
538    fn substring_matches_label() {
539        let corpus = two_issue_corpus();
540        let hits = search_substring(&corpus, "urgent");
541        assert_eq!(hits.len(), 2, "both issues carry the `urgent` label");
542        assert_eq!(
543            hits.iter().map(|h| h.issue.number).collect::<Vec<_>>(),
544            vec![1, 2],
545            "stable number-ascending order (NFR-007)"
546        );
547    }
548
549    #[test]
550    fn substring_is_case_insensitive() {
551        let corpus = two_issue_corpus();
552        assert_eq!(search_substring(&corpus, "LOGIN BUG").len(), 1);
553        assert_eq!(search_substring(&corpus, "login bug").len(), 1);
554    }
555
556    #[test]
557    fn substring_orders_by_number_ascending() {
558        let (_tmp, store) = make_store();
559        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
560        let hi = sample_issue(9, "Zed bug", "The redirect loop");
561        let lo = sample_issue(3, "Alpha", "The redirect loop, too");
562        write_issue(&store, "alice/2026-01-01-zed-bug.md", &hi);
563        write_issue(&store, "alice/2026-01-01-alpha.md", &lo);
564        let corpus = Corpus::build(&store).unwrap();
565        let hits = search_substring(&corpus, "redirect loop");
566        assert_eq!(
567            hits.iter().map(|h| h.issue.number).collect::<Vec<_>>(),
568            vec![3, 9],
569            "hits sorted by number regardless of file order"
570        );
571    }
572
573    #[test]
574    fn substring_zero_matches_is_empty() {
575        let corpus = two_issue_corpus();
576        assert!(search_substring(&corpus, "nope-nothing-here").is_empty());
577    }
578
579    #[test]
580    fn substring_empty_query_is_empty() {
581        let corpus = two_issue_corpus();
582        assert!(search_substring(&corpus, "").is_empty());
583    }
584
585    // ----------------------------------------------------------------------
586    // T009 — fuzzy subsequence scorer
587    // ----------------------------------------------------------------------
588
589    #[test]
590    fn fuzzy_exact_match_scores_higher_than_partial() {
591        let exact = fuzzy_score("login bug", "login bug").unwrap();
592        let partial = fuzzy_score("login bug", "debug the login bug report").unwrap();
593        assert!(exact > partial);
594    }
595
596    #[test]
597    fn fuzzy_contiguous_beats_spread() {
598        let contiguous = fuzzy_score("ab", "xyab").unwrap();
599        let spread = fuzzy_score("ab", "xayb").unwrap();
600        assert!(contiguous > spread);
601    }
602
603    #[test]
604    fn fuzzy_prefix_beats_mid_string() {
605        let prefix = fuzzy_score("ab", "abxy").unwrap();
606        let mid = fuzzy_score("ab", "xaby").unwrap();
607        assert!(prefix > mid);
608    }
609
610    #[test]
611    fn fuzzy_non_subsequence_is_none() {
612        assert_eq!(fuzzy_score("abc", "aac"), None);
613        assert_eq!(fuzzy_score("zzz", "haystack"), None);
614    }
615
616    #[test]
617    fn fuzzy_is_case_insensitive() {
618        assert_eq!(
619            fuzzy_score("LOGIN", "login").unwrap(),
620            fuzzy_score("login", "login").unwrap()
621        );
622        assert_eq!(
623            fuzzy_score("aBc", "xAbC").unwrap(),
624            fuzzy_score("abc", "xabc").unwrap()
625        );
626    }
627
628    #[test]
629    fn fuzzy_empty_query_scores_zero() {
630        assert_eq!(fuzzy_score("", "anything"), Some(0));
631    }
632
633    #[test]
634    fn fuzzy_earlier_positions_beat_later() {
635        let early = fuzzy_score("ab", "abcxyz").unwrap();
636        let late = fuzzy_score("ab", "xyzabc").unwrap();
637        assert!(early > late);
638    }
639
640    #[test]
641    fn fuzzy_perf_tripwire_500_issue_sweep_stays_fast() {
642        // Synthetic 500-issue corpus, each ~1KB of prose — modelled on what
643        // a `to_file_contents()` body actually looks like. The scorer runs
644        // against each issue's text once per keystroke, so this is the real
645        // hot path (C-005: no asset-dir binary reads).
646        let issues: Vec<String> = (0..500u32)
647            .map(|i| {
648                format!(
649                    "---\nnumber: {i}\ntitle: \"synthetic issue {i}\"\npriority: medium\n---\n\n\
650                     A long body about feature {i} that mentions refunds, retries, idempotency keys, \
651                     and a blocking redirect loop. The assigned developer needs to investigate the \
652                     flaky test and prepare a hotfix for the staging deploy.\n\n\
653                     ## Comments\n\n### alice (2026-01-01)\n\nNotes for issue {i}.\n"
654                )
655            })
656            .collect();
657        // Score 10 representative queries against the full corpus (each call
658        // = one issue text per keystroke).
659        let queries = [
660            "issue",
661            "transient race",
662            "payment",
663            "hotfix 3",
664            "aAa",
665            "zzz",
666            "issue 499",
667            "staging",
668            "deploy",
669            "ref",
670        ];
671        let start = std::time::Instant::now();
672        for q in &queries {
673            for issue in &issues {
674                let _ = fuzzy_score(q, issue);
675            }
676        }
677        let elapsed = start.elapsed();
678        // 5000 fuzzy_score calls (10 queries × 500 issues); generous 500ms
679        // bound. On modern hardware this runs in < 10ms.
680        assert!(
681            elapsed < std::time::Duration::from_millis(500),
682            "500-issue corpus re-filter must stay under 500ms, took {elapsed:?}"
683        );
684    }
685
686    // ----------------------------------------------------------------------
687    // WP02 phase 0 — T008/T009/T010: StructuredFilter, evaluate, matches
688    // ----------------------------------------------------------------------
689
690    #[test]
691    fn filter_default_has_no_active_criteria() {
692        let filter = StructuredFilter::default();
693        assert!(!filter.has_active_criteria());
694        assert_eq!(filter.criteria().count(), 0);
695        assert_eq!(filter.to_string(), "(no active criteria)");
696    }
697
698    #[test]
699    fn filter_criteria_lists_only_active_in_all_order() {
700        let filter = StructuredFilter {
701            label: Some("urgent".to_string()),
702            assignee: Some(String::new()),
703            status: Some("todo".to_string()),
704            priority: None,
705        };
706        let criteria: Vec<(FilterField, &str)> = filter.criteria().collect();
707        assert_eq!(
708            criteria,
709            vec![
710                (FilterField::Label, "urgent"),
711                (FilterField::Status, "todo")
712            ]
713        );
714    }
715
716    #[test]
717    fn filter_evaluate_is_case_insensitive_substring() {
718        let filter = StructuredFilter {
719            status: Some("TODO".to_string()),
720            ..Default::default()
721        };
722        assert!(filter.evaluate("---\nstatus: todo\n---\n"));
723        assert!(filter.evaluate("---\nstatus: Todo\n---\n"));
724        assert!(!filter.evaluate("---\nstatus: done\n---\n"));
725        // Substring semantics on the serialized text (T009): a status token
726        // must appear anywhere in the haystack, not only as a standalone field.
727        assert!(filter.evaluate("todo"));
728    }
729
730    #[test]
731    fn filter_evaluate_requires_all_active_criteria_and() {
732        let haystack = "---\nstatus: todo\nlabels: [urgent]\npriority: high\nassignee: alice\n/**/";
733        let all = StructuredFilter {
734            assignee: Some("alice".to_string()),
735            label: Some("urgent".to_string()),
736            status: Some("todo".to_string()),
737            priority: Some("high".to_string()),
738        };
739        assert!(all.evaluate(haystack), "all four present => match");
740        // Same filter, haystack missing one criterion => no match (AND).
741        assert!(!all.evaluate("status: todo labels: [urgent] priority: high"));
742        assert!(
743            !all.evaluate(
744                "---\nstatus: todo\nlabels: [urgent]\npriority: medium\nassignee: alice\n"
745            )
746        );
747        assert!(
748            !all.evaluate("---\nstatus: todo\nlabels: [other]\npriority: high\nassignee: alice\n")
749        );
750    }
751
752    #[test]
753    fn filter_empty_string_criterion_counts_as_unset() {
754        let filter = StructuredFilter {
755            assignee: Some(String::new()),
756            label: Some("urgent".to_string()),
757            ..Default::default()
758        };
759        assert!(
760            filter.evaluate("labels: [urgent]"),
761            "empty assignee excluded"
762        );
763        assert!(filter.has_active_criteria());
764    }
765
766    #[test]
767    fn filter_no_criteria_matches_everything() {
768        let filter = StructuredFilter::default();
769        assert!(filter.evaluate("anything at all"));
770        assert!(filter.evaluate(""));
771    }
772
773    #[test]
774    fn filter_display_renders_key_value_pairs() {
775        let filter = StructuredFilter {
776            assignee: Some("alice".to_string()),
777            status: Some("todo".to_string()),
778            ..Default::default()
779        };
780        assert_eq!(filter.to_string(), "assignee:alice, status:todo");
781    }
782
783    #[test]
784    fn filter_normalize_trims_and_drops_empty() {
785        let mut filter = StructuredFilter {
786            assignee: Some("  ".to_string()),
787            label: Some(" urgent ".to_string()),
788            ..Default::default()
789        };
790        filter.normalize();
791        assert_eq!(
792            filter,
793            StructuredFilter {
794                assignee: None,
795                label: Some("urgent".to_string()),
796                ..Default::default()
797            }
798        );
799    }
800
801    #[test]
802    fn filter_round_trips_through_toml() {
803        let filter = StructuredFilter {
804            assignee: Some("alice".to_string()),
805            label: Some("urgent".to_string()),
806            status: Some("todo".to_string()),
807            priority: Some("high".to_string()),
808        };
809        let text = toml::to_string(&filter).unwrap();
810        let back: StructuredFilter = toml::from_str(&text).unwrap();
811        assert_eq!(back, filter);
812    }
813
814    #[test]
815    fn number_query_classifies_bare_numbers() {
816        assert_eq!(number_query("55"), Some(55));
817        assert_eq!(number_query(" 55 "), Some(55), "whitespace-padded trims");
818        assert_eq!(number_query("055"), Some(55), "leading zeros parse");
819        assert_eq!(number_query("0"), Some(0));
820    }
821
822    #[test]
823    fn number_query_rejects_non_number_inputs() {
824        assert_eq!(number_query(""), None, "empty");
825        assert_eq!(number_query("   "), None, "whitespace-only");
826        assert_eq!(number_query("login"), None, "non-digit");
827        assert_eq!(number_query("55bug"), None, "mixed digits + letters");
828        assert_eq!(
829            number_query("99999999999999999999"),
830            None,
831            "overflows u32 -> falls back to fuzzy"
832        );
833        assert_eq!(number_query("-55"), None, "sign not a bare number");
834    }
835
836    #[test]
837    fn number_query_accepts_u32_max() {
838        assert_eq!(number_query(&u32::MAX.to_string()), Some(u32::MAX));
839    }
840
841    #[test]
842    fn matches_composes_fuzzy_and_structured() {
843        let filter = StructuredFilter {
844            label: Some("urgent".to_string()),
845            ..Default::default()
846        };
847        let haystack_1 = "---\ntitle: \"Login bug\"\nlabels: [urgent]\n---\n";
848        let haystack_2 = "---\ntitle: \"Export csv\"\nlabels: [urgent]\n---\n";
849
850        // Both layers must hold when both are active (FR-013).
851        assert!(matches("Login", &filter, haystack_1));
852        assert!(
853            !matches("Login", &filter, haystack_2),
854            "label matches but text does not"
855        );
856        assert!(
857            !matches("zzz", &filter, haystack_1),
858            "text matches but label does not"
859        );
860
861        // Empty query => structured filter only.
862        assert!(matches("", &filter, haystack_1));
863        assert!(!matches("", &filter, "labels: [critical]\n"));
864
865        // Default filter => fuzzy text only.
866        let default = StructuredFilter::default();
867        assert!(matches("Login", &default, haystack_1));
868        assert!(!matches("zzz", &default, haystack_1));
869
870        // Empty layers both => always true.
871        assert!(matches("", &default, "anything"));
872    }
873
874    #[test]
875    fn matches_against_real_corpus() {
876        let corpus = two_issue_corpus();
877        let filter = StructuredFilter {
878            label: Some("urgent".to_string()),
879            status: Some("todo".to_string()),
880            ..Default::default()
881        };
882        let matches_by = |query: &str| {
883            corpus
884                .issues
885                .iter()
886                .zip(&corpus.texts)
887                .filter(|(_, text)| matches(query, &filter, text))
888                .map(|(issue, _)| issue.number)
889                .collect::<std::collections::BTreeSet<u32>>()
890                .into_iter()
891                .collect::<Vec<_>>()
892        };
893        // Both issues carry `urgent` label + todo status: filter-only
894        // returns both; fuzzy text narrows: "Login bug" -> issue 1 only,
895        // "empty rows" -> issue 2 only, miss -> empty.
896        assert_eq!(matches_by(""), vec![1, 2]);
897        assert_eq!(matches_by("Login bug"), vec![1]);
898        assert_eq!(matches_by("empty rows"), vec![2]);
899        assert_eq!(matches_by("zzz-no-hit"), Vec::<u32>::new());
900    }
901
902    #[test]
903    fn filter_evaluate_perf_tripwire_500_issue_sweep_stays_fast() {
904        // Same synthetic corpus as the fuzzy tripwire; `evaluate` runs once
905        // per entry per re-filter (NFR-001, C-002). With 4 active criteria
906        // each evaluate is a handful of lowercases + contains calls.
907        let filter = StructuredFilter {
908            assignee: Some("alice".to_string()),
909            label: Some("urgent".to_string()),
910            status: Some("todo".to_string()),
911            priority: Some("medium".to_string()),
912        };
913        let issues: Vec<String> = (0..500u32)
914            .map(|i| {
915                format!(
916                    "---\nnumber: {i}\ntitle: \"synthetic issue {i}\"\nstatus: todo\nlabels: [urgent]\npriority: medium\nassignee: alice\n---\n\n\
917                     A long body about feature {i}.\n"
918                )
919            })
920            .collect();
921        let start = std::time::Instant::now();
922        for haystack in &issues {
923            assert!(filter.evaluate(haystack));
924        }
925        let elapsed = start.elapsed();
926        assert!(
927            elapsed < std::time::Duration::from_millis(500),
928            "500-issue structured re-filter must stay under 500ms, took {elapsed:?}"
929        );
930    }
931}