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