Skip to main content

tix/search/
corpus.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4/// The corpus: one `Issue` per file (in [`Store::list`] order) and the
5/// byte-for-byte serialized file text matching it.
6pub struct Corpus {
7    pub issues: Vec<Issue>,
8    /// Serialized file text per issue, built on first use (tix #1520).
9    ///
10    /// `texts()[i]` is exactly `issues[i].to_file_contents()` — the same
11    /// invariant [`Corpus::build`] has always upheld — but *when* it is
12    /// computed is now deferred: a startup that never searches should not pay
13    /// to serialize every issue. Access through [`Corpus::texts`].
14    texts: std::cell::OnceCell<Vec<String>>,
15}
16
17impl Corpus {
18    /// Snapshot the full serialized text of every issue under `store`.
19    ///
20    /// `texts[i]` is exactly `issues[i].to_file_contents()`, so the corpus is
21    /// byte-identical to the on-disk files (modulo whatever the serializer
22    /// itself produces). Files are read once here, through [`Store::list`];
23    /// consumers never re-read them (C-005/NFR-007 keep the corpus a single,
24    /// stable source).
25    pub fn build(store: &Store) -> Result<Corpus> {
26        Self::build_with(store, false)
27    }
28
29    /// Snapshot the corpus, optionally including archived issues (issue #1492).
30    ///
31    /// `with_archived == false` is the default path and reads
32    /// [`Store::list`], which never opens an archived file. `true` reads
33    /// [`Store::list_all`] so `tix search --with-archived` can match the archived
34    /// set; the archived issues carry `archived == true`.
35    pub fn build_with(store: &Store, with_archived: bool) -> Result<Corpus> {
36        let issues = if with_archived {
37            store.list_all()?
38        } else {
39            store.list()?
40        };
41        Ok(Self::from_issues(issues))
42    }
43
44    /// Snapshot a corpus from an **already-read** issue set (tix #1518).
45    ///
46    /// The TUI's startup reads the tree exactly once and shares that single
47    /// enumeration between the board, the rollover plan and this corpus; this
48    /// constructor is that sharing point. It upholds the same invariant
49    /// [`Corpus::build`] does — `texts[i] == issues[i].to_file_contents()` — so a
50    /// corpus built here is byte-identical to one built by a fresh walk.
51    pub fn from_issues(issues: Vec<Issue>) -> Corpus {
52        // No serialization here: `texts()` builds the cached snapshot on first
53        // use (tix #1520). Startup that never searches pays nothing.
54        Corpus {
55            issues,
56            texts: std::cell::OnceCell::new(),
57        }
58    }
59
60    /// The serialized file text of every issue, built on first use and cached
61    /// thereafter (tix #1520).
62    ///
63    /// `texts()[i]` is exactly `issues[i].to_file_contents()`, so a caller
64    /// cannot tell whether the corpus was built by a full walk or shared from an
65    /// already-read issue set — only that it is not paying to serialize issues
66    /// no search will ever look at.
67    pub fn texts(&self) -> &[String] {
68        self.texts.get_or_init(|| {
69            self.issues
70                .iter()
71                .map(|issue| issue.to_file_contents())
72                .collect()
73        })
74    }
75
76    /// Build a corpus with a fixed serialized-text snapshot (tests only).
77    ///
78    /// Production always goes through [`Corpus::from_issues`], which defers
79    /// serialization; a test that wants to pin the matcher's input without a
80    /// store uses this instead of reaching into the private cache.
81    #[cfg(test)]
82    pub fn with_texts(issues: Vec<Issue>, texts: Vec<String>) -> Corpus {
83        Corpus {
84            issues,
85            texts: std::cell::OnceCell::from(texts),
86        }
87    }
88
89    /// Whether the serialized snapshot has been built yet (tests only).
90    #[cfg(test)]
91    pub fn texts_initialized(&self) -> bool {
92        self.texts.get().is_some()
93    }
94
95    /// Index of the issue with `number` `n`, if present (stable, linear scan).
96    pub fn by_number(&self, n: u32) -> Option<usize> {
97        self.issues.iter().position(|issue| issue.number == n)
98    }
99
100    /// Apply a filesystem delta to the corpus without re-reading the tree
101    /// (issue #10 follow-up).
102    ///
103    /// Re-reads only the paths the watcher reported changed, drops the removed
104    /// ones, and keeps `texts[i] == issues[i].to_file_contents()` and the
105    /// corpus ordered by `filename` exactly as [`Store::scan`] returns it — so
106    /// an incrementally updated corpus is byte-identical to a full
107    /// [`Corpus::build_with`] over the same files.
108    ///
109    /// Paths [`Store::scan`] would not index are ignored: non-`.md` files,
110    /// files in a `.tix-tmp-*`/`*-assets/` segment, and anything nested deeper
111    /// than one segment (see [`crate::storage::is_issue_rel_path`]). A file
112    /// that vanished or cannot be read since the watcher saw it counts as
113    /// removed. A **parse** failure is returned as an error with the corpus
114    /// left untouched — all-or-nothing — so a half-written file cannot replace
115    /// the last good corpus, and so the caller's board stays as it was.
116    ///
117    /// The delta is applied as given: the archived-view policy lives in the
118    /// caller (`App::refresh_from_disk` strips archived paths when the archived
119    /// toggle is off).
120    pub fn apply(&mut self, store: &Store, delta: &FsDelta) -> Result<()> {
121        if !delta
122            .changed
123            .iter()
124            .any(|p| crate::storage::is_issue_rel_path(p))
125            && !delta
126                .removed
127                .iter()
128                .any(|p| crate::storage::is_issue_rel_path(p))
129        {
130            return Ok(());
131        }
132        // Read every changed file first: a parse error must leave `self` as it
133        // was, which is only possible if nothing is mutated before all reads
134        // succeed.
135        let mut updates: Vec<Issue> = Vec::new();
136        for path in &delta.changed {
137            if !crate::storage::is_issue_rel_path(path) {
138                continue;
139            }
140            match store.read_issue_at(path) {
141                Ok(issue) => updates.push(issue),
142                // Gone or unreadable since the walk: a removal, not an error.
143                Err(TixError::NotFound(_) | TixError::Io(_)) => {}
144                Err(e) => return Err(e),
145            }
146        }
147        // Every indexable changed path replaces its corpus entry, and every
148        // indexable removed path drops one.
149        let dropped: HashSet<&Path> = delta
150            .changed
151            .iter()
152            .chain(delta.removed.iter())
153            .filter(|path| crate::storage::is_issue_rel_path(path))
154            .map(PathBuf::as_path)
155            .collect();
156        let mut combined: Vec<Issue> = Vec::with_capacity(self.issues.len() + updates.len());
157        let issues = std::mem::take(&mut self.issues);
158        for issue in issues {
159            if !dropped.contains(Path::new(&issue.filename)) {
160                combined.push(issue);
161            }
162        }
163        combined.extend(updates);
164        // `scan` sorts by filename; matching it keeps an incremental corpus
165        // byte-identical to a rebuild.
166        combined.sort_by(|a, b| a.filename.cmp(&b.filename));
167        self.issues = combined;
168        // The serialized snapshot is stale now (tix #1520). Dropping the cache
169        // rather than rebuilding it keeps the deferral: the next reader
170        // re-serializes from the updated issue set, and `texts()` still upholds
171        // `texts[i] == issues[i].to_file_contents()`.
172        self.texts = std::cell::OnceCell::new();
173        Ok(())
174    }
175}
176
177/// One substring search hit: the issue and every corpus line that contains
178/// the matched query (context for the CLI result block, NFR-007).
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct SearchHit {
181    pub issue: Issue,
182    pub matched_lines: Vec<String>,
183}
184
185/// Case-insensitive substring search over the corpus.
186///
187/// For each corpus text containing `query` (case-insensitive), every line
188/// containing the match is collected verbatim as matched-line context. Hits
189/// are ordered by issue `number` ascending (stable, NFR-007).
190///
191/// An empty query matches **nothing** by contract: the CLI refuses it via
192/// clap, and the TUI treats an empty filter as "no filter" — callers decide,
193/// this function returns an empty vec so they never see a surprise.
194pub fn search_substring(corpus: &Corpus, query: &str) -> Vec<SearchHit> {
195    if query.is_empty() {
196        return Vec::new();
197    }
198    let needle = query.to_lowercase();
199    let mut hits: Vec<SearchHit> = Vec::new();
200    for (issue, text) in corpus.issues.iter().zip(corpus.texts().iter()) {
201        let hay = text.to_lowercase();
202        if !hay.contains(&needle) {
203            continue;
204        }
205        let matched_lines: Vec<String> = text
206            .lines()
207            .filter(|line| line.to_lowercase().contains(&needle))
208            .map(str::to_string)
209            .collect();
210        hits.push(SearchHit {
211            issue: issue.clone(),
212            matched_lines,
213        });
214    }
215    hits.sort_by_key(|hit| hit.issue.number);
216    hits
217}
218
219/// Deterministic fuzzy subsequence score: higher is a better match, `None`
220/// when `query` is not a subsequence of `haystack` (case-insensitive).
221///
222/// The scorer is the fzf/vim-style "fast substring" heuristic, kept simple
223/// and pure (no normalization, no unicode tables, no dependencies):
224///
225/// - every matched character contributes a presence bonus plus an
226///   earlier-position bonus (`1000 - position`), so matches near the start of
227///   the text outrank those buried deep in it;
228/// - adjacent matched characters (contiguous runs) get a run bonus;
229/// - a match that starts at the very beginning gets a prefix bonus;
230/// - an exact match (equal strings, case-insensitive) dominates.
231///
232/// An empty query is an implicit match with score `Some(0)` — the TUI treats
233/// it as "no filter". Deterministic and fast enough for > 500 issues per
234/// keystroke (verified by the perf tripwire test).
235pub fn fuzzy_score(query: &str, haystack: &str) -> Option<u32> {
236    let q = query.to_lowercase();
237    let h = haystack.to_lowercase();
238    if q.is_empty() {
239        return Some(0);
240    }
241    if q == h {
242        return Some(EXACT_MATCH_SCORE);
243    }
244    let positions = greedy_subsequence_positions(&q, &h)?;
245    let mut score = 0u32;
246    let mut prev: Option<usize> = None;
247    for pos in &positions {
248        score += 100;
249        score += (1000 - (*pos).min(1000)) as u32;
250        if prev.is_some_and(|p| *pos == p + 1) {
251            score += RUN_BONUS;
252        }
253        prev = Some(*pos);
254    }
255    if positions.first() == Some(&0) {
256        score += PREFIX_BONUS;
257    }
258    Some(score)
259}
260
261/// Bonus for a contiguous pair of matched characters.
262pub(super) const RUN_BONUS: u32 = 150;
263
264/// Bonus when the first matched character is the first character of the text.
265pub(super) const PREFIX_BONUS: u32 = 500;
266
267/// Score for an exact (case-insensitive) equality — dominates any partial.
268pub(super) const EXACT_MATCH_SCORE: u32 = 50_000;
269
270/// Greedy leftmost subsequence match, case-insensitive. Returns the haystack
271/// positions of `query`'s characters in order, or `None` when `query` is not
272/// a subsequence of `haystack`.
273pub(super) fn greedy_subsequence_positions(query: &str, haystack: &str) -> Option<Vec<usize>> {
274    let q: Vec<char> = query.chars().collect();
275    if q.is_empty() {
276        return Some(Vec::new());
277    }
278    let h: Vec<char> = haystack.chars().collect();
279    let mut positions = Vec::with_capacity(q.len());
280    let mut hi = 0usize;
281    /* tolerate differing-cased surrogates the same way the lowercase scan does */
282    for qc in &q {
283        let found = h[hi..]
284            .iter()
285            .position(|hc| *hc == *qc)
286            .map(|off| off + hi)?;
287        positions.push(found);
288        hi = found + 1;
289    }
290    Some(positions)
291}