Skip to main content

rustbrain_core/
indexer.rs

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