Skip to main content

memstead_base/filesystem/
config.rs

1//! Workspace-shape `.memstead/config.json` for filesystem mems.
2//!
3//! Distinct from the archive-shape config in
4//! [`super::super::validator::config`]: the workspace shape adds a
5//! `deps` list (cross-mem dependencies on registry-published mems)
6//! and pins `format` to a different version namespace so a workspace
7//! file accidentally fed to the archive validator (or vice versa)
8//! surfaces as a typed mismatch rather than a generic serde error.
9//!
10//! ## Shape (as written to disk)
11//!
12//! ```json
13//! {
14//!   "format": 1,
15//!   "schema": "default@1.0.0",
16//!   "deps": ["anthropic/core"]
17//! }
18//! ```
19//!
20//! - `format`: workspace-config format integer. Bumped on breaking
21//!   shape changes; current = [`FILESYSTEM_WORKSPACE_FORMAT`].
22//! - `name`: mem slug — **path-derived**, not persisted. Identity of
23//!   record is the mounts roster (`state/mounts.json`); on read an absent
24//!   `name` is filled from the workspace-root basename. The schema
25//!   validator tombstones a stray `name` in `config.json`.
26//! - `schema`: mem schema pin in exact `<name>@<version>` form
27//!   (e.g. `"default@1.0.0"`) — bare-name pins are rejected at parse.
28//! - `deps`: cross-mem dependencies, each in `scope/name` form. The
29//!   list ordering is preserved on round-trip; duplicates are
30//!   rejected at parse time.
31//! - `version`, `description`, `authors`: optional fields used by
32//!   `memstead publish` to populate the archive shape. Carried through
33//!   so the workspace remains the source of truth for publish
34//!   metadata.
35//!
36//! ## Publish projection
37//!
38//! [`Self::to_published`] converts the workspace shape to a strict
39//! [`PublishedMemConfig`] for `memstead publish`, dropping `deps` and
40//! enforcing the archive's stricter requirements (versioned schema,
41//! present `version` field). Errors surface via
42//! [`PublishConversionError`] from `memstead-schema`.
43
44use std::collections::BTreeSet;
45use std::path::{Path, PathBuf};
46
47use memstead_schema::{
48    PUBLISHED_MEM_FORMAT, PublishConversionError, PublishedMemConfig, SchemaRef,
49};
50use regex::Regex;
51use serde::{Deserialize, Serialize};
52
53/// Format integer for the filesystem workspace `.memstead/config.json`. Bumped
54/// on breaking shape changes; current consumers (`memstead init`,
55/// `memstead link`, `memstead publish`, the filesystem engine) all check this
56/// before parsing the rest. Distinct from
57/// [`memstead_schema::PUBLISHED_MEM_FORMAT`] so a misfiled archive
58/// config inside a workspace surfaces as
59/// [`WorkspaceConfigError::UnsupportedFormat`].
60pub const FILESYSTEM_WORKSPACE_FORMAT: u32 = 1;
61
62/// Cross-mem dependency entry. Mirrors the Tier 3 wiki-link
63/// addressing scheme `[[scope/name:slug]]` and the `memstead link
64/// <scope>/<name>` CLI shorthand.
65#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
66pub struct DepRef {
67    pub scope: String,
68    pub name: String,
69}
70
71impl DepRef {
72    /// Round-trip display form used in `deps`: `<scope>/<name>`.
73    pub fn as_display(&self) -> String {
74        format!("{}/{}", self.scope, self.name)
75    }
76}
77
78impl std::fmt::Display for DepRef {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        f.write_str(&self.as_display())
81    }
82}
83
84impl std::str::FromStr for DepRef {
85    type Err = String;
86
87    fn from_str(s: &str) -> Result<Self, Self::Err> {
88        let trimmed = s.trim();
89        if trimmed.is_empty() {
90            return Err("dep ref must not be empty (expected \"scope/name\")".into());
91        }
92        let (scope, name) = trimmed
93            .split_once('/')
94            .ok_or_else(|| format!("dep ref '{trimmed}' must be in 'scope/name' form"))?;
95        check_slug(scope, "scope")?;
96        check_slug(name, "name")?;
97        Ok(DepRef {
98            scope: scope.to_string(),
99            name: name.to_string(),
100        })
101    }
102}
103
104impl Serialize for DepRef {
105    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
106        serializer.serialize_str(&self.as_display())
107    }
108}
109
110impl<'de> Deserialize<'de> for DepRef {
111    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
112        let s = String::deserialize(deserializer)?;
113        s.parse::<DepRef>().map_err(serde::de::Error::custom)
114    }
115}
116
117/// Workspace-shape `.memstead/config.json` for a filesystem mem. Distinct
118/// from [`PublishedMemConfig`] (the archive shape).
119///
120/// Unknown fields are preserved, not refused: the engine's own runtime
121/// machinery writes fields this struct does not model (`syncState` from
122/// the projection sync baseline, `writeGuidance` from per-mem guidance
123/// additions), and a strict reader would both break export for any
124/// projection-maintained mem and drop those fields on rewrite.
125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
126pub struct WorkspaceConfig {
127    /// Format integer. Always [`FILESYSTEM_WORKSPACE_FORMAT`] on
128    /// successful load.
129    pub format: u32,
130    /// Mem slug — path-derived under the unified layout. The mem's identity
131    /// of record lives in the mounts roster (`state/mounts.json`); the engine no
132    /// longer writes `name` into `config.json` (the schema validator tombstones a
133    /// stray `name`), so it is omitted on serialize and, when absent on read,
134    /// filled from the workspace-root basename by `read_workspace_config`.
135    #[serde(default, skip_serializing)]
136    pub name: String,
137    /// Schema pin. Exact `<name>@<version>` only — bare-name pins are
138    /// rejected at parse.
139    pub schema: SchemaRef,
140    /// Mem version, used by `memstead publish`. Optional in the
141    /// workspace shape; required when publishing.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub version: Option<semver::Version>,
144    /// Optional human-readable description.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub description: Option<String>,
147    /// Optional human-readable display title (display text, not
148    /// identity — the slug `name` stays the sole handle).
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub title: Option<String>,
151    /// Optional subject block — published verbatim.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub subject: Option<memstead_schema::MemSubject>,
154    /// Optional author list.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub authors: Option<Vec<String>>,
157    /// Cross-mem dependencies. Order preserved; duplicates rejected
158    /// at parse time.
159    #[serde(default, skip_serializing_if = "Vec::is_empty")]
160    pub deps: Vec<DepRef>,
161    /// Engine-owned runtime fields this shape does not model
162    /// (`syncState`, `writeGuidance`, …) — carried verbatim so a
163    /// read-modify-write round-trip never destroys them.
164    #[serde(flatten)]
165    pub extra: serde_json::Map<String, serde_json::Value>,
166}
167
168impl WorkspaceConfig {
169    /// Build a fresh workspace config with `format` set to
170    /// [`FILESYSTEM_WORKSPACE_FORMAT`], the engine default `version`
171    /// (`0.1.0`), and an empty deps list. Convenience for `memstead init`.
172    /// F1: every mem carries a populated `version` from creation
173    /// onward — operators bump via `memstead mem set-version` before
174    /// publishing.
175    pub fn new(name: impl Into<String>, schema: SchemaRef) -> Self {
176        Self {
177            format: FILESYSTEM_WORKSPACE_FORMAT,
178            name: name.into(),
179            schema,
180            version: Some(semver::Version::new(0, 1, 0)),
181            description: None,
182            title: None,
183            subject: None,
184            authors: None,
185            deps: Vec::new(),
186            extra: serde_json::Map::new(),
187        }
188    }
189
190    /// Add a dep entry to the list. Idempotent — re-adding an existing
191    /// dep is a no-op so `memstead link` can be invoked twice in a row
192    /// without producing a duplicate. Returns `true` when the entry
193    /// was newly added, `false` when it was already present.
194    pub fn add_dep(&mut self, dep: DepRef) -> bool {
195        if self.deps.iter().any(|d| d == &dep) {
196            return false;
197        }
198        self.deps.push(dep);
199        true
200    }
201
202    /// Project the workspace config to the strict archive shape used
203    /// by sealed `.mem` archives. Drops `deps` and applies the same
204    /// requirements as [`memstead_schema::published_config_from`]
205    /// (versioned schema, present `version`).
206    pub fn to_published(&self) -> Result<PublishedMemConfig, PublishConversionError> {
207        let version = self
208            .version
209            .clone()
210            .ok_or(PublishConversionError::MissingVersion)?;
211        Ok(PublishedMemConfig {
212            format: PUBLISHED_MEM_FORMAT,
213            name: self.name.clone(),
214            version,
215            description: self.description.clone(),
216            title: self.title.clone(),
217            subject: self.subject.clone(),
218            authors: self.authors.clone(),
219            schema: self.schema.clone(),
220        })
221    }
222}
223
224/// Errors surfaced by [`WorkspaceConfig`] load + parse.
225#[derive(Debug, thiserror::Error)]
226pub enum WorkspaceConfigError {
227    #[error("workspace config not found at {0}")]
228    NotFound(PathBuf),
229    #[error("workspace config io error at {path}: {source}")]
230    Io {
231        path: PathBuf,
232        #[source]
233        source: std::io::Error,
234    },
235    #[error("workspace config malformed: {0}")]
236    Malformed(String),
237    #[error(
238        "workspace config format {got} is not supported (expected {expected}) — \
239         re-run `memstead init` against a fresh folder"
240    )]
241    UnsupportedFormat { got: u32, expected: u32 },
242    #[error("workspace config invalid name: {0}")]
243    InvalidName(String),
244    #[error("workspace config has duplicate dep: {0}")]
245    DuplicateDep(String),
246}
247
248fn name_regex() -> &'static Regex {
249    static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
250    RE.get_or_init(|| Regex::new(r"^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$").unwrap())
251}
252
253fn check_slug(value: &str, label: &str) -> Result<(), String> {
254    if !name_regex().is_match(value) {
255        return Err(format!(
256            "{label} {value:?} must match ^[a-z0-9][a-z0-9-]{{0,62}}[a-z0-9]$"
257        ));
258    }
259    Ok(())
260}
261
262/// Validate a mem name against the slug shape. Since the name is now
263/// path-derived (no longer persisted in `config.json`), callers that accept a
264/// mem name as input — e.g. `memstead init --name` — validate it here at the
265/// boundary instead of relying on a config round-trip to reject a bad value.
266pub fn validate_mem_name(name: &str) -> Result<(), String> {
267    check_slug(name, "name")
268}
269
270/// Conventional path of the workspace config inside a workspace root.
271pub fn config_path(workspace_root: &Path) -> PathBuf {
272    workspace_root
273        .join(crate::mem::MEM_META_DIR)
274        .join("config.json")
275}
276
277/// Parse workspace config bytes, enforcing the format pin, the name
278/// shape, and the deps-uniqueness invariant.
279pub fn parse_workspace_config(bytes: &[u8]) -> Result<WorkspaceConfig, WorkspaceConfigError> {
280    let value: serde_json::Value = serde_json::from_slice(bytes)
281        .map_err(|e| WorkspaceConfigError::Malformed(e.to_string()))?;
282
283    if !value.is_object() {
284        return Err(WorkspaceConfigError::Malformed(
285            "expected a JSON object".to_string(),
286        ));
287    }
288
289    // Pull `format` out first so a wrong-format file produces a
290    // typed error instead of a serde mismatch on a downstream field.
291    let format = value
292        .get("format")
293        .and_then(|v| v.as_u64())
294        .ok_or_else(|| WorkspaceConfigError::Malformed("missing or non-integer 'format'".into()))?;
295    if format != FILESYSTEM_WORKSPACE_FORMAT as u64 {
296        return Err(WorkspaceConfigError::UnsupportedFormat {
297            got: format as u32,
298            expected: FILESYSTEM_WORKSPACE_FORMAT,
299        });
300    }
301
302    let config: WorkspaceConfig = serde_json::from_value(value)
303        .map_err(|e| WorkspaceConfigError::Malformed(e.to_string()))?;
304
305    // A legacy `name` carried by an older on-disk config is still shape-checked;
306    // engine-written configs omit it (path-derived) and parse with an empty name
307    // that `read_workspace_config` fills from the workspace-root basename.
308    if !config.name.is_empty() {
309        check_slug(&config.name, "name").map_err(WorkspaceConfigError::InvalidName)?;
310    }
311
312    // Reject duplicate deps at parse time so the on-disk file matches
313    // the in-memory invariant `add_dep` upholds.
314    let mut seen: BTreeSet<String> = BTreeSet::new();
315    for dep in &config.deps {
316        let key = dep.as_display();
317        if !seen.insert(key.clone()) {
318            return Err(WorkspaceConfigError::DuplicateDep(key));
319        }
320    }
321
322    Ok(config)
323}
324
325/// Read + parse the workspace config at `<workspace_root>/.memstead/config.json`.
326pub fn read_workspace_config(
327    workspace_root: &Path,
328) -> Result<WorkspaceConfig, WorkspaceConfigError> {
329    let path = config_path(workspace_root);
330    let bytes = match std::fs::read(&path) {
331        Ok(b) => b,
332        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
333            return Err(WorkspaceConfigError::NotFound(path));
334        }
335        Err(e) => {
336            return Err(WorkspaceConfigError::Io { path, source: e });
337        }
338    };
339    let mut config = parse_workspace_config(&bytes)?;
340    // Path-derived identity: an engine-written config omits `name`, so fall back
341    // to the workspace-root basename (matches the unified-layout rule the schema
342    // validator enforces and `standalone_workspace` already applies).
343    if config.name.is_empty() {
344        config.name = workspace_root
345            .file_name()
346            .map(|n| n.to_string_lossy().to_string())
347            .unwrap_or_else(|| "mem".to_string());
348    }
349    Ok(config)
350}
351
352/// Write the workspace config to `<workspace_root>/.memstead/config.json`
353/// atomically (write-to-temp + rename). Creates the `.memstead/` parent
354/// directory if absent. Pretty-printed with 2-space indent for human
355/// inspection (the file is the operator's primary debugging surface).
356pub fn write_workspace_config(
357    workspace_root: &Path,
358    config: &WorkspaceConfig,
359) -> Result<(), WorkspaceConfigError> {
360    let target = config_path(workspace_root);
361    if let Some(parent) = target.parent() {
362        std::fs::create_dir_all(parent).map_err(|e| WorkspaceConfigError::Io {
363            path: parent.to_path_buf(),
364            source: e,
365        })?;
366    }
367
368    // Serialize in struct-field order (format, schema, version?, description?,
369    // authors?, deps). `name` is path-derived and intentionally omitted.
370    let mut bytes = serde_json::to_vec_pretty(config)
371        .map_err(|e| WorkspaceConfigError::Malformed(format!("serialise: {e}")))?;
372    bytes.push(b'\n');
373
374    let tmp = make_tmp_path(&target);
375    std::fs::write(&tmp, &bytes).map_err(|e| WorkspaceConfigError::Io {
376        path: tmp.clone(),
377        source: e,
378    })?;
379    if let Err(e) = std::fs::rename(&tmp, &target) {
380        let _ = std::fs::remove_file(&tmp);
381        return Err(WorkspaceConfigError::Io {
382            path: target,
383            source: e,
384        });
385    }
386    Ok(())
387}
388
389/// Initialise a brand-new filesystem (folder-backed) mem at `root` — the
390/// engine-owned counterpart of `memstead init` for a single collapsed mem.
391/// Writes the canonical `.memstead/config.json`, the `cache/` + `memstead-io/`
392/// subdirs, the `workspace.toml` adapter marker, and the `state/mounts.json`
393/// one-folder-mount roster, so the result roots directly through
394/// [`crate::Engine::from_workspace_root`]. The mem root *is* the workspace
395/// root (collapsed single-mem form).
396///
397/// This is the engine entry external embedders (the macOS app's bootstrap)
398/// route through instead of hand-writing `.memstead/config.json` from their
399/// own code — the engine owns the seed structure. Creates `root` (and the
400/// `.memstead/` tree) if absent; the caller is responsible for refusing a
401/// non-empty target if that matters.
402pub fn init_filesystem_mem(root: &Path, name: &str, schema: &SchemaRef) -> std::io::Result<()> {
403    use crate::workspace::{
404        Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
405    };
406    use crate::workspace_store::{FileWorkspaceStore, WorkspaceStoreAdapter};
407
408    let config = WorkspaceConfig::new(name, schema.clone());
409    write_workspace_config(root, &config).map_err(std::io::Error::other)?;
410
411    let memstead_dir = root.join(crate::WORKSPACE_STORE_DIR);
412    std::fs::create_dir_all(memstead_dir.join("cache"))?;
413    std::fs::create_dir_all(memstead_dir.join("memstead-io"))?;
414    // Two-layer file adapter marker — `from_workspace_root` recognises a
415    // workspace by `.memstead/workspace.toml`. The filesystem mem collapses
416    // workspace = mem root: one folder mount carries every entity.
417    std::fs::write(
418        memstead_dir.join("workspace.toml"),
419        "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
420    )?;
421
422    let workspace = Workspace {
423        mounts: vec![Mount {
424            mem: name.to_string(),
425            schema: Some(schema.clone()),
426            storage: MountStorage::Folder {
427                path: root.to_path_buf(),
428            },
429            capability: MountCapability::Write,
430            lifecycle: MountLifecycle::Eager,
431            cross_linkable: true,
432            migration_target: None,
433        }],
434        settings: WorkspaceSettings::default(),
435    };
436    FileWorkspaceStore::new()
437        .save_state(root, &workspace)
438        .map_err(std::io::Error::other)?;
439    Ok(())
440}
441
442fn make_tmp_path(target: &Path) -> PathBuf {
443    let name = target
444        .file_name()
445        .map(|n| n.to_string_lossy().to_string())
446        .unwrap_or_else(|| "_".to_string());
447    let nanos = std::time::SystemTime::now()
448        .duration_since(std::time::UNIX_EPOCH)
449        .map(|d| d.as_nanos())
450        .unwrap_or(0);
451    target.with_file_name(format!(".{name}.tmp.{nanos:x}"))
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use tempfile::TempDir;
458
459    fn versioned(name: &str, version: &str) -> SchemaRef {
460        SchemaRef::new(name, semver::Version::parse(version).unwrap())
461    }
462
463    #[test]
464    fn init_filesystem_mem_produces_a_rootable_workspace() {
465        let tmp = TempDir::new().unwrap();
466        let root = tmp.path().join("notes");
467        init_filesystem_mem(&root, "notes", &versioned("default", "1.0.0")).unwrap();
468
469        // Seed structure landed: config + adapter marker + mounts roster.
470        assert!(config_path(&root).is_file());
471        assert!(root.join(".memstead").join("workspace.toml").is_file());
472        assert!(
473            root.join(".memstead")
474                .join("state")
475                .join("mounts.json")
476                .is_file()
477        );
478
479        // And it roots directly through the engine, listing the one mem.
480        let engine = crate::Engine::from_workspace_root(&root).unwrap();
481        assert!(
482            engine
483                .mem_router()
484                .writable_mems()
485                .iter()
486                .any(|v| v == "notes"),
487            "init'd mem must be writable in the rooted engine"
488        );
489    }
490
491    fn ok_config_value() -> serde_json::Value {
492        serde_json::json!({
493            "format": FILESYSTEM_WORKSPACE_FORMAT,
494            "name": "demo-mem",
495            "schema": "default@1.0.0",
496        })
497    }
498
499    fn parse(value: serde_json::Value) -> Result<WorkspaceConfig, WorkspaceConfigError> {
500        parse_workspace_config(value.to_string().as_bytes())
501    }
502
503    #[test]
504    fn parses_minimal_config() {
505        let cfg = parse(ok_config_value()).unwrap();
506        assert_eq!(cfg.format, FILESYSTEM_WORKSPACE_FORMAT);
507        assert_eq!(cfg.name, "demo-mem");
508        assert_eq!(cfg.schema.as_display(), "default@1.0.0");
509        assert!(cfg.deps.is_empty());
510        assert!(cfg.version.is_none());
511    }
512
513    #[test]
514    fn parses_full_config() {
515        let v = serde_json::json!({
516            "format": FILESYSTEM_WORKSPACE_FORMAT,
517            "name": "demo-mem",
518            "schema": "default@1.0.0",
519            "version": "0.1.0",
520            "description": "demo",
521            "authors": ["alice"],
522            "deps": ["anthropic/core", "anthropic/agents"],
523        });
524        let cfg = parse(v).unwrap();
525        assert_eq!(cfg.deps.len(), 2);
526        assert_eq!(cfg.deps[0].as_display(), "anthropic/core");
527        assert_eq!(cfg.deps[1].as_display(), "anthropic/agents");
528        assert_eq!(cfg.version.unwrap().to_string(), "0.1.0");
529        assert_eq!(cfg.authors.unwrap(), vec!["alice".to_string()]);
530    }
531
532    #[test]
533    fn rejects_bare_name_schema_pin() {
534        let mut v = ok_config_value();
535        v["schema"] = serde_json::json!("default");
536        let err = parse(v).unwrap_err();
537        assert!(
538            matches!(err, WorkspaceConfigError::Malformed(_)),
539            "expected Malformed for bare-name pin, got {err:?}"
540        );
541    }
542
543    #[test]
544    fn rejects_unsupported_format() {
545        let mut v = ok_config_value();
546        v["format"] = serde_json::json!(99);
547        let err = parse(v).unwrap_err();
548        match err {
549            WorkspaceConfigError::UnsupportedFormat { got: 99, expected } => {
550                assert_eq!(expected, FILESYSTEM_WORKSPACE_FORMAT);
551            }
552            other => panic!("expected UnsupportedFormat, got {other:?}"),
553        }
554    }
555
556    #[test]
557    fn rejects_archive_format_in_workspace_position() {
558        // An archive's `format: 3` config sneaking into the workspace
559        // position must surface as a typed mismatch — the two
560        // namespaces overlap on the filename but not on the format
561        // integer.
562        let mut v = ok_config_value();
563        v["format"] = serde_json::json!(PUBLISHED_MEM_FORMAT);
564        let err = parse(v).unwrap_err();
565        assert!(matches!(
566            err,
567            WorkspaceConfigError::UnsupportedFormat { .. }
568        ));
569    }
570
571    #[test]
572    fn preserves_unknown_top_level_fields() {
573        // Engine-owned runtime fields (`syncState`, `writeGuidance`, …)
574        // land in the same file this shape reads; they must survive a
575        // read-modify-write round-trip instead of refusing the parse
576        // (a strict reader broke export for projection-maintained mems).
577        let mut v = ok_config_value();
578        v["syncState"] = serde_json::json!({"public-docs": "abc123"});
579        let cfg = parse(v).unwrap();
580        assert_eq!(
581            cfg.extra.get("syncState"),
582            Some(&serde_json::json!({"public-docs": "abc123"}))
583        );
584        let back = serde_json::to_value(&cfg).unwrap();
585        assert_eq!(back["syncState"]["public-docs"], "abc123");
586    }
587
588    #[test]
589    fn rejects_invalid_name() {
590        let mut v = ok_config_value();
591        v["name"] = serde_json::json!("Invalid Name");
592        let err = parse(v).unwrap_err();
593        assert!(matches!(err, WorkspaceConfigError::InvalidName(_)));
594    }
595
596    #[test]
597    fn rejects_invalid_schema_pin() {
598        let mut v = ok_config_value();
599        v["schema"] = serde_json::json!("default@^1.0.0");
600        let err = parse(v).unwrap_err();
601        assert!(matches!(err, WorkspaceConfigError::Malformed(_)));
602    }
603
604    #[test]
605    fn rejects_dep_without_scope() {
606        let mut v = ok_config_value();
607        v["deps"] = serde_json::json!(["just-a-name"]);
608        let err = parse(v).unwrap_err();
609        assert!(matches!(err, WorkspaceConfigError::Malformed(_)));
610    }
611
612    #[test]
613    fn rejects_duplicate_deps_on_disk() {
614        let mut v = ok_config_value();
615        v["deps"] = serde_json::json!(["anthropic/core", "anthropic/core"]);
616        let err = parse(v).unwrap_err();
617        assert!(matches!(err, WorkspaceConfigError::DuplicateDep(_)));
618    }
619
620    #[test]
621    fn add_dep_is_idempotent() {
622        let mut cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
623        let dep = DepRef {
624            scope: "anthropic".into(),
625            name: "core".into(),
626        };
627        assert!(cfg.add_dep(dep.clone()));
628        assert!(!cfg.add_dep(dep.clone()));
629        assert_eq!(cfg.deps.len(), 1);
630    }
631
632    #[test]
633    fn round_trip_through_disk_preserves_dep_order() {
634        let tmp = TempDir::new().unwrap();
635        let mut cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
636        cfg.add_dep("anthropic/core".parse().unwrap());
637        cfg.add_dep("anthropic/agents".parse().unwrap());
638        cfg.add_dep("openai/sdk".parse().unwrap());
639
640        write_workspace_config(tmp.path(), &cfg).unwrap();
641        let read = read_workspace_config(tmp.path()).unwrap();
642        assert_eq!(
643            read.deps.iter().map(|d| d.as_display()).collect::<Vec<_>>(),
644            vec![
645                "anthropic/core".to_string(),
646                "anthropic/agents".to_string(),
647                "openai/sdk".to_string(),
648            ]
649        );
650    }
651
652    #[test]
653    fn read_missing_config_returns_not_found() {
654        let tmp = TempDir::new().unwrap();
655        let err = read_workspace_config(tmp.path()).unwrap_err();
656        assert!(matches!(err, WorkspaceConfigError::NotFound(_)));
657    }
658
659    #[test]
660    fn engine_written_config_omits_name_and_read_derives_basename() {
661        let tmp = TempDir::new().unwrap();
662        let root = tmp.path().join("my-mem");
663        std::fs::create_dir_all(&root).unwrap();
664        let cfg = WorkspaceConfig::new("my-mem", versioned("default", "1.0.0"));
665        write_workspace_config(&root, &cfg).unwrap();
666
667        // The persisted config carries no `name` (the schema validator
668        // tombstones it; identity is path-derived).
669        let raw: serde_json::Value =
670            serde_json::from_slice(&std::fs::read(config_path(&root)).unwrap()).unwrap();
671        assert!(
672            raw.get("name").is_none(),
673            "config.json must not carry a path-derived `name`"
674        );
675
676        // Read fills the identity from the basename.
677        let read = read_workspace_config(&root).unwrap();
678        assert_eq!(read.name, "my-mem");
679    }
680
681    #[test]
682    fn read_tolerates_a_legacy_name_field() {
683        let tmp = TempDir::new().unwrap();
684        let v = serde_json::json!({
685            "format": FILESYSTEM_WORKSPACE_FORMAT,
686            "name": "legacy-name",
687            "schema": "default@1.0.0",
688        });
689        std::fs::create_dir_all(tmp.path().join(".memstead")).unwrap();
690        std::fs::write(config_path(tmp.path()), v.to_string()).unwrap();
691        let read = read_workspace_config(tmp.path()).unwrap();
692        // A present legacy name is read as-is (basename fallback only kicks in
693        // when absent), so old mems keep working.
694        assert_eq!(read.name, "legacy-name");
695    }
696
697    #[test]
698    fn write_creates_memstead_parent_directory() {
699        let tmp = TempDir::new().unwrap();
700        assert!(!tmp.path().join(".memstead").exists());
701        let cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
702        write_workspace_config(tmp.path(), &cfg).unwrap();
703        assert!(tmp.path().join(".memstead").is_dir());
704        assert!(tmp.path().join(".memstead").join("config.json").is_file());
705    }
706
707    #[test]
708    fn to_published_drops_deps() {
709        let mut cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
710        cfg.version = Some(semver::Version::parse("0.1.0").unwrap());
711        cfg.add_dep("anthropic/core".parse().unwrap());
712
713        let published = cfg.to_published().unwrap();
714        assert_eq!(published.format, PUBLISHED_MEM_FORMAT);
715        assert_eq!(published.name, "demo");
716        assert_eq!(published.version.to_string(), "0.1.0");
717        assert_eq!(published.schema.name, "default");
718        // PublishedMemConfig has no `deps` field — the projection
719        // simply drops them. Verify it still serialises clean.
720        let serialised = serde_json::to_value(&published).unwrap();
721        assert!(serialised.get("deps").is_none());
722    }
723
724    #[test]
725    fn to_published_requires_version() {
726        // F1: mem-init populates `version` with `0.1.0` by default
727        // so `to_published` no longer trips on a freshly-created
728        // config. Simulate the pre-gate / externally-imported config
729        // by clearing `version` explicitly.
730        let mut cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
731        cfg.version = None;
732        let err = cfg.to_published().unwrap_err();
733        assert!(matches!(err, PublishConversionError::MissingVersion));
734    }
735
736    #[test]
737    fn dep_ref_roundtrip_via_serde() {
738        let dep = DepRef {
739            scope: "scope".into(),
740            name: "name".into(),
741        };
742        let s = serde_json::to_string(&dep).unwrap();
743        assert_eq!(s, "\"scope/name\"");
744        let back: DepRef = serde_json::from_str(&s).unwrap();
745        assert_eq!(back, dep);
746    }
747
748    #[test]
749    fn dep_ref_rejects_uppercase() {
750        let err: Result<DepRef, _> = "Scope/name".parse();
751        assert!(err.is_err());
752    }
753
754    #[test]
755    fn dep_ref_rejects_empty_segments() {
756        let err: Result<DepRef, _> = "/name".parse();
757        assert!(err.is_err());
758        let err: Result<DepRef, _> = "scope/".parse();
759        assert!(err.is_err());
760    }
761}