Skip to main content

schema_validator/
store.rs

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