Skip to main content

memstead_schema/
config.rs

1//! Mem configuration loading, validation, and top-level CRUD.
2//!
3//! Handles `.memstead/config.json` parsing, cross-field validation, and the
4//! `update_config_field` write helper. Projections/mediums, their
5//! validators, and the pre-rework migration have been dropped by the
6//! workspace rewrite — `projections` / `mediums`
7//! survive as unknown keys captured into `MemConfig.extra` so legacy
8//! configs still round-trip, but the engine does not interpret them.
9//!
10//! Port of @memstead/config (config-contract.js, index.js) and
11//! @agent-adapters/config-mcp (workspace.js).
12
13use std::collections::{BTreeMap, HashMap};
14use std::path::{Path, PathBuf};
15
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19/// The per-mem engine-internal directory under a folder mem's
20/// root — `<mem_root>/.memstead/` holds `config.json` and
21/// `changes.jsonl`. Defined here (rather than in `memstead-base`)
22/// because mem-config loading lives in this crate and `memstead-base`
23/// depends on it; `memstead-base` re-exports the constant for
24/// downstream consumers. Distinct from the workspace store directory
25/// (`memstead_base::WORKSPACE_STORE_DIR`) and from the in-zip member
26/// paths inside sealed archives ([`ARCHIVE_META_DIR`]), which are a
27/// separate on-disk format and never use this constant.
28pub const MEM_META_DIR: &str = ".memstead";
29
30// ---------------------------------------------------------------------------
31// Sealed-archive surface constants
32// ---------------------------------------------------------------------------
33//
34// A sealed archive is a zip whose engine-internal members live under one
35// meta directory: `.memstead/config.json` plus the embedded schema tree
36// `.memstead/schema/…` — the sole member layout. The file extension is
37// `.mem` — the sole spelling, read and written. Defined here because
38// this is the lowest crate every archive reader/writer (memstead-base,
39// memstead-git-branch, memstead-registry, memstead-wasm, the CLIs)
40// already depends on.
41
42/// In-zip meta directory of a sealed archive — the only spelling.
43pub const ARCHIVE_META_DIR: &str = ".memstead";
44/// Member path of the published config inside a sealed archive.
45pub const ARCHIVE_CONFIG_PATH: &str = ".memstead/config.json";
46/// Member-path prefix of the embedded schema tree (manifest at
47/// `<prefix>schema.yaml`, type files under `<prefix>types/`).
48pub const ARCHIVE_SCHEMA_PREFIX: &str = ".memstead/schema/";
49/// Member path of the optional authoring-provenance payload inside a
50/// sealed archive (see [`crate::archive_provenance`]). Additive: archives
51/// predating provenance omit it, and an engine that does not recognise it
52/// tolerates it as an unknown meta member.
53pub const ARCHIVE_PROVENANCE_PATH: &str = ".memstead/provenance.json";
54/// File extension (without dot) of a sealed archive — the sole spelling.
55/// The one deliberately-distinct token in a project that is otherwise
56/// "memstead" everywhere — short, and derived from the project name.
57pub const ARCHIVE_EXTENSION: &str = "mem";
58
59// ---------------------------------------------------------------------------
60// Error types
61// ---------------------------------------------------------------------------
62
63#[derive(Debug, thiserror::Error)]
64pub enum ConfigError {
65    #[error("config file not found: {0}")]
66    NotFound(String),
67    #[error("invalid JSON in config file: {0}")]
68    InvalidJson(String),
69    #[error("config validation failed:\n{}", .0.iter().map(|e| format!("  - {e}")).collect::<Vec<_>>().join("\n"))]
70    ValidationFailed(Vec<String>),
71    #[error("{0}")]
72    Other(String),
73    #[error("io error: {0}")]
74    Io(#[from] std::io::Error),
75    #[error("json error: {0}")]
76    Json(#[from] serde_json::Error),
77}
78
79// ---------------------------------------------------------------------------
80// Config check result
81// ---------------------------------------------------------------------------
82
83/// Result of config validation — errors are fatal, warnings are informational.
84#[derive(Debug, Clone)]
85pub struct ConfigCheckResult {
86    pub valid: bool,
87    pub errors: Vec<String>,
88    pub warnings: Vec<String>,
89    /// Stable `UPPER_SNAKE_CASE` envelope code when the validator
90    /// detects a categorical failure that callers should branch on.
91    /// Currently set to `"LEGACY_FIELD_PRESENT"` when any entry in
92    /// `LEGACY_TOMBSTONE_KEYS` is present.
93    pub error_code: Option<String>,
94}
95
96// ---------------------------------------------------------------------------
97// Mem config types (deserialized from .memstead/config.json)
98// ---------------------------------------------------------------------------
99
100/// Role-based publish config.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct RoleConfig {
103    pub include: Vec<String>,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub exclude: Option<Vec<String>>,
106}
107
108/// Publish config.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct PublishConfig {
111    pub roles: HashMap<String, RoleConfig>,
112}
113
114/// Community detection override.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct CommunityOverride {
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub resolution: Option<f64>,
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub seed: Option<u32>,
121}
122
123/// One entry in `MemConfig.read_mems` — a read-only sealed mem
124/// archive attached to the primary mem as reference material.
125///
126/// The engine resolves each entry to a cache file: when `cache_key` is
127/// present, `<mem_cache_dir>/<name>-<cache_key>.mem` (content-addressed
128/// — see [`ReadMemSpec::cache_key`]); otherwise the legacy
129/// `<mem_cache_dir>/<name>.mem`.
130///
131/// Kept as a struct (rather than collapsing to a bare `ReadMemSource`)
132/// so forward-compatible fields can be added without another schema break.
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct ReadMemSpec {
135    pub source: ReadMemSource,
136    /// Content-address of the installed archive — a short hex digest of
137    /// the validator's canonical bytes. The install path writes the cache
138    /// file at `<cache>/<name>-<cache_key>.mem`, so two distinct archives
139    /// sharing an internal mem name land in distinct files (no collision)
140    /// and re-installing identical bytes resolves to the same file (dedup).
141    /// `None` for legacy registrations written before content-addressing;
142    /// the loader then falls back to the bare `<name>.mem` path.
143    #[serde(default, skip_serializing_if = "Option::is_none", rename = "cacheKey")]
144    pub cache_key: Option<String>,
145}
146
147/// How the app reconstitutes a read mem's cache file when missing.
148///
149/// The engine itself never fetches; `source` is metadata consumed by the
150/// app's installer. A `Registry` variant with scope/name identifiers
151/// will be added once the memstead.io registry ships.
152#[derive(Debug, Clone, Serialize, Deserialize)]
153#[serde(tag = "type", rename_all = "camelCase")]
154pub enum ReadMemSource {
155    /// User dropped an archive file onto the app. App cannot auto-reinstall —
156    /// it prompts the user to drop the original file again.
157    Local,
158    /// Fetched from an HTTPS URL (GitHub Releases, shared drive, any static
159    /// host). Engine-side no-op; the app's installer re-fetches on attach.
160    Url { url: String },
161    // `Registry` variant reserved for when the memstead.io registry ships.
162    // The exact shape (fields, id format like `@scope/name`) is designed
163    // then — declaring it up front without semantics would be
164    // speculative, and pre-1.0 adding a variant later is not a breaking
165    // change for anyone.
166}
167
168/// Reference to a schema by exact name and version — `name@x.y.z`.
169///
170/// Serializes/deserializes as a single string so mem configs read
171/// `{ "schema": "default@1.0.0" }` on disk. Range syntax (`^`, `~`,
172/// `latest`) is rejected — schema pinning is strict and explicit.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct SchemaRef {
175    pub name: String,
176    pub version: semver::Version,
177}
178
179impl SchemaRef {
180    pub fn new(name: impl Into<String>, version: semver::Version) -> Self {
181        Self {
182            name: name.into(),
183            version,
184        }
185    }
186
187    pub fn as_display(&self) -> String {
188        format!("{}@{}", self.name, self.version)
189    }
190}
191
192impl std::fmt::Display for SchemaRef {
193    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194        write!(f, "{}@{}", self.name, self.version)
195    }
196}
197
198impl std::str::FromStr for SchemaRef {
199    type Err = String;
200
201    fn from_str(s: &str) -> Result<Self, Self::Err> {
202        let trimmed = s.trim();
203        if trimmed.is_empty() {
204            return Err("schema reference must not be empty (expected \"name@x.y.z\")".into());
205        }
206        let (name, version_str) = trimmed.split_once('@').ok_or_else(|| {
207            format!(
208                "schema reference '{trimmed}' must include an exact version — expected \"name@x.y.z\""
209            )
210        })?;
211        if name.is_empty() {
212            return Err("schema reference name must not be empty".into());
213        }
214        if version_str == "latest" {
215            return Err(format!(
216                "schema reference '{trimmed}' uses 'latest' — exact semver versions only"
217            ));
218        }
219        if version_str.starts_with(['^', '~', '>', '<', '=', '*']) {
220            return Err(format!(
221                "schema reference '{trimmed}' uses range syntax — exact semver only (e.g. 'default@1.0.0')"
222            ));
223        }
224        let version = semver::Version::parse(version_str).map_err(|e| {
225            format!("schema reference '{trimmed}' has invalid semver version '{version_str}': {e}")
226        })?;
227        Ok(Self {
228            name: name.to_string(),
229            version,
230        })
231    }
232}
233
234impl Serialize for SchemaRef {
235    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
236        serializer.serialize_str(&self.as_display())
237    }
238}
239
240impl<'de> Deserialize<'de> for SchemaRef {
241    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
242        let s = String::deserialize(deserializer)?;
243        s.parse::<SchemaRef>().map_err(serde::de::Error::custom)
244    }
245}
246
247// `MemSchemaPin` (the two-variant pin with a name-only fallback) was
248// retired here. Mem configs now declare a strict `<name>@<version>`
249// pin parsed directly through [`SchemaRef`]; bare-name pins are
250// rejected at config load.
251
252/// VCS layout for a writable mem — optional `{ gitdir, worktree }` pair
253/// in `.memstead/config.json`. When absent, the engine resolves the default:
254/// `.git/` at mem root with `.` as worktree.
255///
256/// Paths are relative to mem root and interpreted by `memstead-git-branch` —
257/// this crate just carries them through serde. Masterplan §3.4 is
258/// explicit that the primitive is a pair of paths; the two canonical
259/// idioms (isolated `{ ".git", "." }` and shared `{ "../.git", ".." }`)
260/// are idioms, not enum variants.
261#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
262#[serde(rename_all = "camelCase")]
263pub struct VcsConfig {
264    /// Path to the gitdir relative to mem root. Required when the
265    /// `vcs` block is present.
266    pub gitdir: String,
267    /// Path to the worktree relative to mem root. Optional within the
268    /// `vcs` block — defaults to `"."` (mem root) when omitted.
269    #[serde(default = "vcs_worktree_default")]
270    pub worktree: String,
271}
272
273fn vcs_worktree_default() -> String {
274    ".".to_string()
275}
276
277/// Tolerant deserializer for the `vcs` field: accepts the object form
278/// (`{ gitdir, worktree? }`) and returns `None` for any non-object value
279/// (string, number, boolean, null). A missing field is also `None`.
280///
281/// Motivation: an older macOS Mem-mode UI wrote `"vcs": "system"`
282/// (and similar sentinel strings) into `.memstead/config.json` files that
283/// now must continue to load without editing those files by hand.
284/// Strict validation of the object form — unknown keys, missing
285/// `gitdir`, etc. — still surfaces as a hard serde error.
286fn deserialize_vcs_tolerant<'de, D>(deserializer: D) -> Result<Option<VcsConfig>, D::Error>
287where
288    D: serde::Deserializer<'de>,
289{
290    let value = Option::<Value>::deserialize(deserializer)?;
291    match value {
292        None | Some(Value::Null) => Ok(None),
293        Some(Value::Object(_)) => {
294            let v = value.unwrap();
295            Ok(Some(
296                serde_json::from_value(v).map_err(serde::de::Error::custom)?,
297            ))
298        }
299        Some(_) => Ok(None),
300    }
301}
302
303/// Full mem configuration loaded from .memstead/config.json.
304#[derive(Debug, Clone, Serialize, Deserialize)]
305#[serde(rename_all = "camelCase")]
306pub struct MemConfig {
307    /// Optional mem name. The leaf folder name under
308    /// `__MEMSTEAD:mems/` (and the disk basename on the legacy disk
309    /// path) is authoritative; engine-written configs omit this
310    /// field. Tolerated on read for pre-cutover configs and for the
311    /// [`PublishedMemConfig`] conversion path that still requires
312    /// an explicit identity (the caller passes the name in when
313    /// projecting).
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub name: Option<String>,
316
317    /// Semver version of the mem content. Read at mem-archive export
318    /// time so the engine always knows the current version without manual
319    /// tracking. Parsed at config load — invalid version strings fail fast
320    /// with a source-attributed serde error rather than slipping through to
321    /// export (where the issue only surfaces when a downstream loader tries
322    /// to resolve a `semver::VersionReq` against the mem).
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub version: Option<semver::Version>,
325
326    /// One-line description of the mem, surfaced in mem-archive metadata
327    /// and UI.
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub description: Option<String>,
330
331    /// Optional author attribution, surfaced in mem-archive metadata.
332    #[serde(skip_serializing_if = "Option::is_none")]
333    pub authors: Option<Vec<String>>,
334
335    /// Schema this mem is pinned to. Exact `<name>@<version>` pin
336    /// only — bare-name forms are rejected at config load. Exactly one
337    /// schema per mem. The `Option` keeps serde tolerant so a missing
338    /// key surfaces as a structured error from `check_config` rather
339    /// than a deserialize panic; a `None` value is a validation error.
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub schema: Option<SchemaRef>,
342
343    /// Opaque string-map passed through by the engine. Agents and
344    /// plugin prompt renderers are free to invent their own keys; the
345    /// engine does not parse, validate, or interpret any value inside.
346    /// Stripped from `PublishedMemConfig` — guidance is workspace-
347    /// local authorship metadata, not part of the published identity.
348    ///
349    /// Pre-2026-04-24 this field was `Option<Value>`; the workspace
350    /// rewrite normalised it to a map so the shape on
351    /// the wire is stable and the engine's pass-through guarantee is
352    /// type-checked.
353    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
354    pub write_guidance: HashMap<String, Value>,
355    #[serde(skip_serializing_if = "Option::is_none")]
356    pub rules: Option<Value>,
357
358    #[serde(skip_serializing_if = "Option::is_none")]
359    pub publish: Option<PublishConfig>,
360    #[serde(skip_serializing_if = "Option::is_none")]
361    pub language: Option<String>,
362    /// Read-only sealed-archive mems attached to this mem as reference
363    /// material. Key is the mem name (matches the archive's
364    /// config name). Engine resolves each entry to
365    /// `<mem_cache_dir>/<name>.mem` at init time. An empty or omitted
366    /// map means no attached mems — a graph with no reference material.
367    ///
368    /// `BTreeMap` (not `HashMap`) so iteration and serialization order
369    /// are stable — reproducible log output and diff-friendly config on
370    /// disk. Explicit `rename = "readMems"` documents the on-disk name
371    /// at the field (the struct-level `rename_all = "camelCase"` already
372    /// handles it, but explicit rename is greppable from either side).
373    #[serde(
374        rename = "readMems",
375        default,
376        skip_serializing_if = "BTreeMap::is_empty"
377    )]
378    pub read_mems: BTreeMap<String, ReadMemSpec>,
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub community: Option<CommunityOverride>,
381
382    /// Optional VCS layout override. When absent, `memstead-git-branch` resolves
383    /// the default at init time: `.git/` at mem root with `.` as
384    /// worktree. When present, `gitdir` and `worktree` are paths
385    /// relative to the mem root. Stripped from `PublishedMemConfig`
386    /// — VCS layout is workspace-local mechanics, not part of the
387    /// published mem's identity.
388    ///
389    /// Deserialization is tolerant of legacy non-object values (e.g.
390    /// `"vcs": "system"` — the sentinel an older macOS Mem-mode
391    /// UI wrote): any non-object form deserializes to `None` and falls
392    /// back to the default-resolution path. The object form is validated
393    /// strictly.
394    #[serde(
395        default,
396        deserialize_with = "deserialize_vcs_tolerant",
397        skip_serializing_if = "Option::is_none"
398    )]
399    pub vcs: Option<VcsConfig>,
400
401    /// Tombstone marker written by `memstead mem unregister`. ISO-8601
402    /// UTC timestamp (`YYYY-MM-DDTHH:MM:SSZ`) recorded at the moment
403    /// the mem was unregistered while its storage was preserved.
404    /// When `memstead mem init <same-name>`
405    /// probes the storage and finds an `unregistered_at` value, it
406    /// treats the residue as deliberate operator state and defaults
407    /// to the `Reattach` recovery action (adopting the preserved
408    /// entities and clearing the tombstone). Absence (`None`) on
409    /// otherwise-present residue triggers `MEM_STORAGE_RESIDUE_DETECTED`
410    /// unless the caller passes an explicit `recovery` flag. Stripped
411    /// from `PublishedMemConfig` — tombstones are workspace-local
412    /// lifecycle state, not part of the published mem's identity.
413    #[serde(default, skip_serializing_if = "Option::is_none")]
414    pub unregistered_at: Option<String>,
415
416    /// Per-source "last successfully synced source state", written by
417    /// the ingest layer and surfaced verbatim on the workspace dump.
418    /// The engine never parses, validates, or interprets a value:
419    /// each token is opaque, its meaning owned by the medium-type
420    /// layer that produced it (git → commit id, graph → snapshot
421    /// token, filesystem → a small stat digest the plugin
422    /// JSON-stringifies). The key is likewise opaque — the ingest
423    /// layer keys per `(ingest, facet)` (conventionally
424    /// `"<ingest>/<facet>"`), but the engine treats it as an arbitrary
425    /// string. This is the durable, shared baseline against which a
426    /// fresh ingest iteration diffs "what changed since last time";
427    /// it survives a skill-cache wipe and a machine change because it
428    /// lives in engine-held mem config, not ephemeral plugin cache.
429    ///
430    /// Stripped from `PublishedMemConfig` — sync state is
431    /// workspace-local ingest bookkeeping, not part of a published
432    /// mem's identity. `BTreeMap` (not `HashMap`) for stable
433    /// serialization order: diff-friendly config on disk and
434    /// reproducible dump output.
435    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
436    pub sync_state: BTreeMap<String, String>,
437
438    /// Extra fields not in the known set (captured for round-tripping).
439    ///
440    /// Historical tombstones:
441    /// - `defaultSchema` (pre-2026-04): legacy per-mem default type.
442    ///   Per-entity `type:` frontmatter is authoritative now.
443    /// - `types: [...]` (pre-schema-artifact, 2026-04): replaced by
444    ///   `schema: "<name>@<version>"`. Legacy entries are hard-rejected
445    ///   by `check_config`.
446    #[serde(flatten)]
447    pub extra: HashMap<String, Value>,
448}
449
450// ---------------------------------------------------------------------------
451// Published (archive) mem config
452// ---------------------------------------------------------------------------
453
454/// Strict-ingress shape of a mem config. This is the **only** metadata
455/// form that enters a `.mem` archive. `MemConfig` carries author-only
456/// fields (writeGuidance, rules, publish, readMems, language,
457/// community, defaultSchema, vcs, plus any key captured in
458/// `extra`) that never belong in a published archive;
459/// `published_config_from` projects `MemConfig` →
460/// `PublishedMemConfig`, dropping everything outside the whitelist.
461///
462/// `deny_unknown_fields` + no `serde(flatten)` on purpose: the validator
463/// re-parses this shape with the same struct as defense-in-depth, so any
464/// legacy author key smuggled into an archive surfaces as a rejection
465/// instead of a silently-tolerated payload.
466#[derive(Debug, Clone, Serialize, Deserialize)]
467#[serde(deny_unknown_fields, rename_all = "camelCase")]
468pub struct PublishedMemConfig {
469    pub format: u32,
470    pub name: String,
471    pub version: semver::Version,
472    #[serde(skip_serializing_if = "Option::is_none")]
473    pub description: Option<String>,
474    #[serde(skip_serializing_if = "Option::is_none")]
475    pub authors: Option<Vec<String>>,
476    pub schema: SchemaRef,
477}
478
479/// Archive format integer written to the archive config's `format`
480/// field. Bumped to `3` for the schema-path relocation (embedded schema
481/// moved from top-level `schema/` to the meta-dir schema tree). `format: 1` (V1)
482/// and `format: 2` (V2, top-level `schema/` tree) archives are rejected
483/// cleanly (pre-release, no external users to migrate).
484pub const PUBLISHED_MEM_FORMAT: u32 = 3;
485
486/// Errors returned by `published_config_from`. Actionable messages —
487/// the caller (export pipeline, publish pipeline) surfaces these
488/// directly to the user without wrapping a raw serde error.
489#[derive(Debug, thiserror::Error)]
490pub enum PublishConversionError {
491    #[error("config.version is required for mem publish — set it in .memstead/config.json")]
492    MissingVersion,
493    #[error(
494        "config must declare `schema` (e.g. \"default@1.0.0\") — set it in .memstead/config.json"
495    )]
496    MissingSchema,
497    #[error(
498        "publish requires an explicit mem name — caller must pass the leaf folder name (Goal 3 of mem-repo-restructure dropped the in-config `name` requirement)"
499    )]
500    MissingName,
501}
502
503/// The whitelist projection. Everything author-only is discarded; only
504/// the fields that make sense outside the author's working directory
505/// ride into the archive. `format` is pinned at `PUBLISHED_MEM_FORMAT`.
506///
507/// `name` is supplied explicitly by the caller — the on-disk `name`
508/// field is optional and the engine no longer treats it as the
509/// mem-identity source. The published archive still needs an
510/// identity, so the publishing path passes the leaf folder name
511/// (`__MEMSTEAD:mems/<path>/<leaf>/config.json`'s `<leaf>`, or the
512/// disk basename on the legacy disk path) here. Falls back to the
513/// in-config `name` field when the caller passes an empty string and
514/// the config still carries a legacy `name` value (so pre-cutover
515/// archives published before the migration land cleanly).
516pub fn published_config_from(
517    config: &MemConfig,
518    name: &str,
519) -> Result<PublishedMemConfig, PublishConversionError> {
520    let version = config
521        .version
522        .clone()
523        .ok_or(PublishConversionError::MissingVersion)?;
524    let schema = config
525        .schema
526        .clone()
527        .ok_or(PublishConversionError::MissingSchema)?;
528    let resolved_name = if name.is_empty() {
529        config
530            .name
531            .clone()
532            .ok_or(PublishConversionError::MissingName)?
533    } else {
534        name.to_string()
535    };
536    Ok(PublishedMemConfig {
537        format: PUBLISHED_MEM_FORMAT,
538        name: resolved_name,
539        version,
540        description: config.description.clone(),
541        authors: config.authors.clone(),
542        schema,
543    })
544}
545
546// ---------------------------------------------------------------------------
547// Constants
548// ---------------------------------------------------------------------------
549
550const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
551    "version",
552    "description",
553    "authors",
554    "schema",
555    "writeGuidance",
556    "rules",
557    "publish",
558    "language",
559    "readMems",
560    "community",
561    "vcs",
562    "syncState",
563];
564
565/// Keys that are explicitly rejected with a `LEGACY_FIELD_PRESENT`
566/// envelope when present in a config. The validator surfaces a hard
567/// error (not a soft "unknown key" warning) so agents that recreate
568/// the legacy shape from training-set examples see a structured
569/// rejection instead of silent acceptance with drift.
570///
571/// Each entry pairs the rejected key with an actionable error message.
572/// New tombstones land here when a top-level key migrates from
573/// "deprecated but tolerated" to "must not be re-authored". The table
574/// holds entries for `name` (the field is now path-derived) and
575/// `types: [...]` (the pre-existing tombstone, preserved verbatim).
576const LEGACY_TOMBSTONE_KEYS: &[(&str, &str)] = &[
577    (
578        "types",
579        "Legacy `types: [...]` field detected — replace with `schema: \"<name>@<version>\"` \
580         (e.g. `\"schema\": \"default@1.0.0\"`).",
581    ),
582    (
583        "name",
584        "Legacy `name` field detected — the mem leaf folder under `__MEMSTEAD:mems/` (or the \
585         disk basename on the legacy disk path) is path-derived under the unified layout; \
586         remove the field from `.memstead/config.json`.",
587    ),
588    (
589        "belongsTo",
590        "Legacy `belongsTo` field detected — cross-mem authorization moved to the \
591         workspace-level `[cross_mem_links]` section in `.memstead/workspace.toml`. Remove the \
592         field from `.memstead/config.json` and add an entry under `[cross_mem_links]` \
593         instead.",
594    ),
595];
596
597// ---------------------------------------------------------------------------
598// checkConfig — the main validator
599// ---------------------------------------------------------------------------
600
601/// Validate a raw config JSON value. Returns structured errors and warnings.
602pub fn check_config(config: &Value) -> ConfigCheckResult {
603    let mut errors = Vec::new();
604    let mut warnings = Vec::new();
605
606    let obj = match config.as_object() {
607        Some(o) => o,
608        None => {
609            errors.push("(root): config must be an object".to_string());
610            return ConfigCheckResult {
611                valid: false,
612                errors,
613                warnings,
614                error_code: None,
615            };
616        }
617    };
618
619    // 2. Legacy tombstones — keys that must not be re-authored. Each
620    //    hit produces a hard error and pins the `LEGACY_FIELD_PRESENT`
621    //    envelope code so callers branch on a stable identifier rather
622    //    than the human-readable error message. See
623    //    `LEGACY_TOMBSTONE_KEYS` for the reject list.
624    let mut legacy_field_hit = false;
625    for (key, message) in LEGACY_TOMBSTONE_KEYS {
626        if obj.contains_key(*key) {
627            errors.push((*message).to_string());
628            legacy_field_hit = true;
629        }
630    }
631
632    // 3. Schema field: exact `name@x.y.z` reference required. Bare-name
633    //    pins are rejected at parse time via SchemaRef::from_str.
634    match obj.get("schema") {
635        Some(Value::String(s)) => {
636            if let Err(e) = s.parse::<SchemaRef>() {
637                errors.push(format!("schema: {e}"));
638            }
639        }
640        Some(_) => errors.push(
641            "schema: must be a string of the form \"<name>@<x.y.z>\" \
642             (exact version pin, e.g. \"default@1.0.0\")"
643                .to_string(),
644        ),
645        None => errors.push(
646            "Config must declare `schema` — exact pin of the form \
647             \"<name>@<x.y.z>\" (e.g. \"default@1.0.0\")"
648                .to_string(),
649        ),
650    }
651
652    // 4. Read-mems map — source presence and shape.
653    //    Cache-file existence is checked at engine init (`Engine::init`
654    //    via `mem_cache`), not here, so isolated schema tests don't
655    //    need real archive fixture files. The cached archive's config is
656    //    authoritative for the version; no `version` or `path` is
657    //    recorded in the config entry.
658    if let Some(Value::Object(mems)) = obj.get("readMems") {
659        for (name, spec) in mems {
660            let entry_path = format!("readMems.{name}");
661
662            let spec_obj = match spec.as_object() {
663                Some(o) => o,
664                None => {
665                    errors.push(format!("{entry_path}: read-mem entry must be an object"));
666                    continue;
667                }
668            };
669
670            let source = match spec_obj.get("source").and_then(|v| v.as_object()) {
671                Some(s) => s,
672                None => {
673                    errors.push(format!(
674                        "{entry_path}.source: read-mem entry must declare a source \
675                         (e.g. {{\"type\": \"local\"}} or {{\"type\": \"url\", \"url\": \"…\"}})"
676                    ));
677                    continue;
678                }
679            };
680
681            match source.get("type").and_then(|v| v.as_str()) {
682                Some("local") => {}
683                Some("url") => match source.get("url").and_then(|v| v.as_str()) {
684                    Some(u) if !u.is_empty() => {}
685                    _ => errors.push(format!(
686                        "{entry_path}.source.url: url source must declare a non-empty 'url' string"
687                    )),
688                },
689                // `registry` type is reserved for future use but not
690                // accepted yet — fails here alongside any other unknown.
691                Some(other) => errors.push(format!(
692                    "{entry_path}.source.type: unknown source type '{other}' \
693                     (expected 'local' or 'url')"
694                )),
695                None => errors.push(format!(
696                    "{entry_path}.source.type: source must declare a 'type' \
697                     ('local' or 'url')"
698                )),
699            }
700        }
701    }
702
703    // 5. `belongsTo` is now a tombstone (see `LEGACY_TOMBSTONE_KEYS`).
704    //    Cross-mem authorization moved to the workspace-level
705    //    `[cross_mem_links]` section in `.memstead/workspace.toml`. Per-mem config
706    //    blobs that still carry the field are rejected with the
707    //    tombstone error above; no shape validation runs here.
708
709    // 7. Unknown key warnings. Tombstone keys are rejected above and
710    //    skipped here so callers don't see a redundant warning alongside
711    //    the hard error.
712    for key in obj.keys() {
713        if KNOWN_TOP_LEVEL_KEYS.contains(&key.as_str())
714            || LEGACY_TOMBSTONE_KEYS
715                .iter()
716                .any(|(k, _)| *k == key.as_str())
717        {
718            continue;
719        }
720        warnings.push(format!(
721            "Unknown config key '{key}' \u{2014} will be ignored"
722        ));
723    }
724
725    let error_code = if legacy_field_hit {
726        Some("LEGACY_FIELD_PRESENT".to_string())
727    } else {
728        None
729    };
730
731    ConfigCheckResult {
732        valid: errors.is_empty(),
733        errors,
734        warnings,
735        error_code,
736    }
737}
738
739// ---------------------------------------------------------------------------
740// Config loading
741// ---------------------------------------------------------------------------
742
743/// Load and parse a config from a mem directory.
744/// Reads `<mem_dir>/.memstead/config.json`.
745pub fn load_config(mem_dir: &Path) -> Result<(Value, PathBuf), ConfigError> {
746    let config_path = mem_dir.join(MEM_META_DIR).join("config.json");
747    let raw = std::fs::read_to_string(&config_path).map_err(|e| {
748        if e.kind() == std::io::ErrorKind::NotFound {
749            ConfigError::NotFound(config_path.display().to_string())
750        } else {
751            ConfigError::Io(e)
752        }
753    })?;
754    let parsed: Value = serde_json::from_str(&raw)
755        .map_err(|_| ConfigError::InvalidJson(config_path.display().to_string()))?;
756    Ok((parsed, config_path))
757}
758
759/// Parse a raw JSON value into a MemConfig.
760pub fn parse_mem_config(value: &Value) -> Result<MemConfig, ConfigError> {
761    serde_json::from_value(value.clone()).map_err(|e| ConfigError::Other(e.to_string()))
762}
763
764/// Load, validate, and parse a mem config from disk.
765pub fn load_and_validate(mem_dir: &Path) -> Result<MemConfig, ConfigError> {
766    let (raw, _path) = load_config(mem_dir)?;
767
768    let result = check_config(&raw);
769    if !result.valid {
770        return Err(ConfigError::ValidationFailed(result.errors));
771    }
772
773    parse_mem_config(&raw)
774}
775
776// ---------------------------------------------------------------------------
777// Config writing
778// ---------------------------------------------------------------------------
779
780/// Write a config JSON value to disk (pretty-printed with trailing newline).
781fn write_config(config_path: &Path, config: &Value) -> Result<(), ConfigError> {
782    let json = serde_json::to_string_pretty(config)? + "\n";
783    std::fs::write(config_path, json)?;
784    Ok(())
785}
786
787/// Validate and write a config to disk. Returns check result.
788fn commit_config(
789    config_path: &Path,
790    config: &Value,
791    dry_run: bool,
792) -> Result<ConfigCheckResult, ConfigError> {
793    let check = check_config(config);
794    if !check.valid {
795        return Ok(check);
796    }
797    if !dry_run {
798        write_config(config_path, config)?;
799    }
800    Ok(check)
801}
802
803// ---------------------------------------------------------------------------
804// Config CRUD operations
805// ---------------------------------------------------------------------------
806
807/// Allowed top-level fields for `update_config_field`. Mirrored by the
808/// macOS app's `WorkspaceService.allowedUpdateFields`. The
809/// workspace rewrite dropped `mediums` and
810/// `projections` here: the engine no longer recognises those blocks so
811/// they are not writable through the update surface either.
812const ALLOWED_UPDATE_FIELDS: &[&str] = &[
813    "version",
814    "description",
815    "authors",
816    "writeGuidance",
817    "rules",
818    "readMems",
819    "schema",
820    "language",
821    "publish",
822];
823
824const PROTECTED_FIELDS: &[&str] = &["name"];
825
826/// Update a top-level config field.
827pub fn update_config_field(
828    config_path: &Path,
829    config: &mut Value,
830    field: &str,
831    value: Value,
832    dry_run: bool,
833) -> Result<ConfigCheckResult, ConfigError> {
834    if PROTECTED_FIELDS.contains(&field) {
835        return Err(ConfigError::Other(format!("Field '{field}' is protected")));
836    }
837
838    let obj = config
839        .as_object_mut()
840        .ok_or_else(|| ConfigError::Other("config must be an object".into()))?;
841
842    if !ALLOWED_UPDATE_FIELDS.contains(&field) {
843        return Err(ConfigError::Other(format!(
844            "Field '{field}' is not a recognized config field. Allowed: {}",
845            ALLOWED_UPDATE_FIELDS.join(", ")
846        )));
847    }
848
849    obj.insert(field.to_string(), value);
850    commit_config(config_path, config, dry_run)
851}
852
853// ===========================================================================
854// Tests
855// ===========================================================================
856
857#[cfg(test)]
858mod tests {
859    use super::*;
860    use serde_json::json;
861
862    // --- check_config tests ---
863
864    fn minimal_valid_config() -> Value {
865        json!({
866            "schema": "default@1.0.0"
867        })
868    }
869
870    #[test]
871    fn check_valid_minimal_config() {
872        let result = check_config(&minimal_valid_config());
873        assert!(result.valid, "errors: {:?}", result.errors);
874    }
875
876    /// The in-config `name` field is optional — configs without a
877    /// `name` key are valid; the leaf folder name under
878    /// `__MEMSTEAD:mems/` (or the disk basename on the legacy disk
879    /// path) is the authoritative identifier instead.
880    #[test]
881    fn check_missing_name_now_valid() {
882        let config = json!({"schema": "default@1.0.0"});
883        let result = check_config(&config);
884        assert!(result.valid, "errors: {:?}", result.errors);
885    }
886
887    /// `parse_mem_config` produces a `MemConfig` whose `name` is
888    /// `None` when the on-disk config omits the field. Pins the
889    /// Goal 3 wire-shape contract.
890    #[test]
891    fn parse_mem_config_name_none_when_field_absent() {
892        let config = json!({"schema": "default@1.0.0"});
893        let parsed = parse_mem_config(&config).expect("name-less config parses");
894        assert!(parsed.name.is_none());
895    }
896
897    /// Round-trip: a `MemConfig` whose `name` is `None` serialises
898    /// without the `name` key (skip-if-none on the serde attribute).
899    /// Pins the on-disk minimisation contract.
900    #[test]
901    fn mem_config_omits_name_when_none_on_serialize() {
902        let cfg = MemConfig {
903            name: None,
904            version: None,
905            description: None,
906            authors: None,
907            schema: Some(SchemaRef::new("default", semver::Version::new(1, 0, 0))),
908            write_guidance: Default::default(),
909            rules: None,
910            publish: None,
911            language: None,
912            read_mems: Default::default(),
913            community: None,
914            vcs: None,
915            unregistered_at: None,
916            sync_state: Default::default(),
917            extra: Default::default(),
918        };
919        let json = serde_json::to_string(&cfg).unwrap();
920        assert!(
921            !json.contains("\"name\""),
922            "serialized config must omit `name` when None, got: {json}"
923        );
924    }
925
926    /// A stray `name` field is rejected with `LEGACY_FIELD_PRESENT`
927    /// regardless of its value (empty or non-empty). Both empty and
928    /// non-empty shapes collapse onto the legacy tombstone reject.
929    #[test]
930    fn check_legacy_name_field_rejected() {
931        for value in [json!(""), json!("@test/mem")] {
932            let config = json!({"name": value, "schema": "default@1.0.0"});
933            let result = check_config(&config);
934            assert!(!result.valid, "name={value}: expected reject");
935            assert_eq!(
936                result.error_code.as_deref(),
937                Some("LEGACY_FIELD_PRESENT"),
938                "name={value}: expected LEGACY_FIELD_PRESENT envelope"
939            );
940            assert!(
941                result.errors.iter().any(|e| e.contains("Legacy `name`")),
942                "name={value}: errors {:?}",
943                result.errors
944            );
945        }
946    }
947
948    #[test]
949    fn check_missing_schema() {
950        let config = json!({});
951        let result = check_config(&config);
952        assert!(!result.valid);
953        assert!(result.errors.iter().any(|e| e.contains("`schema`")));
954    }
955
956    #[test]
957    fn check_legacy_types_array_rejected() {
958        let config = json!({"types": ["spec"], "schema": "default@1.0.0"});
959        let result = check_config(&config);
960        assert!(!result.valid);
961        assert!(result.errors.iter().any(|e| e.contains("Legacy `types:")));
962        assert_eq!(
963            result.error_code.as_deref(),
964            Some("LEGACY_FIELD_PRESENT"),
965            "expected LEGACY_FIELD_PRESENT envelope for legacy `types`"
966        );
967    }
968
969    #[test]
970    fn check_schema_wrong_shape() {
971        let config = json!({"schema": ["default@1.0.0"]});
972        let result = check_config(&config);
973        assert!(!result.valid);
974    }
975
976    #[test]
977    fn check_schema_bare_name_rejected() {
978        // Bare-name pins are rejected at load — every mem config must
979        // declare an exact `<name>@<version>` pin so cross-mem link
980        // matching and archive identity are unambiguous.
981        let config = json!({"schema": "default"});
982        let result = check_config(&config);
983        assert!(!result.valid, "expected bare-name pin to be rejected");
984        assert!(
985            result.errors.iter().any(|e| e.contains("schema")),
986            "errors: {:?}",
987            result.errors
988        );
989    }
990
991    #[test]
992    fn check_schema_range_syntax_rejected() {
993        for s in [
994            "default@^1.0.0",
995            "default@~1.0.0",
996            "default@latest",
997            "default@>=1.0.0",
998        ] {
999            let config = json!({"schema": s});
1000            let result = check_config(&config);
1001            assert!(!result.valid, "expected '{s}' to be rejected");
1002        }
1003    }
1004
1005    #[test]
1006    fn check_schema_valid_exact_pin() {
1007        let config = json!({"schema": "default@1.0.0"});
1008        let result = check_config(&config);
1009        assert!(result.valid, "errors: {:?}", result.errors);
1010    }
1011
1012    #[test]
1013    fn schema_pin_versioned_parses() {
1014        let pin: SchemaRef = "software@1.2.3".parse().unwrap();
1015        assert_eq!(pin.name, "software");
1016        assert_eq!(pin.version, semver::Version::new(1, 2, 3));
1017        assert_eq!(pin.as_display(), "software@1.2.3");
1018    }
1019
1020    #[test]
1021    fn schema_pin_bare_name_rejected() {
1022        // Bare-name pins are rejected at parse — agents must declare the
1023        // exact version. Bogus name shapes (uppercase, slash, empty) fall
1024        // through the same gate.
1025        for bad in ["software", "Default", "foo/bar", "", "  "] {
1026            assert!(
1027                bad.parse::<SchemaRef>().is_err(),
1028                "expected '{bad}' to be rejected"
1029            );
1030        }
1031    }
1032
1033    #[test]
1034    fn schema_pin_serde_round_trip() {
1035        let versioned: SchemaRef = serde_json::from_str(r#""software@1.0.0""#).unwrap();
1036        assert_eq!(versioned.as_display(), "software@1.0.0");
1037        let as_json = serde_json::to_string(&versioned).unwrap();
1038        assert_eq!(as_json, r#""software@1.0.0""#);
1039    }
1040
1041    #[test]
1042    fn publish_rejects_missing_schema() {
1043        // Archives record a concrete schema version; a config without a
1044        // `schema` field cannot be published.
1045        let json = json!({ "version": "1.0.0" });
1046        let config = parse_mem_config(&json).unwrap();
1047        let err = published_config_from(&config, "demo").unwrap_err();
1048        assert!(matches!(err, PublishConversionError::MissingSchema));
1049    }
1050
1051    #[test]
1052    fn publish_accepts_versioned_pin() {
1053        let json = json!({
1054            "version": "1.0.0",
1055            "schema": "software@2.3.4"
1056        });
1057        let config = parse_mem_config(&json).unwrap();
1058        let published = published_config_from(&config, "demo").expect("versioned pin publishes");
1059        assert_eq!(published.name, "demo");
1060        assert_eq!(published.schema.name, "software");
1061        assert_eq!(published.schema.version, semver::Version::new(2, 3, 4));
1062    }
1063
1064    #[test]
1065    fn check_legacy_default_schema_field_is_ignored() {
1066        // `defaultSchema` was an author-only tombstone field pre-2026-04.
1067        // It's captured into `extra` and surfaces as an unknown-field
1068        // warning without invalidating an otherwise well-formed config.
1069        let config = json!({
1070            "schema": "default@1.0.0",
1071            "defaultSchema": "spec"
1072        });
1073        let result = check_config(&config);
1074        assert!(result.valid, "errors: {:?}", result.errors);
1075    }
1076
1077    #[test]
1078    fn config_preserves_unknown_fields_on_roundtrip() {
1079        // Any unknown top-level field (legacy `defaultSchema`, future
1080        // fields, typos) is captured into `extra` and re-emitted
1081        // unchanged — guarantees no silent data loss on read-modify-write.
1082        let raw = json!({
1083            "schema": "default@1.0.0",
1084            "defaultSchema": "concept"
1085        });
1086        let cfg: MemConfig = serde_json::from_value(raw).expect("config deserialized");
1087        assert!(
1088            cfg.extra.contains_key("defaultSchema"),
1089            "legacy field should be preserved in extra: {:?}",
1090            cfg.extra
1091        );
1092
1093        let reserialized = serde_json::to_value(&cfg).expect("config reserialized");
1094        assert_eq!(
1095            reserialized.get("defaultSchema").and_then(|v| v.as_str()),
1096            Some("concept"),
1097            "round-trip should preserve the legacy field"
1098        );
1099    }
1100
1101    #[test]
1102    fn check_unknown_keys_warned() {
1103        let config = json!({
1104            "schema": "default@1.0.0",
1105            "unknownKey": "value"
1106        });
1107        let result = check_config(&config);
1108        assert!(result.valid);
1109        assert!(result.warnings.iter().any(|w| w.contains("unknownKey")));
1110    }
1111
1112    /// Tombstone keys produce a hard error, not a soft "unknown key"
1113    /// warning. The unknown-key sweep must skip them so callers see
1114    /// exactly one signal per legacy key.
1115    #[test]
1116    fn legacy_tombstone_does_not_double_warn() {
1117        let config = json!({ "name": "x", "schema": "default@1.0.0" });
1118        let result = check_config(&config);
1119        assert!(!result.valid);
1120        let unknown_warning = result
1121            .warnings
1122            .iter()
1123            .any(|w| w.contains("Unknown config key 'name'"));
1124        assert!(
1125            !unknown_warning,
1126            "legacy tombstone must not also surface as unknown-key warning: {:?}",
1127            result.warnings
1128        );
1129    }
1130
1131    // --- MemConfig slimdown ---
1132
1133    #[test]
1134    fn slim_config_with_only_retained_core_fields_loads() {
1135        // The post-slimdown engine reads a minimal config carrying only
1136        // the fields it actually uses. `schema`, `vcs`, `writeGuidance`
1137        // are the intent-level retained set; serde-required collection
1138        // defaults fill the rest. The mem leaf identity is path-derived
1139        // (Goal 3 of mem-repo-restructure) so the in-config `name`
1140        // field is now a tombstone (Goal 10). Cross-mem authorization
1141        // moved to `.memstead/workspace.toml`'s `[cross_mem_links]` section.
1142        let raw = json!({
1143            "schema": "default@1.0.0",
1144            "writeGuidance": {
1145                "style": "structured",
1146                "audience": "agent"
1147            },
1148            "vcs": { "gitdir": ".git", "worktree": "." }
1149        });
1150        let check = check_config(&raw);
1151        assert!(check.valid, "errors: {:?}", check.errors);
1152        let parsed = parse_mem_config(&raw).expect("slim config parses");
1153        assert!(parsed.name.is_none());
1154        assert_eq!(parsed.write_guidance.len(), 2);
1155        assert_eq!(
1156            parsed.write_guidance.get("style").and_then(|v| v.as_str()),
1157            Some("structured")
1158        );
1159        assert!(parsed.vcs.is_some());
1160        assert!(parsed.extra.is_empty());
1161    }
1162
1163    #[test]
1164    fn legacy_projections_block_lands_in_extra_without_error() {
1165        // Pre-rewrite configs carrying `projections` / `mediums` blocks
1166        // are no longer interpreted by the engine, but round-tripping
1167        // them must not fail — the blocks fall into `MemConfig.extra`
1168        // so read-modify-write preserves authorship. check_config emits
1169        // a "Unknown config key" warning per unrecognised top-level key.
1170        let raw = json!({
1171            "schema": "default@1.0.0",
1172            "mediums": {
1173                "codebase": {
1174                    "type": "codebase",
1175                    "scope": { "tree": [{ "path": "src/", "mode": "allow" }] }
1176                }
1177            },
1178            "projections": {
1179                "p1": {
1180                    "intent": "test",
1181                    "sources": [{ "medium_ref": "codebase" }],
1182                    "destination": { "medium_ref": "graph" }
1183                }
1184            }
1185        });
1186        let check = check_config(&raw);
1187        assert!(
1188            check.valid,
1189            "legacy projections/mediums must load without errors: {:?}",
1190            check.errors
1191        );
1192        let projection_warned = check.warnings.iter().any(|w| w.contains("projections"));
1193        let mediums_warned = check.warnings.iter().any(|w| w.contains("mediums"));
1194        assert!(
1195            projection_warned && mediums_warned,
1196            "unknown-key warnings expected for projections and mediums: {:?}",
1197            check.warnings
1198        );
1199
1200        let parsed = parse_mem_config(&raw).expect("legacy config parses");
1201        assert!(
1202            parsed.extra.contains_key("projections"),
1203            "legacy `projections` must land in extra: {:?}",
1204            parsed.extra.keys().collect::<Vec<_>>()
1205        );
1206        assert!(
1207            parsed.extra.contains_key("mediums"),
1208            "legacy `mediums` must land in extra: {:?}",
1209            parsed.extra.keys().collect::<Vec<_>>()
1210        );
1211    }
1212
1213    #[test]
1214    fn write_guidance_round_trips_as_string_map() {
1215        // `writeGuidance` is an opaque `HashMap<String, Value>` now.
1216        // Round-trip a map with string / array / object / number values
1217        // to confirm every JSON shape survives verbatim — the engine
1218        // must not interpret or normalise its contents.
1219        let raw = json!({
1220            "schema": "default@1.0.0",
1221            "writeGuidance": {
1222                "style": "structured",
1223                "patterns": ["extract", "summarise"],
1224                "nested": { "depth": 2, "flag": true },
1225                "count": 42
1226            }
1227        });
1228        let parsed = parse_mem_config(&raw).expect("config parses");
1229        assert_eq!(parsed.write_guidance.len(), 4);
1230        assert_eq!(
1231            parsed.write_guidance.get("style").and_then(|v| v.as_str()),
1232            Some("structured")
1233        );
1234        let wire = serde_json::to_value(&parsed).expect("reserialize");
1235        let guidance = wire
1236            .get("writeGuidance")
1237            .and_then(|v| v.as_object())
1238            .expect("writeGuidance present in wire form");
1239        assert_eq!(guidance.len(), 4);
1240        assert_eq!(
1241            guidance.get("style").and_then(|v| v.as_str()),
1242            Some("structured")
1243        );
1244        assert_eq!(
1245            guidance
1246                .get("patterns")
1247                .and_then(|v| v.as_array())
1248                .map(|a| a.len()),
1249            Some(2)
1250        );
1251        assert_eq!(
1252            guidance
1253                .get("nested")
1254                .and_then(|v| v.get("depth"))
1255                .and_then(|v| v.as_u64()),
1256            Some(2)
1257        );
1258    }
1259
1260    #[test]
1261    fn write_guidance_empty_map_omits_from_wire() {
1262        // `skip_serializing_if = "HashMap::is_empty"` keeps an unset
1263        // writeGuidance off the wire entirely so existing minimal
1264        // configs don't gain an empty `{}` after a round-trip.
1265        let parsed: MemConfig = serde_json::from_value(minimal_valid_config()).unwrap();
1266        assert!(parsed.write_guidance.is_empty());
1267        let wire = serde_json::to_value(&parsed).unwrap();
1268        assert!(
1269            wire.get("writeGuidance").is_none(),
1270            "empty writeGuidance must be omitted from the wire: {wire}"
1271        );
1272    }
1273
1274    #[test]
1275    fn published_config_strips_extra_and_write_guidance() {
1276        // `PublishedMemConfig` uses `deny_unknown_fields` with a
1277        // fixed whitelist — the catchall `extra` and the pass-through
1278        // `writeGuidance` both fall off the projection. This guards
1279        // against a future reviewer adding either to the whitelist by
1280        // mistake.
1281        let mut extra = HashMap::new();
1282        extra.insert(
1283            "projections".to_string(),
1284            json!({ "p1": { "intent": "x" } }),
1285        );
1286        let mut guidance = HashMap::new();
1287        guidance.insert("style".to_string(), json!("structured"));
1288        let mut sync_state = BTreeMap::new();
1289        sync_state.insert(
1290            "engine-graph/source-files".to_string(),
1291            "deadbeef".to_string(),
1292        );
1293        let cfg = MemConfig {
1294            name: Some("demo".to_string()),
1295            version: Some(semver::Version::new(0, 1, 0)),
1296            description: None,
1297            authors: None,
1298            schema: Some("default@1.0.0".parse().unwrap()),
1299            write_guidance: guidance,
1300            rules: None,
1301            publish: None,
1302            language: None,
1303            read_mems: BTreeMap::new(),
1304            community: None,
1305            vcs: None,
1306            unregistered_at: None,
1307            sync_state,
1308            extra,
1309        };
1310        let published = published_config_from(&cfg, "").expect("publish projection");
1311        let wire = serde_json::to_value(&published).expect("serialize");
1312        assert!(
1313            wire.get("projections").is_none(),
1314            "extra must not leak into published wire: {wire}"
1315        );
1316        assert!(
1317            wire.get("writeGuidance").is_none(),
1318            "writeGuidance must not leak into published wire: {wire}"
1319        );
1320        assert!(
1321            wire.get("syncState").is_none(),
1322            "syncState must not leak into published wire: {wire}"
1323        );
1324    }
1325
1326    // --- legacy tombstones — kept to lock behaviour after slimdown ---
1327
1328    // --- migration tests ---
1329
1330    // --- flatten tests ---
1331
1332    // --- shadow detection tests ---
1333
1334    // --- CRUD dry run tests ---
1335
1336    #[test]
1337    fn update_config_field_protected() {
1338        let tmp = tempfile::tempdir().unwrap();
1339        let config_path = tmp.path().join("config.json");
1340
1341        let mut config = minimal_valid_config();
1342        let err =
1343            update_config_field(&config_path, &mut config, "name", json!("new"), true).unwrap_err();
1344        assert!(err.to_string().contains("protected"));
1345    }
1346
1347    #[test]
1348    fn update_config_field_unknown() {
1349        let tmp = tempfile::tempdir().unwrap();
1350        let config_path = tmp.path().join("config.json");
1351
1352        let mut config = minimal_valid_config();
1353        let err = update_config_field(&config_path, &mut config, "banana", json!("yellow"), true)
1354            .unwrap_err();
1355        assert!(err.to_string().contains("not a recognized"));
1356    }
1357
1358    #[test]
1359    fn update_config_field_allowed() {
1360        let tmp = tempfile::tempdir().unwrap();
1361        let config_path = tmp.path().join("config.json");
1362
1363        let mut config = minimal_valid_config();
1364        let result =
1365            update_config_field(&config_path, &mut config, "language", json!("en"), true).unwrap();
1366        assert!(result.valid, "errors: {:?}", result.errors);
1367        assert_eq!(config["language"], "en");
1368    }
1369
1370    // --- is_encompassed_by tests ---
1371
1372    // --- Graph medium scope validation ---
1373
1374    // --- version parsing (semver) ---
1375
1376    #[test]
1377    fn parse_accepts_valid_semver_version() {
1378        let cfg = json!({
1379            "schema": "default@1.0.0",
1380            "version": "1.2.3-beta.4"
1381        });
1382        let parsed = parse_mem_config(&cfg).expect("valid semver should parse");
1383        let v = parsed.version.expect("version present");
1384        assert_eq!(v.major, 1);
1385        assert_eq!(v.minor, 2);
1386        assert_eq!(v.patch, 3);
1387        assert!(!v.pre.is_empty());
1388    }
1389
1390    #[test]
1391    fn parse_rejects_invalid_semver_version() {
1392        // "1.2" is not valid semver — must be MAJOR.MINOR.PATCH.
1393        let cfg = json!({
1394            "schema": "default@1.0.0",
1395            "version": "1.2"
1396        });
1397        let err = parse_mem_config(&cfg).expect_err("invalid semver must fail at parse");
1398        let msg = format!("{err}");
1399        assert!(
1400            msg.contains("version"),
1401            "error should mention version: {msg}"
1402        );
1403    }
1404
1405    #[test]
1406    fn parse_rejects_non_semver_garbage_version() {
1407        let cfg = json!({
1408            "schema": "default@1.0.0",
1409            "version": "potato"
1410        });
1411        let err = parse_mem_config(&cfg).expect_err("garbage must fail at parse");
1412        let msg = format!("{err}");
1413        assert!(
1414            msg.contains("version"),
1415            "error should mention version: {msg}"
1416        );
1417    }
1418
1419    // --- readMems: `{ source: { type, … } }` entries — no path or
1420    //     version fields (the cached archive's config is authoritative) ---
1421
1422    #[test]
1423    fn parse_accepts_read_mems_with_local_source() {
1424        let cfg = json!({
1425            "schema": "default@1.0.0",
1426            "readMems": {
1427                "internal-notes": { "source": { "type": "local" } }
1428            }
1429        });
1430        let check = check_config(&cfg);
1431        assert!(check.valid, "errors: {:?}", check.errors);
1432        let parsed = parse_mem_config(&cfg).expect("valid readMems must parse");
1433        let spec = parsed
1434            .read_mems
1435            .get("internal-notes")
1436            .expect("entry present");
1437        assert!(matches!(spec.source, ReadMemSource::Local));
1438    }
1439
1440    #[test]
1441    fn parse_accepts_read_mems_with_url_source() {
1442        let cfg = json!({
1443            "schema": "default@1.0.0",
1444            "readMems": {
1445                "aws-patterns": {
1446                    "source": {
1447                        "type": "url",
1448                        "url": "https://example.com/aws-patterns.mem"
1449                    }
1450                }
1451            }
1452        });
1453        let check = check_config(&cfg);
1454        assert!(check.valid, "errors: {:?}", check.errors);
1455        let parsed = parse_mem_config(&cfg).expect("valid readMems must parse");
1456        let spec = parsed.read_mems.get("aws-patterns").expect("entry present");
1457        match &spec.source {
1458            ReadMemSource::Url { url } => {
1459                assert_eq!(url, "https://example.com/aws-patterns.mem")
1460            }
1461            _ => panic!("expected Url source, got {:?}", spec.source),
1462        }
1463    }
1464
1465    #[test]
1466    fn parse_accepts_empty_read_mems_map() {
1467        let cfg = json!({
1468            "schema": "default@1.0.0",
1469            "readMems": {}
1470        });
1471        let parsed = parse_mem_config(&cfg).expect("empty readMems must parse");
1472        assert!(parsed.read_mems.is_empty());
1473    }
1474
1475    #[test]
1476    fn parse_accepts_omitted_read_mems() {
1477        let cfg = json!({
1478            "schema": "default@1.0.0"
1479        });
1480        let parsed = parse_mem_config(&cfg).expect("omitted readMems must parse");
1481        assert!(parsed.read_mems.is_empty());
1482    }
1483
1484    #[test]
1485    fn check_rejects_read_mem_without_source() {
1486        let cfg = json!({
1487            "schema": "default@1.0.0",
1488            "readMems": { "p": {} }
1489        });
1490        let check = check_config(&cfg);
1491        assert!(!check.valid);
1492        assert!(
1493            check.errors.iter().any(|e| e.contains("source")),
1494            "errors: {:?}",
1495            check.errors
1496        );
1497    }
1498
1499    #[test]
1500    fn check_rejects_read_mem_with_unknown_source_type() {
1501        let cfg = json!({
1502            "schema": "default@1.0.0",
1503            "readMems": {
1504                "p": { "source": { "type": "ftp", "url": "ftp://..." } }
1505            }
1506        });
1507        let check = check_config(&cfg);
1508        assert!(!check.valid);
1509        assert!(
1510            check
1511                .errors
1512                .iter()
1513                .any(|e| e.contains("unknown source type")),
1514            "errors: {:?}",
1515            check.errors
1516        );
1517    }
1518
1519    #[test]
1520    fn check_rejects_url_source_with_empty_url() {
1521        let cfg = json!({
1522            "schema": "default@1.0.0",
1523            "readMems": {
1524                "p": { "source": { "type": "url", "url": "" } }
1525            }
1526        });
1527        let check = check_config(&cfg);
1528        assert!(!check.valid);
1529        assert!(
1530            check.errors.iter().any(|e| e.contains("url source")),
1531            "errors: {:?}",
1532            check.errors
1533        );
1534    }
1535
1536    #[test]
1537    fn check_rejects_registry_source_type_reserved_for_phase_d() {
1538        let cfg = json!({
1539            "schema": "default@1.0.0",
1540            "readMems": {
1541                "p": { "source": { "type": "registry" } }
1542            }
1543        });
1544        let check = check_config(&cfg);
1545        // `registry` is a reserved future source type but is not yet
1546        // accepted by the schema — validation must reject it until the
1547        // registry ships.
1548        assert!(!check.valid);
1549        assert!(
1550            check
1551                .errors
1552                .iter()
1553                .any(|e| e.contains("unknown source type")),
1554            "errors: {:?}",
1555            check.errors
1556        );
1557    }
1558
1559    /// Guards the `BTreeMap` choice: serialized read_mems must come out
1560    /// in key-sorted order regardless of insertion order, so config files
1561    /// on disk and log output are reproducible. A future "optimisation"
1562    /// that reintroduces `HashMap` would break this.
1563    #[test]
1564    fn read_mems_serialization_order_is_key_sorted() {
1565        let cfg = json!({
1566            "schema": "default@1.0.0",
1567            "readMems": {
1568                "zebra": { "source": { "type": "local" } },
1569                "alpha": { "source": { "type": "local" } },
1570                "mango": { "source": { "type": "local" } }
1571            }
1572        });
1573        let parsed = parse_mem_config(&cfg).expect("valid config must parse");
1574        let reserialized = serde_json::to_string(&parsed).expect("serialization must succeed");
1575        let alpha = reserialized.find("alpha").expect("alpha present");
1576        let mango = reserialized.find("mango").expect("mango present");
1577        let zebra = reserialized.find("zebra").expect("zebra present");
1578        assert!(
1579            alpha < mango && mango < zebra,
1580            "expected alpha < mango < zebra, got: {reserialized}"
1581        );
1582    }
1583
1584    // ----- vcs field -----
1585
1586    #[test]
1587    fn vcs_config_round_trips_through_serde_with_both_fields() {
1588        let cfg = json!({
1589            "schema": "default@1.0.0",
1590            "vcs": { "gitdir": "../.git", "worktree": ".." }
1591        });
1592        let parsed = parse_mem_config(&cfg).expect("valid config must parse");
1593        let vcs = parsed.vcs.as_ref().expect("vcs must be Some");
1594        assert_eq!(vcs.gitdir, "../.git");
1595        assert_eq!(vcs.worktree, "..");
1596
1597        // Round-trip.
1598        let reserialized = serde_json::to_value(&parsed).unwrap();
1599        let round = parse_mem_config(&reserialized).expect("round-trip parse");
1600        assert_eq!(round.vcs.as_ref().unwrap().gitdir, "../.git");
1601        assert_eq!(round.vcs.as_ref().unwrap().worktree, "..");
1602    }
1603
1604    #[test]
1605    fn vcs_config_worktree_defaults_to_dot_when_omitted() {
1606        let cfg = json!({
1607            "schema": "default@1.0.0",
1608            "vcs": { "gitdir": ".git" }
1609        });
1610        let parsed = parse_mem_config(&cfg).expect("valid config must parse");
1611        let vcs = parsed.vcs.as_ref().expect("vcs must be Some");
1612        assert_eq!(vcs.gitdir, ".git");
1613        assert_eq!(vcs.worktree, ".", "worktree must default to \".\"");
1614    }
1615
1616    #[test]
1617    fn vcs_config_absent_is_none() {
1618        let cfg = json!({ "schema": "default@1.0.0"  });
1619        let parsed = parse_mem_config(&cfg).expect("valid config must parse");
1620        assert!(parsed.vcs.is_none(), "missing vcs must deserialize to None");
1621    }
1622
1623    #[test]
1624    fn vcs_field_tolerates_legacy_string_value() {
1625        // Legacy macOS-app sentinel. The tolerant deserializer must keep
1626        // the config loadable (returning None) without touching the
1627        // user's file by hand.
1628        let cfg = json!({
1629            "schema": "default@1.0.0",
1630            "vcs": "system"
1631        });
1632        let parsed = parse_mem_config(&cfg).expect("legacy vcs string must parse");
1633        assert!(
1634            parsed.vcs.is_none(),
1635            "legacy string must deserialize to None"
1636        );
1637    }
1638
1639    #[test]
1640    fn published_config_strips_vcs() {
1641        // `published_config_from` must drop the `vcs` block — VCS layout
1642        // is workspace-local mechanics, never part of the published
1643        // mem's identity. This is guaranteed by the whitelist
1644        // projection: `PublishedMemConfig` has no `vcs` field, so a
1645        // MemConfig carrying `vcs: Some(...)` projects to a
1646        // PublishedMemConfig with no `vcs` on the wire.
1647        let mut cfg = MemConfig {
1648            name: Some("demo".to_string()),
1649            version: Some(semver::Version::new(0, 1, 0)),
1650            description: None,
1651            authors: None,
1652            schema: Some("default@1.0.0".parse().unwrap()),
1653            write_guidance: HashMap::new(),
1654            rules: None,
1655            publish: None,
1656            language: None,
1657            read_mems: BTreeMap::new(),
1658            community: None,
1659            vcs: None,
1660            unregistered_at: None,
1661            sync_state: BTreeMap::new(),
1662            extra: HashMap::new(),
1663        };
1664        cfg.vcs = Some(VcsConfig {
1665            gitdir: ".git".to_string(),
1666            worktree: ".".to_string(),
1667        });
1668        let published = published_config_from(&cfg, "").expect("valid projection");
1669        let wire = serde_json::to_value(&published).expect("serialize");
1670        assert!(
1671            wire.get("vcs").is_none(),
1672            "published wire form must not carry vcs: got {wire}"
1673        );
1674    }
1675
1676    // ----- belongsTo legacy tombstone -----
1677
1678    /// `belongsTo` is now a tombstone — cross-mem authorization
1679    /// migrated to the workspace-level `[cross_mem_links]` section in
1680    /// `.memstead/workspace.toml`. A per-mem config blob carrying `belongsTo` is
1681    /// rejected with `LEGACY_FIELD_PRESENT`.
1682    #[test]
1683    fn belongs_to_field_is_legacy_tombstone() {
1684        let cfg = json!({
1685            "schema": "default@1.0.0",
1686            "belongsTo": ["main"]
1687        });
1688        let result = check_config(&cfg);
1689        assert!(!result.valid, "belongsTo presence must fail validation");
1690        assert_eq!(result.error_code.as_deref(), Some("LEGACY_FIELD_PRESENT"));
1691        assert!(
1692            result
1693                .errors
1694                .iter()
1695                .any(|e| e.contains("belongsTo") && e.contains("cross_mem_links")),
1696            "tombstone error must name the field and the replacement section: {:?}",
1697            result.errors
1698        );
1699    }
1700}