1use crate::embeddings::{cosine_similarity, reciprocal_rank_fusion, Embedder, QUERY_INSTRUCTION_PREFIX};
2use anyhow::{Context, Result};
3use rayon::prelude::*;
4use rusqlite::{params, functions::FunctionFlags, Connection};
5use serde::{Deserialize, Serialize};
6use std::{
7 collections::{HashMap, HashSet},
8 fs,
9 path::{Path, PathBuf},
10 sync::Mutex,
11};
12use walkdir::WalkDir;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct BrainEntry {
20 pub id: String, pub entry_type: String, pub name: String, pub tags: Vec<String>,
24 pub repos: Vec<String>,
25 pub updated: Option<String>,
26 pub body: String,
27}
28
29#[derive(Debug, Default)]
30pub struct QueryFilters {
31 pub entry_type: Option<String>,
32 pub tag: Option<String>,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct RebuildStats {
38 pub indexed: usize,
40 pub embedded: usize,
42 pub cached: usize,
44}
45
46pub struct BrainIndex {
51 conn: Mutex<Connection>,
52 brain_path: PathBuf,
53}
54
55impl BrainIndex {
56 pub fn open(brain_path: impl AsRef<Path>) -> Result<Self> {
57 let brain_path = brain_path.as_ref().to_path_buf();
58 fs::create_dir_all(&brain_path)
59 .with_context(|| format!("create brain dir {brain_path:?}"))?;
60 let db_path = brain_path.join(".index.db");
61 let conn = Connection::open(&db_path)
62 .with_context(|| format!("open brain db {db_path:?}"))?;
63 conn.execute_batch(
64 "PRAGMA journal_mode=WAL;
65 CREATE TABLE IF NOT EXISTS entries (
66 id TEXT PRIMARY KEY,
67 type TEXT NOT NULL,
68 name TEXT NOT NULL,
69 tags TEXT NOT NULL DEFAULT '[]',
70 repos TEXT NOT NULL DEFAULT '[]',
71 updated TEXT,
72 body TEXT NOT NULL DEFAULT ''
73 );
74 CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts
75 USING fts5(name, tags, body, content=entries, content_rowid=rowid);
76 CREATE TABLE IF NOT EXISTS links (from_id TEXT NOT NULL, target TEXT NOT NULL);
77 CREATE INDEX IF NOT EXISTS links_from ON links(from_id);
78 CREATE INDEX IF NOT EXISTS links_target ON links(target);
79 CREATE TABLE IF NOT EXISTS embeddings (
80 id TEXT PRIMARY KEY,
81 content_hash INTEGER NOT NULL,
82 vector BLOB NOT NULL
83 );",
84 )?;
85 conn.create_scalar_function(
89 "stem",
90 1,
91 FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC,
92 |ctx| {
93 let s: String = ctx.get(0)?;
94 Ok(stem_of(&s))
95 },
96 )?;
97 ensure_gitignore(&brain_path)?;
98 Ok(Self { conn: Mutex::new(conn), brain_path })
99 }
100
101 pub fn rebuild(&self, embedder: Option<&dyn Embedder>) -> Result<RebuildStats> {
106 let paths: Vec<PathBuf> = WalkDir::new(&self.brain_path)
108 .follow_links(false)
109 .into_iter()
110 .filter_map(|e| e.ok())
111 .map(|e| e.into_path())
112 .filter(|p| p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("md"))
113 .collect();
114
115 let records: Vec<FileRecord> = paths
117 .par_iter()
118 .filter_map(|p| process_file(&self.brain_path, p))
119 .collect();
120
121 let mut conn = self.conn.lock().unwrap();
122 let tx = conn.transaction()?;
123 tx.execute_batch("DELETE FROM entries; DELETE FROM entries_fts; DELETE FROM links;")?;
124
125 let indexed = records.len();
126 {
127 let mut insert_entry = tx.prepare(
128 "INSERT INTO entries (id, type, name, tags, repos, updated, body)
129 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
130 )?;
131 let mut insert_link =
132 tx.prepare("INSERT INTO links (from_id, target) VALUES (?1, ?2)")?;
133
134 for rec in &records {
135 let tags_json = serde_json::to_string(&rec.tags)?;
136 let repos_json = serde_json::to_string(&rec.repos)?;
137 insert_entry.execute(params![
138 rec.id,
139 rec.entry_type,
140 rec.name,
141 tags_json,
142 repos_json,
143 rec.updated,
144 rec.body
145 ])?;
146 for target in &rec.links {
147 insert_link.execute(params![rec.id, target])?;
148 }
149 }
150 }
151 tx.commit()?;
152
153 conn.execute_batch("INSERT INTO entries_fts(entries_fts) VALUES('rebuild');")?;
155
156 let (embedded, cached) = match embedder {
157 Some(embedder) => Self::sync_embeddings(&conn, &records, embedder)?,
158 None => (0, 0),
159 };
160
161 Ok(RebuildStats { indexed, embedded, cached })
162 }
163
164 fn sync_embeddings(
170 conn: &Connection,
171 records: &[FileRecord],
172 embedder: &dyn Embedder,
173 ) -> Result<(usize, usize)> {
174 let mut cached_hashes: HashMap<String, i64> = HashMap::new();
175 {
176 let mut stmt = conn.prepare("SELECT id, content_hash FROM embeddings")?;
177 let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?;
178 for row in rows {
179 let (id, hash) = row?;
180 cached_hashes.insert(id, hash);
181 }
182 }
183
184 let mut to_embed: Vec<(&FileRecord, String, i64)> = Vec::new();
185 let mut cached = 0usize;
186 for rec in records {
187 let text = embedding_text(rec);
188 let hash = content_hash(&text);
189 if cached_hashes.get(&rec.id) == Some(&hash) {
190 cached += 1;
191 } else {
192 to_embed.push((rec, text, hash));
193 }
194 }
195
196 let embedded = if to_embed.is_empty() {
197 0
198 } else {
199 let texts: Vec<String> = to_embed.iter().map(|(_, text, _)| text.clone()).collect();
200 match embedder.embed_batch(&texts) {
201 Ok(vectors) => {
202 let mut upsert = conn.prepare(
203 "INSERT INTO embeddings (id, content_hash, vector) VALUES (?1, ?2, ?3)
204 ON CONFLICT(id) DO UPDATE SET content_hash = excluded.content_hash, vector = excluded.vector",
205 )?;
206 let mut upserted = 0usize;
207 for ((rec, _, hash), vector) in to_embed.iter().zip(vectors.iter()) {
208 upsert.execute(params![rec.id, hash, vector_to_blob(vector)])?;
209 upserted += 1;
210 }
211 upserted
212 }
213 Err(err) => {
214 tracing::warn!("brain: failed to embed {} entries: {err}", to_embed.len());
215 0
216 }
217 }
218 };
219
220 let live_ids: HashSet<&str> = records.iter().map(|r| r.id.as_str()).collect();
222 let mut stale: Vec<String> = Vec::new();
223 {
224 let mut stmt = conn.prepare("SELECT id FROM embeddings")?;
225 let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
226 for row in rows {
227 let id = row?;
228 if !live_ids.contains(id.as_str()) {
229 stale.push(id);
230 }
231 }
232 }
233 if !stale.is_empty() {
234 let mut delete = conn.prepare("DELETE FROM embeddings WHERE id = ?1")?;
235 for id in &stale {
236 delete.execute(params![id])?;
237 }
238 }
239
240 Ok((embedded, cached))
241 }
242
243 pub fn query(
249 &self,
250 text: &str,
251 embedder: Option<&dyn Embedder>,
252 filters: QueryFilters,
253 ) -> Result<Vec<BrainEntry>> {
254 let conn = self.conn.lock().unwrap();
255
256 if text.trim().is_empty() && filters.entry_type.is_none() && filters.tag.is_none() {
257 let mut stmt = conn.prepare(
259 "SELECT id, type, name, tags, repos, updated, body FROM entries ORDER BY name",
260 )?;
261 let rows = stmt.query_map([], row_to_entry)?;
262 let entries: Vec<BrainEntry> =
263 rows.collect::<rusqlite::Result<Vec<_>>>()?;
264 return Ok(entries);
265 }
266
267 if text.trim().is_empty() {
268 let mut stmt = conn.prepare(
270 "SELECT id, type, name, tags, repos, updated, body FROM entries
271 WHERE (?1 IS NULL OR type = ?1)
272 ORDER BY name",
273 )?;
274 let rows = stmt.query_map(params![filters.entry_type.as_deref()], row_to_entry)?;
275 let mut results: Vec<BrainEntry> =
276 rows.collect::<rusqlite::Result<Vec<_>>>()?;
277 if let Some(ref tag) = filters.tag {
278 results.retain(|e| e.tags.iter().any(|t| t == tag));
279 }
280 return Ok(results);
281 }
282
283 let mut stmt = conn.prepare(
285 "SELECT e.id, e.type, e.name, e.tags, e.repos, e.updated, e.body
286 FROM entries_fts
287 JOIN entries e ON entries_fts.rowid = e.rowid
288 WHERE entries_fts MATCH ?1
289 ORDER BY rank",
290 )?;
291 let rows = stmt.query_map(params![sanitize_fts_query(text)], row_to_entry)?;
292 let keyword_results: Vec<BrainEntry> = rows.collect::<rusqlite::Result<Vec<_>>>()?;
293 let keyword_ids: Vec<String> = keyword_results.iter().map(|e| e.id.clone()).collect();
294
295 let semantic_ids: Vec<String> = match embedder {
297 Some(embedder) => Self::semantic_candidates(&conn, embedder, text)?,
298 None => Vec::new(),
299 };
300
301 let fusion_ran = !semantic_ids.is_empty();
302 let mut results: Vec<BrainEntry> = if semantic_ids.is_empty() {
303 keyword_results
304 } else {
305 let fused = reciprocal_rank_fusion(&[keyword_ids, semantic_ids], 60.0);
306 let mut by_id: HashMap<String, BrainEntry> =
307 keyword_results.into_iter().map(|e| (e.id.clone(), e)).collect();
308 let mut fused_results = Vec::with_capacity(fused.len());
309 for (id, _score) in fused {
310 if let Some(entry) = by_id.remove(&id) {
311 fused_results.push(entry);
312 } else if let Some(entry) = get_by_id(&conn, &id)? {
313 fused_results.push(entry);
314 }
315 }
316 fused_results
317 };
318
319 if let Some(ref et) = filters.entry_type {
321 results.retain(|e| &e.entry_type == et);
322 }
323 if let Some(ref tag) = filters.tag {
324 results.retain(|e| e.tags.iter().any(|t| t == tag));
325 }
326
327 if fusion_ran {
332 results.truncate(20);
333 }
334 Ok(results)
335 }
336
337 fn semantic_candidates(
343 conn: &Connection,
344 embedder: &dyn Embedder,
345 text: &str,
346 ) -> Result<Vec<String>> {
347 let query_vector = match embedder.embed(&format!("{QUERY_INSTRUCTION_PREFIX}{text}")) {
348 Ok(v) => v,
349 Err(err) => {
350 tracing::warn!("brain: failed to embed query text: {err}");
351 return Ok(Vec::new());
352 }
353 };
354
355 let mut stmt = conn.prepare("SELECT id, vector FROM embeddings")?;
356 let rows = stmt.query_map([], |r| {
357 Ok((r.get::<_, String>(0)?, r.get::<_, Vec<u8>>(1)?))
358 })?;
359
360 let mut scored: Vec<(String, f32)> = Vec::new();
361 for row in rows {
362 let (id, blob) = row?;
363 let vector = blob_to_vector(&blob);
364 scored.push((id, cosine_similarity(&query_vector, &vector)));
365 }
366 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
367 scored.truncate(20);
368 Ok(scored.into_iter().map(|(id, _)| id).collect())
369 }
370
371 pub fn get(&self, id: &str) -> Result<Option<BrainEntry>> {
373 let conn = self.conn.lock().unwrap();
374 get_by_id(&conn, id)
375 }
376
377 pub fn backlinks(&self, id: &str) -> Result<Vec<BrainEntry>> {
383 let conn = self.conn.lock().unwrap();
384 let Some(entry) = get_by_id(&conn, id)? else {
385 return Ok(Vec::new());
386 };
387 let mut stmt = conn.prepare(
388 "SELECT DISTINCT src.id, src.type, src.name, src.tags, src.repos, src.updated, src.body
389 FROM links l
390 JOIN entries src ON src.id = l.from_id
391 WHERE src.id != ?2
392 AND (l.target = ?1 OR l.target = ?2 OR stem(l.target) = stem(?2) OR stem(l.target) = ?1)
393 ORDER BY src.name",
394 )?;
395 let rows = stmt.query_map(params![entry.name, entry.id], row_to_entry)?;
396 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
397 }
398
399 pub fn outlinks(&self, id: &str) -> Result<Vec<BrainEntry>> {
401 let conn = self.conn.lock().unwrap();
402 let mut stmt = conn.prepare(
403 "SELECT DISTINCT e.id, e.type, e.name, e.tags, e.repos, e.updated, e.body
404 FROM links l
405 JOIN entries e ON (
406 l.target = e.name OR
407 l.target = e.id OR
408 stem(l.target) = stem(e.id) OR
409 stem(l.target) = e.name
410 )
411 WHERE l.from_id = ?1 AND e.id != ?1
412 ORDER BY e.name",
413 )?;
414 let rows = stmt.query_map(params![id], row_to_entry)?;
415 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
416 }
417
418 pub fn links_all(&self) -> Result<Vec<(String, String)>> {
420 let conn = self.conn.lock().unwrap();
421 let mut stmt = conn.prepare(
422 "SELECT DISTINCT l.from_id, e.id
423 FROM links l
424 JOIN entries e ON (
425 l.target = e.name OR
426 l.target = e.id OR
427 stem(l.target) = stem(e.id) OR
428 stem(l.target) = e.name
429 )
430 WHERE l.from_id != e.id
431 ORDER BY l.from_id, e.id",
432 )?;
433 let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
434 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
435 }
436
437 pub fn related(&self, id: &str, limit: usize) -> Result<Vec<BrainEntry>> {
442 let entry = match self.get(id)? {
443 Some(e) => e,
444 None => return Ok(Vec::new()),
445 };
446
447 let mut ranked: Vec<BrainEntry> = Vec::new();
448 let mut seen: HashSet<String> = HashSet::new();
449 seen.insert(entry.id.clone());
450
451 let outs = self.outlinks(id)?;
453 let backs = self.backlinks(id)?;
454 merge_unique(outs.clone(), &mut ranked, &mut seen);
455 merge_unique(backs.clone(), &mut ranked, &mut seen);
456
457 let mut co_citation: Vec<BrainEntry> = Vec::new();
462 for target in &outs {
463 co_citation.extend(self.backlinks(&target.id)?);
464 }
465 for source in &backs {
466 co_citation.extend(self.outlinks(&source.id)?);
467 }
468 merge_unique(co_citation, &mut ranked, &mut seen);
469
470 if !entry.tags.is_empty() {
472 let conn = self.conn.lock().unwrap();
473 let clauses: Vec<&str> = entry.tags.iter().map(|_| "tags LIKE ?").collect();
474 let sql = format!(
475 "SELECT id, type, name, tags, repos, updated, body FROM entries
476 WHERE id != ? AND ({}) ORDER BY name",
477 clauses.join(" OR ")
478 );
479 let mut stmt = conn.prepare(&sql)?;
480 let mut bind_params: Vec<String> = vec![entry.id.clone()];
481 bind_params.extend(entry.tags.iter().map(|t| format!("%\"{t}\"%")));
482 let param_refs: Vec<&dyn rusqlite::ToSql> =
483 bind_params.iter().map(|p| p as &dyn rusqlite::ToSql).collect();
484 let rows = stmt.query_map(param_refs.as_slice(), row_to_entry)?;
485 let tag_matches: Vec<BrainEntry> = rows.collect::<rusqlite::Result<Vec<_>>>()?;
486 merge_unique(tag_matches, &mut ranked, &mut seen);
487 }
488
489 ranked.truncate(limit);
490 Ok(ranked)
491 }
492}
493
494fn merge_unique(items: Vec<BrainEntry>, ranked: &mut Vec<BrainEntry>, seen: &mut HashSet<String>) {
496 for item in items {
497 if seen.insert(item.id.clone()) {
498 ranked.push(item);
499 }
500 }
501}
502
503fn get_by_id(conn: &Connection, id: &str) -> Result<Option<BrainEntry>> {
509 let mut stmt = conn
510 .prepare("SELECT id, type, name, tags, repos, updated, body FROM entries WHERE id = ?1")?;
511 let mut rows = stmt.query_map([id], row_to_entry)?;
512 match rows.next() {
513 None => Ok(None),
514 Some(r) => Ok(Some(r?)),
515 }
516}
517
518fn stem_of(s: &str) -> String {
523 let base = s.rsplit('/').next().unwrap_or(s);
524 match base.rsplit_once('.') {
525 Some((stem, _ext)) if !stem.is_empty() => stem.to_string(),
526 _ => base.to_string(),
527 }
528}
529
530fn extract_wikilinks(text: &str) -> Vec<String> {
539 let mut out = Vec::new();
540 let mut search_from = 0usize;
541 while let Some(rel_start) = text[search_from..].find("[[") {
542 let start = search_from + rel_start;
543 let is_embed = start > 0 && text.as_bytes()[start - 1] == b'!';
544 let inner_start = start + 2;
545 let Some(rel_end) = text[inner_start..].find("]]") else {
546 break;
547 };
548 let end = inner_start + rel_end;
549 let inner = &text[inner_start..end];
550 search_from = end + 2;
551
552 if is_embed || inner.is_empty() {
553 continue;
554 }
555 let before_alias = inner.split('|').next().unwrap_or(inner);
556 let target = before_alias.split('#').next().unwrap_or(before_alias).trim();
557 if !target.is_empty() {
558 out.push(target.to_string());
559 }
560 }
561 out
562}
563
564struct FileRecord {
568 id: String,
569 entry_type: String,
570 name: String,
571 tags: Vec<String>,
572 repos: Vec<String>,
573 updated: Option<String>,
574 body: String,
575 links: Vec<String>,
576}
577
578fn process_file(brain_path: &Path, path: &Path) -> Option<FileRecord> {
582 let rel = path
583 .strip_prefix(brain_path)
584 .unwrap_or(path)
585 .to_string_lossy()
586 .to_string();
587
588 let content = fs::read_to_string(path).ok()?;
589 let parsed = parse_markdown(&content);
590
591 let parent_type = path
593 .parent()
594 .and_then(|p| {
595 if p == brain_path { None } else { p.file_name() }
597 })
598 .and_then(|n| n.to_str())
599 .map(str::to_string);
600
601 let entry_type = parsed
602 .frontmatter
603 .get("type")
604 .and_then(|v| v.as_str())
605 .map(str::to_string)
606 .or(parent_type)
607 .unwrap_or_else(|| "note".to_string());
608
609 let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown");
610 let name = parsed
611 .frontmatter
612 .get("name")
613 .and_then(|v| v.as_str())
614 .map(str::to_string)
615 .unwrap_or_else(|| stem.to_string());
616
617 let tags: Vec<String> = parsed
618 .frontmatter
619 .get("tags")
620 .and_then(|v| v.as_sequence())
621 .cloned()
622 .unwrap_or_default();
623
624 let repos: Vec<String> = parsed
625 .frontmatter
626 .get("repos")
627 .and_then(|v| v.as_sequence())
628 .cloned()
629 .unwrap_or_default();
630
631 let updated = parsed
632 .frontmatter
633 .get("updated")
634 .and_then(|v| v.as_str())
635 .map(str::to_string);
636
637 let links = extract_wikilinks(&parsed.body);
638
639 Some(FileRecord { id: rel, entry_type, name, tags, repos, updated, body: parsed.body, links })
640}
641
642fn sanitize_fts_query(text: &str) -> String {
650 text.split_whitespace()
651 .map(|tok| format!("\"{}\"", tok.replace('"', "\"\"")))
652 .collect::<Vec<_>>()
653 .join(" ")
654}
655
656fn embedding_text(rec: &FileRecord) -> String {
662 let mut text = format!("{}\n\n{}", rec.name, rec.body);
663 text.truncate(2000);
664 text
665}
666
667fn content_hash(text: &str) -> i64 {
672 use std::hash::{Hash, Hasher};
673 let mut hasher = std::collections::hash_map::DefaultHasher::new();
674 text.hash(&mut hasher);
675 hasher.finish() as i64
676}
677
678fn vector_to_blob(vector: &[f32]) -> Vec<u8> {
679 vector.iter().flat_map(|f| f.to_le_bytes()).collect()
680}
681
682fn blob_to_vector(blob: &[u8]) -> Vec<f32> {
683 blob.chunks_exact(4)
684 .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
685 .collect()
686}
687
688fn row_to_entry(r: &rusqlite::Row) -> rusqlite::Result<BrainEntry> {
689 let tags_json: String = r.get(3)?;
690 let repos_json: String = r.get(4)?;
691 let tags: Vec<String> = serde_json::from_str(&tags_json).unwrap_or_default();
692 let repos: Vec<String> = serde_json::from_str(&repos_json).unwrap_or_default();
693 Ok(BrainEntry {
694 id: r.get(0)?,
695 entry_type: r.get(1)?,
696 name: r.get(2)?,
697 tags,
698 repos,
699 updated: r.get(5)?,
700 body: r.get(6)?,
701 })
702}
703
704fn ensure_gitignore(brain_path: &Path) -> Result<()> {
706 let gi = brain_path.join(".gitignore");
707 let entry = ".index.db\n";
708 if gi.exists() {
709 let content = fs::read_to_string(&gi)?;
710 if !content.contains(".index.db") {
711 fs::write(&gi, format!("{content}{entry}"))?;
712 }
713 } else {
714 fs::write(&gi, entry)?;
715 }
716 Ok(())
717}
718
719struct FmValue {
724 str_val: Option<String>,
725 seq_val: Option<Vec<String>>,
726}
727
728impl FmValue {
729 fn str(s: &str) -> Self {
730 Self { str_val: Some(s.to_string()), seq_val: None }
731 }
732
733 fn seq(v: Vec<String>) -> Self {
734 Self { str_val: None, seq_val: Some(v) }
735 }
736
737 fn as_str(&self) -> Option<&str> {
738 self.str_val.as_deref()
739 }
740
741 fn as_sequence(&self) -> Option<&Vec<String>> {
742 self.seq_val.as_ref()
743 }
744}
745
746struct Frontmatter(HashMap<String, FmValue>);
747
748impl Frontmatter {
749 fn get(&self, key: &str) -> Option<&FmValue> {
750 self.0.get(key)
751 }
752}
753
754struct ParsedMd {
755 frontmatter: Frontmatter,
756 body: String,
757}
758
759fn parse_markdown(content: &str) -> ParsedMd {
760 if !content.starts_with("---") {
761 return ParsedMd {
762 frontmatter: Frontmatter(HashMap::new()),
763 body: content.to_string(),
764 };
765 }
766 let rest = &content[3..];
767 let end = rest.find("\n---").or_else(|| rest.find("\r\n---"));
768 let (fm_text, body) = match end {
769 None => ("", content),
770 Some(pos) => {
771 let after = &rest[pos + 4..]; let body = after.trim_start_matches('\n').trim_start_matches('\r');
774 (&rest[..pos], body)
775 }
776 };
777 let fm = parse_frontmatter(fm_text);
778 ParsedMd { frontmatter: fm, body: body.to_string() }
779}
780
781fn parse_frontmatter(text: &str) -> Frontmatter {
782 let mut map: HashMap<String, FmValue> = HashMap::new();
783 let mut lines = text.lines().peekable();
784 while let Some(line) = lines.next() {
785 let line = line.trim();
786 if line.is_empty() {
787 continue;
788 }
789 if let Some((key, val)) = line.split_once(':') {
790 let key = key.trim().to_string();
791 let val = val.trim();
792 if val.is_empty() {
793 let mut seq = Vec::new();
795 while let Some(next) = lines.peek() {
796 let t = next.trim();
797 if let Some(stripped) = t.strip_prefix("- ") {
798 seq.push(stripped.trim().to_string());
799 lines.next();
800 } else {
801 break;
802 }
803 }
804 if !seq.is_empty() {
805 map.insert(key, FmValue::seq(seq));
806 }
807 } else if val.starts_with('[') && val.ends_with(']') {
808 let inner = &val[1..val.len() - 1];
810 let seq: Vec<String> = inner
811 .split(',')
812 .map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string())
813 .filter(|s| !s.is_empty())
814 .collect();
815 map.insert(key, FmValue::seq(seq));
816 } else {
817 map.insert(
818 key,
819 FmValue::str(val.trim_matches('"').trim_matches('\'')),
820 );
821 }
822 }
823 }
824 Frontmatter(map)
825}
826
827#[cfg(test)]
832mod tests {
833 use super::*;
834 use crate::embeddings::Embedder;
835 use std::sync::atomic::{AtomicUsize, Ordering};
836 use tempfile::tempdir;
837
838 fn make_brain() -> (BrainIndex, tempfile::TempDir) {
839 let dir = tempdir().unwrap();
840 let brain = BrainIndex::open(dir.path()).unwrap();
841 (brain, dir)
842 }
843
844 struct FakeEmbedder {
849 calls: AtomicUsize,
850 dim: usize,
851 }
852
853 impl FakeEmbedder {
854 fn new(dim: usize) -> Self {
855 Self { calls: AtomicUsize::new(0), dim }
856 }
857 }
858
859 impl Embedder for FakeEmbedder {
860 fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>> {
861 self.calls.fetch_add(1, Ordering::SeqCst);
862 let seed = text.len() as f32;
863 Ok((0..self.dim).map(|i| seed + i as f32).collect())
864 }
865
866 fn embed_batch(&self, texts: &[String]) -> anyhow::Result<Vec<Vec<f32>>> {
867 texts.iter().map(|t| self.embed(t)).collect()
868 }
869
870 fn dimension(&self) -> usize {
871 self.dim
872 }
873 }
874
875 #[test]
876 fn rebuild_embeds_new_entries() {
877 let (brain, dir) = make_brain();
878 fs::create_dir_all(dir.path().join("notes")).unwrap();
879 fs::write(dir.path().join("notes/a.md"), "# A\n\nSome content.").unwrap();
880
881 let embedder = FakeEmbedder::new(4);
882 let stats = brain.rebuild(Some(&embedder)).unwrap();
883
884 assert_eq!(stats.indexed, 1);
885 assert_eq!(stats.embedded, 1);
886 assert_eq!(stats.cached, 0);
887 assert_eq!(embedder.calls.load(Ordering::SeqCst), 1);
888 }
889
890 #[test]
891 fn rebuild_reuses_cached_embedding_for_unchanged_content() {
892 let (brain, dir) = make_brain();
893 fs::create_dir_all(dir.path().join("notes")).unwrap();
894 fs::write(dir.path().join("notes/a.md"), "# A\n\nSome content.").unwrap();
895
896 let embedder = FakeEmbedder::new(4);
897 brain.rebuild(Some(&embedder)).unwrap();
898 assert_eq!(embedder.calls.load(Ordering::SeqCst), 1);
899
900 let stats = brain.rebuild(Some(&embedder)).unwrap();
902 assert_eq!(stats.embedded, 0);
903 assert_eq!(stats.cached, 1);
904 assert_eq!(embedder.calls.load(Ordering::SeqCst), 1, "should not re-embed unchanged content");
905 }
906
907 #[test]
908 fn rebuild_reembeds_changed_content() {
909 let (brain, dir) = make_brain();
910 let path = dir.path().join("notes");
911 fs::create_dir_all(&path).unwrap();
912 fs::write(path.join("a.md"), "# A\n\nOriginal content.").unwrap();
913
914 let embedder = FakeEmbedder::new(4);
915 brain.rebuild(Some(&embedder)).unwrap();
916 assert_eq!(embedder.calls.load(Ordering::SeqCst), 1);
917
918 fs::write(path.join("a.md"), "# A\n\nChanged content.").unwrap();
919 let stats = brain.rebuild(Some(&embedder)).unwrap();
920 assert_eq!(stats.embedded, 1);
921 assert_eq!(stats.cached, 0);
922 assert_eq!(embedder.calls.load(Ordering::SeqCst), 2);
923 }
924
925 #[test]
926 fn rebuild_prunes_embeddings_for_deleted_entries() {
927 let (brain, dir) = make_brain();
928 let path = dir.path().join("notes");
929 fs::create_dir_all(&path).unwrap();
930 fs::write(path.join("a.md"), "# A\n\nContent.").unwrap();
931
932 let embedder = FakeEmbedder::new(4);
933 brain.rebuild(Some(&embedder)).unwrap();
934
935 fs::remove_file(path.join("a.md")).unwrap();
936 brain.rebuild(Some(&embedder)).unwrap();
937
938 let conn = brain.conn.lock().unwrap();
939 let count: i64 = conn.query_row("SELECT COUNT(*) FROM embeddings", [], |r| r.get(0)).unwrap();
940 assert_eq!(count, 0, "embedding for the deleted entry should be pruned");
941 }
942
943 #[test]
944 fn rebuild_without_embedder_skips_embeddings_entirely() {
945 let (brain, dir) = make_brain();
946 fs::create_dir_all(dir.path().join("notes")).unwrap();
947 fs::write(dir.path().join("notes/a.md"), "# A\n\nContent.").unwrap();
948
949 let stats = brain.rebuild(None).unwrap();
950 assert_eq!(stats.indexed, 1);
951 assert_eq!(stats.embedded, 0);
952 assert_eq!(stats.cached, 0);
953
954 let conn = brain.conn.lock().unwrap();
955 let count: i64 = conn.query_row("SELECT COUNT(*) FROM embeddings", [], |r| r.get(0)).unwrap();
956 assert_eq!(count, 0);
957 }
958
959 struct FailingEmbedder;
962 impl Embedder for FailingEmbedder {
963 fn embed(&self, _text: &str) -> anyhow::Result<Vec<f32>> {
964 anyhow::bail!("simulated embedder failure")
965 }
966 fn embed_batch(&self, _texts: &[String]) -> anyhow::Result<Vec<Vec<f32>>> {
967 anyhow::bail!("simulated embedder failure")
968 }
969 fn dimension(&self) -> usize {
970 4
971 }
972 }
973
974 #[test]
975 fn rebuild_indexing_survives_embedder_failure() {
976 let (brain, dir) = make_brain();
977 fs::create_dir_all(dir.path().join("notes")).unwrap();
978 fs::write(dir.path().join("notes/a.md"), "# A\n\nContent.").unwrap();
979
980 let stats = brain.rebuild(Some(&FailingEmbedder)).unwrap();
981 assert_eq!(stats.indexed, 1, "keyword indexing must succeed even if embedding fails");
982 assert_eq!(stats.embedded, 0);
983 }
984
985 #[test]
986 fn open_creates_schema() {
987 let (_brain, dir) = make_brain();
988 let db_path = dir.path().join(".index.db");
989 assert!(db_path.exists());
990 let gi = dir.path().join(".gitignore");
992 assert!(gi.exists());
993 let content = fs::read_to_string(&gi).unwrap();
994 assert!(content.contains(".index.db"));
995 }
996
997 #[test]
998 fn open_creates_embeddings_table() {
999 let (brain, _dir) = make_brain();
1000 let conn = brain.conn.lock().unwrap();
1001 let count: i64 = conn
1004 .query_row("SELECT COUNT(*) FROM embeddings", [], |r| r.get(0))
1005 .unwrap();
1006 assert_eq!(count, 0);
1007 }
1008
1009 #[test]
1010 fn rebuild_indexes_files() {
1011 let (brain, dir) = make_brain();
1012 let people_dir = dir.path().join("people");
1013 fs::create_dir_all(&people_dir).unwrap();
1014 fs::write(
1015 people_dir.join("alice.md"),
1016 "---\nname: Alice Smith\ntags:\n- engineering\n- leadership\n---\nAlice leads the infra team.",
1017 )
1018 .unwrap();
1019 fs::write(
1020 people_dir.join("bob.md"),
1021 "# Bob\n\nBob works on frontend.",
1022 )
1023 .unwrap();
1024
1025 let stats = brain.rebuild(None).unwrap();
1026 assert_eq!(stats.indexed, 2);
1027 }
1028
1029 #[test]
1030 fn query_returns_matches() {
1031 let (brain, dir) = make_brain();
1032 let dir_path = dir.path().join("notes");
1033 fs::create_dir_all(&dir_path).unwrap();
1034 fs::write(
1035 dir_path.join("rust-tips.md"),
1036 "---\nname: Rust Tips\ntags:\n- rust\n---\nUse anyhow for error handling.",
1037 )
1038 .unwrap();
1039 fs::write(
1040 dir_path.join("python-tips.md"),
1041 "---\nname: Python Tips\ntags:\n- python\n---\nUse dataclasses for data.",
1042 )
1043 .unwrap();
1044
1045 brain.rebuild(None).unwrap();
1046
1047 let results = brain.query("anyhow", None, QueryFilters::default()).unwrap();
1048 assert_eq!(results.len(), 1);
1049 assert_eq!(results[0].name, "Rust Tips");
1050 }
1051
1052 #[test]
1056 fn query_tolerates_special_characters() {
1057 let (brain, dir) = make_brain();
1058 let dir_path = dir.path().join("repos");
1059 fs::create_dir_all(&dir_path).unwrap();
1060 fs::write(
1061 dir_path.join("ninox-server.md"),
1062 "---\nname: ninox-server\n---\nHTTP API for ninox-server.",
1063 )
1064 .unwrap();
1065
1066 brain.rebuild(None).unwrap();
1067
1068 for text in ["ninox-server", "foo:bar", "orchestrator\"", "-orchestrator"] {
1069 brain
1070 .query(text, None, QueryFilters::default())
1071 .unwrap_or_else(|e| panic!("query({text:?}) should not error, got: {e}"));
1072 }
1073
1074 let results = brain.query("ninox-server", None, QueryFilters::default()).unwrap();
1075 assert_eq!(results.len(), 1);
1076 assert_eq!(results[0].name, "ninox-server");
1077 }
1078
1079 #[test]
1080 fn query_filters_by_type() {
1081 let (brain, dir) = make_brain();
1082 let people = dir.path().join("people");
1083 let projects = dir.path().join("projects");
1084 fs::create_dir_all(&people).unwrap();
1085 fs::create_dir_all(&projects).unwrap();
1086 fs::write(people.join("alice.md"), "Alice is a person.").unwrap();
1087 fs::write(projects.join("athene.md"), "Athene is a project.").unwrap();
1088
1089 brain.rebuild(None).unwrap();
1090
1091 let results = brain
1092 .query("", None, QueryFilters { entry_type: Some("people".into()), tag: None })
1093 .unwrap();
1094 assert_eq!(results.len(), 1);
1095 assert_eq!(results[0].entry_type, "people");
1096 }
1097
1098 #[test]
1099 fn query_surfaces_semantic_match_with_no_keyword_overlap() {
1100 let (brain, dir) = make_brain();
1101 let notes = dir.path().join("notes");
1102 fs::create_dir_all(¬es).unwrap();
1103 fs::write(notes.join("outage.md"), "---\nname: 401 debugging notes\n---\nHow we diagnosed the outage.").unwrap();
1105 fs::write(notes.join("coffee.md"), "---\nname: Coffee notes\n---\nBest beans for espresso.").unwrap();
1106
1107 struct SemanticFakeEmbedder;
1112 impl Embedder for SemanticFakeEmbedder {
1113 fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>> {
1114 if text.contains("auth") || text.contains("401") || text.contains("outage") {
1115 Ok(vec![1.0, 0.0])
1116 } else {
1117 Ok(vec![0.0, 1.0])
1118 }
1119 }
1120 fn embed_batch(&self, texts: &[String]) -> anyhow::Result<Vec<Vec<f32>>> {
1121 texts.iter().map(|t| self.embed(t)).collect()
1122 }
1123 fn dimension(&self) -> usize {
1124 2
1125 }
1126 }
1127
1128 let embedder = SemanticFakeEmbedder;
1129 brain.rebuild(Some(&embedder)).unwrap();
1130
1131 let results = brain.query("auth failures", Some(&embedder), QueryFilters::default()).unwrap();
1134 assert!(
1135 results.iter().any(|e| e.id == "notes/outage.md"),
1136 "expected the semantically related entry to surface: {results:?}"
1137 );
1138 let outage_rank = results.iter().position(|e| e.id == "notes/outage.md").unwrap();
1142 if let Some(coffee_rank) = results.iter().position(|e| e.id == "notes/coffee.md") {
1143 assert!(
1144 outage_rank < coffee_rank,
1145 "the semantically related entry should outrank the unrelated one: {results:?}"
1146 );
1147 }
1148 }
1149
1150 #[test]
1151 fn query_degrades_gracefully_when_embedder_fails_at_query_time() {
1152 let (brain, dir) = make_brain();
1153 let notes = dir.path().join("notes");
1154 fs::create_dir_all(¬es).unwrap();
1155 fs::write(notes.join("rust.md"), "---\nname: Rust Tips\n---\nUse anyhow for error handling.").unwrap();
1156
1157 brain.rebuild(None).unwrap();
1160 let results = brain.query("anyhow", Some(&FailingEmbedder), QueryFilters::default()).unwrap();
1161
1162 assert_eq!(
1163 results.len(),
1164 1,
1165 "a failing embedder must degrade to keyword-only results, not fail the whole query"
1166 );
1167 assert_eq!(results[0].name, "Rust Tips");
1168 }
1169
1170 #[test]
1171 fn query_without_embedder_matches_current_keyword_only_behavior() {
1172 let (brain, dir) = make_brain();
1173 let notes = dir.path().join("notes");
1174 fs::create_dir_all(¬es).unwrap();
1175 fs::write(notes.join("rust.md"), "---\nname: Rust Tips\n---\nUse anyhow for error handling.").unwrap();
1176 brain.rebuild(None).unwrap();
1177
1178 let results = brain.query("anyhow", None, QueryFilters::default()).unwrap();
1179 assert_eq!(results.len(), 1);
1180 assert_eq!(results[0].name, "Rust Tips");
1181 }
1182
1183 #[test]
1184 fn query_empty_text_ignores_embedder() {
1185 let (brain, dir) = make_brain();
1186 fs::create_dir_all(dir.path().join("notes")).unwrap();
1187 fs::write(dir.path().join("notes/a.md"), "# A\n\nContent.").unwrap();
1188 let embedder = FakeEmbedder::new(4);
1189 brain.rebuild(Some(&embedder)).unwrap();
1190 embedder.calls.store(0, Ordering::SeqCst);
1191
1192 let results = brain.query("", Some(&embedder), QueryFilters::default()).unwrap();
1193 assert_eq!(results.len(), 1);
1194 assert_eq!(
1195 embedder.calls.load(Ordering::SeqCst),
1196 0,
1197 "empty-text queries must never touch the embedder"
1198 );
1199 }
1200
1201 fn write_many_matching_notes(dir: &Path, n: usize) {
1204 let notes = dir.join("notes");
1205 fs::create_dir_all(¬es).unwrap();
1206 for i in 0..n {
1207 fs::write(
1208 notes.join(format!("note{i}.md")),
1209 format!("---\nname: Note {i}\n---\nAll about widgetronic devices, entry {i}."),
1210 )
1211 .unwrap();
1212 }
1213 }
1214
1215 #[test]
1216 fn query_without_embedder_returns_all_keyword_matches_uncapped() {
1217 let (brain, dir) = make_brain();
1218 write_many_matching_notes(dir.path(), 25);
1219 brain.rebuild(None).unwrap();
1220
1221 let results = brain.query("widgetronic", None, QueryFilters::default()).unwrap();
1222 assert_eq!(
1223 results.len(),
1224 25,
1225 "the no-embedder keyword path must stay exactly today's uncapped behavior, not truncate to 20: {}",
1226 results.len()
1227 );
1228 }
1229
1230 #[test]
1231 fn query_hybrid_fusion_caps_at_20() {
1232 let (brain, dir) = make_brain();
1233 write_many_matching_notes(dir.path(), 25);
1234
1235 let embedder = FakeEmbedder::new(4);
1239 brain.rebuild(Some(&embedder)).unwrap();
1240
1241 let results = brain.query("widgetronic", Some(&embedder), QueryFilters::default()).unwrap();
1242 assert_eq!(
1243 results.len(),
1244 20,
1245 "the fused (hybrid) path must still cap at 20 results"
1246 );
1247 }
1248
1249 #[test]
1254 fn wikilink_plain() {
1255 assert_eq!(extract_wikilinks("see [[Target]] please"), vec!["Target"]);
1256 }
1257
1258 #[test]
1259 fn wikilink_with_alias() {
1260 assert_eq!(extract_wikilinks("see [[Target|shown text]]"), vec!["Target"]);
1261 }
1262
1263 #[test]
1264 fn wikilink_with_heading() {
1265 assert_eq!(extract_wikilinks("see [[Target#Heading]]"), vec!["Target"]);
1266 }
1267
1268 #[test]
1269 fn wikilink_with_block_ref() {
1270 assert_eq!(extract_wikilinks("see [[Target#^abc123]]"), vec!["Target"]);
1271 }
1272
1273 #[test]
1274 fn wikilink_with_heading_and_alias() {
1275 assert_eq!(extract_wikilinks("see [[a#b|c]]"), vec!["a"]);
1276 }
1277
1278 #[test]
1279 fn wikilink_skips_embeds() {
1280 assert_eq!(extract_wikilinks("![[embedded-image]]"), Vec::<String>::new());
1281 }
1282
1283 #[test]
1284 fn wikilink_multiple_and_mixed() {
1285 let text = "Links: [[one]], ![[skip-me]], [[two|Two]], and [[three#Sec|Three]].";
1286 assert_eq!(extract_wikilinks(text), vec!["one", "two", "three"]);
1287 }
1288
1289 #[test]
1290 fn stem_of_strips_path_and_extension() {
1291 assert_eq!(stem_of("people/alice.md"), "alice");
1292 assert_eq!(stem_of("alice"), "alice");
1293 assert_eq!(stem_of("alice.md"), "alice");
1294 assert_eq!(stem_of("a/b/c.md"), "c");
1295 }
1296
1297 fn make_linked_brain() -> (BrainIndex, tempfile::TempDir) {
1308 let (brain, dir) = make_brain();
1309 let people = dir.path().join("people");
1310 let projects = dir.path().join("projects");
1311 fs::create_dir_all(&people).unwrap();
1312 fs::create_dir_all(&projects).unwrap();
1313
1314 fs::write(
1315 people.join("alice.md"),
1316 "---\nname: Alice\ntags:\n- infra\n- leads\n---\n\
1317 Manager of [[bob]] and works with [[projects/athene.md|Athene]]. \
1318 See [[bob#Contact]] too. Also embed ![[ignored]].",
1319 )
1320 .unwrap();
1321 fs::write(
1322 people.join("bob.md"),
1323 "---\nname: Bob\ntags:\n- infra\n---\nReports to [[alice]].",
1324 )
1325 .unwrap();
1326 fs::write(
1327 projects.join("athene.md"),
1328 "---\nname: Athene\ntags:\n- platform\n---\nFlagship project.",
1329 )
1330 .unwrap();
1331 fs::write(
1332 people.join("carol.md"),
1333 "---\nname: Carol\ntags:\n- ops\n---\nAlso reports to [[bob]].",
1334 )
1335 .unwrap();
1336 fs::write(
1337 people.join("dave.md"),
1338 "---\nname: Dave\ntags:\n- infra\n---\nNo links, just tag overlap.",
1339 )
1340 .unwrap();
1341
1342 brain.rebuild(None).unwrap();
1343 (brain, dir)
1344 }
1345
1346 #[test]
1347 fn outlinks_resolves_stem_and_id_matches() {
1348 let (brain, _dir) = make_linked_brain();
1349 let outs = brain.outlinks("people/alice.md").unwrap();
1350 let ids: Vec<&str> = outs.iter().map(|e| e.id.as_str()).collect();
1351 assert!(ids.contains(&"people/bob.md"), "expected stem-matched bob in {ids:?}");
1352 assert!(ids.contains(&"projects/athene.md"), "expected id-matched athene in {ids:?}");
1353 assert_eq!(outs.len(), 2, "duplicate [[bob]] mentions should be deduped: {ids:?}");
1354 }
1355
1356 #[test]
1357 fn backlinks_resolves_incoming_links() {
1358 let (brain, _dir) = make_linked_brain();
1359 let backs = brain.backlinks("people/bob.md").unwrap();
1360 let ids: Vec<&str> = backs.iter().map(|e| e.id.as_str()).collect();
1361 assert!(ids.contains(&"people/alice.md"));
1362 assert!(ids.contains(&"people/carol.md"));
1363 assert_eq!(ids.len(), 2);
1364
1365 let alice_backs = brain.backlinks("people/alice.md").unwrap();
1366 assert_eq!(alice_backs.len(), 1);
1367 assert_eq!(alice_backs[0].id, "people/bob.md");
1368 }
1369
1370 #[test]
1371 fn backlinks_and_outlinks_empty_for_unknown_or_unlinked() {
1372 let (brain, _dir) = make_linked_brain();
1373 assert!(brain.backlinks("nowhere.md").unwrap().is_empty());
1374 assert!(brain.outlinks("people/dave.md").unwrap().is_empty());
1375 }
1376
1377 #[test]
1378 fn links_all_returns_resolved_edges() {
1379 let (brain, _dir) = make_linked_brain();
1380 let edges = brain.links_all().unwrap();
1381 assert!(edges.contains(&("people/alice.md".to_string(), "people/bob.md".to_string())));
1382 assert!(edges.contains(&(
1383 "people/alice.md".to_string(),
1384 "projects/athene.md".to_string()
1385 )));
1386 assert!(edges.contains(&("people/bob.md".to_string(), "people/alice.md".to_string())));
1387 assert!(edges.contains(&("people/carol.md".to_string(), "people/bob.md".to_string())));
1388 assert_eq!(edges.len(), 4, "edges should be deduped: {edges:?}");
1389 }
1390
1391 #[test]
1392 fn related_ranks_direct_links_then_co_citation_then_tags() {
1393 let (brain, _dir) = make_linked_brain();
1394 let related = brain.related("people/alice.md", 10).unwrap();
1395 let ids: Vec<&str> = related.iter().map(|e| e.id.as_str()).collect();
1396
1397 assert!(!ids.contains(&"people/alice.md"));
1399
1400 let pos = |id: &str| ids.iter().position(|x| *x == id);
1401 let bob = pos("people/bob.md").expect("bob is a direct link");
1402 let athene = pos("projects/athene.md").expect("athene is a direct link");
1403 let carol = pos("people/carol.md").expect("carol co-cites bob with alice");
1404 let dave = pos("people/dave.md").expect("dave shares the infra tag");
1405
1406 assert!(bob < carol && athene < carol, "direct links should outrank co-citation");
1409 assert!(carol < dave, "co-citation should outrank shared-tag-only");
1410 }
1411
1412 #[test]
1413 fn related_respects_limit() {
1414 let (brain, _dir) = make_linked_brain();
1415 let related = brain.related("people/alice.md", 1).unwrap();
1416 assert_eq!(related.len(), 1);
1417 }
1418
1419 #[test]
1420 fn related_unknown_id_is_empty() {
1421 let (brain, _dir) = make_linked_brain();
1422 assert!(brain.related("nowhere.md", 10).unwrap().is_empty());
1423 }
1424
1425 fn generate_synthetic_vault(root: &Path, n: usize) {
1433 let folders = ["people", "projects", "notes", "meetings", "areas"];
1434 let filler = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. \
1436 Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n";
1437
1438 for i in 0..n {
1439 let folder = folders[i % folders.len()];
1440 let sub = i % 20; let dir = root.join(folder).join(format!("sub{sub}"));
1442 fs::create_dir_all(&dir).unwrap();
1443
1444 let mut body = String::new();
1445 for _ in 0..30 {
1446 body.push_str(filler);
1447 }
1448 for k in 0..8 {
1450 let target_i = (i + k * 37 + 1) % n;
1451 let target_folder = folders[target_i % folders.len()];
1452 body.push_str(&format!("See [[{target_folder}/note{target_i}]].\n"));
1453 }
1454
1455 let content = format!(
1456 "---\nname: Note {i}\ntags:\n- tag{}\n- shared\nupdated: 2026-01-01\n---\n{body}",
1457 i % 50
1458 );
1459 fs::write(dir.join(format!("note{i}.md")), content).unwrap();
1460 }
1461 }
1462
1463 #[test]
1464 fn rebuild_scales_to_500_files_within_ceiling() {
1465 let dir = tempdir().unwrap();
1466 generate_synthetic_vault(dir.path(), 500);
1467 let brain = BrainIndex::open(dir.path()).unwrap();
1468
1469 let start = std::time::Instant::now();
1470 let stats = brain.rebuild(None).unwrap();
1471 let elapsed = start.elapsed();
1472
1473 assert_eq!(stats.indexed, 500);
1474 println!("rebuild of 500 files took {elapsed:?}");
1475 assert!(elapsed.as_secs() < 10, "rebuild of 500 files took too long: {elapsed:?}");
1479 }
1480
1481 #[test]
1482 #[ignore = "benchmark: run explicitly with `cargo test -p ninox-core --release -- --ignored rebuild_scales_to_5000_files -- --nocapture`"]
1483 fn rebuild_scales_to_5000_files() {
1484 let dir = tempdir().unwrap();
1485 generate_synthetic_vault(dir.path(), 5_000);
1486 let brain = BrainIndex::open(dir.path()).unwrap();
1487
1488 let start = std::time::Instant::now();
1489 let stats = brain.rebuild(None).unwrap();
1490 let elapsed = start.elapsed();
1491
1492 assert_eq!(stats.indexed, 5_000);
1493 println!("rebuild of 5,000 files took {elapsed:?}");
1494 }
1495}