Skip to main content

schema_validator/
store.rs

1use crate::compiled::CompiledSchema;
2use crate::overlay::{self, SchemaOverlayError};
3use data_source::embedded::*;
4use std::collections::{BTreeSet, HashMap};
5use template_model::regions::AWS_REGIONS;
6
7/// What applying an overlay did to the store.
8///
9/// An overlay whose type name matches no bundled schema is registered as a new
10/// resource type - the supported way to describe a type CloudFormation has not
11/// published yet - but it is also what a misspelled type name produces, so the
12/// distinction is reported rather than swallowed.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum OverlayOutcome {
15    /// Merged into the bundled schema for an existing resource type.
16    Merged,
17    /// Registered as a resource type the bundled schemas do not contain.
18    Inserted,
19}
20
21pub struct CompiledSchemaStore {
22    schemas: HashMap<String, CompiledSchema>,
23    region_types: HashMap<String, HashMap<String, bool>>,
24    ref_types: RefTypeStore,
25    lifecycle: LifecycleStore,
26    extensions: ExtensionStore,
27    region_enums: RegionEnumStore,
28}
29
30impl Default for CompiledSchemaStore {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36impl CompiledSchemaStore {
37    pub fn new() -> Self {
38        let schemas: HashMap<String, CompiledSchema> =
39            serde_json::from_slice(&COMPILED_SCHEMAS_BYTES).expect("Embedded compiled schemas must be valid JSON");
40        assert!(!schemas.is_empty(), "Embedded compiled schemas must not be empty");
41        let ref_types = RefTypeStore::load(&REF_TYPES_BYTES);
42        let lifecycle = LifecycleStore::load(&RESOURCE_LIFECYCLE_BYTES, &LAMBDA_RUNTIMES_BYTES);
43        let mut extensions = ExtensionStore::load(&EXTENSIONS_BYTES);
44        extensions.remap_keys(&schemas);
45        let region_enums =
46            RegionEnumStore::load(&REGION_ENUMS_BYTES).expect("Embedded region_enums data must be valid and populated");
47        let mut store = CompiledSchemaStore {
48            schemas,
49            region_types: HashMap::new(),
50            ref_types,
51            lifecycle,
52            extensions,
53            region_enums,
54        };
55        // Load the embedded per-region resource-type map so region-availability
56        // (F3006) validates against the target region. Without this the region
57        // check is dormant and unavailable types slip through.
58        store
59            .load_region_data(&REGION_RESOURCE_TYPES_BYTES)
60            .expect("Embedded region_resource_types data must be valid and populated");
61        assert!(
62            store.has_region_data(),
63            "Embedded region-availability data (region_resource_types) is empty; the build is missing regional data"
64        );
65        assert!(
66            store.region_enums.has_data(),
67            "Embedded regional enum data (region_enums) is empty; the build is missing regional data"
68        );
69        store
70    }
71
72    pub fn load_region_data(&mut self, json_bytes: &[u8]) -> Result<(), String> {
73        let wrapper: serde_json::Value = serde_json::from_slice(json_bytes)
74            .map_err(|error| format!("region_resource_types must be valid JSON: {error}"))?;
75        let regions = wrapper
76            .get("region_resource_types")
77            .and_then(|value| value.as_object())
78            .ok_or_else(|| "region_resource_types must contain a region_resource_types object".to_string())?;
79        if regions.is_empty() {
80            return Err("region_resource_types must not be empty".to_string());
81        }
82
83        let mut parsed_regions = HashMap::new();
84        for (region, types) in regions {
85            let type_object =
86                types.as_object().ok_or_else(|| format!("region_resource_types entry '{region}' must be an object"))?;
87            if type_object.is_empty() {
88                return Err(format!("region_resource_types entry '{region}' must not be empty"));
89            }
90            let type_map = type_object.keys().map(|resource_type| (resource_type.clone(), true)).collect();
91            parsed_regions.insert(region.clone(), type_map);
92        }
93        self.region_types.extend(parsed_regions);
94        Ok(())
95    }
96
97    pub fn get(&self, type_name: &str) -> Option<&CompiledSchema> {
98        self.schemas.get(type_name)
99    }
100
101    /// Merge an overlay CloudFormation resource provider schema (raw registry
102    /// JSON) into the store under `type_name`.
103    ///
104    /// The raw schema is compiled with the same transformation used at build time
105    /// and deep-merged into the bundled schema for that type; when no bundled
106    /// schema exists, the compiled overlay is registered as a new type. The
107    /// return value says which happened, so callers can report a `type_name` that
108    /// matched nothing. See the [`crate::overlay`] module for the merge
109    /// model and its scope limits.
110    ///
111    /// Input is validated before anything is committed - an empty type name,
112    /// non-object JSON, nesting past [`MAX_OVERLAY_DEPTH`](crate::overlay::MAX_OVERLAY_DEPTH),
113    /// a cyclic definition graph, or an overlay that would change nothing is an
114    /// error and leaves the store untouched. The merge therefore runs on a copy
115    /// of the bundled schema that is only installed once it validates; a partly
116    /// merged schema is never observable.
117    pub fn apply_overlay(
118        &mut self,
119        type_name: &str,
120        raw: &serde_json::Value,
121    ) -> Result<OverlayOutcome, SchemaOverlayError> {
122        let overlay = overlay::compile(type_name, raw)?;
123        overlay::validate_schema(&overlay)?;
124        match self.schemas.get(type_name) {
125            Some(existing) => {
126                let mut merged = existing.clone();
127                overlay::merge_into(&mut merged, overlay);
128                overlay::validate_schema(&merged)?;
129                overlay::warn_removed_required(existing, &merged);
130                overlay::warn_dangling_refs(&merged);
131                self.ref_types.update_from_schema(&merged);
132                self.schemas.insert(type_name.to_string(), merged);
133                Ok(OverlayOutcome::Merged)
134            }
135            None => {
136                overlay::warn_dangling_refs(&overlay);
137                self.ref_types.update_from_schema(&overlay);
138                self.schemas.insert(type_name.to_string(), overlay);
139                Ok(OverlayOutcome::Inserted)
140            }
141        }
142    }
143
144    /// Registers a schema directly, bypassing the embedded artifacts - lets
145    /// unit tests exercise validation against schema shapes the committed
146    /// artifacts do not yet contain.
147    #[cfg(test)]
148    pub(crate) fn insert_schema(&mut self, schema: CompiledSchema) {
149        self.schemas.insert(schema.type_name.clone(), schema);
150    }
151
152    pub fn len(&self) -> usize {
153        self.schemas.len()
154    }
155
156    pub fn is_available_in_region(&self, type_name: &str, region: &str) -> bool {
157        self.region_types.get(region).map(|types| types.contains_key(type_name)).unwrap_or(true)
158    }
159
160    /// Whether the type appears in at least one region's availability map. Used
161    /// to distinguish a genuine regional provider type (absent only in some
162    /// regions) from a type that is region-agnostic or not a provider type at
163    /// all (SAM/transform placeholders), which must not trigger the region check.
164    pub fn is_known_in_any_region(&self, type_name: &str) -> bool {
165        self.region_types.values().any(|types| types.contains_key(type_name))
166    }
167
168    pub fn has_region_data(&self) -> bool {
169        !self.region_types.is_empty()
170    }
171
172    pub fn ref_types(&self) -> &RefTypeStore {
173        &self.ref_types
174    }
175
176    pub fn lifecycle(&self) -> &LifecycleStore {
177        &self.lifecycle
178    }
179
180    pub fn extensions(&self) -> &ExtensionStore {
181        &self.extensions
182    }
183
184    pub fn region_enums(&self) -> &RegionEnumStore {
185        &self.region_enums
186    }
187}
188
189pub struct RefTypeStore {
190    ref_returns: HashMap<String, String>,
191    getatt_returns: HashMap<String, HashMap<String, String>>,
192    format_compatible_types: HashMap<String, Vec<String>>,
193}
194
195impl RefTypeStore {
196    fn load(bytes: &[u8]) -> Self {
197        let json: serde_json::Value = serde_json::from_slice(bytes).expect("Embedded ref_types must be valid JSON");
198        let ref_returns: HashMap<String, String> = json
199            .get("ref_returns")
200            .and_then(|v| serde_json::from_value(v.clone()).ok())
201            .expect("Embedded ref_types must contain ref_returns");
202        let getatt_returns: HashMap<String, HashMap<String, String>> = json
203            .get("getatt_returns")
204            .and_then(|v| serde_json::from_value(v.clone()).ok())
205            .expect("Embedded ref_types must contain getatt_returns");
206        let format_compatible_types: HashMap<String, Vec<String>> = json
207            .get("format_compatible_types")
208            .and_then(|v| serde_json::from_value(v.clone()).ok())
209            .expect("Embedded ref_types must contain format_compatible_types");
210        assert!(!ref_returns.is_empty(), "Embedded ref_returns must not be empty");
211        assert!(!getatt_returns.is_empty(), "Embedded getatt_returns must not be empty");
212        assert!(!format_compatible_types.is_empty(), "Embedded format_compatible_types must not be empty");
213        RefTypeStore { ref_returns, getatt_returns, format_compatible_types }
214    }
215
216    /// Update Ref/GetAtt return type data from a merged overlay schema so
217    /// type-checking rules see overlay-introduced/changed sources immediately.
218    ///
219    /// Uses the same derivation semantics as the catalog: no ref entry when
220    /// primaryIdentifier is empty; "string" when multiple, readOnly, or
221    /// unresolvable; otherwise the resolved single property type. GetAtt types
222    /// include ALL top-level properties plus full-path readOnly attributes.
223    /// Stale entries for a type that an overlay changes are replaced.
224    pub fn update_from_schema(&mut self, schema: &crate::compiled::CompiledSchema) {
225        let type_name = &schema.type_name;
226        let read_only_set: std::collections::HashSet<&str> =
227            schema.read_only_properties.iter().map(|s| s.as_str()).collect();
228
229        // Ref return type: match catalog derivation semantics.
230        // Remove stale entry first in case an overlay removed or changed
231        // the primary identifier.
232        self.ref_returns.remove(type_name);
233        if !schema.primary_identifier.is_empty() {
234            let ref_type = if schema.primary_identifier.len() > 1 {
235                "string".to_string()
236            } else {
237                let id_prop = &schema.primary_identifier[0];
238                if read_only_set.contains(id_prop.as_str()) {
239                    "string".to_string()
240                } else {
241                    crate::catalog::resolve_property_type(schema, id_prop).unwrap_or_else(|| "string".to_string())
242                }
243            };
244            self.ref_returns.insert(type_name.clone(), ref_type);
245        }
246
247        // GetAtt return types: ALL top-level properties plus full-path readOnly
248        // attributes. Replace the whole entry so stale attributes from a
249        // previous overlay are removed.
250        let mut attr_map: HashMap<String, String> = HashMap::new();
251        for (name, prop) in &schema.properties {
252            let resolved = prop.resolve(&schema.definitions);
253            if let Some(pt) = resolved.prop_type.as_ref().and_then(|p| p.primary()) {
254                attr_map.insert(name.clone(), pt.to_string());
255            }
256        }
257        for attr in &schema.read_only_properties {
258            if attr.contains('.')
259                && let Some(prop_type) = crate::catalog::resolve_property_type(schema, attr)
260            {
261                attr_map.insert(attr.clone(), prop_type);
262            }
263        }
264        // Hand-maintained GetAtt return-type corrections win over the derived
265        // property types, exactly as they do in the build pipeline.
266        crate::catalog::apply_getatt_return_type_overrides(type_name, &mut attr_map);
267        if !attr_map.is_empty() {
268            self.getatt_returns.insert(type_name.clone(), attr_map);
269        } else {
270            self.getatt_returns.remove(type_name);
271        }
272    }
273
274    pub fn ref_type_for(&self, resource_type: &str) -> Option<&str> {
275        self.ref_returns.get(resource_type).map(|s| s.as_str())
276    }
277
278    pub fn getatt_type_for(&self, resource_type: &str, attribute: &str) -> Option<&str> {
279        self.getatt_returns.get(resource_type).and_then(|attrs| attrs.get(attribute)).map(|s| s.as_str())
280    }
281
282    pub fn format_compatible_types(&self, format: &str) -> &[String] {
283        self.format_compatible_types.get(format).map(|v| v.as_slice()).unwrap_or(&[])
284    }
285}
286
287pub struct LifecycleEntry {
288    pub status: String,
289    pub date: Option<String>,
290}
291
292pub struct LifecycleStore {
293    resource_lifecycle: HashMap<String, LifecycleEntry>,
294    deprecated_runtimes: Vec<String>,
295    create_blocked_runtimes: Vec<String>,
296    eol_runtimes: Vec<String>,
297    runtime_lifecycle: HashMap<String, RuntimeLifecycle>,
298}
299
300/// Per-runtime lifecycle dates used to reconstruct the dated runtime-deprecation
301/// message.
302#[derive(Clone)]
303pub struct RuntimeLifecycle {
304    pub deprecated: String,
305    pub create_block: String,
306    pub update_block: String,
307    pub successor: Option<String>,
308}
309
310impl LifecycleStore {
311    fn load(lifecycle_bytes: &[u8], runtimes_bytes: &[u8]) -> Self {
312        let lifecycle_document: serde_json::Value =
313            serde_json::from_slice(lifecycle_bytes).expect("Embedded resource_lifecycle must be valid JSON");
314        let lifecycle_entries = lifecycle_document
315            .get("resource_lifecycle")
316            .and_then(|value| value.as_object())
317            .expect("Embedded resource_lifecycle must contain a resource_lifecycle object");
318        assert!(!lifecycle_entries.is_empty(), "Embedded resource_lifecycle must not be empty");
319        let mut resource_lifecycle = HashMap::new();
320        for (type_name, entry) in lifecycle_entries {
321            let status = entry
322                .get("status")
323                .and_then(|value| value.as_str())
324                .expect("Embedded resource lifecycle entries must contain a status");
325            assert!(!status.is_empty(), "Embedded resource lifecycle status must not be empty");
326            let date = entry.get("date").and_then(|value| value.as_str()).map(String::from);
327            resource_lifecycle.insert(type_name.clone(), LifecycleEntry { status: status.to_string(), date });
328        }
329
330        let runtime_document: serde_json::Value =
331            serde_json::from_slice(runtimes_bytes).expect("Embedded lambda_runtimes must be valid JSON");
332        let runtime_data = runtime_document
333            .get("lambda_runtimes")
334            .and_then(|value| value.as_object())
335            .expect("Embedded lambda_runtimes must contain a lambda_runtimes object");
336        let parse_runtime_list = |field: &str| -> Vec<String> {
337            runtime_data
338                .get(field)
339                .and_then(|value| value.as_array())
340                .unwrap_or_else(|| panic!("Embedded lambda_runtimes must contain a '{}' array", field))
341                .iter()
342                .map(|value| {
343                    value
344                        .as_str()
345                        .unwrap_or_else(|| panic!("Embedded lambda_runtimes '{}' entries must be strings", field))
346                        .to_string()
347                })
348                .collect()
349        };
350        let current_runtimes = parse_runtime_list("current");
351        let deprecated_runtimes = parse_runtime_list("deprecated");
352        let create_blocked_runtimes = parse_runtime_list("create_blocked");
353        let eol_runtimes = parse_runtime_list("eol");
354        assert!(
355            !current_runtimes.is_empty()
356                || !deprecated_runtimes.is_empty()
357                || !create_blocked_runtimes.is_empty()
358                || !eol_runtimes.is_empty(),
359            "Embedded lambda_runtimes lists must not all be empty"
360        );
361
362        let lifecycle_dates = runtime_data
363            .get("lifecycle")
364            .and_then(|value| value.as_object())
365            .expect("Embedded lambda_runtimes must contain a lifecycle object");
366        assert!(!lifecycle_dates.is_empty(), "Embedded lambda runtime lifecycle must not be empty");
367        let mut runtime_lifecycle = HashMap::new();
368        for (runtime, dates) in lifecycle_dates {
369            let required_date = |field: &str| {
370                dates
371                    .get(field)
372                    .and_then(|value| value.as_str())
373                    .unwrap_or_else(|| panic!("Embedded runtime lifecycle must contain a '{}' string", field))
374                    .to_string()
375            };
376            runtime_lifecycle.insert(
377                runtime.clone(),
378                RuntimeLifecycle {
379                    deprecated: required_date("deprecated"),
380                    create_block: required_date("create_block"),
381                    update_block: required_date("update_block"),
382                    successor: dates.get("successor").and_then(|value| value.as_str()).map(String::from),
383                },
384            );
385        }
386
387        LifecycleStore {
388            resource_lifecycle,
389            deprecated_runtimes,
390            create_blocked_runtimes,
391            eol_runtimes,
392            runtime_lifecycle,
393        }
394    }
395
396    pub fn runtime_lifecycle(&self, runtime: &str) -> Option<&RuntimeLifecycle> {
397        self.runtime_lifecycle.get(runtime)
398    }
399
400    pub fn resource_lifecycle(&self, type_name: &str) -> Option<&LifecycleEntry> {
401        self.resource_lifecycle.get(type_name)
402    }
403
404    pub fn is_runtime_deprecated(&self, runtime: &str) -> bool {
405        self.deprecated_runtimes.iter().any(|r| r == runtime)
406    }
407
408    pub fn is_runtime_eol(&self, runtime: &str) -> bool {
409        self.eol_runtimes.iter().any(|r| r == runtime)
410    }
411
412    pub fn is_runtime_create_blocked(&self, runtime: &str) -> bool {
413        self.create_blocked_runtimes.iter().any(|r| r == runtime)
414    }
415}
416
417pub struct ExtensionStore {
418    extensions: HashMap<String, Vec<serde_json::Value>>,
419}
420
421impl ExtensionStore {
422    fn load(bytes: &[u8]) -> Self {
423        let json: HashMap<String, serde_json::Value> =
424            serde_json::from_slice(bytes).expect("Embedded extensions must be valid JSON");
425        assert!(!json.is_empty(), "Embedded extensions must not be empty");
426        let mut extensions = HashMap::new();
427        for (type_name, value) in json {
428            let fragments = value.as_array().expect("Embedded extension entries must be arrays");
429            assert!(!fragments.is_empty(), "Embedded extension arrays must not be empty");
430            extensions.insert(type_name, fragments.clone());
431        }
432        ExtensionStore { extensions }
433    }
434
435    /// Remap extension keys to match canonical resource type names.
436    /// Extension source files may produce keys like "Aws::Rds::Dbcluster"
437    /// but the actual type names are "AWS::RDS::DBCluster".
438    pub fn remap_keys(&mut self, known_types: &HashMap<String, CompiledSchema>) {
439        let lowercase_to_canonical: HashMap<String, String> =
440            known_types.keys().map(|k| (k.to_lowercase(), k.clone())).collect();
441        let old_keys: Vec<String> = self.extensions.keys().cloned().collect();
442        for key in old_keys {
443            if known_types.contains_key(&key) {
444                continue;
445            }
446            if let Some(canonical) = lowercase_to_canonical.get(&key.to_lowercase())
447                && let Some(val) = self.extensions.remove(&key)
448            {
449                self.extensions.insert(canonical.clone(), val);
450            }
451        }
452    }
453
454    pub fn get(&self, type_name: &str) -> Option<&[serde_json::Value]> {
455        self.extensions.get(type_name).map(|v| v.as_slice())
456    }
457}
458
459pub struct RegionEnumStore {
460    enums: HashMap<String, HashMap<String, Vec<String>>>,
461}
462
463impl RegionEnumStore {
464    fn load(bytes: &[u8]) -> Result<Self, String> {
465        let enums: HashMap<String, HashMap<String, Vec<String>>> =
466            serde_json::from_slice(bytes).map_err(|error| format!("region_enums must be valid JSON: {error}"))?;
467        if enums.is_empty() {
468            return Err("region_enums must not be empty".to_string());
469        }
470        if !enums.values().all(|regions| !regions.is_empty() && regions.values().all(|values| !values.is_empty())) {
471            return Err("region_enums entries must not be empty".to_string());
472        }
473        Ok(RegionEnumStore { enums })
474    }
475
476    /// Key format: "AWS::EC2::Instance::InstanceType"
477    pub fn get(&self, resource_type: &str, prop_name: &str, region: &str) -> Option<&[String]> {
478        let key = format!("{}::{}", resource_type, prop_name);
479        self.enums.get(&key).and_then(|regions| regions.get(region)).map(|v| v.as_slice())
480    }
481
482    /// Allowed values for a property in the effective scope: the single `region`
483    /// when configured, or the union across all AWS regions when not (`None`) - so
484    /// with no region a value is accepted when it is valid in any region. Returns
485    /// `None` when the property has no regional override, or when a configured
486    /// region has no entry, so the caller falls back to the region-agnostic enum.
487    pub fn allowed_values(&self, resource_type: &str, prop_name: &str, region: Option<&str>) -> Option<Vec<&str>> {
488        let key = format!("{}::{}", resource_type, prop_name);
489        let regions = self.enums.get(&key)?;
490        match region {
491            Some(region) => regions.get(region).map(|v| v.iter().map(String::as_str).collect()),
492            None => {
493                let mut union: BTreeSet<&str> = BTreeSet::new();
494                for region in AWS_REGIONS {
495                    if let Some(values) = regions.get(*region) {
496                        union.extend(values.iter().map(String::as_str));
497                    }
498                }
499                (!union.is_empty()).then(|| union.into_iter().collect())
500            }
501        }
502    }
503
504    pub fn has_data(&self) -> bool {
505        !self.enums.is_empty()
506    }
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use serde_json::json;
513
514    #[test]
515    fn store_loads_nonempty() {
516        let store = CompiledSchemaStore::new();
517        assert!(store.len() > 0, "store should have schemas, got {}", store.len());
518    }
519
520    #[test]
521    fn store_get_known_type() {
522        let store = CompiledSchemaStore::new();
523        let schema = store.get("AWS::S3::Bucket").expect("expected AWS::S3::Bucket schema");
524        assert_eq!(schema.type_name, "AWS::S3::Bucket");
525    }
526
527    #[test]
528    fn store_get_unknown_type_returns_none() {
529        let store = CompiledSchemaStore::new();
530        assert!(store.get("AWS::Fake::NonExistent").is_none(), "unknown type should return None");
531    }
532
533    #[test]
534    fn store_new_always_has_region_data() {
535        let store = CompiledSchemaStore::new();
536        assert!(store.has_region_data(), "embedded region data must be present");
537    }
538
539    #[test]
540    fn store_load_region_data_filters_types() {
541        let mut store = CompiledSchemaStore::new();
542        let region_json = json!({
543            "region_resource_types": {
544                "us-east-1": { "AWS::S3::Bucket": true, "AWS::EC2::Instance": true },
545                "eu-west-1": { "AWS::S3::Bucket": true }
546            }
547        });
548        store.load_region_data(serde_json::to_vec(&region_json).unwrap().as_slice()).expect("valid region data");
549        assert!(store.has_region_data());
550        assert!(store.is_available_in_region("AWS::S3::Bucket", "us-east-1"));
551        assert!(store.is_available_in_region("AWS::EC2::Instance", "us-east-1"));
552        assert!(!store.is_available_in_region("AWS::EC2::Instance", "eu-west-1"));
553    }
554
555    #[test]
556    fn store_unknown_region_defaults_to_available() {
557        let mut store = CompiledSchemaStore::new();
558        let region_json = json!({
559            "region_resource_types": {
560                "us-east-1": { "AWS::S3::Bucket": true }
561            }
562        });
563        store.load_region_data(serde_json::to_vec(&region_json).unwrap().as_slice()).expect("valid region data");
564        assert!(store.is_available_in_region("AWS::S3::Bucket", "ap-southeast-99"));
565    }
566
567    #[test]
568    fn store_load_region_data_invalid_json_returns_error() {
569        let mut store = CompiledSchemaStore::new();
570
571        let error = store.load_region_data(b"not json").expect_err("invalid JSON must fail");
572
573        assert!(error.contains("must be valid JSON"), "unexpected error: {error}");
574    }
575
576    #[test]
577    fn store_load_region_data_wrong_structure_returns_error() {
578        let mut store = CompiledSchemaStore::new();
579
580        let error = store.load_region_data(b"{}").expect_err("a missing wrapper must fail");
581
582        assert!(error.contains("must contain a region_resource_types object"), "unexpected error: {error}");
583    }
584
585    #[test]
586    fn ref_type_store_loads_from_embedded_data() {
587        let store = CompiledSchemaStore::new();
588        let rt = store.ref_types();
589        let vpc_ref = rt.ref_type_for("AWS::EC2::VPC");
590        assert_eq!(vpc_ref, Some("string"), "expected Ref to VPC to return string");
591    }
592
593    #[test]
594    fn ref_type_unknown_resource_returns_none() {
595        let store = CompiledSchemaStore::new();
596        assert_eq!(store.ref_types().ref_type_for("AWS::Fake::Thing"), None, "unknown type should have no ref type");
597    }
598
599    #[test]
600    fn getatt_type_for_known_attribute() {
601        let store = CompiledSchemaStore::new();
602        let rt = store.ref_types();
603        let sg = rt.getatt_type_for("AWS::EC2::VPC", "DefaultSecurityGroup");
604        assert!(sg.is_some(), "expected GetAtt type for VPC.DefaultSecurityGroup");
605    }
606
607    #[test]
608    fn getatt_type_unknown_attribute_returns_none() {
609        let store = CompiledSchemaStore::new();
610        assert!(store.ref_types().getatt_type_for("AWS::EC2::VPC", "FakeAttr").is_none());
611    }
612
613    #[test]
614    fn format_compatible_types_vpc_id() {
615        let store = CompiledSchemaStore::new();
616        let compatible = store.ref_types().format_compatible_types("AWS::EC2::VPC.Id");
617        assert!(
618            compatible.iter().any(|t| t == "AWS::EC2::VPC"),
619            "expected AWS::EC2::VPC in format-compatible types for VPC.Id, got: {:?}",
620            compatible
621        );
622    }
623
624    #[test]
625    fn format_compatible_types_unknown_format_empty() {
626        let store = CompiledSchemaStore::new();
627        assert!(store.ref_types().format_compatible_types("FakeFormat").is_empty());
628    }
629
630    #[test]
631    fn lifecycle_store_loads() {
632        let store = CompiledSchemaStore::new();
633        let lc = store.lifecycle();
634        let entry = lc.resource_lifecycle("AWS::CodeStar::GitHubRepository");
635        assert!(entry.is_some(), "expected lifecycle entry for AWS::CodeStar::GitHubRepository");
636        assert_eq!(entry.unwrap().status, "shutdown");
637    }
638
639    #[test]
640    fn lifecycle_unknown_type_returns_none() {
641        let store = CompiledSchemaStore::new();
642        assert!(store.lifecycle().resource_lifecycle("AWS::S3::Bucket").is_none());
643    }
644
645    #[test]
646    fn runtime_eol_detection() {
647        let store = CompiledSchemaStore::new();
648        let lc = store.lifecycle();
649        assert!(lc.is_runtime_eol("python2.7"), "expected python2.7 to be EOL");
650        assert!(!lc.is_runtime_eol("python3.12"), "python3.12 should not be EOL");
651    }
652
653    #[test]
654    fn runtime_deprecated_detection() {
655        let store = CompiledSchemaStore::new();
656        let lc = store.lifecycle();
657        assert!(!lc.is_runtime_deprecated("python3.12"), "python3.12 should not be deprecated");
658    }
659
660    #[test]
661    fn extension_store_remap_keys() {
662        let mut extensions = ExtensionStore { extensions: HashMap::new() };
663        extensions.extensions.insert("Aws::S3::Bucket".into(), vec![json!({"test": true})]);
664
665        let mut known = HashMap::new();
666        known.insert(
667            "AWS::S3::Bucket".into(),
668            CompiledSchema { type_name: "AWS::S3::Bucket".into(), ..Default::default() },
669        );
670
671        extensions.remap_keys(&known);
672        assert!(extensions.get("AWS::S3::Bucket").is_some(), "expected remapped key");
673        assert!(extensions.get("Aws::S3::Bucket").is_none(), "old key should be removed");
674    }
675
676    #[test]
677    fn extension_store_remap_preserves_canonical_keys() {
678        let mut extensions = ExtensionStore { extensions: HashMap::new() };
679        extensions.extensions.insert("AWS::S3::Bucket".into(), vec![json!({"test": true})]);
680
681        let mut known = HashMap::new();
682        known.insert(
683            "AWS::S3::Bucket".into(),
684            CompiledSchema { type_name: "AWS::S3::Bucket".into(), ..Default::default() },
685        );
686
687        extensions.remap_keys(&known);
688        assert!(extensions.get("AWS::S3::Bucket").is_some(), "canonical key should be preserved");
689    }
690
691    #[test]
692    fn region_enum_get_unknown_returns_none() {
693        let store = CompiledSchemaStore::new();
694        assert!(store.region_enums().get("AWS::Fake::Type", "FakeProp", "us-east-1").is_none());
695    }
696
697    #[test]
698    fn region_enum_store_rejects_empty_document() {
699        let error = RegionEnumStore::load(b"{}").err().expect("empty required enum data must fail");
700
701        assert!(error.contains("must not be empty"), "unexpected error: {error}");
702    }
703
704    #[test]
705    fn region_enum_store_from_valid_data() {
706        let data = json!({
707            "AWS::EC2::Instance::InstanceType": {
708                "us-east-1": ["t2.micro", "t3.micro"],
709                "eu-west-1": ["t2.micro"]
710            }
711        });
712        let re = RegionEnumStore::load(serde_json::to_vec(&data).unwrap().as_slice()).expect("valid region enums");
713        assert!(re.has_data());
714        let vals =
715            re.get("AWS::EC2::Instance", "InstanceType", "us-east-1").expect("expected enum values for us-east-1");
716        assert_eq!(vals, &["t2.micro", "t3.micro"]);
717        assert_eq!(
718            re.get("AWS::EC2::Instance", "InstanceType", "ap-south-1"),
719            None,
720            "ap-south-1 should have no enum values"
721        );
722    }
723
724    fn region_enum_fixture() -> RegionEnumStore {
725        // t3.micro is valid only in eu-west-1, not us-east-1; "description" is a
726        // synthetic non-region key that must never contribute to the union.
727        let data = json!({
728            "AWS::EC2::Instance::InstanceType": {
729                "us-east-1": ["t2.micro"],
730                "eu-west-1": ["t2.micro", "t3.micro"],
731                "description": ["should.be.ignored"]
732            }
733        });
734        RegionEnumStore::load(serde_json::to_vec(&data).unwrap().as_slice()).expect("valid region enum fixture")
735    }
736
737    #[test]
738    fn allowed_values_with_region_is_that_region_only() {
739        let re = region_enum_fixture();
740        let vals = re.allowed_values("AWS::EC2::Instance", "InstanceType", Some("us-east-1")).expect("us-east-1 entry");
741        assert!(vals.contains(&"t2.micro"));
742        assert!(!vals.contains(&"t3.micro"), "t3.micro is not valid in us-east-1");
743    }
744
745    #[test]
746    fn allowed_values_without_region_unions_all_regions() {
747        let re = region_enum_fixture();
748        let vals = re.allowed_values("AWS::EC2::Instance", "InstanceType", None).expect("union across regions");
749        assert!(vals.contains(&"t2.micro"));
750        assert!(vals.contains(&"t3.micro"), "t3.micro is valid in eu-west-1, so present in the union");
751        assert!(!vals.contains(&"should.be.ignored"), "the synthetic 'description' key must not contribute");
752    }
753
754    #[test]
755    fn allowed_values_unknown_configured_region_is_none() {
756        let re = region_enum_fixture();
757        assert!(re.allowed_values("AWS::EC2::Instance", "InstanceType", Some("ap-south-1")).is_none());
758    }
759
760    #[test]
761    fn allowed_values_unknown_property_is_none() {
762        let re = region_enum_fixture();
763        assert!(re.allowed_values("AWS::Fake::Type", "FakeProp", None).is_none());
764        assert!(re.allowed_values("AWS::Fake::Type", "FakeProp", Some("us-east-1")).is_none());
765    }
766}