1use 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
20pub struct WorkspaceIndexer {
22 db: Database,
23 workspace: PathBuf,
24 ignore: IgnoreSet,
25}
26
27impl WorkspaceIndexer {
28 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 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 pub fn database(&self) -> &Database {
49 &self.db
50 }
51
52 pub fn into_database(self) -> Database {
54 self.db
55 }
56
57 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 let (resolved, still) = self.db.resolve_pending_links()?;
74 stats.edges_created += resolved;
75 stats.edges_pending = still;
76
77 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 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 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 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 Some(crate::hubs::ProjectHub::Readme) => parsed.unwrap_or(NodeType::Goal),
160 Some(crate::hubs::ProjectHub::Changelog) => {
162 parsed.unwrap_or(NodeType::Changelog)
163 }
164 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 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 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 #[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 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 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 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 for v in crate::hubs::changelog_version_aliases(body, 8) {
320 extra_aliases.push(v);
321 }
322 }
323 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 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 #[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 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 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 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 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 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 #[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 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 #[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 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#[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 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 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 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
805fn 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 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 resolve_against_conn(conn, &sym.symbol_name)
828}
829
830fn 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 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 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 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 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
888trait 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 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 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 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 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}