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