1use crate::error::{BrainError, Result};
13use crate::storage::Database;
14use serde::{Deserialize, Serialize};
15use std::collections::{BTreeMap, BTreeSet};
16use std::fs;
17use std::path::{Path, PathBuf};
18
19pub const MAIN_SCOPE: &str = "main";
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
24#[serde(rename_all = "snake_case")]
25pub enum BrainMode {
26 #[default]
28 Single,
29 Multi,
31}
32
33impl BrainMode {
34 pub fn parse(s: &str) -> Option<Self> {
36 match s.trim().to_ascii_lowercase().as_str() {
37 "single" | "one" | "flat" => Some(Self::Single),
38 "multi" | "multi-brain" | "multibrain" | "scopes" => Some(Self::Multi),
39 _ => None,
40 }
41 }
42
43 pub fn as_str(self) -> &'static str {
45 match self {
46 Self::Single => "single",
47 Self::Multi => "multi",
48 }
49 }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
54#[serde(rename_all = "snake_case")]
55pub enum ScopeSource {
56 #[default]
58 Manual,
59 CargoWorkspace,
61 Imported,
63 ExtraRoot,
65}
66
67impl ScopeSource {
68 pub fn as_str(&self) -> &'static str {
70 match self {
71 Self::Manual => "manual",
72 Self::CargoWorkspace => "cargo-workspace",
73 Self::Imported => "imported",
74 Self::ExtraRoot => "extra-root",
75 }
76 }
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
81pub struct ScopeDef {
82 pub id: String,
84 pub roots: Vec<String>,
86 #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 pub aliases: Vec<String>,
89 #[serde(default)]
91 pub source: ScopeSource,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
96pub struct WorkspaceManifest {
97 pub version: u32,
99 pub workspace: String,
101 #[serde(default)]
103 pub mode: BrainMode,
104 #[serde(default = "default_main_id")]
106 pub main_id: String,
107 #[serde(default)]
109 pub scopes: Vec<ScopeDef>,
110 #[serde(default)]
112 pub discovery: DiscoveryOptions,
113}
114
115fn default_main_id() -> String {
116 MAIN_SCOPE.to_string()
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
121pub struct DiscoveryOptions {
122 #[serde(default)]
124 pub cargo_workspace: bool,
125}
126
127impl WorkspaceManifest {
128 pub fn single(workspace: &Path) -> Self {
130 Self {
131 version: 2,
132 workspace: workspace.display().to_string(),
133 mode: BrainMode::Single,
134 main_id: MAIN_SCOPE.to_string(),
135 scopes: Vec::new(),
136 discovery: DiscoveryOptions::default(),
137 }
138 }
139
140 pub fn is_multi(&self) -> bool {
142 self.mode == BrainMode::Multi
143 }
144
145 pub fn resolve_scope(&self, rel_path: &str) -> String {
150 if !self.is_multi() {
151 return self.main_id.clone();
152 }
153 let rel = normalize_rel(rel_path);
154 let mut best: Option<(&str, usize)> = None;
155 for sc in &self.scopes {
156 for root in &sc.roots {
157 let r = normalize_rel(root);
158 if r.is_empty() {
159 continue;
160 }
161 if rel == r || rel.starts_with(&(r.clone() + "/")) {
162 let len = r.len();
163 if best.map(|(_, l)| len > l).unwrap_or(true) {
164 best = Some((sc.id.as_str(), len));
165 }
166 }
167 }
168 }
169 best.map(|(id, _)| id.to_string())
170 .unwrap_or_else(|| self.main_id.clone())
171 }
172
173 pub fn find_scope(&self, id_or_alias: &str) -> Option<&ScopeDef> {
175 let key = id_or_alias.trim();
176 self.scopes.iter().find(|s| {
177 s.id == key || s.aliases.iter().any(|a| a == key)
178 })
179 }
180
181 pub fn find_scope_mut(&mut self, id_or_alias: &str) -> Option<&mut ScopeDef> {
183 let key = id_or_alias.trim().to_string();
184 self.scopes.iter_mut().find(|s| {
185 s.id == key || s.aliases.iter().any(|a| a == &key)
186 })
187 }
188
189 pub fn all_scope_ids(&self) -> Vec<String> {
191 let mut v = vec![self.main_id.clone()];
192 for s in &self.scopes {
193 v.push(s.id.clone());
194 }
195 v
196 }
197
198 pub fn validate(&self) -> Vec<String> {
200 let mut errs = Vec::new();
201 let mut seen_ids = BTreeSet::new();
202 if self.main_id.trim().is_empty() {
203 errs.push("main_id must not be empty".into());
204 }
205 for sc in &self.scopes {
206 if sc.id == self.main_id || sc.id == MAIN_SCOPE && self.main_id != MAIN_SCOPE {
207 }
209 if sc.id == self.main_id {
210 errs.push(format!("scope id {:?} collides with main_id", sc.id));
211 }
212 if !seen_ids.insert(sc.id.clone()) {
213 errs.push(format!("duplicate scope id {:?}", sc.id));
214 }
215 if sc.id.trim().is_empty() {
216 errs.push("empty scope id".into());
217 }
218 if sc.roots.is_empty() {
219 errs.push(format!("scope {:?} has no roots", sc.id));
220 }
221 for root in &sc.roots {
222 if root.trim().is_empty() || root.contains("..") {
223 errs.push(format!("invalid root {:?} on scope {:?}", root, sc.id));
224 }
225 }
226 }
227 let mut root_owners: Vec<(String, String)> = Vec::new();
229 for sc in &self.scopes {
230 for r in &sc.roots {
231 root_owners.push((normalize_rel(r), sc.id.clone()));
232 }
233 }
234 for i in 0..root_owners.len() {
235 for j in (i + 1)..root_owners.len() {
236 let (ra, sa) = &root_owners[i];
237 let (rb, sb) = &root_owners[j];
238 if sa == sb {
239 continue;
240 }
241 if ra == rb || ra.starts_with(&(rb.clone() + "/")) || rb.starts_with(&(ra.clone() + "/"))
242 {
243 errs.push(format!(
244 "overlapping roots {ra:?} (scope {sa}) and {rb:?} (scope {sb})"
245 ));
246 }
247 }
248 }
249 errs
250 }
251}
252
253fn normalize_rel(p: &str) -> String {
254 p.replace('\\', "/")
255 .trim_matches('/')
256 .trim()
257 .to_string()
258}
259
260pub fn sanitize_scope_id(raw: &str) -> Result<String> {
262 let s = raw.trim().to_ascii_lowercase().replace('_', "-");
263 if s.is_empty() {
264 return Err(BrainError::other("scope id must not be empty"));
265 }
266 if s == MAIN_SCOPE {
267 return Err(BrainError::other(
268 "scope id 'main' is reserved for MainBrain — use a SubBrain id",
269 ));
270 }
271 if !s
272 .chars()
273 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.')
274 {
275 return Err(BrainError::other(format!(
276 "invalid scope id {raw:?}: use letters, digits, '-', '.'"
277 )));
278 }
279 Ok(s)
280}
281
282pub fn manifest_path(workspace: &Path) -> PathBuf {
284 workspace.join(".brain").join("workspace.json")
285}
286
287pub fn load_manifest(workspace: &Path) -> Result<WorkspaceManifest> {
289 let path = manifest_path(workspace);
290 if !path.is_file() {
291 return Ok(WorkspaceManifest::single(workspace));
292 }
293 let raw = fs::read_to_string(&path)?;
294 parse_manifest_json(&raw, workspace)
295}
296
297fn parse_manifest_json(raw: &str, workspace: &Path) -> Result<WorkspaceManifest> {
298 let v: serde_json::Value = serde_json::from_str(raw)
300 .map_err(|e| BrainError::other(format!("workspace.json: {e}")))?;
301 let version = v.get("version").and_then(|x| x.as_u64()).unwrap_or(1) as u32;
302 if version <= 1 && v.get("mode").is_none() && v.get("scopes").is_none() {
303 let mut m = WorkspaceManifest::single(workspace);
304 if let Some(ws) = v.get("workspace").and_then(|x| x.as_str()) {
305 m.workspace = ws.to_string();
306 }
307 return Ok(m);
308 }
309 let mut m: WorkspaceManifest = serde_json::from_value(v)
310 .map_err(|e| BrainError::other(format!("workspace.json: {e}")))?;
311 if m.main_id.trim().is_empty() {
312 m.main_id = MAIN_SCOPE.to_string();
313 }
314 if m.version < 2 {
315 m.version = 2;
316 }
317 Ok(m)
318}
319
320pub fn save_manifest(workspace: &Path, manifest: &WorkspaceManifest) -> Result<()> {
322 let errs = manifest.validate();
323 if !errs.is_empty() {
324 return Err(BrainError::other(format!(
325 "invalid workspace manifest: {}",
326 errs.join("; ")
327 )));
328 }
329 let brain = workspace.join(".brain");
330 fs::create_dir_all(&brain)?;
331 let path = manifest_path(workspace);
332 let mut m = manifest.clone();
333 m.version = 2;
334 m.workspace = workspace.display().to_string();
335 let text = serde_json::to_string_pretty(&m)?;
336 fs::write(path, text + "\n")?;
337 Ok(())
338}
339
340pub fn ensure_manifest(workspace: &Path) -> Result<WorkspaceManifest> {
342 let path = manifest_path(workspace);
343 if path.is_file() {
344 return load_manifest(workspace);
345 }
346 let m = WorkspaceManifest::single(workspace);
347 save_manifest(workspace, &m)?;
348 Ok(m)
349}
350
351#[derive(Debug, Clone)]
355pub struct CargoMember {
356 pub id: String,
358 pub root: String,
360 pub package_name: Option<String>,
362}
363
364pub fn discover_cargo_members(workspace: &Path) -> Result<Vec<CargoMember>> {
366 let cargo = workspace.join("Cargo.toml");
367 if !cargo.is_file() {
368 return Ok(Vec::new());
369 }
370 let text = fs::read_to_string(&cargo)?;
371 let value: toml::Value = text
372 .parse()
373 .map_err(|e| BrainError::other(format!("Cargo.toml: {e}")))?;
374 let Some(members) = value
375 .get("workspace")
376 .and_then(|w| w.get("members"))
377 .and_then(|m| m.as_array())
378 else {
379 return Ok(Vec::new());
380 };
381 let mut out = Vec::new();
382 let mut seen = BTreeSet::new();
383 for m in members {
384 let Some(pat) = m.as_str() else { continue };
385 let paths = expand_member_pattern(workspace, pat)?;
387 for rel in paths {
388 let id = path_scope_id(&rel);
389 if !seen.insert(id.clone()) {
390 continue;
391 }
392 let pkg = read_package_name(&workspace.join(&rel));
393 out.push(CargoMember {
394 id,
395 root: normalize_rel(&rel),
396 package_name: pkg,
397 });
398 }
399 }
400 out.sort_by(|a, b| a.id.cmp(&b.id));
401 Ok(out)
402}
403
404fn expand_member_pattern(workspace: &Path, pat: &str) -> Result<Vec<String>> {
405 let pat = pat.trim().trim_matches('/').replace('\\', "/");
406 if let Some(parent) = pat.strip_suffix("/*") {
407 let dir = workspace.join(parent);
408 if !dir.is_dir() {
409 return Ok(Vec::new());
410 }
411 let mut v = Vec::new();
412 for ent in fs::read_dir(&dir)? {
413 let ent = ent?;
414 if ent.file_type()?.is_dir() {
415 let name = ent.file_name().to_string_lossy().to_string();
416 if name.starts_with('.') {
417 continue;
418 }
419 v.push(format!("{}/{}", normalize_rel(parent), name));
420 }
421 }
422 v.sort();
423 return Ok(v);
424 }
425 if workspace.join(&pat).is_dir() {
426 Ok(vec![normalize_rel(&pat)])
427 } else {
428 Ok(Vec::new())
429 }
430}
431
432fn path_scope_id(rel: &str) -> String {
433 let rel = normalize_rel(rel);
434 let last = rel.rsplit('/').next().unwrap_or(&rel);
435 let id = last.to_ascii_lowercase().replace('_', "-");
437 if id.is_empty() {
438 "crate".into()
439 } else {
440 id
441 }
442}
443
444fn read_package_name(member_dir: &Path) -> Option<String> {
445 let cargo = member_dir.join("Cargo.toml");
446 let text = fs::read_to_string(cargo).ok()?;
447 let value: toml::Value = text.parse().ok()?;
448 value
449 .get("package")
450 .and_then(|p| p.get("name"))
451 .and_then(|n| n.as_str())
452 .map(|s| s.to_string())
453}
454
455pub fn apply_cargo_discovery(manifest: &mut WorkspaceManifest, members: &[CargoMember]) {
457 manifest.scopes.retain(|s| s.source != ScopeSource::CargoWorkspace);
458 for m in members {
459 if m.id == manifest.main_id {
460 continue;
461 }
462 if manifest.find_scope(&m.id).is_some() {
463 continue;
465 }
466 let mut aliases = Vec::new();
467 if let Some(pkg) = &m.package_name {
468 let a = pkg.to_ascii_lowercase().replace('_', "-");
469 if a != m.id {
470 aliases.push(a);
471 }
472 }
473 manifest.scopes.push(ScopeDef {
474 id: m.id.clone(),
475 roots: vec![m.root.clone()],
476 aliases,
477 source: ScopeSource::CargoWorkspace,
478 });
479 }
480 manifest.discovery.cargo_workspace = true;
481}
482
483pub fn enable_multi(workspace: &Path, discover_cargo: bool) -> Result<WorkspaceManifest> {
487 let mut m = load_manifest(workspace)?;
488 m.mode = BrainMode::Multi;
489 m.version = 2;
490 if discover_cargo {
491 let members = discover_cargo_members(workspace)?;
492 apply_cargo_discovery(&mut m, &members);
493 }
494 save_manifest(workspace, &m)?;
495 Ok(m)
496}
497
498pub fn disable_multi(workspace: &Path, clear_scopes: bool) -> Result<WorkspaceManifest> {
500 let mut m = load_manifest(workspace)?;
501 m.mode = BrainMode::Single;
502 if clear_scopes {
503 m.scopes.clear();
504 m.discovery.cargo_workspace = false;
505 }
506 save_manifest(workspace, &m)?;
507 Ok(m)
508}
509
510pub fn add_scope(
512 workspace: &Path,
513 id: &str,
514 roots: &[String],
515 aliases: &[String],
516 source: ScopeSource,
517) -> Result<WorkspaceManifest> {
518 let id = sanitize_scope_id(id)?;
519 if roots.is_empty() {
520 return Err(BrainError::other("at least one --root is required"));
521 }
522 let mut m = load_manifest(workspace)?;
523 if m.mode != BrainMode::Multi {
524 m.mode = BrainMode::Multi;
525 }
526 let roots: Vec<String> = roots.iter().map(|r| normalize_rel(r)).collect();
527 for r in &roots {
528 let abs = workspace.join(r);
529 if !abs.exists() {
530 return Err(BrainError::other(format!(
531 "root does not exist: {r} (under {})",
532 workspace.display()
533 )));
534 }
535 }
536 if let Some(existing) = m.find_scope_mut(&id) {
537 existing.roots = roots;
538 for a in aliases {
539 if !existing.aliases.contains(a) {
540 existing.aliases.push(a.clone());
541 }
542 }
543 existing.source = source;
544 } else {
545 m.scopes.push(ScopeDef {
546 id: id.clone(),
547 roots,
548 aliases: aliases.to_vec(),
549 source,
550 });
551 }
552 save_manifest(workspace, &m)?;
553 Ok(m)
554}
555
556pub fn remove_scope_def(workspace: &Path, id: &str) -> Result<WorkspaceManifest> {
558 let mut m = load_manifest(workspace)?;
559 let before = m.scopes.len();
560 m.scopes.retain(|s| s.id != id && !s.aliases.iter().any(|a| a == id));
561 if m.scopes.len() == before {
562 return Err(BrainError::other(format!("unknown scope {id:?}")));
563 }
564 save_manifest(workspace, &m)?;
565 Ok(m)
566}
567
568pub fn reassign_node_scopes(db: &Database, from_scope: &str, to_scope: &str) -> Result<usize> {
570 let n = db.conn.execute(
571 "UPDATE nodes SET scope = ?1 WHERE scope = ?2",
572 rusqlite::params![to_scope, from_scope],
573 )?;
574 Ok(n)
575}
576
577pub fn absorb_scope(workspace: &Path, db: &Database, id: &str) -> Result<AbsorbReport> {
582 let m = load_manifest(workspace)?;
583 let sc = m
584 .find_scope(id)
585 .ok_or_else(|| BrainError::other(format!("unknown scope {id:?}")))?;
586 let scope_id = sc.id.clone();
587 let main = m.main_id.clone();
588 let mut stmt = db
589 .conn
590 .prepare("SELECT id FROM nodes WHERE scope = ?1")?;
591 let ids: Vec<String> = stmt
592 .query_map([&scope_id], |row| row.get(0))?
593 .filter_map(|r| r.ok())
594 .collect();
595 let n = reassign_node_scopes(db, &scope_id, &main)?;
596 for nid in &ids {
597 db.set_node_scope(nid, &main)?;
598 }
599 let m = remove_scope_def(workspace, &scope_id)?;
600 Ok(AbsorbReport {
601 absorbed_id: scope_id,
602 nodes_reassigned: n,
603 manifest: m,
604 })
605}
606
607#[derive(Debug, Clone)]
609pub struct AbsorbReport {
610 pub absorbed_id: String,
612 pub nodes_reassigned: usize,
614 pub manifest: WorkspaceManifest,
616}
617
618pub fn absorb_all_to_main(workspace: &Path, db: &Database) -> Result<AbsorbAllReport> {
620 let m = load_manifest(workspace)?;
621 let ids: Vec<String> = m.scopes.iter().map(|s| s.id.clone()).collect();
622 let mut total = 0usize;
623 let mut touched: Vec<String> = Vec::new();
624 for id in &ids {
625 let mut stmt = db
626 .conn
627 .prepare("SELECT id FROM nodes WHERE scope = ?1")?;
628 for nid in stmt
629 .query_map([id.as_str()], |row| row.get::<_, String>(0))?
630 .flatten()
631 {
632 touched.push(nid);
633 }
634 total += reassign_node_scopes(db, id, MAIN_SCOPE)?;
635 }
636 let mut stmt = db
638 .conn
639 .prepare("SELECT id FROM nodes WHERE scope != ?1")?;
640 for nid in stmt
641 .query_map([MAIN_SCOPE], |row| row.get::<_, String>(0))?
642 .flatten()
643 {
644 touched.push(nid);
645 }
646 total += db.conn.execute(
647 "UPDATE nodes SET scope = ?1 WHERE scope != ?1",
648 rusqlite::params![MAIN_SCOPE],
649 )?;
650 touched.sort();
651 touched.dedup();
652 for nid in &touched {
653 db.set_node_scope(nid, MAIN_SCOPE)?;
654 }
655 let mut m = disable_multi(workspace, true)?;
656 m.mode = BrainMode::Single;
657 save_manifest(workspace, &m)?;
658 Ok(AbsorbAllReport {
659 scopes_removed: ids,
660 nodes_reassigned: total,
661 manifest: m,
662 })
663}
664
665#[derive(Debug, Clone, Serialize)]
669pub struct ReconcileReport {
670 pub updated: usize,
672 pub unchanged: usize,
674 pub orphan_scopes_cleared: Vec<String>,
677 pub mode: String,
679}
680
681pub fn reconcile_scopes(workspace: &Path, db: &Database) -> Result<ReconcileReport> {
685 let m = load_manifest(workspace)?;
686 let mut updated = 0usize;
687 let mut unchanged = 0usize;
688 let mut stmt = db.conn.prepare(
689 "SELECT id, file_path, scope FROM nodes",
690 )?;
691 let rows: Vec<(String, Option<String>, String)> = stmt
692 .query_map([], |row| {
693 Ok((
694 row.get(0)?,
695 row.get(1)?,
696 row.get::<_, Option<String>>(2)?
697 .unwrap_or_else(|| MAIN_SCOPE.to_string()),
698 ))
699 })?
700 .filter_map(|r| r.ok())
701 .collect();
702
703 let known: BTreeSet<String> = m.all_scope_ids().into_iter().collect();
704 let mut orphan_scopes: BTreeSet<String> = BTreeSet::new();
705
706 for (id, file_path, old_scope) in rows {
707 let expected = if !m.is_multi() {
708 m.main_id.clone()
709 } else if let Some(ref fp) = file_path {
710 let path_scope = m.resolve_scope(fp);
711 if let Some(fm) = read_frontmatter_scope(workspace, fp) {
713 if known.contains(&fm) || fm == m.main_id {
714 fm
715 } else {
716 path_scope
717 }
718 } else {
719 path_scope
720 }
721 } else {
722 if known.contains(&old_scope) {
724 old_scope.clone()
725 } else {
726 m.main_id.clone()
727 }
728 };
729
730 if !known.contains(&old_scope) && old_scope != m.main_id {
731 orphan_scopes.insert(old_scope.clone());
732 }
733
734 if expected != old_scope {
735 db.set_node_scope(&id, &expected)?;
736 updated += 1;
737 } else {
738 unchanged += 1;
739 }
740 }
741
742 Ok(ReconcileReport {
743 updated,
744 unchanged,
745 orphan_scopes_cleared: orphan_scopes.into_iter().collect(),
746 mode: m.mode.as_str().to_string(),
747 })
748}
749
750fn read_frontmatter_scope(workspace: &Path, rel: &str) -> Option<String> {
751 #[cfg(feature = "obsidian")]
752 {
753 let path = workspace.join(rel);
754 let text = fs::read_to_string(path).ok()?;
755 let (fm, _) = crate::obsidian::parse_frontmatter(&text);
756 let fm = fm?;
757 fm.extra
758 .get("scope")
759 .and_then(|v| v.as_str())
760 .map(|s| s.trim().to_ascii_lowercase().replace('_', "-"))
761 .filter(|s| !s.is_empty())
762 }
763 #[cfg(not(feature = "obsidian"))]
764 {
765 let _ = (workspace, rel);
766 None
767 }
768}
769
770pub fn attach_subbrain(
786 workspace: &Path,
787 id: &str,
788 root: &str,
789 aliases: &[String],
790) -> Result<WorkspaceManifest> {
791 let root = normalize_rel(root);
792 let abs = workspace.join(&root);
793 if !abs.is_dir() {
794 return Err(BrainError::other(format!(
795 "attach root does not exist or is not a directory: {root}"
796 )));
797 }
798 if root.is_empty() || root == "." {
800 return Err(BrainError::other(
801 "cannot attach workspace root as SubBrain — use MainBrain",
802 ));
803 }
804 add_scope(
805 workspace,
806 id,
807 &[root],
808 aliases,
809 ScopeSource::ExtraRoot,
810 )
811}
812
813#[derive(Debug, Clone)]
815pub struct AbsorbAllReport {
816 pub scopes_removed: Vec<String>,
818 pub nodes_reassigned: usize,
820 pub manifest: WorkspaceManifest,
822}
823
824pub const IMPORT_DEFAULT_MAX_FILES: usize = 5_000;
828pub const IMPORT_DEFAULT_MAX_BYTES: u64 = 50 * 1024 * 1024;
830
831#[derive(Debug, Clone)]
833pub struct ImportBrainOptions {
834 pub into_scope: String,
837 pub dest_root: Option<String>,
839 pub copy_markdown: bool,
841 pub force: bool,
843 pub mount: bool,
846 pub max_files: usize,
848 pub max_bytes: u64,
850}
851
852impl Default for ImportBrainOptions {
853 fn default() -> Self {
854 Self {
855 into_scope: MAIN_SCOPE.to_string(),
856 dest_root: None,
857 copy_markdown: true,
858 force: false,
859 mount: false,
860 max_files: IMPORT_DEFAULT_MAX_FILES,
861 max_bytes: IMPORT_DEFAULT_MAX_BYTES,
862 }
863 }
864}
865
866#[derive(Debug, Clone, Serialize)]
868pub struct ImportBrainReport {
869 pub source: String,
871 pub into_scope: String,
873 pub files_copied: usize,
875 pub files_skipped: usize,
877 pub dest_root: String,
879 pub scope_registered: bool,
881 pub mounted: bool,
883 pub bytes_copied: u64,
885}
886
887pub fn import_brain(
898 target_workspace: &Path,
899 source_workspace: &Path,
900 opts: &ImportBrainOptions,
901) -> Result<ImportBrainReport> {
902 let source_workspace = source_workspace
903 .canonicalize()
904 .unwrap_or_else(|_| source_workspace.to_path_buf());
905 let target_workspace = target_workspace
906 .canonicalize()
907 .unwrap_or_else(|_| target_workspace.to_path_buf());
908 if source_workspace == target_workspace {
909 return Err(BrainError::other(
910 "cannot import a workspace into itself — use scopes attach/absorb instead",
911 ));
912 }
913 if !source_workspace.is_dir() {
914 return Err(BrainError::other(format!(
915 "source workspace not found: {}",
916 source_workspace.display()
917 )));
918 }
919
920 let into_main = opts.into_scope == MAIN_SCOPE || opts.into_scope.eq_ignore_ascii_case("main");
921 let scope_id = if into_main {
922 MAIN_SCOPE.to_string()
923 } else {
924 sanitize_scope_id(&opts.into_scope)?
925 };
926
927 if opts.mount {
929 if into_main {
930 return Err(BrainError::other(
931 "--mount cannot target main; use --as <subbrain-id> to keep it separate",
932 ));
933 }
934 let rel = source_workspace
935 .strip_prefix(&target_workspace)
936 .map_err(|_| {
937 BrainError::other(format!(
938 "mount requires source under target workspace (source={}, target={}). \
939 Move/clone the project under the umbrella, or omit --mount to copy.",
940 source_workspace.display(),
941 target_workspace.display()
942 ))
943 })?;
944 let root = normalize_rel(&rel.to_string_lossy());
945 let m = attach_subbrain(&target_workspace, &scope_id, &root, &[])?;
946 let _ = m;
947 return Ok(ImportBrainReport {
948 source: source_workspace.display().to_string(),
949 into_scope: scope_id,
950 files_copied: 0,
951 files_skipped: 0,
952 dest_root: root,
953 scope_registered: true,
954 mounted: true,
955 bytes_copied: 0,
956 });
957 }
958
959 let dest_rel = opts.dest_root.clone().unwrap_or_else(|| {
960 if into_main {
961 "docs/imported".into()
962 } else {
963 format!("docs/subbrains/{scope_id}")
964 }
965 });
966 let dest_rel = normalize_rel(&dest_rel);
967 let dest_abs = target_workspace.join(&dest_rel);
968 fs::create_dir_all(&dest_abs)?;
969
970 let mut files_copied = 0usize;
971 let mut files_skipped = 0usize;
972 let mut bytes_copied = 0u64;
973
974 if opts.copy_markdown {
975 copy_markdown_tree(
976 &source_workspace,
977 &source_workspace,
978 &dest_abs,
979 opts.force,
980 opts.max_files,
981 opts.max_bytes,
982 &mut files_copied,
983 &mut files_skipped,
984 &mut bytes_copied,
985 )?;
986 }
987
988 let mut scope_registered = false;
989 if !into_main {
990 add_scope(
991 &target_workspace,
992 &scope_id,
993 &[dest_rel.clone()],
994 &[],
995 ScopeSource::Imported,
996 )?;
997 scope_registered = true;
998 } else {
999 let _ = ensure_manifest(&target_workspace)?;
1000 }
1001
1002 Ok(ImportBrainReport {
1003 source: source_workspace.display().to_string(),
1004 into_scope: scope_id,
1005 files_copied,
1006 files_skipped,
1007 dest_root: dest_rel,
1008 scope_registered,
1009 mounted: false,
1010 bytes_copied,
1011 })
1012}
1013
1014fn copy_markdown_tree(
1015 source_root: &Path,
1016 dir: &Path,
1017 dest_root: &Path,
1018 force: bool,
1019 max_files: usize,
1020 max_bytes: u64,
1021 copied: &mut usize,
1022 skipped: &mut usize,
1023 bytes: &mut u64,
1024) -> Result<()> {
1025 let skip_names = [
1026 ".brain",
1027 "target",
1028 ".git",
1029 "node_modules",
1030 "vendor",
1031 "dist",
1032 "build",
1033 ".next",
1034 ];
1035 for ent in fs::read_dir(dir)? {
1036 let ent = ent?;
1037 let path = ent.path();
1038 let name = ent.file_name().to_string_lossy().to_string();
1039 if skip_names.iter().any(|s| *s == name) {
1040 continue;
1041 }
1042 if path.is_dir() {
1043 copy_markdown_tree(
1044 source_root,
1045 &path,
1046 dest_root,
1047 force,
1048 max_files,
1049 max_bytes,
1050 copied,
1051 skipped,
1052 bytes,
1053 )?;
1054 continue;
1055 }
1056 if path.extension().and_then(|e| e.to_str()) != Some("md") {
1057 continue;
1058 }
1059 if *copied >= max_files {
1060 return Err(BrainError::other(format!(
1061 "import exceeded max_files={max_files}; raise limit or narrow source"
1062 )));
1063 }
1064 let meta = fs::metadata(&path)?;
1065 let len = meta.len();
1066 if *bytes + len > max_bytes {
1067 return Err(BrainError::other(format!(
1068 "import exceeded max_bytes={max_bytes}; raise limit or narrow source"
1069 )));
1070 }
1071 let rel = path
1072 .strip_prefix(source_root)
1073 .unwrap_or(&path)
1074 .to_string_lossy()
1075 .replace('\\', "/");
1076 let dest = dest_root.join(&rel);
1077 if let Some(parent) = dest.parent() {
1078 fs::create_dir_all(parent)?;
1079 }
1080 if dest.exists() && !force {
1081 *skipped += 1;
1082 continue;
1083 }
1084 fs::copy(&path, &dest)?;
1085 *copied += 1;
1086 *bytes += len;
1087 }
1088 Ok(())
1089}
1090
1091pub fn count_nodes_by_scope(db: &Database) -> Result<BTreeMap<String, usize>> {
1093 let mut stmt = db
1094 .conn
1095 .prepare("SELECT scope, COUNT(*) FROM nodes GROUP BY scope ORDER BY scope")?;
1096 let rows = stmt.query_map([], |row| {
1097 Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?))
1098 })?;
1099 let mut map = BTreeMap::new();
1100 for r in rows {
1101 let (s, n) = r?;
1102 map.insert(s, n);
1103 }
1104 Ok(map)
1105}
1106
1107pub fn format_scopes_text(
1109 manifest: &WorkspaceManifest,
1110 counts: &BTreeMap<String, usize>,
1111) -> String {
1112 let mut out = String::new();
1113 out.push_str(&format!(
1114 "mode: {} main: {}\n",
1115 manifest.mode.as_str(),
1116 manifest.main_id
1117 ));
1118 if !manifest.is_multi() {
1119 out.push_str("multi-brain: off (all nodes are MainBrain)\n");
1120 out.push_str("enable: rustbrain scopes enable --cargo # or --empty\n");
1121 if let Some(n) = counts.get(MAIN_SCOPE).or_else(|| counts.values().next()) {
1122 out.push_str(&format!("nodes: {n}\n"));
1123 }
1124 return out;
1125 }
1126 let main_n = counts.get(&manifest.main_id).copied().unwrap_or(0);
1127 out.push_str(&format!(
1128 " [{:>6} nodes] {} (MainBrain)\n",
1129 main_n, manifest.main_id
1130 ));
1131 if manifest.scopes.is_empty() {
1132 out.push_str(" (no SubBrains yet — scopes add <id> --root <path>)\n");
1133 }
1134 for sc in &manifest.scopes {
1135 let n = counts.get(&sc.id).copied().unwrap_or(0);
1136 out.push_str(&format!(
1137 " [{:>6} nodes] {} roots={} source={}\n",
1138 n,
1139 sc.id,
1140 sc.roots.join(","),
1141 sc.source.as_str()
1142 ));
1143 if !sc.aliases.is_empty() {
1144 out.push_str(&format!(" aliases: {}\n", sc.aliases.join(", ")));
1145 }
1146 }
1147 for (s, n) in counts {
1149 if s != &manifest.main_id && manifest.find_scope(s).is_none() {
1150 out.push_str(&format!(
1151 " [{n:>6} nodes] {s} (orphan scope in DB — absorb or re-sync)\n"
1152 ));
1153 }
1154 }
1155 out
1156}
1157
1158#[cfg(test)]
1159mod tests {
1160 use super::*;
1161 use tempfile::tempdir;
1162
1163 #[test]
1164 fn longest_prefix_wins() {
1165 let mut m = WorkspaceManifest::single(Path::new("/tmp/ws"));
1166 m.mode = BrainMode::Multi;
1167 m.scopes.push(ScopeDef {
1168 id: "core".into(),
1169 roots: vec!["crates/core".into()],
1170 aliases: vec![],
1171 source: ScopeSource::Manual,
1172 });
1173 m.scopes.push(ScopeDef {
1174 id: "cli".into(),
1175 roots: vec!["crates/cli".into()],
1176 aliases: vec![],
1177 source: ScopeSource::Manual,
1178 });
1179 assert_eq!(m.resolve_scope("crates/core/src/lib.rs"), "core");
1180 assert_eq!(m.resolve_scope("crates/cli/src/main.rs"), "cli");
1181 assert_eq!(m.resolve_scope("docs/adr/x.md"), "main");
1182 assert_eq!(m.resolve_scope("README.md"), "main");
1183 }
1184
1185 #[test]
1186 fn single_mode_always_main() {
1187 let m = WorkspaceManifest::single(Path::new("/tmp/ws"));
1188 assert_eq!(m.resolve_scope("crates/core/src/lib.rs"), "main");
1189 }
1190
1191 #[test]
1192 fn sanitize_rejects_main() {
1193 assert!(sanitize_scope_id("main").is_err());
1194 assert_eq!(sanitize_scope_id("RustBrain_Core").unwrap(), "rustbrain-core");
1195 }
1196
1197 #[test]
1198 fn roundtrip_manifest() {
1199 let dir = tempdir().unwrap();
1200 let ws = dir.path();
1201 fs::create_dir_all(ws.join(".brain")).unwrap();
1202 fs::create_dir_all(ws.join("crates/foo")).unwrap();
1203 let mut m = enable_multi(ws, false).unwrap();
1204 assert!(m.is_multi());
1205 m = add_scope(
1206 ws,
1207 "foo",
1208 &["crates/foo".into()],
1209 &["pkg-foo".into()],
1210 ScopeSource::Manual,
1211 )
1212 .unwrap();
1213 assert_eq!(m.scopes.len(), 1);
1214 let loaded = load_manifest(ws).unwrap();
1215 assert_eq!(loaded.scopes[0].id, "foo");
1216 }
1217
1218 #[test]
1219 fn cargo_discover_this_repo_shape() {
1220 let dir = tempdir().unwrap();
1222 let ws = dir.path();
1223 fs::create_dir_all(ws.join("crates/a")).unwrap();
1224 fs::create_dir_all(ws.join("crates/b")).unwrap();
1225 fs::write(
1226 ws.join("Cargo.toml"),
1227 r#"[workspace]
1228members = ["crates/*"]
1229"#,
1230 )
1231 .unwrap();
1232 fs::write(
1233 ws.join("crates/a/Cargo.toml"),
1234 r#"[package]
1235name = "pkg_a"
1236version = "0.1.0"
1237"#,
1238 )
1239 .unwrap();
1240 fs::write(
1241 ws.join("crates/b/Cargo.toml"),
1242 r#"[package]
1243name = "b"
1244version = "0.1.0"
1245"#,
1246 )
1247 .unwrap();
1248 let members = discover_cargo_members(ws).unwrap();
1249 assert_eq!(members.len(), 2);
1250 let a = members.iter().find(|m| m.id == "a").unwrap();
1251 assert_eq!(a.package_name.as_deref(), Some("pkg_a"));
1252 }
1253
1254 #[test]
1255 fn multi_scope_index_and_query_filter() {
1256 use crate::{Brain, QueryOptions};
1257 let dir = tempdir().unwrap();
1258 let ws = dir.path();
1259 fs::create_dir_all(ws.join("crates/core/src")).unwrap();
1260 fs::create_dir_all(ws.join("crates/cli/src")).unwrap();
1261 fs::create_dir_all(ws.join("docs/concepts")).unwrap();
1262 fs::write(
1263 ws.join("Cargo.toml"),
1264 r#"[workspace]
1265members = ["crates/*"]
1266"#,
1267 )
1268 .unwrap();
1269 fs::write(
1270 ws.join("crates/core/Cargo.toml"),
1271 r#"[package]
1272name = "core"
1273version = "0.1.0"
1274edition = "2021"
1275"#,
1276 )
1277 .unwrap();
1278 fs::write(
1279 ws.join("crates/cli/Cargo.toml"),
1280 r#"[package]
1281name = "cli"
1282version = "0.1.0"
1283edition = "2021"
1284"#,
1285 )
1286 .unwrap();
1287 fs::write(
1288 ws.join("crates/core/src/lib.rs"),
1289 "/// Storage engine core.\npub struct StorageEngine;\n",
1290 )
1291 .unwrap();
1292 fs::write(
1293 ws.join("crates/cli/src/main.rs"),
1294 "fn main() { println!(\"cli clap parse\"); }\n",
1295 )
1296 .unwrap();
1297 fs::write(
1298 ws.join("docs/concepts/shared.md"),
1299 "---\nnode_type: concept\n---\n# Shared\n\nShared note about clap and storage.\n",
1300 )
1301 .unwrap();
1302 fs::write(
1303 ws.join("crates/core/README.md"),
1304 "# Core crate\n\nStorage engine details only in core.\n",
1305 )
1306 .unwrap();
1307
1308 let mut brain = Brain::create(ws).unwrap();
1309 enable_multi(ws, true).unwrap();
1310 brain.sync().unwrap();
1311
1312 let counts = count_nodes_by_scope(brain.database()).unwrap();
1313 assert!(counts.get("core").copied().unwrap_or(0) >= 1);
1314 assert!(counts.get("cli").copied().unwrap_or(0) >= 1);
1315
1316 let mut opts = QueryOptions::human();
1317 opts.scope = Some("core".into());
1318 opts.scope_main = crate::query::ScopeMainInclude::Strict;
1319 opts.limit = 20;
1320 let hits = brain.query_ranked("storage", &opts).unwrap();
1321 assert!(
1322 hits.iter().all(|h| h.node.scope == "core"),
1323 "strict core scope should not leak other scopes: {:?}",
1324 hits.iter().map(|h| (&h.node.id, &h.node.scope)).collect::<Vec<_>>()
1325 );
1326
1327 let rep = absorb_scope(ws, brain.database(), "core").unwrap();
1329 assert!(rep.nodes_reassigned >= 1);
1330 let m = load_manifest(ws).unwrap();
1331 assert!(m.find_scope("core").is_none());
1332 }
1333
1334 #[test]
1335 fn import_brain_as_subbrain_copies_md() {
1336 let src = tempdir().unwrap();
1337 let dst = tempdir().unwrap();
1338 fs::create_dir_all(src.path().join("docs")).unwrap();
1339 fs::write(
1340 src.path().join("docs/note.md"),
1341 "---\nnode_type: concept\n---\n# Imported Note\n\nHello from source brain.\n",
1342 )
1343 .unwrap();
1344 let report = import_brain(
1345 dst.path(),
1346 src.path(),
1347 &ImportBrainOptions {
1348 into_scope: "foreign".into(),
1349 dest_root: None,
1350 copy_markdown: true,
1351 force: false,
1352 mount: false,
1353 max_files: IMPORT_DEFAULT_MAX_FILES,
1354 max_bytes: IMPORT_DEFAULT_MAX_BYTES,
1355 },
1356 )
1357 .unwrap();
1358 assert_eq!(report.files_copied, 1);
1359 assert!(report.scope_registered);
1360 assert!(!report.mounted);
1361 assert!(dst
1362 .path()
1363 .join("docs/subbrains/foreign/docs/note.md")
1364 .is_file());
1365 let m = load_manifest(dst.path()).unwrap();
1366 assert!(m.is_multi());
1367 assert!(m.find_scope("foreign").is_some());
1368 }
1369
1370 #[test]
1371 fn umbrella_mount_three_former_mainbrains() {
1372 let umbrella = tempdir().unwrap();
1373 let u = umbrella.path();
1374 for name in ["alpha", "beta", "gamma"] {
1375 fs::create_dir_all(u.join(name).join("docs")).unwrap();
1376 fs::write(
1377 u.join(name).join("docs/note.md"),
1378 format!("---\nnode_type: concept\n---\n# {name}\n\nNote in {name}.\n"),
1379 )
1380 .unwrap();
1381 fs::create_dir_all(u.join(name).join(".brain")).unwrap();
1383 fs::write(
1384 u.join(name).join(".brain/workspace.json"),
1385 r#"{"version":1,"workspace":"nested"}"#,
1386 )
1387 .unwrap();
1388 }
1389 use crate::Brain;
1390 let mut brain = Brain::create(u).unwrap();
1391 enable_multi(u, false).unwrap();
1392 for name in ["alpha", "beta", "gamma"] {
1393 import_brain(
1394 u,
1395 &u.join(name),
1396 &ImportBrainOptions {
1397 into_scope: name.into(),
1398 mount: true,
1399 copy_markdown: false,
1400 ..Default::default()
1401 },
1402 )
1403 .unwrap();
1404 }
1405 let m = load_manifest(u).unwrap();
1406 assert_eq!(m.scopes.len(), 3);
1407 assert_eq!(m.resolve_scope("alpha/docs/note.md"), "alpha");
1408 assert_eq!(m.resolve_scope("beta/docs/note.md"), "beta");
1409 assert_eq!(m.resolve_scope("docs/x.md"), "main");
1410
1411 brain.sync().unwrap();
1412 let rep = reconcile_scopes(u, brain.database()).unwrap();
1413 assert!(rep.updated + rep.unchanged > 0);
1414 let counts = count_nodes_by_scope(brain.database()).unwrap();
1415 assert!(counts.get("alpha").copied().unwrap_or(0) >= 1);
1416 assert!(counts.get("beta").copied().unwrap_or(0) >= 1);
1417 }
1418}