Skip to main content

fakecloud_cloudformation/template/
mod.rs

1use base64::Engine;
2use serde_json::{json, Value};
3use std::collections::{BTreeMap, BTreeSet};
4
5/// Internal sentinel emitted whenever a CFN value resolves to
6/// `AWS::NoValue`. After resolution finishes, [`strip_no_value`] walks
7/// the result and removes any object entry / array slot whose value is
8/// this marker, matching CloudFormation's "drop the property" semantics
9/// (e.g. inside an `Fn::If` branch). Picked so it cannot collide with
10/// any real CFN property.
11const NO_VALUE_SENTINEL_KEY: &str = "__fakecloud_aws_no_value__";
12
13/// A parsed CloudFormation template.
14#[derive(Debug, Clone, Default)]
15pub struct ParsedTemplate {
16    pub description: Option<String>,
17    pub resources: Vec<ResourceDefinition>,
18    pub outputs: Vec<TemplateOutput>,
19}
20
21/// Resolved Outputs entry from the template's top-level `Outputs` block.
22/// `value` is the post-resolution string; `export_name` is set when the
23/// output declares `Export.Name`.
24#[derive(Debug, Clone)]
25pub struct TemplateOutput {
26    pub logical_id: String,
27    pub value: String,
28    pub description: Option<String>,
29    pub export_name: Option<String>,
30}
31
32/// A single resource from the template.
33#[derive(Debug, Clone)]
34pub struct ResourceDefinition {
35    pub logical_id: String,
36    pub resource_type: String,
37    pub properties: Value,
38}
39
40/// Known pseudo-references that should be passed through as-is.
41const PSEUDO_REFS: &[&str] = &[
42    "AWS::AccountId",
43    "AWS::NotificationARNs",
44    "AWS::NoValue",
45    "AWS::Partition",
46    "AWS::Region",
47    "AWS::StackId",
48    "AWS::StackName",
49    "AWS::URLSuffix",
50];
51
52type Mappings = BTreeMap<String, BTreeMap<String, BTreeMap<String, Value>>>;
53
54/// Map an AWS region to its IAM/ARN partition. China regions land on
55/// `aws-cn`, GovCloud on `aws-us-gov`, everything else on `aws`. Used
56/// by both `AWS::Partition` resolution and the URL-suffix derivation
57/// below so partition decisions stay consistent.
58pub(crate) fn partition_for_region(region: &str) -> &'static str {
59    if region.starts_with("cn-") {
60        "aws-cn"
61    } else if region.starts_with("us-gov-") {
62        "aws-us-gov"
63    } else {
64        "aws"
65    }
66}
67
68/// Map an AWS region to its DNS URL suffix. China regions use
69/// `amazonaws.com.cn`; every other partition (commercial + GovCloud)
70/// uses `amazonaws.com`, matching the real CFN `AWS::URLSuffix`.
71pub(crate) fn url_suffix_for_region(region: &str) -> &'static str {
72    if region.starts_with("cn-") {
73        "amazonaws.com.cn"
74    } else {
75        "amazonaws.com"
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn parse_json_template() {
85        let template = r#"{
86            "Resources": {
87                "MyQueue": {
88                    "Type": "AWS::SQS::Queue",
89                    "Properties": {
90                        "QueueName": "test-queue"
91                    }
92                }
93            }
94        }"#;
95
96        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
97        assert_eq!(parsed.resources.len(), 1);
98        assert_eq!(parsed.resources[0].logical_id, "MyQueue");
99        assert_eq!(parsed.resources[0].resource_type, "AWS::SQS::Queue");
100    }
101
102    #[test]
103    fn parse_yaml_template() {
104        let template = r#"
105Resources:
106  MyTopic:
107    Type: AWS::SNS::Topic
108    Properties:
109      TopicName: test-topic
110"#;
111
112        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
113        assert_eq!(parsed.resources.len(), 1);
114        assert_eq!(parsed.resources[0].logical_id, "MyTopic");
115        assert_eq!(parsed.resources[0].resource_type, "AWS::SNS::Topic");
116    }
117
118    #[test]
119    fn resolve_ref_parameters() {
120        let template = r#"{
121            "Resources": {
122                "MyQueue": {
123                    "Type": "AWS::SQS::Queue",
124                    "Properties": {
125                        "QueueName": { "Ref": "QueueNameParam" }
126                    }
127                }
128            }
129        }"#;
130
131        let mut params = BTreeMap::new();
132        params.insert("QueueNameParam".to_string(), "resolved-queue".to_string());
133        let parsed = parse_template(template, &params).unwrap();
134        assert_eq!(
135            parsed.resources[0].properties["QueueName"],
136            Value::String("resolved-queue".to_string())
137        );
138    }
139
140    #[test]
141    fn ref_resolves_physical_id_over_logical_id() {
142        let template = r#"{
143            "Resources": {
144                "MyTopic": {
145                    "Type": "AWS::SNS::Topic",
146                    "Properties": {
147                        "TopicName": "my-topic"
148                    }
149                },
150                "MySub": {
151                    "Type": "AWS::SNS::Subscription",
152                    "Properties": {
153                        "TopicArn": { "Ref": "MyTopic" },
154                        "Protocol": "sqs",
155                        "Endpoint": "arn:aws:sqs:us-east-1:123456789012:q"
156                    }
157                }
158            }
159        }"#;
160
161        let mut physical_ids = BTreeMap::new();
162        physical_ids.insert(
163            "MyTopic".to_string(),
164            "arn:aws:sns:us-east-1:123456789012:my-topic".to_string(),
165        );
166
167        let parsed =
168            parse_template_with_physical_ids(template, &BTreeMap::new(), &physical_ids).unwrap();
169        let sub = parsed
170            .resources
171            .iter()
172            .find(|r| r.logical_id == "MySub")
173            .unwrap();
174        assert_eq!(
175            sub.properties["TopicArn"],
176            Value::String("arn:aws:sns:us-east-1:123456789012:my-topic".to_string())
177        );
178    }
179
180    #[test]
181    fn ref_without_physical_id_returns_logical_id_for_known_resource() {
182        let template = r#"{
183            "Resources": {
184                "MyTopic": {
185                    "Type": "AWS::SNS::Topic",
186                    "Properties": {
187                        "TopicName": "my-topic"
188                    }
189                },
190                "MySub": {
191                    "Type": "AWS::SNS::Subscription",
192                    "Properties": {
193                        "TopicArn": { "Ref": "MyTopic" },
194                        "Protocol": "sqs",
195                        "Endpoint": "arn:aws:sqs:us-east-1:123456789012:q"
196                    }
197                }
198            }
199        }"#;
200
201        // No physical IDs yet — logical ID returned for known resources
202        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
203        let sub = parsed
204            .resources
205            .iter()
206            .find(|r| r.logical_id == "MySub")
207            .unwrap();
208        assert_eq!(
209            sub.properties["TopicArn"],
210            Value::String("MyTopic".to_string())
211        );
212    }
213
214    #[test]
215    fn pseudo_ref_substitutes_when_param_provided() {
216        let template = r#"{
217            "Resources": {
218                "MyQueue": {
219                    "Type": "AWS::SQS::Queue",
220                    "Properties": {
221                        "QueueArn": {
222                            "Fn::Join": ["", [
223                                "arn:", {"Ref": "AWS::Partition"}, ":sqs:",
224                                {"Ref": "AWS::Region"}, ":", {"Ref": "AWS::AccountId"},
225                                ":", {"Ref": "AWS::StackName"}, "-q"
226                            ]]
227                        }
228                    }
229                }
230            }
231        }"#;
232        let mut params = BTreeMap::new();
233        params.insert("AWS::Region".to_string(), "us-west-2".to_string());
234        params.insert("AWS::AccountId".to_string(), "111122223333".to_string());
235        params.insert("AWS::Partition".to_string(), "aws".to_string());
236        params.insert("AWS::StackName".to_string(), "demo".to_string());
237
238        let parsed = parse_template(template, &params).unwrap();
239        assert_eq!(
240            parsed.resources[0].properties["QueueArn"],
241            Value::String("arn:aws:sqs:us-west-2:111122223333:demo-q".to_string())
242        );
243    }
244
245    #[test]
246    fn pseudo_ref_partition_default_when_unset() {
247        let template = r#"{
248            "Resources": {
249                "MyQueue": {
250                    "Type": "AWS::SQS::Queue",
251                    "Properties": {
252                        "Partition": {"Ref": "AWS::Partition"},
253                        "Suffix": {"Ref": "AWS::URLSuffix"}
254                    }
255                }
256            }
257        }"#;
258        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
259        assert_eq!(
260            parsed.resources[0].properties["Partition"],
261            Value::String("aws".to_string())
262        );
263        assert_eq!(
264            parsed.resources[0].properties["Suffix"],
265            Value::String("amazonaws.com".to_string())
266        );
267    }
268
269    #[test]
270    fn pseudo_ref_passes_through() {
271        let template = r#"{
272            "Resources": {
273                "MyQueue": {
274                    "Type": "AWS::SQS::Queue",
275                    "Properties": {
276                        "QueueName": { "Ref": "AWS::StackName" }
277                    }
278                }
279            }
280        }"#;
281
282        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
283        assert_eq!(
284            parsed.resources[0].properties["QueueName"],
285            Value::String("AWS::StackName".to_string())
286        );
287    }
288
289    // ── BB6: pseudo-parameter coverage ────────────────────────────
290
291    #[test]
292    fn bb6_ref_aws_region_returns_seeded_region() {
293        let template = r#"{
294            "Resources": {
295                "Q": {
296                    "Type": "AWS::SQS::Queue",
297                    "Properties": {"Region": {"Ref": "AWS::Region"}}
298                }
299            }
300        }"#;
301        let mut params = BTreeMap::new();
302        params.insert("AWS::Region".to_string(), "us-east-1".to_string());
303        let parsed = parse_template(template, &params).unwrap();
304        assert_eq!(
305            parsed.resources[0].properties["Region"],
306            Value::String("us-east-1".to_string())
307        );
308    }
309
310    #[test]
311    fn bb6_fn_sub_substitutes_aws_account_id() {
312        let template = r#"{
313            "Resources": {
314                "Q": {
315                    "Type": "AWS::SQS::Queue",
316                    "Properties": {
317                        "Owner": {"Fn::Sub": "owner-${AWS::AccountId}"}
318                    }
319                }
320            }
321        }"#;
322        let mut params = BTreeMap::new();
323        params.insert("AWS::AccountId".to_string(), "123456789012".to_string());
324        let parsed = parse_template(template, &params).unwrap();
325        assert_eq!(
326            parsed.resources[0].properties["Owner"],
327            Value::String("owner-123456789012".to_string())
328        );
329    }
330
331    #[test]
332    fn bb6_partition_for_china_region_is_aws_cn() {
333        // Caller seeds region but no explicit partition; pseudo_value
334        // should derive `aws-cn` for cn-* regions.
335        let template = r#"{
336            "Resources": {
337                "Q": {
338                    "Type": "AWS::SQS::Queue",
339                    "Properties": {"P": {"Ref": "AWS::Partition"}}
340                }
341            }
342        }"#;
343        let mut params = BTreeMap::new();
344        params.insert("AWS::Region".to_string(), "cn-north-1".to_string());
345        let parsed = parse_template(template, &params).unwrap();
346        assert_eq!(
347            parsed.resources[0].properties["P"],
348            Value::String("aws-cn".to_string())
349        );
350    }
351
352    #[test]
353    fn bb6_partition_for_govcloud_region_is_aws_us_gov() {
354        let template = r#"{
355            "Resources": {
356                "Q": {
357                    "Type": "AWS::SQS::Queue",
358                    "Properties": {"P": {"Ref": "AWS::Partition"}}
359                }
360            }
361        }"#;
362        let mut params = BTreeMap::new();
363        params.insert("AWS::Region".to_string(), "us-gov-west-1".to_string());
364        let parsed = parse_template(template, &params).unwrap();
365        assert_eq!(
366            parsed.resources[0].properties["P"],
367            Value::String("aws-us-gov".to_string())
368        );
369    }
370
371    #[test]
372    fn bb6_url_suffix_for_china_is_amazonaws_com_cn() {
373        let template = r#"{
374            "Resources": {
375                "Q": {
376                    "Type": "AWS::SQS::Queue",
377                    "Properties": {"S": {"Ref": "AWS::URLSuffix"}}
378                }
379            }
380        }"#;
381        let mut params = BTreeMap::new();
382        params.insert("AWS::Region".to_string(), "cn-north-1".to_string());
383        let parsed = parse_template(template, &params).unwrap();
384        assert_eq!(
385            parsed.resources[0].properties["S"],
386            Value::String("amazonaws.com.cn".to_string())
387        );
388    }
389
390    #[test]
391    fn bb6_url_suffix_for_govcloud_stays_amazonaws_com() {
392        // GovCloud keeps the standard suffix — only China switches.
393        let template = r#"{
394            "Resources": {
395                "Q": {
396                    "Type": "AWS::SQS::Queue",
397                    "Properties": {"S": {"Ref": "AWS::URLSuffix"}}
398                }
399            }
400        }"#;
401        let mut params = BTreeMap::new();
402        params.insert("AWS::Region".to_string(), "us-gov-east-1".to_string());
403        let parsed = parse_template(template, &params).unwrap();
404        assert_eq!(
405            parsed.resources[0].properties["S"],
406            Value::String("amazonaws.com".to_string())
407        );
408    }
409
410    #[test]
411    fn bb6_no_value_omits_property_from_resource_input() {
412        // Direct Ref to AWS::NoValue (no Fn::If wrapper) must still
413        // drop the property from the resolved resource map.
414        let template = r#"{
415            "Resources": {
416                "Q": {
417                    "Type": "AWS::SQS::Queue",
418                    "Properties": {
419                        "QueueName": "q",
420                        "OptionalProp": {"Ref": "AWS::NoValue"}
421                    }
422                }
423            }
424        }"#;
425        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
426        let props = parsed.resources[0].properties.as_object().unwrap();
427        assert!(
428            !props.contains_key("OptionalProp"),
429            "OptionalProp should be omitted, got: {props:?}"
430        );
431        assert_eq!(
432            props.get("QueueName"),
433            Some(&Value::String("q".to_string()))
434        );
435    }
436
437    #[test]
438    fn bb6_notification_arns_returns_seeded_array() {
439        // Pseudo-parameter `AWS::NotificationARNs` resolves to an array
440        // sourced from the JSON-encoded seed (matching the wiring in
441        // service::create_stack).
442        let template = r#"{
443            "Resources": {
444                "Q": {
445                    "Type": "AWS::SQS::Queue",
446                    "Properties": {"Targets": {"Ref": "AWS::NotificationARNs"}}
447                }
448            }
449        }"#;
450        let mut params = BTreeMap::new();
451        params.insert(
452            "AWS::NotificationARNs".to_string(),
453            r#"["arn:aws:sns:us-east-1:111122223333:topic"]"#.to_string(),
454        );
455        let parsed = parse_template(template, &params).unwrap();
456        assert_eq!(
457            parsed.resources[0].properties["Targets"],
458            serde_json::json!(["arn:aws:sns:us-east-1:111122223333:topic"])
459        );
460    }
461
462    #[test]
463    fn bb6_notification_arns_defaults_to_empty_array() {
464        let template = r#"{
465            "Resources": {
466                "Q": {
467                    "Type": "AWS::SQS::Queue",
468                    "Properties": {"Targets": {"Ref": "AWS::NotificationARNs"}}
469                }
470            }
471        }"#;
472        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
473        assert_eq!(
474            parsed.resources[0].properties["Targets"],
475            serde_json::json!([])
476        );
477    }
478
479    #[test]
480    fn bb6_fn_sub_array_form_substitutes_extra_vars() {
481        // The array form `Fn::Sub: ["literal", {Var: ...}]` lets the
482        // template pass extra bindings; pseudo-params still resolve.
483        let template = r#"{
484            "Resources": {
485                "Q": {
486                    "Type": "AWS::SQS::Queue",
487                    "Properties": {
488                        "Path": {"Fn::Sub": ["${AWS::Region}/${Suffix}", {"Suffix": "tail"}]}
489                    }
490                }
491            }
492        }"#;
493        let mut params = BTreeMap::new();
494        params.insert("AWS::Region".to_string(), "eu-west-1".to_string());
495        let parsed = parse_template(template, &params).unwrap();
496        assert_eq!(
497            parsed.resources[0].properties["Path"],
498            Value::String("eu-west-1/tail".to_string())
499        );
500    }
501
502    #[test]
503    fn bb6_partition_helper_classifies_regions() {
504        assert_eq!(partition_for_region("us-east-1"), "aws");
505        assert_eq!(partition_for_region("eu-central-1"), "aws");
506        assert_eq!(partition_for_region("cn-north-1"), "aws-cn");
507        assert_eq!(partition_for_region("cn-northwest-1"), "aws-cn");
508        assert_eq!(partition_for_region("us-gov-west-1"), "aws-us-gov");
509        assert_eq!(partition_for_region("us-gov-east-1"), "aws-us-gov");
510    }
511
512    #[test]
513    fn bb6_url_suffix_helper_classifies_regions() {
514        assert_eq!(url_suffix_for_region("us-east-1"), "amazonaws.com");
515        assert_eq!(url_suffix_for_region("us-gov-west-1"), "amazonaws.com");
516        assert_eq!(url_suffix_for_region("cn-north-1"), "amazonaws.com.cn");
517    }
518
519    #[test]
520    fn fn_sub_resolves_physical_ids() {
521        let template = r#"{
522            "Resources": {
523                "MyTopic": {
524                    "Type": "AWS::SNS::Topic",
525                    "Properties": {
526                        "TopicName": "my-topic"
527                    }
528                },
529                "MyParam": {
530                    "Type": "AWS::SSM::Parameter",
531                    "Properties": {
532                        "Name": "/app/topic",
533                        "Type": "String",
534                        "Value": { "Fn::Sub": "Topic is ${MyTopic}" }
535                    }
536                }
537            }
538        }"#;
539
540        let mut physical_ids = BTreeMap::new();
541        physical_ids.insert(
542            "MyTopic".to_string(),
543            "arn:aws:sns:us-east-1:123456789012:my-topic".to_string(),
544        );
545
546        let parsed =
547            parse_template_with_physical_ids(template, &BTreeMap::new(), &physical_ids).unwrap();
548        let param = parsed
549            .resources
550            .iter()
551            .find(|r| r.logical_id == "MyParam")
552            .unwrap();
553        assert_eq!(
554            param.properties["Value"],
555            Value::String("Topic is arn:aws:sns:us-east-1:123456789012:my-topic".to_string())
556        );
557    }
558
559    // ── error paths ──
560
561    #[test]
562    fn parse_template_invalid_json_errors() {
563        let params = BTreeMap::new();
564        let result = parse_template("{not-json}", &params);
565        assert!(result.is_err());
566    }
567
568    #[test]
569    fn parse_template_missing_resources_errors() {
570        let params = BTreeMap::new();
571        let result = parse_template(r#"{"Description":"no resources"}"#, &params);
572        assert!(result.is_err());
573    }
574
575    #[test]
576    fn parse_template_resources_not_object_errors() {
577        let params = BTreeMap::new();
578        let result = parse_template(r#"{"Resources": []}"#, &params);
579        assert!(result.is_err());
580    }
581
582    #[test]
583    fn parse_template_missing_type_errors() {
584        let params = BTreeMap::new();
585        let result = parse_template(r#"{"Resources":{"R":{"Properties":{}}}}"#, &params);
586        assert!(result.is_err());
587    }
588
589    // ── Fn::GetAtt ──
590
591    #[test]
592    fn fn_getatt_resolves_attribute_in_array_form() {
593        let template = r#"{
594            "Resources": {
595                "MyQueue": {
596                    "Type": "AWS::SQS::Queue",
597                    "Properties": { "QueueName": "q1" }
598                },
599                "MyTopic": {
600                    "Type": "AWS::SNS::Topic",
601                    "Properties": {
602                        "TopicName": "t1",
603                        "DataProtectionPolicy": {
604                            "Fn::GetAtt": ["MyQueue", "Arn"]
605                        }
606                    }
607                }
608            }
609        }"#;
610
611        let mut attrs = BTreeMap::new();
612        let mut q_attrs = BTreeMap::new();
613        q_attrs.insert(
614            "Arn".to_string(),
615            "arn:aws:sqs:us-east-1:123456789012:q1".to_string(),
616        );
617        attrs.insert("MyQueue".to_string(), q_attrs);
618
619        let parsed =
620            parse_template_with_resolution(template, &BTreeMap::new(), &BTreeMap::new(), &attrs)
621                .unwrap();
622        let topic = parsed
623            .resources
624            .iter()
625            .find(|r| r.logical_id == "MyTopic")
626            .unwrap();
627        assert_eq!(
628            topic.properties["DataProtectionPolicy"],
629            Value::String("arn:aws:sqs:us-east-1:123456789012:q1".to_string())
630        );
631    }
632
633    #[test]
634    fn fn_getatt_resolves_attribute_in_short_string_form() {
635        let template = r#"{
636            "Resources": {
637                "MyTopic": {
638                    "Type": "AWS::SNS::Topic",
639                    "Properties": {
640                        "TopicName": "t1",
641                        "PolicyArn": { "Fn::GetAtt": "MyQueue.Arn" }
642                    }
643                }
644            }
645        }"#;
646
647        let mut attrs = BTreeMap::new();
648        let mut q_attrs = BTreeMap::new();
649        q_attrs.insert(
650            "Arn".to_string(),
651            "arn:aws:sqs:us-east-1:123456789012:q1".to_string(),
652        );
653        attrs.insert("MyQueue".to_string(), q_attrs);
654
655        let parsed =
656            parse_template_with_resolution(template, &BTreeMap::new(), &BTreeMap::new(), &attrs)
657                .unwrap();
658        assert_eq!(
659            parsed.resources[0].properties["PolicyArn"],
660            Value::String("arn:aws:sqs:us-east-1:123456789012:q1".to_string())
661        );
662    }
663
664    #[test]
665    fn fn_getatt_unknown_resource_returns_placeholder() {
666        let template = r#"{
667            "Resources": {
668                "MyTopic": {
669                    "Type": "AWS::SNS::Topic",
670                    "Properties": {
671                        "TopicName": { "Fn::GetAtt": ["MyQueue", "Arn"] }
672                    }
673                }
674            }
675        }"#;
676
677        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
678        // Unresolved GetAtt becomes a placeholder; multi-pass provisioning
679        // re-resolves once the target is known.
680        assert_eq!(
681            parsed.resources[0].properties["TopicName"],
682            Value::String("MyQueue.Arn".to_string())
683        );
684    }
685
686    #[test]
687    fn fn_getatt_inside_fn_join_resolves() {
688        let template = r#"{
689            "Resources": {
690                "MyParam": {
691                    "Type": "AWS::SSM::Parameter",
692                    "Properties": {
693                        "Name": "/app/q",
694                        "Type": "String",
695                        "Value": {
696                            "Fn::Join": [":", ["queue", { "Fn::GetAtt": ["MyQueue", "Arn"] }]]
697                        }
698                    }
699                }
700            }
701        }"#;
702
703        let mut attrs = BTreeMap::new();
704        let mut q_attrs = BTreeMap::new();
705        q_attrs.insert(
706            "Arn".to_string(),
707            "arn:aws:sqs:us-east-1:123456789012:q1".to_string(),
708        );
709        attrs.insert("MyQueue".to_string(), q_attrs);
710
711        let parsed =
712            parse_template_with_resolution(template, &BTreeMap::new(), &BTreeMap::new(), &attrs)
713                .unwrap();
714        assert_eq!(
715            parsed.resources[0].properties["Value"],
716            Value::String("queue:arn:aws:sqs:us-east-1:123456789012:q1".to_string())
717        );
718    }
719
720    #[test]
721    fn fn_sub_resolves_getatt_style_substitution() {
722        let template = r#"{
723            "Resources": {
724                "MyParam": {
725                    "Type": "AWS::SSM::Parameter",
726                    "Properties": {
727                        "Name": "/app/q",
728                        "Type": "String",
729                        "Value": { "Fn::Sub": "Queue arn is ${MyQueue.Arn}" }
730                    }
731                }
732            }
733        }"#;
734
735        let mut attrs = BTreeMap::new();
736        let mut q_attrs = BTreeMap::new();
737        q_attrs.insert(
738            "Arn".to_string(),
739            "arn:aws:sqs:us-east-1:123456789012:q1".to_string(),
740        );
741        attrs.insert("MyQueue".to_string(), q_attrs);
742
743        let parsed =
744            parse_template_with_resolution(template, &BTreeMap::new(), &BTreeMap::new(), &attrs)
745                .unwrap();
746        assert_eq!(
747            parsed.resources[0].properties["Value"],
748            Value::String("Queue arn is arn:aws:sqs:us-east-1:123456789012:q1".to_string())
749        );
750    }
751
752    #[test]
753    fn parse_template_with_description() {
754        let params = BTreeMap::new();
755        let parsed = parse_template(
756            r#"{"Description":"My template","Resources":{"R":{"Type":"AWS::SQS::Queue"}}}"#,
757            &params,
758        )
759        .unwrap();
760        assert_eq!(parsed.description.as_deref(), Some("My template"));
761        assert_eq!(parsed.resources.len(), 1);
762    }
763
764    type EmptyCtx = (
765        BTreeMap<String, String>,
766        serde_json::Map<String, Value>,
767        BTreeMap<String, String>,
768        BTreeMap<String, BTreeMap<String, String>>,
769    );
770
771    fn empty() -> EmptyCtx {
772        (
773            BTreeMap::new(),
774            serde_json::Map::new(),
775            BTreeMap::new(),
776            BTreeMap::new(),
777        )
778    }
779
780    #[test]
781    fn fn_base64_encodes_string() {
782        let (p, r, ids, attrs) = empty();
783        let v: Value = serde_json::from_str(r#"{"Fn::Base64": "hello"}"#).unwrap();
784        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
785        assert_eq!(resolved, Value::String("aGVsbG8=".to_string()));
786    }
787
788    #[test]
789    fn fn_split_emits_array() {
790        let (p, r, ids, attrs) = empty();
791        let v: Value = serde_json::from_str(r#"{"Fn::Split": [",", "a,b,c"]}"#).unwrap();
792        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
793        assert_eq!(resolved, serde_json::json!(["a", "b", "c"]));
794    }
795
796    #[test]
797    fn fn_select_picks_index() {
798        let (p, r, ids, attrs) = empty();
799        let v: Value =
800            serde_json::from_str(r#"{"Fn::Select": [1, {"Fn::Split": [",", "a,b,c"]}]}"#).unwrap();
801        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
802        assert_eq!(resolved, Value::String("b".to_string()));
803    }
804
805    #[test]
806    fn fn_length_counts_array() {
807        let (p, r, ids, attrs) = empty();
808        let v: Value = serde_json::from_str(r#"{"Fn::Length": [1,2,3,4]}"#).unwrap();
809        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
810        assert_eq!(resolved, Value::Number(4.into()));
811    }
812
813    #[test]
814    fn fn_to_json_string_serializes() {
815        let (p, r, ids, attrs) = empty();
816        let v: Value =
817            serde_json::from_str(r#"{"Fn::ToJsonString": {"a": 1, "b": [2, 3]}}"#).unwrap();
818        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
819        let s = resolved.as_str().unwrap();
820        // Order-insensitive: just verify it parses back.
821        let parsed: Value = serde_json::from_str(s).unwrap();
822        assert_eq!(parsed["a"], serde_json::json!(1));
823        assert_eq!(parsed["b"], serde_json::json!([2, 3]));
824    }
825
826    #[test]
827    fn fn_cidr_carves_subnets() {
828        let (p, r, ids, attrs) = empty();
829        // Carve 10.0.0.0/16 into 4 /24 subnets (cidr_bits = 8 host bits).
830        let v: Value = serde_json::from_str(r#"{"Fn::Cidr": ["10.0.0.0/16", 4, 8]}"#).unwrap();
831        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
832        assert_eq!(
833            resolved,
834            serde_json::json!(["10.0.0.0/24", "10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24",])
835        );
836    }
837
838    #[test]
839    fn condition_skips_resource_when_false() {
840        let template = r#"{
841            "Parameters": {"Env": {"Type": "String"}},
842            "Conditions": {
843                "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
844            },
845            "Resources": {
846                "ProdQueue": {
847                    "Type": "AWS::SQS::Queue",
848                    "Condition": "IsProd",
849                    "Properties": {"QueueName": "prod-q"}
850                },
851                "AlwaysQueue": {
852                    "Type": "AWS::SQS::Queue",
853                    "Properties": {"QueueName": "always-q"}
854                }
855            }
856        }"#;
857        let mut params = BTreeMap::new();
858        params.insert("Env".to_string(), "dev".to_string());
859        let parsed = parse_template(template, &params).unwrap();
860        let names: Vec<&str> = parsed
861            .resources
862            .iter()
863            .map(|r| r.logical_id.as_str())
864            .collect();
865        assert!(names.contains(&"AlwaysQueue"));
866        assert!(!names.contains(&"ProdQueue"));
867    }
868
869    #[test]
870    fn condition_includes_resource_when_true() {
871        let template = r#"{
872            "Parameters": {"Env": {"Type": "String"}},
873            "Conditions": {
874                "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
875            },
876            "Resources": {
877                "ProdQueue": {
878                    "Type": "AWS::SQS::Queue",
879                    "Condition": "IsProd",
880                    "Properties": {"QueueName": "prod-q"}
881                }
882            }
883        }"#;
884        let mut params = BTreeMap::new();
885        params.insert("Env".to_string(), "prod".to_string());
886        let parsed = parse_template(template, &params).unwrap();
887        assert_eq!(parsed.resources.len(), 1);
888    }
889
890    #[test]
891    fn fn_if_picks_branch_based_on_condition() {
892        let template = r#"{
893            "Parameters": {"Env": {"Type": "String"}},
894            "Conditions": {
895                "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
896            },
897            "Resources": {
898                "Q": {
899                    "Type": "AWS::SQS::Queue",
900                    "Properties": {
901                        "QueueName": {"Fn::If": ["IsProd", "prod-q", "dev-q"]}
902                    }
903                }
904            }
905        }"#;
906        let mut params = BTreeMap::new();
907        params.insert("Env".to_string(), "dev".to_string());
908        let parsed = parse_template(template, &params).unwrap();
909        assert_eq!(
910            parsed.resources[0].properties["QueueName"],
911            Value::String("dev-q".to_string())
912        );
913    }
914
915    #[test]
916    fn fn_and_or_not_combine_conditions() {
917        let template = r#"{
918            "Parameters": {"Env": {"Type": "String"}, "Region": {"Type": "String"}},
919            "Conditions": {
920                "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
921                "IsUsEast": {"Fn::Equals": [{"Ref": "Region"}, "us-east-1"]},
922                "IsProdInUsEast": {"Fn::And": [{"Condition": "IsProd"}, {"Condition": "IsUsEast"}]},
923                "IsNotProd": {"Fn::Not": [{"Condition": "IsProd"}]},
924                "IsAny": {"Fn::Or": [{"Condition": "IsProd"}, {"Condition": "IsNotProd"}]}
925            },
926            "Resources": {
927                "Q": {
928                    "Type": "AWS::SQS::Queue",
929                    "Properties": {
930                        "P1": {"Fn::If": ["IsProdInUsEast", "yes", "no"]},
931                        "P2": {"Fn::If": ["IsNotProd", "yes", "no"]},
932                        "P3": {"Fn::If": ["IsAny", "yes", "no"]}
933                    }
934                }
935            }
936        }"#;
937        let mut params = BTreeMap::new();
938        params.insert("Env".to_string(), "prod".to_string());
939        params.insert("Region".to_string(), "us-east-1".to_string());
940        let parsed = parse_template(template, &params).unwrap();
941        let p = &parsed.resources[0].properties;
942        assert_eq!(p["P1"], Value::String("yes".to_string()));
943        assert_eq!(p["P2"], Value::String("no".to_string()));
944        assert_eq!(p["P3"], Value::String("yes".to_string()));
945    }
946
947    #[test]
948    fn fn_find_in_map_resolves_leaf_value() {
949        let template = r#"{
950            "Mappings": {
951                "RegionMap": {
952                    "us-east-1": {"AMI": "ami-east"},
953                    "us-west-2": {"AMI": "ami-west"}
954                }
955            },
956            "Resources": {
957                "Inst": {
958                    "Type": "AWS::EC2::Instance",
959                    "Properties": {
960                        "ImageId": {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}
961                    }
962                }
963            }
964        }"#;
965        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
966        assert_eq!(
967            parsed.resources[0].properties["ImageId"],
968            Value::String("ami-east".to_string())
969        );
970    }
971
972    #[test]
973    fn fn_find_in_map_resolves_keys_via_ref() {
974        let template = r#"{
975            "Parameters": {"Region": {"Type": "String"}},
976            "Mappings": {
977                "RegionMap": {
978                    "us-east-1": {"AMI": "ami-east"},
979                    "us-west-2": {"AMI": "ami-west"}
980                }
981            },
982            "Resources": {
983                "Inst": {
984                    "Type": "AWS::EC2::Instance",
985                    "Properties": {
986                        "ImageId": {"Fn::FindInMap": ["RegionMap", {"Ref": "Region"}, "AMI"]}
987                    }
988                }
989            }
990        }"#;
991        let mut params = BTreeMap::new();
992        params.insert("Region".to_string(), "us-west-2".to_string());
993        let parsed = parse_template(template, &params).unwrap();
994        assert_eq!(
995            parsed.resources[0].properties["ImageId"],
996            Value::String("ami-west".to_string())
997        );
998    }
999
1000    #[test]
1001    fn fn_find_in_map_unknown_keys_returns_error() {
1002        let template = r#"{
1003            "Mappings": {
1004                "RegionMap": {
1005                    "us-east-1": {"AMI": "ami-east"}
1006                }
1007            },
1008            "Resources": {
1009                "Inst": {
1010                    "Type": "AWS::EC2::Instance",
1011                    "Properties": {
1012                        "ImageId": {"Fn::FindInMap": ["RegionMap", "ap-south-1", "AMI"]}
1013                    }
1014                }
1015            }
1016        }"#;
1017        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
1018        assert!(
1019            err.contains("Unable to get mapping for RegionMap::ap-south-1::AMI"),
1020            "got: {err}"
1021        );
1022    }
1023
1024    #[test]
1025    fn fn_find_in_map_four_arg_returns_default_when_missing() {
1026        let template = r#"{
1027            "Mappings": {
1028                "RegionMap": {
1029                    "us-east-1": {"AMI": "ami-east"}
1030                }
1031            },
1032            "Resources": {
1033                "Inst": {
1034                    "Type": "AWS::EC2::Instance",
1035                    "Properties": {
1036                        "ImageId": {"Fn::FindInMap": [
1037                            "RegionMap",
1038                            "ap-south-1",
1039                            "AMI",
1040                            {"DefaultValue": "ami-fallback"}
1041                        ]}
1042                    }
1043                }
1044            }
1045        }"#;
1046        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1047        assert_eq!(
1048            parsed.resources[0].properties["ImageId"],
1049            Value::String("ami-fallback".to_string())
1050        );
1051    }
1052
1053    #[test]
1054    fn fn_find_in_map_four_arg_prefers_match_over_default() {
1055        let template = r#"{
1056            "Mappings": {
1057                "RegionMap": {
1058                    "us-east-1": {"AMI": "ami-east"}
1059                }
1060            },
1061            "Resources": {
1062                "Inst": {
1063                    "Type": "AWS::EC2::Instance",
1064                    "Properties": {
1065                        "ImageId": {"Fn::FindInMap": [
1066                            "RegionMap",
1067                            "us-east-1",
1068                            "AMI",
1069                            {"DefaultValue": "ami-fallback"}
1070                        ]}
1071                    }
1072                }
1073            }
1074        }"#;
1075        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1076        assert_eq!(
1077            parsed.resources[0].properties["ImageId"],
1078            Value::String("ami-east".to_string())
1079        );
1080    }
1081
1082    #[test]
1083    fn fn_find_in_map_default_value_is_resolved_intrinsic() {
1084        let template = r#"{
1085            "Parameters": {"Fallback": {"Type": "String"}},
1086            "Mappings": {
1087                "RegionMap": {
1088                    "us-east-1": {"AMI": "ami-east"}
1089                }
1090            },
1091            "Resources": {
1092                "Inst": {
1093                    "Type": "AWS::EC2::Instance",
1094                    "Properties": {
1095                        "ImageId": {"Fn::FindInMap": [
1096                            "RegionMap",
1097                            "ap-south-1",
1098                            "AMI",
1099                            {"DefaultValue": {"Ref": "Fallback"}}
1100                        ]}
1101                    }
1102                }
1103            }
1104        }"#;
1105        let mut params = BTreeMap::new();
1106        params.insert("Fallback".to_string(), "ami-default".to_string());
1107        let parsed = parse_template(template, &params).unwrap();
1108        assert_eq!(
1109            parsed.resources[0].properties["ImageId"],
1110            Value::String("ami-default".to_string())
1111        );
1112    }
1113
1114    #[test]
1115    fn fn_find_in_map_unknown_map_name_errors() {
1116        let template = r#"{
1117            "Mappings": {
1118                "RegionMap": {
1119                    "us-east-1": {"AMI": "ami-east"}
1120                }
1121            },
1122            "Resources": {
1123                "Inst": {
1124                    "Type": "AWS::EC2::Instance",
1125                    "Properties": {
1126                        "ImageId": {"Fn::FindInMap": ["DoesNotExist", "us-east-1", "AMI"]}
1127                    }
1128                }
1129            }
1130        }"#;
1131        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
1132        assert!(
1133            err.contains("Unable to get mapping for DoesNotExist::us-east-1::AMI"),
1134            "got: {err}"
1135        );
1136    }
1137
1138    #[test]
1139    fn fn_find_in_map_wrong_arg_count_errors() {
1140        let template = r#"{
1141            "Mappings": {"M": {"a": {"b": "c"}}},
1142            "Resources": {
1143                "Q": {
1144                    "Type": "AWS::SQS::Queue",
1145                    "Properties": {
1146                        "QueueName": {"Fn::FindInMap": ["M", "a"]}
1147                    }
1148                }
1149            }
1150        }"#;
1151        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
1152        assert!(
1153            err.contains("Fn::FindInMap requires 3 or 4 arguments"),
1154            "got: {err}"
1155        );
1156    }
1157
1158    #[test]
1159    fn fn_find_in_map_resolves_via_pseudo_region() {
1160        let template = r#"{
1161            "Mappings": {
1162                "RegionMap": {
1163                    "us-east-1": {"AMI": "ami-east"},
1164                    "us-west-2": {"AMI": "ami-west"}
1165                }
1166            },
1167            "Resources": {
1168                "Inst": {
1169                    "Type": "AWS::EC2::Instance",
1170                    "Properties": {
1171                        "ImageId": {"Fn::FindInMap": [
1172                            "RegionMap",
1173                            {"Ref": "AWS::Region"},
1174                            "AMI"
1175                        ]}
1176                    }
1177                }
1178            }
1179        }"#;
1180        // No AWS::Region in parameters — the pseudo-default ("us-east-1")
1181        // should kick in so FindInMap still resolves.
1182        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1183        assert_eq!(
1184            parsed.resources[0].properties["ImageId"],
1185            Value::String("ami-east".to_string())
1186        );
1187    }
1188
1189    #[test]
1190    fn fn_find_in_map_in_unused_if_branch_does_not_error() {
1191        // FindInMap sits in the FALSE branch of Fn::If; the path
1192        // `RegionMap::ap-south-1::AMI` doesn't exist. Because
1193        // `WantAlt` resolves to "no" the alt branch is unused and
1194        // CFN never executes that FindInMap — parse_template must
1195        // succeed instead of erroring.
1196        let template = r#"{
1197            "Parameters": {"WantAlt": {"Type": "String"}},
1198            "Conditions": {
1199                "UseAlt": {"Fn::Equals": [{"Ref": "WantAlt"}, "yes"]}
1200            },
1201            "Mappings": {
1202                "RegionMap": {
1203                    "us-east-1": {"AMI": "ami-east"}
1204                }
1205            },
1206            "Resources": {
1207                "Inst": {
1208                    "Type": "AWS::EC2::Instance",
1209                    "Properties": {
1210                        "ImageId": {"Fn::If": [
1211                            "UseAlt",
1212                            {"Fn::FindInMap": ["RegionMap", "ap-south-1", "AMI"]},
1213                            {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}
1214                        ]}
1215                    }
1216                }
1217            }
1218        }"#;
1219        let mut params = BTreeMap::new();
1220        params.insert("WantAlt".to_string(), "no".to_string());
1221        let parsed = parse_template(template, &params).unwrap();
1222        assert_eq!(
1223            parsed.resources[0].properties["ImageId"],
1224            Value::String("ami-east".to_string())
1225        );
1226    }
1227
1228    #[test]
1229    fn fn_find_in_map_in_active_if_branch_still_errors_on_miss() {
1230        // Same shape as above but the active branch is the broken
1231        // one; the strict miss handling must still surface.
1232        let template = r#"{
1233            "Parameters": {"WantAlt": {"Type": "String"}},
1234            "Conditions": {
1235                "UseAlt": {"Fn::Equals": [{"Ref": "WantAlt"}, "yes"]}
1236            },
1237            "Mappings": {
1238                "RegionMap": {
1239                    "us-east-1": {"AMI": "ami-east"}
1240                }
1241            },
1242            "Resources": {
1243                "Inst": {
1244                    "Type": "AWS::EC2::Instance",
1245                    "Properties": {
1246                        "ImageId": {"Fn::If": [
1247                            "UseAlt",
1248                            {"Fn::FindInMap": ["RegionMap", "ap-south-1", "AMI"]},
1249                            {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}
1250                        ]}
1251                    }
1252                }
1253            }
1254        }"#;
1255        let mut params = BTreeMap::new();
1256        params.insert("WantAlt".to_string(), "yes".to_string());
1257        let err = parse_template(template, &params).unwrap_err();
1258        assert!(
1259            err.contains("Unable to get mapping for RegionMap::ap-south-1::AMI"),
1260            "got: {err}"
1261        );
1262    }
1263
1264    #[test]
1265    fn fn_find_in_map_alongside_ref_and_sub_still_resolve() {
1266        let template = r#"{
1267            "Parameters": {"Env": {"Type": "String"}},
1268            "Mappings": {
1269                "EnvMap": {
1270                    "prod": {"Suffix": "live"},
1271                    "dev": {"Suffix": "test"}
1272                }
1273            },
1274            "Resources": {
1275                "Q": {
1276                    "Type": "AWS::SQS::Queue",
1277                    "Properties": {
1278                        "QueueName": {"Fn::FindInMap": ["EnvMap", {"Ref": "Env"}, "Suffix"]},
1279                        "Tags": [
1280                            {"Key": "EnvRef", "Value": {"Ref": "Env"}},
1281                            {"Key": "Subbed", "Value": {"Fn::Sub": "env-${Env}"}}
1282                        ]
1283                    }
1284                }
1285            }
1286        }"#;
1287        let mut params = BTreeMap::new();
1288        params.insert("Env".to_string(), "prod".to_string());
1289        let parsed = parse_template(template, &params).unwrap();
1290        let p = &parsed.resources[0].properties;
1291        assert_eq!(p["QueueName"], Value::String("live".to_string()));
1292        assert_eq!(p["Tags"][0]["Value"], Value::String("prod".to_string()));
1293        assert_eq!(p["Tags"][1]["Value"], Value::String("env-prod".to_string()));
1294    }
1295
1296    // ── Conditions: cycle detection + AWS::NoValue removal ──
1297
1298    #[test]
1299    fn cyclic_conditions_self_reference_errors() {
1300        let template = r#"{
1301            "Conditions": {
1302                "A": {"Condition": "A"}
1303            },
1304            "Resources": {
1305                "Q": {
1306                    "Type": "AWS::SQS::Queue",
1307                    "Condition": "A",
1308                    "Properties": {"QueueName": "q"}
1309                }
1310            }
1311        }"#;
1312        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
1313        assert!(err.contains("Circular reference"), "got: {err}");
1314        assert!(err.contains("'A'"), "got: {err}");
1315    }
1316
1317    #[test]
1318    fn cyclic_conditions_two_step_errors() {
1319        let template = r#"{
1320            "Conditions": {
1321                "A": {"Condition": "B"},
1322                "B": {"Condition": "A"}
1323            },
1324            "Resources": {
1325                "Q": {
1326                    "Type": "AWS::SQS::Queue",
1327                    "Condition": "A",
1328                    "Properties": {"QueueName": "q"}
1329                }
1330            }
1331        }"#;
1332        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
1333        assert!(err.contains("Circular reference"), "got: {err}");
1334    }
1335
1336    #[test]
1337    fn condition_referencing_undefined_name_errors() {
1338        let template = r#"{
1339            "Conditions": {
1340                "A": {"Condition": "DoesNotExist"}
1341            },
1342            "Resources": {
1343                "Q": {
1344                    "Type": "AWS::SQS::Queue",
1345                    "Condition": "A",
1346                    "Properties": {"QueueName": "q"}
1347                }
1348            }
1349        }"#;
1350        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
1351        assert!(err.contains("DoesNotExist"), "got: {err}");
1352    }
1353
1354    #[test]
1355    fn fn_if_no_value_removes_property_from_parent_map() {
1356        let template = r#"{
1357            "Parameters": {"WantTags": {"Type": "String"}},
1358            "Conditions": {
1359                "HasTags": {"Fn::Equals": [{"Ref": "WantTags"}, "yes"]}
1360            },
1361            "Resources": {
1362                "Q": {
1363                    "Type": "AWS::SQS::Queue",
1364                    "Properties": {
1365                        "QueueName": "q",
1366                        "Tags": {"Fn::If": [
1367                            "HasTags",
1368                            [{"Key": "a", "Value": "b"}],
1369                            {"Ref": "AWS::NoValue"}
1370                        ]}
1371                    }
1372                }
1373            }
1374        }"#;
1375        let mut params = BTreeMap::new();
1376        params.insert("WantTags".to_string(), "no".to_string());
1377        let parsed = parse_template(template, &params).unwrap();
1378        let props = parsed.resources[0].properties.as_object().unwrap();
1379        assert!(
1380            !props.contains_key("Tags"),
1381            "Tags should be omitted when AWS::NoValue picked, got: {props:?}"
1382        );
1383        assert_eq!(
1384            props.get("QueueName"),
1385            Some(&Value::String("q".to_string()))
1386        );
1387    }
1388
1389    #[test]
1390    fn fn_if_no_value_keeps_property_when_branch_concrete() {
1391        let template = r#"{
1392            "Parameters": {"WantTags": {"Type": "String"}},
1393            "Conditions": {
1394                "HasTags": {"Fn::Equals": [{"Ref": "WantTags"}, "yes"]}
1395            },
1396            "Resources": {
1397                "Q": {
1398                    "Type": "AWS::SQS::Queue",
1399                    "Properties": {
1400                        "QueueName": "q",
1401                        "Tags": {"Fn::If": [
1402                            "HasTags",
1403                            [{"Key": "a", "Value": "b"}],
1404                            {"Ref": "AWS::NoValue"}
1405                        ]}
1406                    }
1407                }
1408            }
1409        }"#;
1410        let mut params = BTreeMap::new();
1411        params.insert("WantTags".to_string(), "yes".to_string());
1412        let parsed = parse_template(template, &params).unwrap();
1413        let tags = &parsed.resources[0].properties["Tags"];
1414        assert_eq!(
1415            tags,
1416            &serde_json::json!([{"Key": "a", "Value": "b"}]),
1417            "tags should be the true branch's array"
1418        );
1419    }
1420
1421    #[test]
1422    fn fn_if_no_value_in_array_drops_element() {
1423        let template = r#"{
1424            "Parameters": {"Extra": {"Type": "String"}},
1425            "Conditions": {
1426                "HasExtra": {"Fn::Equals": [{"Ref": "Extra"}, "yes"]}
1427            },
1428            "Resources": {
1429                "Q": {
1430                    "Type": "AWS::SQS::Queue",
1431                    "Properties": {
1432                        "Items": [
1433                            "first",
1434                            {"Fn::If": ["HasExtra", "second", {"Ref": "AWS::NoValue"}]},
1435                            "third"
1436                        ]
1437                    }
1438                }
1439            }
1440        }"#;
1441        let mut params = BTreeMap::new();
1442        params.insert("Extra".to_string(), "no".to_string());
1443        let parsed = parse_template(template, &params).unwrap();
1444        assert_eq!(
1445            parsed.resources[0].properties["Items"],
1446            serde_json::json!(["first", "third"])
1447        );
1448    }
1449
1450    #[test]
1451    fn condition_skips_output_when_false() {
1452        let template = r#"{
1453            "Parameters": {"Env": {"Type": "String"}},
1454            "Conditions": {
1455                "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
1456            },
1457            "Resources": {
1458                "Q": {
1459                    "Type": "AWS::SQS::Queue",
1460                    "Properties": {"QueueName": "q"}
1461                }
1462            },
1463            "Outputs": {
1464                "ProdName": {
1465                    "Condition": "IsProd",
1466                    "Value": "prod-only"
1467                },
1468                "Always": {
1469                    "Value": "shown"
1470                }
1471            }
1472        }"#;
1473        let mut params = BTreeMap::new();
1474        params.insert("Env".to_string(), "dev".to_string());
1475        let parsed = parse_template(template, &params).unwrap();
1476        let names: Vec<&str> = parsed
1477            .outputs
1478            .iter()
1479            .map(|o| o.logical_id.as_str())
1480            .collect();
1481        assert!(names.contains(&"Always"));
1482        assert!(!names.contains(&"ProdName"));
1483    }
1484
1485    #[test]
1486    fn fn_and_short_circuits_on_false() {
1487        let template = r#"{
1488            "Parameters": {"Env": {"Type": "String"}},
1489            "Conditions": {
1490                "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
1491                "Combined": {"Fn::And": [
1492                    {"Condition": "IsProd"},
1493                    {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
1494                ]}
1495            },
1496            "Resources": {
1497                "Q": {
1498                    "Type": "AWS::SQS::Queue",
1499                    "Condition": "Combined",
1500                    "Properties": {"QueueName": "q"}
1501                }
1502            }
1503        }"#;
1504        let mut params = BTreeMap::new();
1505        params.insert("Env".to_string(), "dev".to_string());
1506        let parsed = parse_template(template, &params).unwrap();
1507        assert_eq!(parsed.resources.len(), 0);
1508    }
1509
1510    #[test]
1511    fn fn_or_short_circuits_on_true() {
1512        let template = r#"{
1513            "Parameters": {"Env": {"Type": "String"}},
1514            "Conditions": {
1515                "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
1516                "AnyEnv": {"Fn::Or": [
1517                    {"Condition": "IsProd"},
1518                    {"Fn::Equals": [{"Ref": "Env"}, "dev"]},
1519                    {"Fn::Equals": [{"Ref": "Env"}, "stage"]}
1520                ]}
1521            },
1522            "Resources": {
1523                "Q": {
1524                    "Type": "AWS::SQS::Queue",
1525                    "Condition": "AnyEnv",
1526                    "Properties": {"QueueName": "q"}
1527                }
1528            }
1529        }"#;
1530        let mut params = BTreeMap::new();
1531        params.insert("Env".to_string(), "stage".to_string());
1532        let parsed = parse_template(template, &params).unwrap();
1533        assert_eq!(parsed.resources.len(), 1);
1534    }
1535
1536    #[test]
1537    fn fn_and_rejects_arity_outside_1_to_10() {
1538        let template = r#"{
1539            "Conditions": {
1540                "Empty": {"Fn::And": []}
1541            },
1542            "Resources": {
1543                "Q": {
1544                    "Type": "AWS::SQS::Queue",
1545                    "Condition": "Empty",
1546                    "Properties": {"QueueName": "q"}
1547                }
1548            }
1549        }"#;
1550        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
1551        assert!(err.contains("Fn::And"), "got: {err}");
1552    }
1553
1554    #[test]
1555    fn condition_evaluation_memoizes_complex_expression() {
1556        // Both `Outer` branches reuse `Inner`. With memoization the
1557        // inner condition only resolves once; without it, this would
1558        // still pass — but the test guards against regressions where
1559        // re-evaluation triggers double Fn::Equals work.
1560        let template = r#"{
1561            "Parameters": {"Env": {"Type": "String"}},
1562            "Conditions": {
1563                "Inner": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
1564                "OuterA": {"Fn::And": [{"Condition": "Inner"}, {"Condition": "Inner"}]},
1565                "OuterB": {"Fn::Or": [{"Condition": "Inner"}, {"Condition": "OuterA"}]}
1566            },
1567            "Resources": {
1568                "Q": {
1569                    "Type": "AWS::SQS::Queue",
1570                    "Condition": "OuterB",
1571                    "Properties": {"QueueName": "q"}
1572                }
1573            }
1574        }"#;
1575        let mut params = BTreeMap::new();
1576        params.insert("Env".to_string(), "prod".to_string());
1577        let parsed = parse_template(template, &params).unwrap();
1578        assert_eq!(parsed.resources.len(), 1);
1579    }
1580
1581    #[test]
1582    fn fn_not_rejects_multiple_arguments() {
1583        let template = r#"{
1584            "Parameters": {"Env": {"Type": "String"}},
1585            "Conditions": {
1586                "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
1587                "Bad": {"Fn::Not": [
1588                    {"Condition": "IsProd"},
1589                    {"Condition": "IsProd"}
1590                ]}
1591            },
1592            "Resources": {
1593                "Q": {
1594                    "Type": "AWS::SQS::Queue",
1595                    "Condition": "Bad",
1596                    "Properties": {"QueueName": "q"}
1597                }
1598            }
1599        }"#;
1600        let mut params = BTreeMap::new();
1601        params.insert("Env".to_string(), "prod".to_string());
1602        let err = parse_template(template, &params).unwrap_err();
1603        assert!(err.contains("Fn::Not"), "got: {err}");
1604    }
1605
1606    #[test]
1607    fn fn_not_rejects_zero_arguments() {
1608        let template = r#"{
1609            "Conditions": {
1610                "Bad": {"Fn::Not": []}
1611            },
1612            "Resources": {
1613                "Q": {
1614                    "Type": "AWS::SQS::Queue",
1615                    "Condition": "Bad",
1616                    "Properties": {"QueueName": "q"}
1617                }
1618            }
1619        }"#;
1620        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
1621        assert!(err.contains("Fn::Not"), "got: {err}");
1622    }
1623
1624    #[test]
1625    fn resolve_resource_properties_strips_no_value_at_provision_time() {
1626        // Mirrors the incremental-provisioning code path which calls
1627        // resolve_resource_properties_with_attrs after the initial parse.
1628        // The sentinel must not leak into the resolved properties even
1629        // when re-resolved with updated physical IDs.
1630        let template = r#"{
1631            "Parameters": {"WantTags": {"Type": "String"}},
1632            "Conditions": {
1633                "HasTags": {"Fn::Equals": [{"Ref": "WantTags"}, "yes"]}
1634            },
1635            "Resources": {
1636                "Q": {
1637                    "Type": "AWS::SQS::Queue",
1638                    "Properties": {
1639                        "QueueName": "q",
1640                        "Tags": {"Fn::If": [
1641                            "HasTags",
1642                            [{"Key": "a", "Value": "b"}],
1643                            {"Ref": "AWS::NoValue"}
1644                        ]}
1645                    }
1646                }
1647            }
1648        }"#;
1649        let mut params = BTreeMap::new();
1650        params.insert("WantTags".to_string(), "no".to_string());
1651        let parsed = parse_template(template, &params).unwrap();
1652        let resource = parsed
1653            .resources
1654            .iter()
1655            .find(|r| r.logical_id == "Q")
1656            .unwrap();
1657        // First parse already strips Tags.
1658        assert!(!resource
1659            .properties
1660            .as_object()
1661            .unwrap()
1662            .contains_key("Tags"));
1663
1664        // Re-resolve with empty physical IDs (mid-provisioning). The
1665        // sentinel must still be stripped — no `__fakecloud_aws_no_value__`
1666        // marker should reach the caller.
1667        let reresolved = resolve_resource_properties_with_attrs(
1668            resource,
1669            template,
1670            &params,
1671            &BTreeMap::new(),
1672            &BTreeMap::new(),
1673        )
1674        .unwrap();
1675        let props = reresolved.properties.as_object().unwrap();
1676        assert!(
1677            !props.contains_key("Tags"),
1678            "Tags should be stripped on re-resolve, got: {props:?}"
1679        );
1680        // Sanity: serialized form must not contain the sentinel key.
1681        let serialized = serde_json::to_string(&reresolved.properties).unwrap();
1682        assert!(
1683            !serialized.contains(NO_VALUE_SENTINEL_KEY),
1684            "sentinel leaked: {serialized}"
1685        );
1686    }
1687
1688    // ── BB5: Fn::Select / Split / Base64 / Cidr / Length / ToJsonString / ForEach ──
1689
1690    #[test]
1691    fn fn_select_string_index_resolves() {
1692        // CFN accepts the index as a string literal (`"0"`) — CFN's
1693        // own examples do this, so the engine must coerce.
1694        let (p, r, ids, attrs) = empty();
1695        let v: Value = serde_json::from_str(r#"{"Fn::Select": ["2", ["a", "b", "c", "d"]]}"#)
1696            .expect("static fixture parses");
1697        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1698        assert_eq!(resolved, Value::String("c".to_string()));
1699    }
1700
1701    #[test]
1702    fn fn_select_out_of_range_returns_null() {
1703        let (p, r, ids, attrs) = empty();
1704        let v: Value = serde_json::from_str(r#"{"Fn::Select": [10, ["a", "b"]]}"#)
1705            .expect("static fixture parses");
1706        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1707        assert_eq!(resolved, Value::Null);
1708    }
1709
1710    #[test]
1711    fn fn_select_resolves_ref_inside_list() {
1712        let template = r#"{
1713            "Parameters": {"AZs": {"Type": "CommaDelimitedList"}},
1714            "Resources": {
1715                "Q": {
1716                    "Type": "AWS::SQS::Queue",
1717                    "Properties": {
1718                        "QueueName": {"Fn::Select": [0, {"Fn::Split": [",", {"Ref": "AZs"}]}]}
1719                    }
1720                }
1721            }
1722        }"#;
1723        let mut params = BTreeMap::new();
1724        params.insert(
1725            "AZs".to_string(),
1726            "us-east-1a,us-east-1b,us-east-1c".to_string(),
1727        );
1728        let parsed = parse_template(template, &params).unwrap();
1729        assert_eq!(
1730            parsed.resources[0].properties["QueueName"],
1731            Value::String("us-east-1a".to_string())
1732        );
1733    }
1734
1735    #[test]
1736    fn fn_split_empty_delimiter_returns_full_string_split_per_char() {
1737        let (p, r, ids, attrs) = empty();
1738        let v: Value =
1739            serde_json::from_str(r#"{"Fn::Split": ["", "abc"]}"#).expect("static fixture parses");
1740        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1741        // str::split("") yields `["", "a", "b", "c", ""]` in Rust.
1742        // CFN's behavior with empty delimiter is undefined, but match
1743        // the underlying primitive so callers can reason about it.
1744        assert!(resolved.is_array());
1745    }
1746
1747    #[test]
1748    fn fn_split_no_match_returns_single_element_array() {
1749        let (p, r, ids, attrs) = empty();
1750        let v: Value = serde_json::from_str(r#"{"Fn::Split": [",", "no-commas-here"]}"#)
1751            .expect("static fixture parses");
1752        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1753        assert_eq!(resolved, serde_json::json!(["no-commas-here"]));
1754    }
1755
1756    #[test]
1757    fn fn_base64_encodes_unicode() {
1758        let (p, r, ids, attrs) = empty();
1759        let v: Value =
1760            serde_json::from_str(r#"{"Fn::Base64": "héllo"}"#).expect("static fixture parses");
1761        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1762        // "héllo" is 6 bytes UTF-8 (h=1, é=2, l=1, l=1, o=1).
1763        assert_eq!(resolved, Value::String("aMOpbGxv".to_string()));
1764    }
1765
1766    #[test]
1767    fn fn_base64_resolves_nested_intrinsic() {
1768        let template = r#"{
1769            "Parameters": {"Greeting": {"Type": "String"}},
1770            "Resources": {
1771                "Q": {
1772                    "Type": "AWS::SQS::Queue",
1773                    "Properties": {
1774                        "QueueName": {"Fn::Base64": {"Ref": "Greeting"}}
1775                    }
1776                }
1777            }
1778        }"#;
1779        let mut params = BTreeMap::new();
1780        params.insert("Greeting".to_string(), "hello".to_string());
1781        let parsed = parse_template(template, &params).unwrap();
1782        assert_eq!(
1783            parsed.resources[0].properties["QueueName"],
1784            Value::String("aGVsbG8=".to_string())
1785        );
1786    }
1787
1788    #[test]
1789    fn fn_length_counts_string_chars() {
1790        let (p, r, ids, attrs) = empty();
1791        let v: Value =
1792            serde_json::from_str(r#"{"Fn::Length": "héllo"}"#).expect("static fixture parses");
1793        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1794        // 5 chars (not 6 bytes) — multibyte counted once.
1795        assert_eq!(resolved, Value::Number(5.into()));
1796    }
1797
1798    #[test]
1799    fn fn_length_resolves_nested_split() {
1800        let (p, r, ids, attrs) = empty();
1801        let v: Value = serde_json::from_str(r#"{"Fn::Length": {"Fn::Split": [",", "a,b,c,d,e"]}}"#)
1802            .expect("static fixture parses");
1803        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1804        assert_eq!(resolved, Value::Number(5.into()));
1805    }
1806
1807    #[test]
1808    fn fn_to_json_string_serializes_array() {
1809        let (p, r, ids, attrs) = empty();
1810        let v: Value = serde_json::from_str(r#"{"Fn::ToJsonString": ["a", "b", "c"]}"#)
1811            .expect("static fixture parses");
1812        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1813        assert_eq!(resolved, Value::String(r#"["a","b","c"]"#.to_string()));
1814    }
1815
1816    #[test]
1817    fn fn_to_json_string_resolves_inner_ref() {
1818        let template = r#"{
1819            "Parameters": {"Name": {"Type": "String"}},
1820            "Resources": {
1821                "Q": {
1822                    "Type": "AWS::SQS::Queue",
1823                    "Properties": {
1824                        "QueueName": {
1825                            "Fn::ToJsonString": {"k": {"Ref": "Name"}}
1826                        }
1827                    }
1828                }
1829            }
1830        }"#;
1831        let mut params = BTreeMap::new();
1832        params.insert("Name".to_string(), "abc".to_string());
1833        let parsed = parse_template(template, &params).unwrap();
1834        assert_eq!(
1835            parsed.resources[0].properties["QueueName"],
1836            Value::String(r#"{"k":"abc"}"#.to_string())
1837        );
1838    }
1839
1840    #[test]
1841    fn fn_cidr_count_matches_request() {
1842        // Real Fn::Cidr returns up to 2^cidr_bits subnets; we ask for 2
1843        // out of a possible 256, so only 2 land in the output.
1844        let (p, r, ids, attrs) = empty();
1845        let v: Value = serde_json::from_str(r#"{"Fn::Cidr": ["10.0.0.0/16", 2, 8]}"#)
1846            .expect("static fixture parses");
1847        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1848        assert_eq!(resolved, serde_json::json!(["10.0.0.0/24", "10.0.1.0/24"]));
1849    }
1850
1851    #[test]
1852    fn fn_cidr_resolves_via_ref() {
1853        let template = r#"{
1854            "Parameters": {"Vpc": {"Type": "String"}},
1855            "Resources": {
1856                "Q": {
1857                    "Type": "AWS::SQS::Queue",
1858                    "Properties": {
1859                        "QueueName": {"Fn::Select": [
1860                            0,
1861                            {"Fn::Cidr": [{"Ref": "Vpc"}, 4, 8]}
1862                        ]}
1863                    }
1864                }
1865            }
1866        }"#;
1867        let mut params = BTreeMap::new();
1868        params.insert("Vpc".to_string(), "172.16.0.0/16".to_string());
1869        let parsed = parse_template(template, &params).unwrap();
1870        assert_eq!(
1871            parsed.resources[0].properties["QueueName"],
1872            Value::String("172.16.0.0/24".to_string())
1873        );
1874    }
1875
1876    #[test]
1877    fn fn_for_each_expands_resources() {
1878        let template = r#"{
1879            "Resources": {
1880                "Fn::ForEach::TopicLoop": [
1881                    "TopicName",
1882                    ["alpha", "beta", "gamma"],
1883                    {
1884                        "${TopicName}Topic": {
1885                            "Type": "AWS::SNS::Topic",
1886                            "Properties": {"TopicName": "${TopicName}-topic"}
1887                        }
1888                    }
1889                ]
1890            }
1891        }"#;
1892        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1893        let names: Vec<&str> = parsed
1894            .resources
1895            .iter()
1896            .map(|r| r.logical_id.as_str())
1897            .collect();
1898        assert!(names.contains(&"alphaTopic"), "got: {names:?}");
1899        assert!(names.contains(&"betaTopic"), "got: {names:?}");
1900        assert!(names.contains(&"gammaTopic"), "got: {names:?}");
1901        let alpha = parsed
1902            .resources
1903            .iter()
1904            .find(|r| r.logical_id == "alphaTopic")
1905            .unwrap();
1906        assert_eq!(
1907            alpha.properties["TopicName"],
1908            Value::String("alpha-topic".to_string())
1909        );
1910    }
1911
1912    #[test]
1913    fn fn_for_each_substitutes_in_nested_values() {
1914        let template = r#"{
1915            "Resources": {
1916                "Fn::ForEach::Q": [
1917                    "QName",
1918                    ["one", "two"],
1919                    {
1920                        "${QName}Queue": {
1921                            "Type": "AWS::SQS::Queue",
1922                            "Properties": {
1923                                "QueueName": "${QName}",
1924                                "Tags": [
1925                                    {"Key": "name", "Value": "${QName}"}
1926                                ]
1927                            }
1928                        }
1929                    }
1930                ]
1931            }
1932        }"#;
1933        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1934        let one = parsed
1935            .resources
1936            .iter()
1937            .find(|r| r.logical_id == "oneQueue")
1938            .unwrap();
1939        assert_eq!(
1940            one.properties["QueueName"],
1941            Value::String("one".to_string())
1942        );
1943        assert_eq!(
1944            one.properties["Tags"][0]["Value"],
1945            Value::String("one".to_string())
1946        );
1947    }
1948
1949    #[test]
1950    fn fn_for_each_nested_loops_expand_cartesian() {
1951        let template = r#"{
1952            "Resources": {
1953                "Fn::ForEach::Outer": [
1954                    "Env",
1955                    ["dev", "prod"],
1956                    {
1957                        "Fn::ForEach::Inner": [
1958                            "Region",
1959                            ["us-east-1", "eu-west-1"],
1960                            {
1961                                "${Env}${Region}Q": {
1962                                    "Type": "AWS::SQS::Queue",
1963                                    "Properties": {"QueueName": "${Env}-${Region}"}
1964                                }
1965                            }
1966                        ]
1967                    }
1968                ]
1969            }
1970        }"#;
1971        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1972        let names: Vec<&str> = parsed
1973            .resources
1974            .iter()
1975            .map(|r| r.logical_id.as_str())
1976            .collect();
1977        for env in ["dev", "prod"] {
1978            for region in ["us-east-1", "eu-west-1"] {
1979                let expected = format!("{env}{region}Q");
1980                assert!(
1981                    names.contains(&expected.as_str()),
1982                    "missing {expected} in {names:?}"
1983                );
1984            }
1985        }
1986        let dev_us = parsed
1987            .resources
1988            .iter()
1989            .find(|r| r.logical_id == "devus-east-1Q")
1990            .unwrap();
1991        assert_eq!(
1992            dev_us.properties["QueueName"],
1993            Value::String("dev-us-east-1".to_string())
1994        );
1995    }
1996
1997    #[test]
1998    fn fn_for_each_keeps_other_resources_untouched() {
1999        let template = r#"{
2000            "Resources": {
2001                "Static": {
2002                    "Type": "AWS::SQS::Queue",
2003                    "Properties": {"QueueName": "static-q"}
2004                },
2005                "Fn::ForEach::Loop": [
2006                    "I",
2007                    ["a", "b"],
2008                    {
2009                        "${I}Topic": {
2010                            "Type": "AWS::SNS::Topic",
2011                            "Properties": {"TopicName": "${I}"}
2012                        }
2013                    }
2014                ]
2015            }
2016        }"#;
2017        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2018        let names: Vec<&str> = parsed
2019            .resources
2020            .iter()
2021            .map(|r| r.logical_id.as_str())
2022            .collect();
2023        assert!(names.contains(&"Static"));
2024        assert!(names.contains(&"aTopic"));
2025        assert!(names.contains(&"bTopic"));
2026        assert_eq!(parsed.resources.len(), 3);
2027    }
2028
2029    #[test]
2030    fn fn_for_each_invalid_arity_errors() {
2031        let template = r#"{
2032            "Resources": {
2033                "Fn::ForEach::Bad": [
2034                    "Var",
2035                    ["a"]
2036                ]
2037            }
2038        }"#;
2039        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
2040        assert!(err.contains("Fn::ForEach"), "got: {err}");
2041    }
2042
2043    #[test]
2044    fn fn_for_each_resolves_intrinsics_in_emitted_resources() {
2045        // Body of the loop references both the loop variable and a
2046        // stack parameter, exercising that downstream intrinsic
2047        // resolution still runs over emitted resources.
2048        let template = r#"{
2049            "Parameters": {"Env": {"Type": "String"}},
2050            "Resources": {
2051                "Fn::ForEach::Q": [
2052                    "Name",
2053                    ["alpha", "beta"],
2054                    {
2055                        "${Name}Queue": {
2056                            "Type": "AWS::SQS::Queue",
2057                            "Properties": {
2058                                "QueueName": {"Fn::Sub": "${Env}-${Name}"}
2059                            }
2060                        }
2061                    }
2062                ]
2063            }
2064        }"#;
2065        let mut params = BTreeMap::new();
2066        params.insert("Env".to_string(), "prod".to_string());
2067        let parsed = parse_template(template, &params).unwrap();
2068        // ${Name} substitutes at ForEach expansion time; ${Env} comes
2069        // from the parameter at Fn::Sub time. Both must land.
2070        let alpha = parsed
2071            .resources
2072            .iter()
2073            .find(|r| r.logical_id == "alphaQueue")
2074            .unwrap();
2075        assert_eq!(
2076            alpha.properties["QueueName"],
2077            Value::String("prod-alpha".to_string())
2078        );
2079    }
2080
2081    #[test]
2082    fn fn_for_each_re_resolves_at_provision_time() {
2083        // resolve_resource_properties_with_attrs must also expand
2084        // ForEach so the looked-up resource by logical ID matches the
2085        // post-expansion template.
2086        let template = r#"{
2087            "Resources": {
2088                "Fn::ForEach::Q": [
2089                    "Name",
2090                    ["alpha"],
2091                    {
2092                        "${Name}Queue": {
2093                            "Type": "AWS::SQS::Queue",
2094                            "Properties": {"QueueName": "${Name}-q"}
2095                        }
2096                    }
2097                ]
2098            }
2099        }"#;
2100        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2101        let resource = parsed
2102            .resources
2103            .iter()
2104            .find(|r| r.logical_id == "alphaQueue")
2105            .unwrap();
2106        let reresolved = resolve_resource_properties_with_attrs(
2107            resource,
2108            template,
2109            &BTreeMap::new(),
2110            &BTreeMap::new(),
2111            &BTreeMap::new(),
2112        )
2113        .unwrap();
2114        assert_eq!(
2115            reresolved.properties["QueueName"],
2116            Value::String("alpha-q".to_string())
2117        );
2118    }
2119
2120    #[test]
2121    fn fn_for_each_resolves_ref_to_comma_delimited_list_param() {
2122        // CommaDelimitedList parameters are a documented ForEach input
2123        // shape. Stack passes the value as a single string; ForEach
2124        // must split it before iterating.
2125        let template = r#"{
2126            "Parameters": {"Names": {"Type": "CommaDelimitedList"}},
2127            "Resources": {
2128                "Fn::ForEach::Q": [
2129                    "N",
2130                    {"Ref": "Names"},
2131                    {
2132                        "${N}Queue": {
2133                            "Type": "AWS::SQS::Queue",
2134                            "Properties": {"QueueName": "${N}-q"}
2135                        }
2136                    }
2137                ]
2138            }
2139        }"#;
2140        let mut params = BTreeMap::new();
2141        params.insert("Names".to_string(), "alpha,beta,gamma".to_string());
2142        let parsed = parse_template(template, &params).unwrap();
2143        let names: Vec<&str> = parsed
2144            .resources
2145            .iter()
2146            .map(|r| r.logical_id.as_str())
2147            .collect();
2148        for v in ["alphaQueue", "betaQueue", "gammaQueue"] {
2149            assert!(names.contains(&v), "missing {v} in {names:?}");
2150        }
2151    }
2152
2153    #[test]
2154    fn fn_for_each_ampersand_substitution_form() {
2155        // AWS supports `&{Var}` in addition to `${Var}` for ForEach
2156        // loop variable substitution; needed when the surrounding
2157        // template separately uses ${}-style for Fn::Sub.
2158        let template = r#"{
2159            "Resources": {
2160                "Fn::ForEach::Q": [
2161                    "Name",
2162                    ["alpha", "beta"],
2163                    {
2164                        "&{Name}Queue": {
2165                            "Type": "AWS::SQS::Queue",
2166                            "Properties": {"QueueName": "&{Name}"}
2167                        }
2168                    }
2169                ]
2170            }
2171        }"#;
2172        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2173        let names: Vec<&str> = parsed
2174            .resources
2175            .iter()
2176            .map(|r| r.logical_id.as_str())
2177            .collect();
2178        assert!(names.contains(&"alphaQueue"), "got: {names:?}");
2179        assert!(names.contains(&"betaQueue"), "got: {names:?}");
2180        let alpha = parsed
2181            .resources
2182            .iter()
2183            .find(|r| r.logical_id == "alphaQueue")
2184            .unwrap();
2185        assert_eq!(
2186            alpha.properties["QueueName"],
2187            Value::String("alpha".to_string())
2188        );
2189    }
2190
2191    #[test]
2192    fn fn_for_each_in_outputs_expands() {
2193        let template = r#"{
2194            "Resources": {
2195                "Q": {"Type": "AWS::SQS::Queue", "Properties": {"QueueName": "q"}}
2196            },
2197            "Outputs": {
2198                "Fn::ForEach::OutputLoop": [
2199                    "I",
2200                    ["one", "two"],
2201                    {
2202                        "${I}Out": {"Value": "${I}-value"}
2203                    }
2204                ]
2205            }
2206        }"#;
2207        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2208        let names: Vec<&str> = parsed
2209            .outputs
2210            .iter()
2211            .map(|o| o.logical_id.as_str())
2212            .collect();
2213        assert!(names.contains(&"oneOut"), "got: {names:?}");
2214        assert!(names.contains(&"twoOut"), "got: {names:?}");
2215        let one = parsed
2216            .outputs
2217            .iter()
2218            .find(|o| o.logical_id == "oneOut")
2219            .unwrap();
2220        assert_eq!(one.value, "one-value");
2221    }
2222}
2223
2224mod conditions;
2225mod for_each;
2226mod intrinsics;
2227mod mappings;
2228mod parser;
2229mod resolution;
2230use conditions::*;
2231use for_each::*;
2232use intrinsics::*;
2233use mappings::*;
2234
2235pub use parser::{
2236    collect_import_value_names, parse_outputs, parse_template, parse_template_with_physical_ids,
2237    parse_template_with_resolution,
2238};
2239pub use resolution::{resolve_resource_properties, resolve_resource_properties_with_attrs};