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