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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum OverlayOutcome {
16 Merged,
18 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(®ION_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 store
60 .load_region_data(®ION_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 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 #[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 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 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 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 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#[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 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 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 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(®ion_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(®ion_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 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}