Skip to main content

schema_validator/
catalog.rs

1//! A centralized catalog of rule-engine–visible metadata derived from the
2//! **final merged** [`CompiledSchemaStore`] for only the resource types that
3//! were touched by at least one overlay.
4//!
5//! The catalog gives Rego and CEL engines overlay-aware data without coupling
6//! them to the schema-validator internals or paying the cost of deriving it on
7//! every validation call. It is built once when overlays are applied and carried
8//! into the engines by reference.
9
10use crate::compiled::{CompiledSchema, PropSchema};
11use data_source::types::{
12    SchemaMetadataCatalog, SchemaMetadataEntry, SchemaPropertyConstraints, primary_identifier_override,
13};
14use std::collections::{HashMap, HashSet};
15use std::sync::LazyLock;
16
17/// Hand-maintained corrections for the type `Fn::GetAtt` actually returns
18/// where it differs from the raw schema property type (CloudFormation
19/// stringifies many GetAtt values). Applied after every runtime derivation so
20/// an overlay touching a corrected type cannot regress the correction the
21/// build pipeline bakes into the bundled artifacts.
22pub(crate) static GETATT_RETURN_TYPE_OVERRIDES: LazyLock<HashMap<String, HashMap<String, String>>> =
23    LazyLock::new(|| {
24        let artifact: serde_json::Value =
25            serde_json::from_slice(&data_source::embedded::GETATT_RETURN_TYPE_OVERRIDES_BYTES)
26                .expect("Embedded getatt_return_type_overrides must be valid JSON");
27        let overrides = artifact
28            .get("getatt_return_type_overrides")
29            .expect("Embedded getatt_return_type_overrides must contain getatt_return_type_overrides");
30        let overrides: HashMap<String, HashMap<String, String>> = serde_json::from_value(overrides.clone())
31            .expect("Embedded getatt_return_type_overrides must contain a valid override map");
32        assert!(!overrides.is_empty(), "Embedded getatt_return_type_overrides must not be empty");
33        overrides
34    });
35
36/// Applies the hand-maintained GetAtt return-type corrections for `type_name`
37/// over a freshly derived attribute-type map.
38fn apply_getatt_return_type_overrides(type_name: &str, attr_types: &mut HashMap<String, String>) {
39    if let Some(corrections) = GETATT_RETURN_TYPE_OVERRIDES.get(type_name) {
40        for (attribute, return_type) in corrections {
41            if attr_types.contains_key(attribute) {
42                attr_types.insert(attribute.clone(), return_type.clone());
43            }
44        }
45    }
46}
47
48/// Catalog of rule-engine metadata for overlaid resource types.
49///
50/// All vectors are sorted and deduplicated. The catalog is derived from the
51/// *merged* store, so it reflects the bundled schema with overlays on top -
52/// exactly what the schema validator sees.
53#[doc(hidden)]
54#[derive(Debug, Clone, Default)]
55pub struct OverlayCatalog {
56    /// Sorted, deduplicated type names that had at least one overlay applied.
57    pub type_names: Vec<String>,
58    /// GetAtt attribute names per resource type (sorted/deduped readOnly paths).
59    pub getatt_attributes: HashMap<String, Vec<String>>,
60    /// GetAtt attribute return types per resource type: includes resolved types
61    /// for ALL top-level properties, plus full-path readOnly attributes where
62    /// the type is resolvable.
63    pub getatt_attribute_types: HashMap<String, HashMap<String, String>>,
64    /// Primary identifier property names per resource type. Excludes the entry
65    /// entirely if any primary path is readOnly; includes only non-nested root
66    /// property names.
67    pub primary_identifiers: HashMap<String, Vec<String>>,
68    /// Ref return type per resource type (derived from primary identifier types).
69    /// No entry when primaryIdentifier is empty; "string" when multiple, readOnly,
70    /// or unresolvable; otherwise the resolved single property type.
71    pub ref_returns: HashMap<String, String>,
72    /// Per-type schema metadata for the overlaid resource types, in the same
73    /// typed model the schema validator and both rule engines share. An overlay
74    /// entry replaces the base catalog entry for the type it touches.
75    pub schema_metadata: SchemaMetadataCatalog,
76}
77
78impl OverlayCatalog {
79    /// Whether the catalog is empty (no overlays contributed anything).
80    pub fn is_empty(&self) -> bool {
81        self.type_names.is_empty()
82    }
83
84    /// Derive the catalog from a compiled schema store for the given set of
85    /// overlaid type names. Only the named types are processed.
86    pub fn from_store(store: &crate::store::CompiledSchemaStore, overlaid_type_names: &[String]) -> Self {
87        if overlaid_type_names.is_empty() {
88            return Self::default();
89        }
90
91        let mut type_names: Vec<String> = overlaid_type_names.to_vec();
92        type_names.sort();
93        type_names.dedup();
94
95        let mut getatt_attributes: HashMap<String, Vec<String>> = HashMap::new();
96        let mut getatt_attribute_types: HashMap<String, HashMap<String, String>> = HashMap::new();
97        let mut primary_identifiers: HashMap<String, Vec<String>> = HashMap::new();
98        let mut ref_returns: HashMap<String, String> = HashMap::new();
99        let mut schema_metadata: HashMap<String, SchemaMetadataEntry> = HashMap::new();
100
101        for type_name in &type_names {
102            let Some(schema) = store.get(type_name) else {
103                continue;
104            };
105
106            // GetAtt attributes from read_only_properties (sorted/deduped paths)
107            let attrs = derive_getatt_attributes(schema);
108            if !attrs.is_empty() {
109                getatt_attributes.insert(type_name.clone(), attrs.clone());
110            }
111
112            let attr_types = derive_getatt_attribute_types(schema);
113            if !attr_types.is_empty() {
114                getatt_attribute_types.insert(type_name.clone(), attr_types);
115            }
116
117            // Primary identifiers: exclude the whole entry if any primary path
118            // is readOnly; include only non-nested root property names.
119            let read_only_set: HashSet<&str> = schema.read_only_properties.iter().map(|s| s.as_str()).collect();
120            if let Some(pids) = derive_primary_identifiers(schema, &read_only_set) {
121                primary_identifiers.insert(type_name.clone(), pids);
122            }
123
124            // Ref return type: no entry when primaryIdentifier is empty;
125            // "string" when multiple, readOnly, or unresolvable; otherwise
126            // the resolved single property type.
127            if let Some(ref_type) = derive_ref_return_type(schema, &read_only_set) {
128                ref_returns.insert(type_name.clone(), ref_type);
129            }
130
131            // Schema metadata for the engines
132            let entry = derive_schema_metadata(schema);
133            schema_metadata.insert(type_name.clone(), entry);
134        }
135
136        OverlayCatalog {
137            type_names,
138            getatt_attributes,
139            getatt_attribute_types,
140            primary_identifiers,
141            ref_returns,
142            schema_metadata,
143        }
144    }
145}
146
147/// Derive GetAtt attribute names from `read_only_properties`.
148///
149/// Each entry in `read_only_properties` is a dot-delimited path like
150/// `"Arn"` or `"Nested.Id"` (the `/properties/` prefix is stripped during
151/// compilation). GetAtt attributes are the full dot paths - matching how
152/// `Fn::GetAtt` uses them. Result is sorted and deduplicated.
153fn derive_getatt_attributes(schema: &CompiledSchema) -> Vec<String> {
154    let mut attrs: Vec<String> = schema.read_only_properties.clone();
155    attrs.sort();
156    attrs.dedup();
157    attrs
158}
159
160/// Derive GetAtt attribute return types, matching the build-time generator:
161/// - The resolved primary type of every top-level property (output string-type
162///   and property type-mismatch checks look attributes up here)
163/// - Every nested readOnly attribute path (such as `Endpoint.Address`) whose
164///   type resolves
165/// - The hand-maintained return-type corrections applied last, so an overlay
166///   cannot regress them
167pub(crate) fn derive_getatt_attribute_types(schema: &CompiledSchema) -> HashMap<String, String> {
168    let mut types = HashMap::new();
169
170    for (name, prop) in &schema.properties {
171        let resolved = prop.resolve(&schema.definitions);
172        if let Some(pt) = resolved.prop_type.as_ref().and_then(|p| p.primary()) {
173            types.insert(name.clone(), pt.to_string());
174        }
175    }
176
177    for attr in &schema.read_only_properties {
178        if attr.contains('.')
179            && let Some(prop_type) = resolve_property_type(schema, attr)
180        {
181            types.insert(attr.clone(), prop_type);
182        }
183    }
184
185    apply_getatt_return_type_overrides(&schema.type_name, &mut types);
186    types
187}
188
189/// Derive primary identifier property names, matching build-time semantics:
190/// - A type in the shared override table uses its overriding properties
191/// - Exclude the whole entry if any primary path is readOnly
192/// - Include only non-nested root property names
193/// - Returns None if the schema has no primaryIdentifier or should be excluded
194fn derive_primary_identifiers(schema: &CompiledSchema, read_only_set: &HashSet<&str>) -> Option<Vec<String>> {
195    if let Some(overridden) = primary_identifier_override(&schema.type_name) {
196        return Some(overridden);
197    }
198    if schema.primary_identifier.is_empty() {
199        return None;
200    }
201
202    // Skip if any primary ID path is readOnly (service-generated)
203    if schema.primary_identifier.iter().any(|p| read_only_set.contains(p.as_str())) {
204        return None;
205    }
206
207    // Only non-nested root property names
208    let props: Vec<String> = schema
209        .primary_identifier
210        .iter()
211        .filter_map(|p| {
212            // Skip nested paths
213            if p.contains('.') {
214                return None;
215            }
216            Some(p.clone())
217        })
218        .collect();
219
220    // Must have the same count as primaryIdentifier (all paths must be valid roots)
221    if props.is_empty() || props.len() != schema.primary_identifier.len() {
222        return None;
223    }
224
225    Some(props)
226}
227
228/// Derive the Ref return type from the primary identifier's property type.
229///
230/// - No entry when primaryIdentifier is empty
231/// - "string" when multiple identifiers, readOnly, or unresolvable
232/// - Otherwise the resolved single property type
233pub(crate) fn derive_ref_return_type(schema: &CompiledSchema, read_only_set: &HashSet<&str>) -> Option<String> {
234    if schema.primary_identifier.is_empty() {
235        return None;
236    }
237
238    // Multiple primary identifiers → always "string"
239    if schema.primary_identifier.len() > 1 {
240        return Some("string".to_string());
241    }
242
243    // Single primary identifier that is readOnly → "string"
244    let id_prop = &schema.primary_identifier[0];
245    if read_only_set.contains(id_prop.as_str()) {
246        return Some("string".to_string());
247    }
248
249    // Single primary identifier - resolve type or fall back to "string"
250    match resolve_property_type(schema, id_prop) {
251        Some(t) => Some(t),
252        None => Some("string".to_string()),
253    }
254}
255
256/// Resolve the type of a property along a dot-separated path, following `$ref`
257/// chains. Returns `None` for an empty path and guards against recursive
258/// references.
259pub(crate) fn resolve_property_type(schema: &CompiledSchema, path: &str) -> Option<String> {
260    if path.is_empty() {
261        return None;
262    }
263    let parts: Vec<&str> = path.split('.').collect();
264    resolve_nested_property_type(schema, &parts, &schema.properties, &mut HashSet::new())
265}
266
267/// Helper to resolve the type of a nested property path starting from a given
268/// properties map, with cycle detection for recursive definitions.
269fn resolve_nested_property_type(
270    schema: &CompiledSchema,
271    remaining_parts: &[&str],
272    properties: &HashMap<String, PropSchema>,
273    visited_refs: &mut HashSet<String>,
274) -> Option<String> {
275    if remaining_parts.is_empty() {
276        return None;
277    }
278    let prop = properties.get(remaining_parts[0])?;
279
280    // Track $ref to guard against cycles
281    if let Some(ref ref_name) = prop.ref_name {
282        if visited_refs.contains(ref_name) {
283            return None;
284        }
285        visited_refs.insert(ref_name.clone());
286    }
287
288    let resolved = prop.resolve(&schema.definitions);
289    if remaining_parts.len() == 1 {
290        return resolved.prop_type.as_ref().and_then(|pt| pt.primary()).map(|s| s.to_string());
291    }
292    resolve_nested_property_type(schema, &remaining_parts[1..], &resolved.properties, visited_refs)
293}
294
295/// Build the schema metadata entry for a resource type in the shared typed
296/// model: the type's top-level properties, each with its primary type, allowed
297/// values, and own scalar constraints. Matches the build-time `process.rs`
298/// output field for field, which the parity test below enforces.
299fn derive_schema_metadata(schema: &CompiledSchema) -> SchemaMetadataEntry {
300    let mut properties: Vec<String> = schema.properties.keys().cloned().collect();
301    properties.sort();
302
303    let mut property_types: HashMap<String, String> = HashMap::new();
304    let mut property_enums: HashMap<String, Vec<serde_json::Value>> = HashMap::new();
305    let mut property_constraints: HashMap<String, SchemaPropertyConstraints> = HashMap::new();
306
307    for (name, prop) in &schema.properties {
308        let resolved = prop.resolve(&schema.definitions);
309        if let Some(pt) = resolved.prop_type.as_ref().and_then(|p| p.primary()) {
310            property_types.insert(name.clone(), pt.to_string());
311        }
312        // `enum` and `enumCaseInsensitive` are one value set in two comparison
313        // modes; a property carries one of them.
314        if !resolved.enum_values.is_empty() {
315            property_enums.insert(name.clone(), resolved.enum_values.clone());
316        } else if !resolved.enum_case_insensitive.is_empty() {
317            property_enums.insert(name.clone(), resolved.enum_case_insensitive.clone());
318        }
319        if let Some(constraint) = build_property_constraint(&resolved) {
320            property_constraints.insert(name.clone(), constraint);
321        }
322    }
323
324    SchemaMetadataEntry {
325        properties,
326        required: schema.required.clone(),
327        property_types,
328        property_enums,
329        property_constraints,
330        dependent_required: schema.dependent_required.clone(),
331        dependent_excluded: schema.dependent_excluded.clone(),
332        required_or: schema.required_or.clone(),
333        required_xor: schema.required_xor.clone(),
334        ..Default::default()
335    }
336}
337
338/// Convert a schema `minimum`/`maximum` (stored as `f64`) into the JSON number
339/// form the build-time artifact records: a whole value becomes an integer, and
340/// any other finite value keeps its decimal form. The integer conversion
341/// saturates at the `i64` range, which is how the artifact's largest bound
342/// (`i64::MAX`, not exactly representable as `f64`) reads back as itself.
343/// Returns `None` only for a non-finite value, which a JSON-sourced bound can
344/// never be.
345fn f64_to_number(value: f64) -> Option<serde_json::Number> {
346    if value.fract() == 0.0 && value >= i64::MIN as f64 && value <= i64::MAX as f64 {
347        Some(serde_json::Number::from(value as i64))
348    } else {
349        serde_json::Number::from_f64(value)
350    }
351}
352
353/// Build the typed constraints for a resolved property schema, or `None` when
354/// the property states no scalar constraint of its own. Matches the build-time
355/// `extract_property_constraints` in process.rs.
356fn build_property_constraint(prop: &PropSchema) -> Option<SchemaPropertyConstraints> {
357    let constraints = SchemaPropertyConstraints {
358        pattern: prop.pattern.clone(),
359        minimum: prop.minimum.and_then(f64_to_number),
360        maximum: prop.maximum.and_then(f64_to_number),
361        min_length: prop.min_length,
362        max_length: prop.max_length,
363        min_items: prop.min_items,
364        max_items: prop.max_items,
365        format: prop.format.clone(),
366        unique_items: (prop.unique_items == Some(true)).then_some(true),
367        additional: Default::default(),
368    };
369    let SchemaPropertyConstraints {
370        pattern,
371        minimum,
372        maximum,
373        min_length,
374        max_length,
375        min_items,
376        max_items,
377        format,
378        unique_items,
379        additional: _,
380    } = &constraints;
381    let any = pattern.is_some()
382        || minimum.is_some()
383        || maximum.is_some()
384        || min_length.is_some()
385        || max_length.is_some()
386        || min_items.is_some()
387        || max_items.is_some()
388        || format.is_some()
389        || unique_items.is_some();
390    any.then_some(constraints)
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use crate::compiled::PropType;
397    use crate::store::CompiledSchemaStore;
398    use serde_json::json;
399
400    #[test]
401    fn resolve_property_type_returns_none_for_empty_path() {
402        let schema = CompiledSchema {
403            properties: HashMap::from([(
404                String::new(),
405                PropSchema { prop_type: Some(PropType::Single("string".into())), ..Default::default() },
406            )]),
407            ..Default::default()
408        };
409
410        assert_eq!(
411            resolve_property_type(&schema, ""),
412            None,
413            "an empty path must not resolve an empty-string property name"
414        );
415    }
416
417    #[test]
418    fn empty_overlay_produces_empty_catalog() {
419        let catalog = OverlayCatalog::from_store(&CompiledSchemaStore::new(), &[]);
420        assert!(catalog.is_empty());
421        assert!(catalog.type_names.is_empty());
422    }
423
424    #[test]
425    fn catalog_for_existing_type_extracts_getatt_and_primary_id() {
426        let store = CompiledSchemaStore::new();
427        let catalog = OverlayCatalog::from_store(&store, &["AWS::S3::Bucket".to_string()]);
428        assert_eq!(catalog.type_names, vec!["AWS::S3::Bucket".to_string()]);
429        assert!(
430            catalog.getatt_attributes.contains_key("AWS::S3::Bucket"),
431            "S3 Bucket must have GetAtt attributes derived from read_only_properties"
432        );
433        // Attribute types must include ALL top-level properties
434        let attr_types = catalog.getatt_attribute_types.get("AWS::S3::Bucket").expect("must have attr types");
435        assert!(
436            attr_types.len() > catalog.getatt_attributes.get("AWS::S3::Bucket").unwrap().len(),
437            "attribute types must include more entries than just readOnly attributes"
438        );
439    }
440
441    #[test]
442    fn catalog_type_names_are_sorted_and_deduped() {
443        let store = CompiledSchemaStore::new();
444        let catalog = OverlayCatalog::from_store(
445            &store,
446            &["AWS::Lambda::Function".to_string(), "AWS::S3::Bucket".to_string(), "AWS::Lambda::Function".to_string()],
447        );
448        assert_eq!(catalog.type_names, vec!["AWS::Lambda::Function".to_string(), "AWS::S3::Bucket".to_string()]);
449    }
450
451    #[test]
452    fn catalog_schema_metadata_includes_properties_and_types() {
453        let store = CompiledSchemaStore::new();
454        let catalog = OverlayCatalog::from_store(&store, &["AWS::S3::Bucket".to_string()]);
455        let entry = catalog.schema_metadata.get("AWS::S3::Bucket").expect("S3 metadata must exist");
456        assert!(!entry.properties.is_empty(), "S3 Bucket must have properties");
457        assert!(!entry.property_types.is_empty(), "S3 Bucket must have property types");
458    }
459
460    #[test]
461    fn catalog_for_unknown_type_is_skipped() {
462        let store = CompiledSchemaStore::new();
463        let catalog = OverlayCatalog::from_store(&store, &["AWS::Fake::NotExist".to_string()]);
464        assert_eq!(catalog.type_names, vec!["AWS::Fake::NotExist".to_string()]);
465        assert!(!catalog.getatt_attributes.contains_key("AWS::Fake::NotExist"));
466        assert!(!catalog.schema_metadata.contains_key("AWS::Fake::NotExist"));
467    }
468
469    #[test]
470    fn catalog_derives_overlay_only_type_metadata() {
471        let mut store = CompiledSchemaStore::new();
472        store
473            .apply_overlay(
474                "AWS::Test::OverlayOnly",
475                &json!({
476                    "properties": {
477                        "Name": { "type": "string", "minLength": 1, "maxLength": 64 },
478                        "Count": { "type": "integer", "enum": [1, 2, 3] },
479                        "Arn": { "type": "string" }
480                    },
481                    "required": ["Name"],
482                    "readOnlyProperties": ["/properties/Arn"],
483                    "primaryIdentifier": ["/properties/Name"],
484                    "additionalProperties": false
485                }),
486            )
487            .expect("overlay applies");
488
489        let catalog = OverlayCatalog::from_store(&store, &["AWS::Test::OverlayOnly".to_string()]);
490
491        // GetAtt attributes (readOnly only)
492        let attrs = catalog.getatt_attributes.get("AWS::Test::OverlayOnly").expect("attrs");
493        assert_eq!(attrs, &vec!["Arn".to_string()]);
494
495        // GetAtt attribute types - ALL top-level properties
496        let attr_types = catalog.getatt_attribute_types.get("AWS::Test::OverlayOnly").expect("attr types");
497        assert_eq!(attr_types.get("Arn"), Some(&"string".to_string()));
498        assert_eq!(attr_types.get("Name"), Some(&"string".to_string()));
499        assert_eq!(attr_types.get("Count"), Some(&"integer".to_string()));
500
501        // Primary identifier (Name is not readOnly, non-nested)
502        let pids = catalog.primary_identifiers.get("AWS::Test::OverlayOnly").expect("pids");
503        assert_eq!(pids, &vec!["Name".to_string()]);
504
505        // Ref return type - single string primary identifier
506        assert_eq!(catalog.ref_returns.get("AWS::Test::OverlayOnly"), Some(&"string".to_string()));
507
508        // Schema metadata
509        let meta = catalog.schema_metadata.get("AWS::Test::OverlayOnly").expect("schema metadata");
510        assert!(meta.properties.contains(&"Name".to_string()));
511        assert!(meta.properties.contains(&"Count".to_string()));
512        assert_eq!(meta.required, vec!["Name".to_string()]);
513        assert_eq!(meta.property_types.get("Name"), Some(&"string".to_string()));
514        assert_eq!(meta.property_types.get("Count"), Some(&"integer".to_string()));
515        assert_eq!(meta.property_enums.get("Count"), Some(&vec![json!(1), json!(2), json!(3)]));
516
517        // Constraints for Name
518        let name_constraints = meta.property_constraints.get("Name").expect("Name constraints");
519        assert_eq!(name_constraints.min_length, Some(1));
520        assert_eq!(name_constraints.max_length, Some(64));
521    }
522
523    #[test]
524    fn catalog_readonly_primary_excludes_entry() {
525        let mut store = CompiledSchemaStore::new();
526        store
527            .apply_overlay(
528                "AWS::Test::ReadOnlyPrimary",
529                &json!({
530                    "properties": {
531                        "Id": { "type": "string" },
532                        "Name": { "type": "string" }
533                    },
534                    "readOnlyProperties": ["/properties/Id"],
535                    "primaryIdentifier": ["/properties/Id"]
536                }),
537            )
538            .expect("overlay applies");
539
540        let catalog = OverlayCatalog::from_store(&store, &["AWS::Test::ReadOnlyPrimary".to_string()]);
541
542        // Should have no primary_identifiers entry - the primary ID is readOnly
543        assert!(
544            !catalog.primary_identifiers.contains_key("AWS::Test::ReadOnlyPrimary"),
545            "readOnly primary identifier must exclude the whole entry"
546        );
547        // Ref returns should be "string" - the schema has a primary identifier
548        // but it's readOnly (matches build-time semantics)
549        assert_eq!(
550            catalog.ref_returns.get("AWS::Test::ReadOnlyPrimary"),
551            Some(&"string".to_string()),
552            "readOnly primary identifier produces 'string' ref type"
553        );
554    }
555
556    #[test]
557    fn catalog_no_primary_no_ref_returns_entry() {
558        let mut store = CompiledSchemaStore::new();
559        store
560            .apply_overlay(
561                "AWS::Test::NoPrimary",
562                &json!({
563                    "properties": {
564                        "Name": { "type": "string" }
565                    }
566                }),
567            )
568            .expect("overlay applies");
569
570        let catalog = OverlayCatalog::from_store(&store, &["AWS::Test::NoPrimary".to_string()]);
571
572        assert!(!catalog.primary_identifiers.contains_key("AWS::Test::NoPrimary"));
573        assert!(
574            !catalog.ref_returns.contains_key("AWS::Test::NoPrimary"),
575            "no ref_returns entry when primaryIdentifier is empty"
576        );
577    }
578
579    #[test]
580    fn catalog_all_property_getatt_types() {
581        let mut store = CompiledSchemaStore::new();
582        store
583            .apply_overlay(
584                "AWS::Test::AllProps",
585                &json!({
586                    "properties": {
587                        "Name": { "type": "string" },
588                        "Port": { "type": "integer" },
589                        "Tags": { "type": "array" },
590                        "Config": { "type": "object" }
591                    },
592                    "readOnlyProperties": ["/properties/Name"]
593                }),
594            )
595            .expect("overlay applies");
596
597        let catalog = OverlayCatalog::from_store(&store, &["AWS::Test::AllProps".to_string()]);
598
599        let attr_types = catalog.getatt_attribute_types.get("AWS::Test::AllProps").expect("attr types");
600        assert_eq!(attr_types.get("Name"), Some(&"string".to_string()));
601        assert_eq!(attr_types.get("Port"), Some(&"integer".to_string()));
602        assert_eq!(attr_types.get("Tags"), Some(&"array".to_string()));
603        assert_eq!(attr_types.get("Config"), Some(&"object".to_string()));
604    }
605
606    #[test]
607    fn catalog_metadata_describes_top_level_properties_only() {
608        let mut store = CompiledSchemaStore::new();
609        store
610            .apply_overlay(
611                "AWS::Test::Nested",
612                &json!({
613                    "properties": {
614                        "Config": {
615                            "type": "object",
616                            "properties": {
617                                "Name": { "type": "string", "maxLength": 32 },
618                                "Port": { "type": "integer", "minimum": 1 }
619                            },
620                            "required": ["Name"],
621                            "dependentRequired": { "Name": ["Port"] }
622                        },
623                        "Items": {
624                            "type": "array",
625                            "minItems": 1,
626                            "uniqueItems": true,
627                            "items": {
628                                "type": "object",
629                                "properties": {
630                                    "Key": { "type": "string" },
631                                    "Value": { "type": "string" }
632                                },
633                                "required": ["Key"]
634                            }
635                        },
636                        "Document": { "type": ["object", "string"] },
637                        "Mode": { "type": "string", "enumCaseInsensitive": ["fast", "slow"] },
638                        "Ratio": { "type": "number", "minimum": 0.5, "maximum": 2.0 }
639                    },
640                    "additionalProperties": false
641                }),
642            )
643            .expect("overlay applies");
644
645        let catalog = OverlayCatalog::from_store(&store, &["AWS::Test::Nested".to_string()]);
646        let meta = catalog.schema_metadata.get("AWS::Test::Nested").expect("metadata");
647
648        // A nested object states no scalar constraint of its own, so it has no
649        // constraint entry; its sub-properties and dependency groups belong to the
650        // schema validator, not the metadata.
651        assert_eq!(meta.property_types.get("Config"), Some(&"object".to_string()));
652        assert!(!meta.property_constraints.contains_key("Config"), "nested shape must not be repeated");
653        assert!(meta.dependent_required.is_empty(), "a nested dependency group is not lifted to the resource");
654
655        // An array records its own bounds and uniqueness, not its element schema.
656        let items_c = meta.property_constraints.get("Items").expect("Items constraints");
657        assert_eq!(items_c.min_items, Some(1));
658        assert_eq!(items_c.unique_items, Some(true));
659        assert!(items_c.additional.is_empty());
660
661        // A union type reports its first declared member; case-insensitive enum
662        // values are the property's allowed values; a whole-number bound written
663        // as a float reads back as an integer.
664        assert_eq!(meta.property_types.get("Document"), Some(&"object".to_string()));
665        assert_eq!(meta.property_enums.get("Mode"), Some(&vec![json!("fast"), json!("slow")]));
666        let ratio_c = meta.property_constraints.get("Ratio").expect("Ratio constraints");
667        assert_eq!(ratio_c.minimum, Some(serde_json::Number::from_f64(0.5).expect("finite")));
668        assert_eq!(ratio_c.maximum, Some(serde_json::Number::from(2)));
669    }
670
671    #[test]
672    fn catalog_recursive_refs_terminate() {
673        let mut store = CompiledSchemaStore::new();
674        store
675            .apply_overlay(
676                "AWS::Test::Recursive",
677                &json!({
678                    "properties": {
679                        "Root": { "$ref": "#/definitions/TreeNode" }
680                    },
681                    "definitions": {
682                        "TreeNode": {
683                            "type": "object",
684                            "properties": {
685                                "Value": { "type": "string" },
686                                "Children": {
687                                    "type": "array",
688                                    "items": { "$ref": "#/definitions/TreeNode" }
689                                }
690                            }
691                        }
692                    }
693                }),
694            )
695            .expect("overlay applies");
696
697        let catalog = OverlayCatalog::from_store(&store, &["AWS::Test::Recursive".to_string()]);
698        let meta = catalog.schema_metadata.get("AWS::Test::Recursive").expect("metadata");
699        // Must not panic from infinite recursion
700        assert!(meta.properties.contains(&"Root".to_string()));
701        assert_eq!(meta.property_types.get("Root"), Some(&"object".to_string()));
702    }
703
704    #[test]
705    fn catalog_ref_type_for_integer_primary_id() {
706        let mut store = CompiledSchemaStore::new();
707        store
708            .apply_overlay(
709                "AWS::Test::IntId",
710                &json!({
711                    "properties": {
712                        "Id": { "type": "integer" }
713                    },
714                    "primaryIdentifier": ["/properties/Id"]
715                }),
716            )
717            .expect("overlay applies");
718
719        let catalog = OverlayCatalog::from_store(&store, &["AWS::Test::IntId".to_string()]);
720        assert_eq!(catalog.ref_returns.get("AWS::Test::IntId"), Some(&"integer".to_string()));
721    }
722
723    #[test]
724    fn catalog_resolves_nested_ref_for_primary_id() {
725        let mut store = CompiledSchemaStore::new();
726        store
727            .apply_overlay(
728                "AWS::Test::NestedRef",
729                &json!({
730                    "properties": {
731                        "Id": { "$ref": "#/definitions/IdDef" }
732                    },
733                    "definitions": {
734                        "IdDef": { "type": "string", "pattern": "^[a-z]+$" }
735                    },
736                    "primaryIdentifier": ["/properties/Id"]
737                }),
738            )
739            .expect("overlay applies");
740
741        let catalog = OverlayCatalog::from_store(&store, &["AWS::Test::NestedRef".to_string()]);
742        assert_eq!(
743            catalog.ref_returns.get("AWS::Test::NestedRef"),
744            Some(&"string".to_string()),
745            "ref type must resolve through $ref chain"
746        );
747    }
748
749    #[test]
750    fn catalog_multiple_primary_ids_returns_string() {
751        let mut store = CompiledSchemaStore::new();
752        store
753            .apply_overlay(
754                "AWS::Test::MultiId",
755                &json!({
756                    "properties": {
757                        "Namespace": { "type": "string" },
758                        "Name": { "type": "string" }
759                    },
760                    "primaryIdentifier": ["/properties/Namespace", "/properties/Name"]
761                }),
762            )
763            .expect("overlay applies");
764
765        let catalog = OverlayCatalog::from_store(&store, &["AWS::Test::MultiId".to_string()]);
766        assert_eq!(
767            catalog.ref_returns.get("AWS::Test::MultiId"),
768            Some(&"string".to_string()),
769            "multiple primary identifiers must return 'string'"
770        );
771    }
772
773    #[test]
774    fn catalog_metadata_includes_format_constraint() {
775        let mut store = CompiledSchemaStore::new();
776        store
777            .apply_overlay(
778                "AWS::Test::Format",
779                &json!({
780                    "properties": {
781                        "Email": { "type": "string", "format": "email" },
782                        "Arn": { "type": "string", "format": "AWS::EC2::VPC.Id" }
783                    },
784                    "additionalProperties": false
785                }),
786            )
787            .expect("overlay applies");
788
789        let catalog = OverlayCatalog::from_store(&store, &["AWS::Test::Format".to_string()]);
790        let meta = catalog.schema_metadata.get("AWS::Test::Format").expect("metadata");
791        let email_c = meta.property_constraints.get("Email").expect("Email constraints");
792        assert_eq!(email_c.format.as_deref(), Some("email"));
793        let arn_c = meta.property_constraints.get("Arn").expect("Arn constraints");
794        assert_eq!(arn_c.format.as_deref(), Some("AWS::EC2::VPC.Id"));
795    }
796
797    #[test]
798    fn catalog_metadata_dependent_required_and_excluded() {
799        let mut store = CompiledSchemaStore::new();
800        store
801            .apply_overlay(
802                "AWS::Test::Deps",
803                &json!({
804                    "properties": {
805                        "Mode": { "type": "string" },
806                        "Config": { "type": "object" },
807                        "Legacy": { "type": "boolean" }
808                    },
809                    "dependentRequired": { "Mode": ["Config"] },
810                    "dependentExcluded": { "Mode": ["Legacy"] },
811                    "additionalProperties": false
812                }),
813            )
814            .expect("overlay applies");
815
816        let catalog = OverlayCatalog::from_store(&store, &["AWS::Test::Deps".to_string()]);
817        let meta = catalog.schema_metadata.get("AWS::Test::Deps").expect("metadata");
818        assert_eq!(meta.dependent_required.get("Mode"), Some(&vec!["Config".to_string()]));
819        assert_eq!(meta.dependent_excluded.get("Mode"), Some(&vec!["Legacy".to_string()]));
820    }
821
822    #[test]
823    fn catalog_metadata_required_or_xor() {
824        let mut store = CompiledSchemaStore::new();
825        store
826            .apply_overlay(
827                "AWS::Test::OrXor",
828                &json!({
829                    "properties": {
830                        "A": { "type": "string" },
831                        "B": { "type": "string" },
832                        "C": { "type": "string" }
833                    },
834                    "requiredOr": ["A", "B"],
835                    "requiredXor": ["B", "C"],
836                    "additionalProperties": false
837                }),
838            )
839            .expect("overlay applies");
840
841        let catalog = OverlayCatalog::from_store(&store, &["AWS::Test::OrXor".to_string()]);
842        let meta = catalog.schema_metadata.get("AWS::Test::OrXor").expect("metadata");
843        assert_eq!(meta.required_or, vec!["A".to_string(), "B".to_string()]);
844        assert_eq!(meta.required_xor, vec!["B".to_string(), "C".to_string()]);
845    }
846
847    /// The runtime derivation (from `CompiledSchema`) and the committed artifact
848    /// (built by `process.rs` from the raw provider schemas) are two paths to the
849    /// same metadata. An overlaid type replaces its artifact entry with the derived
850    /// one, so the two must agree exactly on every bundled type: property names,
851    /// required lists, primary types, allowed values, scalar constraints, and
852    /// dependency groups. Order-insensitive fields are compared as sets so map
853    /// iteration order cannot cause a spurious mismatch.
854    #[test]
855    fn full_catalog_derivation_matches_committed_artifact() {
856        let document: data_source::types::SchemaMetadataDocument =
857            serde_json::from_slice(&data_source::embedded::SCHEMA_METADATA_BYTES).expect("committed artifact parses");
858        let store = CompiledSchemaStore::new();
859        let mut checked = 0usize;
860        let mut mismatches: Vec<String> = Vec::new();
861        for (type_name, artifact_entry) in &document.schema_metadata {
862            let Some(schema) = store.get(type_name) else {
863                mismatches.push(format!("{type_name}: in the artifact but not in the compiled store"));
864                continue;
865            };
866            let derived = derive_schema_metadata(schema);
867            checked += 1;
868
869            let mut report = |field: &str, artifact: &dyn std::fmt::Debug, derived: &dyn std::fmt::Debug| {
870                mismatches
871                    .push(format!("{type_name}: {field} diverges\n  artifact: {artifact:?}\n  derived:  {derived:?}"));
872            };
873            if derived.properties != artifact_entry.properties {
874                report("properties", &artifact_entry.properties, &derived.properties);
875            }
876            if derived.required != artifact_entry.required {
877                report("required", &artifact_entry.required, &derived.required);
878            }
879            if derived.property_types != artifact_entry.property_types {
880                report("property_types", &artifact_entry.property_types, &derived.property_types);
881            }
882            if derived.property_enums != artifact_entry.property_enums {
883                report("property_enums", &artifact_entry.property_enums, &derived.property_enums);
884            }
885            let artifact_constraints = serde_json::to_value(&artifact_entry.property_constraints).expect("serializes");
886            let derived_constraints = serde_json::to_value(&derived.property_constraints).expect("serializes");
887            if artifact_constraints != derived_constraints {
888                report("property_constraints", &artifact_constraints, &derived_constraints);
889            }
890            if derived.dependent_required != artifact_entry.dependent_required {
891                report("dependent_required", &artifact_entry.dependent_required, &derived.dependent_required);
892            }
893            if derived.dependent_excluded != artifact_entry.dependent_excluded {
894                report("dependent_excluded", &artifact_entry.dependent_excluded, &derived.dependent_excluded);
895            }
896            if derived.required_or != artifact_entry.required_or {
897                report("required_or", &artifact_entry.required_or, &derived.required_or);
898            }
899            if derived.required_xor != artifact_entry.required_xor {
900                report("required_xor", &artifact_entry.required_xor, &derived.required_xor);
901            }
902            assert!(derived.additional.is_empty(), "{type_name}: derived entry must not carry unmodeled fields");
903        }
904        assert_eq!(
905            checked,
906            store.len(),
907            "every compiled schema must have an artifact entry (artifact has {}, store has {})",
908            document.schema_metadata.len(),
909            store.len()
910        );
911        assert!(
912            mismatches.is_empty(),
913            "{} bundled type(s) derive differently from the committed artifact:\n{}",
914            mismatches.len(),
915            mismatches.join("\n")
916        );
917    }
918
919    /// The GetAtt, primary-identifier, and Ref-return catalogs the runtime derives
920    /// for an overlaid type must match what the build pipeline records for the
921    /// same bundled type, so applying an overlay never changes how an untouched
922    /// attribute or identifier is typed. GetAtt attribute *names* are compared as
923    /// a subset because the artifact also folds in the attributes CloudFormation
924    /// exposes beyond the schema's readOnly properties.
925    #[test]
926    fn full_catalog_derivation_matches_committed_getatt_primary_and_ref_artifacts() {
927        #[derive(serde::Deserialize)]
928        struct RefTypesArtifact {
929            ref_returns: HashMap<String, String>,
930        }
931        let getatt: data_source::types::GetattData =
932            serde_json::from_slice(&data_source::embedded::GETATT_ATTRIBUTES_BYTES).expect("getatt artifact parses");
933        let primary: data_source::types::PrimaryIdentifiers =
934            serde_json::from_slice(&data_source::embedded::PRIMARY_IDENTIFIERS_BYTES).expect("primary ids parse");
935        let refs: RefTypesArtifact =
936            serde_json::from_slice(&data_source::embedded::REF_TYPES_BYTES).expect("ref_types artifact parses");
937
938        let store = CompiledSchemaStore::new();
939        let type_names: Vec<String> = store.type_names().map(String::from).collect();
940        let catalog = OverlayCatalog::from_store(&store, &type_names);
941
942        let mut mismatches: Vec<String> = Vec::new();
943        for type_name in &type_names {
944            let artifact_attrs = getatt.getatt_attributes.get(type_name).cloned().unwrap_or_default();
945            for attr in catalog.getatt_attributes.get(type_name).into_iter().flatten() {
946                if !artifact_attrs.contains(attr) {
947                    mismatches
948                        .push(format!("{type_name}: derived GetAtt attribute '{attr}' missing from the artifact"));
949                }
950            }
951            let artifact_types = getatt.getatt_attribute_types.get(type_name).cloned().unwrap_or_default();
952            let derived_types = catalog.getatt_attribute_types.get(type_name).cloned().unwrap_or_default();
953            if artifact_types != derived_types {
954                mismatches.push(format!(
955                    "{type_name}: getatt_attribute_types diverge\n  artifact: {artifact_types:?}\n  derived:  {derived_types:?}"
956                ));
957            }
958            if primary.primary_identifiers.get(type_name) != catalog.primary_identifiers.get(type_name) {
959                mismatches.push(format!(
960                    "{type_name}: primary_identifiers diverge\n  artifact: {:?}\n  derived:  {:?}",
961                    primary.primary_identifiers.get(type_name),
962                    catalog.primary_identifiers.get(type_name)
963                ));
964            }
965            if refs.ref_returns.get(type_name) != catalog.ref_returns.get(type_name) {
966                mismatches.push(format!(
967                    "{type_name}: ref_returns diverge\n  artifact: {:?}\n  derived:  {:?}",
968                    refs.ref_returns.get(type_name),
969                    catalog.ref_returns.get(type_name)
970                ));
971            }
972        }
973        assert!(
974            mismatches.is_empty(),
975            "{} bundled type(s) derive differently from the committed artifacts:\n{}",
976            mismatches.len(),
977            mismatches.join("\n")
978        );
979    }
980}