Skip to main content

schema_validator/
store.rs

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