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 hub = crate::hubs::detect_project_hub(&rel);
125        let node_id = hub
126            .map(|h| h.node_id().to_string())
127            .unwrap_or_else(|| node_id_from_rel_path(&rel));
128
129        if let Some(existing) = self.db.get_content_hash(&node_id)? {
130            if existing == hash {
131                stats.nodes_skipped_unchanged += 1;
132                return Ok(());
133            }
134        }
135
136        #[cfg(feature = "obsidian")]
137        let (frontmatter, body) = crate::obsidian::parse_frontmatter(&content);
138        #[cfg(not(feature = "obsidian"))]
139        let (frontmatter, body): (Option<()>, &str) = (None, content.as_ref());
140
141        let title = resolve_title(
142            #[cfg(feature = "obsidian")]
143            frontmatter.as_ref(),
144            #[cfg(not(feature = "obsidian"))]
145            None,
146            body,
147            &rel,
148        );
149
150        let node_type = {
151            #[cfg(feature = "obsidian")]
152            {
153                let parsed = frontmatter
154                    .as_ref()
155                    .and_then(|fm| fm.node_type.as_deref())
156                    .and_then(NodeType::parse);
157                match hub {
158                    // Root README is the project front door; default to Goal unless author overrides.
159                    Some(crate::hubs::ProjectHub::Readme) => parsed.unwrap_or(NodeType::Goal),
160                    // Keep a Changelog / ROADMAP / BACKLOG: project-level reference docs.
161                    Some(
162                        crate::hubs::ProjectHub::Changelog
163                        | crate::hubs::ProjectHub::Roadmap
164                        | crate::hubs::ProjectHub::Backlog,
165                    ) => parsed.unwrap_or(NodeType::Reference),
166                    None => parsed.unwrap_or(NodeType::Concept),
167                }
168            }
169            #[cfg(not(feature = "obsidian"))]
170            {
171                match hub {
172                    Some(crate::hubs::ProjectHub::Readme) => NodeType::Goal,
173                    Some(
174                        crate::hubs::ProjectHub::Changelog
175                        | crate::hubs::ProjectHub::Roadmap
176                        | crate::hubs::ProjectHub::Backlog,
177                    ) => NodeType::Reference,
178                    None => NodeType::Concept,
179                }
180            }
181        };
182
183        let now = Utc::now().timestamp();
184        let summary = if hub == Some(crate::hubs::ProjectHub::Changelog) {
185            crate::hubs::changelog_latest_heading(body)
186                .unwrap_or_else(|| first_substantive_line(body))
187        } else {
188            first_substantive_line(body)
189        };
190
191        let tags: Vec<String> = {
192            #[cfg(feature = "obsidian")]
193            {
194                frontmatter
195                    .as_ref()
196                    .map(|fm| fm.tags.clone())
197                    .unwrap_or_default()
198            }
199            #[cfg(not(feature = "obsidian"))]
200            {
201                Vec::new()
202            }
203        };
204
205        let aliases: Vec<String> = {
206            #[cfg(feature = "obsidian")]
207            {
208                frontmatter
209                    .as_ref()
210                    .map(|fm| fm.aliases.clone())
211                    .unwrap_or_default()
212            }
213            #[cfg(not(feature = "obsidian"))]
214            {
215                Vec::new()
216            }
217        };
218
219        // Collect wikilinks before the transaction writes.
220        #[cfg(feature = "obsidian")]
221        let wikilinks = crate::obsidian::extract_wikilinks(body);
222        #[cfg(not(feature = "obsidian"))]
223        let wikilinks: Vec<RawLink> = Vec::new();
224
225        // Architecture plan: symbol:crate::module::Name anchors in notes.
226        let symbol_refs = crate::symbols::extract_symbol_refs(body);
227
228        let node = Node {
229            id: node_id.clone(),
230            node_type,
231            title: title.clone(),
232            file_path: Some(rel_str),
233            symbol_hash: None,
234            summary: Some(summary),
235            content_hash: Some(hash),
236            created_at: now,
237            updated_at: now,
238        };
239
240        let tags_str = tags.join(" ");
241        let mut links_for_tx: Vec<(String, String)> = {
242            #[cfg(feature = "obsidian")]
243            {
244                wikilinks
245                    .iter()
246                    .map(|l| {
247                        // WikiLinks of form [[symbol:…]] become anchors, not relates_to.
248                        if let Some(rest) = l.target_node.strip_prefix("symbol:") {
249                            (format!("symbol:{rest}"), "anchors".to_string())
250                        } else {
251                            (l.target_node.clone(), "relates_to".to_string())
252                        }
253                    })
254                    .collect()
255            }
256            #[cfg(not(feature = "obsidian"))]
257            {
258                let _ = &wikilinks;
259                Vec::new()
260            }
261        };
262        for sref in &symbol_refs {
263            links_for_tx.push((format!("symbol:{}", sref.raw), "anchors".to_string()));
264        }
265
266        // Also register file stem and title as aliases for link resolution.
267        let mut extra_aliases = aliases.clone();
268        if let Some(stem) = Path::new(&node.file_path.as_deref().unwrap_or(""))
269            .file_stem()
270            .and_then(|s| s.to_str())
271        {
272            extra_aliases.push(stem.to_string());
273        }
274        extra_aliases.push(title.clone());
275        if let Some(h) = hub {
276            for a in h.aliases() {
277                extra_aliases.push((*a).to_string());
278            }
279            if h == crate::hubs::ProjectHub::Readme {
280                if let Some(name) = self.workspace.file_name().and_then(|n| n.to_str()) {
281                    extra_aliases.push(name.to_string());
282                }
283            }
284            if h == crate::hubs::ProjectHub::Changelog {
285                // Recent SemVer labels from Keep a Changelog headings → FTS aliases.
286                for v in crate::hubs::changelog_version_aliases(body, 8) {
287                    extra_aliases.push(v);
288                }
289            }
290        }
291
292        self.db.with_transaction(|conn| {
293            self.db.insert_node_on(conn, &node)?;
294            self.db.replace_node_tags_on(conn, &node_id, &tags)?;
295            self.db
296                .replace_node_aliases_on(conn, &node_id, &extra_aliases)?;
297            self.db
298                .index_fts_on(conn, &node_id, &title, body, &tags_str)?;
299
300            // Clear prior outbound edges + pending for this source (idempotent).
301            self.db
302                .clear_edges_from_on(conn, &node_id, "relates_to")?;
303            self.db.clear_edges_from_on(conn, &node_id, "anchors")?;
304            self.db.clear_pending_links_for_on(conn, &node_id)?;
305
306            for (raw_target, rel_type) in &links_for_tx {
307                let resolved = if let Some(sym_path) = raw_target.strip_prefix("symbol:") {
308                    resolve_symbol_against_conn(conn, sym_path)?
309                } else {
310                    resolve_against_conn(conn, raw_target)?
311                };
312
313                if let Some(target_id) = resolved {
314                    let edge = Edge {
315                        source_id: node_id.clone(),
316                        target_id,
317                        relation_type: rel_type.clone(),
318                        weight: 1.0,
319                        decay_rate: 0.0,
320                        created_at: now,
321                    };
322                    self.db.insert_edge_on(conn, &edge)?;
323                    stats.edges_created += 1;
324                } else {
325                    self.db.insert_pending_link_on(
326                        conn,
327                        &node_id,
328                        raw_target,
329                        rel_type,
330                        now,
331                    )?;
332                    stats.edges_pending += 1;
333                }
334            }
335            Ok(())
336        })?;
337
338        stats.markdown_files += 1;
339        stats.nodes_upserted += 1;
340        Ok(())
341    }
342
343    /// Index an Obsidian Canvas (`.canvas`) file into graph edges.
344    ///
345    /// Resolves file/text node labels to existing note ids when possible;
346    /// otherwise records pending links. Requires the `obsidian` feature.
347    #[cfg(feature = "obsidian")]
348    pub fn index_canvas_file(&self, file_path: &Path, stats: &mut SyncStats) -> Result<()> {
349        if !file_path.exists() || file_path.extension().and_then(|e| e.to_str()) != Some("canvas")
350        {
351            return Ok(());
352        }
353
354        let content = std::fs::read_to_string(file_path)?;
355        let canvas = crate::obsidian::ObsidianCanvas::parse_str(&content)
356            .map_err(|e| BrainError::indexer(e.to_string()))?;
357        let now = Utc::now().timestamp();
358        let relationships = canvas.extract_relationships();
359
360        let (ids, aliases, titles) = self.db.link_resolution_maps()?;
361
362        self.db.with_transaction(|conn| {
363            for (src_raw, dst_raw, rel) in &relationships {
364                let src = resolve_link_target(src_raw, &ids, &aliases, &titles);
365                let dst = resolve_link_target(dst_raw, &ids, &aliases, &titles);
366                match (src, dst) {
367                    (Some(s), Some(d)) => {
368                        let edge = Edge {
369                            source_id: s,
370                            target_id: d,
371                            relation_type: rel.clone(),
372                            weight: 1.0,
373                            decay_rate: 0.0,
374                            created_at: now,
375                        };
376                        self.db.insert_edge_on(conn, &edge)?;
377                        stats.edges_created += 1;
378                    }
379                    (Some(s), None) => {
380                        self.db
381                            .insert_pending_link_on(conn, &s, dst_raw, rel, now)?;
382                        stats.edges_pending += 1;
383                    }
384                    _ => {
385                        // Source unknown — skip with pending if we can map nothing.
386                        stats.edges_pending += 1;
387                    }
388                }
389            }
390            Ok(())
391        })?;
392
393        stats.canvas_files += 1;
394        Ok(())
395    }
396
397    fn walk_and_index(
398        &self,
399        dir: &Path,
400        stats: &mut SyncStats,
401        #[cfg(feature = "ast")] ast_parser: &mut crate::ast::CodeAstParser,
402    ) -> Result<()> {
403        if !dir.exists() {
404            return Ok(());
405        }
406
407        let entries = std::fs::read_dir(dir)?;
408        for entry in entries {
409            let entry = entry?;
410            let path = entry.path();
411
412            if path.is_dir() {
413                if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
414                    if self.ignore.skip_dir_name(name) {
415                        continue;
416                    }
417                }
418                let rel = rel_path_from_workspace(&self.workspace, &path);
419                let rel_str = rel.to_string_lossy().replace('\\', "/");
420                if self.ignore.is_ignored(&rel_str, true) {
421                    continue;
422                }
423                self.walk_and_index(
424                    &path,
425                    stats,
426                    #[cfg(feature = "ast")]
427                    ast_parser,
428                )?;
429            } else {
430                let rel = rel_path_from_workspace(&self.workspace, &path);
431                let rel_str = rel.to_string_lossy().replace('\\', "/");
432                if self.ignore.is_ignored(&rel_str, false) {
433                    continue;
434                }
435                let ext = path.extension().and_then(|e| e.to_str());
436                let result = match ext {
437                    Some("md") => self.index_markdown_file(&path, stats),
438                    #[cfg(feature = "obsidian")]
439                    Some("canvas") => self.index_canvas_file(&path, stats),
440                    #[cfg(feature = "ast")]
441                    Some("rs") => self.index_rust_file(&path, ast_parser, stats),
442                    _ => Ok(()),
443                };
444                if let Err(e) = result {
445                    // Never abort a full-workspace sync for one bad file.
446                    stats.file_errors += 1;
447                    eprintln!(
448                        "rustbrain: skip {} ({e})",
449                        path.display()
450                    );
451                }
452            }
453        }
454        Ok(())
455    }
456
457    #[cfg(feature = "ast")]
458    fn index_rust_file(
459        &self,
460        path: &Path,
461        ast_parser: &mut crate::ast::CodeAstParser,
462        stats: &mut SyncStats,
463    ) -> Result<()> {
464        let rel = rel_path_from_workspace(&self.workspace, path);
465        let rel_str = rel.to_string_lossy().replace('\\', "/");
466        if self.ignore.is_ignored(&rel_str, false) {
467            return Ok(());
468        }
469        let crate_name = infer_crate_name(&self.workspace, path);
470        let source = std::fs::read_to_string(path)?;
471        let anchors = ast_parser
472            .parse_symbols(&crate_name, &rel_str, &source)
473            .map_err(|e| BrainError::Ast(e.to_string()))?;
474
475        for anchor in &anchors {
476            self.db.insert_symbol_anchor(anchor)?;
477
478            let node_id = crate::symbols::symbol_node_id(
479                &anchor.crate_name,
480                &anchor.module_path,
481                &anchor.symbol_name,
482            );
483            // Include doc comments in the hash so WikiLink edits in /// re-index.
484            let sig = format!(
485                "{}::{}::{}@{}-{}#{}",
486                anchor.crate_name,
487                anchor.module_path,
488                anchor.symbol_name,
489                anchor.start_line,
490                anchor.end_line,
491                anchor.doc_comment.as_deref().unwrap_or("")
492            );
493            let chash = crate::id::content_hash(sig.as_bytes());
494            let unchanged = self
495                .db
496                .get_content_hash(&node_id)?
497                .map(|existing| existing == chash)
498                .unwrap_or(false);
499
500            if !unchanged {
501                let now = Utc::now().timestamp();
502                let node = Node {
503                    id: node_id.clone(),
504                    node_type: NodeType::Symbol,
505                    title: anchor.symbol_name.clone(),
506                    file_path: Some(anchor.file_path.clone()),
507                    symbol_hash: Some(anchor.symbol_hash),
508                    summary: anchor.doc_comment.clone(),
509                    content_hash: Some(chash),
510                    created_at: now,
511                    updated_at: now,
512                };
513                self.db.insert_node(&node)?;
514
515                // Aliases for resolution: bare name, Type::method, full path.
516                let mut aliases = vec![anchor.symbol_name.clone()];
517                if let Some((_, method)) = anchor.symbol_name.split_once("::") {
518                    aliases.push(method.to_string());
519                }
520                aliases.push(format!(
521                    "{}::{}::{}",
522                    anchor.crate_name, anchor.module_path, anchor.symbol_name
523                ));
524                self.db.replace_node_aliases(&node_id, &aliases)?;
525
526                let fts_body = format!(
527                    "{} {} {} {}",
528                    anchor.symbol_name,
529                    anchor.module_path,
530                    anchor.crate_name,
531                    anchor.doc_comment.as_deref().unwrap_or("")
532                );
533                self.db
534                    .index_fts(&node_id, &anchor.symbol_name, &fts_body, "symbol")?;
535
536                stats.nodes_upserted += 1;
537            } else {
538                stats.nodes_skipped_unchanged += 1;
539            }
540
541            // Always (re)link rustdoc WikiLinks → notes so new notes resolve later.
542            self.link_symbol_doc_wikis(&node_id, anchor.doc_comment.as_deref(), stats)?;
543
544            stats.symbol_anchors += 1;
545        }
546        stats.rust_files += 1;
547        Ok(())
548    }
549
550    /// Parse `[[WikiLinks]]` in rustdoc (`///` / `//!` / `/**`) and edge symbol → note.
551    ///
552    /// Relation type: `doc_links` (code documents/references a brain node).
553    /// Unresolved targets become pending links (resolved on later syncs).
554    #[cfg(feature = "ast")]
555    fn link_symbol_doc_wikis(
556        &self,
557        symbol_node_id: &str,
558        doc_comment: Option<&str>,
559        stats: &mut SyncStats,
560    ) -> Result<()> {
561        let now = Utc::now().timestamp();
562        let doc_plain = doc_comment.map(strip_rustdoc_prefixes).unwrap_or_default();
563
564        self.db.with_transaction(|conn| {
565            self.db
566                .clear_edges_from_on(conn, symbol_node_id, "doc_links")?;
567            // Drop only pending rows for this symbol that were doc_links (full clear is ok:
568            // symbols do not use other pending kinds).
569            self.db.clear_pending_links_for_on(conn, symbol_node_id)?;
570
571            if doc_plain.trim().is_empty() {
572                return Ok(());
573            }
574
575            // WikiLink extraction lives under the `obsidian` feature (default on).
576            #[cfg(feature = "obsidian")]
577            {
578                let wikis = crate::obsidian::extract_wikilinks(&doc_plain);
579                for w in wikis {
580                    let target = w.target_node.trim();
581                    if target.is_empty() {
582                        continue;
583                    }
584                    // Skip pure symbol: refs in docs — notes own note→code anchors.
585                    if target.starts_with("symbol:") {
586                        continue;
587                    }
588                    let resolved = resolve_against_conn(conn, target)?;
589                    if let Some(target_id) = resolved {
590                        if target_id == symbol_node_id {
591                            continue;
592                        }
593                        let edge = Edge {
594                            source_id: symbol_node_id.to_string(),
595                            target_id,
596                            relation_type: "doc_links".into(),
597                            weight: 1.0,
598                            decay_rate: 0.0,
599                            created_at: now,
600                        };
601                        self.db.insert_edge_on(conn, &edge)?;
602                        stats.edges_created += 1;
603                    } else {
604                        self.db.insert_pending_link_on(
605                            conn,
606                            symbol_node_id,
607                            &format!("[[{target}]]"),
608                            "doc_links",
609                            now,
610                        )?;
611                        stats.edges_pending += 1;
612                    }
613                }
614            }
615            Ok(())
616        })
617    }
618}
619
620/// Normalize `///` / `//!` / block-doc lines into plain text for WikiLink extraction.
621#[cfg(feature = "ast")]
622fn strip_rustdoc_prefixes(doc: &str) -> String {
623    let mut out = String::new();
624    for line in doc.lines() {
625        let t = line.trim();
626        let body = if let Some(rest) = t.strip_prefix("///") {
627            rest.strip_prefix(' ').unwrap_or(rest)
628        } else if let Some(rest) = t.strip_prefix("//!") {
629            rest.strip_prefix(' ').unwrap_or(rest)
630        } else if t.starts_with("/**") || t.starts_with("*/") {
631            continue;
632        } else if let Some(rest) = t.strip_prefix('*') {
633            rest.strip_prefix(' ').unwrap_or(rest)
634        } else {
635            t
636        };
637        out.push_str(body);
638        out.push('\n');
639    }
640    out
641}
642
643fn rustbrainignore_requests_gitignore(workspace: &Path) -> bool {
644    let path = workspace.join(".rustbrainignore");
645    let Ok(text) = std::fs::read_to_string(path) else {
646        return false;
647    };
648    text.lines().any(|l| {
649        let t = l.trim().to_ascii_lowercase();
650        t == "# rustbrain: import-gitignore"
651            || t == "#!import-gitignore"
652            || t.contains("rustbrain: import-gitignore")
653    })
654}
655
656fn first_substantive_line(body: &str) -> String {
657    for line in body.lines() {
658        let t = line.trim();
659        if t.is_empty() {
660            continue;
661        }
662        // Skip pure heading markers only lines handled below
663        let cleaned = t.trim_start_matches('#').trim();
664        if !cleaned.is_empty() {
665            return cleaned.to_string();
666        }
667    }
668    String::new()
669}
670
671fn resolve_title(
672    #[cfg(feature = "obsidian")] frontmatter: Option<&crate::obsidian::Frontmatter>,
673    #[cfg(not(feature = "obsidian"))] frontmatter: Option<&()>,
674    body: &str,
675    rel: &Path,
676) -> String {
677    #[cfg(feature = "obsidian")]
678    if let Some(fm) = frontmatter {
679        if let Some(title) = fm.extra.get("title").and_then(|v| v.as_str()) {
680            let t = title.trim();
681            if !t.is_empty() {
682                return t.to_string();
683            }
684        }
685    }
686    #[cfg(not(feature = "obsidian"))]
687    let _ = frontmatter;
688
689    // First H1
690    for line in body.lines() {
691        let t = line.trim();
692        if let Some(rest) = t.strip_prefix("# ") {
693            let t = rest.trim();
694            if !t.is_empty() {
695                return t.to_string();
696            }
697        }
698    }
699
700    rel.file_stem()
701        .and_then(|s| s.to_str())
702        .unwrap_or("Untitled")
703        .to_string()
704}
705
706#[cfg(feature = "ast")]
707fn infer_crate_name(workspace: &Path, file: &Path) -> String {
708    // Walk up looking for Cargo.toml
709    let mut cur = file.parent();
710    while let Some(dir) = cur {
711        let cargo = dir.join("Cargo.toml");
712        if cargo.exists() {
713            if let Ok(text) = std::fs::read_to_string(&cargo) {
714                if let Some(name) = parse_cargo_package_name(&text) {
715                    return name;
716                }
717            }
718            if let Some(n) = dir.file_name().and_then(|n| n.to_str()) {
719                return n.to_string();
720            }
721        }
722        if dir == workspace {
723            break;
724        }
725        cur = dir.parent();
726    }
727    workspace
728        .file_name()
729        .and_then(|n| n.to_str())
730        .unwrap_or("workspace")
731        .to_string()
732}
733
734#[cfg(feature = "ast")]
735fn parse_cargo_package_name(toml: &str) -> Option<String> {
736    let mut in_package = false;
737    for line in toml.lines() {
738        let t = line.trim();
739        if t.starts_with('[') {
740            in_package = t == "[package]";
741            continue;
742        }
743        if in_package {
744            if let Some(rest) = t.strip_prefix("name") {
745                let rest = rest.trim().trim_start_matches('=').trim();
746                let name = rest.trim_matches('"').trim_matches('\'').to_string();
747                if !name.is_empty() {
748                    return Some(name);
749                }
750            }
751        }
752    }
753    None
754}
755
756/// Resolve `symbol:…` path against symbol nodes / aliases.
757fn resolve_symbol_against_conn(
758    conn: &rusqlite::Connection,
759    raw_path: &str,
760) -> Result<Option<String>> {
761    let Some(sym) = crate::symbols::parse_symbol_path(raw_path) else {
762        return Ok(None);
763    };
764
765    // Collect all symbol node ids (type=symbol).
766    let mut stmt = conn.prepare("SELECT id FROM nodes WHERE node_type = 'symbol'")?;
767    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
768    let mut ids = std::collections::HashSet::new();
769    for r in rows {
770        ids.insert(r?);
771    }
772
773    if let Some(id) = crate::symbols::resolve_symbol_ref(&sym, &ids) {
774        return Ok(Some(id));
775    }
776
777    // Fall back to alias / title resolution for the bare name.
778    resolve_against_conn(conn, &sym.symbol_name)
779}
780
781/// Resolve a link target using the live connection (aliases + ids + titles).
782fn resolve_against_conn(conn: &rusqlite::Connection, raw: &str) -> Result<Option<String>> {
783    let key = raw.trim().to_lowercase();
784    if key.is_empty() {
785        return Ok(None);
786    }
787
788    // Exact id
789    let exact: Option<String> = conn
790        .query_row("SELECT id FROM nodes WHERE id = ?1", [&key], |row| row.get(0))
791        .optional_compat()?;
792    if exact.is_some() {
793        return Ok(exact);
794    }
795
796    // Alias
797    let by_alias: Option<String> = conn
798        .query_row(
799            "SELECT node_id FROM node_aliases WHERE alias = ?1",
800            [&key],
801            |row| row.get(0),
802        )
803        .optional_compat()?;
804    if by_alias.is_some() {
805        return Ok(by_alias);
806    }
807
808    // Title (case-insensitive) — may be ambiguous; take if unique
809    let mut stmt = conn.prepare("SELECT id FROM nodes WHERE lower(title) = ?1")?;
810    let rows = stmt.query_map([&key], |row| row.get::<_, String>(0))?;
811    let mut hits = Vec::new();
812    for r in rows {
813        hits.push(r?);
814    }
815    if hits.len() == 1 {
816        return Ok(Some(hits.remove(0)));
817    }
818
819    // Unique suffix match
820    let mut stmt = conn.prepare("SELECT id FROM nodes")?;
821    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
822    let mut suffix_hits = Vec::new();
823    for r in rows {
824        let id = r?;
825        if id == key || id.ends_with(&format!("/{key}")) || id.rsplit('/').next() == Some(key.as_str())
826        {
827            suffix_hits.push(id);
828        }
829    }
830    suffix_hits.sort();
831    suffix_hits.dedup();
832    if suffix_hits.len() == 1 {
833        return Ok(Some(suffix_hits.remove(0)));
834    }
835
836    Ok(None)
837}
838
839/// Local trait shim so we can call `.optional()` style without importing OptionalExtension in every use.
840trait OptionalCompat<T> {
841    fn optional_compat(self) -> Result<Option<T>>;
842}
843
844impl<T> OptionalCompat<T> for std::result::Result<T, rusqlite::Error> {
845    fn optional_compat(self) -> Result<Option<T>> {
846        match self {
847            Ok(v) => Ok(Some(v)),
848            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
849            Err(e) => Err(BrainError::from(e)),
850        }
851    }
852}
853
854#[cfg(not(feature = "obsidian"))]
855struct RawLink {
856    target_node: String,
857}
858
859#[cfg(test)]
860mod tests {
861    use super::*;
862    use tempfile::tempdir;
863
864    #[test]
865    fn index_workspace_and_fts_idempotent() {
866        let dir = tempdir().unwrap();
867        let docs = dir.path().join("docs");
868        std::fs::create_dir_all(&docs).unwrap();
869        std::fs::write(
870            docs.join("raft.md"),
871            "---\ntags: [raft]\nnode_type: concept\naliases: [Raft Consensus]\n---\n# Raft Consensus\nRelates to [[log-compaction]].\n",
872        )
873        .unwrap();
874        std::fs::write(
875            docs.join("log-compaction.md"),
876            "---\ntags: [log]\nnode_type: concept\n---\n# Log Compaction\nSee [[raft]].\n",
877        )
878        .unwrap();
879
880        let brain = dir.path().join(".brain");
881        std::fs::create_dir_all(&brain).unwrap();
882        let db = Database::open(brain.join("db.sqlite")).unwrap();
883        let indexer = WorkspaceIndexer::new(db, dir.path());
884
885        let s1 = indexer.index_workspace().unwrap();
886        assert_eq!(s1.markdown_files, 2);
887        assert!(s1.edges_created >= 2);
888        let fts1 = indexer.database().count_fts_rows().unwrap();
889        assert_eq!(fts1, 2);
890
891        // Second sync should skip unchanged content and keep FTS row count stable.
892        // Touch is not done — content hash matches → nodes_skipped.
893        let s2 = indexer.index_workspace().unwrap();
894        assert_eq!(s2.nodes_skipped_unchanged, 2);
895        assert_eq!(indexer.database().count_fts_rows().unwrap(), 2);
896
897        // Force reindex by changing content
898        std::fs::write(
899            docs.join("raft.md"),
900            "---\ntags: [raft]\nnode_type: concept\n---\n# Raft Consensus\nUpdated body [[log-compaction]].\n",
901        )
902        .unwrap();
903        let s3 = indexer.index_workspace().unwrap();
904        assert_eq!(s3.nodes_upserted, 1);
905        assert_eq!(indexer.database().count_fts_rows().unwrap(), 2);
906
907        let hits = indexer.database().search_fts("raft").unwrap();
908        assert!(!hits.is_empty());
909        assert!(hits.iter().any(|n| n.id.contains("raft")));
910    }
911
912    #[test]
913    fn strip_rustdoc_prefixes_keeps_wikilink() {
914        let raw = "/// Primary engine. See [[use-sqlite]].\n/// Second line.";
915        let plain = strip_rustdoc_prefixes(raw);
916        assert!(plain.contains("[[use-sqlite]]"));
917        assert!(!plain.contains("///"));
918    }
919
920    #[cfg(all(feature = "ast", feature = "obsidian"))]
921    #[test]
922    fn rustdoc_wikilink_creates_doc_links_edge() {
923        let dir = tempdir().unwrap();
924        let docs = dir.path().join("docs/adr");
925        let src = dir.path().join("src");
926        std::fs::create_dir_all(&docs).unwrap();
927        std::fs::create_dir_all(&src).unwrap();
928        std::fs::write(
929            dir.path().join("Cargo.toml"),
930            "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
931        )
932        .unwrap();
933        std::fs::write(
934            docs.join("use-sqlite.md"),
935            "---\nnode_type: adr\naliases: [use-sqlite]\n---\n# Use SQLite\n\nLocal store.\n",
936        )
937        .unwrap();
938        std::fs::write(
939            src.join("lib.rs"),
940            r#"/// Primary engine. See [[use-sqlite]] and [[docs/adr/use-sqlite]].
941pub struct StorageEngine;
942
943impl StorageEngine {
944    /// Open the store.
945    pub fn open() {}
946}
947"#,
948        )
949        .unwrap();
950
951        let brain = dir.path().join(".brain");
952        std::fs::create_dir_all(&brain).unwrap();
953        let db = Database::open(brain.join("db.sqlite")).unwrap();
954        let indexer = WorkspaceIndexer::new(db, dir.path());
955        // Two syncs: notes first pass may race walk order; second resolves pending.
956        let _ = indexer.index_workspace().unwrap();
957        let s = indexer.index_workspace().unwrap();
958        let _ = s;
959
960        let edges = indexer.database().get_all_edges().unwrap();
961        let doc_links: Vec<_> = edges
962            .iter()
963            .filter(|e| e.relation_type == "doc_links")
964            .collect();
965        assert!(
966            !doc_links.is_empty(),
967            "expected doc_links from rustdoc WikiLink, edges={edges:?}"
968        );
969        assert!(doc_links.iter().any(|e| e.source_id.contains("StorageEngine")));
970        assert!(doc_links.iter().any(|e| e.target_id.contains("use-sqlite")
971            || e.target_id.contains("docs/adr/use-sqlite")));
972    }
973
974    #[test]
975    fn pending_link_then_resolve() {
976        let dir = tempdir().unwrap();
977        let docs = dir.path().join("docs");
978        std::fs::create_dir_all(&docs).unwrap();
979        std::fs::write(
980            docs.join("a.md"),
981            "---\nnode_type: concept\n---\n# A\nLink to [[b]].\n",
982        )
983        .unwrap();
984
985        let brain = dir.path().join(".brain");
986        std::fs::create_dir_all(&brain).unwrap();
987        let db = Database::open(brain.join("db.sqlite")).unwrap();
988        let indexer = WorkspaceIndexer::new(db, dir.path());
989        let s1 = indexer.index_workspace().unwrap();
990        assert!(s1.edges_pending >= 1 || indexer.database().count_pending_links().unwrap() >= 1);
991
992        std::fs::write(
993            docs.join("b.md"),
994            "---\nnode_type: concept\n---\n# B\nBack.\n",
995        )
996        .unwrap();
997        let s2 = indexer.index_workspace().unwrap();
998        assert!(s2.edges_created >= 1 || indexer.database().count_edges().unwrap() >= 1);
999    }
1000
1001    #[test]
1002    fn root_changelog_indexes_as_stable_hub() {
1003        let dir = tempdir().unwrap();
1004        std::fs::write(
1005            dir.path().join("CHANGELOG.md"),
1006            "# Changelog\n\n## [0.3.15] - 2026-07-31\n\n### Added\n- changelog hub\n\n## [0.3.14] - 2026-07-30\n\n### Fixed\n- prior\n",
1007        )
1008        .unwrap();
1009        std::fs::write(dir.path().join("README.md"), "# Demo\n\nA crate.\n").unwrap();
1010        let brain = dir.path().join(".brain");
1011        std::fs::create_dir_all(&brain).unwrap();
1012        let db = Database::open(brain.join("db.sqlite")).unwrap();
1013        let indexer = WorkspaceIndexer::new(db, dir.path());
1014        indexer.index_workspace().unwrap();
1015
1016        let node = indexer
1017            .database()
1018            .get_node(crate::hubs::HUB_CHANGELOG)
1019            .unwrap()
1020            .expect("changelog hub");
1021        assert_eq!(node.node_type, NodeType::Reference);
1022        assert_eq!(node.file_path.as_deref(), Some("CHANGELOG.md"));
1023        assert!(
1024            node.summary
1025                .as_deref()
1026                .is_some_and(|s| s.contains("0.3.15")),
1027            "summary={:?}",
1028            node.summary
1029        );
1030        // Version alias for FTS resolution
1031        let hits = indexer
1032            .database()
1033            .search_ranked("0.3.15", &crate::query::QueryOptions::default())
1034            .unwrap();
1035        assert!(
1036            hits.iter().any(|h| h.node.id == crate::hubs::HUB_CHANGELOG),
1037            "hits={:?}",
1038            hits.iter().map(|h| &h.node.id).collect::<Vec<_>>()
1039        );
1040    }
1041}