Skip to main content

rustbrain_core/
indexer.rs

1//! Workspace walker: Markdown notes, optional Canvas, optional Rust AST.
2//!
3//! [`WorkspaceIndexer`] is the engine behind [`crate::Brain::sync`]. It owns a
4//! [`Database`], walks the workspace (skipping `target/`, `.git/`, etc.), and:
5//!
6//! 1. Upserts Markdown nodes transactionally (FTS + tags + aliases + edges)
7//! 2. Indexes Obsidian Canvas relationships when the `obsidian` feature is on
8//! 3. Extracts Rust symbols when the `ast` feature is on
9//! 4. Resolves pending WikiLink / `symbol:` targets
10//! 5. Compiles `.brain/graph.mmap` when the `mmap` feature is on
11
12use crate::error::{BrainError, Result};
13use crate::id::{content_hash, node_id_from_rel_path, rel_path_from_workspace, resolve_link_target};
14use crate::ignore::IgnoreSet;
15use crate::storage::Database;
16use crate::types::{Edge, Node, NodeType, SyncStats};
17use chrono::Utc;
18use std::path::{Path, PathBuf};
19
20/// Indexes a workspace directory into a [`Database`].
21pub struct WorkspaceIndexer {
22    db: Database,
23    workspace: PathBuf,
24    ignore: IgnoreSet,
25}
26
27impl WorkspaceIndexer {
28    /// Create an indexer for `workspace` writing into `db`.
29    ///
30    /// Loads `.rustbrainignore` (if present) plus built-in skips. When the env
31    /// var `RUSTBRAIN_IMPORT_GITIGNORE=1` is set, also merges root `.gitignore`.
32    pub fn new(db: Database, workspace: impl Into<PathBuf>) -> Self {
33        let workspace = workspace.into();
34        let import_gi = std::env::var_os("RUSTBRAIN_IMPORT_GITIGNORE")
35            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
36            .unwrap_or(false);
37        // Also auto-import gitignore when .rustbrainignore asks for it.
38        let import_gi = import_gi || rustbrainignore_requests_gitignore(&workspace);
39        let ignore = IgnoreSet::load(&workspace, import_gi).unwrap_or_default();
40        Self {
41            db,
42            workspace,
43            ignore,
44        }
45    }
46
47    /// Borrow the database.
48    pub fn database(&self) -> &Database {
49        &self.db
50    }
51
52    /// Consume the indexer and return the database.
53    pub fn into_database(self) -> Database {
54        self.db
55    }
56
57    /// Index all supported files under the workspace and bake the mmap cache.
58    pub fn index_workspace(&self) -> Result<SyncStats> {
59        let mut stats = SyncStats::default();
60
61        #[cfg(feature = "ast")]
62        let mut ast_parser = crate::ast::CodeAstParser::new_rust()
63            .map_err(|e| BrainError::Ast(e.to_string()))?;
64
65        self.walk_and_index(
66            &self.workspace,
67            &mut stats,
68            #[cfg(feature = "ast")]
69            &mut ast_parser,
70        )?;
71
72        // Phase 2: resolve any pending WikiLinks now that all nodes exist.
73        let (resolved, still) = self.db.resolve_pending_links()?;
74        stats.edges_created += resolved;
75        stats.edges_pending = still;
76
77        // Bake CSR mmap if feature enabled and .brain dir exists.
78        let brain_dir = self.workspace.join(".brain");
79        if brain_dir.exists() {
80            #[cfg(feature = "mmap")]
81            {
82                let mmap_path = brain_dir.join("graph.mmap");
83                self.compile_mmap(&mmap_path)?;
84                stats.mmap_written = true;
85            }
86        }
87
88        Ok(stats)
89    }
90
91    /// Bake current database into zero-copy CSR mmap (atomic replace).
92    pub fn compile_mmap(&self, output_path: &Path) -> Result<()> {
93        #[cfg(feature = "mmap")]
94        {
95            let node_ids = self.db.get_all_node_ids()?;
96            let edges = self.db.get_csr_edges()?;
97            // Phase A: no embeddings yet — vector_dim = 0.
98            crate::mmap::CsrCompiler::compile(output_path, &node_ids, &edges, None, 0)?;
99            Ok(())
100        }
101        #[cfg(not(feature = "mmap"))]
102        {
103            let _ = output_path;
104            Err(BrainError::FeatureDisabled("mmap"))
105        }
106    }
107
108    /// Index a single Markdown note (transactional, content-hash aware).
109    pub fn index_markdown_file(&self, file_path: &Path, stats: &mut SyncStats) -> Result<()> {
110        if !file_path.exists() || file_path.extension().and_then(|e| e.to_str()) != Some("md") {
111            return Ok(());
112        }
113
114        let raw_bytes = std::fs::read(file_path)?;
115        let hash = content_hash(&raw_bytes);
116        let content = String::from_utf8_lossy(&raw_bytes);
117
118        let rel = rel_path_from_workspace(&self.workspace, file_path);
119        let rel_str = rel.to_string_lossy().replace('\\', "/");
120        if self.ignore.is_ignored(&rel_str, false) {
121            return Ok(());
122        }
123
124        let is_readme_hub = is_root_readme(&rel);
125        let node_id = if is_readme_hub {
126            "readme".to_string()
127        } else {
128            node_id_from_rel_path(&rel)
129        };
130
131        if let Some(existing) = self.db.get_content_hash(&node_id)? {
132            if existing == hash {
133                stats.nodes_skipped_unchanged += 1;
134                return Ok(());
135            }
136        }
137
138        #[cfg(feature = "obsidian")]
139        let (frontmatter, body) = crate::obsidian::parse_frontmatter(&content);
140        #[cfg(not(feature = "obsidian"))]
141        let (frontmatter, body): (Option<()>, &str) = (None, content.as_ref());
142
143        let title = resolve_title(
144            #[cfg(feature = "obsidian")]
145            frontmatter.as_ref(),
146            #[cfg(not(feature = "obsidian"))]
147            None,
148            body,
149            &rel,
150        );
151
152        let node_type = {
153            #[cfg(feature = "obsidian")]
154            {
155                let parsed = frontmatter
156                    .as_ref()
157                    .and_then(|fm| fm.node_type.as_deref())
158                    .and_then(NodeType::parse);
159                if is_readme_hub {
160                    // Root README is the project hub; default to Goal unless author overrides.
161                    parsed.unwrap_or(NodeType::Goal)
162                } else {
163                    parsed.unwrap_or(NodeType::Concept)
164                }
165            }
166            #[cfg(not(feature = "obsidian"))]
167            {
168                if is_readme_hub {
169                    NodeType::Goal
170                } else {
171                    NodeType::Concept
172                }
173            }
174        };
175
176        let now = Utc::now().timestamp();
177        let summary = first_substantive_line(body);
178
179        let tags: Vec<String> = {
180            #[cfg(feature = "obsidian")]
181            {
182                frontmatter
183                    .as_ref()
184                    .map(|fm| fm.tags.clone())
185                    .unwrap_or_default()
186            }
187            #[cfg(not(feature = "obsidian"))]
188            {
189                Vec::new()
190            }
191        };
192
193        let aliases: Vec<String> = {
194            #[cfg(feature = "obsidian")]
195            {
196                frontmatter
197                    .as_ref()
198                    .map(|fm| fm.aliases.clone())
199                    .unwrap_or_default()
200            }
201            #[cfg(not(feature = "obsidian"))]
202            {
203                Vec::new()
204            }
205        };
206
207        // Collect wikilinks before the transaction writes.
208        #[cfg(feature = "obsidian")]
209        let wikilinks = crate::obsidian::extract_wikilinks(body);
210        #[cfg(not(feature = "obsidian"))]
211        let wikilinks: Vec<RawLink> = Vec::new();
212
213        // Architecture plan: symbol:crate::module::Name anchors in notes.
214        let symbol_refs = crate::symbols::extract_symbol_refs(body);
215
216        let node = Node {
217            id: node_id.clone(),
218            node_type,
219            title: title.clone(),
220            file_path: Some(rel_str),
221            symbol_hash: None,
222            summary: Some(summary),
223            content_hash: Some(hash),
224            created_at: now,
225            updated_at: now,
226        };
227
228        let tags_str = tags.join(" ");
229        let mut links_for_tx: Vec<(String, String)> = {
230            #[cfg(feature = "obsidian")]
231            {
232                wikilinks
233                    .iter()
234                    .map(|l| {
235                        // WikiLinks of form [[symbol:…]] become anchors, not relates_to.
236                        if let Some(rest) = l.target_node.strip_prefix("symbol:") {
237                            (format!("symbol:{rest}"), "anchors".to_string())
238                        } else {
239                            (l.target_node.clone(), "relates_to".to_string())
240                        }
241                    })
242                    .collect()
243            }
244            #[cfg(not(feature = "obsidian"))]
245            {
246                let _ = &wikilinks;
247                Vec::new()
248            }
249        };
250        for sref in &symbol_refs {
251            links_for_tx.push((format!("symbol:{}", sref.raw), "anchors".to_string()));
252        }
253
254        // Also register file stem and title as aliases for link resolution.
255        let mut extra_aliases = aliases.clone();
256        if let Some(stem) = Path::new(&node.file_path.as_deref().unwrap_or(""))
257            .file_stem()
258            .and_then(|s| s.to_str())
259        {
260            extra_aliases.push(stem.to_string());
261        }
262        extra_aliases.push(title.clone());
263        if is_readme_hub {
264            extra_aliases.push("readme".into());
265            extra_aliases.push("hub".into());
266            extra_aliases.push("home".into());
267            if let Some(name) = self.workspace.file_name().and_then(|n| n.to_str()) {
268                extra_aliases.push(name.to_string());
269            }
270        }
271
272        self.db.with_transaction(|conn| {
273            self.db.insert_node_on(conn, &node)?;
274            self.db.replace_node_tags_on(conn, &node_id, &tags)?;
275            self.db
276                .replace_node_aliases_on(conn, &node_id, &extra_aliases)?;
277            self.db
278                .index_fts_on(conn, &node_id, &title, body, &tags_str)?;
279
280            // Clear prior outbound edges + pending for this source (idempotent).
281            self.db
282                .clear_edges_from_on(conn, &node_id, "relates_to")?;
283            self.db.clear_edges_from_on(conn, &node_id, "anchors")?;
284            self.db.clear_pending_links_for_on(conn, &node_id)?;
285
286            for (raw_target, rel_type) in &links_for_tx {
287                let resolved = if let Some(sym_path) = raw_target.strip_prefix("symbol:") {
288                    resolve_symbol_against_conn(conn, sym_path)?
289                } else {
290                    resolve_against_conn(conn, raw_target)?
291                };
292
293                if let Some(target_id) = resolved {
294                    let edge = Edge {
295                        source_id: node_id.clone(),
296                        target_id,
297                        relation_type: rel_type.clone(),
298                        weight: 1.0,
299                        decay_rate: 0.0,
300                        created_at: now,
301                    };
302                    self.db.insert_edge_on(conn, &edge)?;
303                    stats.edges_created += 1;
304                } else {
305                    self.db.insert_pending_link_on(
306                        conn,
307                        &node_id,
308                        raw_target,
309                        rel_type,
310                        now,
311                    )?;
312                    stats.edges_pending += 1;
313                }
314            }
315            Ok(())
316        })?;
317
318        stats.markdown_files += 1;
319        stats.nodes_upserted += 1;
320        Ok(())
321    }
322
323    /// Index an Obsidian Canvas (`.canvas`) file into graph edges.
324    ///
325    /// Resolves file/text node labels to existing note ids when possible;
326    /// otherwise records pending links. Requires the `obsidian` feature.
327    #[cfg(feature = "obsidian")]
328    pub fn index_canvas_file(&self, file_path: &Path, stats: &mut SyncStats) -> Result<()> {
329        if !file_path.exists() || file_path.extension().and_then(|e| e.to_str()) != Some("canvas")
330        {
331            return Ok(());
332        }
333
334        let content = std::fs::read_to_string(file_path)?;
335        let canvas = crate::obsidian::ObsidianCanvas::parse_str(&content)
336            .map_err(|e| BrainError::indexer(e.to_string()))?;
337        let now = Utc::now().timestamp();
338        let relationships = canvas.extract_relationships();
339
340        let (ids, aliases, titles) = self.db.link_resolution_maps()?;
341
342        self.db.with_transaction(|conn| {
343            for (src_raw, dst_raw, rel) in &relationships {
344                let src = resolve_link_target(src_raw, &ids, &aliases, &titles);
345                let dst = resolve_link_target(dst_raw, &ids, &aliases, &titles);
346                match (src, dst) {
347                    (Some(s), Some(d)) => {
348                        let edge = Edge {
349                            source_id: s,
350                            target_id: d,
351                            relation_type: rel.clone(),
352                            weight: 1.0,
353                            decay_rate: 0.0,
354                            created_at: now,
355                        };
356                        self.db.insert_edge_on(conn, &edge)?;
357                        stats.edges_created += 1;
358                    }
359                    (Some(s), None) => {
360                        self.db
361                            .insert_pending_link_on(conn, &s, dst_raw, rel, now)?;
362                        stats.edges_pending += 1;
363                    }
364                    _ => {
365                        // Source unknown — skip with pending if we can map nothing.
366                        stats.edges_pending += 1;
367                    }
368                }
369            }
370            Ok(())
371        })?;
372
373        stats.canvas_files += 1;
374        Ok(())
375    }
376
377    fn walk_and_index(
378        &self,
379        dir: &Path,
380        stats: &mut SyncStats,
381        #[cfg(feature = "ast")] ast_parser: &mut crate::ast::CodeAstParser,
382    ) -> Result<()> {
383        if !dir.exists() {
384            return Ok(());
385        }
386
387        let entries = std::fs::read_dir(dir)?;
388        for entry in entries {
389            let entry = entry?;
390            let path = entry.path();
391
392            if path.is_dir() {
393                if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
394                    if self.ignore.skip_dir_name(name) {
395                        continue;
396                    }
397                }
398                let rel = rel_path_from_workspace(&self.workspace, &path);
399                let rel_str = rel.to_string_lossy().replace('\\', "/");
400                if self.ignore.is_ignored(&rel_str, true) {
401                    continue;
402                }
403                self.walk_and_index(
404                    &path,
405                    stats,
406                    #[cfg(feature = "ast")]
407                    ast_parser,
408                )?;
409            } else {
410                let rel = rel_path_from_workspace(&self.workspace, &path);
411                let rel_str = rel.to_string_lossy().replace('\\', "/");
412                if self.ignore.is_ignored(&rel_str, false) {
413                    continue;
414                }
415                let ext = path.extension().and_then(|e| e.to_str());
416                let result = match ext {
417                    Some("md") => self.index_markdown_file(&path, stats),
418                    #[cfg(feature = "obsidian")]
419                    Some("canvas") => self.index_canvas_file(&path, stats),
420                    #[cfg(feature = "ast")]
421                    Some("rs") => self.index_rust_file(&path, ast_parser, stats),
422                    _ => Ok(()),
423                };
424                if let Err(e) = result {
425                    // Never abort a full-workspace sync for one bad file.
426                    stats.file_errors += 1;
427                    eprintln!(
428                        "rustbrain: skip {} ({e})",
429                        path.display()
430                    );
431                }
432            }
433        }
434        Ok(())
435    }
436
437    #[cfg(feature = "ast")]
438    fn index_rust_file(
439        &self,
440        path: &Path,
441        ast_parser: &mut crate::ast::CodeAstParser,
442        stats: &mut SyncStats,
443    ) -> Result<()> {
444        let rel = rel_path_from_workspace(&self.workspace, path);
445        let rel_str = rel.to_string_lossy().replace('\\', "/");
446        if self.ignore.is_ignored(&rel_str, false) {
447            return Ok(());
448        }
449        let crate_name = infer_crate_name(&self.workspace, path);
450        let source = std::fs::read_to_string(path)?;
451        let anchors = ast_parser
452            .parse_symbols(&crate_name, &rel_str, &source)
453            .map_err(|e| BrainError::Ast(e.to_string()))?;
454
455        for anchor in &anchors {
456            self.db.insert_symbol_anchor(anchor)?;
457
458            let node_id = crate::symbols::symbol_node_id(
459                &anchor.crate_name,
460                &anchor.module_path,
461                &anchor.symbol_name,
462            );
463            // Content hash from signature so unchanged symbols are skipped next sync.
464            let sig = format!(
465                "{}::{}::{}@{}-{}",
466                anchor.crate_name,
467                anchor.module_path,
468                anchor.symbol_name,
469                anchor.start_line,
470                anchor.end_line
471            );
472            let chash = crate::id::content_hash(sig.as_bytes());
473            if let Some(existing) = self.db.get_content_hash(&node_id)? {
474                if existing == chash {
475                    stats.nodes_skipped_unchanged += 1;
476                    stats.symbol_anchors += 1;
477                    continue;
478                }
479            }
480
481            let now = Utc::now().timestamp();
482            let node = Node {
483                id: node_id.clone(),
484                node_type: NodeType::Symbol,
485                title: anchor.symbol_name.clone(),
486                file_path: Some(anchor.file_path.clone()),
487                symbol_hash: Some(anchor.symbol_hash),
488                summary: anchor.doc_comment.clone(),
489                content_hash: Some(chash),
490                created_at: now,
491                updated_at: now,
492            };
493            self.db.insert_node(&node)?;
494
495            // Aliases for resolution: bare name, Type::method, full path.
496            let mut aliases = vec![anchor.symbol_name.clone()];
497            if let Some((_, method)) = anchor.symbol_name.split_once("::") {
498                aliases.push(method.to_string());
499            }
500            aliases.push(format!(
501                "{}::{}::{}",
502                anchor.crate_name, anchor.module_path, anchor.symbol_name
503            ));
504            self.db.replace_node_aliases(&node_id, &aliases)?;
505
506            let fts_body = format!(
507                "{} {} {} {}",
508                anchor.symbol_name,
509                anchor.module_path,
510                anchor.crate_name,
511                anchor.doc_comment.as_deref().unwrap_or("")
512            );
513            self.db
514                .index_fts(&node_id, &anchor.symbol_name, &fts_body, "symbol")?;
515
516            stats.nodes_upserted += 1;
517            stats.symbol_anchors += 1;
518        }
519        stats.rust_files += 1;
520        Ok(())
521    }
522}
523
524fn is_root_readme(rel: &Path) -> bool {
525    let comps: Vec<_> = rel
526        .components()
527        .filter(|c| matches!(c, std::path::Component::Normal(_)))
528        .collect();
529    if comps.len() != 1 {
530        return false;
531    }
532    rel.file_name()
533        .and_then(|n| n.to_str())
534        .map(|n| n.eq_ignore_ascii_case("README.md"))
535        .unwrap_or(false)
536}
537
538fn rustbrainignore_requests_gitignore(workspace: &Path) -> bool {
539    let path = workspace.join(".rustbrainignore");
540    let Ok(text) = std::fs::read_to_string(path) else {
541        return false;
542    };
543    text.lines().any(|l| {
544        let t = l.trim().to_ascii_lowercase();
545        t == "# rustbrain: import-gitignore"
546            || t == "#!import-gitignore"
547            || t.contains("rustbrain: import-gitignore")
548    })
549}
550
551fn first_substantive_line(body: &str) -> String {
552    for line in body.lines() {
553        let t = line.trim();
554        if t.is_empty() {
555            continue;
556        }
557        // Skip pure heading markers only lines handled below
558        let cleaned = t.trim_start_matches('#').trim();
559        if !cleaned.is_empty() {
560            return cleaned.to_string();
561        }
562    }
563    String::new()
564}
565
566fn resolve_title(
567    #[cfg(feature = "obsidian")] frontmatter: Option<&crate::obsidian::Frontmatter>,
568    #[cfg(not(feature = "obsidian"))] frontmatter: Option<&()>,
569    body: &str,
570    rel: &Path,
571) -> String {
572    #[cfg(feature = "obsidian")]
573    if let Some(fm) = frontmatter {
574        if let Some(title) = fm.extra.get("title").and_then(|v| v.as_str()) {
575            let t = title.trim();
576            if !t.is_empty() {
577                return t.to_string();
578            }
579        }
580    }
581    #[cfg(not(feature = "obsidian"))]
582    let _ = frontmatter;
583
584    // First H1
585    for line in body.lines() {
586        let t = line.trim();
587        if let Some(rest) = t.strip_prefix("# ") {
588            let t = rest.trim();
589            if !t.is_empty() {
590                return t.to_string();
591            }
592        }
593    }
594
595    rel.file_stem()
596        .and_then(|s| s.to_str())
597        .unwrap_or("Untitled")
598        .to_string()
599}
600
601#[cfg(feature = "ast")]
602fn infer_crate_name(workspace: &Path, file: &Path) -> String {
603    // Walk up looking for Cargo.toml
604    let mut cur = file.parent();
605    while let Some(dir) = cur {
606        let cargo = dir.join("Cargo.toml");
607        if cargo.exists() {
608            if let Ok(text) = std::fs::read_to_string(&cargo) {
609                if let Some(name) = parse_cargo_package_name(&text) {
610                    return name;
611                }
612            }
613            if let Some(n) = dir.file_name().and_then(|n| n.to_str()) {
614                return n.to_string();
615            }
616        }
617        if dir == workspace {
618            break;
619        }
620        cur = dir.parent();
621    }
622    workspace
623        .file_name()
624        .and_then(|n| n.to_str())
625        .unwrap_or("workspace")
626        .to_string()
627}
628
629#[cfg(feature = "ast")]
630fn parse_cargo_package_name(toml: &str) -> Option<String> {
631    let mut in_package = false;
632    for line in toml.lines() {
633        let t = line.trim();
634        if t.starts_with('[') {
635            in_package = t == "[package]";
636            continue;
637        }
638        if in_package {
639            if let Some(rest) = t.strip_prefix("name") {
640                let rest = rest.trim().trim_start_matches('=').trim();
641                let name = rest.trim_matches('"').trim_matches('\'').to_string();
642                if !name.is_empty() {
643                    return Some(name);
644                }
645            }
646        }
647    }
648    None
649}
650
651/// Resolve `symbol:…` path against symbol nodes / aliases.
652fn resolve_symbol_against_conn(
653    conn: &rusqlite::Connection,
654    raw_path: &str,
655) -> Result<Option<String>> {
656    let Some(sym) = crate::symbols::parse_symbol_path(raw_path) else {
657        return Ok(None);
658    };
659
660    // Collect all symbol node ids (type=symbol).
661    let mut stmt = conn.prepare("SELECT id FROM nodes WHERE node_type = 'symbol'")?;
662    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
663    let mut ids = std::collections::HashSet::new();
664    for r in rows {
665        ids.insert(r?);
666    }
667
668    if let Some(id) = crate::symbols::resolve_symbol_ref(&sym, &ids) {
669        return Ok(Some(id));
670    }
671
672    // Fall back to alias / title resolution for the bare name.
673    resolve_against_conn(conn, &sym.symbol_name)
674}
675
676/// Resolve a link target using the live connection (aliases + ids + titles).
677fn resolve_against_conn(conn: &rusqlite::Connection, raw: &str) -> Result<Option<String>> {
678    let key = raw.trim().to_lowercase();
679    if key.is_empty() {
680        return Ok(None);
681    }
682
683    // Exact id
684    let exact: Option<String> = conn
685        .query_row("SELECT id FROM nodes WHERE id = ?1", [&key], |row| row.get(0))
686        .optional_compat()?;
687    if exact.is_some() {
688        return Ok(exact);
689    }
690
691    // Alias
692    let by_alias: Option<String> = conn
693        .query_row(
694            "SELECT node_id FROM node_aliases WHERE alias = ?1",
695            [&key],
696            |row| row.get(0),
697        )
698        .optional_compat()?;
699    if by_alias.is_some() {
700        return Ok(by_alias);
701    }
702
703    // Title (case-insensitive) — may be ambiguous; take if unique
704    let mut stmt = conn.prepare("SELECT id FROM nodes WHERE lower(title) = ?1")?;
705    let rows = stmt.query_map([&key], |row| row.get::<_, String>(0))?;
706    let mut hits = Vec::new();
707    for r in rows {
708        hits.push(r?);
709    }
710    if hits.len() == 1 {
711        return Ok(Some(hits.remove(0)));
712    }
713
714    // Unique suffix match
715    let mut stmt = conn.prepare("SELECT id FROM nodes")?;
716    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
717    let mut suffix_hits = Vec::new();
718    for r in rows {
719        let id = r?;
720        if id == key || id.ends_with(&format!("/{key}")) || id.rsplit('/').next() == Some(key.as_str())
721        {
722            suffix_hits.push(id);
723        }
724    }
725    suffix_hits.sort();
726    suffix_hits.dedup();
727    if suffix_hits.len() == 1 {
728        return Ok(Some(suffix_hits.remove(0)));
729    }
730
731    Ok(None)
732}
733
734/// Local trait shim so we can call `.optional()` style without importing OptionalExtension in every use.
735trait OptionalCompat<T> {
736    fn optional_compat(self) -> Result<Option<T>>;
737}
738
739impl<T> OptionalCompat<T> for std::result::Result<T, rusqlite::Error> {
740    fn optional_compat(self) -> Result<Option<T>> {
741        match self {
742            Ok(v) => Ok(Some(v)),
743            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
744            Err(e) => Err(BrainError::from(e)),
745        }
746    }
747}
748
749#[cfg(not(feature = "obsidian"))]
750struct RawLink {
751    target_node: String,
752}
753
754#[cfg(test)]
755mod tests {
756    use super::*;
757    use tempfile::tempdir;
758
759    #[test]
760    fn index_workspace_and_fts_idempotent() {
761        let dir = tempdir().unwrap();
762        let docs = dir.path().join("docs");
763        std::fs::create_dir_all(&docs).unwrap();
764        std::fs::write(
765            docs.join("raft.md"),
766            "---\ntags: [raft]\nnode_type: concept\naliases: [Raft Consensus]\n---\n# Raft Consensus\nRelates to [[log-compaction]].\n",
767        )
768        .unwrap();
769        std::fs::write(
770            docs.join("log-compaction.md"),
771            "---\ntags: [log]\nnode_type: concept\n---\n# Log Compaction\nSee [[raft]].\n",
772        )
773        .unwrap();
774
775        let brain = dir.path().join(".brain");
776        std::fs::create_dir_all(&brain).unwrap();
777        let db = Database::open(brain.join("db.sqlite")).unwrap();
778        let indexer = WorkspaceIndexer::new(db, dir.path());
779
780        let s1 = indexer.index_workspace().unwrap();
781        assert_eq!(s1.markdown_files, 2);
782        assert!(s1.edges_created >= 2);
783        let fts1 = indexer.database().count_fts_rows().unwrap();
784        assert_eq!(fts1, 2);
785
786        // Second sync should skip unchanged content and keep FTS row count stable.
787        // Touch is not done — content hash matches → nodes_skipped.
788        let s2 = indexer.index_workspace().unwrap();
789        assert_eq!(s2.nodes_skipped_unchanged, 2);
790        assert_eq!(indexer.database().count_fts_rows().unwrap(), 2);
791
792        // Force reindex by changing content
793        std::fs::write(
794            docs.join("raft.md"),
795            "---\ntags: [raft]\nnode_type: concept\n---\n# Raft Consensus\nUpdated body [[log-compaction]].\n",
796        )
797        .unwrap();
798        let s3 = indexer.index_workspace().unwrap();
799        assert_eq!(s3.nodes_upserted, 1);
800        assert_eq!(indexer.database().count_fts_rows().unwrap(), 2);
801
802        let hits = indexer.database().search_fts("raft").unwrap();
803        assert!(!hits.is_empty());
804        assert!(hits.iter().any(|n| n.id.contains("raft")));
805    }
806
807    #[test]
808    fn pending_link_then_resolve() {
809        let dir = tempdir().unwrap();
810        let docs = dir.path().join("docs");
811        std::fs::create_dir_all(&docs).unwrap();
812        std::fs::write(
813            docs.join("a.md"),
814            "---\nnode_type: concept\n---\n# A\nLink to [[b]].\n",
815        )
816        .unwrap();
817
818        let brain = dir.path().join(".brain");
819        std::fs::create_dir_all(&brain).unwrap();
820        let db = Database::open(brain.join("db.sqlite")).unwrap();
821        let indexer = WorkspaceIndexer::new(db, dir.path());
822        let s1 = indexer.index_workspace().unwrap();
823        assert!(s1.edges_pending >= 1 || indexer.database().count_pending_links().unwrap() >= 1);
824
825        std::fs::write(
826            docs.join("b.md"),
827            "---\nnode_type: concept\n---\n# B\nBack.\n",
828        )
829        .unwrap();
830        let s2 = indexer.index_workspace().unwrap();
831        assert!(s2.edges_created >= 1 || indexer.database().count_edges().unwrap() >= 1);
832    }
833}