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