Skip to main content

memstead_schema/
lib.rs

1//! Type definitions, schema loading, and mem-config validation for Memstead.
2//!
3//! Schemas are first-class packages — named, versioned bundles of type
4//! definitions + relationship vocabulary + LLM-facing documentation. The
5//! engine holds a `SchemaRegistry` mapping `(name, version)` to `Arc<Schema>`.
6//! Each mem pins exactly one schema via `MemConfig.schema: SchemaRef`.
7
8pub mod archive_provenance;
9pub mod base_metadata;
10pub mod builtins;
11pub mod config;
12pub mod content_expr;
13pub mod loader;
14pub mod manifest;
15pub mod meta_schema;
16pub mod schema;
17pub mod source;
18pub mod types;
19pub mod workspace_config;
20
21use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23use std::sync::Arc;
24
25pub use archive_provenance::{
26    ARCHIVE_PROVENANCE_FORMAT, ArchiveProvenance, EntityProvenance, History,
27};
28pub use config::{
29    ARCHIVE_ANCHORS_PATH, ARCHIVE_CONFIG_PATH, ARCHIVE_EXTENSION, ARCHIVE_META_DIR,
30    ARCHIVE_PROVENANCE_PATH, ARCHIVE_SCHEMA_PREFIX, CommunityOverride, ConfigCheckResult,
31    ConfigError, MEM_META_DIR, MemConfig, MemSubject, MutationStamp, PUBLISHED_MEM_FORMAT,
32    PublishConfig, PublishConversionError, PublishedMemConfig, ReadMemSource, ReadMemSpec,
33    RoleConfig, SchemaRef, VcsConfig, check_config, load_and_validate, load_config,
34    parse_mem_config, published_config_from, published_format_accepted,
35};
36pub use loader::{
37    HeadingKeyViolation, MetadataPolarityFormat, SchemaLoadError, check_reserved_metadata_keys,
38    check_section_formats, check_section_heading_roundtrip, load_schema_from_dir,
39    load_schema_from_memory, load_schema_from_memory_with_format,
40};
41pub use manifest::{
42    Cardinality, CommunityConfig, CrossMemRelationshipEntry, DefaultWritingGuidance,
43    ManualAuthoring, PerEdgeDescription, RelationshipDef, RelationshipMode, RelationshipVocabulary,
44    SchemaManifest,
45};
46pub use schema::Schema;
47pub use source::{
48    INSTALL_PROVENANCE_FILE, SchemaSourceError, SchemaSourceFile, collect_schema_source,
49};
50pub use types::{
51    ConstraintDef, ConstraintSeverity, DueAxis, FieldType, Filterable, MetadataFieldDef,
52    PropagationDirection, RequiredCardinality, RequiredOutgoing, SectionDef, Serialization,
53    TableFormat, TypeDefinition, derive_section_key,
54};
55
56/// Name constants for the 10 built-in knowledge types shipped in the
57/// `default` schema. Kept as a module to catch typos at compile time.
58pub mod builtin_names {
59    pub const SPEC: &str = "spec";
60    pub const MEMO: &str = "memo";
61    pub const ASSERTION: &str = "assertion";
62    pub const CONCEPT: &str = "concept";
63    pub const INQUIRY: &str = "inquiry";
64    pub const MODEL: &str = "model";
65    pub const NARRATIVE: &str = "narrative";
66    pub const PERSPECTIVE: &str = "perspective";
67    pub const PRINCIPLE: &str = "principle";
68    pub const PROCESS: &str = "process";
69
70    pub const ALL: [&str; 10] = [
71        SPEC,
72        MEMO,
73        ASSERTION,
74        CONCEPT,
75        INQUIRY,
76        MODEL,
77        NARRATIVE,
78        PERSPECTIVE,
79        PRINCIPLE,
80        PROCESS,
81    ];
82}
83
84/// Registry holding every loaded schema keyed by `(name, version)`.
85#[derive(Debug)]
86pub struct SchemaRegistry {
87    schemas: HashMap<(String, semver::Version), Arc<Schema>>,
88}
89
90#[derive(Debug, thiserror::Error)]
91pub enum SchemaRegistryError {
92    #[error("schema '{name}' version '{version}' is already registered — cannot reinsert")]
93    AlreadyRegistered {
94        name: String,
95        version: semver::Version,
96    },
97}
98
99/// Error returned by [`SchemaRegistry::resolve_by_name`] when a bare-name
100/// lookup matches multiple registered versions. Surfaced by the
101/// `memstead_schema(name=...)` discovery surface — callers must supply an
102/// exact `<name>@<version>` to disambiguate.
103#[derive(Debug, thiserror::Error)]
104#[error(
105    "schema name '{name}' is ambiguous: {} versions registered ({}). \
106     Use a versioned pin (e.g. \"{name}@{}\") to disambiguate.",
107    .versions.len(),
108    .versions.join(", "),
109    .versions.first().map(String::as_str).unwrap_or("")
110)]
111pub struct SchemaNameAmbiguous {
112    pub name: String,
113    pub versions: Vec<String>,
114}
115
116/// Errors raised while scanning workspace-level or cache schema directories.
117#[derive(Debug, thiserror::Error)]
118pub enum WorkspaceSchemaLoadError {
119    /// A schema directory failed semantic or structural validation. The
120    /// offending directory path is captured alongside the underlying
121    /// loader error so operators can jump straight to the broken file.
122    #[error("failed to load schema at {}: {source}", .path.display())]
123    Invalid {
124        path: PathBuf,
125        #[source]
126        source: SchemaLoadError,
127    },
128
129    /// Filesystem error while iterating a schemas/ or .memstead.cache/schemas/
130    /// directory.
131    #[error("i/o error scanning {}: {source}", .path.display())]
132    Io {
133        path: PathBuf,
134        #[source]
135        source: std::io::Error,
136    },
137
138    /// Two distinct schema directories share the same `<name>-<version>`
139    /// cache key. Surfaces as `SCHEMA_CACHE_COLLISION` to MCP callers —
140    /// silent overwrite would mask a bug in the extraction pipeline.
141    #[error(
142        "schema cache collision: '{name}-{version}' has more than one source directory ({} and {})",
143        .first.display(),
144        .second.display()
145    )]
146    CacheCollision {
147        name: String,
148        version: semver::Version,
149        first: PathBuf,
150        second: PathBuf,
151    },
152}
153
154impl SchemaRegistry {
155    pub fn empty() -> Self {
156        Self {
157            schemas: HashMap::new(),
158        }
159    }
160
161    /// Preloaded with every schema embedded in the binary.
162    pub fn builtin() -> Self {
163        let mut reg = Self::empty();
164        for schema in builtins::load_builtin_schemas()
165            .expect("built-in schemas must load cleanly — bug in shipped YAML")
166        {
167            reg.schemas.insert(
168                (schema.manifest.name.clone(), schema.version.clone()),
169                schema,
170            );
171        }
172        reg
173    }
174
175    /// Build a registry starting from the embedded builtins, then layer
176    /// in the workspace-level shared schemas and the workspace-wide
177    /// schema cache.
178    ///
179    /// Precedence (highest wins on identical `(name, version)`):
180    /// 1. `<workspace_schemas_dir>/<schema>/` — workspace-level shared schemas
181    /// 2. Embedded builtins (`default@1.0.0`, ...)
182    /// 3. `<workspace_root>/.memstead.cache/schemas/<schema>-<version>/` —
183    ///    extracted from read-mem archives (workspace-wide cache)
184    ///
185    /// Different versions of the same schema coexist; a mem picks by exact
186    /// pin via `MemConfig.schema`.
187    ///
188    /// Hidden directories (name starts with `.`) are skipped at every scan
189    /// level so VCS metadata and OS dotdirs cannot be mistaken for a schema
190    /// definition.
191    ///
192    /// Returns the first validation failure it hits so a broken schema can
193    /// never silently shadow a working builtin. Two distinct cache
194    /// directories sharing the same `<name>-<version>` key surface as
195    /// [`WorkspaceSchemaLoadError::CacheCollision`] — the extraction
196    /// pipeline must guarantee uniqueness, and silent overwrite would mask
197    /// the bug.
198    ///
199    /// `workspace_root` and `workspace_schemas_dir` are independent
200    /// optionals: passing `None` for both yields the builtins-only registry
201    /// (the `Engine::init` no-settings variant).
202    pub fn load_for_workspace(
203        workspace_root: Option<&Path>,
204        workspace_schemas_dir: Option<&Path>,
205    ) -> Result<Self, WorkspaceSchemaLoadError> {
206        let mut reg = Self::empty();
207
208        // Pass 1 (lowest precedence): cache schemas extracted from archives.
209        if let Some(ws_root) = workspace_root {
210            let cache_dir = ws_root.join(".memstead.cache").join("schemas");
211            // Track seen `(name, version)` against the directory that
212            // contributed the schema — a second hit means the extraction
213            // pipeline produced two cache entries for the same key, which
214            // is a bug we surface rather than mask.
215            let mut seen: HashMap<(String, semver::Version), PathBuf> = HashMap::new();
216            for path in list_schema_subdirs(&cache_dir)? {
217                let schema = loader::load_schema_from_dir(&path).map_err(|source| {
218                    WorkspaceSchemaLoadError::Invalid {
219                        path: path.clone(),
220                        source,
221                    }
222                })?;
223                let key = (schema.manifest.name.clone(), schema.version.clone());
224                if let Some(prev) = seen.get(&key) {
225                    return Err(WorkspaceSchemaLoadError::CacheCollision {
226                        name: key.0,
227                        version: key.1,
228                        first: prev.clone(),
229                        second: path,
230                    });
231                }
232                seen.insert(key.clone(), path);
233                reg.schemas.insert(key, Arc::new(schema));
234            }
235        }
236
237        // Pass 2: embedded builtins override cache at the same key.
238        for schema in builtins::load_builtin_schemas()
239            .expect("built-in schemas must load cleanly — bug in shipped YAML")
240        {
241            let key = (schema.manifest.name.clone(), schema.version.clone());
242            reg.schemas.insert(key, schema);
243        }
244
245        // Pass 3 (highest precedence): workspace-level schemas override
246        // both cache and builtins.
247        if let Some(ws_dir) = workspace_schemas_dir {
248            for path in list_schema_subdirs(ws_dir)? {
249                let schema = loader::load_schema_from_dir(&path).map_err(|source| {
250                    WorkspaceSchemaLoadError::Invalid {
251                        path: path.clone(),
252                        source,
253                    }
254                })?;
255                let key = (schema.manifest.name.clone(), schema.version.clone());
256                reg.schemas.insert(key, Arc::new(schema));
257            }
258        }
259
260        Ok(reg)
261    }
262
263    /// Resolve a schema by name alone. Used by the `memstead_schema(name=...)`
264    /// lookup surface: the registry is expected to hold at most one
265    /// schema with the given name after workspace-level loading.
266    ///
267    /// Returns:
268    /// - `Ok(Some(schema))` when exactly one schema is registered with
269    ///   this name (any version — the version is metadata).
270    /// - `Ok(None)` when no schema with that name is registered.
271    /// - `Err(SchemaNameAmbiguous)` when multiple versions of the same
272    ///   name are registered (cache + builtin collision, or mixed
273    ///   workspace-level versions). Callers surface this — bare-name
274    ///   lookups need a unique winner.
275    pub fn resolve_by_name(&self, name: &str) -> Result<Option<Arc<Schema>>, SchemaNameAmbiguous> {
276        let candidates: Vec<&Arc<Schema>> = self
277            .schemas
278            .iter()
279            .filter(|((n, _), _)| n == name)
280            .map(|(_, s)| s)
281            .collect();
282        match candidates.len() {
283            0 => Ok(None),
284            1 => Ok(Some(candidates[0].clone())),
285            _ => {
286                let mut versions: Vec<String> =
287                    candidates.iter().map(|s| s.version.to_string()).collect();
288                versions.sort();
289                Err(SchemaNameAmbiguous {
290                    name: name.to_string(),
291                    versions,
292                })
293            }
294        }
295    }
296
297    /// Merge another registry into this one. `name@version` keys already
298    /// present are left untouched — the caller controls precedence by the
299    /// order it merges. Used by the engine to build one aggregate registry
300    /// across writable mems without copying arcs twice.
301    pub fn merge_from(&mut self, other: &SchemaRegistry) {
302        for (key, schema) in &other.schemas {
303            self.schemas
304                .entry(key.clone())
305                .or_insert_with(|| schema.clone());
306        }
307    }
308
309    /// Insert a schema, replacing any existing entry at the same
310    /// `(name, version)` key. Used by storage backends that source
311    /// workspace-level schemas from outside the disk-walker (e.g. the
312    /// `gix`-tree-backed loader in `memstead-git-branch::mem_repo_schemas`) to
313    /// overlay workspace schemas on top of the cache + builtins layers
314    /// loaded by [`Self::load_for_workspace`] with `workspace_schemas_dir
315    /// = None`.
316    ///
317    /// **Shadowing semantics:** this method overwrites builtin entries
318    /// at the same `(name, version)`. That is intentional for the
319    /// canonical use case — a workspace's `software@1.0.0` schema
320    /// overlay legitimately replaces the `default@1.0.0` builtin's
321    /// slot only when the names happen to collide, which is the
322    /// mem-repo overlay pattern. Callers MUST NOT use this
323    /// method to silently shadow an unrelated builtin name unless
324    /// they own the workspace-overlay precedence story; the only
325    /// in-tree caller is `memstead-git-branch::lib::build_workspace_schema_registry`.
326    pub fn insert_overwriting(&mut self, schema: Arc<Schema>) {
327        let key = (schema.manifest.name.clone(), schema.version.clone());
328        self.schemas.insert(key, schema);
329    }
330
331    pub fn get(&self, name: &str, version: &semver::Version) -> Option<Arc<Schema>> {
332        self.schemas
333            .get(&(name.to_string(), version.clone()))
334            .cloned()
335    }
336
337    pub fn iter(&self) -> impl Iterator<Item = Arc<Schema>> + '_ {
338        self.schemas.values().cloned()
339    }
340
341    pub fn available_versions(&self, name: &str) -> Vec<semver::Version> {
342        let mut versions: Vec<semver::Version> = self
343            .schemas
344            .keys()
345            .filter(|(n, _)| n == name)
346            .map(|(_, v)| v.clone())
347            .collect();
348        versions.sort();
349        versions
350    }
351
352    /// Closest-match schema name by Levenshtein edit distance against the
353    /// currently-registered schemas. Returns `None` if the registry is
354    /// empty or every candidate's distance from `name` is 0 (exact match,
355    /// shouldn't be called in that case) — callers get a clean `Option`
356    /// to plug into error messages without format-plumbing.
357    pub fn suggest_name(&self, name: &str) -> Option<String> {
358        let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
359        let mut best: Option<(usize, String)> = None;
360        for (n, _) in self.schemas.keys() {
361            if !seen.insert(n.as_str()) {
362                continue;
363            }
364            let d = strsim::levenshtein(name, n);
365            match &best {
366                Some((bd, _)) if *bd <= d => {}
367                _ => best = Some((d, n.clone())),
368            }
369        }
370        best.and_then(|(d, n)| if d > 0 { Some(n) } else { None })
371    }
372
373    /// List every registered `(name, version)` pair, stably sorted so
374    /// iteration is deterministic.
375    pub fn identities(&self) -> Vec<(String, semver::Version)> {
376        let mut ids: Vec<(String, semver::Version)> = self.schemas.keys().cloned().collect();
377        ids.sort();
378        ids
379    }
380
381    pub fn is_empty(&self) -> bool {
382        self.schemas.is_empty()
383    }
384
385    pub fn len(&self) -> usize {
386        self.schemas.len()
387    }
388
389    pub fn insert(&mut self, schema: Arc<Schema>) -> Result<(), SchemaRegistryError> {
390        let key = (schema.manifest.name.clone(), schema.version.clone());
391        if self.schemas.contains_key(&key) {
392            return Err(SchemaRegistryError::AlreadyRegistered {
393                name: key.0,
394                version: key.1,
395            });
396        }
397        self.schemas.insert(key, schema);
398        Ok(())
399    }
400}
401
402impl Default for SchemaRegistry {
403    fn default() -> Self {
404        Self::builtin()
405    }
406}
407
408/// Lookup by short type name against the built-in `default` schema.
409///
410/// Kept as a convenience because ~100 engine call sites use short names to
411/// resolve type definitions. Production consumers should prefer
412/// `mem.schema.get_type(name)` for user-defined schemas; this helper
413/// exists for test fixtures, CLI one-offs, and callers that legitimately
414/// target the built-in `default` schema.
415pub fn type_by_name(name: &str) -> Option<Arc<TypeDefinition>> {
416    Schema::builtin_default().get_type(name)
417}
418
419/// Enumerate immediate subdirectories of `dir` that look like schema roots.
420///
421/// Missing directories are treated as "no schemas here" rather than an
422/// error — a workspace without `.memstead/schemas/` is legitimate. Hidden
423/// directories (leading `.`) are skipped so `.cache`, `.git`, `.DS_Store`,
424/// and other VCS/OS metadata cannot masquerade as a schema package.
425/// Non-directory entries (stray files like `README.md`) are ignored too.
426fn list_schema_subdirs(dir: &Path) -> Result<Vec<PathBuf>, WorkspaceSchemaLoadError> {
427    if !dir.is_dir() {
428        return Ok(Vec::new());
429    }
430    let entries = std::fs::read_dir(dir).map_err(|e| WorkspaceSchemaLoadError::Io {
431        path: dir.to_path_buf(),
432        source: e,
433    })?;
434    let mut out = Vec::new();
435    for entry in entries {
436        let entry = entry.map_err(|e| WorkspaceSchemaLoadError::Io {
437            path: dir.to_path_buf(),
438            source: e,
439        })?;
440        let path = entry.path();
441        if !path.is_dir() {
442            continue;
443        }
444        let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
445            continue;
446        };
447        if name.starts_with('.') {
448            continue;
449        }
450        out.push(path);
451    }
452    // Deterministic order so log output and error messages match across runs.
453    out.sort();
454    Ok(out)
455}
456
457/// Every type in the built-in `default` schema, in declaration order.
458pub fn all_types() -> Vec<Arc<TypeDefinition>> {
459    let schema = Schema::builtin_default();
460    // Preserve `manifest.types` order so callers iterating this get a
461    // stable sequence instead of HashMap iteration order.
462    schema
463        .manifest
464        .types
465        .iter()
466        .filter_map(|name| schema.get_type(name))
467        .collect()
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    #[test]
475    fn builtin_registry_contains_default() {
476        let reg = SchemaRegistry::builtin();
477        assert!(!reg.is_empty());
478        let versions = reg.available_versions("default");
479        // 1.0.0 (sealed, retired vocabulary) + 1.1.0 (plan-06 rename)
480        // + 1.2.0 (exemplars) + 1.3.0 (current generation,
481        // required-opt-in metadata polarity) — retention keeps every
482        // shipped version.
483        assert_eq!(versions.len(), 4);
484    }
485
486    #[test]
487    fn builtin_default_has_ten_types() {
488        assert_eq!(all_types().len(), 10);
489        for name in builtin_names::ALL {
490            assert!(type_by_name(name).is_some(), "missing type: {name}");
491        }
492    }
493
494    #[test]
495    fn registry_rejects_duplicate_insert() {
496        let schema = Schema::builtin_default();
497        let mut reg = SchemaRegistry::empty();
498        reg.insert(schema.clone()).unwrap();
499        let err = reg.insert(schema).unwrap_err();
500        assert!(matches!(err, SchemaRegistryError::AlreadyRegistered { .. }));
501    }
502
503    /// `software@0.1.0` lifecycle-stage date fields are optional: a
504    /// brand-new entity in its default stage (`verification_status:
505    /// unverified`, `deprecation_status: current`) must author without
506    /// supplying a date for an event that has not happened. Sibling
507    /// non-lifecycle fields stay required. Locks the requiredness
508    /// decision so a future schema edit can't silently re-require them.
509    #[test]
510    fn software_lifecycle_date_fields_are_optional() {
511        let reg = SchemaRegistry::builtin();
512        let software = reg
513            .get("software", &semver::Version::new(0, 2, 0))
514            .expect("software builtin present");
515
516        let requirement = software.get_type("requirement").expect("requirement type");
517        assert!(
518            !requirement
519                .metadata_field("verified_on")
520                .unwrap()
521                .is_required(),
522            "verified_on must be optional — an unverified requirement has no verification date"
523        );
524        // Sibling required field is untouched.
525        assert!(
526            requirement.metadata_field("source").unwrap().is_required(),
527            "source stays required"
528        );
529
530        let contract = software.get_type("contract").expect("contract type");
531        for field in ["deprecated_on", "removal_on"] {
532            assert!(
533                !contract.metadata_field(field).unwrap().is_required(),
534                "{field} must be optional — a current contract has no deprecation/removal date"
535            );
536        }
537        // Sibling required fields are untouched.
538        for field in ["protocol", "version"] {
539            assert!(
540                contract.metadata_field(field).unwrap().is_required(),
541                "{field} stays required"
542            );
543        }
544    }
545
546    mod workspace_layer {
547        use super::*;
548        use tempfile::TempDir;
549
550        /// Minimal schema fixture writer — builds `schema.yaml` + one type.
551        fn write_schema(dir: &Path, name: &str, version: &str) {
552            std::fs::create_dir_all(dir.join("types")).unwrap();
553            let manifest = format!(
554                r#"name: {name}
555version: {version}
556description: test
557when_to_use: test
558types:
559  - spec
560relationships:
561  mode: strict
562  definitions:
563    - name: _default
564      description: default
565      default_weight: 1.0
566    - name: PART_OF
567      description: hier
568      default_weight: 3.0
569community:
570  resolution: 1.0
571  seed: 42
572"#
573            );
574            std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
575            std::fs::write(
576                dir.join("types/spec.yaml"),
577                r#"name: spec
578description: test
579when_to_use: test
580sections:
581  - key: body
582    heading: Body
583    required: true
584    search_weight: 10.0
585    catch_all: true
586metadata_fields: []
587title_weight: 1.0
588text_fields: [body]
589hierarchy_relationship: PART_OF
590no_self_loop_relationships: []
591updatable_fields: [title, body]
592health_required_fields: [body]
593staleness_threshold_days: 30
594write_rules: []
595"#,
596            )
597            .unwrap();
598        }
599
600        #[test]
601        fn workspace_schema_resolves_by_name() {
602            let tmp = TempDir::new().unwrap();
603            let workspace_schemas = tmp.path().join("schemas");
604
605            // Use a name that does not collide with any registered
606            // builtin schema (default / ingest / planning / project /
607            // software all ship as builtins).
608            write_schema(
609                &workspace_schemas.join("test-isolated"),
610                "test-isolated",
611                "1.0.0",
612            );
613
614            let reg =
615                SchemaRegistry::load_for_workspace(Some(tmp.path()), Some(&workspace_schemas))
616                    .expect("workspace load succeeds");
617
618            let resolved = reg
619                .resolve_by_name("test-isolated")
620                .expect("unique name resolves");
621            assert!(resolved.is_some(), "workspace schema must be registered");
622        }
623
624        #[test]
625        fn per_mem_schema_override_no_longer_resolves() {
626            // Pre-cutover, a per-mem `<mem>/.memstead/schemas/<name>/`
627            // shadowed builtins and the workspace layer. That level is
628            // gone — the builtin survives even
629            // with a per-mem directory present on disk.
630            let tmp = TempDir::new().unwrap();
631            let mem_override = tmp.path().join("mem/.memstead/schemas/default");
632            write_schema(&mem_override, "default", "1.0.0");
633
634            let reg = SchemaRegistry::load_for_workspace(Some(tmp.path()), None).unwrap();
635            let schema = reg
636                .get("default", &semver::Version::new(1, 0, 0))
637                .expect("builtin default still registered");
638            // Builtin ships 10 types; the per-mem override would have
639            // been a 1-type schema if the level still existed.
640            assert_eq!(
641                schema.types.len(),
642                10,
643                "per-mem override must no longer shadow the builtin"
644            );
645        }
646
647        #[test]
648        fn workspace_layer_falls_through_to_builtins() {
649            let tmp = TempDir::new().unwrap();
650            // No workspace dir → only builtins remain.
651            let reg = SchemaRegistry::load_for_workspace(Some(tmp.path()), None)
652                .expect("builtin-only load succeeds");
653            assert!(
654                reg.get("default", &semver::Version::new(1, 1, 0)).is_some(),
655                "builtin default must be registered when no other layers contribute"
656            );
657        }
658
659        #[test]
660        fn workspace_overrides_builtin_at_same_key() {
661            // Workspace-level schemas sit above builtins. A workspace-level
662            // `default@1.0.0` with a single type must replace the 10-type
663            // shipped builtin.
664            let tmp = TempDir::new().unwrap();
665            let workspace_schemas = tmp.path().join("schemas");
666
667            write_schema(&workspace_schemas.join("default"), "default", "1.0.0");
668
669            let reg =
670                SchemaRegistry::load_for_workspace(Some(tmp.path()), Some(&workspace_schemas))
671                    .expect("workspace override loads");
672
673            let schema = reg
674                .get("default", &semver::Version::new(1, 0, 0))
675                .expect("default@1.0.0 still registered");
676            // Builtin ships 10 types; the workspace override carries 1.
677            assert_eq!(
678                schema.types.len(),
679                1,
680                "workspace-level schema must replace the builtin shape"
681            );
682        }
683
684        #[test]
685        fn workspace_cache_path_is_workspace_wide() {
686            // The cache lives at `<workspace_root>/.memstead.cache/schemas/`
687            // — never under any mem directory.
688            let tmp = TempDir::new().unwrap();
689            let cache_dir = tmp.path().join(".memstead.cache/schemas/recipe-1.0.0");
690            write_schema(&cache_dir, "recipe", "1.0.0");
691
692            let reg = SchemaRegistry::load_for_workspace(Some(tmp.path()), None).unwrap();
693            assert!(
694                reg.get("recipe", &semver::Version::new(1, 0, 0)).is_some(),
695                "workspace-wide cache schema must register"
696            );
697        }
698
699        #[test]
700        fn schema_cache_collision_yields_error() {
701            // Two cache directories sharing the same `(name, version)` key
702            // are a bug — surface the collision instead of silently
703            // overwriting one with the other.
704            let tmp = TempDir::new().unwrap();
705            let first = tmp.path().join(".memstead.cache/schemas/dup-a");
706            let second = tmp.path().join(".memstead.cache/schemas/dup-b");
707            write_schema(&first, "shared", "1.0.0");
708            write_schema(&second, "shared", "1.0.0");
709
710            let err = SchemaRegistry::load_for_workspace(Some(tmp.path()), None).unwrap_err();
711            assert!(
712                matches!(err, WorkspaceSchemaLoadError::CacheCollision { .. }),
713                "expected CacheCollision, got {err:?}"
714            );
715        }
716
717        #[test]
718        fn three_level_chain_resolves_correctly() {
719            // Workspace > builtins > cache. Place a unique schema at
720            // each level and confirm fall-through and override semantics.
721            let tmp = TempDir::new().unwrap();
722            let workspace_schemas = tmp.path().join("schemas");
723
724            // Cache only — falls through to it when nothing else matches.
725            let cache_only = tmp.path().join(".memstead.cache/schemas/cache-only-1.0.0");
726            write_schema(&cache_only, "cache-only", "1.0.0");
727
728            // Workspace overrides builtin default.
729            write_schema(&workspace_schemas.join("default"), "default", "1.0.0");
730
731            // Workspace also overrides the cache at the same key.
732            let cache_overridden = tmp.path().join(".memstead.cache/schemas/overridden-1.0.0");
733            write_schema(&cache_overridden, "overridden", "1.0.0");
734            write_schema(&workspace_schemas.join("overridden"), "overridden", "1.0.0");
735
736            let reg =
737                SchemaRegistry::load_for_workspace(Some(tmp.path()), Some(&workspace_schemas))
738                    .unwrap();
739
740            assert!(
741                reg.get("cache-only", &semver::Version::new(1, 0, 0))
742                    .is_some(),
743                "cache-only schema must register via cache layer"
744            );
745            // Builtin had 10 types; workspace override has 1.
746            assert_eq!(
747                reg.get("default", &semver::Version::new(1, 0, 0))
748                    .expect("default registered")
749                    .types
750                    .len(),
751                1,
752                "workspace-level default must override the 10-type builtin"
753            );
754            assert!(
755                reg.get("overridden", &semver::Version::new(1, 0, 0))
756                    .is_some(),
757                "workspace-level schema overrides the cache copy at the same key"
758            );
759        }
760
761        #[test]
762        fn unknown_name_resolves_to_none() {
763            let tmp = TempDir::new().unwrap();
764            let reg = SchemaRegistry::load_for_workspace(Some(tmp.path()), None).unwrap();
765            assert!(reg.resolve_by_name("does-not-exist").unwrap().is_none());
766        }
767
768        #[test]
769        fn ambiguous_name_surfaces_versions() {
770            let tmp = TempDir::new().unwrap();
771            let workspace_schemas = tmp.path().join("schemas");
772
773            // Two different versions of the same schema name registered
774            // under two different directory names so both get loaded.
775            // Uses a name that does not collide with any builtin
776            // schema (those would add a third version).
777            write_schema(
778                &workspace_schemas.join("test-ambig-1"),
779                "test-ambig",
780                "1.0.0",
781            );
782            write_schema(
783                &workspace_schemas.join("test-ambig-2"),
784                "test-ambig",
785                "2.0.0",
786            );
787
788            let reg =
789                SchemaRegistry::load_for_workspace(Some(tmp.path()), Some(&workspace_schemas))
790                    .unwrap();
791            let err = reg
792                .resolve_by_name("test-ambig")
793                .expect_err("two versions under same name must be ambiguous");
794            assert_eq!(err.versions.len(), 2);
795            assert!(err.versions.iter().any(|v| v == "1.0.0"));
796            assert!(err.versions.iter().any(|v| v == "2.0.0"));
797            let msg = format!("{err}");
798            assert!(msg.contains("ambiguous"));
799            assert!(msg.contains("1.0.0"));
800        }
801    }
802}