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_resolves_ref_inside_list() {
1727        let template = r#"{
1728            "Parameters": {"AZs": {"Type": "CommaDelimitedList"}},
1729            "Resources": {
1730                "Q": {
1731                    "Type": "AWS::SQS::Queue",
1732                    "Properties": {
1733                        "QueueName": {"Fn::Select": [0, {"Fn::Split": [",", {"Ref": "AZs"}]}]}
1734                    }
1735                }
1736            }
1737        }"#;
1738        let mut params = BTreeMap::new();
1739        params.insert(
1740            "AZs".to_string(),
1741            "us-east-1a,us-east-1b,us-east-1c".to_string(),
1742        );
1743        let parsed = parse_template(template, &params).unwrap();
1744        assert_eq!(
1745            parsed.resources[0].properties["QueueName"],
1746            Value::String("us-east-1a".to_string())
1747        );
1748    }
1749
1750    #[test]
1751    fn fn_split_empty_delimiter_returns_full_string_split_per_char() {
1752        let (p, r, ids, attrs) = empty();
1753        let v: Value =
1754            serde_json::from_str(r#"{"Fn::Split": ["", "abc"]}"#).expect("static fixture parses");
1755        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1756        // str::split("") yields `["", "a", "b", "c", ""]` in Rust.
1757        // CFN's behavior with empty delimiter is undefined, but match
1758        // the underlying primitive so callers can reason about it.
1759        assert!(resolved.is_array());
1760    }
1761
1762    #[test]
1763    fn fn_split_no_match_returns_single_element_array() {
1764        let (p, r, ids, attrs) = empty();
1765        let v: Value = serde_json::from_str(r#"{"Fn::Split": [",", "no-commas-here"]}"#)
1766            .expect("static fixture parses");
1767        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1768        assert_eq!(resolved, serde_json::json!(["no-commas-here"]));
1769    }
1770
1771    #[test]
1772    fn fn_base64_encodes_unicode() {
1773        let (p, r, ids, attrs) = empty();
1774        let v: Value =
1775            serde_json::from_str(r#"{"Fn::Base64": "héllo"}"#).expect("static fixture parses");
1776        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1777        // "héllo" is 6 bytes UTF-8 (h=1, é=2, l=1, l=1, o=1).
1778        assert_eq!(resolved, Value::String("aMOpbGxv".to_string()));
1779    }
1780
1781    #[test]
1782    fn fn_base64_resolves_nested_intrinsic() {
1783        let template = r#"{
1784            "Parameters": {"Greeting": {"Type": "String"}},
1785            "Resources": {
1786                "Q": {
1787                    "Type": "AWS::SQS::Queue",
1788                    "Properties": {
1789                        "QueueName": {"Fn::Base64": {"Ref": "Greeting"}}
1790                    }
1791                }
1792            }
1793        }"#;
1794        let mut params = BTreeMap::new();
1795        params.insert("Greeting".to_string(), "hello".to_string());
1796        let parsed = parse_template(template, &params).unwrap();
1797        assert_eq!(
1798            parsed.resources[0].properties["QueueName"],
1799            Value::String("aGVsbG8=".to_string())
1800        );
1801    }
1802
1803    #[test]
1804    fn fn_length_counts_string_chars() {
1805        let (p, r, ids, attrs) = empty();
1806        let v: Value =
1807            serde_json::from_str(r#"{"Fn::Length": "héllo"}"#).expect("static fixture parses");
1808        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1809        // 5 chars (not 6 bytes) — multibyte counted once.
1810        assert_eq!(resolved, Value::Number(5.into()));
1811    }
1812
1813    #[test]
1814    fn fn_length_resolves_nested_split() {
1815        let (p, r, ids, attrs) = empty();
1816        let v: Value = serde_json::from_str(r#"{"Fn::Length": {"Fn::Split": [",", "a,b,c,d,e"]}}"#)
1817            .expect("static fixture parses");
1818        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1819        assert_eq!(resolved, Value::Number(5.into()));
1820    }
1821
1822    #[test]
1823    fn fn_to_json_string_serializes_array() {
1824        let (p, r, ids, attrs) = empty();
1825        let v: Value = serde_json::from_str(r#"{"Fn::ToJsonString": ["a", "b", "c"]}"#)
1826            .expect("static fixture parses");
1827        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1828        assert_eq!(resolved, Value::String(r#"["a","b","c"]"#.to_string()));
1829    }
1830
1831    #[test]
1832    fn fn_to_json_string_resolves_inner_ref() {
1833        let template = r#"{
1834            "Parameters": {"Name": {"Type": "String"}},
1835            "Resources": {
1836                "Q": {
1837                    "Type": "AWS::SQS::Queue",
1838                    "Properties": {
1839                        "QueueName": {
1840                            "Fn::ToJsonString": {"k": {"Ref": "Name"}}
1841                        }
1842                    }
1843                }
1844            }
1845        }"#;
1846        let mut params = BTreeMap::new();
1847        params.insert("Name".to_string(), "abc".to_string());
1848        let parsed = parse_template(template, &params).unwrap();
1849        assert_eq!(
1850            parsed.resources[0].properties["QueueName"],
1851            Value::String(r#"{"k":"abc"}"#.to_string())
1852        );
1853    }
1854
1855    #[test]
1856    fn fn_cidr_count_matches_request() {
1857        // Real Fn::Cidr returns up to 2^cidr_bits subnets; we ask for 2
1858        // out of a possible 256, so only 2 land in the output.
1859        let (p, r, ids, attrs) = empty();
1860        let v: Value = serde_json::from_str(r#"{"Fn::Cidr": ["10.0.0.0/16", 2, 8]}"#)
1861            .expect("static fixture parses");
1862        let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
1863        assert_eq!(resolved, serde_json::json!(["10.0.0.0/24", "10.0.1.0/24"]));
1864    }
1865
1866    #[test]
1867    fn fn_cidr_resolves_via_ref() {
1868        let template = r#"{
1869            "Parameters": {"Vpc": {"Type": "String"}},
1870            "Resources": {
1871                "Q": {
1872                    "Type": "AWS::SQS::Queue",
1873                    "Properties": {
1874                        "QueueName": {"Fn::Select": [
1875                            0,
1876                            {"Fn::Cidr": [{"Ref": "Vpc"}, 4, 8]}
1877                        ]}
1878                    }
1879                }
1880            }
1881        }"#;
1882        let mut params = BTreeMap::new();
1883        params.insert("Vpc".to_string(), "172.16.0.0/16".to_string());
1884        let parsed = parse_template(template, &params).unwrap();
1885        assert_eq!(
1886            parsed.resources[0].properties["QueueName"],
1887            Value::String("172.16.0.0/24".to_string())
1888        );
1889    }
1890
1891    #[test]
1892    fn fn_for_each_expands_resources() {
1893        let template = r#"{
1894            "Resources": {
1895                "Fn::ForEach::TopicLoop": [
1896                    "TopicName",
1897                    ["alpha", "beta", "gamma"],
1898                    {
1899                        "${TopicName}Topic": {
1900                            "Type": "AWS::SNS::Topic",
1901                            "Properties": {"TopicName": "${TopicName}-topic"}
1902                        }
1903                    }
1904                ]
1905            }
1906        }"#;
1907        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1908        let names: Vec<&str> = parsed
1909            .resources
1910            .iter()
1911            .map(|r| r.logical_id.as_str())
1912            .collect();
1913        assert!(names.contains(&"alphaTopic"), "got: {names:?}");
1914        assert!(names.contains(&"betaTopic"), "got: {names:?}");
1915        assert!(names.contains(&"gammaTopic"), "got: {names:?}");
1916        let alpha = parsed
1917            .resources
1918            .iter()
1919            .find(|r| r.logical_id == "alphaTopic")
1920            .unwrap();
1921        assert_eq!(
1922            alpha.properties["TopicName"],
1923            Value::String("alpha-topic".to_string())
1924        );
1925    }
1926
1927    #[test]
1928    fn fn_for_each_substitutes_in_nested_values() {
1929        let template = r#"{
1930            "Resources": {
1931                "Fn::ForEach::Q": [
1932                    "QName",
1933                    ["one", "two"],
1934                    {
1935                        "${QName}Queue": {
1936                            "Type": "AWS::SQS::Queue",
1937                            "Properties": {
1938                                "QueueName": "${QName}",
1939                                "Tags": [
1940                                    {"Key": "name", "Value": "${QName}"}
1941                                ]
1942                            }
1943                        }
1944                    }
1945                ]
1946            }
1947        }"#;
1948        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1949        let one = parsed
1950            .resources
1951            .iter()
1952            .find(|r| r.logical_id == "oneQueue")
1953            .unwrap();
1954        assert_eq!(
1955            one.properties["QueueName"],
1956            Value::String("one".to_string())
1957        );
1958        assert_eq!(
1959            one.properties["Tags"][0]["Value"],
1960            Value::String("one".to_string())
1961        );
1962    }
1963
1964    #[test]
1965    fn fn_for_each_nested_loops_expand_cartesian() {
1966        let template = r#"{
1967            "Resources": {
1968                "Fn::ForEach::Outer": [
1969                    "Env",
1970                    ["dev", "prod"],
1971                    {
1972                        "Fn::ForEach::Inner": [
1973                            "Region",
1974                            ["us-east-1", "eu-west-1"],
1975                            {
1976                                "${Env}${Region}Q": {
1977                                    "Type": "AWS::SQS::Queue",
1978                                    "Properties": {"QueueName": "${Env}-${Region}"}
1979                                }
1980                            }
1981                        ]
1982                    }
1983                ]
1984            }
1985        }"#;
1986        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
1987        let names: Vec<&str> = parsed
1988            .resources
1989            .iter()
1990            .map(|r| r.logical_id.as_str())
1991            .collect();
1992        for env in ["dev", "prod"] {
1993            for region in ["us-east-1", "eu-west-1"] {
1994                let expected = format!("{env}{region}Q");
1995                assert!(
1996                    names.contains(&expected.as_str()),
1997                    "missing {expected} in {names:?}"
1998                );
1999            }
2000        }
2001        let dev_us = parsed
2002            .resources
2003            .iter()
2004            .find(|r| r.logical_id == "devus-east-1Q")
2005            .unwrap();
2006        assert_eq!(
2007            dev_us.properties["QueueName"],
2008            Value::String("dev-us-east-1".to_string())
2009        );
2010    }
2011
2012    #[test]
2013    fn fn_for_each_keeps_other_resources_untouched() {
2014        let template = r#"{
2015            "Resources": {
2016                "Static": {
2017                    "Type": "AWS::SQS::Queue",
2018                    "Properties": {"QueueName": "static-q"}
2019                },
2020                "Fn::ForEach::Loop": [
2021                    "I",
2022                    ["a", "b"],
2023                    {
2024                        "${I}Topic": {
2025                            "Type": "AWS::SNS::Topic",
2026                            "Properties": {"TopicName": "${I}"}
2027                        }
2028                    }
2029                ]
2030            }
2031        }"#;
2032        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2033        let names: Vec<&str> = parsed
2034            .resources
2035            .iter()
2036            .map(|r| r.logical_id.as_str())
2037            .collect();
2038        assert!(names.contains(&"Static"));
2039        assert!(names.contains(&"aTopic"));
2040        assert!(names.contains(&"bTopic"));
2041        assert_eq!(parsed.resources.len(), 3);
2042    }
2043
2044    #[test]
2045    fn fn_for_each_invalid_arity_errors() {
2046        let template = r#"{
2047            "Resources": {
2048                "Fn::ForEach::Bad": [
2049                    "Var",
2050                    ["a"]
2051                ]
2052            }
2053        }"#;
2054        let err = parse_template(template, &BTreeMap::new()).unwrap_err();
2055        assert!(err.contains("Fn::ForEach"), "got: {err}");
2056    }
2057
2058    #[test]
2059    fn fn_for_each_resolves_intrinsics_in_emitted_resources() {
2060        // Body of the loop references both the loop variable and a
2061        // stack parameter, exercising that downstream intrinsic
2062        // resolution still runs over emitted resources.
2063        let template = r#"{
2064            "Parameters": {"Env": {"Type": "String"}},
2065            "Resources": {
2066                "Fn::ForEach::Q": [
2067                    "Name",
2068                    ["alpha", "beta"],
2069                    {
2070                        "${Name}Queue": {
2071                            "Type": "AWS::SQS::Queue",
2072                            "Properties": {
2073                                "QueueName": {"Fn::Sub": "${Env}-${Name}"}
2074                            }
2075                        }
2076                    }
2077                ]
2078            }
2079        }"#;
2080        let mut params = BTreeMap::new();
2081        params.insert("Env".to_string(), "prod".to_string());
2082        let parsed = parse_template(template, &params).unwrap();
2083        // ${Name} substitutes at ForEach expansion time; ${Env} comes
2084        // from the parameter at Fn::Sub time. Both must land.
2085        let alpha = parsed
2086            .resources
2087            .iter()
2088            .find(|r| r.logical_id == "alphaQueue")
2089            .unwrap();
2090        assert_eq!(
2091            alpha.properties["QueueName"],
2092            Value::String("prod-alpha".to_string())
2093        );
2094    }
2095
2096    #[test]
2097    fn fn_for_each_re_resolves_at_provision_time() {
2098        // resolve_resource_properties_with_attrs must also expand
2099        // ForEach so the looked-up resource by logical ID matches the
2100        // post-expansion template.
2101        let template = r#"{
2102            "Resources": {
2103                "Fn::ForEach::Q": [
2104                    "Name",
2105                    ["alpha"],
2106                    {
2107                        "${Name}Queue": {
2108                            "Type": "AWS::SQS::Queue",
2109                            "Properties": {"QueueName": "${Name}-q"}
2110                        }
2111                    }
2112                ]
2113            }
2114        }"#;
2115        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2116        let resource = parsed
2117            .resources
2118            .iter()
2119            .find(|r| r.logical_id == "alphaQueue")
2120            .unwrap();
2121        let reresolved = resolve_resource_properties_with_attrs(
2122            resource,
2123            template,
2124            &BTreeMap::new(),
2125            &BTreeMap::new(),
2126            &BTreeMap::new(),
2127            &BTreeMap::new(),
2128        )
2129        .unwrap();
2130        assert_eq!(
2131            reresolved.properties["QueueName"],
2132            Value::String("alpha-q".to_string())
2133        );
2134    }
2135
2136    #[test]
2137    fn fn_for_each_resolves_ref_to_comma_delimited_list_param() {
2138        // CommaDelimitedList parameters are a documented ForEach input
2139        // shape. Stack passes the value as a single string; ForEach
2140        // must split it before iterating.
2141        let template = r#"{
2142            "Parameters": {"Names": {"Type": "CommaDelimitedList"}},
2143            "Resources": {
2144                "Fn::ForEach::Q": [
2145                    "N",
2146                    {"Ref": "Names"},
2147                    {
2148                        "${N}Queue": {
2149                            "Type": "AWS::SQS::Queue",
2150                            "Properties": {"QueueName": "${N}-q"}
2151                        }
2152                    }
2153                ]
2154            }
2155        }"#;
2156        let mut params = BTreeMap::new();
2157        params.insert("Names".to_string(), "alpha,beta,gamma".to_string());
2158        let parsed = parse_template(template, &params).unwrap();
2159        let names: Vec<&str> = parsed
2160            .resources
2161            .iter()
2162            .map(|r| r.logical_id.as_str())
2163            .collect();
2164        for v in ["alphaQueue", "betaQueue", "gammaQueue"] {
2165            assert!(names.contains(&v), "missing {v} in {names:?}");
2166        }
2167    }
2168
2169    #[test]
2170    fn fn_for_each_ampersand_substitution_form() {
2171        // AWS supports `&{Var}` in addition to `${Var}` for ForEach
2172        // loop variable substitution; needed when the surrounding
2173        // template separately uses ${}-style for Fn::Sub.
2174        let template = r#"{
2175            "Resources": {
2176                "Fn::ForEach::Q": [
2177                    "Name",
2178                    ["alpha", "beta"],
2179                    {
2180                        "&{Name}Queue": {
2181                            "Type": "AWS::SQS::Queue",
2182                            "Properties": {"QueueName": "&{Name}"}
2183                        }
2184                    }
2185                ]
2186            }
2187        }"#;
2188        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2189        let names: Vec<&str> = parsed
2190            .resources
2191            .iter()
2192            .map(|r| r.logical_id.as_str())
2193            .collect();
2194        assert!(names.contains(&"alphaQueue"), "got: {names:?}");
2195        assert!(names.contains(&"betaQueue"), "got: {names:?}");
2196        let alpha = parsed
2197            .resources
2198            .iter()
2199            .find(|r| r.logical_id == "alphaQueue")
2200            .unwrap();
2201        assert_eq!(
2202            alpha.properties["QueueName"],
2203            Value::String("alpha".to_string())
2204        );
2205    }
2206
2207    #[test]
2208    fn fn_for_each_in_outputs_expands() {
2209        let template = r#"{
2210            "Resources": {
2211                "Q": {"Type": "AWS::SQS::Queue", "Properties": {"QueueName": "q"}}
2212            },
2213            "Outputs": {
2214                "Fn::ForEach::OutputLoop": [
2215                    "I",
2216                    ["one", "two"],
2217                    {
2218                        "${I}Out": {"Value": "${I}-value"}
2219                    }
2220                ]
2221            }
2222        }"#;
2223        let parsed = parse_template(template, &BTreeMap::new()).unwrap();
2224        let names: Vec<&str> = parsed
2225            .outputs
2226            .iter()
2227            .map(|o| o.logical_id.as_str())
2228            .collect();
2229        assert!(names.contains(&"oneOut"), "got: {names:?}");
2230        assert!(names.contains(&"twoOut"), "got: {names:?}");
2231        let one = parsed
2232            .outputs
2233            .iter()
2234            .find(|o| o.logical_id == "oneOut")
2235            .unwrap();
2236        assert_eq!(one.value, "one-value");
2237    }
2238}
2239
2240mod conditions;
2241mod for_each;
2242mod intrinsics;
2243mod mappings;
2244mod parser;
2245mod resolution;
2246mod sam_events;
2247use conditions::*;
2248use for_each::*;
2249use intrinsics::*;
2250use mappings::*;
2251
2252pub use parser::{
2253    collect_import_value_names, parse_outputs, parse_template, parse_template_with_physical_ids,
2254    parse_template_with_resolution,
2255};
2256pub use resolution::{
2257    dependency_order, resolve_resource_properties, resolve_resource_properties_with_attrs,
2258};