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