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