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