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                let result = match ext {
368                    Some("md") => self.index_markdown_file(&path, stats),
369                    #[cfg(feature = "obsidian")]
370                    Some("canvas") => self.index_canvas_file(&path, stats),
371                    #[cfg(feature = "ast")]
372                    Some("rs") => self.index_rust_file(&path, ast_parser, stats),
373                    _ => Ok(()),
374                };
375                if let Err(e) = result {
376                    // Never abort a full-workspace sync for one bad file.
377                    stats.file_errors += 1;
378                    eprintln!(
379                        "rustbrain: skip {} ({e})",
380                        path.display()
381                    );
382                }
383            }
384        }
385        Ok(())
386    }
387
388    #[cfg(feature = "ast")]
389    fn index_rust_file(
390        &self,
391        path: &Path,
392        ast_parser: &mut crate::ast::CodeAstParser,
393        stats: &mut SyncStats,
394    ) -> Result<()> {
395        let rel = rel_path_from_workspace(&self.workspace, path);
396        let rel_str = rel.to_string_lossy().replace('\\', "/");
397        let crate_name = infer_crate_name(&self.workspace, path);
398        let source = std::fs::read_to_string(path)?;
399        let anchors = ast_parser
400            .parse_symbols(&crate_name, &rel_str, &source)
401            .map_err(|e| BrainError::Ast(e.to_string()))?;
402
403        for anchor in &anchors {
404            self.db.insert_symbol_anchor(anchor)?;
405
406            let node_id = crate::symbols::symbol_node_id(
407                &anchor.crate_name,
408                &anchor.module_path,
409                &anchor.symbol_name,
410            );
411            // Content hash from signature so unchanged symbols are skipped next sync.
412            let sig = format!(
413                "{}::{}::{}@{}-{}",
414                anchor.crate_name,
415                anchor.module_path,
416                anchor.symbol_name,
417                anchor.start_line,
418                anchor.end_line
419            );
420            let chash = crate::id::content_hash(sig.as_bytes());
421            if let Some(existing) = self.db.get_content_hash(&node_id)? {
422                if existing == chash {
423                    stats.nodes_skipped_unchanged += 1;
424                    stats.symbol_anchors += 1;
425                    continue;
426                }
427            }
428
429            let now = Utc::now().timestamp();
430            let node = Node {
431                id: node_id.clone(),
432                node_type: NodeType::Symbol,
433                title: anchor.symbol_name.clone(),
434                file_path: Some(anchor.file_path.clone()),
435                symbol_hash: Some(anchor.symbol_hash),
436                summary: anchor.doc_comment.clone(),
437                content_hash: Some(chash),
438                created_at: now,
439                updated_at: now,
440            };
441            self.db.insert_node(&node)?;
442
443            // Aliases for resolution: bare name, Type::method, full path.
444            let mut aliases = vec![anchor.symbol_name.clone()];
445            if let Some((_, method)) = anchor.symbol_name.split_once("::") {
446                aliases.push(method.to_string());
447            }
448            aliases.push(format!(
449                "{}::{}::{}",
450                anchor.crate_name, anchor.module_path, anchor.symbol_name
451            ));
452            self.db.replace_node_aliases(&node_id, &aliases)?;
453
454            let fts_body = format!(
455                "{} {} {} {}",
456                anchor.symbol_name,
457                anchor.module_path,
458                anchor.crate_name,
459                anchor.doc_comment.as_deref().unwrap_or("")
460            );
461            self.db
462                .index_fts(&node_id, &anchor.symbol_name, &fts_body, "symbol")?;
463
464            stats.nodes_upserted += 1;
465            stats.symbol_anchors += 1;
466        }
467        stats.rust_files += 1;
468        Ok(())
469    }
470}
471
472fn should_skip_dir(name: &str) -> bool {
473    matches!(
474        name,
475        "target"
476            | "node_modules"
477            | "vendor"
478            | ".git"
479            | ".brain"
480            | "dist"
481            | "build"
482            | ".svn"
483            | ".hg"
484    ) || name.starts_with('.')
485}
486
487fn first_substantive_line(body: &str) -> String {
488    for line in body.lines() {
489        let t = line.trim();
490        if t.is_empty() {
491            continue;
492        }
493        // Skip pure heading markers only lines handled below
494        let cleaned = t.trim_start_matches('#').trim();
495        if !cleaned.is_empty() {
496            return cleaned.to_string();
497        }
498    }
499    String::new()
500}
501
502fn resolve_title(
503    #[cfg(feature = "obsidian")] frontmatter: Option<&crate::obsidian::Frontmatter>,
504    #[cfg(not(feature = "obsidian"))] frontmatter: Option<&()>,
505    body: &str,
506    rel: &Path,
507) -> String {
508    #[cfg(feature = "obsidian")]
509    if let Some(fm) = frontmatter {
510        if let Some(title) = fm.extra.get("title").and_then(|v| v.as_str()) {
511            let t = title.trim();
512            if !t.is_empty() {
513                return t.to_string();
514            }
515        }
516    }
517    #[cfg(not(feature = "obsidian"))]
518    let _ = frontmatter;
519
520    // First H1
521    for line in body.lines() {
522        let t = line.trim();
523        if let Some(rest) = t.strip_prefix("# ") {
524            let t = rest.trim();
525            if !t.is_empty() {
526                return t.to_string();
527            }
528        }
529    }
530
531    rel.file_stem()
532        .and_then(|s| s.to_str())
533        .unwrap_or("Untitled")
534        .to_string()
535}
536
537#[cfg(feature = "ast")]
538fn infer_crate_name(workspace: &Path, file: &Path) -> String {
539    // Walk up looking for Cargo.toml
540    let mut cur = file.parent();
541    while let Some(dir) = cur {
542        let cargo = dir.join("Cargo.toml");
543        if cargo.exists() {
544            if let Ok(text) = std::fs::read_to_string(&cargo) {
545                if let Some(name) = parse_cargo_package_name(&text) {
546                    return name;
547                }
548            }
549            if let Some(n) = dir.file_name().and_then(|n| n.to_str()) {
550                return n.to_string();
551            }
552        }
553        if dir == workspace {
554            break;
555        }
556        cur = dir.parent();
557    }
558    workspace
559        .file_name()
560        .and_then(|n| n.to_str())
561        .unwrap_or("workspace")
562        .to_string()
563}
564
565#[cfg(feature = "ast")]
566fn parse_cargo_package_name(toml: &str) -> Option<String> {
567    let mut in_package = false;
568    for line in toml.lines() {
569        let t = line.trim();
570        if t.starts_with('[') {
571            in_package = t == "[package]";
572            continue;
573        }
574        if in_package {
575            if let Some(rest) = t.strip_prefix("name") {
576                let rest = rest.trim().trim_start_matches('=').trim();
577                let name = rest.trim_matches('"').trim_matches('\'').to_string();
578                if !name.is_empty() {
579                    return Some(name);
580                }
581            }
582        }
583    }
584    None
585}
586
587/// Resolve `symbol:…` path against symbol nodes / aliases.
588fn resolve_symbol_against_conn(
589    conn: &rusqlite::Connection,
590    raw_path: &str,
591) -> Result<Option<String>> {
592    let Some(sym) = crate::symbols::parse_symbol_path(raw_path) else {
593        return Ok(None);
594    };
595
596    // Collect all symbol node ids (type=symbol).
597    let mut stmt = conn.prepare("SELECT id FROM nodes WHERE node_type = 'symbol'")?;
598    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
599    let mut ids = std::collections::HashSet::new();
600    for r in rows {
601        ids.insert(r?);
602    }
603
604    if let Some(id) = crate::symbols::resolve_symbol_ref(&sym, &ids) {
605        return Ok(Some(id));
606    }
607
608    // Fall back to alias / title resolution for the bare name.
609    resolve_against_conn(conn, &sym.symbol_name)
610}
611
612/// Resolve a link target using the live connection (aliases + ids + titles).
613fn resolve_against_conn(conn: &rusqlite::Connection, raw: &str) -> Result<Option<String>> {
614    let key = raw.trim().to_lowercase();
615    if key.is_empty() {
616        return Ok(None);
617    }
618
619    // Exact id
620    let exact: Option<String> = conn
621        .query_row("SELECT id FROM nodes WHERE id = ?1", [&key], |row| row.get(0))
622        .optional_compat()?;
623    if exact.is_some() {
624        return Ok(exact);
625    }
626
627    // Alias
628    let by_alias: Option<String> = conn
629        .query_row(
630            "SELECT node_id FROM node_aliases WHERE alias = ?1",
631            [&key],
632            |row| row.get(0),
633        )
634        .optional_compat()?;
635    if by_alias.is_some() {
636        return Ok(by_alias);
637    }
638
639    // Title (case-insensitive) — may be ambiguous; take if unique
640    let mut stmt = conn.prepare("SELECT id FROM nodes WHERE lower(title) = ?1")?;
641    let rows = stmt.query_map([&key], |row| row.get::<_, String>(0))?;
642    let mut hits = Vec::new();
643    for r in rows {
644        hits.push(r?);
645    }
646    if hits.len() == 1 {
647        return Ok(Some(hits.remove(0)));
648    }
649
650    // Unique suffix match
651    let mut stmt = conn.prepare("SELECT id FROM nodes")?;
652    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
653    let mut suffix_hits = Vec::new();
654    for r in rows {
655        let id = r?;
656        if id == key || id.ends_with(&format!("/{key}")) || id.rsplit('/').next() == Some(key.as_str())
657        {
658            suffix_hits.push(id);
659        }
660    }
661    suffix_hits.sort();
662    suffix_hits.dedup();
663    if suffix_hits.len() == 1 {
664        return Ok(Some(suffix_hits.remove(0)));
665    }
666
667    Ok(None)
668}
669
670/// Local trait shim so we can call `.optional()` style without importing OptionalExtension in every use.
671trait OptionalCompat<T> {
672    fn optional_compat(self) -> Result<Option<T>>;
673}
674
675impl<T> OptionalCompat<T> for std::result::Result<T, rusqlite::Error> {
676    fn optional_compat(self) -> Result<Option<T>> {
677        match self {
678            Ok(v) => Ok(Some(v)),
679            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
680            Err(e) => Err(BrainError::from(e)),
681        }
682    }
683}
684
685#[cfg(not(feature = "obsidian"))]
686struct RawLink {
687    target_node: String,
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693    use tempfile::tempdir;
694
695    #[test]
696    fn index_workspace_and_fts_idempotent() {
697        let dir = tempdir().unwrap();
698        let docs = dir.path().join("docs");
699        std::fs::create_dir_all(&docs).unwrap();
700        std::fs::write(
701            docs.join("raft.md"),
702            "---\ntags: [raft]\nnode_type: concept\naliases: [Raft Consensus]\n---\n# Raft Consensus\nRelates to [[log-compaction]].\n",
703        )
704        .unwrap();
705        std::fs::write(
706            docs.join("log-compaction.md"),
707            "---\ntags: [log]\nnode_type: concept\n---\n# Log Compaction\nSee [[raft]].\n",
708        )
709        .unwrap();
710
711        let brain = dir.path().join(".brain");
712        std::fs::create_dir_all(&brain).unwrap();
713        let db = Database::open(brain.join("db.sqlite")).unwrap();
714        let indexer = WorkspaceIndexer::new(db, dir.path());
715
716        let s1 = indexer.index_workspace().unwrap();
717        assert_eq!(s1.markdown_files, 2);
718        assert!(s1.edges_created >= 2);
719        let fts1 = indexer.database().count_fts_rows().unwrap();
720        assert_eq!(fts1, 2);
721
722        // Second sync should skip unchanged content and keep FTS row count stable.
723        // Touch is not done — content hash matches → nodes_skipped.
724        let s2 = indexer.index_workspace().unwrap();
725        assert_eq!(s2.nodes_skipped_unchanged, 2);
726        assert_eq!(indexer.database().count_fts_rows().unwrap(), 2);
727
728        // Force reindex by changing content
729        std::fs::write(
730            docs.join("raft.md"),
731            "---\ntags: [raft]\nnode_type: concept\n---\n# Raft Consensus\nUpdated body [[log-compaction]].\n",
732        )
733        .unwrap();
734        let s3 = indexer.index_workspace().unwrap();
735        assert_eq!(s3.nodes_upserted, 1);
736        assert_eq!(indexer.database().count_fts_rows().unwrap(), 2);
737
738        let hits = indexer.database().search_fts("raft").unwrap();
739        assert!(!hits.is_empty());
740        assert!(hits.iter().any(|n| n.id.contains("raft")));
741    }
742
743    #[test]
744    fn pending_link_then_resolve() {
745        let dir = tempdir().unwrap();
746        let docs = dir.path().join("docs");
747        std::fs::create_dir_all(&docs).unwrap();
748        std::fs::write(
749            docs.join("a.md"),
750            "---\nnode_type: concept\n---\n# A\nLink to [[b]].\n",
751        )
752        .unwrap();
753
754        let brain = dir.path().join(".brain");
755        std::fs::create_dir_all(&brain).unwrap();
756        let db = Database::open(brain.join("db.sqlite")).unwrap();
757        let indexer = WorkspaceIndexer::new(db, dir.path());
758        let s1 = indexer.index_workspace().unwrap();
759        assert!(s1.edges_pending >= 1 || indexer.database().count_pending_links().unwrap() >= 1);
760
761        std::fs::write(
762            docs.join("b.md"),
763            "---\nnode_type: concept\n---\n# B\nBack.\n",
764        )
765        .unwrap();
766        let s2 = indexer.index_workspace().unwrap();
767        assert!(s2.edges_created >= 1 || indexer.database().count_edges().unwrap() >= 1);
768    }
769}