Skip to main content

ninox_core/
brain.rs

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// ---------------------------------------------------------------------------
15// Public types
16// ---------------------------------------------------------------------------
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct BrainEntry {
20    pub id: String,         // relative path from brain root (e.g. "people/alice.md")
21    pub entry_type: String, // derived from parent directory name
22    pub name: String,       // from frontmatter or filename stem
23    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/// Result of a `BrainIndex::rebuild()` call.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct RebuildStats {
38    /// Entries (files) indexed this run.
39    pub indexed: usize,
40    /// Entries newly embedded this run (content changed or never embedded).
41    pub embedded: usize,
42    /// Entries whose cached embedding was reused because content is unchanged.
43    pub cached: usize,
44}
45
46// ---------------------------------------------------------------------------
47// BrainIndex
48// ---------------------------------------------------------------------------
49
50pub 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        // Expose `stem(x)` to SQL so link-resolution queries can share the
86        // same "stem(target) == stem(id)" rule used in Rust, without
87        // round-tripping candidate sets through the application layer.
88        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    /// Walk the brain directory, parse markdown files, and repopulate the
102    /// index, then embed any new or changed entries (skipped entirely if
103    /// `embedder` is `None`, or if embedding a given entry fails — indexing
104    /// is never blocked by the embedding step).
105    pub fn rebuild(&self, embedder: Option<&dyn Embedder>) -> Result<RebuildStats> {
106        // Cheap sequential walk to enumerate candidate files.
107        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        // Read + parse across a thread pool. Pure function, no shared state.
116        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        // Rebuild the FTS index from the content table.
154        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    /// Compute-or-reuse an embedding for every current record, then prune
165    /// embeddings for entries that no longer exist. Returns
166    /// `(newly_embedded, reused_from_cache)`. A failure embedding any single
167    /// batch is logged and treated as "not embedded" rather than
168    /// propagated — a broken model must never break `rebuild()`.
169    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        // Prune embeddings for entries that no longer exist.
221        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    /// Hybrid full-text + semantic search. When `text` is non-empty and
244    /// `embedder` is `Some`, blends the existing FTS5 keyword ranking with
245    /// vector similarity via Reciprocal Rank Fusion. Falls back to exactly
246    /// today's keyword-only (or filter-only) behavior when `text` is empty
247    /// or `embedder` is `None`.
248    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            // Return all entries when no constraints given
258            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            // Filter-only query
269            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        // Keyword leg: existing FTS5 ranking, as an ordered id list.
284        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        // Semantic leg: top-20 by cosine similarity, if an embedder is available.
296        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        // Post-filter by type and tag.
320        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        // Cap only applies when semantic fusion actually happened — the
328        // no-embedder / no-semantic-contribution path stays exactly
329        // today's uncapped keyword behavior, matching the design spec's
330        // degradation guarantee.
331        if fusion_ran {
332            results.truncate(20);
333        }
334        Ok(results)
335    }
336
337    /// Top-20 entry ids by cosine similarity to `text`, embedded with the
338    /// query-instruction prefix Arctic Embed expects for asymmetric
339    /// retrieval. Returns an empty list (never an error) if embedding the
340    /// query text fails — a broken embedder degrades to keyword-only
341    /// results, it never fails the whole query.
342    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    /// Fetch a single entry by its relative path id.
372    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    /// Entries whose links resolve to `id` (i.e. entries that link *to* `id`).
378    ///
379    /// Resolution rule (matches the app's render-time wikilink resolution):
380    /// a raw link target `t` resolves to entry `e` when
381    /// `t == e.name OR t == e.id OR stem(t) == stem(e.id) OR stem(t) == e.name`.
382    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    /// Resolved targets of `id`'s own links (i.e. entries that `id` links to).
400    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    /// The full resolved (from_id, to_id) edge list, for pinboard rendering.
419    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    /// Entries related to `id`, ranked: direct links (either direction) first,
438    /// then co-citation (entries reachable via a shared linked target or a
439    /// shared linking source), then entries sharing at least one tag.
440    /// Deduplicated, excludes `id` itself, capped at `limit`.
441    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        // Tier 1: direct links, either direction.
452        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        // Tier 2: co-citation -- entries that share an outbound target with
458        // `id` (found via the backlinks of each of `id`'s targets), plus
459        // entries reachable from the same sources that link to `id` (found
460        // via the outlinks of each of `id`'s linking sources).
461        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        // Tier 3: shared-tag overlap.
471        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
494/// Merge `items` into `ranked`, skipping anything already present in `seen`.
495fn 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
503// ---------------------------------------------------------------------------
504// Helpers
505// ---------------------------------------------------------------------------
506
507/// Fetch a single entry by id against an already-locked connection.
508fn 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
518/// The file-stem of a path-like string: the last `/`-separated segment with
519/// its trailing extension stripped. Used both from Rust and registered as a
520/// SQL scalar function (`stem(x)`) so link-resolution queries can apply the
521/// same rule the app uses when it needs to compare raw wikilink targets.
522fn 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
530/// Extract raw wikilink targets from a markdown body, Obsidian-style.
531///
532/// Handles `[[target]]`, `[[target|alias]]` (target `target`),
533/// `[[target#heading]]` / `[[target#^block]]` (target `target`), and
534/// `[[target#heading|alias]]` (target `target`). Embeds (`![[x]]`) are
535/// skipped. Targets are returned raw, exactly as written -- resolving them
536/// to entry ids happens at query time via [`BrainIndex::backlinks`] /
537/// [`BrainIndex::outlinks`] / [`BrainIndex::links_all`].
538fn 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
564/// Everything derived from a single markdown file, computed off the main
565/// thread during [`BrainIndex::rebuild`]. Pure data -- no connection, no I/O
566/// side effects beyond the initial read -- so it's safely `Send`.
567struct 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
578/// Read and parse a single candidate file into a [`FileRecord`]. Pure
579/// function of its inputs (aside from the filesystem read), safe to call
580/// from any thread in the rebuild worker pool.
581fn 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    // Derive entry_type from parent dir name (or frontmatter "type" field)
592    let parent_type = path
593        .parent()
594        .and_then(|p| {
595            // If the parent IS brain_path itself, there's no meaningful type dir
596            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
642/// Turn free-form user search text into an FTS5 query string that can't be
643/// misinterpreted as query syntax. Raw text handed straight to `MATCH`
644/// exposes FTS5's operators (`-` for NOT, `:` for column filters, unmatched
645/// `"` for phrases, etc.), so e.g. searching for `ninox-server` or `foo:bar`
646/// throws a SQL error instead of finding matches. Wrapping each
647/// whitespace-separated token in `"..."` (doubling internal quotes) forces
648/// every token to be matched literally.
649fn sanitize_fts_query(text: &str) -> String {
650    text.split_whitespace()
651        .map(|tok| format!("\"{}\"", tok.replace('"', "\"\"")))
652        .collect::<Vec<_>>()
653        .join(" ")
654}
655
656/// Text an entry is embedded from: name plus body, truncated to a bounded
657/// length. Small embedding models have a limited token context; truncating
658/// to a character prefix is a pragmatic approximation, not a guarantee the
659/// whole document is represented (see the design spec's non-goals — full
660/// chunking is out of scope).
661fn embedding_text(rec: &FileRecord) -> String {
662    let mut text = format!("{}\n\n{}", rec.name, rec.body);
663    text.truncate(2000);
664    text
665}
666
667/// Non-cryptographic hash used purely to detect content changes between
668/// `rebuild()` calls. Not guaranteed stable across Rust/std versions — the
669/// worst case of that is one redundant re-embed after an upgrade, never a
670/// correctness problem, so no extra hashing dependency is justified.
671fn 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
704/// Ensure `.index.db` is in the brain directory's `.gitignore`.
705fn 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
719// ---------------------------------------------------------------------------
720// Frontmatter parsing (manual YAML split, no extra dep)
721// ---------------------------------------------------------------------------
722
723struct 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..]; // skip "\n---"
772            // skip optional trailing newline
773            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                // Possibly a sequence starting on the next lines
794                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                // Inline sequence: [a, b, c]
809                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// ---------------------------------------------------------------------------
828// Tests
829// ---------------------------------------------------------------------------
830
831#[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    /// Deterministic, call-counting fake — never touches the network or a
845    /// real model. The vector is derived from the text's length so distinct
846    /// inputs get distinct (but stable) vectors, which is enough for cache
847    /// and fusion tests without needing real semantic meaning.
848    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        // Second rebuild, same content: must not re-embed.
901        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    /// An embedder whose calls always fail — proves indexing is never
960    /// blocked by embedding failures.
961    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        // Verify the gitignore was created
991        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        // A query against the table succeeding at all (vs. an SQL error)
1002        // proves the table exists with these columns.
1003        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    /// FTS5 treats `-`, `:`, and unmatched `"` as query-syntax operators.
1053    /// Raw user search text containing them (e.g. a hyphenated crate name)
1054    /// must not surface as a SQL error.
1055    #[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(&notes).unwrap();
1103        // Deliberately no shared words with the query text below.
1104        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        // A fake embedder that makes "auth failures" and "401 debugging
1108        // notes" cosine-similar (same first dimension marker) while
1109        // "Coffee notes" is dissimilar — enough to prove the fusion path
1110        // runs end-to-end without needing the real model.
1111        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        // No shared vocabulary with either file's title/body at all, so the
1132        // keyword leg alone would return nothing for this query.
1133        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        // The design applies no similarity threshold (see the spec's
1139        // "Query flow" section) — a dissimilar entry may still appear, it
1140        // must just rank below the related one if both are present.
1141        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(&notes).unwrap();
1155        fs::write(notes.join("rust.md"), "---\nname: Rust Tips\n---\nUse anyhow for error handling.").unwrap();
1156
1157        // Indexed without embeddings (embedder is only used at query time
1158        // here), then queried with an embedder whose every call fails.
1159        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(&notes).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    /// Write `n` markdown files, each containing the shared keyword
1202    /// "widgetronic" so a keyword search for it matches all of them.
1203    fn write_many_matching_notes(dir: &Path, n: usize) {
1204        let notes = dir.join("notes");
1205        fs::create_dir_all(&notes).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        // FakeEmbedder succeeds for every input, so all 25 entries get
1236        // embeddings and `semantic_candidates` always returns a non-empty
1237        // (top-20) list — enough to force the fused path to actually run.
1238        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    // -----------------------------------------------------------------
1250    // Wikilink extraction
1251    // -----------------------------------------------------------------
1252
1253    #[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    // -----------------------------------------------------------------
1298    // Link-graph fixture for backlinks / outlinks / links_all / related
1299    // -----------------------------------------------------------------
1300
1301    /// Builds a small vault with a known link graph:
1302    ///   alice --[[bob]]--> bob            (stem match: "bob" == stem("people/bob.md"))
1303    ///   alice --[[projects/athene.md|Athene]]--> athene   (exact id match)
1304    ///   bob   --[[alice]]--> alice        (stem match)
1305    ///   carol --[[bob]]--> bob            (stem match; co-cites bob with alice)
1306    ///   dave: no links, shares the "infra" tag with alice only.
1307    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        // Never includes self.
1398        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        // Tier 1 (direct links) ranks above tier 2 (co-citation) which ranks
1407        // above tier 3 (shared tag only).
1408        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    // -----------------------------------------------------------------
1426    // Scale proof
1427    // -----------------------------------------------------------------
1428
1429    /// Synthesizes a vault with `n` markdown files spread across nested
1430    /// folders, each with realistic-ish frontmatter, body size, and a
1431    /// handful of wikilinks to other generated files.
1432    fn generate_synthetic_vault(root: &Path, n: usize) {
1433        let folders = ["people", "projects", "notes", "meetings", "areas"];
1434        // Pad body text out to roughly 2-4KB per file.
1435        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; // nest further to exercise deeper directory walks
1441            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            // ~8 wikilinks to other files in the vault.
1449            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        // Generous ceiling: this is here to catch a catastrophic regression
1476        // (e.g. an accidental fsync-per-file reintroduction), not to pin
1477        // down exact performance.
1478        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}