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