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 ref_on_lambda_alias_resolves_to_alias_arn() {
790        // `Ref` returns a resource's physical id; the Lambda alias
791        // provisioner exposes the alias ARN as its physical id, so
792        // `Ref` on the alias yields the ARN — like real CloudFormation,
793        // and what the ESM create path needs to find the function.
794        let (p, r, mut ids, attrs) = empty();
795        let alias_arn = "arn:aws:lambda:us-east-1:123456789012:function:my-func:live";
796        ids.insert("Alias".to_string(), alias_arn.to_string());
797        let v: Value = serde_json::from_str(r#"{"Ref": "Alias"}"#).unwrap();
798        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
799        assert_eq!(resolved, Value::String(alias_arn.to_string()));
800    }
801
802    #[test]
803    fn fn_split_emits_array() {
804        let (p, r, ids, attrs) = empty();
805        let v: Value = serde_json::from_str(r#"{"Fn::Split": [",", "a,b,c"]}"#).unwrap();
806        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
807        assert_eq!(resolved, serde_json::json!(["a", "b", "c"]));
808    }
809
810    #[test]
811    fn fn_select_picks_index() {
812        let (p, r, ids, attrs) = empty();
813        let v: Value =
814            serde_json::from_str(r#"{"Fn::Select": [1, {"Fn::Split": [",", "a,b,c"]}]}"#).unwrap();
815        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
816        assert_eq!(resolved, Value::String("b".to_string()));
817    }
818
819    #[test]
820    fn fn_length_counts_array() {
821        let (p, r, ids, attrs) = empty();
822        let v: Value = serde_json::from_str(r#"{"Fn::Length": [1,2,3,4]}"#).unwrap();
823        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
824        assert_eq!(resolved, Value::Number(4.into()));
825    }
826
827    #[test]
828    fn fn_to_json_string_serializes() {
829        let (p, r, ids, attrs) = empty();
830        let v: Value =
831            serde_json::from_str(r#"{"Fn::ToJsonString": {"a": 1, "b": [2, 3]}}"#).unwrap();
832        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
833        let s = resolved.as_str().unwrap();
834        // Order-insensitive: just verify it parses back.
835        let parsed: Value = serde_json::from_str(s).unwrap();
836        assert_eq!(parsed["a"], serde_json::json!(1));
837        assert_eq!(parsed["b"], serde_json::json!([2, 3]));
838    }
839
840    #[test]
841    fn fn_cidr_carves_subnets() {
842        let (p, r, ids, attrs) = empty();
843        // Carve 10.0.0.0/16 into 4 /24 subnets (cidr_bits = 8 host bits).
844        let v: Value = serde_json::from_str(r#"{"Fn::Cidr": ["10.0.0.0/16", 4, 8]}"#).unwrap();
845        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
846        assert_eq!(
847            resolved,
848            serde_json::json!(["10.0.0.0/24", "10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24",])
849        );
850    }
851
852    #[test]
853    fn condition_skips_resource_when_false() {
854        let template = r#"{
855            "Parameters": {"Env": {"Type": "String"}},
856            "Conditions": {
857                "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
858            },
859            "Resources": {
860                "ProdQueue": {
861                    "Type": "AWS::SQS::Queue",
862                    "Condition": "IsProd",
863                    "Properties": {"QueueName": "prod-q"}
864                },
865                "AlwaysQueue": {
866                    "Type": "AWS::SQS::Queue",
867                    "Properties": {"QueueName": "always-q"}
868                }
869            }
870        }"#;
871        let mut params = BTreeMap::new();
872        params.insert("Env".to_string(), "dev".to_string());
873        let parsed = parse_template(template, &params).unwrap();
874        let names: Vec<&str> = parsed
875            .resources
876            .iter()
877            .map(|r| r.logical_id.as_str())
878            .collect();
879        assert!(names.contains(&"AlwaysQueue"));
880        assert!(!names.contains(&"ProdQueue"));
881    }
882
883    #[test]
884    fn condition_includes_resource_when_true() {
885        let template = r#"{
886            "Parameters": {"Env": {"Type": "String"}},
887            "Conditions": {
888                "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
889            },
890            "Resources": {
891                "ProdQueue": {
892                    "Type": "AWS::SQS::Queue",
893                    "Condition": "IsProd",
894                    "Properties": {"QueueName": "prod-q"}
895                }
896            }
897        }"#;
898        let mut params = BTreeMap::new();
899        params.insert("Env".to_string(), "prod".to_string());
900        let parsed = parse_template(template, &params).unwrap();
901        assert_eq!(parsed.resources.len(), 1);
902    }
903
904    #[test]
905    fn fn_if_picks_branch_based_on_condition() {
906        let template = r#"{
907            "Parameters": {"Env": {"Type": "String"}},
908            "Conditions": {
909                "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
910            },
911            "Resources": {
912                "Q": {
913                    "Type": "AWS::SQS::Queue",
914                    "Properties": {
915                        "QueueName": {"Fn::If": ["IsProd", "prod-q", "dev-q"]}
916                    }
917                }
918            }
919        }"#;
920        let mut params = BTreeMap::new();
921        params.insert("Env".to_string(), "dev".to_string());
922        let parsed = parse_template(template, &params).unwrap();
923        assert_eq!(
924            parsed.resources[0].properties["QueueName"],
925            Value::String("dev-q".to_string())
926        );
927    }
928
929    #[test]
930    fn fn_and_or_not_combine_conditions() {
931        let template = r#"{
932            "Parameters": {"Env": {"Type": "String"}, "Region": {"Type": "String"}},
933            "Conditions": {
934                "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
935                "IsUsEast": {"Fn::Equals": [{"Ref": "Region"}, "us-east-1"]},
936                "IsProdInUsEast": {"Fn::And": [{"Condition": "IsProd"}, {"Condition": "IsUsEast"}]},
937                "IsNotProd": {"Fn::Not": [{"Condition": "IsProd"}]},
938                "IsAny": {"Fn::Or": [{"Condition": "IsProd"}, {"Condition": "IsNotProd"}]}
939            },
940            "Resources": {
941                "Q": {
942                    "Type": "AWS::SQS::Queue",
943                    "Properties": {
944                        "P1": {"Fn::If": ["IsProdInUsEast", "yes", "no"]},
945                        "P2": {"Fn::If": ["IsNotProd", "yes", "no"]},
946                        "P3": {"Fn::If": ["IsAny", "yes", "no"]}
947                    }
948                }
949            }
950        }"#;
951        let mut params = BTreeMap::new();
952        params.insert("Env".to_string(), "prod".to_string());
953        params.insert("Region".to_string(), "us-east-1".to_string());
954        let parsed = parse_template(template, &params).unwrap();
955        let p = &parsed.resources[0].properties;
956        assert_eq!(p["P1"], Value::String("yes".to_string()));
957        assert_eq!(p["P2"], Value::String("no".to_string()));
958        assert_eq!(p["P3"], Value::String("yes".to_string()));
959    }
960
961    #[test]
962    fn fn_find_in_map_resolves_leaf_value() {
963        let template = r#"{
964            "Mappings": {
965                "RegionMap": {
966                    "us-east-1": {"AMI": "ami-east"},
967                    "us-west-2": {"AMI": "ami-west"}
968                }
969            },
970            "Resources": {
971                "Inst": {
972                    "Type": "AWS::EC2::Instance",
973                    "Properties": {
974                        "ImageId": {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}
975                    }
976                }
977            }
978        }"#;
979        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
980        assert_eq!(
981            parsed.resources[0].properties["ImageId"],
982            Value::String("ami-east".to_string())
983        );
984    }
985
986    #[test]
987    fn fn_find_in_map_resolves_keys_via_ref() {
988        let template = r#"{
989            "Parameters": {"Region": {"Type": "String"}},
990            "Mappings": {
991                "RegionMap": {
992                    "us-east-1": {"AMI": "ami-east"},
993                    "us-west-2": {"AMI": "ami-west"}
994                }
995            },
996            "Resources": {
997                "Inst": {
998                    "Type": "AWS::EC2::Instance",
999                    "Properties": {
1000                        "ImageId": {"Fn::FindInMap": ["RegionMap", {"Ref": "Region"}, "AMI"]}
1001                    }
1002                }
1003            }
1004        }"#;
1005        let mut params = BTreeMap::new();
1006        params.insert("Region".to_string(), "us-west-2".to_string());
1007        let parsed = parse_template(template, &params).unwrap();
1008        assert_eq!(
1009            parsed.resources[0].properties["ImageId"],
1010            Value::String("ami-west".to_string())
1011        );
1012    }
1013
1014    #[test]
1015    fn fn_find_in_map_unknown_keys_returns_error() {
1016        let template = r#"{
1017            "Mappings": {
1018                "RegionMap": {
1019                    "us-east-1": {"AMI": "ami-east"}
1020                }
1021            },
1022            "Resources": {
1023                "Inst": {
1024                    "Type": "AWS::EC2::Instance",
1025                    "Properties": {
1026                        "ImageId": {"Fn::FindInMap": ["RegionMap", "ap-south-1", "AMI"]}
1027                    }
1028                }
1029            }
1030        }"#;
1031        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
1032        assert!(
1033            err.contains("Unable to get mapping for RegionMap::ap-south-1::AMI"),
1034            "got: {err}"
1035        );
1036    }
1037
1038    #[test]
1039    fn fn_find_in_map_four_arg_returns_default_when_missing() {
1040        let template = r#"{
1041            "Mappings": {
1042                "RegionMap": {
1043                    "us-east-1": {"AMI": "ami-east"}
1044                }
1045            },
1046            "Resources": {
1047                "Inst": {
1048                    "Type": "AWS::EC2::Instance",
1049                    "Properties": {
1050                        "ImageId": {"Fn::FindInMap": [
1051                            "RegionMap",
1052                            "ap-south-1",
1053                            "AMI",
1054                            {"DefaultValue": "ami-fallback"}
1055                        ]}
1056                    }
1057                }
1058            }
1059        }"#;
1060        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1061        assert_eq!(
1062            parsed.resources[0].properties["ImageId"],
1063            Value::String("ami-fallback".to_string())
1064        );
1065    }
1066
1067    #[test]
1068    fn fn_find_in_map_four_arg_prefers_match_over_default() {
1069        let template = r#"{
1070            "Mappings": {
1071                "RegionMap": {
1072                    "us-east-1": {"AMI": "ami-east"}
1073                }
1074            },
1075            "Resources": {
1076                "Inst": {
1077                    "Type": "AWS::EC2::Instance",
1078                    "Properties": {
1079                        "ImageId": {"Fn::FindInMap": [
1080                            "RegionMap",
1081                            "us-east-1",
1082                            "AMI",
1083                            {"DefaultValue": "ami-fallback"}
1084                        ]}
1085                    }
1086                }
1087            }
1088        }"#;
1089        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1090        assert_eq!(
1091            parsed.resources[0].properties["ImageId"],
1092            Value::String("ami-east".to_string())
1093        );
1094    }
1095
1096    #[test]
1097    fn fn_find_in_map_default_value_is_resolved_intrinsic() {
1098        let template = r#"{
1099            "Parameters": {"Fallback": {"Type": "String"}},
1100            "Mappings": {
1101                "RegionMap": {
1102                    "us-east-1": {"AMI": "ami-east"}
1103                }
1104            },
1105            "Resources": {
1106                "Inst": {
1107                    "Type": "AWS::EC2::Instance",
1108                    "Properties": {
1109                        "ImageId": {"Fn::FindInMap": [
1110                            "RegionMap",
1111                            "ap-south-1",
1112                            "AMI",
1113                            {"DefaultValue": {"Ref": "Fallback"}}
1114                        ]}
1115                    }
1116                }
1117            }
1118        }"#;
1119        let mut params = BTreeMap::new();
1120        params.insert("Fallback".to_string(), "ami-default".to_string());
1121        let parsed = parse_template(template, &params).unwrap();
1122        assert_eq!(
1123            parsed.resources[0].properties["ImageId"],
1124            Value::String("ami-default".to_string())
1125        );
1126    }
1127
1128    #[test]
1129    fn fn_find_in_map_unknown_map_name_errors() {
1130        let template = r#"{
1131            "Mappings": {
1132                "RegionMap": {
1133                    "us-east-1": {"AMI": "ami-east"}
1134                }
1135            },
1136            "Resources": {
1137                "Inst": {
1138                    "Type": "AWS::EC2::Instance",
1139                    "Properties": {
1140                        "ImageId": {"Fn::FindInMap": ["DoesNotExist", "us-east-1", "AMI"]}
1141                    }
1142                }
1143            }
1144        }"#;
1145        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
1146        assert!(
1147            err.contains("Unable to get mapping for DoesNotExist::us-east-1::AMI"),
1148            "got: {err}"
1149        );
1150    }
1151
1152    #[test]
1153    fn fn_find_in_map_wrong_arg_count_errors() {
1154        let template = r#"{
1155            "Mappings": {"M": {"a": {"b": "c"}}},
1156            "Resources": {
1157                "Q": {
1158                    "Type": "AWS::SQS::Queue",
1159                    "Properties": {
1160                        "QueueName": {"Fn::FindInMap": ["M", "a"]}
1161                    }
1162                }
1163            }
1164        }"#;
1165        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
1166        assert!(
1167            err.contains("Fn::FindInMap requires 3 or 4 arguments"),
1168            "got: {err}"
1169        );
1170    }
1171
1172    #[test]
1173    fn fn_find_in_map_resolves_via_pseudo_region() {
1174        let template = r#"{
1175            "Mappings": {
1176                "RegionMap": {
1177                    "us-east-1": {"AMI": "ami-east"},
1178                    "us-west-2": {"AMI": "ami-west"}
1179                }
1180            },
1181            "Resources": {
1182                "Inst": {
1183                    "Type": "AWS::EC2::Instance",
1184                    "Properties": {
1185                        "ImageId": {"Fn::FindInMap": [
1186                            "RegionMap",
1187                            {"Ref": "AWS::Region"},
1188                            "AMI"
1189                        ]}
1190                    }
1191                }
1192            }
1193        }"#;
1194        // No AWS::Region in parameters — the pseudo-default ("us-east-1")
1195        // should kick in so FindInMap still resolves.
1196        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1197        assert_eq!(
1198            parsed.resources[0].properties["ImageId"],
1199            Value::String("ami-east".to_string())
1200        );
1201    }
1202
1203    #[test]
1204    fn fn_find_in_map_in_unused_if_branch_does_not_error() {
1205        // FindInMap sits in the FALSE branch of Fn::If; the path
1206        // `RegionMap::ap-south-1::AMI` doesn't exist. Because
1207        // `WantAlt` resolves to "no" the alt branch is unused and
1208        // CFN never executes that FindInMap — parse_template must
1209        // succeed instead of erroring.
1210        let template = r#"{
1211            "Parameters": {"WantAlt": {"Type": "String"}},
1212            "Conditions": {
1213                "UseAlt": {"Fn::Equals": [{"Ref": "WantAlt"}, "yes"]}
1214            },
1215            "Mappings": {
1216                "RegionMap": {
1217                    "us-east-1": {"AMI": "ami-east"}
1218                }
1219            },
1220            "Resources": {
1221                "Inst": {
1222                    "Type": "AWS::EC2::Instance",
1223                    "Properties": {
1224                        "ImageId": {"Fn::If": [
1225                            "UseAlt",
1226                            {"Fn::FindInMap": ["RegionMap", "ap-south-1", "AMI"]},
1227                            {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}
1228                        ]}
1229                    }
1230                }
1231            }
1232        }"#;
1233        let mut params = BTreeMap::new();
1234        params.insert("WantAlt".to_string(), "no".to_string());
1235        let parsed = parse_template(template, &params).unwrap();
1236        assert_eq!(
1237            parsed.resources[0].properties["ImageId"],
1238            Value::String("ami-east".to_string())
1239        );
1240    }
1241
1242    #[test]
1243    fn fn_find_in_map_in_active_if_branch_still_errors_on_miss() {
1244        // Same shape as above but the active branch is the broken
1245        // one; the strict miss handling must still surface.
1246        let template = r#"{
1247            "Parameters": {"WantAlt": {"Type": "String"}},
1248            "Conditions": {
1249                "UseAlt": {"Fn::Equals": [{"Ref": "WantAlt"}, "yes"]}
1250            },
1251            "Mappings": {
1252                "RegionMap": {
1253                    "us-east-1": {"AMI": "ami-east"}
1254                }
1255            },
1256            "Resources": {
1257                "Inst": {
1258                    "Type": "AWS::EC2::Instance",
1259                    "Properties": {
1260                        "ImageId": {"Fn::If": [
1261                            "UseAlt",
1262                            {"Fn::FindInMap": ["RegionMap", "ap-south-1", "AMI"]},
1263                            {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}
1264                        ]}
1265                    }
1266                }
1267            }
1268        }"#;
1269        let mut params = BTreeMap::new();
1270        params.insert("WantAlt".to_string(), "yes".to_string());
1271        let err = parse_template(template, &params).unwrap_err();
1272        assert!(
1273            err.contains("Unable to get mapping for RegionMap::ap-south-1::AMI"),
1274            "got: {err}"
1275        );
1276    }
1277
1278    #[test]
1279    fn fn_find_in_map_alongside_ref_and_sub_still_resolve() {
1280        let template = r#"{
1281            "Parameters": {"Env": {"Type": "String"}},
1282            "Mappings": {
1283                "EnvMap": {
1284                    "prod": {"Suffix": "live"},
1285                    "dev": {"Suffix": "test"}
1286                }
1287            },
1288            "Resources": {
1289                "Q": {
1290                    "Type": "AWS::SQS::Queue",
1291                    "Properties": {
1292                        "QueueName": {"Fn::FindInMap": ["EnvMap", {"Ref": "Env"}, "Suffix"]},
1293                        "Tags": [
1294                            {"Key": "EnvRef", "Value": {"Ref": "Env"}},
1295                            {"Key": "Subbed", "Value": {"Fn::Sub": "env-${Env}"}}
1296                        ]
1297                    }
1298                }
1299            }
1300        }"#;
1301        let mut params = BTreeMap::new();
1302        params.insert("Env".to_string(), "prod".to_string());
1303        let parsed = parse_template(template, &params).unwrap();
1304        let p = &parsed.resources[0].properties;
1305        assert_eq!(p["QueueName"], Value::String("live".to_string()));
1306        assert_eq!(p["Tags"][0]["Value"], Value::String("prod".to_string()));
1307        assert_eq!(p["Tags"][1]["Value"], Value::String("env-prod".to_string()));
1308    }
1309
1310    // ── Conditions: cycle detection + AWS::NoValue removal ──
1311
1312    #[test]
1313    fn cyclic_conditions_self_reference_errors() {
1314        let template = r#"{
1315            "Conditions": {
1316                "A": {"Condition": "A"}
1317            },
1318            "Resources": {
1319                "Q": {
1320                    "Type": "AWS::SQS::Queue",
1321                    "Condition": "A",
1322                    "Properties": {"QueueName": "q"}
1323                }
1324            }
1325        }"#;
1326        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
1327        assert!(err.contains("Circular reference"), "got: {err}");
1328        assert!(err.contains("'A'"), "got: {err}");
1329    }
1330
1331    #[test]
1332    fn cyclic_conditions_two_step_errors() {
1333        let template = r#"{
1334            "Conditions": {
1335                "A": {"Condition": "B"},
1336                "B": {"Condition": "A"}
1337            },
1338            "Resources": {
1339                "Q": {
1340                    "Type": "AWS::SQS::Queue",
1341                    "Condition": "A",
1342                    "Properties": {"QueueName": "q"}
1343                }
1344            }
1345        }"#;
1346        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
1347        assert!(err.contains("Circular reference"), "got: {err}");
1348    }
1349
1350    #[test]
1351    fn condition_referencing_undefined_name_errors() {
1352        let template = r#"{
1353            "Conditions": {
1354                "A": {"Condition": "DoesNotExist"}
1355            },
1356            "Resources": {
1357                "Q": {
1358                    "Type": "AWS::SQS::Queue",
1359                    "Condition": "A",
1360                    "Properties": {"QueueName": "q"}
1361                }
1362            }
1363        }"#;
1364        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
1365        assert!(err.contains("DoesNotExist"), "got: {err}");
1366    }
1367
1368    #[test]
1369    fn fn_if_no_value_removes_property_from_parent_map() {
1370        let template = r#"{
1371            "Parameters": {"WantTags": {"Type": "String"}},
1372            "Conditions": {
1373                "HasTags": {"Fn::Equals": [{"Ref": "WantTags"}, "yes"]}
1374            },
1375            "Resources": {
1376                "Q": {
1377                    "Type": "AWS::SQS::Queue",
1378                    "Properties": {
1379                        "QueueName": "q",
1380                        "Tags": {"Fn::If": [
1381                            "HasTags",
1382                            [{"Key": "a", "Value": "b"}],
1383                            {"Ref": "AWS::NoValue"}
1384                        ]}
1385                    }
1386                }
1387            }
1388        }"#;
1389        let mut params = BTreeMap::new();
1390        params.insert("WantTags".to_string(), "no".to_string());
1391        let parsed = parse_template(template, &params).unwrap();
1392        let props = parsed.resources[0].properties.as_object().unwrap();
1393        assert!(
1394            !props.contains_key("Tags"),
1395            "Tags should be omitted when AWS::NoValue picked, got: {props:?}"
1396        );
1397        assert_eq!(
1398            props.get("QueueName"),
1399            Some(&Value::String("q".to_string()))
1400        );
1401    }
1402
1403    #[test]
1404    fn fn_if_no_value_keeps_property_when_branch_concrete() {
1405        let template = r#"{
1406            "Parameters": {"WantTags": {"Type": "String"}},
1407            "Conditions": {
1408                "HasTags": {"Fn::Equals": [{"Ref": "WantTags"}, "yes"]}
1409            },
1410            "Resources": {
1411                "Q": {
1412                    "Type": "AWS::SQS::Queue",
1413                    "Properties": {
1414                        "QueueName": "q",
1415                        "Tags": {"Fn::If": [
1416                            "HasTags",
1417                            [{"Key": "a", "Value": "b"}],
1418                            {"Ref": "AWS::NoValue"}
1419                        ]}
1420                    }
1421                }
1422            }
1423        }"#;
1424        let mut params = BTreeMap::new();
1425        params.insert("WantTags".to_string(), "yes".to_string());
1426        let parsed = parse_template(template, &params).unwrap();
1427        let tags = &parsed.resources[0].properties["Tags"];
1428        assert_eq!(
1429            tags,
1430            &serde_json::json!([{"Key": "a", "Value": "b"}]),
1431            "tags should be the true branch's array"
1432        );
1433    }
1434
1435    #[test]
1436    fn fn_if_no_value_in_array_drops_element() {
1437        let template = r#"{
1438            "Parameters": {"Extra": {"Type": "String"}},
1439            "Conditions": {
1440                "HasExtra": {"Fn::Equals": [{"Ref": "Extra"}, "yes"]}
1441            },
1442            "Resources": {
1443                "Q": {
1444                    "Type": "AWS::SQS::Queue",
1445                    "Properties": {
1446                        "Items": [
1447                            "first",
1448                            {"Fn::If": ["HasExtra", "second", {"Ref": "AWS::NoValue"}]},
1449                            "third"
1450                        ]
1451                    }
1452                }
1453            }
1454        }"#;
1455        let mut params = BTreeMap::new();
1456        params.insert("Extra".to_string(), "no".to_string());
1457        let parsed = parse_template(template, &params).unwrap();
1458        assert_eq!(
1459            parsed.resources[0].properties["Items"],
1460            serde_json::json!(["first", "third"])
1461        );
1462    }
1463
1464    #[test]
1465    fn condition_skips_output_when_false() {
1466        let template = r#"{
1467            "Parameters": {"Env": {"Type": "String"}},
1468            "Conditions": {
1469                "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
1470            },
1471            "Resources": {
1472                "Q": {
1473                    "Type": "AWS::SQS::Queue",
1474                    "Properties": {"QueueName": "q"}
1475                }
1476            },
1477            "Outputs": {
1478                "ProdName": {
1479                    "Condition": "IsProd",
1480                    "Value": "prod-only"
1481                },
1482                "Always": {
1483                    "Value": "shown"
1484                }
1485            }
1486        }"#;
1487        let mut params = BTreeMap::new();
1488        params.insert("Env".to_string(), "dev".to_string());
1489        let parsed = parse_template(template, &params).unwrap();
1490        let names: Vec<&str> = parsed
1491            .outputs
1492            .iter()
1493            .map(|o| o.logical_id.as_str())
1494            .collect();
1495        assert!(names.contains(&"Always"));
1496        assert!(!names.contains(&"ProdName"));
1497    }
1498
1499    #[test]
1500    fn fn_and_short_circuits_on_false() {
1501        let template = r#"{
1502            "Parameters": {"Env": {"Type": "String"}},
1503            "Conditions": {
1504                "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
1505                "Combined": {"Fn::And": [
1506                    {"Condition": "IsProd"},
1507                    {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
1508                ]}
1509            },
1510            "Resources": {
1511                "Q": {
1512                    "Type": "AWS::SQS::Queue",
1513                    "Condition": "Combined",
1514                    "Properties": {"QueueName": "q"}
1515                }
1516            }
1517        }"#;
1518        let mut params = BTreeMap::new();
1519        params.insert("Env".to_string(), "dev".to_string());
1520        let parsed = parse_template(template, &params).unwrap();
1521        assert_eq!(parsed.resources.len(), 0);
1522    }
1523
1524    #[test]
1525    fn fn_or_short_circuits_on_true() {
1526        let template = r#"{
1527            "Parameters": {"Env": {"Type": "String"}},
1528            "Conditions": {
1529                "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
1530                "AnyEnv": {"Fn::Or": [
1531                    {"Condition": "IsProd"},
1532                    {"Fn::Equals": [{"Ref": "Env"}, "dev"]},
1533                    {"Fn::Equals": [{"Ref": "Env"}, "stage"]}
1534                ]}
1535            },
1536            "Resources": {
1537                "Q": {
1538                    "Type": "AWS::SQS::Queue",
1539                    "Condition": "AnyEnv",
1540                    "Properties": {"QueueName": "q"}
1541                }
1542            }
1543        }"#;
1544        let mut params = BTreeMap::new();
1545        params.insert("Env".to_string(), "stage".to_string());
1546        let parsed = parse_template(template, &params).unwrap();
1547        assert_eq!(parsed.resources.len(), 1);
1548    }
1549
1550    #[test]
1551    fn fn_and_rejects_arity_outside_1_to_10() {
1552        let template = r#"{
1553            "Conditions": {
1554                "Empty": {"Fn::And": []}
1555            },
1556            "Resources": {
1557                "Q": {
1558                    "Type": "AWS::SQS::Queue",
1559                    "Condition": "Empty",
1560                    "Properties": {"QueueName": "q"}
1561                }
1562            }
1563        }"#;
1564        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
1565        assert!(err.contains("Fn::And"), "got: {err}");
1566    }
1567
1568    #[test]
1569    fn condition_evaluation_memoizes_complex_expression() {
1570        // Both `Outer` branches reuse `Inner`. With memoization the
1571        // inner condition only resolves once; without it, this would
1572        // still pass — but the test guards against regressions where
1573        // re-evaluation triggers double Fn::Equals work.
1574        let template = r#"{
1575            "Parameters": {"Env": {"Type": "String"}},
1576            "Conditions": {
1577                "Inner": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
1578                "OuterA": {"Fn::And": [{"Condition": "Inner"}, {"Condition": "Inner"}]},
1579                "OuterB": {"Fn::Or": [{"Condition": "Inner"}, {"Condition": "OuterA"}]}
1580            },
1581            "Resources": {
1582                "Q": {
1583                    "Type": "AWS::SQS::Queue",
1584                    "Condition": "OuterB",
1585                    "Properties": {"QueueName": "q"}
1586                }
1587            }
1588        }"#;
1589        let mut params = BTreeMap::new();
1590        params.insert("Env".to_string(), "prod".to_string());
1591        let parsed = parse_template(template, &params).unwrap();
1592        assert_eq!(parsed.resources.len(), 1);
1593    }
1594
1595    #[test]
1596    fn fn_not_rejects_multiple_arguments() {
1597        let template = r#"{
1598            "Parameters": {"Env": {"Type": "String"}},
1599            "Conditions": {
1600                "IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
1601                "Bad": {"Fn::Not": [
1602                    {"Condition": "IsProd"},
1603                    {"Condition": "IsProd"}
1604                ]}
1605            },
1606            "Resources": {
1607                "Q": {
1608                    "Type": "AWS::SQS::Queue",
1609                    "Condition": "Bad",
1610                    "Properties": {"QueueName": "q"}
1611                }
1612            }
1613        }"#;
1614        let mut params = BTreeMap::new();
1615        params.insert("Env".to_string(), "prod".to_string());
1616        let err = parse_template(template, &params).unwrap_err();
1617        assert!(err.contains("Fn::Not"), "got: {err}");
1618    }
1619
1620    #[test]
1621    fn fn_not_rejects_zero_arguments() {
1622        let template = r#"{
1623            "Conditions": {
1624                "Bad": {"Fn::Not": []}
1625            },
1626            "Resources": {
1627                "Q": {
1628                    "Type": "AWS::SQS::Queue",
1629                    "Condition": "Bad",
1630                    "Properties": {"QueueName": "q"}
1631                }
1632            }
1633        }"#;
1634        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
1635        assert!(err.contains("Fn::Not"), "got: {err}");
1636    }
1637
1638    #[test]
1639    fn resolve_resource_properties_strips_no_value_at_provision_time() {
1640        // Mirrors the incremental-provisioning code path which calls
1641        // resolve_resource_properties_with_attrs after the initial parse.
1642        // The sentinel must not leak into the resolved properties even
1643        // when re-resolved with updated physical IDs.
1644        let template = r#"{
1645            "Parameters": {"WantTags": {"Type": "String"}},
1646            "Conditions": {
1647                "HasTags": {"Fn::Equals": [{"Ref": "WantTags"}, "yes"]}
1648            },
1649            "Resources": {
1650                "Q": {
1651                    "Type": "AWS::SQS::Queue",
1652                    "Properties": {
1653                        "QueueName": "q",
1654                        "Tags": {"Fn::If": [
1655                            "HasTags",
1656                            [{"Key": "a", "Value": "b"}],
1657                            {"Ref": "AWS::NoValue"}
1658                        ]}
1659                    }
1660                }
1661            }
1662        }"#;
1663        let mut params = BTreeMap::new();
1664        params.insert("WantTags".to_string(), "no".to_string());
1665        let parsed = parse_template(template, &params).unwrap();
1666        let resource = parsed
1667            .resources
1668            .iter()
1669            .find(|r| r.logical_id == "Q")
1670            .unwrap();
1671        // First parse already strips Tags.
1672        assert!(!resource
1673            .properties
1674            .as_object()
1675            .unwrap()
1676            .contains_key("Tags"));
1677
1678        // Re-resolve with empty physical IDs (mid-provisioning). The
1679        // sentinel must still be stripped — no `__fakecloud_aws_no_value__`
1680        // marker should reach the caller.
1681        let reresolved = resolve_resource_properties_with_attrs(
1682            resource,
1683            template,
1684            &params,
1685            &BTreeMap::new(),
1686            &BTreeMap::new(),
1687            &BTreeMap::new(),
1688        )
1689        .unwrap();
1690        let props = reresolved.properties.as_object().unwrap();
1691        assert!(
1692            !props.contains_key("Tags"),
1693            "Tags should be stripped on re-resolve, got: {props:?}"
1694        );
1695        // Sanity: serialized form must not contain the sentinel key.
1696        let serialized = serde_json::to_string(&reresolved.properties).unwrap();
1697        assert!(
1698            !serialized.contains(NO_VALUE_SENTINEL_KEY),
1699            "sentinel leaked: {serialized}"
1700        );
1701    }
1702
1703    // ── BB5: Fn::Select / Split / Base64 / Cidr / Length / ToJsonString / ForEach ──
1704
1705    #[test]
1706    fn fn_select_string_index_resolves() {
1707        // CFN accepts the index as a string literal (`"0"`) — CFN's
1708        // own examples do this, so the engine must coerce.
1709        let (p, r, ids, attrs) = empty();
1710        let v: Value = serde_json::from_str(r#"{"Fn::Select": ["2", ["a", "b", "c", "d"]]}"#)
1711            .expect("static fixture parses");
1712        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1713        assert_eq!(resolved, Value::String("c".to_string()));
1714    }
1715
1716    #[test]
1717    fn fn_select_out_of_range_returns_null() {
1718        let (p, r, ids, attrs) = empty();
1719        let v: Value = serde_json::from_str(r#"{"Fn::Select": [10, ["a", "b"]]}"#)
1720            .expect("static fixture parses");
1721        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1722        assert_eq!(resolved, Value::Null);
1723    }
1724
1725    #[test]
1726    fn fn_select_over_json_array_string_getatt_resolves_as_list() {
1727        // A list-valued Fn::GetAtt (e.g. AWS::AmazonMQ::Broker AmqpEndpoints)
1728        // resolves to a JSON-array *string* because attributes are String-keyed;
1729        // Fn::Select must parse it back into a list and index into it rather than
1730        // collapsing to the first element.
1731        let (p, r, ids, mut attrs) = empty();
1732        let mut broker_attrs = BTreeMap::new();
1733        broker_attrs.insert(
1734            "AmqpEndpoints".to_string(),
1735            r#"["amqps://a.example:5671","amqps://b.example:5671"]"#.to_string(),
1736        );
1737        attrs.insert("MyBroker".to_string(), broker_attrs);
1738        let v: Value = serde_json::from_str(
1739            r#"{"Fn::Select": [1, {"Fn::GetAtt": ["MyBroker", "AmqpEndpoints"]}]}"#,
1740        )
1741        .expect("static fixture parses");
1742        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1743        assert_eq!(
1744            resolved,
1745            Value::String("amqps://b.example:5671".to_string())
1746        );
1747    }
1748
1749    #[test]
1750    fn fn_select_resolves_ref_inside_list() {
1751        let template = r#"{
1752            "Parameters": {"AZs": {"Type": "CommaDelimitedList"}},
1753            "Resources": {
1754                "Q": {
1755                    "Type": "AWS::SQS::Queue",
1756                    "Properties": {
1757                        "QueueName": {"Fn::Select": [0, {"Fn::Split": [",", {"Ref": "AZs"}]}]}
1758                    }
1759                }
1760            }
1761        }"#;
1762        let mut params = BTreeMap::new();
1763        params.insert(
1764            "AZs".to_string(),
1765            "us-east-1a,us-east-1b,us-east-1c".to_string(),
1766        );
1767        let parsed = parse_template(template, &params).unwrap();
1768        assert_eq!(
1769            parsed.resources[0].properties["QueueName"],
1770            Value::String("us-east-1a".to_string())
1771        );
1772    }
1773
1774    #[test]
1775    fn fn_split_empty_delimiter_returns_full_string_split_per_char() {
1776        let (p, r, ids, attrs) = empty();
1777        let v: Value =
1778            serde_json::from_str(r#"{"Fn::Split": ["", "abc"]}"#).expect("static fixture parses");
1779        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1780        // str::split("") yields `["", "a", "b", "c", ""]` in Rust.
1781        // CFN's behavior with empty delimiter is undefined, but match
1782        // the underlying primitive so callers can reason about it.
1783        assert!(resolved.is_array());
1784    }
1785
1786    #[test]
1787    fn fn_split_no_match_returns_single_element_array() {
1788        let (p, r, ids, attrs) = empty();
1789        let v: Value = serde_json::from_str(r#"{"Fn::Split": [",", "no-commas-here"]}"#)
1790            .expect("static fixture parses");
1791        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1792        assert_eq!(resolved, serde_json::json!(["no-commas-here"]));
1793    }
1794
1795    #[test]
1796    fn fn_base64_encodes_unicode() {
1797        let (p, r, ids, attrs) = empty();
1798        let v: Value =
1799            serde_json::from_str(r#"{"Fn::Base64": "héllo"}"#).expect("static fixture parses");
1800        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1801        // "héllo" is 6 bytes UTF-8 (h=1, é=2, l=1, l=1, o=1).
1802        assert_eq!(resolved, Value::String("aMOpbGxv".to_string()));
1803    }
1804
1805    #[test]
1806    fn fn_base64_resolves_nested_intrinsic() {
1807        let template = r#"{
1808            "Parameters": {"Greeting": {"Type": "String"}},
1809            "Resources": {
1810                "Q": {
1811                    "Type": "AWS::SQS::Queue",
1812                    "Properties": {
1813                        "QueueName": {"Fn::Base64": {"Ref": "Greeting"}}
1814                    }
1815                }
1816            }
1817        }"#;
1818        let mut params = BTreeMap::new();
1819        params.insert("Greeting".to_string(), "hello".to_string());
1820        let parsed = parse_template(template, &params).unwrap();
1821        assert_eq!(
1822            parsed.resources[0].properties["QueueName"],
1823            Value::String("aGVsbG8=".to_string())
1824        );
1825    }
1826
1827    #[test]
1828    fn fn_length_counts_string_chars() {
1829        let (p, r, ids, attrs) = empty();
1830        let v: Value =
1831            serde_json::from_str(r#"{"Fn::Length": "héllo"}"#).expect("static fixture parses");
1832        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1833        // 5 chars (not 6 bytes) — multibyte counted once.
1834        assert_eq!(resolved, Value::Number(5.into()));
1835    }
1836
1837    #[test]
1838    fn fn_length_resolves_nested_split() {
1839        let (p, r, ids, attrs) = empty();
1840        let v: Value = serde_json::from_str(r#"{"Fn::Length": {"Fn::Split": [",", "a,b,c,d,e"]}}"#)
1841            .expect("static fixture parses");
1842        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1843        assert_eq!(resolved, Value::Number(5.into()));
1844    }
1845
1846    #[test]
1847    fn fn_to_json_string_serializes_array() {
1848        let (p, r, ids, attrs) = empty();
1849        let v: Value = serde_json::from_str(r#"{"Fn::ToJsonString": ["a", "b", "c"]}"#)
1850            .expect("static fixture parses");
1851        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1852        assert_eq!(resolved, Value::String(r#"["a","b","c"]"#.to_string()));
1853    }
1854
1855    #[test]
1856    fn fn_to_json_string_resolves_inner_ref() {
1857        let template = r#"{
1858            "Parameters": {"Name": {"Type": "String"}},
1859            "Resources": {
1860                "Q": {
1861                    "Type": "AWS::SQS::Queue",
1862                    "Properties": {
1863                        "QueueName": {
1864                            "Fn::ToJsonString": {"k": {"Ref": "Name"}}
1865                        }
1866                    }
1867                }
1868            }
1869        }"#;
1870        let mut params = BTreeMap::new();
1871        params.insert("Name".to_string(), "abc".to_string());
1872        let parsed = parse_template(template, &params).unwrap();
1873        assert_eq!(
1874            parsed.resources[0].properties["QueueName"],
1875            Value::String(r#"{"k":"abc"}"#.to_string())
1876        );
1877    }
1878
1879    #[test]
1880    fn fn_cidr_count_matches_request() {
1881        // Real Fn::Cidr returns up to 2^cidr_bits subnets; we ask for 2
1882        // out of a possible 256, so only 2 land in the output.
1883        let (p, r, ids, attrs) = empty();
1884        let v: Value = serde_json::from_str(r#"{"Fn::Cidr": ["10.0.0.0/16", 2, 8]}"#)
1885            .expect("static fixture parses");
1886        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1887        assert_eq!(resolved, serde_json::json!(["10.0.0.0/24", "10.0.1.0/24"]));
1888    }
1889
1890    #[test]
1891    fn fn_cidr_resolves_via_ref() {
1892        let template = r#"{
1893            "Parameters": {"Vpc": {"Type": "String"}},
1894            "Resources": {
1895                "Q": {
1896                    "Type": "AWS::SQS::Queue",
1897                    "Properties": {
1898                        "QueueName": {"Fn::Select": [
1899                            0,
1900                            {"Fn::Cidr": [{"Ref": "Vpc"}, 4, 8]}
1901                        ]}
1902                    }
1903                }
1904            }
1905        }"#;
1906        let mut params = BTreeMap::new();
1907        params.insert("Vpc".to_string(), "172.16.0.0/16".to_string());
1908        let parsed = parse_template(template, &params).unwrap();
1909        assert_eq!(
1910            parsed.resources[0].properties["QueueName"],
1911            Value::String("172.16.0.0/24".to_string())
1912        );
1913    }
1914
1915    #[test]
1916    fn fn_for_each_expands_resources() {
1917        let template = r#"{
1918            "Resources": {
1919                "Fn::ForEach::TopicLoop": [
1920                    "TopicName",
1921                    ["alpha", "beta", "gamma"],
1922                    {
1923                        "${TopicName}Topic": {
1924                            "Type": "AWS::SNS::Topic",
1925                            "Properties": {"TopicName": "${TopicName}-topic"}
1926                        }
1927                    }
1928                ]
1929            }
1930        }"#;
1931        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1932        let names: Vec<&str> = parsed
1933            .resources
1934            .iter()
1935            .map(|r| r.logical_id.as_str())
1936            .collect();
1937        assert!(names.contains(&"alphaTopic"), "got: {names:?}");
1938        assert!(names.contains(&"betaTopic"), "got: {names:?}");
1939        assert!(names.contains(&"gammaTopic"), "got: {names:?}");
1940        let alpha = parsed
1941            .resources
1942            .iter()
1943            .find(|r| r.logical_id == "alphaTopic")
1944            .unwrap();
1945        assert_eq!(
1946            alpha.properties["TopicName"],
1947            Value::String("alpha-topic".to_string())
1948        );
1949    }
1950
1951    #[test]
1952    fn fn_for_each_substitutes_in_nested_values() {
1953        let template = r#"{
1954            "Resources": {
1955                "Fn::ForEach::Q": [
1956                    "QName",
1957                    ["one", "two"],
1958                    {
1959                        "${QName}Queue": {
1960                            "Type": "AWS::SQS::Queue",
1961                            "Properties": {
1962                                "QueueName": "${QName}",
1963                                "Tags": [
1964                                    {"Key": "name", "Value": "${QName}"}
1965                                ]
1966                            }
1967                        }
1968                    }
1969                ]
1970            }
1971        }"#;
1972        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1973        let one = parsed
1974            .resources
1975            .iter()
1976            .find(|r| r.logical_id == "oneQueue")
1977            .unwrap();
1978        assert_eq!(
1979            one.properties["QueueName"],
1980            Value::String("one".to_string())
1981        );
1982        assert_eq!(
1983            one.properties["Tags"][0]["Value"],
1984            Value::String("one".to_string())
1985        );
1986    }
1987
1988    #[test]
1989    fn fn_for_each_nested_loops_expand_cartesian() {
1990        let template = r#"{
1991            "Resources": {
1992                "Fn::ForEach::Outer": [
1993                    "Env",
1994                    ["dev", "prod"],
1995                    {
1996                        "Fn::ForEach::Inner": [
1997                            "Region",
1998                            ["us-east-1", "eu-west-1"],
1999                            {
2000                                "${Env}${Region}Q": {
2001                                    "Type": "AWS::SQS::Queue",
2002                                    "Properties": {"QueueName": "${Env}-${Region}"}
2003                                }
2004                            }
2005                        ]
2006                    }
2007                ]
2008            }
2009        }"#;
2010        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2011        let names: Vec<&str> = parsed
2012            .resources
2013            .iter()
2014            .map(|r| r.logical_id.as_str())
2015            .collect();
2016        for env in ["dev", "prod"] {
2017            for region in ["us-east-1", "eu-west-1"] {
2018                let expected = format!("{env}{region}Q");
2019                assert!(
2020                    names.contains(&expected.as_str()),
2021                    "missing {expected} in {names:?}"
2022                );
2023            }
2024        }
2025        let dev_us = parsed
2026            .resources
2027            .iter()
2028            .find(|r| r.logical_id == "devus-east-1Q")
2029            .unwrap();
2030        assert_eq!(
2031            dev_us.properties["QueueName"],
2032            Value::String("dev-us-east-1".to_string())
2033        );
2034    }
2035
2036    #[test]
2037    fn fn_for_each_keeps_other_resources_untouched() {
2038        let template = r#"{
2039            "Resources": {
2040                "Static": {
2041                    "Type": "AWS::SQS::Queue",
2042                    "Properties": {"QueueName": "static-q"}
2043                },
2044                "Fn::ForEach::Loop": [
2045                    "I",
2046                    ["a", "b"],
2047                    {
2048                        "${I}Topic": {
2049                            "Type": "AWS::SNS::Topic",
2050                            "Properties": {"TopicName": "${I}"}
2051                        }
2052                    }
2053                ]
2054            }
2055        }"#;
2056        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2057        let names: Vec<&str> = parsed
2058            .resources
2059            .iter()
2060            .map(|r| r.logical_id.as_str())
2061            .collect();
2062        assert!(names.contains(&"Static"));
2063        assert!(names.contains(&"aTopic"));
2064        assert!(names.contains(&"bTopic"));
2065        assert_eq!(parsed.resources.len(), 3);
2066    }
2067
2068    #[test]
2069    fn fn_for_each_invalid_arity_errors() {
2070        let template = r#"{
2071            "Resources": {
2072                "Fn::ForEach::Bad": [
2073                    "Var",
2074                    ["a"]
2075                ]
2076            }
2077        }"#;
2078        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
2079        assert!(err.contains("Fn::ForEach"), "got: {err}");
2080    }
2081
2082    #[test]
2083    fn fn_for_each_resolves_intrinsics_in_emitted_resources() {
2084        // Body of the loop references both the loop variable and a
2085        // stack parameter, exercising that downstream intrinsic
2086        // resolution still runs over emitted resources.
2087        let template = r#"{
2088            "Parameters": {"Env": {"Type": "String"}},
2089            "Resources": {
2090                "Fn::ForEach::Q": [
2091                    "Name",
2092                    ["alpha", "beta"],
2093                    {
2094                        "${Name}Queue": {
2095                            "Type": "AWS::SQS::Queue",
2096                            "Properties": {
2097                                "QueueName": {"Fn::Sub": "${Env}-${Name}"}
2098                            }
2099                        }
2100                    }
2101                ]
2102            }
2103        }"#;
2104        let mut params = BTreeMap::new();
2105        params.insert("Env".to_string(), "prod".to_string());
2106        let parsed = parse_template(template, &params).unwrap();
2107        // ${Name} substitutes at ForEach expansion time; ${Env} comes
2108        // from the parameter at Fn::Sub time. Both must land.
2109        let alpha = parsed
2110            .resources
2111            .iter()
2112            .find(|r| r.logical_id == "alphaQueue")
2113            .unwrap();
2114        assert_eq!(
2115            alpha.properties["QueueName"],
2116            Value::String("prod-alpha".to_string())
2117        );
2118    }
2119
2120    #[test]
2121    fn fn_for_each_re_resolves_at_provision_time() {
2122        // resolve_resource_properties_with_attrs must also expand
2123        // ForEach so the looked-up resource by logical ID matches the
2124        // post-expansion template.
2125        let template = r#"{
2126            "Resources": {
2127                "Fn::ForEach::Q": [
2128                    "Name",
2129                    ["alpha"],
2130                    {
2131                        "${Name}Queue": {
2132                            "Type": "AWS::SQS::Queue",
2133                            "Properties": {"QueueName": "${Name}-q"}
2134                        }
2135                    }
2136                ]
2137            }
2138        }"#;
2139        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2140        let resource = parsed
2141            .resources
2142            .iter()
2143            .find(|r| r.logical_id == "alphaQueue")
2144            .unwrap();
2145        let reresolved = resolve_resource_properties_with_attrs(
2146            resource,
2147            template,
2148            &BTreeMap::new(),
2149            &BTreeMap::new(),
2150            &BTreeMap::new(),
2151            &BTreeMap::new(),
2152        )
2153        .unwrap();
2154        assert_eq!(
2155            reresolved.properties["QueueName"],
2156            Value::String("alpha-q".to_string())
2157        );
2158    }
2159
2160    #[test]
2161    fn fn_for_each_resolves_ref_to_comma_delimited_list_param() {
2162        // CommaDelimitedList parameters are a documented ForEach input
2163        // shape. Stack passes the value as a single string; ForEach
2164        // must split it before iterating.
2165        let template = r#"{
2166            "Parameters": {"Names": {"Type": "CommaDelimitedList"}},
2167            "Resources": {
2168                "Fn::ForEach::Q": [
2169                    "N",
2170                    {"Ref": "Names"},
2171                    {
2172                        "${N}Queue": {
2173                            "Type": "AWS::SQS::Queue",
2174                            "Properties": {"QueueName": "${N}-q"}
2175                        }
2176                    }
2177                ]
2178            }
2179        }"#;
2180        let mut params = BTreeMap::new();
2181        params.insert("Names".to_string(), "alpha,beta,gamma".to_string());
2182        let parsed = parse_template(template, &params).unwrap();
2183        let names: Vec<&str> = parsed
2184            .resources
2185            .iter()
2186            .map(|r| r.logical_id.as_str())
2187            .collect();
2188        for v in ["alphaQueue", "betaQueue", "gammaQueue"] {
2189            assert!(names.contains(&v), "missing {v} in {names:?}");
2190        }
2191    }
2192
2193    #[test]
2194    fn fn_for_each_ampersand_substitution_form() {
2195        // AWS supports `&{Var}` in addition to `${Var}` for ForEach
2196        // loop variable substitution; needed when the surrounding
2197        // template separately uses ${}-style for Fn::Sub.
2198        let template = r#"{
2199            "Resources": {
2200                "Fn::ForEach::Q": [
2201                    "Name",
2202                    ["alpha", "beta"],
2203                    {
2204                        "&{Name}Queue": {
2205                            "Type": "AWS::SQS::Queue",
2206                            "Properties": {"QueueName": "&{Name}"}
2207                        }
2208                    }
2209                ]
2210            }
2211        }"#;
2212        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2213        let names: Vec<&str> = parsed
2214            .resources
2215            .iter()
2216            .map(|r| r.logical_id.as_str())
2217            .collect();
2218        assert!(names.contains(&"alphaQueue"), "got: {names:?}");
2219        assert!(names.contains(&"betaQueue"), "got: {names:?}");
2220        let alpha = parsed
2221            .resources
2222            .iter()
2223            .find(|r| r.logical_id == "alphaQueue")
2224            .unwrap();
2225        assert_eq!(
2226            alpha.properties["QueueName"],
2227            Value::String("alpha".to_string())
2228        );
2229    }
2230
2231    #[test]
2232    fn fn_for_each_in_outputs_expands() {
2233        let template = r#"{
2234            "Resources": {
2235                "Q": {"Type": "AWS::SQS::Queue", "Properties": {"QueueName": "q"}}
2236            },
2237            "Outputs": {
2238                "Fn::ForEach::OutputLoop": [
2239                    "I",
2240                    ["one", "two"],
2241                    {
2242                        "${I}Out": {"Value": "${I}-value"}
2243                    }
2244                ]
2245            }
2246        }"#;
2247        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2248        let names: Vec<&str> = parsed
2249            .outputs
2250            .iter()
2251            .map(|o| o.logical_id.as_str())
2252            .collect();
2253        assert!(names.contains(&"oneOut"), "got: {names:?}");
2254        assert!(names.contains(&"twoOut"), "got: {names:?}");
2255        let one = parsed
2256            .outputs
2257            .iter()
2258            .find(|o| o.logical_id == "oneOut")
2259            .unwrap();
2260        assert_eq!(one.value, "one-value");
2261    }
2262}
2263
2264mod conditions;
2265mod for_each;
2266mod intrinsics;
2267mod mappings;
2268mod parser;
2269mod resolution;
2270mod sam_events;
2271use conditions::*;
2272use for_each::*;
2273use intrinsics::*;
2274use mappings::*;
2275
2276pub use parser::{
2277    collect_import_value_names, parse_outputs, parse_template, parse_template_with_physical_ids,
2278    parse_template_with_resolution,
2279};
2280pub use resolution::{
2281    dependency_order, resolve_resource_properties, resolve_resource_properties_with_attrs,
2282};