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    /// Scope assignment (MainBrain / SubBrain).
26    scopes: crate::scopes::WorkspaceManifest,
27}
28
29impl WorkspaceIndexer {
30    /// Create an indexer for `workspace` writing into `db`.
31    ///
32    /// Loads `.rustbrainignore` (if present) plus built-in skips. When the env
33    /// var `RUSTBRAIN_IMPORT_GITIGNORE=1` is set, also merges root `.gitignore`.
34    pub fn new(db: Database, workspace: impl Into<PathBuf>) -> Self {
35        let workspace = workspace.into();
36        let import_gi = std::env::var_os("RUSTBRAIN_IMPORT_GITIGNORE")
37            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
38            .unwrap_or(false);
39        // Also auto-import gitignore when .rustbrainignore asks for it.
40        let import_gi = import_gi || rustbrainignore_requests_gitignore(&workspace);
41        let ignore = IgnoreSet::load(&workspace, import_gi).unwrap_or_default();
42        let scopes = crate::scopes::load_manifest(&workspace).unwrap_or_else(|_| {
43            crate::scopes::WorkspaceManifest::single(&workspace)
44        });
45        Self {
46            db,
47            workspace,
48            ignore,
49            scopes,
50        }
51    }
52
53    fn owner_scope(&self, rel_str: &str) -> String {
54        self.scopes.resolve_scope(rel_str)
55    }
56
57    /// Borrow the database.
58    pub fn database(&self) -> &Database {
59        &self.db
60    }
61
62    /// Consume the indexer and return the database.
63    pub fn into_database(self) -> Database {
64        self.db
65    }
66
67    /// Index all supported files under the workspace and bake the mmap cache.
68    pub fn index_workspace(&self) -> Result<SyncStats> {
69        let mut stats = SyncStats::default();
70
71        #[cfg(feature = "ast")]
72        let mut ast_parser = crate::ast::CodeAstParser::new_rust()
73            .map_err(|e| BrainError::Ast(e.to_string()))?;
74
75        self.walk_and_index(
76            &self.workspace,
77            &mut stats,
78            #[cfg(feature = "ast")]
79            &mut ast_parser,
80        )?;
81
82        // Phase 2: resolve any pending WikiLinks now that all nodes exist.
83        let (resolved, still) = self.db.resolve_pending_links()?;
84        stats.edges_created += resolved;
85        stats.edges_pending = still;
86
87        // Bake CSR mmap if feature enabled and .brain dir exists.
88        let brain_dir = self.workspace.join(".brain");
89        if brain_dir.exists() {
90            #[cfg(feature = "mmap")]
91            {
92                let mmap_path = brain_dir.join("graph.mmap");
93                self.compile_mmap(&mmap_path)?;
94                stats.mmap_written = true;
95            }
96        }
97
98        Ok(stats)
99    }
100
101    /// Bake current database into zero-copy CSR mmap (atomic replace).
102    pub fn compile_mmap(&self, output_path: &Path) -> Result<()> {
103        #[cfg(feature = "mmap")]
104        {
105            let node_ids = self.db.get_all_node_ids()?;
106            let edges = self.db.get_csr_edges()?;
107            // Phase A: no embeddings yet — vector_dim = 0.
108            crate::mmap::CsrCompiler::compile(output_path, &node_ids, &edges, None, 0)?;
109            Ok(())
110        }
111        #[cfg(not(feature = "mmap"))]
112        {
113            let _ = output_path;
114            Err(BrainError::FeatureDisabled("mmap"))
115        }
116    }
117
118    /// Index a single Markdown note (transactional, content-hash aware).
119    pub fn index_markdown_file(&self, file_path: &Path, stats: &mut SyncStats) -> Result<()> {
120        if !file_path.exists() || file_path.extension().and_then(|e| e.to_str()) != Some("md") {
121            return Ok(());
122        }
123
124        let raw_bytes = std::fs::read(file_path)?;
125        let hash = content_hash(&raw_bytes);
126        let content = String::from_utf8_lossy(&raw_bytes);
127
128        let rel = rel_path_from_workspace(&self.workspace, file_path);
129        let rel_str = rel.to_string_lossy().replace('\\', "/");
130        if self.ignore.is_ignored(&rel_str, false) {
131            return Ok(());
132        }
133
134        let hub = crate::hubs::detect_project_hub(&rel);
135        let node_id = hub
136            .map(|h| h.node_id().to_string())
137            .unwrap_or_else(|| node_id_from_rel_path(&rel));
138
139        let mut scope = self.owner_scope(&rel_str);
140        // Frontmatter `scope:` may override path ownership when multi-brain and id is known.
141        if self.scopes.is_multi() {
142            #[cfg(feature = "obsidian")]
143            {
144                let (fm_early, _) = crate::obsidian::parse_frontmatter(&content);
145                if let Some(fm) = fm_early.as_ref() {
146                    if let Some(s) = fm
147                        .extra
148                        .get("scope")
149                        .and_then(|v| v.as_str())
150                        .map(|s| s.trim().to_ascii_lowercase().replace('_', "-"))
151                        .filter(|s| !s.is_empty())
152                    {
153                        if s == self.scopes.main_id
154                            || self.scopes.find_scope(&s).is_some()
155                            || s == crate::scopes::MAIN_SCOPE
156                        {
157                            scope = if s == crate::scopes::MAIN_SCOPE {
158                                self.scopes.main_id.clone()
159                            } else {
160                                self.scopes
161                                    .find_scope(&s)
162                                    .map(|sc| sc.id.clone())
163                                    .unwrap_or(s)
164                            };
165                        }
166                    }
167                }
168            }
169        }
170        if let Some(existing) = self.db.get_content_hash(&node_id)? {
171            if existing == hash {
172                // Keep scope aligned when multi-brain roots change without content edits.
173                let _ = self.db.set_node_scope(&node_id, &scope);
174                stats.nodes_skipped_unchanged += 1;
175                return Ok(());
176            }
177        }
178
179        #[cfg(feature = "obsidian")]
180        let (frontmatter, body) = crate::obsidian::parse_frontmatter(&content);
181        #[cfg(not(feature = "obsidian"))]
182        let (frontmatter, body): (Option<()>, &str) = (None, content.as_ref());
183
184        let title = resolve_title(
185            #[cfg(feature = "obsidian")]
186            frontmatter.as_ref(),
187            #[cfg(not(feature = "obsidian"))]
188            None,
189            body,
190            &rel,
191        );
192
193        let node_type = {
194            #[cfg(feature = "obsidian")]
195            {
196                let parsed = frontmatter
197                    .as_ref()
198                    .and_then(|fm| fm.node_type.as_deref())
199                    .and_then(NodeType::parse);
200                match hub {
201                    // Root README is the project front door; default to Goal unless author overrides.
202                    Some(crate::hubs::ProjectHub::Readme) => parsed.unwrap_or(NodeType::Goal),
203                    // Keep a Changelog → first-class Changelog type.
204                    Some(crate::hubs::ProjectHub::Changelog) => {
205                        parsed.unwrap_or(NodeType::Changelog)
206                    }
207                    // ROADMAP / BACKLOG → Plan (roadmaps, tasklists, todos).
208                    Some(
209                        crate::hubs::ProjectHub::Roadmap | crate::hubs::ProjectHub::Backlog,
210                    ) => parsed.unwrap_or(NodeType::Plan),
211                    None => parsed.unwrap_or(NodeType::Concept),
212                }
213            }
214            #[cfg(not(feature = "obsidian"))]
215            {
216                match hub {
217                    Some(crate::hubs::ProjectHub::Readme) => NodeType::Goal,
218                    Some(crate::hubs::ProjectHub::Changelog) => NodeType::Changelog,
219                    Some(
220                        crate::hubs::ProjectHub::Roadmap | crate::hubs::ProjectHub::Backlog,
221                    ) => NodeType::Plan,
222                    None => NodeType::Concept,
223                }
224            }
225        };
226
227        let now = Utc::now().timestamp();
228        let base_summary = if hub == Some(crate::hubs::ProjectHub::Changelog) {
229            crate::hubs::changelog_latest_heading(body)
230                .unwrap_or_else(|| first_substantive_line(body))
231        } else {
232            first_substantive_line(body)
233        };
234
235        let mut tags: Vec<String> = {
236            #[cfg(feature = "obsidian")]
237            {
238                frontmatter
239                    .as_ref()
240                    .map(|fm| fm.tags.clone())
241                    .unwrap_or_default()
242            }
243            #[cfg(not(feature = "obsidian"))]
244            {
245                Vec::new()
246            }
247        };
248
249        let aliases: Vec<String> = {
250            #[cfg(feature = "obsidian")]
251            {
252                frontmatter
253                    .as_ref()
254                    .map(|fm| fm.aliases.clone())
255                    .unwrap_or_default()
256            }
257            #[cfg(not(feature = "obsidian"))]
258            {
259                Vec::new()
260            }
261        };
262
263        // Optional frontmatter status/state (plan lifecycle).
264        let fm_status: Option<String> = {
265            #[cfg(feature = "obsidian")]
266            {
267                frontmatter.as_ref().and_then(|fm| {
268                    fm.extra
269                        .get("status")
270                        .or_else(|| fm.extra.get("state"))
271                        .and_then(|v| v.as_str())
272                        .map(|s| s.to_string())
273                })
274            }
275            #[cfg(not(feature = "obsidian"))]
276            {
277                None
278            }
279        };
280
281        // Plan densification: status tokens for FTS + compact summary (optional; no-op if no signal).
282        let (fts_body, summary) = if node_type == NodeType::Plan
283            || hub == Some(crate::hubs::ProjectHub::Roadmap)
284            || hub == Some(crate::hubs::ProjectHub::Backlog)
285        {
286            crate::plan_status::enrich_plan_index_fields(
287                body,
288                fm_status.as_deref(),
289                &base_summary,
290            )
291        } else {
292            (body.to_string(), base_summary)
293        };
294
295        // Collect wikilinks before the transaction writes.
296        #[cfg(feature = "obsidian")]
297        let wikilinks = crate::obsidian::extract_wikilinks(body);
298        #[cfg(not(feature = "obsidian"))]
299        let wikilinks: Vec<RawLink> = Vec::new();
300
301        // Architecture plan: symbol:crate::module::Name anchors in notes.
302        let symbol_refs = crate::symbols::extract_symbol_refs(body);
303
304        let node = Node {
305            id: node_id.clone(),
306            node_type,
307            title: title.clone(),
308            file_path: Some(rel_str.clone()),
309            symbol_hash: None,
310            summary: Some(summary),
311            content_hash: Some(hash),
312            scope: scope.clone(),
313            created_at: now,
314            updated_at: now,
315        };
316
317        // Densify scope token for FTS when multi-brain is on.
318        if self.scopes.is_multi() {
319            let token = format!("scope:{scope}");
320            if !tags.iter().any(|t| t == &token) {
321                tags.push(token);
322            }
323        }
324        let tags_str = tags.join(" ");
325        let mut links_for_tx: Vec<(String, String)> = {
326            #[cfg(feature = "obsidian")]
327            {
328                wikilinks
329                    .iter()
330                    .map(|l| {
331                        // WikiLinks of form [[symbol:…]] become anchors, not relates_to.
332                        if let Some(rest) = l.target_node.strip_prefix("symbol:") {
333                            (format!("symbol:{rest}"), "anchors".to_string())
334                        } else {
335                            (l.target_node.clone(), "relates_to".to_string())
336                        }
337                    })
338                    .collect()
339            }
340            #[cfg(not(feature = "obsidian"))]
341            {
342                let _ = &wikilinks;
343                Vec::new()
344            }
345        };
346        for sref in &symbol_refs {
347            links_for_tx.push((format!("symbol:{}", sref.raw), "anchors".to_string()));
348        }
349
350        // Also register file stem and title as aliases for link resolution.
351        let mut extra_aliases = aliases.clone();
352        if let Some(stem) = Path::new(&node.file_path.as_deref().unwrap_or(""))
353            .file_stem()
354            .and_then(|s| s.to_str())
355        {
356            extra_aliases.push(stem.to_string());
357        }
358        extra_aliases.push(title.clone());
359        if let Some(h) = hub {
360            for a in h.aliases() {
361                extra_aliases.push((*a).to_string());
362            }
363            if h == crate::hubs::ProjectHub::Readme {
364                if let Some(name) = self.workspace.file_name().and_then(|n| n.to_str()) {
365                    extra_aliases.push(name.to_string());
366                }
367            }
368            if h == crate::hubs::ProjectHub::Changelog {
369                // Recent SemVer labels from Keep a Changelog headings → FTS aliases.
370                for v in crate::hubs::changelog_version_aliases(body, 8) {
371                    extra_aliases.push(v);
372                }
373            }
374            // Plan hub aliases include overall status for resolution (e.g. "in_progress").
375            if matches!(
376                h,
377                crate::hubs::ProjectHub::Roadmap | crate::hubs::ProjectHub::Backlog
378            ) {
379                if let Some(st) = fm_status.as_deref().and_then(crate::plan_status::PlanStatus::parse)
380                {
381                    extra_aliases.push(st.as_str().to_string());
382                }
383            }
384        }
385        if node_type == NodeType::Plan {
386            if let Some(st) = fm_status.as_deref().and_then(crate::plan_status::PlanStatus::parse) {
387                extra_aliases.push(st.as_str().to_string());
388                extra_aliases.push(format!("status:{}", st.as_str()));
389            }
390        }
391
392        self.db.with_transaction(|conn| {
393            self.db.insert_node_on(conn, &node)?;
394            self.db.replace_node_tags_on(conn, &node_id, &tags)?;
395            self.db
396                .replace_node_aliases_on(conn, &node_id, &extra_aliases)?;
397            self.db
398                .index_fts_on(conn, &node_id, &title, &fts_body, &tags_str)?;
399
400            // Clear prior outbound edges + pending for this source (idempotent).
401            self.db
402                .clear_edges_from_on(conn, &node_id, "relates_to")?;
403            self.db.clear_edges_from_on(conn, &node_id, "anchors")?;
404            self.db.clear_pending_links_for_on(conn, &node_id)?;
405
406            for (raw_target, rel_type) in &links_for_tx {
407                let resolved = if let Some(sym_path) = raw_target.strip_prefix("symbol:") {
408                    resolve_symbol_against_conn(conn, sym_path)?
409                } else {
410                    resolve_against_conn(conn, raw_target)?
411                };
412
413                if let Some(target_id) = resolved {
414                    let edge = Edge {
415                        source_id: node_id.clone(),
416                        target_id,
417                        relation_type: rel_type.clone(),
418                        weight: 1.0,
419                        decay_rate: 0.0,
420                        created_at: now,
421                    };
422                    self.db.insert_edge_on(conn, &edge)?;
423                    stats.edges_created += 1;
424                } else {
425                    self.db.insert_pending_link_on(
426                        conn,
427                        &node_id,
428                        raw_target,
429                        rel_type,
430                        now,
431                    )?;
432                    stats.edges_pending += 1;
433                }
434            }
435            Ok(())
436        })?;
437
438        stats.markdown_files += 1;
439        stats.nodes_upserted += 1;
440        Ok(())
441    }
442
443    /// Index an Obsidian Canvas (`.canvas`) file into graph edges.
444    ///
445    /// Resolves file/text node labels to existing note ids when possible;
446    /// otherwise records pending links. Requires the `obsidian` feature.
447    #[cfg(feature = "obsidian")]
448    pub fn index_canvas_file(&self, file_path: &Path, stats: &mut SyncStats) -> Result<()> {
449        if !file_path.exists() || file_path.extension().and_then(|e| e.to_str()) != Some("canvas")
450        {
451            return Ok(());
452        }
453
454        let content = std::fs::read_to_string(file_path)?;
455        let canvas = crate::obsidian::ObsidianCanvas::parse_str(&content)
456            .map_err(|e| BrainError::indexer(e.to_string()))?;
457        let now = Utc::now().timestamp();
458        let relationships = canvas.extract_relationships();
459
460        let (ids, aliases, titles) = self.db.link_resolution_maps()?;
461
462        self.db.with_transaction(|conn| {
463            for (src_raw, dst_raw, rel) in &relationships {
464                let src = resolve_link_target(src_raw, &ids, &aliases, &titles);
465                let dst = resolve_link_target(dst_raw, &ids, &aliases, &titles);
466                match (src, dst) {
467                    (Some(s), Some(d)) => {
468                        let edge = Edge {
469                            source_id: s,
470                            target_id: d,
471                            relation_type: rel.clone(),
472                            weight: 1.0,
473                            decay_rate: 0.0,
474                            created_at: now,
475                        };
476                        self.db.insert_edge_on(conn, &edge)?;
477                        stats.edges_created += 1;
478                    }
479                    (Some(s), None) => {
480                        self.db
481                            .insert_pending_link_on(conn, &s, dst_raw, rel, now)?;
482                        stats.edges_pending += 1;
483                    }
484                    _ => {
485                        // Source unknown — skip with pending if we can map nothing.
486                        stats.edges_pending += 1;
487                    }
488                }
489            }
490            Ok(())
491        })?;
492
493        stats.canvas_files += 1;
494        Ok(())
495    }
496
497    fn walk_and_index(
498        &self,
499        dir: &Path,
500        stats: &mut SyncStats,
501        #[cfg(feature = "ast")] ast_parser: &mut crate::ast::CodeAstParser,
502    ) -> Result<()> {
503        if !dir.exists() {
504            return Ok(());
505        }
506
507        let entries = std::fs::read_dir(dir)?;
508        for entry in entries {
509            let entry = entry?;
510            let path = entry.path();
511
512            if path.is_dir() {
513                if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
514                    if self.ignore.skip_dir_name(name) {
515                        continue;
516                    }
517                }
518                let rel = rel_path_from_workspace(&self.workspace, &path);
519                let rel_str = rel.to_string_lossy().replace('\\', "/");
520                if self.ignore.is_ignored(&rel_str, true) {
521                    continue;
522                }
523                self.walk_and_index(
524                    &path,
525                    stats,
526                    #[cfg(feature = "ast")]
527                    ast_parser,
528                )?;
529            } else {
530                let rel = rel_path_from_workspace(&self.workspace, &path);
531                let rel_str = rel.to_string_lossy().replace('\\', "/");
532                if self.ignore.is_ignored(&rel_str, false) {
533                    continue;
534                }
535                let ext = path.extension().and_then(|e| e.to_str());
536                let result = match ext {
537                    Some("md") => self.index_markdown_file(&path, stats),
538                    #[cfg(feature = "obsidian")]
539                    Some("canvas") => self.index_canvas_file(&path, stats),
540                    #[cfg(feature = "ast")]
541                    Some("rs") => self.index_rust_file(&path, ast_parser, stats),
542                    _ => Ok(()),
543                };
544                if let Err(e) = result {
545                    // Never abort a full-workspace sync for one bad file.
546                    stats.file_errors += 1;
547                    eprintln!(
548                        "rustbrain: skip {} ({e})",
549                        path.display()
550                    );
551                }
552            }
553        }
554        Ok(())
555    }
556
557    #[cfg(feature = "ast")]
558    fn index_rust_file(
559        &self,
560        path: &Path,
561        ast_parser: &mut crate::ast::CodeAstParser,
562        stats: &mut SyncStats,
563    ) -> Result<()> {
564        let rel = rel_path_from_workspace(&self.workspace, path);
565        let rel_str = rel.to_string_lossy().replace('\\', "/");
566        if self.ignore.is_ignored(&rel_str, false) {
567            return Ok(());
568        }
569        let crate_name = infer_crate_name(&self.workspace, path);
570        let source = std::fs::read_to_string(path)?;
571        let anchors = ast_parser
572            .parse_symbols(&crate_name, &rel_str, &source)
573            .map_err(|e| BrainError::Ast(e.to_string()))?;
574
575        for anchor in &anchors {
576            self.db.insert_symbol_anchor(anchor)?;
577
578            let node_id = crate::symbols::symbol_node_id(
579                &anchor.crate_name,
580                &anchor.module_path,
581                &anchor.symbol_name,
582            );
583            // Include doc comments in the hash so WikiLink edits in /// re-index.
584            let sig = format!(
585                "{}::{}::{}@{}-{}#{}",
586                anchor.crate_name,
587                anchor.module_path,
588                anchor.symbol_name,
589                anchor.start_line,
590                anchor.end_line,
591                anchor.doc_comment.as_deref().unwrap_or("")
592            );
593            let chash = crate::id::content_hash(sig.as_bytes());
594            let unchanged = self
595                .db
596                .get_content_hash(&node_id)?
597                .map(|existing| existing == chash)
598                .unwrap_or(false);
599
600            let scope = self.owner_scope(&anchor.file_path);
601            if unchanged {
602                let _ = self.db.set_node_scope(&node_id, &scope);
603            }
604            if !unchanged {
605                let now = Utc::now().timestamp();
606                let node = Node {
607                    id: node_id.clone(),
608                    node_type: NodeType::Symbol,
609                    title: anchor.symbol_name.clone(),
610                    file_path: Some(anchor.file_path.clone()),
611                    symbol_hash: Some(anchor.symbol_hash),
612                    summary: anchor.doc_comment.clone(),
613                    content_hash: Some(chash),
614                    scope: scope.clone(),
615                    created_at: now,
616                    updated_at: now,
617                };
618                self.db.insert_node(&node)?;
619
620                // Aliases for resolution: bare name, Type::method, full path.
621                let mut aliases = vec![anchor.symbol_name.clone()];
622                if let Some((_, method)) = anchor.symbol_name.split_once("::") {
623                    aliases.push(method.to_string());
624                }
625                aliases.push(format!(
626                    "{}::{}::{}",
627                    anchor.crate_name, anchor.module_path, anchor.symbol_name
628                ));
629                self.db.replace_node_aliases(&node_id, &aliases)?;
630
631                let fts_body = format!(
632                    "{} {} {} {}",
633                    anchor.symbol_name,
634                    anchor.module_path,
635                    anchor.crate_name,
636                    anchor.doc_comment.as_deref().unwrap_or("")
637                );
638                self.db
639                    .index_fts(&node_id, &anchor.symbol_name, &fts_body, "symbol")?;
640
641                stats.nodes_upserted += 1;
642            } else {
643                stats.nodes_skipped_unchanged += 1;
644            }
645
646            // Always (re)link rustdoc WikiLinks → notes so new notes resolve later.
647            self.link_symbol_doc_wikis(&node_id, anchor.doc_comment.as_deref(), stats)?;
648
649            stats.symbol_anchors += 1;
650        }
651        stats.rust_files += 1;
652        Ok(())
653    }
654
655    /// Parse `[[WikiLinks]]` in rustdoc (`///` / `//!` / `/**`) and edge symbol → note.
656    ///
657    /// Relation type: `doc_links` (code documents/references a brain node).
658    /// Unresolved targets become pending links (resolved on later syncs).
659    #[cfg(feature = "ast")]
660    fn link_symbol_doc_wikis(
661        &self,
662        symbol_node_id: &str,
663        doc_comment: Option<&str>,
664        stats: &mut SyncStats,
665    ) -> Result<()> {
666        let now = Utc::now().timestamp();
667        let doc_plain = doc_comment.map(strip_rustdoc_prefixes).unwrap_or_default();
668
669        self.db.with_transaction(|conn| {
670            self.db
671                .clear_edges_from_on(conn, symbol_node_id, "doc_links")?;
672            // Drop only pending rows for this symbol that were doc_links (full clear is ok:
673            // symbols do not use other pending kinds).
674            self.db.clear_pending_links_for_on(conn, symbol_node_id)?;
675
676            if doc_plain.trim().is_empty() {
677                return Ok(());
678            }
679
680            // WikiLink extraction lives under the `obsidian` feature (default on).
681            #[cfg(feature = "obsidian")]
682            {
683                let wikis = crate::obsidian::extract_wikilinks(&doc_plain);
684                for w in wikis {
685                    let target = w.target_node.trim();
686                    if target.is_empty() {
687                        continue;
688                    }
689                    // Skip pure symbol: refs in docs — notes own note→code anchors.
690                    if target.starts_with("symbol:") {
691                        continue;
692                    }
693                    let resolved = resolve_against_conn(conn, target)?;
694                    if let Some(target_id) = resolved {
695                        if target_id == symbol_node_id {
696                            continue;
697                        }
698                        let edge = Edge {
699                            source_id: symbol_node_id.to_string(),
700                            target_id,
701                            relation_type: "doc_links".into(),
702                            weight: 1.0,
703                            decay_rate: 0.0,
704                            created_at: now,
705                        };
706                        self.db.insert_edge_on(conn, &edge)?;
707                        stats.edges_created += 1;
708                    } else {
709                        self.db.insert_pending_link_on(
710                            conn,
711                            symbol_node_id,
712                            &format!("[[{target}]]"),
713                            "doc_links",
714                            now,
715                        )?;
716                        stats.edges_pending += 1;
717                    }
718                }
719            }
720            Ok(())
721        })
722    }
723}
724
725/// Normalize `///` / `//!` / block-doc lines into plain text for WikiLink extraction.
726#[cfg(feature = "ast")]
727fn strip_rustdoc_prefixes(doc: &str) -> String {
728    let mut out = String::new();
729    for line in doc.lines() {
730        let t = line.trim();
731        let body = if let Some(rest) = t.strip_prefix("///") {
732            rest.strip_prefix(' ').unwrap_or(rest)
733        } else if let Some(rest) = t.strip_prefix("//!") {
734            rest.strip_prefix(' ').unwrap_or(rest)
735        } else if t.starts_with("/**") || t.starts_with("*/") {
736            continue;
737        } else if let Some(rest) = t.strip_prefix('*') {
738            rest.strip_prefix(' ').unwrap_or(rest)
739        } else {
740            t
741        };
742        out.push_str(body);
743        out.push('\n');
744    }
745    out
746}
747
748fn rustbrainignore_requests_gitignore(workspace: &Path) -> bool {
749    let path = workspace.join(".rustbrainignore");
750    let Ok(text) = std::fs::read_to_string(path) else {
751        return false;
752    };
753    text.lines().any(|l| {
754        let t = l.trim().to_ascii_lowercase();
755        t == "# rustbrain: import-gitignore"
756            || t == "#!import-gitignore"
757            || t.contains("rustbrain: import-gitignore")
758    })
759}
760
761fn first_substantive_line(body: &str) -> String {
762    for line in body.lines() {
763        let t = line.trim();
764        if t.is_empty() {
765            continue;
766        }
767        // Skip pure heading markers only lines handled below
768        let cleaned = t.trim_start_matches('#').trim();
769        if !cleaned.is_empty() {
770            return cleaned.to_string();
771        }
772    }
773    String::new()
774}
775
776fn resolve_title(
777    #[cfg(feature = "obsidian")] frontmatter: Option<&crate::obsidian::Frontmatter>,
778    #[cfg(not(feature = "obsidian"))] frontmatter: Option<&()>,
779    body: &str,
780    rel: &Path,
781) -> String {
782    #[cfg(feature = "obsidian")]
783    if let Some(fm) = frontmatter {
784        if let Some(title) = fm.extra.get("title").and_then(|v| v.as_str()) {
785            let t = title.trim();
786            if !t.is_empty() {
787                return t.to_string();
788            }
789        }
790    }
791    #[cfg(not(feature = "obsidian"))]
792    let _ = frontmatter;
793
794    // First H1
795    for line in body.lines() {
796        let t = line.trim();
797        if let Some(rest) = t.strip_prefix("# ") {
798            let t = rest.trim();
799            if !t.is_empty() {
800                return t.to_string();
801            }
802        }
803    }
804
805    rel.file_stem()
806        .and_then(|s| s.to_str())
807        .unwrap_or("Untitled")
808        .to_string()
809}
810
811#[cfg(feature = "ast")]
812fn infer_crate_name(workspace: &Path, file: &Path) -> String {
813    // Walk up looking for Cargo.toml
814    let mut cur = file.parent();
815    while let Some(dir) = cur {
816        let cargo = dir.join("Cargo.toml");
817        if cargo.exists() {
818            if let Ok(text) = std::fs::read_to_string(&cargo) {
819                if let Some(name) = parse_cargo_package_name(&text) {
820                    return name;
821                }
822            }
823            if let Some(n) = dir.file_name().and_then(|n| n.to_str()) {
824                return n.to_string();
825            }
826        }
827        if dir == workspace {
828            break;
829        }
830        cur = dir.parent();
831    }
832    workspace
833        .file_name()
834        .and_then(|n| n.to_str())
835        .unwrap_or("workspace")
836        .to_string()
837}
838
839#[cfg(feature = "ast")]
840fn parse_cargo_package_name(toml: &str) -> Option<String> {
841    let mut in_package = false;
842    for line in toml.lines() {
843        let t = line.trim();
844        if t.starts_with('[') {
845            in_package = t == "[package]";
846            continue;
847        }
848        if in_package {
849            if let Some(rest) = t.strip_prefix("name") {
850                let rest = rest.trim().trim_start_matches('=').trim();
851                let name = rest.trim_matches('"').trim_matches('\'').to_string();
852                if !name.is_empty() {
853                    return Some(name);
854                }
855            }
856        }
857    }
858    None
859}
860
861/// Resolve `symbol:…` path against symbol nodes / aliases.
862fn resolve_symbol_against_conn(
863    conn: &rusqlite::Connection,
864    raw_path: &str,
865) -> Result<Option<String>> {
866    let Some(sym) = crate::symbols::parse_symbol_path(raw_path) else {
867        return Ok(None);
868    };
869
870    // Collect all symbol node ids (type=symbol).
871    let mut stmt = conn.prepare("SELECT id FROM nodes WHERE node_type = 'symbol'")?;
872    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
873    let mut ids = std::collections::HashSet::new();
874    for r in rows {
875        ids.insert(r?);
876    }
877
878    if let Some(id) = crate::symbols::resolve_symbol_ref(&sym, &ids) {
879        return Ok(Some(id));
880    }
881
882    // Fall back to alias / title resolution for the bare name.
883    resolve_against_conn(conn, &sym.symbol_name)
884}
885
886/// Resolve a link target using the live connection (aliases + ids + titles).
887fn resolve_against_conn(conn: &rusqlite::Connection, raw: &str) -> Result<Option<String>> {
888    let key = raw.trim().to_lowercase();
889    if key.is_empty() {
890        return Ok(None);
891    }
892
893    // Exact id
894    let exact: Option<String> = conn
895        .query_row("SELECT id FROM nodes WHERE id = ?1", [&key], |row| row.get(0))
896        .optional_compat()?;
897    if exact.is_some() {
898        return Ok(exact);
899    }
900
901    // Alias
902    let by_alias: Option<String> = conn
903        .query_row(
904            "SELECT node_id FROM node_aliases WHERE alias = ?1",
905            [&key],
906            |row| row.get(0),
907        )
908        .optional_compat()?;
909    if by_alias.is_some() {
910        return Ok(by_alias);
911    }
912
913    // Title (case-insensitive) — may be ambiguous; take if unique
914    let mut stmt = conn.prepare("SELECT id FROM nodes WHERE lower(title) = ?1")?;
915    let rows = stmt.query_map([&key], |row| row.get::<_, String>(0))?;
916    let mut hits = Vec::new();
917    for r in rows {
918        hits.push(r?);
919    }
920    if hits.len() == 1 {
921        return Ok(Some(hits.remove(0)));
922    }
923
924    // Unique suffix match
925    let mut stmt = conn.prepare("SELECT id FROM nodes")?;
926    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
927    let mut suffix_hits = Vec::new();
928    for r in rows {
929        let id = r?;
930        if id == key || id.ends_with(&format!("/{key}")) || id.rsplit('/').next() == Some(key.as_str())
931        {
932            suffix_hits.push(id);
933        }
934    }
935    suffix_hits.sort();
936    suffix_hits.dedup();
937    if suffix_hits.len() == 1 {
938        return Ok(Some(suffix_hits.remove(0)));
939    }
940
941    Ok(None)
942}
943
944/// Local trait shim so we can call `.optional()` style without importing OptionalExtension in every use.
945trait OptionalCompat<T> {
946    fn optional_compat(self) -> Result<Option<T>>;
947}
948
949impl<T> OptionalCompat<T> for std::result::Result<T, rusqlite::Error> {
950    fn optional_compat(self) -> Result<Option<T>> {
951        match self {
952            Ok(v) => Ok(Some(v)),
953            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
954            Err(e) => Err(BrainError::from(e)),
955        }
956    }
957}
958
959#[cfg(not(feature = "obsidian"))]
960struct RawLink {
961    target_node: String,
962}
963
964#[cfg(test)]
965mod tests {
966    use super::*;
967    use tempfile::tempdir;
968
969    #[test]
970    fn index_workspace_and_fts_idempotent() {
971        let dir = tempdir().unwrap();
972        let docs = dir.path().join("docs");
973        std::fs::create_dir_all(&docs).unwrap();
974        std::fs::write(
975            docs.join("raft.md"),
976            "---\ntags: [raft]\nnode_type: concept\naliases: [Raft Consensus]\n---\n# Raft Consensus\nRelates to [[log-compaction]].\n",
977        )
978        .unwrap();
979        std::fs::write(
980            docs.join("log-compaction.md"),
981            "---\ntags: [log]\nnode_type: concept\n---\n# Log Compaction\nSee [[raft]].\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
990        let s1 = indexer.index_workspace().unwrap();
991        assert_eq!(s1.markdown_files, 2);
992        assert!(s1.edges_created >= 2);
993        let fts1 = indexer.database().count_fts_rows().unwrap();
994        assert_eq!(fts1, 2);
995
996        // Second sync should skip unchanged content and keep FTS row count stable.
997        // Touch is not done — content hash matches → nodes_skipped.
998        let s2 = indexer.index_workspace().unwrap();
999        assert_eq!(s2.nodes_skipped_unchanged, 2);
1000        assert_eq!(indexer.database().count_fts_rows().unwrap(), 2);
1001
1002        // Force reindex by changing content
1003        std::fs::write(
1004            docs.join("raft.md"),
1005            "---\ntags: [raft]\nnode_type: concept\n---\n# Raft Consensus\nUpdated body [[log-compaction]].\n",
1006        )
1007        .unwrap();
1008        let s3 = indexer.index_workspace().unwrap();
1009        assert_eq!(s3.nodes_upserted, 1);
1010        assert_eq!(indexer.database().count_fts_rows().unwrap(), 2);
1011
1012        let hits = indexer.database().search_fts("raft").unwrap();
1013        assert!(!hits.is_empty());
1014        assert!(hits.iter().any(|n| n.id.contains("raft")));
1015    }
1016
1017    #[test]
1018    fn strip_rustdoc_prefixes_keeps_wikilink() {
1019        let raw = "/// Primary engine. See [[use-sqlite]].\n/// Second line.";
1020        let plain = strip_rustdoc_prefixes(raw);
1021        assert!(plain.contains("[[use-sqlite]]"));
1022        assert!(!plain.contains("///"));
1023    }
1024
1025    #[cfg(all(feature = "ast", feature = "obsidian"))]
1026    #[test]
1027    fn rustdoc_wikilink_creates_doc_links_edge() {
1028        let dir = tempdir().unwrap();
1029        let docs = dir.path().join("docs/adr");
1030        let src = dir.path().join("src");
1031        std::fs::create_dir_all(&docs).unwrap();
1032        std::fs::create_dir_all(&src).unwrap();
1033        std::fs::write(
1034            dir.path().join("Cargo.toml"),
1035            "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
1036        )
1037        .unwrap();
1038        std::fs::write(
1039            docs.join("use-sqlite.md"),
1040            "---\nnode_type: adr\naliases: [use-sqlite]\n---\n# Use SQLite\n\nLocal store.\n",
1041        )
1042        .unwrap();
1043        std::fs::write(
1044            src.join("lib.rs"),
1045            r#"/// Primary engine. See [[use-sqlite]] and [[docs/adr/use-sqlite]].
1046pub struct StorageEngine;
1047
1048impl StorageEngine {
1049    /// Open the store.
1050    pub fn open() {}
1051}
1052"#,
1053        )
1054        .unwrap();
1055
1056        let brain = dir.path().join(".brain");
1057        std::fs::create_dir_all(&brain).unwrap();
1058        let db = Database::open(brain.join("db.sqlite")).unwrap();
1059        let indexer = WorkspaceIndexer::new(db, dir.path());
1060        // Two syncs: notes first pass may race walk order; second resolves pending.
1061        let _ = indexer.index_workspace().unwrap();
1062        let s = indexer.index_workspace().unwrap();
1063        let _ = s;
1064
1065        let edges = indexer.database().get_all_edges().unwrap();
1066        let doc_links: Vec<_> = edges
1067            .iter()
1068            .filter(|e| e.relation_type == "doc_links")
1069            .collect();
1070        assert!(
1071            !doc_links.is_empty(),
1072            "expected doc_links from rustdoc WikiLink, edges={edges:?}"
1073        );
1074        assert!(doc_links.iter().any(|e| e.source_id.contains("StorageEngine")));
1075        assert!(doc_links.iter().any(|e| e.target_id.contains("use-sqlite")
1076            || e.target_id.contains("docs/adr/use-sqlite")));
1077    }
1078
1079    #[test]
1080    fn pending_link_then_resolve() {
1081        let dir = tempdir().unwrap();
1082        let docs = dir.path().join("docs");
1083        std::fs::create_dir_all(&docs).unwrap();
1084        std::fs::write(
1085            docs.join("a.md"),
1086            "---\nnode_type: concept\n---\n# A\nLink to [[b]].\n",
1087        )
1088        .unwrap();
1089
1090        let brain = dir.path().join(".brain");
1091        std::fs::create_dir_all(&brain).unwrap();
1092        let db = Database::open(brain.join("db.sqlite")).unwrap();
1093        let indexer = WorkspaceIndexer::new(db, dir.path());
1094        let s1 = indexer.index_workspace().unwrap();
1095        assert!(s1.edges_pending >= 1 || indexer.database().count_pending_links().unwrap() >= 1);
1096
1097        std::fs::write(
1098            docs.join("b.md"),
1099            "---\nnode_type: concept\n---\n# B\nBack.\n",
1100        )
1101        .unwrap();
1102        let s2 = indexer.index_workspace().unwrap();
1103        assert!(s2.edges_created >= 1 || indexer.database().count_edges().unwrap() >= 1);
1104    }
1105
1106    #[test]
1107    fn plan_note_status_densified_in_fts() {
1108        let dir = tempdir().unwrap();
1109        let plans = dir.path().join("docs/plans");
1110        std::fs::create_dir_all(&plans).unwrap();
1111        std::fs::write(
1112            plans.join("sprint.md"),
1113            "---\nnode_type: plan\nstatus: in_progress\n---\n# Sprint\n\n## Backlog\n\n- [ ] Write docs\n\n## Done\n\n- [x] Scaffold hub\n",
1114        )
1115        .unwrap();
1116        let brain = dir.path().join(".brain");
1117        std::fs::create_dir_all(&brain).unwrap();
1118        let db = Database::open(brain.join("db.sqlite")).unwrap();
1119        let indexer = WorkspaceIndexer::new(db, dir.path());
1120        indexer.index_workspace().unwrap();
1121        let node = indexer
1122            .database()
1123            .get_node("docs/plans/sprint")
1124            .unwrap()
1125            .expect("plan node");
1126        assert_eq!(node.node_type, NodeType::Plan);
1127        assert!(
1128            node.summary
1129                .as_deref()
1130                .is_some_and(|s| s.contains("status=in_progress") && s.contains("open")),
1131            "summary={:?}",
1132            node.summary
1133        );
1134        let fts = indexer
1135            .database()
1136            .get_fts_content("docs/plans/sprint")
1137            .unwrap()
1138            .unwrap_or_default();
1139        assert!(fts.contains("status:in_progress"), "fts={fts}");
1140        assert!(fts.contains("status:done") || fts.contains("task:done:"), "fts={fts}");
1141        let hits = indexer
1142            .database()
1143            .search_ranked("status:in_progress", &crate::query::QueryOptions::human())
1144            .unwrap();
1145        assert!(
1146            hits.iter().any(|h| h.node.id == "docs/plans/sprint"),
1147            "hits={:?}",
1148            hits.iter().map(|h| &h.node.id).collect::<Vec<_>>()
1149        );
1150    }
1151
1152    #[test]
1153    fn root_changelog_indexes_as_stable_hub() {
1154        let dir = tempdir().unwrap();
1155        std::fs::write(
1156            dir.path().join("CHANGELOG.md"),
1157            "# 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",
1158        )
1159        .unwrap();
1160        std::fs::write(dir.path().join("README.md"), "# Demo\n\nA crate.\n").unwrap();
1161        let brain = dir.path().join(".brain");
1162        std::fs::create_dir_all(&brain).unwrap();
1163        let db = Database::open(brain.join("db.sqlite")).unwrap();
1164        let indexer = WorkspaceIndexer::new(db, dir.path());
1165        indexer.index_workspace().unwrap();
1166
1167        let node = indexer
1168            .database()
1169            .get_node(crate::hubs::HUB_CHANGELOG)
1170            .unwrap()
1171            .expect("changelog hub");
1172        assert_eq!(node.node_type, NodeType::Changelog);
1173        assert_eq!(node.file_path.as_deref(), Some("CHANGELOG.md"));
1174        assert!(
1175            node.summary
1176                .as_deref()
1177                .is_some_and(|s| s.contains("0.3.15")),
1178            "summary={:?}",
1179            node.summary
1180        );
1181        // Version alias for FTS resolution
1182        let hits = indexer
1183            .database()
1184            .search_ranked("0.3.15", &crate::query::QueryOptions::default())
1185            .unwrap();
1186        assert!(
1187            hits.iter().any(|h| h.node.id == crate::hubs::HUB_CHANGELOG),
1188            "hits={:?}",
1189            hits.iter().map(|h| &h.node.id).collect::<Vec<_>>()
1190        );
1191    }
1192}