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 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 #[cfg(feature = "obsidian")]
222 let wikilinks = crate::obsidian::extract_wikilinks(body);
223 #[cfg(not(feature = "obsidian"))]
224 let wikilinks: Vec<RawLink> = Vec::new();
225
226 let symbol_refs = crate::symbols::extract_symbol_refs(body);
228
229 let node = Node {
230 id: node_id.clone(),
231 node_type,
232 title: title.clone(),
233 file_path: Some(rel_str),
234 symbol_hash: None,
235 summary: Some(summary),
236 content_hash: Some(hash),
237 created_at: now,
238 updated_at: now,
239 };
240
241 let tags_str = tags.join(" ");
242 let mut links_for_tx: Vec<(String, String)> = {
243 #[cfg(feature = "obsidian")]
244 {
245 wikilinks
246 .iter()
247 .map(|l| {
248 if let Some(rest) = l.target_node.strip_prefix("symbol:") {
250 (format!("symbol:{rest}"), "anchors".to_string())
251 } else {
252 (l.target_node.clone(), "relates_to".to_string())
253 }
254 })
255 .collect()
256 }
257 #[cfg(not(feature = "obsidian"))]
258 {
259 let _ = &wikilinks;
260 Vec::new()
261 }
262 };
263 for sref in &symbol_refs {
264 links_for_tx.push((format!("symbol:{}", sref.raw), "anchors".to_string()));
265 }
266
267 let mut extra_aliases = aliases.clone();
269 if let Some(stem) = Path::new(&node.file_path.as_deref().unwrap_or(""))
270 .file_stem()
271 .and_then(|s| s.to_str())
272 {
273 extra_aliases.push(stem.to_string());
274 }
275 extra_aliases.push(title.clone());
276 if let Some(h) = hub {
277 for a in h.aliases() {
278 extra_aliases.push((*a).to_string());
279 }
280 if h == crate::hubs::ProjectHub::Readme {
281 if let Some(name) = self.workspace.file_name().and_then(|n| n.to_str()) {
282 extra_aliases.push(name.to_string());
283 }
284 }
285 if h == crate::hubs::ProjectHub::Changelog {
286 for v in crate::hubs::changelog_version_aliases(body, 8) {
288 extra_aliases.push(v);
289 }
290 }
291 }
292
293 self.db.with_transaction(|conn| {
294 self.db.insert_node_on(conn, &node)?;
295 self.db.replace_node_tags_on(conn, &node_id, &tags)?;
296 self.db
297 .replace_node_aliases_on(conn, &node_id, &extra_aliases)?;
298 self.db
299 .index_fts_on(conn, &node_id, &title, body, &tags_str)?;
300
301 self.db
303 .clear_edges_from_on(conn, &node_id, "relates_to")?;
304 self.db.clear_edges_from_on(conn, &node_id, "anchors")?;
305 self.db.clear_pending_links_for_on(conn, &node_id)?;
306
307 for (raw_target, rel_type) in &links_for_tx {
308 let resolved = if let Some(sym_path) = raw_target.strip_prefix("symbol:") {
309 resolve_symbol_against_conn(conn, sym_path)?
310 } else {
311 resolve_against_conn(conn, raw_target)?
312 };
313
314 if let Some(target_id) = resolved {
315 let edge = Edge {
316 source_id: node_id.clone(),
317 target_id,
318 relation_type: rel_type.clone(),
319 weight: 1.0,
320 decay_rate: 0.0,
321 created_at: now,
322 };
323 self.db.insert_edge_on(conn, &edge)?;
324 stats.edges_created += 1;
325 } else {
326 self.db.insert_pending_link_on(
327 conn,
328 &node_id,
329 raw_target,
330 rel_type,
331 now,
332 )?;
333 stats.edges_pending += 1;
334 }
335 }
336 Ok(())
337 })?;
338
339 stats.markdown_files += 1;
340 stats.nodes_upserted += 1;
341 Ok(())
342 }
343
344 #[cfg(feature = "obsidian")]
349 pub fn index_canvas_file(&self, file_path: &Path, stats: &mut SyncStats) -> Result<()> {
350 if !file_path.exists() || file_path.extension().and_then(|e| e.to_str()) != Some("canvas")
351 {
352 return Ok(());
353 }
354
355 let content = std::fs::read_to_string(file_path)?;
356 let canvas = crate::obsidian::ObsidianCanvas::parse_str(&content)
357 .map_err(|e| BrainError::indexer(e.to_string()))?;
358 let now = Utc::now().timestamp();
359 let relationships = canvas.extract_relationships();
360
361 let (ids, aliases, titles) = self.db.link_resolution_maps()?;
362
363 self.db.with_transaction(|conn| {
364 for (src_raw, dst_raw, rel) in &relationships {
365 let src = resolve_link_target(src_raw, &ids, &aliases, &titles);
366 let dst = resolve_link_target(dst_raw, &ids, &aliases, &titles);
367 match (src, dst) {
368 (Some(s), Some(d)) => {
369 let edge = Edge {
370 source_id: s,
371 target_id: d,
372 relation_type: rel.clone(),
373 weight: 1.0,
374 decay_rate: 0.0,
375 created_at: now,
376 };
377 self.db.insert_edge_on(conn, &edge)?;
378 stats.edges_created += 1;
379 }
380 (Some(s), None) => {
381 self.db
382 .insert_pending_link_on(conn, &s, dst_raw, rel, now)?;
383 stats.edges_pending += 1;
384 }
385 _ => {
386 stats.edges_pending += 1;
388 }
389 }
390 }
391 Ok(())
392 })?;
393
394 stats.canvas_files += 1;
395 Ok(())
396 }
397
398 fn walk_and_index(
399 &self,
400 dir: &Path,
401 stats: &mut SyncStats,
402 #[cfg(feature = "ast")] ast_parser: &mut crate::ast::CodeAstParser,
403 ) -> Result<()> {
404 if !dir.exists() {
405 return Ok(());
406 }
407
408 let entries = std::fs::read_dir(dir)?;
409 for entry in entries {
410 let entry = entry?;
411 let path = entry.path();
412
413 if path.is_dir() {
414 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
415 if self.ignore.skip_dir_name(name) {
416 continue;
417 }
418 }
419 let rel = rel_path_from_workspace(&self.workspace, &path);
420 let rel_str = rel.to_string_lossy().replace('\\', "/");
421 if self.ignore.is_ignored(&rel_str, true) {
422 continue;
423 }
424 self.walk_and_index(
425 &path,
426 stats,
427 #[cfg(feature = "ast")]
428 ast_parser,
429 )?;
430 } else {
431 let rel = rel_path_from_workspace(&self.workspace, &path);
432 let rel_str = rel.to_string_lossy().replace('\\', "/");
433 if self.ignore.is_ignored(&rel_str, false) {
434 continue;
435 }
436 let ext = path.extension().and_then(|e| e.to_str());
437 let result = match ext {
438 Some("md") => self.index_markdown_file(&path, stats),
439 #[cfg(feature = "obsidian")]
440 Some("canvas") => self.index_canvas_file(&path, stats),
441 #[cfg(feature = "ast")]
442 Some("rs") => self.index_rust_file(&path, ast_parser, stats),
443 _ => Ok(()),
444 };
445 if let Err(e) = result {
446 stats.file_errors += 1;
448 eprintln!(
449 "rustbrain: skip {} ({e})",
450 path.display()
451 );
452 }
453 }
454 }
455 Ok(())
456 }
457
458 #[cfg(feature = "ast")]
459 fn index_rust_file(
460 &self,
461 path: &Path,
462 ast_parser: &mut crate::ast::CodeAstParser,
463 stats: &mut SyncStats,
464 ) -> Result<()> {
465 let rel = rel_path_from_workspace(&self.workspace, path);
466 let rel_str = rel.to_string_lossy().replace('\\', "/");
467 if self.ignore.is_ignored(&rel_str, false) {
468 return Ok(());
469 }
470 let crate_name = infer_crate_name(&self.workspace, path);
471 let source = std::fs::read_to_string(path)?;
472 let anchors = ast_parser
473 .parse_symbols(&crate_name, &rel_str, &source)
474 .map_err(|e| BrainError::Ast(e.to_string()))?;
475
476 for anchor in &anchors {
477 self.db.insert_symbol_anchor(anchor)?;
478
479 let node_id = crate::symbols::symbol_node_id(
480 &anchor.crate_name,
481 &anchor.module_path,
482 &anchor.symbol_name,
483 );
484 let sig = format!(
486 "{}::{}::{}@{}-{}#{}",
487 anchor.crate_name,
488 anchor.module_path,
489 anchor.symbol_name,
490 anchor.start_line,
491 anchor.end_line,
492 anchor.doc_comment.as_deref().unwrap_or("")
493 );
494 let chash = crate::id::content_hash(sig.as_bytes());
495 let unchanged = self
496 .db
497 .get_content_hash(&node_id)?
498 .map(|existing| existing == chash)
499 .unwrap_or(false);
500
501 if !unchanged {
502 let now = Utc::now().timestamp();
503 let node = Node {
504 id: node_id.clone(),
505 node_type: NodeType::Symbol,
506 title: anchor.symbol_name.clone(),
507 file_path: Some(anchor.file_path.clone()),
508 symbol_hash: Some(anchor.symbol_hash),
509 summary: anchor.doc_comment.clone(),
510 content_hash: Some(chash),
511 created_at: now,
512 updated_at: now,
513 };
514 self.db.insert_node(&node)?;
515
516 let mut aliases = vec![anchor.symbol_name.clone()];
518 if let Some((_, method)) = anchor.symbol_name.split_once("::") {
519 aliases.push(method.to_string());
520 }
521 aliases.push(format!(
522 "{}::{}::{}",
523 anchor.crate_name, anchor.module_path, anchor.symbol_name
524 ));
525 self.db.replace_node_aliases(&node_id, &aliases)?;
526
527 let fts_body = format!(
528 "{} {} {} {}",
529 anchor.symbol_name,
530 anchor.module_path,
531 anchor.crate_name,
532 anchor.doc_comment.as_deref().unwrap_or("")
533 );
534 self.db
535 .index_fts(&node_id, &anchor.symbol_name, &fts_body, "symbol")?;
536
537 stats.nodes_upserted += 1;
538 } else {
539 stats.nodes_skipped_unchanged += 1;
540 }
541
542 self.link_symbol_doc_wikis(&node_id, anchor.doc_comment.as_deref(), stats)?;
544
545 stats.symbol_anchors += 1;
546 }
547 stats.rust_files += 1;
548 Ok(())
549 }
550
551 #[cfg(feature = "ast")]
556 fn link_symbol_doc_wikis(
557 &self,
558 symbol_node_id: &str,
559 doc_comment: Option<&str>,
560 stats: &mut SyncStats,
561 ) -> Result<()> {
562 let now = Utc::now().timestamp();
563 let doc_plain = doc_comment.map(strip_rustdoc_prefixes).unwrap_or_default();
564
565 self.db.with_transaction(|conn| {
566 self.db
567 .clear_edges_from_on(conn, symbol_node_id, "doc_links")?;
568 self.db.clear_pending_links_for_on(conn, symbol_node_id)?;
571
572 if doc_plain.trim().is_empty() {
573 return Ok(());
574 }
575
576 #[cfg(feature = "obsidian")]
578 {
579 let wikis = crate::obsidian::extract_wikilinks(&doc_plain);
580 for w in wikis {
581 let target = w.target_node.trim();
582 if target.is_empty() {
583 continue;
584 }
585 if target.starts_with("symbol:") {
587 continue;
588 }
589 let resolved = resolve_against_conn(conn, target)?;
590 if let Some(target_id) = resolved {
591 if target_id == symbol_node_id {
592 continue;
593 }
594 let edge = Edge {
595 source_id: symbol_node_id.to_string(),
596 target_id,
597 relation_type: "doc_links".into(),
598 weight: 1.0,
599 decay_rate: 0.0,
600 created_at: now,
601 };
602 self.db.insert_edge_on(conn, &edge)?;
603 stats.edges_created += 1;
604 } else {
605 self.db.insert_pending_link_on(
606 conn,
607 symbol_node_id,
608 &format!("[[{target}]]"),
609 "doc_links",
610 now,
611 )?;
612 stats.edges_pending += 1;
613 }
614 }
615 }
616 Ok(())
617 })
618 }
619}
620
621#[cfg(feature = "ast")]
623fn strip_rustdoc_prefixes(doc: &str) -> String {
624 let mut out = String::new();
625 for line in doc.lines() {
626 let t = line.trim();
627 let body = if let Some(rest) = t.strip_prefix("///") {
628 rest.strip_prefix(' ').unwrap_or(rest)
629 } else if let Some(rest) = t.strip_prefix("//!") {
630 rest.strip_prefix(' ').unwrap_or(rest)
631 } else if t.starts_with("/**") || t.starts_with("*/") {
632 continue;
633 } else if let Some(rest) = t.strip_prefix('*') {
634 rest.strip_prefix(' ').unwrap_or(rest)
635 } else {
636 t
637 };
638 out.push_str(body);
639 out.push('\n');
640 }
641 out
642}
643
644fn rustbrainignore_requests_gitignore(workspace: &Path) -> bool {
645 let path = workspace.join(".rustbrainignore");
646 let Ok(text) = std::fs::read_to_string(path) else {
647 return false;
648 };
649 text.lines().any(|l| {
650 let t = l.trim().to_ascii_lowercase();
651 t == "# rustbrain: import-gitignore"
652 || t == "#!import-gitignore"
653 || t.contains("rustbrain: import-gitignore")
654 })
655}
656
657fn first_substantive_line(body: &str) -> String {
658 for line in body.lines() {
659 let t = line.trim();
660 if t.is_empty() {
661 continue;
662 }
663 let cleaned = t.trim_start_matches('#').trim();
665 if !cleaned.is_empty() {
666 return cleaned.to_string();
667 }
668 }
669 String::new()
670}
671
672fn resolve_title(
673 #[cfg(feature = "obsidian")] frontmatter: Option<&crate::obsidian::Frontmatter>,
674 #[cfg(not(feature = "obsidian"))] frontmatter: Option<&()>,
675 body: &str,
676 rel: &Path,
677) -> String {
678 #[cfg(feature = "obsidian")]
679 if let Some(fm) = frontmatter {
680 if let Some(title) = fm.extra.get("title").and_then(|v| v.as_str()) {
681 let t = title.trim();
682 if !t.is_empty() {
683 return t.to_string();
684 }
685 }
686 }
687 #[cfg(not(feature = "obsidian"))]
688 let _ = frontmatter;
689
690 for line in body.lines() {
692 let t = line.trim();
693 if let Some(rest) = t.strip_prefix("# ") {
694 let t = rest.trim();
695 if !t.is_empty() {
696 return t.to_string();
697 }
698 }
699 }
700
701 rel.file_stem()
702 .and_then(|s| s.to_str())
703 .unwrap_or("Untitled")
704 .to_string()
705}
706
707#[cfg(feature = "ast")]
708fn infer_crate_name(workspace: &Path, file: &Path) -> String {
709 let mut cur = file.parent();
711 while let Some(dir) = cur {
712 let cargo = dir.join("Cargo.toml");
713 if cargo.exists() {
714 if let Ok(text) = std::fs::read_to_string(&cargo) {
715 if let Some(name) = parse_cargo_package_name(&text) {
716 return name;
717 }
718 }
719 if let Some(n) = dir.file_name().and_then(|n| n.to_str()) {
720 return n.to_string();
721 }
722 }
723 if dir == workspace {
724 break;
725 }
726 cur = dir.parent();
727 }
728 workspace
729 .file_name()
730 .and_then(|n| n.to_str())
731 .unwrap_or("workspace")
732 .to_string()
733}
734
735#[cfg(feature = "ast")]
736fn parse_cargo_package_name(toml: &str) -> Option<String> {
737 let mut in_package = false;
738 for line in toml.lines() {
739 let t = line.trim();
740 if t.starts_with('[') {
741 in_package = t == "[package]";
742 continue;
743 }
744 if in_package {
745 if let Some(rest) = t.strip_prefix("name") {
746 let rest = rest.trim().trim_start_matches('=').trim();
747 let name = rest.trim_matches('"').trim_matches('\'').to_string();
748 if !name.is_empty() {
749 return Some(name);
750 }
751 }
752 }
753 }
754 None
755}
756
757fn resolve_symbol_against_conn(
759 conn: &rusqlite::Connection,
760 raw_path: &str,
761) -> Result<Option<String>> {
762 let Some(sym) = crate::symbols::parse_symbol_path(raw_path) else {
763 return Ok(None);
764 };
765
766 let mut stmt = conn.prepare("SELECT id FROM nodes WHERE node_type = 'symbol'")?;
768 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
769 let mut ids = std::collections::HashSet::new();
770 for r in rows {
771 ids.insert(r?);
772 }
773
774 if let Some(id) = crate::symbols::resolve_symbol_ref(&sym, &ids) {
775 return Ok(Some(id));
776 }
777
778 resolve_against_conn(conn, &sym.symbol_name)
780}
781
782fn resolve_against_conn(conn: &rusqlite::Connection, raw: &str) -> Result<Option<String>> {
784 let key = raw.trim().to_lowercase();
785 if key.is_empty() {
786 return Ok(None);
787 }
788
789 let exact: Option<String> = conn
791 .query_row("SELECT id FROM nodes WHERE id = ?1", [&key], |row| row.get(0))
792 .optional_compat()?;
793 if exact.is_some() {
794 return Ok(exact);
795 }
796
797 let by_alias: Option<String> = conn
799 .query_row(
800 "SELECT node_id FROM node_aliases WHERE alias = ?1",
801 [&key],
802 |row| row.get(0),
803 )
804 .optional_compat()?;
805 if by_alias.is_some() {
806 return Ok(by_alias);
807 }
808
809 let mut stmt = conn.prepare("SELECT id FROM nodes WHERE lower(title) = ?1")?;
811 let rows = stmt.query_map([&key], |row| row.get::<_, String>(0))?;
812 let mut hits = Vec::new();
813 for r in rows {
814 hits.push(r?);
815 }
816 if hits.len() == 1 {
817 return Ok(Some(hits.remove(0)));
818 }
819
820 let mut stmt = conn.prepare("SELECT id FROM nodes")?;
822 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
823 let mut suffix_hits = Vec::new();
824 for r in rows {
825 let id = r?;
826 if id == key || id.ends_with(&format!("/{key}")) || id.rsplit('/').next() == Some(key.as_str())
827 {
828 suffix_hits.push(id);
829 }
830 }
831 suffix_hits.sort();
832 suffix_hits.dedup();
833 if suffix_hits.len() == 1 {
834 return Ok(Some(suffix_hits.remove(0)));
835 }
836
837 Ok(None)
838}
839
840trait OptionalCompat<T> {
842 fn optional_compat(self) -> Result<Option<T>>;
843}
844
845impl<T> OptionalCompat<T> for std::result::Result<T, rusqlite::Error> {
846 fn optional_compat(self) -> Result<Option<T>> {
847 match self {
848 Ok(v) => Ok(Some(v)),
849 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
850 Err(e) => Err(BrainError::from(e)),
851 }
852 }
853}
854
855#[cfg(not(feature = "obsidian"))]
856struct RawLink {
857 target_node: String,
858}
859
860#[cfg(test)]
861mod tests {
862 use super::*;
863 use tempfile::tempdir;
864
865 #[test]
866 fn index_workspace_and_fts_idempotent() {
867 let dir = tempdir().unwrap();
868 let docs = dir.path().join("docs");
869 std::fs::create_dir_all(&docs).unwrap();
870 std::fs::write(
871 docs.join("raft.md"),
872 "---\ntags: [raft]\nnode_type: concept\naliases: [Raft Consensus]\n---\n# Raft Consensus\nRelates to [[log-compaction]].\n",
873 )
874 .unwrap();
875 std::fs::write(
876 docs.join("log-compaction.md"),
877 "---\ntags: [log]\nnode_type: concept\n---\n# Log Compaction\nSee [[raft]].\n",
878 )
879 .unwrap();
880
881 let brain = dir.path().join(".brain");
882 std::fs::create_dir_all(&brain).unwrap();
883 let db = Database::open(brain.join("db.sqlite")).unwrap();
884 let indexer = WorkspaceIndexer::new(db, dir.path());
885
886 let s1 = indexer.index_workspace().unwrap();
887 assert_eq!(s1.markdown_files, 2);
888 assert!(s1.edges_created >= 2);
889 let fts1 = indexer.database().count_fts_rows().unwrap();
890 assert_eq!(fts1, 2);
891
892 let s2 = indexer.index_workspace().unwrap();
895 assert_eq!(s2.nodes_skipped_unchanged, 2);
896 assert_eq!(indexer.database().count_fts_rows().unwrap(), 2);
897
898 std::fs::write(
900 docs.join("raft.md"),
901 "---\ntags: [raft]\nnode_type: concept\n---\n# Raft Consensus\nUpdated body [[log-compaction]].\n",
902 )
903 .unwrap();
904 let s3 = indexer.index_workspace().unwrap();
905 assert_eq!(s3.nodes_upserted, 1);
906 assert_eq!(indexer.database().count_fts_rows().unwrap(), 2);
907
908 let hits = indexer.database().search_fts("raft").unwrap();
909 assert!(!hits.is_empty());
910 assert!(hits.iter().any(|n| n.id.contains("raft")));
911 }
912
913 #[test]
914 fn strip_rustdoc_prefixes_keeps_wikilink() {
915 let raw = "/// Primary engine. See [[use-sqlite]].\n/// Second line.";
916 let plain = strip_rustdoc_prefixes(raw);
917 assert!(plain.contains("[[use-sqlite]]"));
918 assert!(!plain.contains("///"));
919 }
920
921 #[cfg(all(feature = "ast", feature = "obsidian"))]
922 #[test]
923 fn rustdoc_wikilink_creates_doc_links_edge() {
924 let dir = tempdir().unwrap();
925 let docs = dir.path().join("docs/adr");
926 let src = dir.path().join("src");
927 std::fs::create_dir_all(&docs).unwrap();
928 std::fs::create_dir_all(&src).unwrap();
929 std::fs::write(
930 dir.path().join("Cargo.toml"),
931 "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
932 )
933 .unwrap();
934 std::fs::write(
935 docs.join("use-sqlite.md"),
936 "---\nnode_type: adr\naliases: [use-sqlite]\n---\n# Use SQLite\n\nLocal store.\n",
937 )
938 .unwrap();
939 std::fs::write(
940 src.join("lib.rs"),
941 r#"/// Primary engine. See [[use-sqlite]] and [[docs/adr/use-sqlite]].
942pub struct StorageEngine;
943
944impl StorageEngine {
945 /// Open the store.
946 pub fn open() {}
947}
948"#,
949 )
950 .unwrap();
951
952 let brain = dir.path().join(".brain");
953 std::fs::create_dir_all(&brain).unwrap();
954 let db = Database::open(brain.join("db.sqlite")).unwrap();
955 let indexer = WorkspaceIndexer::new(db, dir.path());
956 let _ = indexer.index_workspace().unwrap();
958 let s = indexer.index_workspace().unwrap();
959 let _ = s;
960
961 let edges = indexer.database().get_all_edges().unwrap();
962 let doc_links: Vec<_> = edges
963 .iter()
964 .filter(|e| e.relation_type == "doc_links")
965 .collect();
966 assert!(
967 !doc_links.is_empty(),
968 "expected doc_links from rustdoc WikiLink, edges={edges:?}"
969 );
970 assert!(doc_links.iter().any(|e| e.source_id.contains("StorageEngine")));
971 assert!(doc_links.iter().any(|e| e.target_id.contains("use-sqlite")
972 || e.target_id.contains("docs/adr/use-sqlite")));
973 }
974
975 #[test]
976 fn pending_link_then_resolve() {
977 let dir = tempdir().unwrap();
978 let docs = dir.path().join("docs");
979 std::fs::create_dir_all(&docs).unwrap();
980 std::fs::write(
981 docs.join("a.md"),
982 "---\nnode_type: concept\n---\n# A\nLink to [[b]].\n",
983 )
984 .unwrap();
985
986 let brain = dir.path().join(".brain");
987 std::fs::create_dir_all(&brain).unwrap();
988 let db = Database::open(brain.join("db.sqlite")).unwrap();
989 let indexer = WorkspaceIndexer::new(db, dir.path());
990 let s1 = indexer.index_workspace().unwrap();
991 assert!(s1.edges_pending >= 1 || indexer.database().count_pending_links().unwrap() >= 1);
992
993 std::fs::write(
994 docs.join("b.md"),
995 "---\nnode_type: concept\n---\n# B\nBack.\n",
996 )
997 .unwrap();
998 let s2 = indexer.index_workspace().unwrap();
999 assert!(s2.edges_created >= 1 || indexer.database().count_edges().unwrap() >= 1);
1000 }
1001
1002 #[test]
1003 fn root_changelog_indexes_as_stable_hub() {
1004 let dir = tempdir().unwrap();
1005 std::fs::write(
1006 dir.path().join("CHANGELOG.md"),
1007 "# 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",
1008 )
1009 .unwrap();
1010 std::fs::write(dir.path().join("README.md"), "# Demo\n\nA crate.\n").unwrap();
1011 let brain = dir.path().join(".brain");
1012 std::fs::create_dir_all(&brain).unwrap();
1013 let db = Database::open(brain.join("db.sqlite")).unwrap();
1014 let indexer = WorkspaceIndexer::new(db, dir.path());
1015 indexer.index_workspace().unwrap();
1016
1017 let node = indexer
1018 .database()
1019 .get_node(crate::hubs::HUB_CHANGELOG)
1020 .unwrap()
1021 .expect("changelog hub");
1022 assert_eq!(node.node_type, NodeType::Changelog);
1023 assert_eq!(node.file_path.as_deref(), Some("CHANGELOG.md"));
1024 assert!(
1025 node.summary
1026 .as_deref()
1027 .is_some_and(|s| s.contains("0.3.15")),
1028 "summary={:?}",
1029 node.summary
1030 );
1031 let hits = indexer
1033 .database()
1034 .search_ranked("0.3.15", &crate::query::QueryOptions::default())
1035 .unwrap();
1036 assert!(
1037 hits.iter().any(|h| h.node.id == crate::hubs::HUB_CHANGELOG),
1038 "hits={:?}",
1039 hits.iter().map(|h| &h.node.id).collect::<Vec<_>>()
1040 );
1041 }
1042}