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(open) = pat.find('{') {
408 if let Some(close) = pat[open..].find('}') {
409 let close = open + close;
410 let prefix = &pat[..open];
411 let suffix = &pat[close + 1..];
412 let inner = &pat[open + 1..close];
413 let mut out = Vec::new();
414 for part in inner.split(',') {
415 let part = part.trim();
416 if part.is_empty() {
417 continue;
418 }
419 let expanded = format!("{prefix}{part}{suffix}");
420 out.extend(expand_member_pattern(workspace, &expanded)?);
421 }
422 out.sort();
423 out.dedup();
424 return Ok(out);
425 }
426 }
427 if let Some(star) = pat.find("/*") {
429 let parent = &pat[..star];
430 let rest = &pat[star + 2..]; let rest = rest.trim_start_matches('/');
432 let dir = workspace.join(parent);
433 if !dir.is_dir() {
434 return Ok(Vec::new());
435 }
436 let mut v = Vec::new();
437 for ent in fs::read_dir(&dir)? {
438 let ent = ent?;
439 if !ent.file_type()?.is_dir() {
440 continue;
441 }
442 let name = ent.file_name().to_string_lossy().to_string();
443 if name.starts_with('.') {
444 continue;
445 }
446 let candidate = if rest.is_empty() {
447 format!("{}/{}", normalize_rel(parent), name)
448 } else if rest.contains('*') {
449 let nested = format!("{}/{}/{}", normalize_rel(parent), name, rest);
451 v.extend(expand_member_pattern(workspace, &nested)?);
452 continue;
453 } else {
454 format!("{}/{}/{}", normalize_rel(parent), name, rest)
455 };
456 if workspace.join(&candidate).is_dir() || rest.is_empty() {
457 if workspace.join(&candidate).is_dir() {
458 v.push(normalize_rel(&candidate));
459 }
460 }
461 }
462 v.sort();
463 v.dedup();
464 return Ok(v);
465 }
466 if workspace.join(&pat).is_dir() {
467 Ok(vec![normalize_rel(&pat)])
468 } else {
469 Ok(Vec::new())
470 }
471}
472
473pub fn suggest_scope_id(path: &Path) -> String {
475 let name = path
476 .file_name()
477 .and_then(|s| s.to_str())
478 .unwrap_or("crate");
479 let id = name.to_ascii_lowercase().replace('_', "-");
480 if id.is_empty() || id == "main" {
481 "project".into()
482 } else {
483 id
484 }
485}
486
487#[derive(Debug, Clone, Serialize)]
489pub struct DetectReport {
490 pub path: String,
492 pub suggested_id: String,
494 pub under_workspace: bool,
496 pub relative_root: Option<String>,
498 pub mountable: bool,
500 pub has_nested_brain: bool,
502 pub existing_scope: Option<String>,
504 pub tips: Vec<String>,
506}
507
508pub fn detect_path(workspace: &Path, path: &Path) -> Result<DetectReport> {
512 let workspace = workspace
513 .canonicalize()
514 .unwrap_or_else(|_| workspace.to_path_buf());
515 let path = if path.exists() {
516 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
517 } else {
518 return Err(BrainError::other(format!(
519 "path does not exist: {}",
520 path.display()
521 )));
522 };
523 if !path.is_dir() {
524 return Err(BrainError::other(format!(
525 "path is not a directory: {}",
526 path.display()
527 )));
528 }
529
530 let suggested_id = suggest_scope_id(&path);
531 let under = path.starts_with(&workspace) && path != workspace;
532 let relative_root = if under {
533 path.strip_prefix(&workspace)
534 .ok()
535 .map(|p| normalize_rel(&p.to_string_lossy()))
536 } else {
537 None
538 };
539 let has_nested_brain = path.join(".brain").join("db.sqlite").is_file();
540 let m = load_manifest(&workspace).unwrap_or_else(|_| WorkspaceManifest::single(&workspace));
541 let existing_scope = relative_root.as_ref().and_then(|rel| {
542 m.scopes.iter().find_map(|sc| {
543 if sc.roots.iter().any(|r| r == rel || rel.starts_with(&(r.clone() + "/"))) {
544 Some(sc.id.clone())
545 } else {
546 None
547 }
548 })
549 });
550
551 let mut tips = Vec::new();
552 if let Some(ref id) = existing_scope {
553 tips.push(format!("already a SubBrain root — use --scope {id}"));
554 } else if under {
555 tips.push(format!(
556 "attach: rustbrain scopes attach {suggested_id} --root {}",
557 relative_root.as_deref().unwrap_or("?")
558 ));
559 tips.push(format!(
560 "or mount: rustbrain scopes import --from {} --as {suggested_id} --mount",
561 path.display()
562 ));
563 } else {
564 tips.push(format!(
565 "copy as SubBrain: rustbrain scopes import --from {} --as {suggested_id}",
566 path.display()
567 ));
568 tips.push(
569 "or move/clone under the workspace, then use --mount / attach".into(),
570 );
571 }
572 if has_nested_brain {
573 tips.push(
574 "nested .brain present — local open inside this dir still uses that brain".into(),
575 );
576 }
577
578 Ok(DetectReport {
579 path: path.display().to_string(),
580 suggested_id,
581 under_workspace: under,
582 relative_root,
583 mountable: under,
584 has_nested_brain,
585 existing_scope,
586 tips,
587 })
588}
589
590pub fn scope_for_cwd(workspace: &Path, cwd: &Path) -> Option<String> {
594 let m = load_manifest(workspace).ok()?;
595 if !m.is_multi() {
596 return None;
597 }
598 let ws = workspace.canonicalize().ok()?;
599 let cwd = cwd.canonicalize().ok()?;
600 let rel = cwd.strip_prefix(&ws).ok()?;
601 let rel = normalize_rel(&rel.to_string_lossy());
602 if rel.is_empty() {
603 return None;
604 }
605 let probe = format!("{rel}/.");
607 let scope = m.resolve_scope(&probe);
608 if scope == m.main_id || scope == MAIN_SCOPE {
609 let scope2 = m.resolve_scope(&rel);
611 if scope2 == m.main_id || scope2 == MAIN_SCOPE {
612 return None;
613 }
614 return Some(scope2);
615 }
616 Some(scope)
617}
618
619fn path_scope_id(rel: &str) -> String {
620 let rel = normalize_rel(rel);
621 let last = rel.rsplit('/').next().unwrap_or(&rel);
622 let id = last.to_ascii_lowercase().replace('_', "-");
624 if id.is_empty() {
625 "crate".into()
626 } else {
627 id
628 }
629}
630
631fn read_package_name(member_dir: &Path) -> Option<String> {
632 let cargo = member_dir.join("Cargo.toml");
633 let text = fs::read_to_string(cargo).ok()?;
634 let value: toml::Value = text.parse().ok()?;
635 value
636 .get("package")
637 .and_then(|p| p.get("name"))
638 .and_then(|n| n.as_str())
639 .map(|s| s.to_string())
640}
641
642pub fn apply_cargo_discovery(manifest: &mut WorkspaceManifest, members: &[CargoMember]) {
644 manifest.scopes.retain(|s| s.source != ScopeSource::CargoWorkspace);
645 for m in members {
646 if m.id == manifest.main_id {
647 continue;
648 }
649 if manifest.find_scope(&m.id).is_some() {
650 continue;
652 }
653 let mut aliases = Vec::new();
654 if let Some(pkg) = &m.package_name {
655 let a = pkg.to_ascii_lowercase().replace('_', "-");
656 if a != m.id {
657 aliases.push(a);
658 }
659 }
660 manifest.scopes.push(ScopeDef {
661 id: m.id.clone(),
662 roots: vec![m.root.clone()],
663 aliases,
664 source: ScopeSource::CargoWorkspace,
665 });
666 }
667 manifest.discovery.cargo_workspace = true;
668}
669
670pub fn enable_multi(workspace: &Path, discover_cargo: bool) -> Result<WorkspaceManifest> {
674 let mut m = load_manifest(workspace)?;
675 m.mode = BrainMode::Multi;
676 m.version = 2;
677 if discover_cargo {
678 let members = discover_cargo_members(workspace)?;
679 apply_cargo_discovery(&mut m, &members);
680 }
681 save_manifest(workspace, &m)?;
682 Ok(m)
683}
684
685pub fn disable_multi(workspace: &Path, clear_scopes: bool) -> Result<WorkspaceManifest> {
687 let mut m = load_manifest(workspace)?;
688 m.mode = BrainMode::Single;
689 if clear_scopes {
690 m.scopes.clear();
691 m.discovery.cargo_workspace = false;
692 }
693 save_manifest(workspace, &m)?;
694 Ok(m)
695}
696
697pub fn add_scope(
699 workspace: &Path,
700 id: &str,
701 roots: &[String],
702 aliases: &[String],
703 source: ScopeSource,
704) -> Result<WorkspaceManifest> {
705 let id = sanitize_scope_id(id)?;
706 if roots.is_empty() {
707 return Err(BrainError::other("at least one --root is required"));
708 }
709 let mut m = load_manifest(workspace)?;
710 if m.mode != BrainMode::Multi {
711 m.mode = BrainMode::Multi;
712 }
713 let roots: Vec<String> = roots.iter().map(|r| normalize_rel(r)).collect();
714 for r in &roots {
715 let abs = workspace.join(r);
716 if !abs.exists() {
717 return Err(BrainError::other(format!(
718 "root does not exist: {r} (under {})",
719 workspace.display()
720 )));
721 }
722 }
723 if let Some(existing) = m.find_scope_mut(&id) {
724 existing.roots = roots;
725 for a in aliases {
726 if !existing.aliases.contains(a) {
727 existing.aliases.push(a.clone());
728 }
729 }
730 existing.source = source;
731 } else {
732 m.scopes.push(ScopeDef {
733 id: id.clone(),
734 roots,
735 aliases: aliases.to_vec(),
736 source,
737 });
738 }
739 save_manifest(workspace, &m)?;
740 Ok(m)
741}
742
743pub fn remove_scope_def(workspace: &Path, id: &str) -> Result<WorkspaceManifest> {
745 let mut m = load_manifest(workspace)?;
746 let before = m.scopes.len();
747 m.scopes.retain(|s| s.id != id && !s.aliases.iter().any(|a| a == id));
748 if m.scopes.len() == before {
749 return Err(BrainError::other(format!("unknown scope {id:?}")));
750 }
751 save_manifest(workspace, &m)?;
752 Ok(m)
753}
754
755pub fn reassign_node_scopes(db: &Database, from_scope: &str, to_scope: &str) -> Result<usize> {
757 let n = db.conn.execute(
758 "UPDATE nodes SET scope = ?1 WHERE scope = ?2",
759 rusqlite::params![to_scope, from_scope],
760 )?;
761 Ok(n)
762}
763
764pub fn absorb_scope(workspace: &Path, db: &Database, id: &str) -> Result<AbsorbReport> {
769 let m = load_manifest(workspace)?;
770 let sc = m
771 .find_scope(id)
772 .ok_or_else(|| BrainError::other(format!("unknown scope {id:?}")))?;
773 let scope_id = sc.id.clone();
774 let main = m.main_id.clone();
775 let mut stmt = db
776 .conn
777 .prepare("SELECT id FROM nodes WHERE scope = ?1")?;
778 let ids: Vec<String> = stmt
779 .query_map([&scope_id], |row| row.get(0))?
780 .filter_map(|r| r.ok())
781 .collect();
782 let n = reassign_node_scopes(db, &scope_id, &main)?;
783 for nid in &ids {
784 db.set_node_scope(nid, &main)?;
785 }
786 let m = remove_scope_def(workspace, &scope_id)?;
787 Ok(AbsorbReport {
788 absorbed_id: scope_id,
789 nodes_reassigned: n,
790 manifest: m,
791 })
792}
793
794#[derive(Debug, Clone)]
796pub struct AbsorbReport {
797 pub absorbed_id: String,
799 pub nodes_reassigned: usize,
801 pub manifest: WorkspaceManifest,
803}
804
805pub fn absorb_all_to_main(workspace: &Path, db: &Database) -> Result<AbsorbAllReport> {
807 let m = load_manifest(workspace)?;
808 let ids: Vec<String> = m.scopes.iter().map(|s| s.id.clone()).collect();
809 let mut total = 0usize;
810 let mut touched: Vec<String> = Vec::new();
811 for id in &ids {
812 let mut stmt = db
813 .conn
814 .prepare("SELECT id FROM nodes WHERE scope = ?1")?;
815 for nid in stmt
816 .query_map([id.as_str()], |row| row.get::<_, String>(0))?
817 .flatten()
818 {
819 touched.push(nid);
820 }
821 total += reassign_node_scopes(db, id, MAIN_SCOPE)?;
822 }
823 let mut stmt = db
825 .conn
826 .prepare("SELECT id FROM nodes WHERE scope != ?1")?;
827 for nid in stmt
828 .query_map([MAIN_SCOPE], |row| row.get::<_, String>(0))?
829 .flatten()
830 {
831 touched.push(nid);
832 }
833 total += db.conn.execute(
834 "UPDATE nodes SET scope = ?1 WHERE scope != ?1",
835 rusqlite::params![MAIN_SCOPE],
836 )?;
837 touched.sort();
838 touched.dedup();
839 for nid in &touched {
840 db.set_node_scope(nid, MAIN_SCOPE)?;
841 }
842 let mut m = disable_multi(workspace, true)?;
843 m.mode = BrainMode::Single;
844 save_manifest(workspace, &m)?;
845 Ok(AbsorbAllReport {
846 scopes_removed: ids,
847 nodes_reassigned: total,
848 manifest: m,
849 })
850}
851
852#[derive(Debug, Clone, Serialize)]
856pub struct ReconcileReport {
857 pub updated: usize,
859 pub unchanged: usize,
861 pub orphan_scopes_cleared: Vec<String>,
864 pub mode: String,
866}
867
868pub fn reconcile_scopes(workspace: &Path, db: &Database) -> Result<ReconcileReport> {
872 let m = load_manifest(workspace)?;
873 let mut updated = 0usize;
874 let mut unchanged = 0usize;
875 let mut stmt = db.conn.prepare(
876 "SELECT id, file_path, scope FROM nodes",
877 )?;
878 let rows: Vec<(String, Option<String>, String)> = stmt
879 .query_map([], |row| {
880 Ok((
881 row.get(0)?,
882 row.get(1)?,
883 row.get::<_, Option<String>>(2)?
884 .unwrap_or_else(|| MAIN_SCOPE.to_string()),
885 ))
886 })?
887 .filter_map(|r| r.ok())
888 .collect();
889
890 let known: BTreeSet<String> = m.all_scope_ids().into_iter().collect();
891 let mut orphan_scopes: BTreeSet<String> = BTreeSet::new();
892
893 for (id, file_path, old_scope) in rows {
894 let expected = if !m.is_multi() {
895 m.main_id.clone()
896 } else if let Some(ref fp) = file_path {
897 let path_scope = m.resolve_scope(fp);
898 if let Some(fm) = read_frontmatter_scope(workspace, fp) {
900 if known.contains(&fm) || fm == m.main_id {
901 fm
902 } else {
903 path_scope
904 }
905 } else {
906 path_scope
907 }
908 } else {
909 if known.contains(&old_scope) {
911 old_scope.clone()
912 } else {
913 m.main_id.clone()
914 }
915 };
916
917 if !known.contains(&old_scope) && old_scope != m.main_id {
918 orphan_scopes.insert(old_scope.clone());
919 }
920
921 if expected != old_scope {
922 db.set_node_scope(&id, &expected)?;
923 updated += 1;
924 } else {
925 unchanged += 1;
926 }
927 }
928
929 Ok(ReconcileReport {
930 updated,
931 unchanged,
932 orphan_scopes_cleared: orphan_scopes.into_iter().collect(),
933 mode: m.mode.as_str().to_string(),
934 })
935}
936
937fn read_frontmatter_scope(workspace: &Path, rel: &str) -> Option<String> {
938 #[cfg(feature = "obsidian")]
939 {
940 let path = workspace.join(rel);
941 let text = fs::read_to_string(path).ok()?;
942 let (fm, _) = crate::obsidian::parse_frontmatter(&text);
943 let fm = fm?;
944 fm.extra
945 .get("scope")
946 .and_then(|v| v.as_str())
947 .map(|s| s.trim().to_ascii_lowercase().replace('_', "-"))
948 .filter(|s| !s.is_empty())
949 }
950 #[cfg(not(feature = "obsidian"))]
951 {
952 let _ = (workspace, rel);
953 None
954 }
955}
956
957pub fn attach_subbrain(
973 workspace: &Path,
974 id: &str,
975 root: &str,
976 aliases: &[String],
977) -> Result<WorkspaceManifest> {
978 let root = normalize_rel(root);
979 let abs = workspace.join(&root);
980 if !abs.is_dir() {
981 return Err(BrainError::other(format!(
982 "attach root does not exist or is not a directory: {root}"
983 )));
984 }
985 if root.is_empty() || root == "." {
987 return Err(BrainError::other(
988 "cannot attach workspace root as SubBrain — use MainBrain",
989 ));
990 }
991 add_scope(
992 workspace,
993 id,
994 &[root],
995 aliases,
996 ScopeSource::ExtraRoot,
997 )
998}
999
1000#[derive(Debug, Clone)]
1002pub struct AbsorbAllReport {
1003 pub scopes_removed: Vec<String>,
1005 pub nodes_reassigned: usize,
1007 pub manifest: WorkspaceManifest,
1009}
1010
1011pub const IMPORT_DEFAULT_MAX_FILES: usize = 5_000;
1015pub const IMPORT_DEFAULT_MAX_BYTES: u64 = 50 * 1024 * 1024;
1017
1018#[derive(Debug, Clone)]
1020pub struct ImportBrainOptions {
1021 pub into_scope: String,
1024 pub dest_root: Option<String>,
1026 pub copy_markdown: bool,
1028 pub force: bool,
1030 pub mount: bool,
1033 pub max_files: usize,
1035 pub max_bytes: u64,
1037}
1038
1039impl Default for ImportBrainOptions {
1040 fn default() -> Self {
1041 Self {
1042 into_scope: MAIN_SCOPE.to_string(),
1043 dest_root: None,
1044 copy_markdown: true,
1045 force: false,
1046 mount: false,
1047 max_files: IMPORT_DEFAULT_MAX_FILES,
1048 max_bytes: IMPORT_DEFAULT_MAX_BYTES,
1049 }
1050 }
1051}
1052
1053#[derive(Debug, Clone, Serialize)]
1055pub struct ImportBrainReport {
1056 pub source: String,
1058 pub into_scope: String,
1060 pub files_copied: usize,
1062 pub files_skipped: usize,
1064 pub dest_root: String,
1066 pub scope_registered: bool,
1068 pub mounted: bool,
1070 pub bytes_copied: u64,
1072}
1073
1074pub fn import_brain(
1085 target_workspace: &Path,
1086 source_workspace: &Path,
1087 opts: &ImportBrainOptions,
1088) -> Result<ImportBrainReport> {
1089 let source_workspace = source_workspace
1090 .canonicalize()
1091 .unwrap_or_else(|_| source_workspace.to_path_buf());
1092 let target_workspace = target_workspace
1093 .canonicalize()
1094 .unwrap_or_else(|_| target_workspace.to_path_buf());
1095 if source_workspace == target_workspace {
1096 return Err(BrainError::other(
1097 "cannot import a workspace into itself — use scopes attach/absorb instead",
1098 ));
1099 }
1100 if !source_workspace.is_dir() {
1101 return Err(BrainError::other(format!(
1102 "source workspace not found: {}",
1103 source_workspace.display()
1104 )));
1105 }
1106
1107 let into_main = opts.into_scope == MAIN_SCOPE || opts.into_scope.eq_ignore_ascii_case("main");
1108 let scope_id = if into_main {
1109 MAIN_SCOPE.to_string()
1110 } else {
1111 sanitize_scope_id(&opts.into_scope)?
1112 };
1113
1114 if opts.mount {
1116 if into_main {
1117 return Err(BrainError::other(
1118 "--mount cannot target main; use --as <subbrain-id> to keep it separate",
1119 ));
1120 }
1121 let rel = source_workspace
1122 .strip_prefix(&target_workspace)
1123 .map_err(|_| {
1124 BrainError::other(format!(
1125 "mount requires source under target workspace (source={}, target={}). \
1126 Move/clone the project under the umbrella, or omit --mount to copy.",
1127 source_workspace.display(),
1128 target_workspace.display()
1129 ))
1130 })?;
1131 let root = normalize_rel(&rel.to_string_lossy());
1132 let m = attach_subbrain(&target_workspace, &scope_id, &root, &[])?;
1133 let _ = m;
1134 return Ok(ImportBrainReport {
1135 source: source_workspace.display().to_string(),
1136 into_scope: scope_id,
1137 files_copied: 0,
1138 files_skipped: 0,
1139 dest_root: root,
1140 scope_registered: true,
1141 mounted: true,
1142 bytes_copied: 0,
1143 });
1144 }
1145
1146 let dest_rel = opts.dest_root.clone().unwrap_or_else(|| {
1147 if into_main {
1148 "docs/imported".into()
1149 } else {
1150 format!("docs/subbrains/{scope_id}")
1151 }
1152 });
1153 let dest_rel = normalize_rel(&dest_rel);
1154 let dest_abs = target_workspace.join(&dest_rel);
1155 fs::create_dir_all(&dest_abs)?;
1156
1157 let mut files_copied = 0usize;
1158 let mut files_skipped = 0usize;
1159 let mut bytes_copied = 0u64;
1160
1161 if opts.copy_markdown {
1162 copy_markdown_tree(
1163 &source_workspace,
1164 &source_workspace,
1165 &dest_abs,
1166 &dest_rel,
1167 opts.force,
1168 opts.max_files,
1169 opts.max_bytes,
1170 &mut files_copied,
1171 &mut files_skipped,
1172 &mut bytes_copied,
1173 )?;
1174 }
1175
1176 let mut scope_registered = false;
1177 if !into_main {
1178 add_scope(
1179 &target_workspace,
1180 &scope_id,
1181 &[dest_rel.clone()],
1182 &[],
1183 ScopeSource::Imported,
1184 )?;
1185 scope_registered = true;
1186 } else {
1187 let _ = ensure_manifest(&target_workspace)?;
1188 }
1189
1190 Ok(ImportBrainReport {
1191 source: source_workspace.display().to_string(),
1192 into_scope: scope_id,
1193 files_copied,
1194 files_skipped,
1195 dest_root: dest_rel,
1196 scope_registered,
1197 mounted: false,
1198 bytes_copied,
1199 })
1200}
1201
1202fn copy_markdown_tree(
1203 source_root: &Path,
1204 dir: &Path,
1205 dest_root: &Path,
1206 dest_prefix: &str,
1207 force: bool,
1208 max_files: usize,
1209 max_bytes: u64,
1210 copied: &mut usize,
1211 skipped: &mut usize,
1212 bytes: &mut u64,
1213) -> Result<()> {
1214 let skip_names = [
1215 ".brain",
1216 "target",
1217 ".git",
1218 "node_modules",
1219 "vendor",
1220 "dist",
1221 "build",
1222 ".next",
1223 ];
1224 for ent in fs::read_dir(dir)? {
1225 let ent = ent?;
1226 let path = ent.path();
1227 let name = ent.file_name().to_string_lossy().to_string();
1228 if skip_names.iter().any(|s| *s == name) {
1229 continue;
1230 }
1231 if path.is_dir() {
1232 copy_markdown_tree(
1233 source_root,
1234 &path,
1235 dest_root,
1236 dest_prefix,
1237 force,
1238 max_files,
1239 max_bytes,
1240 copied,
1241 skipped,
1242 bytes,
1243 )?;
1244 continue;
1245 }
1246 if path.extension().and_then(|e| e.to_str()) != Some("md") {
1247 continue;
1248 }
1249 if *copied >= max_files {
1250 return Err(BrainError::other(format!(
1251 "import exceeded max_files={max_files}; raise limit or narrow source"
1252 )));
1253 }
1254 let meta = fs::metadata(&path)?;
1255 let len = meta.len();
1256 if *bytes + len > max_bytes {
1257 return Err(BrainError::other(format!(
1258 "import exceeded max_bytes={max_bytes}; raise limit or narrow source"
1259 )));
1260 }
1261 let rel = path
1262 .strip_prefix(source_root)
1263 .unwrap_or(&path)
1264 .to_string_lossy()
1265 .replace('\\', "/");
1266 let dest = dest_root.join(&rel);
1267 if let Some(parent) = dest.parent() {
1268 fs::create_dir_all(parent)?;
1269 }
1270 if dest.exists() && !force {
1271 *skipped += 1;
1272 continue;
1273 }
1274 let raw = fs::read_to_string(&path)?;
1275 let rewritten = rewrite_wikilinks_for_import(&raw, dest_prefix);
1276 fs::write(&dest, rewritten)?;
1277 *copied += 1;
1278 *bytes += len;
1279 }
1280 Ok(())
1281}
1282
1283pub fn rewrite_wikilinks_for_import(content: &str, dest_prefix: &str) -> String {
1288 let dest_prefix = dest_prefix.trim_matches('/');
1289 if dest_prefix.is_empty() {
1290 return content.to_string();
1291 }
1292 let mut out = String::with_capacity(content.len() + 64);
1293 let bytes = content.as_bytes();
1294 let mut i = 0;
1295 let mut in_fence = false;
1296 while i < bytes.len() {
1297 if is_line_start(bytes, i) && i + 2 < bytes.len() && &bytes[i..i + 3] == b"```" {
1299 in_fence = !in_fence;
1300 out.push_str("```");
1301 i += 3;
1302 continue;
1303 }
1304 if !in_fence && i + 1 < bytes.len() && bytes[i] == b'[' && bytes[i + 1] == b'[' {
1305 if let Some(end) = find_wikilink_end(bytes, i + 2) {
1306 let inner = &content[i + 2..end];
1307 let rewritten = rewrite_wikilink_inner(inner, dest_prefix);
1308 out.push_str("[[");
1309 out.push_str(&rewritten);
1310 out.push_str("]]");
1311 i = end + 2;
1312 continue;
1313 }
1314 }
1315 out.push(bytes[i] as char);
1316 i += 1;
1317 }
1318 out
1319}
1320
1321fn is_line_start(bytes: &[u8], i: usize) -> bool {
1322 i == 0 || bytes[i - 1] == b'\n'
1323}
1324
1325fn find_wikilink_end(bytes: &[u8], start: usize) -> Option<usize> {
1326 let mut j = start;
1327 while j + 1 < bytes.len() {
1328 if bytes[j] == b']' && bytes[j + 1] == b']' {
1329 return Some(j);
1330 }
1331 if bytes[j] == b'\n' {
1332 return None;
1333 }
1334 j += 1;
1335 }
1336 None
1337}
1338
1339fn rewrite_wikilink_inner(inner: &str, dest_prefix: &str) -> String {
1340 let (target_part, rest) = if let Some((t, a)) = inner.split_once('|') {
1342 (t, Some(format!("|{a}")))
1343 } else {
1344 (inner, None)
1345 };
1346 let (path_part, section) = if let Some((p, s)) = target_part.split_once('#') {
1347 (p.trim(), Some(s))
1348 } else {
1349 (target_part.trim(), None)
1350 };
1351 if path_part.is_empty()
1352 || path_part.starts_with("symbol:")
1353 || crate::hubs::is_hub_node_id(path_part)
1354 || path_part.starts_with(dest_prefix)
1355 {
1356 return inner.to_string();
1357 }
1358 let path_like = path_part.contains('/')
1360 || path_part.starts_with("docs")
1361 || path_part.ends_with(".md");
1362 if !path_like {
1363 return inner.to_string();
1364 }
1365 let cleaned = path_part.trim_end_matches(".md");
1366 let mut new_t = format!("{dest_prefix}/{cleaned}");
1367 new_t = new_t.replace("//", "/");
1369 let mut out = new_t;
1370 if let Some(sec) = section {
1371 out.push('#');
1372 out.push_str(sec);
1373 }
1374 if let Some(r) = rest {
1375 out.push_str(&r);
1376 }
1377 out
1378}
1379
1380pub fn count_nodes_by_scope(db: &Database) -> Result<BTreeMap<String, usize>> {
1382 let mut stmt = db
1383 .conn
1384 .prepare("SELECT scope, COUNT(*) FROM nodes GROUP BY scope ORDER BY scope")?;
1385 let rows = stmt.query_map([], |row| {
1386 Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?))
1387 })?;
1388 let mut map = BTreeMap::new();
1389 for r in rows {
1390 let (s, n) = r?;
1391 map.insert(s, n);
1392 }
1393 Ok(map)
1394}
1395
1396pub fn format_scopes_text(
1398 manifest: &WorkspaceManifest,
1399 counts: &BTreeMap<String, usize>,
1400) -> String {
1401 let mut out = String::new();
1402 out.push_str(&format!(
1403 "mode: {} main: {}\n",
1404 manifest.mode.as_str(),
1405 manifest.main_id
1406 ));
1407 if !manifest.is_multi() {
1408 out.push_str("multi-brain: off (all nodes are MainBrain)\n");
1409 out.push_str("enable: rustbrain scopes enable --cargo # or --empty\n");
1410 if let Some(n) = counts.get(MAIN_SCOPE).or_else(|| counts.values().next()) {
1411 out.push_str(&format!("nodes: {n}\n"));
1412 }
1413 return out;
1414 }
1415 let main_n = counts.get(&manifest.main_id).copied().unwrap_or(0);
1416 out.push_str(&format!(
1417 " [{:>6} nodes] {} (MainBrain)\n",
1418 main_n, manifest.main_id
1419 ));
1420 if manifest.scopes.is_empty() {
1421 out.push_str(" (no SubBrains yet — scopes add <id> --root <path>)\n");
1422 }
1423 for sc in &manifest.scopes {
1424 let n = counts.get(&sc.id).copied().unwrap_or(0);
1425 out.push_str(&format!(
1426 " [{:>6} nodes] {} roots={} source={}\n",
1427 n,
1428 sc.id,
1429 sc.roots.join(","),
1430 sc.source.as_str()
1431 ));
1432 if !sc.aliases.is_empty() {
1433 out.push_str(&format!(" aliases: {}\n", sc.aliases.join(", ")));
1434 }
1435 }
1436 for (s, n) in counts {
1438 if s != &manifest.main_id && manifest.find_scope(s).is_none() {
1439 out.push_str(&format!(
1440 " [{n:>6} nodes] {s} (orphan scope in DB — absorb or re-sync)\n"
1441 ));
1442 }
1443 }
1444 out
1445}
1446
1447#[cfg(test)]
1448mod tests {
1449 use super::*;
1450 use tempfile::tempdir;
1451
1452 #[test]
1453 fn longest_prefix_wins() {
1454 let mut m = WorkspaceManifest::single(Path::new("/tmp/ws"));
1455 m.mode = BrainMode::Multi;
1456 m.scopes.push(ScopeDef {
1457 id: "core".into(),
1458 roots: vec!["crates/core".into()],
1459 aliases: vec![],
1460 source: ScopeSource::Manual,
1461 });
1462 m.scopes.push(ScopeDef {
1463 id: "cli".into(),
1464 roots: vec!["crates/cli".into()],
1465 aliases: vec![],
1466 source: ScopeSource::Manual,
1467 });
1468 assert_eq!(m.resolve_scope("crates/core/src/lib.rs"), "core");
1469 assert_eq!(m.resolve_scope("crates/cli/src/main.rs"), "cli");
1470 assert_eq!(m.resolve_scope("docs/adr/x.md"), "main");
1471 assert_eq!(m.resolve_scope("README.md"), "main");
1472 }
1473
1474 #[test]
1475 fn single_mode_always_main() {
1476 let m = WorkspaceManifest::single(Path::new("/tmp/ws"));
1477 assert_eq!(m.resolve_scope("crates/core/src/lib.rs"), "main");
1478 }
1479
1480 #[test]
1481 fn sanitize_rejects_main() {
1482 assert!(sanitize_scope_id("main").is_err());
1483 assert_eq!(sanitize_scope_id("RustBrain_Core").unwrap(), "rustbrain-core");
1484 }
1485
1486 #[test]
1487 fn rewrite_wikilinks_prefixes_paths() {
1488 let src = "See [[docs/concepts/raft]] and [[docs/adr/x|Alias]] and [[Bare Title]].\n";
1489 let out = rewrite_wikilinks_for_import(src, "docs/subbrains/foreign");
1490 assert!(out.contains("[[docs/subbrains/foreign/docs/concepts/raft]]"));
1491 assert!(out.contains("[[docs/subbrains/foreign/docs/adr/x|Alias]]"));
1492 assert!(out.contains("[[Bare Title]]"));
1493 }
1494
1495 #[test]
1496 fn brace_and_star_cargo_patterns() {
1497 let dir = tempdir().unwrap();
1498 let ws = dir.path();
1499 fs::create_dir_all(ws.join("crates/a")).unwrap();
1500 fs::create_dir_all(ws.join("crates/b")).unwrap();
1501 fs::create_dir_all(ws.join("apps/web")).unwrap();
1502 let braced = expand_member_pattern(ws, "crates/{a,b}").unwrap();
1503 assert_eq!(braced.len(), 2);
1504 let star = expand_member_pattern(ws, "crates/*").unwrap();
1505 assert_eq!(star.len(), 2);
1506 }
1507
1508 #[test]
1509 fn detect_and_cwd_scope() {
1510 let dir = tempdir().unwrap();
1511 let ws = dir.path();
1512 fs::create_dir_all(ws.join("project-a/src")).unwrap();
1513 fs::create_dir_all(ws.join(".brain")).unwrap();
1514 enable_multi(ws, false).unwrap();
1515 attach_subbrain(ws, "project-a", "project-a", &[]).unwrap();
1516 let rep = detect_path(ws, &ws.join("project-a")).unwrap();
1517 assert_eq!(rep.suggested_id, "project-a");
1518 assert!(rep.mountable);
1519 assert_eq!(rep.existing_scope.as_deref(), Some("project-a"));
1520 let sc = scope_for_cwd(ws, &ws.join("project-a/src")).unwrap();
1521 assert_eq!(sc, "project-a");
1522 assert!(scope_for_cwd(ws, ws).is_none());
1523 }
1524
1525 #[test]
1526 fn roundtrip_manifest() {
1527 let dir = tempdir().unwrap();
1528 let ws = dir.path();
1529 fs::create_dir_all(ws.join(".brain")).unwrap();
1530 fs::create_dir_all(ws.join("crates/foo")).unwrap();
1531 let mut m = enable_multi(ws, false).unwrap();
1532 assert!(m.is_multi());
1533 m = add_scope(
1534 ws,
1535 "foo",
1536 &["crates/foo".into()],
1537 &["pkg-foo".into()],
1538 ScopeSource::Manual,
1539 )
1540 .unwrap();
1541 assert_eq!(m.scopes.len(), 1);
1542 let loaded = load_manifest(ws).unwrap();
1543 assert_eq!(loaded.scopes[0].id, "foo");
1544 }
1545
1546 #[test]
1547 fn cargo_discover_this_repo_shape() {
1548 let dir = tempdir().unwrap();
1550 let ws = dir.path();
1551 fs::create_dir_all(ws.join("crates/a")).unwrap();
1552 fs::create_dir_all(ws.join("crates/b")).unwrap();
1553 fs::write(
1554 ws.join("Cargo.toml"),
1555 r#"[workspace]
1556members = ["crates/*"]
1557"#,
1558 )
1559 .unwrap();
1560 fs::write(
1561 ws.join("crates/a/Cargo.toml"),
1562 r#"[package]
1563name = "pkg_a"
1564version = "0.1.0"
1565"#,
1566 )
1567 .unwrap();
1568 fs::write(
1569 ws.join("crates/b/Cargo.toml"),
1570 r#"[package]
1571name = "b"
1572version = "0.1.0"
1573"#,
1574 )
1575 .unwrap();
1576 let members = discover_cargo_members(ws).unwrap();
1577 assert_eq!(members.len(), 2);
1578 let a = members.iter().find(|m| m.id == "a").unwrap();
1579 assert_eq!(a.package_name.as_deref(), Some("pkg_a"));
1580 }
1581
1582 #[test]
1583 fn multi_scope_index_and_query_filter() {
1584 use crate::{Brain, QueryOptions};
1585 let dir = tempdir().unwrap();
1586 let ws = dir.path();
1587 fs::create_dir_all(ws.join("crates/core/src")).unwrap();
1588 fs::create_dir_all(ws.join("crates/cli/src")).unwrap();
1589 fs::create_dir_all(ws.join("docs/concepts")).unwrap();
1590 fs::write(
1591 ws.join("Cargo.toml"),
1592 r#"[workspace]
1593members = ["crates/*"]
1594"#,
1595 )
1596 .unwrap();
1597 fs::write(
1598 ws.join("crates/core/Cargo.toml"),
1599 r#"[package]
1600name = "core"
1601version = "0.1.0"
1602edition = "2021"
1603"#,
1604 )
1605 .unwrap();
1606 fs::write(
1607 ws.join("crates/cli/Cargo.toml"),
1608 r#"[package]
1609name = "cli"
1610version = "0.1.0"
1611edition = "2021"
1612"#,
1613 )
1614 .unwrap();
1615 fs::write(
1616 ws.join("crates/core/src/lib.rs"),
1617 "/// Storage engine core.\npub struct StorageEngine;\n",
1618 )
1619 .unwrap();
1620 fs::write(
1621 ws.join("crates/cli/src/main.rs"),
1622 "fn main() { println!(\"cli clap parse\"); }\n",
1623 )
1624 .unwrap();
1625 fs::write(
1626 ws.join("docs/concepts/shared.md"),
1627 "---\nnode_type: concept\n---\n# Shared\n\nShared note about clap and storage.\n",
1628 )
1629 .unwrap();
1630 fs::write(
1631 ws.join("crates/core/README.md"),
1632 "# Core crate\n\nStorage engine details only in core.\n",
1633 )
1634 .unwrap();
1635
1636 let mut brain = Brain::create(ws).unwrap();
1637 enable_multi(ws, true).unwrap();
1638 brain.sync().unwrap();
1639
1640 let counts = count_nodes_by_scope(brain.database()).unwrap();
1641 assert!(counts.get("core").copied().unwrap_or(0) >= 1);
1642 assert!(counts.get("cli").copied().unwrap_or(0) >= 1);
1643
1644 let mut opts = QueryOptions::human();
1645 opts.scope = Some("core".into());
1646 opts.scope_main = crate::query::ScopeMainInclude::Strict;
1647 opts.limit = 20;
1648 let hits = brain.query_ranked("storage", &opts).unwrap();
1649 assert!(
1650 hits.iter().all(|h| h.node.scope == "core"),
1651 "strict core scope should not leak other scopes: {:?}",
1652 hits.iter().map(|h| (&h.node.id, &h.node.scope)).collect::<Vec<_>>()
1653 );
1654
1655 let rep = absorb_scope(ws, brain.database(), "core").unwrap();
1657 assert!(rep.nodes_reassigned >= 1);
1658 let m = load_manifest(ws).unwrap();
1659 assert!(m.find_scope("core").is_none());
1660 }
1661
1662 #[test]
1663 fn import_brain_as_subbrain_copies_md() {
1664 let src = tempdir().unwrap();
1665 let dst = tempdir().unwrap();
1666 fs::create_dir_all(src.path().join("docs")).unwrap();
1667 fs::write(
1668 src.path().join("docs/note.md"),
1669 "---\nnode_type: concept\n---\n# Imported Note\n\nHello from source brain.\n",
1670 )
1671 .unwrap();
1672 let report = import_brain(
1673 dst.path(),
1674 src.path(),
1675 &ImportBrainOptions {
1676 into_scope: "foreign".into(),
1677 dest_root: None,
1678 copy_markdown: true,
1679 force: false,
1680 mount: false,
1681 max_files: IMPORT_DEFAULT_MAX_FILES,
1682 max_bytes: IMPORT_DEFAULT_MAX_BYTES,
1683 },
1684 )
1685 .unwrap();
1686 assert_eq!(report.files_copied, 1);
1687 assert!(report.scope_registered);
1688 assert!(!report.mounted);
1689 assert!(dst
1690 .path()
1691 .join("docs/subbrains/foreign/docs/note.md")
1692 .is_file());
1693 let m = load_manifest(dst.path()).unwrap();
1694 assert!(m.is_multi());
1695 assert!(m.find_scope("foreign").is_some());
1696 }
1697
1698 #[test]
1699 fn umbrella_mount_three_former_mainbrains() {
1700 let umbrella = tempdir().unwrap();
1701 let u = umbrella.path();
1702 for name in ["alpha", "beta", "gamma"] {
1703 fs::create_dir_all(u.join(name).join("docs")).unwrap();
1704 fs::write(
1705 u.join(name).join("docs/note.md"),
1706 format!("---\nnode_type: concept\n---\n# {name}\n\nNote in {name}.\n"),
1707 )
1708 .unwrap();
1709 fs::create_dir_all(u.join(name).join(".brain")).unwrap();
1711 fs::write(
1712 u.join(name).join(".brain/workspace.json"),
1713 r#"{"version":1,"workspace":"nested"}"#,
1714 )
1715 .unwrap();
1716 }
1717 use crate::Brain;
1718 let mut brain = Brain::create(u).unwrap();
1719 enable_multi(u, false).unwrap();
1720 for name in ["alpha", "beta", "gamma"] {
1721 import_brain(
1722 u,
1723 &u.join(name),
1724 &ImportBrainOptions {
1725 into_scope: name.into(),
1726 mount: true,
1727 copy_markdown: false,
1728 ..Default::default()
1729 },
1730 )
1731 .unwrap();
1732 }
1733 let m = load_manifest(u).unwrap();
1734 assert_eq!(m.scopes.len(), 3);
1735 assert_eq!(m.resolve_scope("alpha/docs/note.md"), "alpha");
1736 assert_eq!(m.resolve_scope("beta/docs/note.md"), "beta");
1737 assert_eq!(m.resolve_scope("docs/x.md"), "main");
1738
1739 brain.sync().unwrap();
1740 let rep = reconcile_scopes(u, brain.database()).unwrap();
1741 assert!(rep.updated + rep.unchanged > 0);
1742 let counts = count_nodes_by_scope(brain.database()).unwrap();
1743 assert!(counts.get("alpha").copied().unwrap_or(0) >= 1);
1744 assert!(counts.get("beta").copied().unwrap_or(0) >= 1);
1745 }
1746}