1use std::collections::{BTreeMap, BTreeSet, HashMap};
36use std::path::{Path, PathBuf};
37
38use fst::automaton::{Levenshtein, Str};
39use fst::{Automaton, IntoStreamer, Map, MapBuilder, Streamer};
40use regex_automata::Anchored;
43use regex_automata::dfa::Automaton as _;
44use regex_automata::dfa::{StartKind, dense};
45use regex_automata::util::primitives::StateID;
46
47const MANIFEST_VERSION: u64 = 1;
49
50const MAX_FUZZY: u32 = 2;
53
54pub 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
76fn 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
93fn 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
111fn 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
123fn 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#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct FileStat {
146 pub key: String,
148 pub path: PathBuf,
150 pub mtime_ns: u64,
152 pub size: u64,
154}
155
156#[derive(Debug, Clone, Default, PartialEq, Eq)]
159pub struct DocSource {
160 pub title: String,
162 pub type_: String,
164 pub tags: Vec<String>,
166 pub text: String,
168}
169
170#[derive(Debug, Default, Clone, PartialEq, Eq)]
172pub struct UpdateReport {
173 pub added: usize,
174 pub changed: usize,
175 pub removed: usize,
176 pub wrote_segment: bool,
178}
179
180impl UpdateReport {
181 pub fn is_empty(&self) -> bool {
183 self.added == 0 && self.changed == 0 && self.removed == 0
184 }
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
191struct DocMeta {
192 key: String,
193 title: String,
194 type_: String,
195 tags: Vec<String>,
196 len: u32,
198}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
202struct FileRec {
203 doc: u64,
204 mtime_ns: u64,
205 size: u64,
206}
207
208#[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#[derive(Debug, Clone, PartialEq, Eq)]
346pub enum QueryTerm {
347 Exact(String),
349 Prefix(String),
351 Fuzzy(String, u32),
353 Regex(String),
355}
356
357pub fn parse_query(query: &str) -> Vec<QueryTerm> {
365 let mut out = Vec::new();
366 for tok in query.split_whitespace() {
367 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 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
408struct Segment {
412 map: Map<Vec<u8>>,
413 pos: Vec<u8>,
414}
415
416fn 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
441struct 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 self.dfa.is_match_state(self.dfa.next_eoi_state(*state))
473 }
474
475 fn can_match(&self, state: &StateID) -> bool {
476 !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#[derive(Debug, Clone, PartialEq)]
489pub struct SearchHit {
490 pub key: String,
492 pub title: String,
493 pub type_: String,
494 pub tags: Vec<String>,
495 pub score: f32,
496}
497
498pub struct Index {
500 dir: PathBuf,
501 manifest: Manifest,
502}
503
504impl Index {
505 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 pub fn doc_count(&self) -> usize {
526 self.manifest.docs.len()
527 }
528
529 pub fn segment_count(&self) -> usize {
531 self.manifest.segments.len()
532 }
533
534 pub fn tombstone_count(&self) -> usize {
536 self.manifest.deleted.len()
537 }
538
539 pub fn generation(&self) -> u64 {
542 self.manifest.generation
543 }
544
545 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 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 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 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 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 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 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 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(¤t.into_values().collect::<Vec<_>>(), load)
753 }
754
755 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 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 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 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 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 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 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 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 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 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 let hits = idx.search("customers", 10).unwrap();
1018 assert_eq!(hits.len(), 1);
1019 assert_eq!(hits[0].key, "a.md");
1020
1021 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 let hits = idx.search("ordrs~", 10).unwrap();
1030 assert_eq!(hits[0].key, "b.md");
1031
1032 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 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 let hits = idx.search("/orders/", 10).unwrap();
1059 assert_eq!(hits[0].key, "a.md");
1060
1061 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 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); assert_eq!(idx.tombstone_count(), 2); 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 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 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 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 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 assert_eq!(
1161 idx.pending(&[stat("a.md", 1), stat("b.md", 2), stat("c.md", 1)]),
1162 (1, 1, 0)
1163 );
1164 assert_eq!(idx.pending(&[stat("a.md", 1)]), (0, 0, 1));
1166 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 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 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}