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