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