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    // Brace expansion: crates/{foo,bar} or {a,b}
407    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    // One-level glob: crates/* or crates/*/src (only first * segment)
428    if let Some(star) = pat.find("/*") {
429        let parent = &pat[..star];
430        let rest = &pat[star + 2..]; // after /*
431        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                // recurse remaining globs
450                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
473/// Suggest a path-stable SubBrain id for a directory name or path.
474pub 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/// Report from [`detect_path`] — what id to use for import/attach.
488#[derive(Debug, Clone, Serialize)]
489pub struct DetectReport {
490    /// Absolute path inspected.
491    pub path: String,
492    /// Suggested SubBrain id (directory name, sanitized).
493    pub suggested_id: String,
494    /// True if `path` is under `workspace`.
495    pub under_workspace: bool,
496    /// Workspace-relative root when under_workspace.
497    pub relative_root: Option<String>,
498    /// True if `--mount` / `attach` is possible (under workspace).
499    pub mountable: bool,
500    /// True if path has its own `.brain/db.sqlite`.
501    pub has_nested_brain: bool,
502    /// Existing SubBrain id if this root is already registered.
503    pub existing_scope: Option<String>,
504    /// Human tips.
505    pub tips: Vec<String>,
506}
507
508/// Detect how a path should be attached/imported as a SubBrain.
509///
510/// Agents should run this **before** `import --as` / `attach` to learn the id.
511pub 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
590/// If `cwd` sits under a SubBrain root of `workspace`, return that scope id.
591///
592/// Returns `None` for single mode, workspace root CWD, or paths that only match MainBrain.
593pub 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    // Prefer matching as if indexing a file under cwd
606    let probe = format!("{rel}/.");
607    let scope = m.resolve_scope(&probe);
608    if scope == m.main_id || scope == MAIN_SCOPE {
609        // try direct
610        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    // Prefer path segment; keep dots for crate names like foo.bar rarely
623    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
642/// Apply Cargo members into manifest scopes (replaces prior cargo-workspace sources).
643pub 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            // Keep manual overrides; only fill if missing
651            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
670// ── Enable / disable / mutate ───────────────────────────────────────────────
671
672/// Enable multi-brain mode. Optionally discover Cargo members.
673pub 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
685/// Disable multi-brain (single mode). Does not change node scopes until absorb/re-sync.
686pub 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
697/// Add or replace a SubBrain root.
698pub 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
743/// Remove a SubBrain from the manifest (does not rewrite nodes).
744pub 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
755/// Reassign all nodes with `from_scope` → `to_scope` in SQLite.
756pub 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
764/// Absorb a SubBrain into MainBrain: reassign nodes + drop scope def.
765///
766/// Order: DB reassignment first (idempotent), then manifest update so a crash
767/// mid-way never leaves a live SubBrain def with nodes already on main.
768pub 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/// Report from absorb.
795#[derive(Debug, Clone)]
796pub struct AbsorbReport {
797    /// Scope that was removed.
798    pub absorbed_id: String,
799    /// Rows updated in SQLite.
800    pub nodes_reassigned: usize,
801    /// Resulting manifest.
802    pub manifest: WorkspaceManifest,
803}
804
805/// Absorb **all** SubBrains into main and set single mode.
806pub 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    // Stray scopes not in manifest
824    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// ── Reconcile / attach (umbrella workspaces) ────────────────────────────────
853
854/// Report from [`reconcile_scopes`].
855#[derive(Debug, Clone, Serialize)]
856pub struct ReconcileReport {
857    /// Nodes whose scope column changed.
858    pub updated: usize,
859    /// Nodes already correct.
860    pub unchanged: usize,
861    /// Scope labels present in DB but not in manifest (reassigned to main when multi,
862    /// or all → main when single).
863    pub orphan_scopes_cleared: Vec<String>,
864    /// Manifest mode after reconcile.
865    pub mode: String,
866}
867
868/// Recompute every node's owner scope from the current manifest (+ optional frontmatter).
869///
870/// Run after `enable` / `add` / `attach` / moving roots, or when doctor reports orphans.
871pub 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            // Frontmatter override when multi
899            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            // symbols without path: keep main unless already a known scope
910            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
957/// Attach an existing directory under the workspace as a SubBrain **without copying**.
958///
959/// Umbrella pattern: three former mono-repos under one folder become SubBrains of a
960/// new MainBrain:
961///
962/// ```text
963/// umbrella/           ← rustbrain scopes enable --empty
964///   .brain/           ← new MainBrain
965///   project-a/        ← scopes attach project-a --root project-a
966///   project-b/
967///   project-c/
968/// ```
969///
970/// Nested `project-a/.brain` may still exist for working inside that tree alone
971/// (`Brain::open` finds the nearest `.brain`). The umbrella indexes path ownership.
972pub 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    // Refuse attaching the workspace root itself as a SubBrain.
986    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/// Report for absorb-all.
1001#[derive(Debug, Clone)]
1002pub struct AbsorbAllReport {
1003    /// Removed SubBrain ids.
1004    pub scopes_removed: Vec<String>,
1005    /// Nodes reassigned.
1006    pub nodes_reassigned: usize,
1007    /// Resulting manifest.
1008    pub manifest: WorkspaceManifest,
1009}
1010
1011// ── Import another brain ────────────────────────────────────────────────────
1012
1013/// Default max Markdown files copied in one import (DoS guard).
1014pub const IMPORT_DEFAULT_MAX_FILES: usize = 5_000;
1015/// Default max total bytes copied in one import.
1016pub const IMPORT_DEFAULT_MAX_BYTES: u64 = 50 * 1024 * 1024;
1017
1018/// Options for importing one workspace brain into another.
1019#[derive(Debug, Clone)]
1020pub struct ImportBrainOptions {
1021    /// Destination scope id. Use [`MAIN_SCOPE`] to **merge** into MainBrain.
1022    /// Any other id registers/keeps a **separate SubBrain**.
1023    pub into_scope: String,
1024    /// Relative dest root for copied Markdown (default: `docs/subbrains/<id>` or `docs/imported`).
1025    pub dest_root: Option<String>,
1026    /// Copy Markdown notes from source workspace (ignored when `mount` succeeds).
1027    pub copy_markdown: bool,
1028    /// Overwrite existing files at destination.
1029    pub force: bool,
1030    /// If source lies under target, register that path as SubBrain root without copying.
1031    /// Umbrella: `scopes import --from ./project-a --as project-a --mount`.
1032    pub mount: bool,
1033    /// Max `.md` files to copy (default [`IMPORT_DEFAULT_MAX_FILES`]).
1034    pub max_files: usize,
1035    /// Max total bytes to copy (default [`IMPORT_DEFAULT_MAX_BYTES`]).
1036    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/// Result of importing a brain.
1054#[derive(Debug, Clone, Serialize)]
1055pub struct ImportBrainReport {
1056    /// Source workspace path.
1057    pub source: String,
1058    /// Target scope id.
1059    pub into_scope: String,
1060    /// Files copied.
1061    pub files_copied: usize,
1062    /// Files skipped (exist, no force).
1063    pub files_skipped: usize,
1064    /// Destination or mount root used.
1065    pub dest_root: String,
1066    /// Whether a SubBrain was registered.
1067    pub scope_registered: bool,
1068    /// True when source was attached in-place (no file copy).
1069    pub mounted: bool,
1070    /// Bytes copied (0 when mounted).
1071    pub bytes_copied: u64,
1072}
1073
1074/// Import knowledge from `source_workspace` into `target_workspace`.
1075///
1076/// | Mode | Behavior |
1077/// |------|----------|
1078/// | `--as id` (copy) | Copy Markdown under `docs/subbrains/id/`; SubBrain stays separate |
1079/// | `--as id --mount` | If source is under target, attach path as root (umbrella, no copy) |
1080/// | `--into main` | **Merge** copy into MainBrain tree (`docs/imported/` by default) |
1081///
1082/// Nested source `.brain/` is **not** deleted; mount keeps projects shareable and
1083/// independently openable. Call `sync` + `scopes reconcile` after.
1084pub 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    // Mount path: source is a subdirectory of target → attach without copy.
1115    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
1283/// Prefix path-like WikiLink targets so they resolve after copy under `dest_prefix`.
1284///
1285/// Bare titles / aliases are left alone. Targets that already start with `dest_prefix`
1286/// or look like hubs (`readme`, …) are left alone.
1287pub 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        // toggle ``` fences (line start)
1298        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    // [[target]], [[target|alias]], [[target#sec]], [[target#sec|alias]]
1341    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    // Path-like: contains / or looks like docs/...
1359    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    // collapse double prefix
1368    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
1380/// Counts of nodes per scope (from SQLite).
1381pub 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
1396/// Human-readable scopes listing.
1397pub 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    // Stray scopes in DB not in manifest
1437    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        // Pattern expansion unit: create mini workspace
1549        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        // Absorb core into main
1656        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            // Nested former brain marker (still valid for local open)
1710            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}