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