Skip to main content

memstead_schema/
lib.rs

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