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