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 scopes: crate::scopes::WorkspaceManifest,
27}
28
29impl WorkspaceIndexer {
30 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 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 pub fn database(&self) -> &Database {
59 &self.db
60 }
61
62 pub fn into_database(self) -> Database {
64 self.db
65 }
66
67 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 let (resolved, still) = self.db.resolve_pending_links()?;
84 stats.edges_created += resolved;
85 stats.edges_pending = still;
86
87 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 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 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 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 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 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 Some(crate::hubs::ProjectHub::Readme) => parsed.unwrap_or(NodeType::Goal),
207 Some(crate::hubs::ProjectHub::Changelog) => {
209 parsed.unwrap_or(NodeType::Changelog)
210 }
211 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 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 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 #[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 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 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 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 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 for v in crate::hubs::changelog_version_aliases(body, 8) {
375 extra_aliases.push(v);
376 }
377 }
378 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 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 #[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 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 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 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 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 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 #[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 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 #[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 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#[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 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 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 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
865fn 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 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 resolve_against_conn(conn, &sym.symbol_name)
888}
889
890fn 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 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 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 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 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
948trait 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 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 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 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 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}