Skip to main content

rustbrain_core/
scopes.rs

1//! MainBrain / SubBrain workspace scopes (multi-crate / multi-root).
2//!
3//! **Default is single-brain:** every node owns scope [`MAIN_SCOPE`] (`main`).
4//! Multi-brain is **opt-in** via [`enable_multi`] / `rustbrain scopes enable`.
5//!
6//! Design rules:
7//! - Flat sibling SubBrains only (no nested brains).
8//! - One canonical owner scope per node.
9//! - Cross-scope WikiLinks / symbols stay in one workspace graph.
10//! - Distinct from `--all-workspaces` (machine-global registry of projects).
11
12use 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
19/// Canonical MainBrain scope id.
20pub const MAIN_SCOPE: &str = "main";
21
22/// Workspace brain mode.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
24#[serde(rename_all = "snake_case")]
25pub enum BrainMode {
26    /// One bag of knowledge; all nodes are `main`. Default for most repos.
27    #[default]
28    Single,
29    /// Multi-scope: MainBrain + optional SubBrains with path roots.
30    Multi,
31}
32
33impl BrainMode {
34    /// Parse `single` / `multi`.
35    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    /// Wire string.
44    pub fn as_str(self) -> &'static str {
45        match self {
46            Self::Single => "single",
47            Self::Multi => "multi",
48        }
49    }
50}
51
52/// How a scope entry was created.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
54#[serde(rename_all = "snake_case")]
55pub enum ScopeSource {
56    /// User / agent added via CLI.
57    #[default]
58    Manual,
59    /// Discovered from Cargo `[workspace].members`.
60    CargoWorkspace,
61    /// Imported from another brain / workspace.
62    Imported,
63    /// Explicit extra root (non-Cargo).
64    ExtraRoot,
65}
66
67impl ScopeSource {
68    /// Wire string.
69    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/// One SubBrain (or extra root) under the MainBrain.
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
81pub struct ScopeDef {
82    /// Flat scope id (path-stable, e.g. `rustbrain-core`).
83    pub id: String,
84    /// Workspace-relative directory roots owned by this scope.
85    pub roots: Vec<String>,
86    /// Optional aliases (Cargo package name when different from id).
87    #[serde(default, skip_serializing_if = "Vec::is_empty")]
88    pub aliases: Vec<String>,
89    /// Provenance.
90    #[serde(default)]
91    pub source: ScopeSource,
92}
93
94/// Versioned `.brain/workspace.json` manifest.
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
96pub struct WorkspaceManifest {
97    /// Manifest format version (2 = scopes).
98    pub version: u32,
99    /// Absolute or display path of workspace root.
100    pub workspace: String,
101    /// Single vs multi-brain.
102    #[serde(default)]
103    pub mode: BrainMode,
104    /// MainBrain id (always present conceptually; default `main`).
105    #[serde(default = "default_main_id")]
106    pub main_id: String,
107    /// SubBrain definitions (never includes main itself).
108    #[serde(default)]
109    pub scopes: Vec<ScopeDef>,
110    /// Discovery toggles.
111    #[serde(default)]
112    pub discovery: DiscoveryOptions,
113}
114
115fn default_main_id() -> String {
116    MAIN_SCOPE.to_string()
117}
118
119/// How scopes were / should be discovered.
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
121pub struct DiscoveryOptions {
122    /// Auto-refresh Cargo workspace members on enable / discover.
123    #[serde(default)]
124    pub cargo_workspace: bool,
125}
126
127impl WorkspaceManifest {
128    /// New single-mode marker (compatible with older tiny markers).
129    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    /// Whether multi-brain filtering is active.
141    pub fn is_multi(&self) -> bool {
142        self.mode == BrainMode::Multi
143    }
144
145    /// Resolve owner scope for a workspace-relative path (`/`-separated).
146    ///
147    /// Longest matching SubBrain root wins; otherwise MainBrain.
148    /// In **single** mode always returns [`MAIN_SCOPE`].
149    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    /// Look up a SubBrain by id or alias.
174    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    /// Mut look up.
182    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    /// All known scope ids including main.
190    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    /// Validate no overlapping roots; returns error messages.
199    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                // allow only if main_id is literally that id — subbrains must not use main id
208            }
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        // Overlap detection (equal roots or one prefix of another across scopes).
228        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
260/// Sanitize a user-provided scope id.
261pub 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
282/// Path to `.brain/workspace.json`.
283pub fn manifest_path(workspace: &Path) -> PathBuf {
284    workspace.join(".brain").join("workspace.json")
285}
286
287/// Load manifest if present; otherwise single-mode default (does not write).
288pub 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    // Backward compat: tiny v1 marker `{ "version": 1, "workspace": "..." }`
299    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
320/// Write manifest (pretty JSON). Creates `.brain/` if needed.
321pub 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
340/// Ensure a workspace.json exists (single mode) without enabling multi.
341pub 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// ── Cargo discovery ─────────────────────────────────────────────────────────
352
353/// One discovered Cargo workspace member.
354#[derive(Debug, Clone)]
355pub struct CargoMember {
356    /// Path-stable scope id (last segment of member path, sanitized).
357    pub id: String,
358    /// Workspace-relative member directory.
359    pub root: String,
360    /// Package name from member Cargo.toml if present.
361    pub package_name: Option<String>,
362}
363
364/// Discover `[workspace].members` from root `Cargo.toml` (no recursion into nested workspaces).
365pub 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        // Simple globs: "crates/*" or exact path
386        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    // Prefer path segment; keep dots for crate names like foo.bar rarely
436    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
455/// Apply Cargo members into manifest scopes (replaces prior cargo-workspace sources).
456pub 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            // Keep manual overrides; only fill if missing
464            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
483// ── Enable / disable / mutate ───────────────────────────────────────────────
484
485/// Enable multi-brain mode. Optionally discover Cargo members.
486pub 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
498/// Disable multi-brain (single mode). Does not change node scopes until absorb/re-sync.
499pub 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
510/// Add or replace a SubBrain root.
511pub 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
556/// Remove a SubBrain from the manifest (does not rewrite nodes).
557pub 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
568/// Reassign all nodes with `from_scope` → `to_scope` in SQLite.
569pub 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
577/// Absorb a SubBrain into MainBrain: reassign nodes + drop scope def.
578///
579/// Order: DB reassignment first (idempotent), then manifest update so a crash
580/// mid-way never leaves a live SubBrain def with nodes already on main.
581pub 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/// Report from absorb.
608#[derive(Debug, Clone)]
609pub struct AbsorbReport {
610    /// Scope that was removed.
611    pub absorbed_id: String,
612    /// Rows updated in SQLite.
613    pub nodes_reassigned: usize,
614    /// Resulting manifest.
615    pub manifest: WorkspaceManifest,
616}
617
618/// Absorb **all** SubBrains into main and set single mode.
619pub 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    // Stray scopes not in manifest
637    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// ── Reconcile / attach (umbrella workspaces) ────────────────────────────────
666
667/// Report from [`reconcile_scopes`].
668#[derive(Debug, Clone, Serialize)]
669pub struct ReconcileReport {
670    /// Nodes whose scope column changed.
671    pub updated: usize,
672    /// Nodes already correct.
673    pub unchanged: usize,
674    /// Scope labels present in DB but not in manifest (reassigned to main when multi,
675    /// or all → main when single).
676    pub orphan_scopes_cleared: Vec<String>,
677    /// Manifest mode after reconcile.
678    pub mode: String,
679}
680
681/// Recompute every node's owner scope from the current manifest (+ optional frontmatter).
682///
683/// Run after `enable` / `add` / `attach` / moving roots, or when doctor reports orphans.
684pub 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            // Frontmatter override when multi
712            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            // symbols without path: keep main unless already a known scope
723            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
770/// Attach an existing directory under the workspace as a SubBrain **without copying**.
771///
772/// Umbrella pattern: three former mono-repos under one folder become SubBrains of a
773/// new MainBrain:
774///
775/// ```text
776/// umbrella/           ← rustbrain scopes enable --empty
777///   .brain/           ← new MainBrain
778///   project-a/        ← scopes attach project-a --root project-a
779///   project-b/
780///   project-c/
781/// ```
782///
783/// Nested `project-a/.brain` may still exist for working inside that tree alone
784/// (`Brain::open` finds the nearest `.brain`). The umbrella indexes path ownership.
785pub 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    // Refuse attaching the workspace root itself as a SubBrain.
799    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/// Report for absorb-all.
814#[derive(Debug, Clone)]
815pub struct AbsorbAllReport {
816    /// Removed SubBrain ids.
817    pub scopes_removed: Vec<String>,
818    /// Nodes reassigned.
819    pub nodes_reassigned: usize,
820    /// Resulting manifest.
821    pub manifest: WorkspaceManifest,
822}
823
824// ── Import another brain ────────────────────────────────────────────────────
825
826/// Default max Markdown files copied in one import (DoS guard).
827pub const IMPORT_DEFAULT_MAX_FILES: usize = 5_000;
828/// Default max total bytes copied in one import.
829pub const IMPORT_DEFAULT_MAX_BYTES: u64 = 50 * 1024 * 1024;
830
831/// Options for importing one workspace brain into another.
832#[derive(Debug, Clone)]
833pub struct ImportBrainOptions {
834    /// Destination scope id. Use [`MAIN_SCOPE`] to **merge** into MainBrain.
835    /// Any other id registers/keeps a **separate SubBrain**.
836    pub into_scope: String,
837    /// Relative dest root for copied Markdown (default: `docs/subbrains/<id>` or `docs/imported`).
838    pub dest_root: Option<String>,
839    /// Copy Markdown notes from source workspace (ignored when `mount` succeeds).
840    pub copy_markdown: bool,
841    /// Overwrite existing files at destination.
842    pub force: bool,
843    /// If source lies under target, register that path as SubBrain root without copying.
844    /// Umbrella: `scopes import --from ./project-a --as project-a --mount`.
845    pub mount: bool,
846    /// Max `.md` files to copy (default [`IMPORT_DEFAULT_MAX_FILES`]).
847    pub max_files: usize,
848    /// Max total bytes to copy (default [`IMPORT_DEFAULT_MAX_BYTES`]).
849    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/// Result of importing a brain.
867#[derive(Debug, Clone, Serialize)]
868pub struct ImportBrainReport {
869    /// Source workspace path.
870    pub source: String,
871    /// Target scope id.
872    pub into_scope: String,
873    /// Files copied.
874    pub files_copied: usize,
875    /// Files skipped (exist, no force).
876    pub files_skipped: usize,
877    /// Destination or mount root used.
878    pub dest_root: String,
879    /// Whether a SubBrain was registered.
880    pub scope_registered: bool,
881    /// True when source was attached in-place (no file copy).
882    pub mounted: bool,
883    /// Bytes copied (0 when mounted).
884    pub bytes_copied: u64,
885}
886
887/// Import knowledge from `source_workspace` into `target_workspace`.
888///
889/// | Mode | Behavior |
890/// |------|----------|
891/// | `--as id` (copy) | Copy Markdown under `docs/subbrains/id/`; SubBrain stays separate |
892/// | `--as id --mount` | If source is under target, attach path as root (umbrella, no copy) |
893/// | `--into main` | **Merge** copy into MainBrain tree (`docs/imported/` by default) |
894///
895/// Nested source `.brain/` is **not** deleted; mount keeps projects shareable and
896/// independently openable. Call `sync` + `scopes reconcile` after.
897pub 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    // Mount path: source is a subdirectory of target → attach without copy.
928    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
1091/// Counts of nodes per scope (from SQLite).
1092pub 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
1107/// Human-readable scopes listing.
1108pub 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    // Stray scopes in DB not in manifest
1148    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        // Pattern expansion unit: create mini workspace
1221        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        // Absorb core into main
1328        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            // Nested former brain marker (still valid for local open)
1382            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}