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