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
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    init_filesystem_mem_at(root, root, name, schema)
404}
405
406/// Initialise a filesystem (folder-backed) mem whose folder is `mem_dir`
407/// inside the workspace rooted at `workspace_root` — the uncollapsed form
408/// of [`init_filesystem_mem`], which is exactly this call with
409/// `mem_dir == workspace_root`.
410///
411/// The split exists because a folder mount's path is independent of the
412/// workspace root everywhere else in the engine (`mem create` has always
413/// placed folder mems in subdirectories), and a workspace root that
414/// already holds unrelated content — a source repository, say — can only
415/// carry a mem if the mem owns a folder of its own rather than the root.
416/// Both facts of that shape follow from the mount roster and need no
417/// special-casing downstream: the mem folder is a mount storage location,
418/// so [`crate::ingest::cursor`] already excludes it from every binding's
419/// input set unconditionally.
420///
421/// Writes `<mem_dir>/.memstead/config.json` (the mem's own config, the
422/// same file the collapsed form writes at the root) and the workspace
423/// tier — `cache/`, `memstead-io/`, `workspace.toml`, `state/mounts.json`
424/// — under `<workspace_root>/.memstead/`. Creates both directories if
425/// absent; the caller is responsible for refusing a non-empty target if
426/// that matters.
427pub fn init_filesystem_mem_at(
428    workspace_root: &Path,
429    mem_dir: &Path,
430    name: &str,
431    schema: &SchemaRef,
432) -> std::io::Result<()> {
433    use crate::workspace::{
434        Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
435    };
436    use crate::workspace_store::{FileWorkspaceStore, WorkspaceStoreAdapter};
437
438    let root = workspace_root;
439    std::fs::create_dir_all(mem_dir)?;
440    let config = WorkspaceConfig::new(name, schema.clone());
441    write_workspace_config(mem_dir, &config).map_err(std::io::Error::other)?;
442
443    let memstead_dir = root.join(crate::WORKSPACE_STORE_DIR);
444    std::fs::create_dir_all(memstead_dir.join("cache"))?;
445    std::fs::create_dir_all(memstead_dir.join("memstead-io"))?;
446    // Two-layer file adapter marker — `from_workspace_root` recognises a
447    // workspace by `.memstead/workspace.toml`. One folder mount carries
448    // every entity, whether its folder is the workspace root (collapsed
449    // form) or a subdirectory of it.
450    std::fs::write(
451        memstead_dir.join("workspace.toml"),
452        "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
453    )?;
454
455    let workspace = Workspace {
456        mounts: vec![Mount {
457            mem: name.to_string(),
458            schema: Some(schema.clone()),
459            storage: MountStorage::Folder {
460                path: mem_dir.to_path_buf(),
461            },
462            capability: MountCapability::Write,
463            lifecycle: MountLifecycle::Eager,
464            cross_linkable: true,
465            migration_target: None,
466        }],
467        settings: WorkspaceSettings::default(),
468    };
469    FileWorkspaceStore::new()
470        .save_state(root, &workspace)
471        .map_err(std::io::Error::other)?;
472    Ok(())
473}
474
475fn make_tmp_path(target: &Path) -> PathBuf {
476    let name = target
477        .file_name()
478        .map(|n| n.to_string_lossy().to_string())
479        .unwrap_or_else(|| "_".to_string());
480    let nanos = std::time::SystemTime::now()
481        .duration_since(std::time::UNIX_EPOCH)
482        .map(|d| d.as_nanos())
483        .unwrap_or(0);
484    target.with_file_name(format!(".{name}.tmp.{nanos:x}"))
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use tempfile::TempDir;
491
492    fn versioned(name: &str, version: &str) -> SchemaRef {
493        SchemaRef::new(name, semver::Version::parse(version).unwrap())
494    }
495
496    #[test]
497    fn init_filesystem_mem_produces_a_rootable_workspace() {
498        let tmp = TempDir::new().unwrap();
499        let root = tmp.path().join("notes");
500        init_filesystem_mem(&root, "notes", &versioned("default", "1.0.0")).unwrap();
501
502        // Seed structure landed: config + adapter marker + mounts roster.
503        assert!(config_path(&root).is_file());
504        assert!(root.join(".memstead").join("workspace.toml").is_file());
505        assert!(
506            root.join(".memstead")
507                .join("state")
508                .join("mounts.json")
509                .is_file()
510        );
511
512        // And it roots directly through the engine, listing the one mem.
513        let engine = crate::Engine::from_workspace_root(&root).unwrap();
514        assert!(
515            engine
516                .mem_router()
517                .writable_mems()
518                .iter()
519                .any(|v| v == "notes"),
520            "init'd mem must be writable in the rooted engine"
521        );
522    }
523
524    /// The uncollapsed form: the mem takes a folder inside a workspace
525    /// root that holds unrelated content. The workspace tier lands at the
526    /// root, the mem's own config inside its folder, and the mount points
527    /// at the folder — which is what makes the root's own files belong to
528    /// no mem at all.
529    #[test]
530    fn init_filesystem_mem_at_puts_the_mem_in_its_own_folder() {
531        let tmp = TempDir::new().unwrap();
532        let root = tmp.path().join("repo");
533        std::fs::create_dir_all(root.join("src")).unwrap();
534        std::fs::write(root.join("README.md"), b"# not an entity\n").unwrap();
535        let mem_dir = root.join("notes");
536
537        init_filesystem_mem_at(&root, &mem_dir, "notes", &versioned("default", "1.0.0")).unwrap();
538
539        // Workspace tier at the root, mem config in the mem's folder.
540        assert!(root.join(".memstead").join("workspace.toml").is_file());
541        assert!(config_path(&mem_dir).is_file());
542        assert!(
543            !root.join(".memstead").join("config.json").is_file(),
544            "the root is not the mem, so it carries no mem config"
545        );
546
547        let engine = crate::Engine::from_workspace_root(&root).unwrap();
548        assert!(
549            engine
550                .mem_router()
551                .writable_mems()
552                .iter()
553                .any(|v| v == "notes"),
554        );
555
556        // The mount is the folder, stored relative to the root — so the
557        // root's own `README.md` is not in any mem's folder.
558        let mounts: serde_json::Value = serde_json::from_slice(
559            &std::fs::read(root.join(".memstead").join("state").join("mounts.json")).unwrap(),
560        )
561        .unwrap();
562        let on_disk = serde_json::to_string(&mounts).unwrap();
563        assert!(
564            on_disk.contains("\"notes\""),
565            "mount path stays relative to the workspace root: {on_disk}"
566        );
567        assert!(
568            !on_disk.contains(&tmp.path().display().to_string()),
569            "no absolute prefix is baked into the roster: {on_disk}"
570        );
571    }
572
573    fn ok_config_value() -> serde_json::Value {
574        serde_json::json!({
575            "format": FILESYSTEM_WORKSPACE_FORMAT,
576            "name": "demo-mem",
577            "schema": "default@1.0.0",
578        })
579    }
580
581    fn parse(value: serde_json::Value) -> Result<WorkspaceConfig, WorkspaceConfigError> {
582        parse_workspace_config(value.to_string().as_bytes())
583    }
584
585    #[test]
586    fn parses_minimal_config() {
587        let cfg = parse(ok_config_value()).unwrap();
588        assert_eq!(cfg.format, FILESYSTEM_WORKSPACE_FORMAT);
589        assert_eq!(cfg.name, "demo-mem");
590        assert_eq!(cfg.schema.as_display(), "default@1.0.0");
591        assert!(cfg.deps.is_empty());
592        assert!(cfg.version.is_none());
593    }
594
595    #[test]
596    fn parses_full_config() {
597        let v = serde_json::json!({
598            "format": FILESYSTEM_WORKSPACE_FORMAT,
599            "name": "demo-mem",
600            "schema": "default@1.0.0",
601            "version": "0.1.0",
602            "description": "demo",
603            "authors": ["alice"],
604            "deps": ["anthropic/core", "anthropic/agents"],
605        });
606        let cfg = parse(v).unwrap();
607        assert_eq!(cfg.deps.len(), 2);
608        assert_eq!(cfg.deps[0].as_display(), "anthropic/core");
609        assert_eq!(cfg.deps[1].as_display(), "anthropic/agents");
610        assert_eq!(cfg.version.unwrap().to_string(), "0.1.0");
611        assert_eq!(cfg.authors.unwrap(), vec!["alice".to_string()]);
612    }
613
614    #[test]
615    fn rejects_bare_name_schema_pin() {
616        let mut v = ok_config_value();
617        v["schema"] = serde_json::json!("default");
618        let err = parse(v).unwrap_err();
619        assert!(
620            matches!(err, WorkspaceConfigError::Malformed(_)),
621            "expected Malformed for bare-name pin, got {err:?}"
622        );
623    }
624
625    #[test]
626    fn rejects_unsupported_format() {
627        let mut v = ok_config_value();
628        v["format"] = serde_json::json!(99);
629        let err = parse(v).unwrap_err();
630        match err {
631            WorkspaceConfigError::UnsupportedFormat { got: 99, expected } => {
632                assert_eq!(expected, FILESYSTEM_WORKSPACE_FORMAT);
633            }
634            other => panic!("expected UnsupportedFormat, got {other:?}"),
635        }
636    }
637
638    #[test]
639    fn rejects_archive_format_in_workspace_position() {
640        // An archive's `format: 3` config sneaking into the workspace
641        // position must surface as a typed mismatch — the two
642        // namespaces overlap on the filename but not on the format
643        // integer.
644        let mut v = ok_config_value();
645        v["format"] = serde_json::json!(PUBLISHED_MEM_FORMAT);
646        let err = parse(v).unwrap_err();
647        assert!(matches!(
648            err,
649            WorkspaceConfigError::UnsupportedFormat { .. }
650        ));
651    }
652
653    #[test]
654    fn preserves_unknown_top_level_fields() {
655        // Engine-owned runtime fields (`syncState`, `writeGuidance`, …)
656        // land in the same file this shape reads; they must survive a
657        // read-modify-write round-trip instead of refusing the parse
658        // (a strict reader broke export for projection-maintained mems).
659        let mut v = ok_config_value();
660        v["syncState"] = serde_json::json!({"public-docs": "abc123"});
661        let cfg = parse(v).unwrap();
662        assert_eq!(
663            cfg.extra.get("syncState"),
664            Some(&serde_json::json!({"public-docs": "abc123"}))
665        );
666        let back = serde_json::to_value(&cfg).unwrap();
667        assert_eq!(back["syncState"]["public-docs"], "abc123");
668    }
669
670    #[test]
671    fn rejects_invalid_name() {
672        let mut v = ok_config_value();
673        v["name"] = serde_json::json!("Invalid Name");
674        let err = parse(v).unwrap_err();
675        assert!(matches!(err, WorkspaceConfigError::InvalidName(_)));
676    }
677
678    #[test]
679    fn rejects_invalid_schema_pin() {
680        let mut v = ok_config_value();
681        v["schema"] = serde_json::json!("default@^1.0.0");
682        let err = parse(v).unwrap_err();
683        assert!(matches!(err, WorkspaceConfigError::Malformed(_)));
684    }
685
686    #[test]
687    fn rejects_dep_without_scope() {
688        let mut v = ok_config_value();
689        v["deps"] = serde_json::json!(["just-a-name"]);
690        let err = parse(v).unwrap_err();
691        assert!(matches!(err, WorkspaceConfigError::Malformed(_)));
692    }
693
694    #[test]
695    fn rejects_duplicate_deps_on_disk() {
696        let mut v = ok_config_value();
697        v["deps"] = serde_json::json!(["anthropic/core", "anthropic/core"]);
698        let err = parse(v).unwrap_err();
699        assert!(matches!(err, WorkspaceConfigError::DuplicateDep(_)));
700    }
701
702    #[test]
703    fn add_dep_is_idempotent() {
704        let mut cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
705        let dep = DepRef {
706            scope: "anthropic".into(),
707            name: "core".into(),
708        };
709        assert!(cfg.add_dep(dep.clone()));
710        assert!(!cfg.add_dep(dep.clone()));
711        assert_eq!(cfg.deps.len(), 1);
712    }
713
714    #[test]
715    fn round_trip_through_disk_preserves_dep_order() {
716        let tmp = TempDir::new().unwrap();
717        let mut cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
718        cfg.add_dep("anthropic/core".parse().unwrap());
719        cfg.add_dep("anthropic/agents".parse().unwrap());
720        cfg.add_dep("openai/sdk".parse().unwrap());
721
722        write_workspace_config(tmp.path(), &cfg).unwrap();
723        let read = read_workspace_config(tmp.path()).unwrap();
724        assert_eq!(
725            read.deps.iter().map(|d| d.as_display()).collect::<Vec<_>>(),
726            vec![
727                "anthropic/core".to_string(),
728                "anthropic/agents".to_string(),
729                "openai/sdk".to_string(),
730            ]
731        );
732    }
733
734    #[test]
735    fn read_missing_config_returns_not_found() {
736        let tmp = TempDir::new().unwrap();
737        let err = read_workspace_config(tmp.path()).unwrap_err();
738        assert!(matches!(err, WorkspaceConfigError::NotFound(_)));
739    }
740
741    #[test]
742    fn engine_written_config_omits_name_and_read_derives_basename() {
743        let tmp = TempDir::new().unwrap();
744        let root = tmp.path().join("my-mem");
745        std::fs::create_dir_all(&root).unwrap();
746        let cfg = WorkspaceConfig::new("my-mem", versioned("default", "1.0.0"));
747        write_workspace_config(&root, &cfg).unwrap();
748
749        // The persisted config carries no `name` (the schema validator
750        // tombstones it; identity is path-derived).
751        let raw: serde_json::Value =
752            serde_json::from_slice(&std::fs::read(config_path(&root)).unwrap()).unwrap();
753        assert!(
754            raw.get("name").is_none(),
755            "config.json must not carry a path-derived `name`"
756        );
757
758        // Read fills the identity from the basename.
759        let read = read_workspace_config(&root).unwrap();
760        assert_eq!(read.name, "my-mem");
761    }
762
763    #[test]
764    fn read_tolerates_a_legacy_name_field() {
765        let tmp = TempDir::new().unwrap();
766        let v = serde_json::json!({
767            "format": FILESYSTEM_WORKSPACE_FORMAT,
768            "name": "legacy-name",
769            "schema": "default@1.0.0",
770        });
771        std::fs::create_dir_all(tmp.path().join(".memstead")).unwrap();
772        std::fs::write(config_path(tmp.path()), v.to_string()).unwrap();
773        let read = read_workspace_config(tmp.path()).unwrap();
774        // A present legacy name is read as-is (basename fallback only kicks in
775        // when absent), so old mems keep working.
776        assert_eq!(read.name, "legacy-name");
777    }
778
779    #[test]
780    fn write_creates_memstead_parent_directory() {
781        let tmp = TempDir::new().unwrap();
782        assert!(!tmp.path().join(".memstead").exists());
783        let cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
784        write_workspace_config(tmp.path(), &cfg).unwrap();
785        assert!(tmp.path().join(".memstead").is_dir());
786        assert!(tmp.path().join(".memstead").join("config.json").is_file());
787    }
788
789    #[test]
790    fn to_published_drops_deps() {
791        let mut cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
792        cfg.version = Some(semver::Version::parse("0.1.0").unwrap());
793        cfg.add_dep("anthropic/core".parse().unwrap());
794
795        let published = cfg.to_published().unwrap();
796        assert_eq!(published.format, PUBLISHED_MEM_FORMAT);
797        assert_eq!(published.name, "demo");
798        assert_eq!(published.version.to_string(), "0.1.0");
799        assert_eq!(published.schema.name, "default");
800        // PublishedMemConfig has no `deps` field — the projection
801        // simply drops them. Verify it still serialises clean.
802        let serialised = serde_json::to_value(&published).unwrap();
803        assert!(serialised.get("deps").is_none());
804    }
805
806    #[test]
807    fn to_published_requires_version() {
808        // F1: mem-init populates `version` with `0.1.0` by default
809        // so `to_published` no longer trips on a freshly-created
810        // config. Simulate the pre-gate / externally-imported config
811        // by clearing `version` explicitly.
812        let mut cfg = WorkspaceConfig::new("demo", versioned("default", "1.0.0"));
813        cfg.version = None;
814        let err = cfg.to_published().unwrap_err();
815        assert!(matches!(err, PublishConversionError::MissingVersion));
816    }
817
818    #[test]
819    fn dep_ref_roundtrip_via_serde() {
820        let dep = DepRef {
821            scope: "scope".into(),
822            name: "name".into(),
823        };
824        let s = serde_json::to_string(&dep).unwrap();
825        assert_eq!(s, "\"scope/name\"");
826        let back: DepRef = serde_json::from_str(&s).unwrap();
827        assert_eq!(back, dep);
828    }
829
830    #[test]
831    fn dep_ref_rejects_uppercase() {
832        let err: Result<DepRef, _> = "Scope/name".parse();
833        assert!(err.is_err());
834    }
835
836    #[test]
837    fn dep_ref_rejects_empty_segments() {
838        let err: Result<DepRef, _> = "/name".parse();
839        assert!(err.is_err());
840        let err: Result<DepRef, _> = "scope/".parse();
841        assert!(err.is_err());
842    }
843}