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 match ext {
368 Some("md") => {
369 self.index_markdown_file(&path, stats)?;
370 }
371 #[cfg(feature = "obsidian")]
372 Some("canvas") => {
373 self.index_canvas_file(&path, stats)?;
374 }
375 #[cfg(feature = "ast")]
376 Some("rs") => {
377 self.index_rust_file(&path, ast_parser, stats)?;
378 }
379 _ => {}
380 }
381 }
382 }
383 Ok(())
384 }
385
386 #[cfg(feature = "ast")]
387 fn index_rust_file(
388 &self,
389 path: &Path,
390 ast_parser: &mut crate::ast::CodeAstParser,
391 stats: &mut SyncStats,
392 ) -> Result<()> {
393 let rel = rel_path_from_workspace(&self.workspace, path);
394 let rel_str = rel.to_string_lossy().replace('\\', "/");
395 let crate_name = infer_crate_name(&self.workspace, path);
396 let source = std::fs::read_to_string(path)?;
397 let anchors = ast_parser
398 .parse_symbols(&crate_name, &rel_str, &source)
399 .map_err(|e| BrainError::Ast(e.to_string()))?;
400
401 for anchor in &anchors {
402 self.db.insert_symbol_anchor(anchor)?;
403
404 let node_id = crate::symbols::symbol_node_id(
405 &anchor.crate_name,
406 &anchor.module_path,
407 &anchor.symbol_name,
408 );
409 let sig = format!(
411 "{}::{}::{}@{}-{}",
412 anchor.crate_name,
413 anchor.module_path,
414 anchor.symbol_name,
415 anchor.start_line,
416 anchor.end_line
417 );
418 let chash = crate::id::content_hash(sig.as_bytes());
419 if let Some(existing) = self.db.get_content_hash(&node_id)? {
420 if existing == chash {
421 stats.nodes_skipped_unchanged += 1;
422 stats.symbol_anchors += 1;
423 continue;
424 }
425 }
426
427 let now = Utc::now().timestamp();
428 let node = Node {
429 id: node_id.clone(),
430 node_type: NodeType::Symbol,
431 title: anchor.symbol_name.clone(),
432 file_path: Some(anchor.file_path.clone()),
433 symbol_hash: Some(anchor.symbol_hash),
434 summary: anchor.doc_comment.clone(),
435 content_hash: Some(chash),
436 created_at: now,
437 updated_at: now,
438 };
439 self.db.insert_node(&node)?;
440
441 let mut aliases = vec![anchor.symbol_name.clone()];
443 if let Some((_, method)) = anchor.symbol_name.split_once("::") {
444 aliases.push(method.to_string());
445 }
446 aliases.push(format!(
447 "{}::{}::{}",
448 anchor.crate_name, anchor.module_path, anchor.symbol_name
449 ));
450 self.db.replace_node_aliases(&node_id, &aliases)?;
451
452 let fts_body = format!(
453 "{} {} {} {}",
454 anchor.symbol_name,
455 anchor.module_path,
456 anchor.crate_name,
457 anchor.doc_comment.as_deref().unwrap_or("")
458 );
459 self.db
460 .index_fts(&node_id, &anchor.symbol_name, &fts_body, "symbol")?;
461
462 stats.nodes_upserted += 1;
463 stats.symbol_anchors += 1;
464 }
465 stats.rust_files += 1;
466 Ok(())
467 }
468}
469
470fn should_skip_dir(name: &str) -> bool {
471 matches!(
472 name,
473 "target"
474 | "node_modules"
475 | "vendor"
476 | ".git"
477 | ".brain"
478 | "dist"
479 | "build"
480 | ".svn"
481 | ".hg"
482 ) || name.starts_with('.')
483}
484
485fn first_substantive_line(body: &str) -> String {
486 for line in body.lines() {
487 let t = line.trim();
488 if t.is_empty() {
489 continue;
490 }
491 let cleaned = t.trim_start_matches('#').trim();
493 if !cleaned.is_empty() {
494 return cleaned.to_string();
495 }
496 }
497 String::new()
498}
499
500fn resolve_title(
501 #[cfg(feature = "obsidian")] frontmatter: Option<&crate::obsidian::Frontmatter>,
502 #[cfg(not(feature = "obsidian"))] frontmatter: Option<&()>,
503 body: &str,
504 rel: &Path,
505) -> String {
506 #[cfg(feature = "obsidian")]
507 if let Some(fm) = frontmatter {
508 if let Some(title) = fm.extra.get("title").and_then(|v| v.as_str()) {
509 let t = title.trim();
510 if !t.is_empty() {
511 return t.to_string();
512 }
513 }
514 }
515 #[cfg(not(feature = "obsidian"))]
516 let _ = frontmatter;
517
518 for line in body.lines() {
520 let t = line.trim();
521 if let Some(rest) = t.strip_prefix("# ") {
522 let t = rest.trim();
523 if !t.is_empty() {
524 return t.to_string();
525 }
526 }
527 }
528
529 rel.file_stem()
530 .and_then(|s| s.to_str())
531 .unwrap_or("Untitled")
532 .to_string()
533}
534
535#[cfg(feature = "ast")]
536fn infer_crate_name(workspace: &Path, file: &Path) -> String {
537 let mut cur = file.parent();
539 while let Some(dir) = cur {
540 let cargo = dir.join("Cargo.toml");
541 if cargo.exists() {
542 if let Ok(text) = std::fs::read_to_string(&cargo) {
543 if let Some(name) = parse_cargo_package_name(&text) {
544 return name;
545 }
546 }
547 if let Some(n) = dir.file_name().and_then(|n| n.to_str()) {
548 return n.to_string();
549 }
550 }
551 if dir == workspace {
552 break;
553 }
554 cur = dir.parent();
555 }
556 workspace
557 .file_name()
558 .and_then(|n| n.to_str())
559 .unwrap_or("workspace")
560 .to_string()
561}
562
563#[cfg(feature = "ast")]
564fn parse_cargo_package_name(toml: &str) -> Option<String> {
565 let mut in_package = false;
566 for line in toml.lines() {
567 let t = line.trim();
568 if t.starts_with('[') {
569 in_package = t == "[package]";
570 continue;
571 }
572 if in_package {
573 if let Some(rest) = t.strip_prefix("name") {
574 let rest = rest.trim().trim_start_matches('=').trim();
575 let name = rest.trim_matches('"').trim_matches('\'').to_string();
576 if !name.is_empty() {
577 return Some(name);
578 }
579 }
580 }
581 }
582 None
583}
584
585fn resolve_symbol_against_conn(
587 conn: &rusqlite::Connection,
588 raw_path: &str,
589) -> Result<Option<String>> {
590 let Some(sym) = crate::symbols::parse_symbol_path(raw_path) else {
591 return Ok(None);
592 };
593
594 let mut stmt = conn.prepare("SELECT id FROM nodes WHERE node_type = 'symbol'")?;
596 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
597 let mut ids = std::collections::HashSet::new();
598 for r in rows {
599 ids.insert(r?);
600 }
601
602 if let Some(id) = crate::symbols::resolve_symbol_ref(&sym, &ids) {
603 return Ok(Some(id));
604 }
605
606 resolve_against_conn(conn, &sym.symbol_name)
608}
609
610fn resolve_against_conn(conn: &rusqlite::Connection, raw: &str) -> Result<Option<String>> {
612 let key = raw.trim().to_lowercase();
613 if key.is_empty() {
614 return Ok(None);
615 }
616
617 let exact: Option<String> = conn
619 .query_row("SELECT id FROM nodes WHERE id = ?1", [&key], |row| row.get(0))
620 .optional_compat()?;
621 if exact.is_some() {
622 return Ok(exact);
623 }
624
625 let by_alias: Option<String> = conn
627 .query_row(
628 "SELECT node_id FROM node_aliases WHERE alias = ?1",
629 [&key],
630 |row| row.get(0),
631 )
632 .optional_compat()?;
633 if by_alias.is_some() {
634 return Ok(by_alias);
635 }
636
637 let mut stmt = conn.prepare("SELECT id FROM nodes WHERE lower(title) = ?1")?;
639 let rows = stmt.query_map([&key], |row| row.get::<_, String>(0))?;
640 let mut hits = Vec::new();
641 for r in rows {
642 hits.push(r?);
643 }
644 if hits.len() == 1 {
645 return Ok(Some(hits.remove(0)));
646 }
647
648 let mut stmt = conn.prepare("SELECT id FROM nodes")?;
650 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
651 let mut suffix_hits = Vec::new();
652 for r in rows {
653 let id = r?;
654 if id == key || id.ends_with(&format!("/{key}")) || id.rsplit('/').next() == Some(key.as_str())
655 {
656 suffix_hits.push(id);
657 }
658 }
659 suffix_hits.sort();
660 suffix_hits.dedup();
661 if suffix_hits.len() == 1 {
662 return Ok(Some(suffix_hits.remove(0)));
663 }
664
665 Ok(None)
666}
667
668trait OptionalCompat<T> {
670 fn optional_compat(self) -> Result<Option<T>>;
671}
672
673impl<T> OptionalCompat<T> for std::result::Result<T, rusqlite::Error> {
674 fn optional_compat(self) -> Result<Option<T>> {
675 match self {
676 Ok(v) => Ok(Some(v)),
677 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
678 Err(e) => Err(BrainError::from(e)),
679 }
680 }
681}
682
683#[cfg(not(feature = "obsidian"))]
684struct RawLink {
685 target_node: String,
686}
687
688#[cfg(test)]
689mod tests {
690 use super::*;
691 use tempfile::tempdir;
692
693 #[test]
694 fn index_workspace_and_fts_idempotent() {
695 let dir = tempdir().unwrap();
696 let docs = dir.path().join("docs");
697 std::fs::create_dir_all(&docs).unwrap();
698 std::fs::write(
699 docs.join("raft.md"),
700 "---\ntags: [raft]\nnode_type: concept\naliases: [Raft Consensus]\n---\n# Raft Consensus\nRelates to [[log-compaction]].\n",
701 )
702 .unwrap();
703 std::fs::write(
704 docs.join("log-compaction.md"),
705 "---\ntags: [log]\nnode_type: concept\n---\n# Log Compaction\nSee [[raft]].\n",
706 )
707 .unwrap();
708
709 let brain = dir.path().join(".brain");
710 std::fs::create_dir_all(&brain).unwrap();
711 let db = Database::open(brain.join("db.sqlite")).unwrap();
712 let indexer = WorkspaceIndexer::new(db, dir.path());
713
714 let s1 = indexer.index_workspace().unwrap();
715 assert_eq!(s1.markdown_files, 2);
716 assert!(s1.edges_created >= 2);
717 let fts1 = indexer.database().count_fts_rows().unwrap();
718 assert_eq!(fts1, 2);
719
720 let s2 = indexer.index_workspace().unwrap();
723 assert_eq!(s2.nodes_skipped_unchanged, 2);
724 assert_eq!(indexer.database().count_fts_rows().unwrap(), 2);
725
726 std::fs::write(
728 docs.join("raft.md"),
729 "---\ntags: [raft]\nnode_type: concept\n---\n# Raft Consensus\nUpdated body [[log-compaction]].\n",
730 )
731 .unwrap();
732 let s3 = indexer.index_workspace().unwrap();
733 assert_eq!(s3.nodes_upserted, 1);
734 assert_eq!(indexer.database().count_fts_rows().unwrap(), 2);
735
736 let hits = indexer.database().search_fts("raft").unwrap();
737 assert!(!hits.is_empty());
738 assert!(hits.iter().any(|n| n.id.contains("raft")));
739 }
740
741 #[test]
742 fn pending_link_then_resolve() {
743 let dir = tempdir().unwrap();
744 let docs = dir.path().join("docs");
745 std::fs::create_dir_all(&docs).unwrap();
746 std::fs::write(
747 docs.join("a.md"),
748 "---\nnode_type: concept\n---\n# A\nLink to [[b]].\n",
749 )
750 .unwrap();
751
752 let brain = dir.path().join(".brain");
753 std::fs::create_dir_all(&brain).unwrap();
754 let db = Database::open(brain.join("db.sqlite")).unwrap();
755 let indexer = WorkspaceIndexer::new(db, dir.path());
756 let s1 = indexer.index_workspace().unwrap();
757 assert!(s1.edges_pending >= 1 || indexer.database().count_pending_links().unwrap() >= 1);
758
759 std::fs::write(
760 docs.join("b.md"),
761 "---\nnode_type: concept\n---\n# B\nBack.\n",
762 )
763 .unwrap();
764 let s2 = indexer.index_workspace().unwrap();
765 assert!(s2.edges_created >= 1 || indexer.database().count_edges().unwrap() >= 1);
766 }
767}