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            // Include doc comments in the hash so WikiLink edits in /// re-index.
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                anchor.doc_comment.as_deref().unwrap_or("")
472            );
473            let chash = crate::id::content_hash(sig.as_bytes());
474            let unchanged = self
475                .db
476                .get_content_hash(&node_id)?
477                .map(|existing| existing == chash)
478                .unwrap_or(false);
479
480            if !unchanged {
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            } else {
518                stats.nodes_skipped_unchanged += 1;
519            }
520
521            // Always (re)link rustdoc WikiLinks → notes so new notes resolve later.
522            self.link_symbol_doc_wikis(&node_id, anchor.doc_comment.as_deref(), stats)?;
523
524            stats.symbol_anchors += 1;
525        }
526        stats.rust_files += 1;
527        Ok(())
528    }
529
530    /// Parse `[[WikiLinks]]` in rustdoc (`///` / `//!` / `/**`) and edge symbol → note.
531    ///
532    /// Relation type: `doc_links` (code documents/references a brain node).
533    /// Unresolved targets become pending links (resolved on later syncs).
534    #[cfg(feature = "ast")]
535    fn link_symbol_doc_wikis(
536        &self,
537        symbol_node_id: &str,
538        doc_comment: Option<&str>,
539        stats: &mut SyncStats,
540    ) -> Result<()> {
541        let now = Utc::now().timestamp();
542        let doc_plain = doc_comment.map(strip_rustdoc_prefixes).unwrap_or_default();
543
544        self.db.with_transaction(|conn| {
545            self.db
546                .clear_edges_from_on(conn, symbol_node_id, "doc_links")?;
547            // Drop only pending rows for this symbol that were doc_links (full clear is ok:
548            // symbols do not use other pending kinds).
549            self.db.clear_pending_links_for_on(conn, symbol_node_id)?;
550
551            if doc_plain.trim().is_empty() {
552                return Ok(());
553            }
554
555            // WikiLink extraction lives under the `obsidian` feature (default on).
556            #[cfg(feature = "obsidian")]
557            {
558                let wikis = crate::obsidian::extract_wikilinks(&doc_plain);
559                for w in wikis {
560                    let target = w.target_node.trim();
561                    if target.is_empty() {
562                        continue;
563                    }
564                    // Skip pure symbol: refs in docs — notes own note→code anchors.
565                    if target.starts_with("symbol:") {
566                        continue;
567                    }
568                    let resolved = resolve_against_conn(conn, target)?;
569                    if let Some(target_id) = resolved {
570                        if target_id == symbol_node_id {
571                            continue;
572                        }
573                        let edge = Edge {
574                            source_id: symbol_node_id.to_string(),
575                            target_id,
576                            relation_type: "doc_links".into(),
577                            weight: 1.0,
578                            decay_rate: 0.0,
579                            created_at: now,
580                        };
581                        self.db.insert_edge_on(conn, &edge)?;
582                        stats.edges_created += 1;
583                    } else {
584                        self.db.insert_pending_link_on(
585                            conn,
586                            symbol_node_id,
587                            &format!("[[{target}]]"),
588                            "doc_links",
589                            now,
590                        )?;
591                        stats.edges_pending += 1;
592                    }
593                }
594            }
595            Ok(())
596        })
597    }
598}
599
600/// Normalize `///` / `//!` / block-doc lines into plain text for WikiLink extraction.
601#[cfg(feature = "ast")]
602fn strip_rustdoc_prefixes(doc: &str) -> String {
603    let mut out = String::new();
604    for line in doc.lines() {
605        let t = line.trim();
606        let body = if let Some(rest) = t.strip_prefix("///") {
607            rest.strip_prefix(' ').unwrap_or(rest)
608        } else if let Some(rest) = t.strip_prefix("//!") {
609            rest.strip_prefix(' ').unwrap_or(rest)
610        } else if t.starts_with("/**") || t.starts_with("*/") {
611            continue;
612        } else if let Some(rest) = t.strip_prefix('*') {
613            rest.strip_prefix(' ').unwrap_or(rest)
614        } else {
615            t
616        };
617        out.push_str(body);
618        out.push('\n');
619    }
620    out
621}
622
623fn is_root_readme(rel: &Path) -> bool {
624    let comps: Vec<_> = rel
625        .components()
626        .filter(|c| matches!(c, std::path::Component::Normal(_)))
627        .collect();
628    if comps.len() != 1 {
629        return false;
630    }
631    rel.file_name()
632        .and_then(|n| n.to_str())
633        .map(|n| n.eq_ignore_ascii_case("README.md"))
634        .unwrap_or(false)
635}
636
637fn rustbrainignore_requests_gitignore(workspace: &Path) -> bool {
638    let path = workspace.join(".rustbrainignore");
639    let Ok(text) = std::fs::read_to_string(path) else {
640        return false;
641    };
642    text.lines().any(|l| {
643        let t = l.trim().to_ascii_lowercase();
644        t == "# rustbrain: import-gitignore"
645            || t == "#!import-gitignore"
646            || t.contains("rustbrain: import-gitignore")
647    })
648}
649
650fn first_substantive_line(body: &str) -> String {
651    for line in body.lines() {
652        let t = line.trim();
653        if t.is_empty() {
654            continue;
655        }
656        // Skip pure heading markers only lines handled below
657        let cleaned = t.trim_start_matches('#').trim();
658        if !cleaned.is_empty() {
659            return cleaned.to_string();
660        }
661    }
662    String::new()
663}
664
665fn resolve_title(
666    #[cfg(feature = "obsidian")] frontmatter: Option<&crate::obsidian::Frontmatter>,
667    #[cfg(not(feature = "obsidian"))] frontmatter: Option<&()>,
668    body: &str,
669    rel: &Path,
670) -> String {
671    #[cfg(feature = "obsidian")]
672    if let Some(fm) = frontmatter {
673        if let Some(title) = fm.extra.get("title").and_then(|v| v.as_str()) {
674            let t = title.trim();
675            if !t.is_empty() {
676                return t.to_string();
677            }
678        }
679    }
680    #[cfg(not(feature = "obsidian"))]
681    let _ = frontmatter;
682
683    // First H1
684    for line in body.lines() {
685        let t = line.trim();
686        if let Some(rest) = t.strip_prefix("# ") {
687            let t = rest.trim();
688            if !t.is_empty() {
689                return t.to_string();
690            }
691        }
692    }
693
694    rel.file_stem()
695        .and_then(|s| s.to_str())
696        .unwrap_or("Untitled")
697        .to_string()
698}
699
700#[cfg(feature = "ast")]
701fn infer_crate_name(workspace: &Path, file: &Path) -> String {
702    // Walk up looking for Cargo.toml
703    let mut cur = file.parent();
704    while let Some(dir) = cur {
705        let cargo = dir.join("Cargo.toml");
706        if cargo.exists() {
707            if let Ok(text) = std::fs::read_to_string(&cargo) {
708                if let Some(name) = parse_cargo_package_name(&text) {
709                    return name;
710                }
711            }
712            if let Some(n) = dir.file_name().and_then(|n| n.to_str()) {
713                return n.to_string();
714            }
715        }
716        if dir == workspace {
717            break;
718        }
719        cur = dir.parent();
720    }
721    workspace
722        .file_name()
723        .and_then(|n| n.to_str())
724        .unwrap_or("workspace")
725        .to_string()
726}
727
728#[cfg(feature = "ast")]
729fn parse_cargo_package_name(toml: &str) -> Option<String> {
730    let mut in_package = false;
731    for line in toml.lines() {
732        let t = line.trim();
733        if t.starts_with('[') {
734            in_package = t == "[package]";
735            continue;
736        }
737        if in_package {
738            if let Some(rest) = t.strip_prefix("name") {
739                let rest = rest.trim().trim_start_matches('=').trim();
740                let name = rest.trim_matches('"').trim_matches('\'').to_string();
741                if !name.is_empty() {
742                    return Some(name);
743                }
744            }
745        }
746    }
747    None
748}
749
750/// Resolve `symbol:…` path against symbol nodes / aliases.
751fn resolve_symbol_against_conn(
752    conn: &rusqlite::Connection,
753    raw_path: &str,
754) -> Result<Option<String>> {
755    let Some(sym) = crate::symbols::parse_symbol_path(raw_path) else {
756        return Ok(None);
757    };
758
759    // Collect all symbol node ids (type=symbol).
760    let mut stmt = conn.prepare("SELECT id FROM nodes WHERE node_type = 'symbol'")?;
761    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
762    let mut ids = std::collections::HashSet::new();
763    for r in rows {
764        ids.insert(r?);
765    }
766
767    if let Some(id) = crate::symbols::resolve_symbol_ref(&sym, &ids) {
768        return Ok(Some(id));
769    }
770
771    // Fall back to alias / title resolution for the bare name.
772    resolve_against_conn(conn, &sym.symbol_name)
773}
774
775/// Resolve a link target using the live connection (aliases + ids + titles).
776fn resolve_against_conn(conn: &rusqlite::Connection, raw: &str) -> Result<Option<String>> {
777    let key = raw.trim().to_lowercase();
778    if key.is_empty() {
779        return Ok(None);
780    }
781
782    // Exact id
783    let exact: Option<String> = conn
784        .query_row("SELECT id FROM nodes WHERE id = ?1", [&key], |row| row.get(0))
785        .optional_compat()?;
786    if exact.is_some() {
787        return Ok(exact);
788    }
789
790    // Alias
791    let by_alias: Option<String> = conn
792        .query_row(
793            "SELECT node_id FROM node_aliases WHERE alias = ?1",
794            [&key],
795            |row| row.get(0),
796        )
797        .optional_compat()?;
798    if by_alias.is_some() {
799        return Ok(by_alias);
800    }
801
802    // Title (case-insensitive) — may be ambiguous; take if unique
803    let mut stmt = conn.prepare("SELECT id FROM nodes WHERE lower(title) = ?1")?;
804    let rows = stmt.query_map([&key], |row| row.get::<_, String>(0))?;
805    let mut hits = Vec::new();
806    for r in rows {
807        hits.push(r?);
808    }
809    if hits.len() == 1 {
810        return Ok(Some(hits.remove(0)));
811    }
812
813    // Unique suffix match
814    let mut stmt = conn.prepare("SELECT id FROM nodes")?;
815    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
816    let mut suffix_hits = Vec::new();
817    for r in rows {
818        let id = r?;
819        if id == key || id.ends_with(&format!("/{key}")) || id.rsplit('/').next() == Some(key.as_str())
820        {
821            suffix_hits.push(id);
822        }
823    }
824    suffix_hits.sort();
825    suffix_hits.dedup();
826    if suffix_hits.len() == 1 {
827        return Ok(Some(suffix_hits.remove(0)));
828    }
829
830    Ok(None)
831}
832
833/// Local trait shim so we can call `.optional()` style without importing OptionalExtension in every use.
834trait OptionalCompat<T> {
835    fn optional_compat(self) -> Result<Option<T>>;
836}
837
838impl<T> OptionalCompat<T> for std::result::Result<T, rusqlite::Error> {
839    fn optional_compat(self) -> Result<Option<T>> {
840        match self {
841            Ok(v) => Ok(Some(v)),
842            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
843            Err(e) => Err(BrainError::from(e)),
844        }
845    }
846}
847
848#[cfg(not(feature = "obsidian"))]
849struct RawLink {
850    target_node: String,
851}
852
853#[cfg(test)]
854mod tests {
855    use super::*;
856    use tempfile::tempdir;
857
858    #[test]
859    fn index_workspace_and_fts_idempotent() {
860        let dir = tempdir().unwrap();
861        let docs = dir.path().join("docs");
862        std::fs::create_dir_all(&docs).unwrap();
863        std::fs::write(
864            docs.join("raft.md"),
865            "---\ntags: [raft]\nnode_type: concept\naliases: [Raft Consensus]\n---\n# Raft Consensus\nRelates to [[log-compaction]].\n",
866        )
867        .unwrap();
868        std::fs::write(
869            docs.join("log-compaction.md"),
870            "---\ntags: [log]\nnode_type: concept\n---\n# Log Compaction\nSee [[raft]].\n",
871        )
872        .unwrap();
873
874        let brain = dir.path().join(".brain");
875        std::fs::create_dir_all(&brain).unwrap();
876        let db = Database::open(brain.join("db.sqlite")).unwrap();
877        let indexer = WorkspaceIndexer::new(db, dir.path());
878
879        let s1 = indexer.index_workspace().unwrap();
880        assert_eq!(s1.markdown_files, 2);
881        assert!(s1.edges_created >= 2);
882        let fts1 = indexer.database().count_fts_rows().unwrap();
883        assert_eq!(fts1, 2);
884
885        // Second sync should skip unchanged content and keep FTS row count stable.
886        // Touch is not done — content hash matches → nodes_skipped.
887        let s2 = indexer.index_workspace().unwrap();
888        assert_eq!(s2.nodes_skipped_unchanged, 2);
889        assert_eq!(indexer.database().count_fts_rows().unwrap(), 2);
890
891        // Force reindex by changing content
892        std::fs::write(
893            docs.join("raft.md"),
894            "---\ntags: [raft]\nnode_type: concept\n---\n# Raft Consensus\nUpdated body [[log-compaction]].\n",
895        )
896        .unwrap();
897        let s3 = indexer.index_workspace().unwrap();
898        assert_eq!(s3.nodes_upserted, 1);
899        assert_eq!(indexer.database().count_fts_rows().unwrap(), 2);
900
901        let hits = indexer.database().search_fts("raft").unwrap();
902        assert!(!hits.is_empty());
903        assert!(hits.iter().any(|n| n.id.contains("raft")));
904    }
905
906    #[test]
907    fn strip_rustdoc_prefixes_keeps_wikilink() {
908        let raw = "/// Primary engine. See [[use-sqlite]].\n/// Second line.";
909        let plain = strip_rustdoc_prefixes(raw);
910        assert!(plain.contains("[[use-sqlite]]"));
911        assert!(!plain.contains("///"));
912    }
913
914    #[cfg(all(feature = "ast", feature = "obsidian"))]
915    #[test]
916    fn rustdoc_wikilink_creates_doc_links_edge() {
917        let dir = tempdir().unwrap();
918        let docs = dir.path().join("docs/adr");
919        let src = dir.path().join("src");
920        std::fs::create_dir_all(&docs).unwrap();
921        std::fs::create_dir_all(&src).unwrap();
922        std::fs::write(
923            dir.path().join("Cargo.toml"),
924            "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
925        )
926        .unwrap();
927        std::fs::write(
928            docs.join("use-sqlite.md"),
929            "---\nnode_type: adr\naliases: [use-sqlite]\n---\n# Use SQLite\n\nLocal store.\n",
930        )
931        .unwrap();
932        std::fs::write(
933            src.join("lib.rs"),
934            r#"/// Primary engine. See [[use-sqlite]] and [[docs/adr/use-sqlite]].
935pub struct StorageEngine;
936
937impl StorageEngine {
938    /// Open the store.
939    pub fn open() {}
940}
941"#,
942        )
943        .unwrap();
944
945        let brain = dir.path().join(".brain");
946        std::fs::create_dir_all(&brain).unwrap();
947        let db = Database::open(brain.join("db.sqlite")).unwrap();
948        let indexer = WorkspaceIndexer::new(db, dir.path());
949        // Two syncs: notes first pass may race walk order; second resolves pending.
950        let _ = indexer.index_workspace().unwrap();
951        let s = indexer.index_workspace().unwrap();
952        let _ = s;
953
954        let edges = indexer.database().get_all_edges().unwrap();
955        let doc_links: Vec<_> = edges
956            .iter()
957            .filter(|e| e.relation_type == "doc_links")
958            .collect();
959        assert!(
960            !doc_links.is_empty(),
961            "expected doc_links from rustdoc WikiLink, edges={edges:?}"
962        );
963        assert!(doc_links.iter().any(|e| e.source_id.contains("StorageEngine")));
964        assert!(doc_links.iter().any(|e| e.target_id.contains("use-sqlite")
965            || e.target_id.contains("docs/adr/use-sqlite")));
966    }
967
968    #[test]
969    fn pending_link_then_resolve() {
970        let dir = tempdir().unwrap();
971        let docs = dir.path().join("docs");
972        std::fs::create_dir_all(&docs).unwrap();
973        std::fs::write(
974            docs.join("a.md"),
975            "---\nnode_type: concept\n---\n# A\nLink to [[b]].\n",
976        )
977        .unwrap();
978
979        let brain = dir.path().join(".brain");
980        std::fs::create_dir_all(&brain).unwrap();
981        let db = Database::open(brain.join("db.sqlite")).unwrap();
982        let indexer = WorkspaceIndexer::new(db, dir.path());
983        let s1 = indexer.index_workspace().unwrap();
984        assert!(s1.edges_pending >= 1 || indexer.database().count_pending_links().unwrap() >= 1);
985
986        std::fs::write(
987            docs.join("b.md"),
988            "---\nnode_type: concept\n---\n# B\nBack.\n",
989        )
990        .unwrap();
991        let s2 = indexer.index_workspace().unwrap();
992        assert!(s2.edges_created >= 1 || indexer.database().count_edges().unwrap() >= 1);
993    }
994}