1use crate::error::{BrainError, Result};
13use crate::id::{content_hash, node_id_from_rel_path, rel_path_from_workspace, resolve_link_target};
14use crate::storage::Database;
15use crate::types::{Edge, Node, NodeType, SyncStats};
16use chrono::Utc;
17use std::path::{Path, PathBuf};
18
19pub struct WorkspaceIndexer {
21 db: Database,
22 workspace: PathBuf,
23}
24
25impl WorkspaceIndexer {
26 pub fn new(db: Database, workspace: impl Into<PathBuf>) -> Self {
28 Self {
29 db,
30 workspace: workspace.into(),
31 }
32 }
33
34 pub fn database(&self) -> &Database {
36 &self.db
37 }
38
39 pub fn into_database(self) -> Database {
41 self.db
42 }
43
44 pub fn index_workspace(&self) -> Result<SyncStats> {
46 let mut stats = SyncStats::default();
47
48 #[cfg(feature = "ast")]
49 let mut ast_parser = crate::ast::CodeAstParser::new_rust()
50 .map_err(|e| BrainError::Ast(e.to_string()))?;
51
52 self.walk_and_index(
53 &self.workspace,
54 &mut stats,
55 #[cfg(feature = "ast")]
56 &mut ast_parser,
57 )?;
58
59 let (resolved, still) = self.db.resolve_pending_links()?;
61 stats.edges_created += resolved;
62 stats.edges_pending = still;
63
64 let brain_dir = self.workspace.join(".brain");
66 if brain_dir.exists() {
67 #[cfg(feature = "mmap")]
68 {
69 let mmap_path = brain_dir.join("graph.mmap");
70 self.compile_mmap(&mmap_path)?;
71 stats.mmap_written = true;
72 }
73 }
74
75 Ok(stats)
76 }
77
78 pub fn compile_mmap(&self, output_path: &Path) -> Result<()> {
80 #[cfg(feature = "mmap")]
81 {
82 let node_ids = self.db.get_all_node_ids()?;
83 let edges = self.db.get_csr_edges()?;
84 crate::mmap::CsrCompiler::compile(output_path, &node_ids, &edges, None, 0)?;
86 Ok(())
87 }
88 #[cfg(not(feature = "mmap"))]
89 {
90 let _ = output_path;
91 Err(BrainError::FeatureDisabled("mmap"))
92 }
93 }
94
95 pub fn index_markdown_file(&self, file_path: &Path, stats: &mut SyncStats) -> Result<()> {
97 if !file_path.exists() || file_path.extension().and_then(|e| e.to_str()) != Some("md") {
98 return Ok(());
99 }
100
101 let raw_bytes = std::fs::read(file_path)?;
102 let hash = content_hash(&raw_bytes);
103 let content = String::from_utf8_lossy(&raw_bytes);
104
105 let rel = rel_path_from_workspace(&self.workspace, file_path);
106 let node_id = node_id_from_rel_path(&rel);
107 let rel_str = rel.to_string_lossy().replace('\\', "/");
108
109 if let Some(existing) = self.db.get_content_hash(&node_id)? {
110 if existing == hash {
111 stats.nodes_skipped_unchanged += 1;
112 return Ok(());
113 }
114 }
115
116 #[cfg(feature = "obsidian")]
117 let (frontmatter, body) = crate::obsidian::parse_frontmatter(&content);
118 #[cfg(not(feature = "obsidian"))]
119 let (frontmatter, body): (Option<()>, &str) = (None, content.as_ref());
120
121 let title = resolve_title(
122 #[cfg(feature = "obsidian")]
123 frontmatter.as_ref(),
124 #[cfg(not(feature = "obsidian"))]
125 None,
126 body,
127 &rel,
128 );
129
130 let node_type = {
131 #[cfg(feature = "obsidian")]
132 {
133 frontmatter
134 .as_ref()
135 .and_then(|fm| fm.node_type.as_deref())
136 .and_then(NodeType::parse)
137 .unwrap_or(NodeType::Concept)
138 }
139 #[cfg(not(feature = "obsidian"))]
140 {
141 NodeType::Concept
142 }
143 };
144
145 let now = Utc::now().timestamp();
146 let summary = first_substantive_line(body);
147
148 let tags: Vec<String> = {
149 #[cfg(feature = "obsidian")]
150 {
151 frontmatter
152 .as_ref()
153 .map(|fm| fm.tags.clone())
154 .unwrap_or_default()
155 }
156 #[cfg(not(feature = "obsidian"))]
157 {
158 Vec::new()
159 }
160 };
161
162 let aliases: Vec<String> = {
163 #[cfg(feature = "obsidian")]
164 {
165 frontmatter
166 .as_ref()
167 .map(|fm| fm.aliases.clone())
168 .unwrap_or_default()
169 }
170 #[cfg(not(feature = "obsidian"))]
171 {
172 Vec::new()
173 }
174 };
175
176 #[cfg(feature = "obsidian")]
178 let wikilinks = crate::obsidian::extract_wikilinks(body);
179 #[cfg(not(feature = "obsidian"))]
180 let wikilinks: Vec<RawLink> = Vec::new();
181
182 let symbol_refs = crate::symbols::extract_symbol_refs(body);
184
185 let node = Node {
186 id: node_id.clone(),
187 node_type,
188 title: title.clone(),
189 file_path: Some(rel_str),
190 symbol_hash: None,
191 summary: Some(summary),
192 content_hash: Some(hash),
193 created_at: now,
194 updated_at: now,
195 };
196
197 let tags_str = tags.join(" ");
198 let mut links_for_tx: Vec<(String, String)> = {
199 #[cfg(feature = "obsidian")]
200 {
201 wikilinks
202 .iter()
203 .map(|l| {
204 if let Some(rest) = l.target_node.strip_prefix("symbol:") {
206 (format!("symbol:{rest}"), "anchors".to_string())
207 } else {
208 (l.target_node.clone(), "relates_to".to_string())
209 }
210 })
211 .collect()
212 }
213 #[cfg(not(feature = "obsidian"))]
214 {
215 let _ = &wikilinks;
216 Vec::new()
217 }
218 };
219 for sref in &symbol_refs {
220 links_for_tx.push((format!("symbol:{}", sref.raw), "anchors".to_string()));
221 }
222
223 let mut extra_aliases = aliases.clone();
225 if let Some(stem) = Path::new(&node.file_path.as_deref().unwrap_or(""))
226 .file_stem()
227 .and_then(|s| s.to_str())
228 {
229 extra_aliases.push(stem.to_string());
230 }
231 extra_aliases.push(title.clone());
232
233 self.db.with_transaction(|conn| {
234 self.db.insert_node_on(conn, &node)?;
235 self.db.replace_node_tags_on(conn, &node_id, &tags)?;
236 self.db
237 .replace_node_aliases_on(conn, &node_id, &extra_aliases)?;
238 self.db
239 .index_fts_on(conn, &node_id, &title, body, &tags_str)?;
240
241 self.db
243 .clear_edges_from_on(conn, &node_id, "relates_to")?;
244 self.db.clear_edges_from_on(conn, &node_id, "anchors")?;
245 self.db.clear_pending_links_for_on(conn, &node_id)?;
246
247 for (raw_target, rel_type) in &links_for_tx {
248 let resolved = if let Some(sym_path) = raw_target.strip_prefix("symbol:") {
249 resolve_symbol_against_conn(conn, sym_path)?
250 } else {
251 resolve_against_conn(conn, raw_target)?
252 };
253
254 if let Some(target_id) = resolved {
255 let edge = Edge {
256 source_id: node_id.clone(),
257 target_id,
258 relation_type: rel_type.clone(),
259 weight: 1.0,
260 decay_rate: 0.0,
261 created_at: now,
262 };
263 self.db.insert_edge_on(conn, &edge)?;
264 stats.edges_created += 1;
265 } else {
266 self.db.insert_pending_link_on(
267 conn,
268 &node_id,
269 raw_target,
270 rel_type,
271 now,
272 )?;
273 stats.edges_pending += 1;
274 }
275 }
276 Ok(())
277 })?;
278
279 stats.markdown_files += 1;
280 stats.nodes_upserted += 1;
281 Ok(())
282 }
283
284 #[cfg(feature = "obsidian")]
289 pub fn index_canvas_file(&self, file_path: &Path, stats: &mut SyncStats) -> Result<()> {
290 if !file_path.exists() || file_path.extension().and_then(|e| e.to_str()) != Some("canvas")
291 {
292 return Ok(());
293 }
294
295 let content = std::fs::read_to_string(file_path)?;
296 let canvas = crate::obsidian::ObsidianCanvas::parse_str(&content)
297 .map_err(|e| BrainError::indexer(e.to_string()))?;
298 let now = Utc::now().timestamp();
299 let relationships = canvas.extract_relationships();
300
301 let (ids, aliases, titles) = self.db.link_resolution_maps()?;
302
303 self.db.with_transaction(|conn| {
304 for (src_raw, dst_raw, rel) in &relationships {
305 let src = resolve_link_target(src_raw, &ids, &aliases, &titles);
306 let dst = resolve_link_target(dst_raw, &ids, &aliases, &titles);
307 match (src, dst) {
308 (Some(s), Some(d)) => {
309 let edge = Edge {
310 source_id: s,
311 target_id: d,
312 relation_type: rel.clone(),
313 weight: 1.0,
314 decay_rate: 0.0,
315 created_at: now,
316 };
317 self.db.insert_edge_on(conn, &edge)?;
318 stats.edges_created += 1;
319 }
320 (Some(s), None) => {
321 self.db
322 .insert_pending_link_on(conn, &s, dst_raw, rel, now)?;
323 stats.edges_pending += 1;
324 }
325 _ => {
326 stats.edges_pending += 1;
328 }
329 }
330 }
331 Ok(())
332 })?;
333
334 stats.canvas_files += 1;
335 Ok(())
336 }
337
338 fn walk_and_index(
339 &self,
340 dir: &Path,
341 stats: &mut SyncStats,
342 #[cfg(feature = "ast")] ast_parser: &mut crate::ast::CodeAstParser,
343 ) -> Result<()> {
344 if !dir.exists() {
345 return Ok(());
346 }
347
348 let entries = std::fs::read_dir(dir)?;
349 for entry in entries {
350 let entry = entry?;
351 let path = entry.path();
352
353 if path.is_dir() {
354 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
355 if should_skip_dir(name) {
356 continue;
357 }
358 }
359 self.walk_and_index(
360 &path,
361 stats,
362 #[cfg(feature = "ast")]
363 ast_parser,
364 )?;
365 } else {
366 let ext = path.extension().and_then(|e| e.to_str());
367 let result = match ext {
368 Some("md") => self.index_markdown_file(&path, stats),
369 #[cfg(feature = "obsidian")]
370 Some("canvas") => self.index_canvas_file(&path, stats),
371 #[cfg(feature = "ast")]
372 Some("rs") => self.index_rust_file(&path, ast_parser, stats),
373 _ => Ok(()),
374 };
375 if let Err(e) = result {
376 stats.file_errors += 1;
378 eprintln!(
379 "rustbrain: skip {} ({e})",
380 path.display()
381 );
382 }
383 }
384 }
385 Ok(())
386 }
387
388 #[cfg(feature = "ast")]
389 fn index_rust_file(
390 &self,
391 path: &Path,
392 ast_parser: &mut crate::ast::CodeAstParser,
393 stats: &mut SyncStats,
394 ) -> Result<()> {
395 let rel = rel_path_from_workspace(&self.workspace, path);
396 let rel_str = rel.to_string_lossy().replace('\\', "/");
397 let crate_name = infer_crate_name(&self.workspace, path);
398 let source = std::fs::read_to_string(path)?;
399 let anchors = ast_parser
400 .parse_symbols(&crate_name, &rel_str, &source)
401 .map_err(|e| BrainError::Ast(e.to_string()))?;
402
403 for anchor in &anchors {
404 self.db.insert_symbol_anchor(anchor)?;
405
406 let node_id = crate::symbols::symbol_node_id(
407 &anchor.crate_name,
408 &anchor.module_path,
409 &anchor.symbol_name,
410 );
411 let sig = format!(
413 "{}::{}::{}@{}-{}",
414 anchor.crate_name,
415 anchor.module_path,
416 anchor.symbol_name,
417 anchor.start_line,
418 anchor.end_line
419 );
420 let chash = crate::id::content_hash(sig.as_bytes());
421 if let Some(existing) = self.db.get_content_hash(&node_id)? {
422 if existing == chash {
423 stats.nodes_skipped_unchanged += 1;
424 stats.symbol_anchors += 1;
425 continue;
426 }
427 }
428
429 let now = Utc::now().timestamp();
430 let node = Node {
431 id: node_id.clone(),
432 node_type: NodeType::Symbol,
433 title: anchor.symbol_name.clone(),
434 file_path: Some(anchor.file_path.clone()),
435 symbol_hash: Some(anchor.symbol_hash),
436 summary: anchor.doc_comment.clone(),
437 content_hash: Some(chash),
438 created_at: now,
439 updated_at: now,
440 };
441 self.db.insert_node(&node)?;
442
443 let mut aliases = vec![anchor.symbol_name.clone()];
445 if let Some((_, method)) = anchor.symbol_name.split_once("::") {
446 aliases.push(method.to_string());
447 }
448 aliases.push(format!(
449 "{}::{}::{}",
450 anchor.crate_name, anchor.module_path, anchor.symbol_name
451 ));
452 self.db.replace_node_aliases(&node_id, &aliases)?;
453
454 let fts_body = format!(
455 "{} {} {} {}",
456 anchor.symbol_name,
457 anchor.module_path,
458 anchor.crate_name,
459 anchor.doc_comment.as_deref().unwrap_or("")
460 );
461 self.db
462 .index_fts(&node_id, &anchor.symbol_name, &fts_body, "symbol")?;
463
464 stats.nodes_upserted += 1;
465 stats.symbol_anchors += 1;
466 }
467 stats.rust_files += 1;
468 Ok(())
469 }
470}
471
472fn should_skip_dir(name: &str) -> bool {
473 matches!(
474 name,
475 "target"
476 | "node_modules"
477 | "vendor"
478 | ".git"
479 | ".brain"
480 | "dist"
481 | "build"
482 | ".svn"
483 | ".hg"
484 ) || name.starts_with('.')
485}
486
487fn first_substantive_line(body: &str) -> String {
488 for line in body.lines() {
489 let t = line.trim();
490 if t.is_empty() {
491 continue;
492 }
493 let cleaned = t.trim_start_matches('#').trim();
495 if !cleaned.is_empty() {
496 return cleaned.to_string();
497 }
498 }
499 String::new()
500}
501
502fn resolve_title(
503 #[cfg(feature = "obsidian")] frontmatter: Option<&crate::obsidian::Frontmatter>,
504 #[cfg(not(feature = "obsidian"))] frontmatter: Option<&()>,
505 body: &str,
506 rel: &Path,
507) -> String {
508 #[cfg(feature = "obsidian")]
509 if let Some(fm) = frontmatter {
510 if let Some(title) = fm.extra.get("title").and_then(|v| v.as_str()) {
511 let t = title.trim();
512 if !t.is_empty() {
513 return t.to_string();
514 }
515 }
516 }
517 #[cfg(not(feature = "obsidian"))]
518 let _ = frontmatter;
519
520 for line in body.lines() {
522 let t = line.trim();
523 if let Some(rest) = t.strip_prefix("# ") {
524 let t = rest.trim();
525 if !t.is_empty() {
526 return t.to_string();
527 }
528 }
529 }
530
531 rel.file_stem()
532 .and_then(|s| s.to_str())
533 .unwrap_or("Untitled")
534 .to_string()
535}
536
537#[cfg(feature = "ast")]
538fn infer_crate_name(workspace: &Path, file: &Path) -> String {
539 let mut cur = file.parent();
541 while let Some(dir) = cur {
542 let cargo = dir.join("Cargo.toml");
543 if cargo.exists() {
544 if let Ok(text) = std::fs::read_to_string(&cargo) {
545 if let Some(name) = parse_cargo_package_name(&text) {
546 return name;
547 }
548 }
549 if let Some(n) = dir.file_name().and_then(|n| n.to_str()) {
550 return n.to_string();
551 }
552 }
553 if dir == workspace {
554 break;
555 }
556 cur = dir.parent();
557 }
558 workspace
559 .file_name()
560 .and_then(|n| n.to_str())
561 .unwrap_or("workspace")
562 .to_string()
563}
564
565#[cfg(feature = "ast")]
566fn parse_cargo_package_name(toml: &str) -> Option<String> {
567 let mut in_package = false;
568 for line in toml.lines() {
569 let t = line.trim();
570 if t.starts_with('[') {
571 in_package = t == "[package]";
572 continue;
573 }
574 if in_package {
575 if let Some(rest) = t.strip_prefix("name") {
576 let rest = rest.trim().trim_start_matches('=').trim();
577 let name = rest.trim_matches('"').trim_matches('\'').to_string();
578 if !name.is_empty() {
579 return Some(name);
580 }
581 }
582 }
583 }
584 None
585}
586
587fn resolve_symbol_against_conn(
589 conn: &rusqlite::Connection,
590 raw_path: &str,
591) -> Result<Option<String>> {
592 let Some(sym) = crate::symbols::parse_symbol_path(raw_path) else {
593 return Ok(None);
594 };
595
596 let mut stmt = conn.prepare("SELECT id FROM nodes WHERE node_type = 'symbol'")?;
598 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
599 let mut ids = std::collections::HashSet::new();
600 for r in rows {
601 ids.insert(r?);
602 }
603
604 if let Some(id) = crate::symbols::resolve_symbol_ref(&sym, &ids) {
605 return Ok(Some(id));
606 }
607
608 resolve_against_conn(conn, &sym.symbol_name)
610}
611
612fn resolve_against_conn(conn: &rusqlite::Connection, raw: &str) -> Result<Option<String>> {
614 let key = raw.trim().to_lowercase();
615 if key.is_empty() {
616 return Ok(None);
617 }
618
619 let exact: Option<String> = conn
621 .query_row("SELECT id FROM nodes WHERE id = ?1", [&key], |row| row.get(0))
622 .optional_compat()?;
623 if exact.is_some() {
624 return Ok(exact);
625 }
626
627 let by_alias: Option<String> = conn
629 .query_row(
630 "SELECT node_id FROM node_aliases WHERE alias = ?1",
631 [&key],
632 |row| row.get(0),
633 )
634 .optional_compat()?;
635 if by_alias.is_some() {
636 return Ok(by_alias);
637 }
638
639 let mut stmt = conn.prepare("SELECT id FROM nodes WHERE lower(title) = ?1")?;
641 let rows = stmt.query_map([&key], |row| row.get::<_, String>(0))?;
642 let mut hits = Vec::new();
643 for r in rows {
644 hits.push(r?);
645 }
646 if hits.len() == 1 {
647 return Ok(Some(hits.remove(0)));
648 }
649
650 let mut stmt = conn.prepare("SELECT id FROM nodes")?;
652 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
653 let mut suffix_hits = Vec::new();
654 for r in rows {
655 let id = r?;
656 if id == key || id.ends_with(&format!("/{key}")) || id.rsplit('/').next() == Some(key.as_str())
657 {
658 suffix_hits.push(id);
659 }
660 }
661 suffix_hits.sort();
662 suffix_hits.dedup();
663 if suffix_hits.len() == 1 {
664 return Ok(Some(suffix_hits.remove(0)));
665 }
666
667 Ok(None)
668}
669
670trait OptionalCompat<T> {
672 fn optional_compat(self) -> Result<Option<T>>;
673}
674
675impl<T> OptionalCompat<T> for std::result::Result<T, rusqlite::Error> {
676 fn optional_compat(self) -> Result<Option<T>> {
677 match self {
678 Ok(v) => Ok(Some(v)),
679 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
680 Err(e) => Err(BrainError::from(e)),
681 }
682 }
683}
684
685#[cfg(not(feature = "obsidian"))]
686struct RawLink {
687 target_node: String,
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693 use tempfile::tempdir;
694
695 #[test]
696 fn index_workspace_and_fts_idempotent() {
697 let dir = tempdir().unwrap();
698 let docs = dir.path().join("docs");
699 std::fs::create_dir_all(&docs).unwrap();
700 std::fs::write(
701 docs.join("raft.md"),
702 "---\ntags: [raft]\nnode_type: concept\naliases: [Raft Consensus]\n---\n# Raft Consensus\nRelates to [[log-compaction]].\n",
703 )
704 .unwrap();
705 std::fs::write(
706 docs.join("log-compaction.md"),
707 "---\ntags: [log]\nnode_type: concept\n---\n# Log Compaction\nSee [[raft]].\n",
708 )
709 .unwrap();
710
711 let brain = dir.path().join(".brain");
712 std::fs::create_dir_all(&brain).unwrap();
713 let db = Database::open(brain.join("db.sqlite")).unwrap();
714 let indexer = WorkspaceIndexer::new(db, dir.path());
715
716 let s1 = indexer.index_workspace().unwrap();
717 assert_eq!(s1.markdown_files, 2);
718 assert!(s1.edges_created >= 2);
719 let fts1 = indexer.database().count_fts_rows().unwrap();
720 assert_eq!(fts1, 2);
721
722 let s2 = indexer.index_workspace().unwrap();
725 assert_eq!(s2.nodes_skipped_unchanged, 2);
726 assert_eq!(indexer.database().count_fts_rows().unwrap(), 2);
727
728 std::fs::write(
730 docs.join("raft.md"),
731 "---\ntags: [raft]\nnode_type: concept\n---\n# Raft Consensus\nUpdated body [[log-compaction]].\n",
732 )
733 .unwrap();
734 let s3 = indexer.index_workspace().unwrap();
735 assert_eq!(s3.nodes_upserted, 1);
736 assert_eq!(indexer.database().count_fts_rows().unwrap(), 2);
737
738 let hits = indexer.database().search_fts("raft").unwrap();
739 assert!(!hits.is_empty());
740 assert!(hits.iter().any(|n| n.id.contains("raft")));
741 }
742
743 #[test]
744 fn pending_link_then_resolve() {
745 let dir = tempdir().unwrap();
746 let docs = dir.path().join("docs");
747 std::fs::create_dir_all(&docs).unwrap();
748 std::fs::write(
749 docs.join("a.md"),
750 "---\nnode_type: concept\n---\n# A\nLink to [[b]].\n",
751 )
752 .unwrap();
753
754 let brain = dir.path().join(".brain");
755 std::fs::create_dir_all(&brain).unwrap();
756 let db = Database::open(brain.join("db.sqlite")).unwrap();
757 let indexer = WorkspaceIndexer::new(db, dir.path());
758 let s1 = indexer.index_workspace().unwrap();
759 assert!(s1.edges_pending >= 1 || indexer.database().count_pending_links().unwrap() >= 1);
760
761 std::fs::write(
762 docs.join("b.md"),
763 "---\nnode_type: concept\n---\n# B\nBack.\n",
764 )
765 .unwrap();
766 let s2 = indexer.index_workspace().unwrap();
767 assert!(s2.edges_created >= 1 || indexer.database().count_edges().unwrap() >= 1);
768 }
769}