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}