Skip to main content

coding_tools/
okfindex.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Jonathan Shook
3
4//! A lazily-maintained full-text index over OKF concept files, built on
5//! BurntSushi's [`fst`] crate — the same immutable finite-state-transducer
6//! machinery search engines like tantivy use for their term dictionaries.
7//!
8//! # Why this shape
9//!
10//! The index is a set of **immutable segments**. Each incremental update writes
11//! one *new* segment for the batch of changed files and never rewrites the
12//! existing ones — so "layering in new content" costs only the new segment,
13//! exactly the property the suite wanted. Superseded or removed documents are
14//! recorded as **tombstones** in the manifest and filtered at query time;
15//! [`Index::condense`] later merges every segment into one and drops the
16//! tombstones, reclaiming space without re-reading the source files.
17//!
18//! A segment is two files: `seg-NNNNN.fst`, an [`fst::Map`] from **term** to a
19//! byte offset, and `seg-NNNNN.pos`, the postings blob those offsets point into
20//! (a varint-encoded `(doc_id, term_frequency)` list per term). Global state —
21//! the next document id, the live segment list, the per-file `(doc, mtime,
22//! size)` records that drive staleness, per-document metadata, and the tombstone
23//! set — lives in `manifest.json`. Segment bytes are read into memory on demand
24//! rather than memory-mapped, keeping the dependency surface to just `fst`.
25//!
26//! # Query modes
27//!
28//! [`Index::search`] understands a small query grammar (see [`QueryTerm`]):
29//! plain terms (exact), `term*` (prefix), `term~`/`term~2` (Levenshtein fuzzy),
30//! and `/regex/`. Exact, prefix, and fuzzy are native fst automata; the regex
31//! mode drives a `regex-automata` dense DFA *as* an [`fst::Automaton`] (the
32//! modern equivalent of the `transducer` feature dropped from regex-automata
33//! 0.4), so it prunes the term FST during traversal instead of scanning it.
34
35use std::collections::{BTreeMap, BTreeSet, HashMap};
36use std::path::{Path, PathBuf};
37
38use fst::automaton::{Levenshtein, Str};
39use fst::{Automaton, IntoStreamer, Map, MapBuilder, Streamer};
40// Drives a regex-automata 0.4 dense DFA as an `fst::Automaton` (the modern
41// equivalent of the `transducer` feature dropped from regex-automata 0.4).
42use regex_automata::Anchored;
43use regex_automata::dfa::Automaton as _;
44use regex_automata::dfa::{StartKind, dense};
45use regex_automata::util::primitives::StateID;
46
47/// Manifest format version, bumped on any incompatible on-disk change.
48const MANIFEST_VERSION: u64 = 1;
49
50/// Maximum edit distance honoured for a `~N` fuzzy query (Levenshtein automata
51/// grow quickly with distance; 2 is the usual practical ceiling).
52const MAX_FUZZY: u32 = 2;
53
54// ----- Tokenization -------------------------------------------------------------------
55
56/// Split `text` into lowercased alphanumeric terms — the shared tokenizer for
57/// both indexing and (per-token) querying. Deliberately minimal: Unicode
58/// alphanumeric runs, lowercased, no stemming or stop-words, so behaviour is
59/// predictable and dependency-free.
60pub fn tokenize(text: &str) -> Vec<String> {
61    let mut out = Vec::new();
62    let mut cur = String::new();
63    for ch in text.chars() {
64        if ch.is_alphanumeric() {
65            cur.extend(ch.to_lowercase());
66        } else if !cur.is_empty() {
67            out.push(std::mem::take(&mut cur));
68        }
69    }
70    if !cur.is_empty() {
71        out.push(cur);
72    }
73    out
74}
75
76// ----- Varint postings ----------------------------------------------------------------
77
78/// Append `val` to `buf` as an unsigned LEB128 varint.
79fn put_uvarint(buf: &mut Vec<u8>, mut val: u64) {
80    loop {
81        let mut byte = (val & 0x7f) as u8;
82        val >>= 7;
83        if val != 0 {
84            byte |= 0x80;
85        }
86        buf.push(byte);
87        if val == 0 {
88            break;
89        }
90    }
91}
92
93/// Read an unsigned LEB128 varint from `buf` at `*pos`, advancing `*pos`.
94fn get_uvarint(buf: &[u8], pos: &mut usize) -> Option<u64> {
95    let mut val = 0u64;
96    let mut shift = 0u32;
97    loop {
98        let byte = *buf.get(*pos)?;
99        *pos += 1;
100        val |= u64::from(byte & 0x7f) << shift;
101        if byte & 0x80 == 0 {
102            return Some(val);
103        }
104        shift += 7;
105        if shift >= 64 {
106            return None;
107        }
108    }
109}
110
111/// Encode one term's postings (a `(doc_id, term_frequency)` list, doc ascending)
112/// into `buf`, returning the byte offset the list begins at.
113fn encode_postings(buf: &mut Vec<u8>, postings: &[(u64, u32)]) -> u64 {
114    let offset = buf.len() as u64;
115    put_uvarint(buf, postings.len() as u64);
116    for &(doc, tf) in postings {
117        put_uvarint(buf, doc);
118        put_uvarint(buf, u64::from(tf));
119    }
120    offset
121}
122
123/// Decode the postings list stored at `offset` in a segment's `.pos` blob.
124fn decode_postings(blob: &[u8], offset: u64) -> Vec<(u64, u32)> {
125    let mut pos = offset as usize;
126    let Some(n) = get_uvarint(blob, &mut pos) else {
127        return Vec::new();
128    };
129    let mut out = Vec::with_capacity(n as usize);
130    for _ in 0..n {
131        let (Some(doc), Some(tf)) = (get_uvarint(blob, &mut pos), get_uvarint(blob, &mut pos))
132        else {
133            break;
134        };
135        out.push((doc, tf as u32));
136    }
137    out
138}
139
140// ----- The document feed --------------------------------------------------------------
141
142/// A file's identity for staleness: its path-relative key plus the `mtime`/size
143/// the indexer compares against the manifest to decide what to re-index.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct FileStat {
146    /// Stable key for the file (e.g. project-relative path with `/` separators).
147    pub key: String,
148    /// Absolute path, for the loader to read when (re)indexing.
149    pub path: PathBuf,
150    /// Last-modified time in nanoseconds since the Unix epoch.
151    pub mtime_ns: u64,
152    /// File size in bytes.
153    pub size: u64,
154}
155
156/// A document ready to index: its searchable text plus the metadata carried into
157/// search results.
158#[derive(Debug, Clone, Default, PartialEq, Eq)]
159pub struct DocSource {
160    /// Human title (frontmatter `title`, else the file stem).
161    pub title: String,
162    /// OKF `type`.
163    pub type_: String,
164    /// Tags.
165    pub tags: Vec<String>,
166    /// The full searchable text (frontmatter fields + body).
167    pub text: String,
168}
169
170/// What an [`Index::update`] changed.
171#[derive(Debug, Default, Clone, PartialEq, Eq)]
172pub struct UpdateReport {
173    pub added: usize,
174    pub changed: usize,
175    pub removed: usize,
176    /// Whether a new segment was written (true iff added+changed > 0).
177    pub wrote_segment: bool,
178}
179
180impl UpdateReport {
181    /// Whether anything at all changed (so the manifest needs saving).
182    pub fn is_empty(&self) -> bool {
183        self.added == 0 && self.changed == 0 && self.removed == 0
184    }
185}
186
187// ----- Manifest -----------------------------------------------------------------------
188
189/// Per-document metadata, surfaced in search results and used for scoring.
190#[derive(Debug, Clone, PartialEq, Eq)]
191struct DocMeta {
192    key: String,
193    title: String,
194    type_: String,
195    tags: Vec<String>,
196    /// Total term count (document length), for length-aware scoring later.
197    len: u32,
198}
199
200/// Per-file staleness record.
201#[derive(Debug, Clone, PartialEq, Eq)]
202struct FileRec {
203    doc: u64,
204    mtime_ns: u64,
205    size: u64,
206}
207
208/// The index's global state, persisted as `manifest.json`.
209#[derive(Debug, Clone, Default)]
210struct Manifest {
211    generation: u64,
212    provider: String,
213    provider_version: u64,
214    next_doc: u64,
215    segments: Vec<u32>,
216    files: BTreeMap<String, FileRec>,
217    docs: BTreeMap<u64, DocMeta>,
218    deleted: BTreeSet<u64>,
219}
220
221impl Manifest {
222    fn to_json(&self) -> serde_json::Value {
223        let files: serde_json::Map<String, serde_json::Value> = self
224            .files
225            .iter()
226            .map(|(k, r)| {
227                (
228                    k.clone(),
229                    serde_json::json!({ "doc": r.doc, "mtime_ns": r.mtime_ns, "size": r.size }),
230                )
231            })
232            .collect();
233        let docs: serde_json::Map<String, serde_json::Value> = self
234            .docs
235            .iter()
236            .map(|(id, m)| {
237                (
238                    id.to_string(),
239                    serde_json::json!({
240                        "key": m.key,
241                        "title": m.title,
242                        "type": m.type_,
243                        "tags": m.tags,
244                        "len": m.len,
245                    }),
246                )
247            })
248            .collect();
249        serde_json::json!({
250            "version": MANIFEST_VERSION,
251            "generation": self.generation,
252            "provider": self.provider,
253            "provider_version": self.provider_version,
254            "next_doc": self.next_doc,
255            "segments": self.segments,
256            "files": files,
257            "docs": docs,
258            "deleted": self.deleted.iter().collect::<Vec<_>>(),
259        })
260    }
261
262    fn from_json(v: &serde_json::Value) -> Result<Manifest, String> {
263        let obj = v.as_object().ok_or("manifest is not an object")?;
264        let mut m = Manifest {
265            generation: obj.get("generation").and_then(|x| x.as_u64()).unwrap_or(0),
266            provider: obj
267                .get("provider")
268                .and_then(|x| x.as_str())
269                .unwrap_or("")
270                .to_string(),
271            provider_version: obj
272                .get("provider_version")
273                .and_then(|x| x.as_u64())
274                .unwrap_or(0),
275            next_doc: obj.get("next_doc").and_then(|x| x.as_u64()).unwrap_or(0),
276            ..Manifest::default()
277        };
278        if let Some(arr) = obj.get("segments").and_then(|x| x.as_array()) {
279            m.segments = arr
280                .iter()
281                .filter_map(|x| x.as_u64().map(|n| n as u32))
282                .collect();
283        }
284        if let Some(files) = obj.get("files").and_then(|x| x.as_object()) {
285            for (k, r) in files {
286                let doc = r.get("doc").and_then(|x| x.as_u64()).unwrap_or(0);
287                let mtime_ns = r.get("mtime_ns").and_then(|x| x.as_u64()).unwrap_or(0);
288                let size = r.get("size").and_then(|x| x.as_u64()).unwrap_or(0);
289                m.files.insert(
290                    k.clone(),
291                    FileRec {
292                        doc,
293                        mtime_ns,
294                        size,
295                    },
296                );
297            }
298        }
299        if let Some(docs) = obj.get("docs").and_then(|x| x.as_object()) {
300            for (id, d) in docs {
301                let Ok(id) = id.parse::<u64>() else { continue };
302                let tags = d
303                    .get("tags")
304                    .and_then(|x| x.as_array())
305                    .map(|a| {
306                        a.iter()
307                            .filter_map(|t| t.as_str().map(String::from))
308                            .collect()
309                    })
310                    .unwrap_or_default();
311                m.docs.insert(
312                    id,
313                    DocMeta {
314                        key: d
315                            .get("key")
316                            .and_then(|x| x.as_str())
317                            .unwrap_or("")
318                            .to_string(),
319                        title: d
320                            .get("title")
321                            .and_then(|x| x.as_str())
322                            .unwrap_or("")
323                            .to_string(),
324                        type_: d
325                            .get("type")
326                            .and_then(|x| x.as_str())
327                            .unwrap_or("")
328                            .to_string(),
329                        tags,
330                        len: d.get("len").and_then(|x| x.as_u64()).unwrap_or(0) as u32,
331                    },
332                );
333            }
334        }
335        if let Some(arr) = obj.get("deleted").and_then(|x| x.as_array()) {
336            m.deleted = arr.iter().filter_map(|x| x.as_u64()).collect();
337        }
338        Ok(m)
339    }
340}
341
342// ----- Query grammar ------------------------------------------------------------------
343
344/// One parsed query token and how it should match the term dictionary.
345#[derive(Debug, Clone, PartialEq, Eq)]
346pub enum QueryTerm {
347    /// Exact term match.
348    Exact(String),
349    /// Prefix match (`term*`).
350    Prefix(String),
351    /// Levenshtein fuzzy match within `dist` edits (`term~` / `term~N`).
352    Fuzzy(String, u32),
353    /// Regex over the term dictionary (`/pattern/`) — a slower full-term scan.
354    Regex(String),
355}
356
357/// Parse a query string into its [`QueryTerm`]s. Whitespace separates tokens;
358/// `/.../` is one regex token. The non-regex modes share the index's
359/// [`tokenize`] so a query splits exactly the way the stored terms did: a token
360/// that tokenizes to several terms contributes leading [`Exact`](QueryTerm::Exact)
361/// terms, and any `*`/`~` operator applies to the final term (the typical token
362/// is a single term, so this is just `Prefix`/`Fuzzy`). Empty/operator-only
363/// tokens are dropped.
364pub fn parse_query(query: &str) -> Vec<QueryTerm> {
365    let mut out = Vec::new();
366    for tok in query.split_whitespace() {
367        // /regex/ — the pattern matches whole dictionary terms verbatim.
368        if tok.len() >= 2 && tok.starts_with('/') && tok.ends_with('/') {
369            let inner = &tok[1..tok.len() - 1];
370            if !inner.is_empty() {
371                out.push(QueryTerm::Regex(inner.to_string()));
372            }
373            continue;
374        }
375        // Split off a trailing operator, then tokenize the core the same way the
376        // index did, applying the operator to the final term.
377        enum Op {
378            Exact,
379            Prefix,
380            Fuzzy(u32),
381        }
382        let (core, op) = if let Some(tilde) = tok.rfind('~') {
383            let dist = tok[tilde + 1..]
384                .parse::<u32>()
385                .unwrap_or(1)
386                .clamp(1, MAX_FUZZY);
387            (&tok[..tilde], Op::Fuzzy(dist))
388        } else if let Some(head) = tok.strip_suffix('*') {
389            (head, Op::Prefix)
390        } else {
391            (tok, Op::Exact)
392        };
393        let mut toks = tokenize(core);
394        if let Some(last) = toks.pop() {
395            for t in toks {
396                out.push(QueryTerm::Exact(t));
397            }
398            out.push(match op {
399                Op::Exact => QueryTerm::Exact(last),
400                Op::Prefix => QueryTerm::Prefix(last),
401                Op::Fuzzy(d) => QueryTerm::Fuzzy(last, d),
402            });
403        }
404    }
405    out
406}
407
408// ----- A loaded segment ---------------------------------------------------------------
409
410/// One immutable segment held in memory for querying.
411struct Segment {
412    map: Map<Vec<u8>>,
413    pos: Vec<u8>,
414}
415
416/// Accumulate, into `merged`, the `(dictionary term -> live postings)` of every
417/// segment key accepted by `aut`, dropping tombstoned documents. Generic over
418/// the automaton so the prefix/fuzzy/exact/regex modes share one walk.
419fn collect_matches<A: Automaton>(
420    segments: &[Segment],
421    aut: &A,
422    deleted: &BTreeSet<u64>,
423    merged: &mut HashMap<String, Vec<(u64, u32)>>,
424) {
425    for seg in segments {
426        let mut stream = seg.map.search(aut).into_stream();
427        while let Some((key, off)) = stream.next() {
428            let live: Vec<(u64, u32)> = decode_postings(&seg.pos, off)
429                .into_iter()
430                .filter(|(doc, _)| !deleted.contains(doc))
431                .collect();
432            if !live.is_empty()
433                && let Ok(term) = std::str::from_utf8(key)
434            {
435                merged.entry(term.to_string()).or_default().extend(live);
436            }
437        }
438    }
439}
440
441/// An [`fst::Automaton`] backed by a regex-automata dense DFA, so `/regex/`
442/// queries prune the term FST during traversal instead of scanning every term.
443/// The DFA is compiled **anchored**, because fst feeds a key's bytes from the
444/// start and a match means the whole key matched.
445struct DfaAutomaton {
446    dfa: dense::DFA<Vec<u32>>,
447    start: StateID,
448}
449
450impl DfaAutomaton {
451    fn new(pattern: &str) -> Result<DfaAutomaton, String> {
452        let dfa = dense::Builder::new()
453            .configure(dense::Config::new().start_kind(StartKind::Anchored))
454            .build(pattern)
455            .map_err(|e| format!("invalid regex: {e}"))?;
456        let start = dfa
457            .universal_start_state(Anchored::Yes)
458            .ok_or("regex start depends on look-around, unsupported here")?;
459        Ok(DfaAutomaton { dfa, start })
460    }
461}
462
463impl Automaton for DfaAutomaton {
464    type State = StateID;
465
466    fn start(&self) -> StateID {
467        self.start
468    }
469
470    fn is_match(&self, state: &StateID) -> bool {
471        // A DFA reports a whole-input match only after the end-of-input step.
472        self.dfa.is_match_state(self.dfa.next_eoi_state(*state))
473    }
474
475    fn can_match(&self, state: &StateID) -> bool {
476        // A dead state can never reach a match, so fst can prune this branch.
477        !self.dfa.is_dead_state(*state)
478    }
479
480    fn accept(&self, state: &StateID, byte: u8) -> StateID {
481        self.dfa.next_state(*state, byte)
482    }
483}
484
485// ----- The index ----------------------------------------------------------------------
486
487/// One search result.
488#[derive(Debug, Clone, PartialEq)]
489pub struct SearchHit {
490    /// The document's stable key (project-relative path).
491    pub key: String,
492    pub title: String,
493    pub type_: String,
494    pub tags: Vec<String>,
495    pub score: f32,
496}
497
498/// A lazily-maintained fst-segment index rooted at a directory (`.ct/okf/`).
499pub struct Index {
500    dir: PathBuf,
501    manifest: Manifest,
502}
503
504impl Index {
505    /// Open the index at `dir`, loading `manifest.json` if present (an absent or
506    /// unreadable manifest yields an empty index). The directory is created on
507    /// the first [`Index::save`].
508    pub fn open(dir: &Path) -> Result<Index, String> {
509        let manifest_path = dir.join("manifest.json");
510        let manifest = match std::fs::read_to_string(&manifest_path) {
511            Ok(text) => {
512                let v: serde_json::Value = serde_json::from_str(&text)
513                    .map_err(|e| format!("{}: {e}", manifest_path.display()))?;
514                Manifest::from_json(&v)?
515            }
516            Err(_) => Manifest::default(),
517        };
518        Ok(Index {
519            dir: dir.to_path_buf(),
520            manifest,
521        })
522    }
523
524    /// Number of live (non-tombstoned) documents.
525    pub fn doc_count(&self) -> usize {
526        self.manifest.docs.len()
527    }
528
529    /// Number of live segments.
530    pub fn segment_count(&self) -> usize {
531        self.manifest.segments.len()
532    }
533
534    /// Number of tombstoned documents awaiting [`condense`](Index::condense).
535    pub fn tombstone_count(&self) -> usize {
536        self.manifest.deleted.len()
537    }
538
539    /// Atomically-published manifest generation. It advances on every
540    /// successful content delta, rebuild, or compaction.
541    pub fn generation(&self) -> u64 {
542        self.manifest.generation
543    }
544
545    /// Bytes of live source documents represented by the manifest.
546    pub fn source_bytes(&self) -> u64 {
547        self.manifest.files.values().map(|f| f.size).sum()
548    }
549
550    pub fn provider(&self) -> (&str, u64) {
551        (&self.manifest.provider, self.manifest.provider_version)
552    }
553
554    /// How many of `current` are new / changed / removed relative to the
555    /// manifest — a read-only staleness probe for `index status` that mutates
556    /// nothing (the same diff [`update`](Index::update) would act on).
557    pub fn pending(&self, current: &[FileStat]) -> (usize, usize, usize) {
558        let present: BTreeSet<&str> = current.iter().map(|f| f.key.as_str()).collect();
559        let removed = self
560            .manifest
561            .files
562            .keys()
563            .filter(|k| !present.contains(k.as_str()))
564            .count();
565        let (mut added, mut changed) = (0, 0);
566        for f in current {
567            match self.manifest.files.get(&f.key) {
568                None => added += 1,
569                Some(r) if r.mtime_ns != f.mtime_ns || r.size != f.size => changed += 1,
570                _ => {}
571            }
572        }
573        (added, changed, removed)
574    }
575
576    fn seg_paths(&self, num: u32) -> (PathBuf, PathBuf) {
577        let base = format!("seg-{num:05}");
578        (
579            self.dir.join(format!("{base}.fst")),
580            self.dir.join(format!("{base}.pos")),
581        )
582    }
583
584    fn load_segment(&self, num: u32) -> Result<Segment, String> {
585        let (fst_path, pos_path) = self.seg_paths(num);
586        let fst_bytes =
587            std::fs::read(&fst_path).map_err(|e| format!("{}: {e}", fst_path.display()))?;
588        let pos = std::fs::read(&pos_path).map_err(|e| format!("{}: {e}", pos_path.display()))?;
589        let map = Map::new(fst_bytes).map_err(|e| format!("{}: {e}", fst_path.display()))?;
590        Ok(Segment { map, pos })
591    }
592
593    /// Reconcile the index against `current` (the live files of the OKF roots),
594    /// re-indexing only what changed. `load` is called to read a file's
595    /// [`DocSource`] only when it is new or modified. Writes at most one new
596    /// segment. Call [`save`](Index::save) afterwards to persist the manifest.
597    pub fn update<F>(&mut self, current: &[FileStat], load: F) -> Result<UpdateReport, String>
598    where
599        F: Fn(&FileStat) -> Result<DocSource, String>,
600    {
601        const PROVIDER: &str = "okf-markdown";
602        const PROVIDER_VERSION: u64 = 1;
603        if self.manifest.provider.is_empty() {
604            self.manifest.provider = PROVIDER.to_string();
605            self.manifest.provider_version = PROVIDER_VERSION;
606        } else if self.manifest.provider != PROVIDER
607            || self.manifest.provider_version != PROVIDER_VERSION
608        {
609            return Err(format!(
610                "index provider {} v{} is incompatible with {PROVIDER} v{PROVIDER_VERSION}; run `ct okf index rebuild`",
611                self.manifest.provider, self.manifest.provider_version
612            ));
613        }
614        let mut report = UpdateReport::default();
615        let present: BTreeSet<&str> = current.iter().map(|f| f.key.as_str()).collect();
616
617        // Removals: files in the manifest that are gone from the roots.
618        let removed_keys: Vec<String> = self
619            .manifest
620            .files
621            .keys()
622            .filter(|k| !present.contains(k.as_str()))
623            .cloned()
624            .collect();
625        for key in removed_keys {
626            if let Some(rec) = self.manifest.files.remove(&key) {
627                self.manifest.docs.remove(&rec.doc);
628                self.manifest.deleted.insert(rec.doc);
629                report.removed += 1;
630            }
631        }
632
633        // Additions / modifications: collect the docs to put in a new segment.
634        // term -> (doc -> tf)
635        let mut postings: BTreeMap<String, BTreeMap<u64, u32>> = BTreeMap::new();
636        for f in current {
637            let unchanged = self
638                .manifest
639                .files
640                .get(&f.key)
641                .is_some_and(|r| r.mtime_ns == f.mtime_ns && r.size == f.size);
642            if unchanged {
643                continue;
644            }
645            let is_change = self.manifest.files.contains_key(&f.key);
646            // Supersede any prior doc for this key.
647            if let Some(old) = self.manifest.files.get(&f.key) {
648                self.manifest.docs.remove(&old.doc);
649                self.manifest.deleted.insert(old.doc);
650            }
651            let src = load(f)?;
652            let doc = self.manifest.next_doc;
653            self.manifest.next_doc += 1;
654            let terms = tokenize(&format!(
655                "{} {} {} {}",
656                src.title,
657                src.type_,
658                src.tags.join(" "),
659                src.text
660            ));
661            let len = terms.len() as u32;
662            for term in terms {
663                *postings.entry(term).or_default().entry(doc).or_insert(0) += 1;
664            }
665            self.manifest.docs.insert(
666                doc,
667                DocMeta {
668                    key: f.key.clone(),
669                    title: src.title,
670                    type_: src.type_,
671                    tags: src.tags,
672                    len,
673                },
674            );
675            self.manifest.files.insert(
676                f.key.clone(),
677                FileRec {
678                    doc,
679                    mtime_ns: f.mtime_ns,
680                    size: f.size,
681                },
682            );
683            if is_change {
684                report.changed += 1;
685            } else {
686                report.added += 1;
687            }
688        }
689
690        if !postings.is_empty() {
691            let num = self
692                .manifest
693                .segments
694                .iter()
695                .copied()
696                .max()
697                .map(|n| n + 1)
698                .unwrap_or(0);
699            self.write_segment(num, &postings)?;
700            self.manifest.segments.push(num);
701            report.wrote_segment = true;
702        }
703        if !report.is_empty() {
704            self.manifest.generation = self.manifest.generation.saturating_add(1);
705        }
706        Ok(report)
707    }
708
709    /// Apply a watcher-derived partial change set without walking unchanged
710    /// scopes. `removed` entries are keys or directory-key prefixes; every
711    /// matching live document is removed before `upserts` replace/add paths.
712    /// Periodic full [`update`](Self::update) calls remain the correctness audit.
713    pub fn update_delta<F>(
714        &mut self,
715        upserts: &[FileStat],
716        removed: &[String],
717        load: F,
718    ) -> Result<UpdateReport, String>
719    where
720        F: Fn(&FileStat) -> Result<DocSource, String>,
721    {
722        let removed_matches = |key: &str| {
723            removed.iter().any(|prefix| {
724                key == prefix
725                    || key
726                        .strip_prefix(prefix)
727                        .is_some_and(|suffix| suffix.starts_with('/'))
728            })
729        };
730        let mut current: BTreeMap<String, FileStat> = self
731            .manifest
732            .files
733            .iter()
734            .filter(|(key, _)| !removed_matches(key))
735            .map(|(key, rec)| {
736                (
737                    key.clone(),
738                    FileStat {
739                        key: key.clone(),
740                        // Unchanged records are never loaded, so no source path
741                        // is needed for them.
742                        path: PathBuf::new(),
743                        mtime_ns: rec.mtime_ns,
744                        size: rec.size,
745                    },
746                )
747            })
748            .collect();
749        for file in upserts {
750            current.insert(file.key.clone(), file.clone());
751        }
752        self.update(&current.into_values().collect::<Vec<_>>(), load)
753    }
754
755    /// Write a segment from a sorted `term -> (doc -> tf)` map.
756    fn write_segment(
757        &self,
758        num: u32,
759        postings: &BTreeMap<String, BTreeMap<u64, u32>>,
760    ) -> Result<(), String> {
761        std::fs::create_dir_all(&self.dir).map_err(|e| format!("{}: {e}", self.dir.display()))?;
762        let (fst_path, pos_path) = self.seg_paths(num);
763        let mut pos_blob = Vec::new();
764        let wtr = std::io::BufWriter::new(
765            std::fs::File::create(&fst_path).map_err(|e| format!("{}: {e}", fst_path.display()))?,
766        );
767        let mut builder = MapBuilder::new(wtr).map_err(|e| e.to_string())?;
768        // BTreeMap iterates terms in sorted order, as fst requires.
769        for (term, docs) in postings {
770            let list: Vec<(u64, u32)> = docs.iter().map(|(&d, &tf)| (d, tf)).collect();
771            let off = encode_postings(&mut pos_blob, &list);
772            builder
773                .insert(term.as_bytes(), off)
774                .map_err(|e| e.to_string())?;
775        }
776        builder.finish().map_err(|e| e.to_string())?;
777        std::fs::write(&pos_path, &pos_blob).map_err(|e| format!("{}: {e}", pos_path.display()))?;
778        Ok(())
779    }
780
781    /// Search the index, returning up to `limit` hits ranked by a tf-idf score.
782    /// Reads every live segment; tombstoned documents are filtered out.
783    pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>, String> {
784        let terms = parse_query(query);
785        if terms.is_empty() {
786            return Ok(Vec::new());
787        }
788        let segments: Vec<Segment> = self
789            .manifest
790            .segments
791            .iter()
792            .map(|&n| self.load_segment(n))
793            .collect::<Result<_, _>>()?;
794        let n_docs = self.manifest.docs.len().max(1) as f32;
795
796        let deleted = &self.manifest.deleted;
797        let mut scores: HashMap<u64, f32> = HashMap::new();
798        for qt in &terms {
799            // Build the mode's automaton once, then walk every segment with it,
800            // merging each matched dictionary term's postings — so document
801            // frequency (and thus idf) is computed over the whole index.
802            let mut merged: HashMap<String, Vec<(u64, u32)>> = HashMap::new();
803            match qt {
804                QueryTerm::Exact(t) => {
805                    collect_matches(&segments, &Str::new(t), deleted, &mut merged)
806                }
807                QueryTerm::Prefix(t) => {
808                    collect_matches(&segments, &Str::new(t).starts_with(), deleted, &mut merged)
809                }
810                QueryTerm::Fuzzy(t, dist) => {
811                    if let Ok(lev) = Levenshtein::new(t, *dist) {
812                        collect_matches(&segments, &lev, deleted, &mut merged);
813                    }
814                }
815                QueryTerm::Regex(pat) => {
816                    if let Ok(dfa) = DfaAutomaton::new(pat) {
817                        collect_matches(&segments, &dfa, deleted, &mut merged);
818                    }
819                }
820            }
821            for list in merged.values() {
822                let df = list.len().max(1) as f32;
823                let idf = (1.0 + n_docs / df).ln();
824                for &(doc, tf) in list {
825                    *scores.entry(doc).or_insert(0.0) += idf * (1.0 + (tf as f32).ln());
826                }
827            }
828        }
829
830        let mut hits: Vec<SearchHit> = scores
831            .into_iter()
832            .filter_map(|(doc, score)| {
833                self.manifest.docs.get(&doc).map(|m| SearchHit {
834                    key: m.key.clone(),
835                    title: m.title.clone(),
836                    type_: m.type_.clone(),
837                    tags: m.tags.clone(),
838                    score,
839                })
840            })
841            .collect();
842        // Highest score first; ties broken by key for deterministic output.
843        hits.sort_by(|a, b| {
844            b.score
845                .partial_cmp(&a.score)
846                .unwrap_or(std::cmp::Ordering::Equal)
847                .then_with(|| a.key.cmp(&b.key))
848        });
849        hits.truncate(limit);
850        Ok(hits)
851    }
852
853    /// Merge every segment into one, dropping tombstoned documents and their
854    /// postings, then delete the old segment files. Reclaims space without
855    /// re-reading any source file. A no-op when there is nothing to gain
856    /// (≤1 segment and no tombstones).
857    pub fn condense(&mut self) -> Result<bool, String> {
858        if self.manifest.segments.len() <= 1 && self.manifest.deleted.is_empty() {
859            return Ok(false);
860        }
861        let old_segments = self.manifest.segments.clone();
862        let segments: Vec<Segment> = old_segments
863            .iter()
864            .map(|&n| self.load_segment(n))
865            .collect::<Result<_, _>>()?;
866
867        // Rebuild a single term -> (doc -> tf) map from the live postings.
868        let mut postings: BTreeMap<String, BTreeMap<u64, u32>> = BTreeMap::new();
869        for seg in &segments {
870            let mut s = seg.map.stream();
871            while let Some((term_bytes, off)) = s.next() {
872                let Ok(term) = std::str::from_utf8(term_bytes) else {
873                    continue;
874                };
875                for (doc, tf) in decode_postings(&seg.pos, off) {
876                    if self.manifest.deleted.contains(&doc) {
877                        continue;
878                    }
879                    *postings
880                        .entry(term.to_string())
881                        .or_default()
882                        .entry(doc)
883                        .or_insert(0) += tf;
884                }
885            }
886        }
887
888        let num = old_segments.iter().copied().max().unwrap_or(0) + 1;
889        if !postings.is_empty() {
890            self.write_segment(num, &postings)?;
891            self.manifest.segments = vec![num];
892        } else {
893            self.manifest.segments.clear();
894        }
895        self.manifest.deleted.clear();
896        for old in old_segments {
897            let (fst_path, pos_path) = self.seg_paths(old);
898            let _ = std::fs::remove_file(fst_path);
899            let _ = std::fs::remove_file(pos_path);
900        }
901        self.manifest.generation = self.manifest.generation.saturating_add(1);
902        Ok(true)
903    }
904
905    /// Discard the whole index (segments + manifest state), so the next
906    /// [`update`](Index::update) re-indexes every file from scratch.
907    pub fn reset(&mut self) {
908        let next_generation = self.manifest.generation.saturating_add(1);
909        for num in std::mem::take(&mut self.manifest.segments) {
910            let (fst_path, pos_path) = self.seg_paths(num);
911            let _ = std::fs::remove_file(fst_path);
912            let _ = std::fs::remove_file(pos_path);
913        }
914        self.manifest = Manifest::default();
915        self.manifest.generation = next_generation;
916    }
917
918    /// Persist `manifest.json` (creating the index directory if needed).
919    pub fn save(&self) -> Result<(), String> {
920        std::fs::create_dir_all(&self.dir).map_err(|e| format!("{}: {e}", self.dir.display()))?;
921        let path = self.dir.join("manifest.json");
922        let text =
923            serde_json::to_string_pretty(&self.manifest.to_json()).map_err(|e| e.to_string())?;
924        crate::atomicfile::write(&path, text)
925    }
926}
927
928#[cfg(test)]
929mod tests {
930    use super::*;
931    use std::sync::atomic::{AtomicU32, Ordering};
932
933    static TAG: AtomicU32 = AtomicU32::new(0);
934
935    /// A fresh, empty scratch dir for one test (cleared if a prior run left it).
936    fn scratch() -> PathBuf {
937        let n = TAG.fetch_add(1, Ordering::Relaxed);
938        let dir = std::env::temp_dir().join(format!("ct-okfindex-{}-{n}", std::process::id()));
939        let _ = std::fs::remove_dir_all(&dir);
940        std::fs::create_dir_all(&dir).unwrap();
941        dir
942    }
943
944    fn stat(key: &str, mtime_ns: u64) -> FileStat {
945        FileStat {
946            key: key.to_string(),
947            path: PathBuf::from(key),
948            mtime_ns,
949            size: mtime_ns,
950        }
951    }
952
953    fn doc(title: &str, type_: &str, tags: &[&str], text: &str) -> DocSource {
954        DocSource {
955            title: title.to_string(),
956            type_: type_.to_string(),
957            tags: tags.iter().map(|s| s.to_string()).collect(),
958            text: text.to_string(),
959        }
960    }
961
962    #[test]
963    fn tokenize_lowercases_and_splits_on_nonalnum() {
964        assert_eq!(
965            tokenize("Hello, World! foo_bar"),
966            ["hello", "world", "foo", "bar"]
967        );
968        assert_eq!(tokenize("  "), Vec::<String>::new());
969    }
970
971    #[test]
972    fn parse_query_recognizes_all_modes() {
973        let q = parse_query("plain data* typo~ deep~2 /sch.*ma/");
974        assert_eq!(q[0], QueryTerm::Exact("plain".into()));
975        assert_eq!(q[1], QueryTerm::Prefix("data".into()));
976        assert_eq!(q[2], QueryTerm::Fuzzy("typo".into(), 1));
977        assert_eq!(q[3], QueryTerm::Fuzzy("deep".into(), 2));
978        assert_eq!(q[4], QueryTerm::Regex("sch.*ma".into()));
979    }
980
981    #[test]
982    fn varint_roundtrips() {
983        for v in [0u64, 1, 127, 128, 300, 16384, u32::MAX as u64, u64::MAX] {
984            let mut buf = Vec::new();
985            put_uvarint(&mut buf, v);
986            let mut pos = 0;
987            assert_eq!(get_uvarint(&buf, &mut pos), Some(v));
988            assert_eq!(pos, buf.len());
989        }
990    }
991
992    #[test]
993    fn index_search_exact_prefix_and_fuzzy() {
994        let dir = scratch();
995        let mut idx = Index::open(&dir).unwrap();
996        let files = [stat("a.md", 1), stat("b.md", 1)];
997        idx.update(&files, |f| {
998            Ok(match f.key.as_str() {
999                "a.md" => doc(
1000                    "Customers",
1001                    "BigQuery Table",
1002                    &["pii"],
1003                    "the customers dimension table",
1004                ),
1005                _ => doc(
1006                    "Orders",
1007                    "BigQuery Table",
1008                    &["core"],
1009                    "the orders fact table schema",
1010                ),
1011            })
1012        })
1013        .unwrap();
1014        idx.save().unwrap();
1015
1016        // Exact term hits only the doc that has it.
1017        let hits = idx.search("customers", 10).unwrap();
1018        assert_eq!(hits.len(), 1);
1019        assert_eq!(hits[0].key, "a.md");
1020
1021        // Prefix matches both ("table" appears in both via type/text).
1022        let hits = idx.search("custom*", 10).unwrap();
1023        assert_eq!(
1024            hits.iter().map(|h| h.key.as_str()).collect::<Vec<_>>(),
1025            ["a.md"]
1026        );
1027
1028        // Fuzzy tolerates a typo.
1029        let hits = idx.search("ordrs~", 10).unwrap();
1030        assert_eq!(hits[0].key, "b.md");
1031
1032        // Reopen and the persisted index still searches.
1033        let idx2 = Index::open(&dir).unwrap();
1034        assert_eq!(idx2.doc_count(), 2);
1035        assert_eq!(idx2.search("schema", 10).unwrap()[0].key, "b.md");
1036    }
1037
1038    #[test]
1039    fn regex_mode_matches_substrings_via_the_dfa_adapter() {
1040        let dir = scratch();
1041        let mut idx = Index::open(&dir).unwrap();
1042        idx.update(&[stat("a.md", 1), stat("b.md", 1)], |f| {
1043            Ok(match f.key.as_str() {
1044                "a.md" => doc("Orders", "Table", &[], "the orders fact table"),
1045                _ => doc("Customers", "Table", &[], "the customers dimension"),
1046            })
1047        })
1048        .unwrap();
1049
1050        // /.*omer.*/ matches the term "customers" (substring) but not "orders".
1051        let hits = idx.search("/.*omer.*/", 10).unwrap();
1052        assert_eq!(
1053            hits.iter().map(|h| h.key.as_str()).collect::<Vec<_>>(),
1054            ["b.md"]
1055        );
1056
1057        // An anchored whole-key pattern still works.
1058        let hits = idx.search("/orders/", 10).unwrap();
1059        assert_eq!(hits[0].key, "a.md");
1060
1061        // A pattern matching no term yields nothing (and does not error).
1062        assert!(idx.search("/zzz.*/", 10).unwrap().is_empty());
1063    }
1064
1065    #[test]
1066    fn incremental_update_supersedes_changed_and_drops_removed() {
1067        let dir = scratch();
1068        let mut idx = Index::open(&dir).unwrap();
1069        idx.update(&[stat("a.md", 1), stat("b.md", 1)], |f| {
1070            Ok(if f.key == "a.md" {
1071                doc("Alpha", "Note", &[], "alpha content markalpha")
1072            } else {
1073                doc("Beta", "Note", &[], "beta content markbeta")
1074            })
1075        })
1076        .unwrap();
1077
1078        // Change a.md (new mtime) and remove b.md.
1079        let report = idx
1080            .update(&[stat("a.md", 2)], |_| {
1081                Ok(doc("Alpha", "Note", &[], "rewritten markgamma"))
1082            })
1083            .unwrap();
1084        assert_eq!((report.changed, report.removed, report.added), (1, 1, 0));
1085        assert_eq!(idx.doc_count(), 1);
1086        assert_eq!(idx.segment_count(), 2); // a second segment was layered on
1087        assert_eq!(idx.tombstone_count(), 2); // old a.md doc + removed b.md
1088
1089        // The old content is gone; the new content is found; b.md is gone.
1090        assert!(idx.search("markalpha", 10).unwrap().is_empty());
1091        assert!(idx.search("markbeta", 10).unwrap().is_empty());
1092        assert_eq!(idx.search("markgamma", 10).unwrap()[0].key, "a.md");
1093
1094        // No-op update writes no new segment.
1095        let report = idx
1096            .update(&[stat("a.md", 2)], |_| {
1097                panic!("should not load unchanged file")
1098            })
1099            .unwrap();
1100        assert!(report.is_empty());
1101        assert_eq!(idx.segment_count(), 2);
1102    }
1103
1104    #[test]
1105    fn condense_merges_segments_and_drops_tombstones() {
1106        let dir = scratch();
1107        let mut idx = Index::open(&dir).unwrap();
1108        idx.update(&[stat("a.md", 1)], |_| {
1109            Ok(doc("A", "Note", &[], "first markone"))
1110        })
1111        .unwrap();
1112        idx.update(&[stat("a.md", 1), stat("b.md", 1)], |f| {
1113            Ok(if f.key == "b.md" {
1114                doc("B", "Note", &[], "second marktwo")
1115            } else {
1116                doc("A", "Note", &[], "first markone")
1117            })
1118        })
1119        .unwrap();
1120        // Force a tombstone by rewriting a.md.
1121        idx.update(&[stat("a.md", 9), stat("b.md", 1)], |f| {
1122            Ok(if f.key == "a.md" {
1123                doc("A", "Note", &[], "third markthree")
1124            } else {
1125                doc("B", "Note", &[], "second marktwo")
1126            })
1127        })
1128        .unwrap();
1129        assert!(idx.segment_count() >= 2);
1130        assert!(idx.tombstone_count() >= 1);
1131
1132        assert!(idx.condense().unwrap());
1133        assert_eq!(idx.segment_count(), 1);
1134        assert_eq!(idx.tombstone_count(), 0);
1135        assert_eq!(idx.doc_count(), 2);
1136
1137        // Live content still searchable; superseded content gone.
1138        assert_eq!(idx.search("markthree", 10).unwrap()[0].key, "a.md");
1139        assert_eq!(idx.search("marktwo", 10).unwrap()[0].key, "b.md");
1140        assert!(idx.search("markone", 10).unwrap().is_empty());
1141
1142        // Old segment files were removed.
1143        let leftover = std::fs::read_dir(&dir)
1144            .unwrap()
1145            .filter_map(|e| e.ok())
1146            .filter(|e| e.path().extension().is_some_and(|x| x == "fst"))
1147            .count();
1148        assert_eq!(leftover, 1);
1149    }
1150
1151    #[test]
1152    fn pending_counts_added_changed_removed_without_mutating() {
1153        let dir = scratch();
1154        let mut idx = Index::open(&dir).unwrap();
1155        idx.update(&[stat("a.md", 1), stat("b.md", 1)], |f| {
1156            Ok(doc(&f.key, "Note", &[], "content"))
1157        })
1158        .unwrap();
1159        // a unchanged, b changed (new mtime), c new -> (1 added, 1 changed, 0 removed).
1160        assert_eq!(
1161            idx.pending(&[stat("a.md", 1), stat("b.md", 2), stat("c.md", 1)]),
1162            (1, 1, 0)
1163        );
1164        // Omitting b makes it a removal.
1165        assert_eq!(idx.pending(&[stat("a.md", 1)]), (0, 0, 1));
1166        // pending is read-only.
1167        assert_eq!(idx.doc_count(), 2);
1168    }
1169
1170    #[test]
1171    fn reset_clears_then_reindexes_from_scratch() {
1172        let dir = scratch();
1173        let mut idx = Index::open(&dir).unwrap();
1174        idx.update(&[stat("a.md", 1)], |_| {
1175            Ok(doc("A", "Note", &[], "unique markx"))
1176        })
1177        .unwrap();
1178        idx.save().unwrap();
1179        assert_eq!(idx.doc_count(), 1);
1180
1181        idx.reset();
1182        assert_eq!((idx.doc_count(), idx.segment_count()), (0, 0));
1183        assert!(idx.search("markx", 10).unwrap().is_empty());
1184
1185        // Indexing works again after a reset.
1186        idx.update(&[stat("a.md", 1)], |_| {
1187            Ok(doc("A", "Note", &[], "unique marky"))
1188        })
1189        .unwrap();
1190        assert_eq!(idx.search("marky", 10).unwrap()[0].key, "a.md");
1191    }
1192
1193    #[test]
1194    fn search_ranks_more_relevant_documents_higher() {
1195        let dir = scratch();
1196        let mut idx = Index::open(&dir).unwrap();
1197        idx.update(&[stat("hi.md", 1), stat("lo.md", 1)], |f| {
1198            Ok(if f.key == "hi.md" {
1199                doc("Hi", "Note", &[], "alpha alpha alpha beta")
1200            } else {
1201                doc("Lo", "Note", &[], "alpha gamma delta epsilon")
1202            })
1203        })
1204        .unwrap();
1205        let hits = idx.search("alpha", 10).unwrap();
1206        assert_eq!(hits.len(), 2);
1207        assert_eq!(
1208            hits[0].key, "hi.md",
1209            "the higher term frequency should rank first"
1210        );
1211        assert!(hits[0].score > hits[1].score);
1212    }
1213
1214    #[test]
1215    fn empty_query_returns_no_hits() {
1216        let dir = scratch();
1217        let mut idx = Index::open(&dir).unwrap();
1218        idx.update(&[stat("a.md", 1)], |_| Ok(doc("A", "Note", &[], "content")))
1219            .unwrap();
1220        assert!(idx.search("", 10).unwrap().is_empty());
1221        assert!(idx.search("   ", 10).unwrap().is_empty());
1222    }
1223
1224    #[test]
1225    fn search_merges_postings_across_segments() {
1226        let dir = scratch();
1227        let mut idx = Index::open(&dir).unwrap();
1228        // Two updates -> two segments, each holding a doc with the term "shared".
1229        idx.update(&[stat("a.md", 1)], |_| {
1230            Ok(doc("A", "Note", &[], "shared onlya"))
1231        })
1232        .unwrap();
1233        idx.update(&[stat("a.md", 1), stat("b.md", 1)], |f| {
1234            Ok(if f.key == "b.md" {
1235                doc("B", "Note", &[], "shared onlyb")
1236            } else {
1237                doc("A", "Note", &[], "shared onlya")
1238            })
1239        })
1240        .unwrap();
1241        assert!(idx.segment_count() >= 2);
1242        let hits = idx.search("shared", 10).unwrap();
1243        let keys: BTreeSet<&str> = hits.iter().map(|h| h.key.as_str()).collect();
1244        assert!(keys.contains("a.md") && keys.contains("b.md"), "{keys:?}");
1245    }
1246}