Skip to main content

template_model/
serialization.rs

1use crate::consts::*;
2use crate::defect::DefectPhase;
3use crate::diagnostic::*;
4use crate::json_value::JsonValue;
5use crate::model::{ResolvedOutput, ResolvedResource, SemanticModel, TemplateRule};
6use crate::resolved_value::collect_condition_refs_from_resolved;
7use crate::resolver::{MapEntry, RefKind, ResolvedValue};
8use std::collections::HashMap;
9
10impl SemanticModel {
11    pub fn to_diagnostic_json(&self) -> DiagnosticModel {
12        let resources = build_resources(&self.resources, &self.graph);
13        let conditions = build_conditions(&self.conditions);
14        let cycles = filter_sam_cycles(self.graph.cycles(), &self.transforms, &self.resources);
15        let outputs = build_outputs(&self.outputs, &self.graph);
16
17        DiagnosticModel {
18            template: DiagnosticTemplate {
19                format_version: self.format_version.clone(),
20                description: self.description.clone(),
21                transforms: self.transforms.clone(),
22                raw_top_level_keys: self.raw_top_level_keys.clone(),
23            },
24            parameters: self
25                .parameters
26                .iter()
27                .map(|(k, v)| {
28                    (
29                        k.clone(),
30                        JsonValue(serde_json::json!({
31                            "type": v.param_type,
32                            "default": v.default,
33                            "allowedValues": v.allowed_values,
34                            "allowedPattern": v.allowed_pattern,
35                            "allowedPatternValid": v.allowed_pattern_valid,
36                            "defaultMatchesAllowedPattern": v.default_matches_allowed_pattern,
37                            "minLength": v.min_length,
38                            "maxLength": v.max_length,
39                            "minValue": v.min_value,
40                            "maxValue": v.max_value,
41                            "noEcho": v.no_echo,
42                        })),
43                    )
44                })
45                .collect(),
46            conditions,
47            condition_param_refs: self.conditions.referenced_params(),
48            // Synthetic conditions (`__`-prefixed, added for inline Fn::If and
49            // Rules-section assertions) drive the internal SAT model but are
50            // filtered out of the serialized `conditions` map, so they must be
51            // filtered out of every serialized structure that names conditions -
52            // otherwise engines and language bindings see implications/mutex
53            // groups referencing condition names that do not exist in the model.
54            condition_implications: self
55                .conditions
56                .implications
57                .iter()
58                .filter(|i| !i.antecedent.starts_with("__") && !i.consequent.starts_with("__"))
59                .map(|i| DiagnosticImplication { antecedent: i.antecedent.clone(), consequent: i.consequent.clone() })
60                .collect(),
61            condition_mutex_groups: self
62                .conditions
63                .mutex_groups
64                .iter()
65                .filter(|g| !g.conditions.iter().any(|c| c.starts_with("__")))
66                .map(|g| DiagnosticMutexGroup {
67                    conditions: g.conditions.clone(),
68                    parameter: g.parameter.clone(),
69                    values: g.values.clone(),
70                })
71                .collect(),
72            condition_exclusions: {
73                // Sort the condition names before the pairwise pass. HashMap
74                // iteration order is randomized per run, so if the cumulative
75                // satisfiability budget is exhausted partway through, an
76                // unsorted order would make the set of pairs examined - and thus
77                // the exclusions found - differ across runs. A stable order keeps
78                // budget-truncated output deterministic and engine-identical,
79                // mirroring the deterministic per-type resource ordering in
80                // `model`.
81                let mut cond_names: Vec<&String> =
82                    self.conditions.conditions.keys().filter(|n| !n.starts_with("__")).collect();
83                cond_names.sort();
84                let mut exclusions = Vec::new();
85                'pairs: for i in 0..cond_names.len() {
86                    for j in (i + 1)..cond_names.len() {
87                        // Stop once the model's cumulative satisfiability budget
88                        // is spent: every remaining pair would only get the
89                        // conservative "compatible" answer, so continuing adds
90                        // no exclusions and would let an adversarial condition
91                        // graph keep the quadratic pass running unbounded.
92                        if self.conditions.satisfiability_budget_exhausted() {
93                            break 'pairs;
94                        }
95                        if !self.conditions.conditions_compatible(cond_names[i], cond_names[j]) {
96                            exclusions.push(vec![cond_names[i].clone(), cond_names[j].clone()]);
97                        }
98                    }
99                }
100                exclusions
101            },
102            resource_condition_map: self
103                .resources
104                .iter()
105                .filter_map(|(id, r)| r.condition.as_ref().map(|c| (id.clone(), c.clone())))
106                .collect(),
107            mappings: JsonValue(serde_json::json!(self.mappings)),
108            resources,
109            outputs,
110            edges: build_edges(&self.graph),
111            cycles,
112            output_empty_joins: self.output_empty_joins.clone(),
113            sam_implicit_resources: self.sam_implicit_resources.iter().cloned().collect(),
114            globals_param_refs: self.globals_param_refs.clone(),
115            is_cdk: self.is_cdk,
116            fn_if_conditions: self.fn_if_conditions.clone(),
117            find_in_map_names: {
118                let mut names: Vec<String> = self.find_in_map_names.iter().cloned().collect();
119                names.sort();
120                names
121            },
122            params_referenced_in_definitions: {
123                let mut names: Vec<String> = self.params_referenced_in_definitions.iter().cloned().collect();
124                names.sort();
125                names
126            },
127            has_dynamic_findinmap_name: self.has_dynamic_findinmap_name,
128            has_parse_errors: self.diagnostics.iter().any(|d| d.is_fatal() && d.phase == Some(DefectPhase::Parse)),
129            parsed_rules: self.parsed_rules.iter().map(build_rule).collect(),
130            resolution_sources: self
131                .resolution_sources
132                .iter()
133                .map(|((rid, path), source)| ResolutionSource {
134                    resource_id: rid.clone(),
135                    property_path: path.clone(),
136                    source: source.clone(),
137                })
138                .collect(),
139        }
140    }
141}
142
143fn build_resources(
144    resources: &HashMap<String, ResolvedResource>,
145    graph: &crate::graph::ReferenceGraph,
146) -> HashMap<String, DiagnosticResource> {
147    resources
148        .iter()
149        .map(|(id, res)| {
150            let props: HashMap<String, JsonValue> =
151                res.properties.iter().map(|(k, v)| (k.clone(), JsonValue(resolved_value_to_json(v)))).collect();
152
153            let outgoing = graph
154                .outgoing_edges(id)
155                .map(|e| {
156                    let (kind, attr) = ref_kind_to_str(&e.kind);
157                    OutgoingRef {
158                        source_path: e.source_path.clone(),
159                        target: e.target.clone(),
160                        kind: kind.to_string(),
161                        attr,
162                        condition_context: e.condition_context.clone(),
163                    }
164                })
165                .collect();
166
167            let incoming = graph
168                .incoming(id)
169                .iter()
170                .map(|e| {
171                    let (kind, attr) = ref_kind_to_str(&e.kind);
172                    IncomingRef {
173                        source: e.source_resource.clone(),
174                        source_path: e.source_path.clone(),
175                        kind: kind.to_string(),
176                        attr,
177                    }
178                })
179                .collect();
180
181            (
182                id.clone(),
183                DiagnosticResource {
184                    resource_type: res.resource_type.clone(),
185                    condition: res.condition.clone(),
186                    depends_on: res.depends_on.clone(),
187                    deletion_policy: res.deletion_policy.as_ref().map(|v| JsonValue(resolved_value_to_json(v))),
188                    update_replace_policy: res
189                        .update_replace_policy
190                        .as_ref()
191                        .map(|v| JsonValue(resolved_value_to_json(v))),
192                    creation_policy: res.creation_policy.as_ref().map(|v| JsonValue(serde_json::json!(v))),
193                    update_policy: res.update_policy.as_ref().map(|v| JsonValue(serde_json::json!(v))),
194                    properties: props,
195                    outgoing_refs: outgoing,
196                    incoming_refs: incoming,
197                    find_in_map_refs: res.diagnostics.find_in_map_refs.clone(),
198                    simple_subs: res
199                        .diagnostics
200                        .simple_subs
201                        .iter()
202                        .map(|s| PathVariable { path: s.path.clone(), variable: s.value.clone() })
203                        .collect(),
204                    redundant_subs: res.diagnostics.redundant_subs.clone(),
205                    empty_joins: res.diagnostics.empty_joins.clone(),
206                    hardcoded_partition_arns: res.diagnostics.hardcoded_partition_arns.clone(),
207                    conditionally_null_props: res
208                        .diagnostics
209                        .conditionally_null_props
210                        .iter()
211                        .map(|s| ConditionalNull {
212                            path: s.path.clone(),
213                            condition: s.condition.clone(),
214                            null_in_true: s.null_in_true_branch,
215                        })
216                        .collect(),
217                    condition_refs: res.diagnostics.condition_refs.clone(),
218                    for_each_expansions: res
219                        .diagnostics
220                        .foreach_expansions
221                        .iter()
222                        .map(|fe| DiagnosticForEachExpansion {
223                            path: fe.property_path.clone(),
224                            identifier: fe.identifier.clone(),
225                            collection: fe.collection_source.clone(),
226                        })
227                        .collect(),
228                    unsubstituted_variables: res
229                        .diagnostics
230                        .unsubstituted_variables
231                        .iter()
232                        .map(|s| PathVariable { path: s.path.clone(), variable: s.value.clone() })
233                        .collect(),
234                    unused_sub_keys: res
235                        .diagnostics
236                        .unused_sub_keys
237                        .iter()
238                        .map(|s| PathVariable { path: s.path.clone(), variable: s.value.clone() })
239                        .collect(),
240                    raw_pseudo_params: res
241                        .diagnostics
242                        .raw_pseudo_params
243                        .iter()
244                        .map(|s| PathVariable { path: s.path.clone(), variable: s.value.clone() })
245                        .collect(),
246                    secretsmanager_ref_paths: res.diagnostics.secretsmanager_ref_paths.clone(),
247                    invalid_refs: res
248                        .diagnostics
249                        .invalid_refs
250                        .iter()
251                        .map(|s| PathTarget { path: s.path.clone(), target: s.value.clone() })
252                        .collect(),
253                },
254            )
255        })
256        .collect()
257}
258
259fn build_conditions(conditions: &crate::conditions::ConditionModel) -> HashMap<String, DiagnosticCondition> {
260    let mut out = HashMap::new();
261    for name in conditions.names().filter(|n| !n.starts_with("__")) {
262        let (expression, deps) = if let Some(expr) = conditions.get(name) {
263            let mut d = Vec::new();
264            crate::conditions::collect_condition_deps(expr, &mut d);
265            d.sort();
266            d.dedup();
267            (Some(crate::conditions::format_condition_expr(expr)), if d.is_empty() { None } else { Some(d) })
268        } else {
269            (None, None)
270        };
271
272        let mut mutex_with: Vec<String> = Vec::new();
273        for group in &conditions.mutex_groups {
274            if group.conditions.contains(&name.to_string()) {
275                for peer in &group.conditions {
276                    if peer != name {
277                        mutex_with.push(peer.clone());
278                    }
279                }
280            }
281        }
282
283        out.insert(
284            name.to_string(),
285            DiagnosticCondition {
286                expression,
287                deps,
288                mutex_with: if mutex_with.is_empty() { None } else { Some(mutex_with) },
289            },
290        );
291    }
292    out
293}
294
295fn build_edges(graph: &crate::graph::ReferenceGraph) -> Vec<ReferenceEdge> {
296    graph
297        .edges
298        .iter()
299        .map(|e| {
300            let (kind, attr) = ref_kind_to_str(&e.kind);
301            ReferenceEdge {
302                source: e.source_resource.clone(),
303                source_path: e.source_path.clone(),
304                target: e.target.clone(),
305                kind: kind.to_string(),
306                attr,
307                condition_context: e.condition_context.clone(),
308            }
309        })
310        .collect()
311}
312
313fn build_outputs(
314    outputs: &HashMap<String, ResolvedOutput>,
315    graph: &crate::graph::ReferenceGraph,
316) -> HashMap<String, DiagnosticOutput> {
317    let mut output_getatt_refs: HashMap<String, Vec<(String, String)>> = HashMap::new();
318    for edge in &graph.edges {
319        if let Some(output_name) = edge.source_resource.strip_prefix(OUTPUT_PSEUDO_RESOURCE_PREFIX)
320            && let RefKind::GetAtt { attr } = &edge.kind
321            && getatt_is_in_string_position(&edge.source_path)
322        {
323            output_getatt_refs.entry(output_name.to_string()).or_default().push((edge.target.clone(), attr.clone()));
324        }
325    }
326
327    outputs
328        .iter()
329        .map(|(name, output)| {
330            // A GetAtt sitting directly inside a literal list/map output value is
331            // not collected for the string-type check: the enclosing container is
332            // itself a non-string value and is reported on its own, so descending
333            // into it would double-report the same defect. GetAtts reached only
334            // through Fn::If branches or string-building functions stay in scope.
335            let mut getatt_refs = Vec::new();
336            collect_getatt_refs_string_position(&output.value, &mut getatt_refs);
337            if let Some(edge_refs) = output_getatt_refs.get(name) {
338                for (t, a) in edge_refs {
339                    if !getatt_refs.iter().any(|(rt, ra)| rt == t && ra == a) {
340                        getatt_refs.push((t.clone(), a.clone()));
341                    }
342                }
343            }
344            (
345                name.clone(),
346                DiagnosticOutput {
347                    value: JsonValue(resolved_value_to_json(&output.value)),
348                    description: output.description.clone(),
349                    condition: output.condition.clone(),
350                    export_name: output.export_name.as_ref().map(|v| JsonValue(resolved_value_to_json(v))),
351                    getatt_refs: getatt_refs
352                        .into_iter()
353                        .map(|(r, a)| GetAttRef { resource: r, attribute: a })
354                        .collect(),
355                    condition_refs: {
356                        let mut crefs = Vec::new();
357                        collect_condition_refs_from_resolved(&output.value, &mut crefs);
358                        crefs.sort();
359                        crefs.dedup();
360                        crefs
361                    },
362                },
363            )
364        })
365        .collect()
366}
367
368fn build_rule(r: &TemplateRule) -> DiagnosticRule {
369    DiagnosticRule {
370        name: r.name.clone(),
371        condition: r.condition.as_ref().map(|v| JsonValue(v.clone())),
372        assertions: r
373            .assertions
374            .iter()
375            .map(|a| DiagnosticRuleAssertion {
376                assert_expr: JsonValue(a.assert.clone()),
377                assert_description: a.description.clone(),
378            })
379            .collect(),
380    }
381}
382
383fn filter_sam_cycles(
384    raw_cycles: &[Vec<String>],
385    transforms: &[String],
386    resources: &HashMap<String, ResolvedResource>,
387) -> Vec<Vec<String>> {
388    let has_sam = transforms.iter().any(|t| t == TRANSFORM_SERVERLESS);
389    if has_sam {
390        raw_cycles
391            .iter()
392            .filter(|cycle| {
393                !cycle.iter().any(|rid| {
394                    resources.get(rid).map(|r| r.resource_type.starts_with(SAM_SERVERLESS_TYPE_PREFIX)).unwrap_or(false)
395                })
396            })
397            .cloned()
398            .collect()
399    } else {
400        raw_cycles.to_vec()
401    }
402}
403
404pub fn resolved_value_to_json(val: &ResolvedValue) -> serde_json::Value {
405    match val {
406        ResolvedValue::Concrete { value: v } => v.0.clone(),
407        ResolvedValue::List { items } => serde_json::Value::Array(items.iter().map(resolved_value_to_json).collect()),
408        ResolvedValue::Map { entries } => {
409            let mut map = serde_json::Map::new();
410            for MapEntry { key: k, value: v } in entries {
411                map.insert(k.clone(), resolved_value_to_json(v));
412            }
413            serde_json::Value::Object(map)
414        }
415        ResolvedValue::Enum { variants: vals } => {
416            serde_json::json!({MARKER_ENUM: vals.iter().map(resolved_value_to_json).collect::<Vec<_>>()})
417        }
418        ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => {
419            serde_json::json!({MARKER_CONDITIONAL: cond, MARKER_IF_TRUE: resolved_value_to_json(t), MARKER_IF_FALSE: resolved_value_to_json(f)})
420        }
421        ResolvedValue::Reference { target, kind } => {
422            let kind_str = match kind {
423                RefKind::Ref => "resource".to_string(),
424                RefKind::GetAtt { attr: a } => format!("getatt:{}", a),
425                RefKind::Sub { var: v } => format!("sub:{}", v),
426                RefKind::DependsOn => "dependson".to_string(),
427            };
428            serde_json::json!({MARKER_REF: target, MARKER_KIND: kind_str})
429        }
430        ResolvedValue::Dynamic { reason } => serde_json::json!({MARKER_DYNAMIC: reason}),
431        ResolvedValue::TypedDynamic { reason, param_type } => {
432            serde_json::json!({MARKER_DYNAMIC: reason, MARKER_PARAM_TYPE: param_type})
433        }
434    }
435}
436
437pub fn resolved_value_to_json_clean(val: &ResolvedValue) -> Option<serde_json::Value> {
438    match val {
439        ResolvedValue::Concrete { value: v } => Some(v.0.clone()),
440        ResolvedValue::List { items } => {
441            Some(serde_json::Value::Array(items.iter().filter_map(resolved_value_to_json_clean).collect()))
442        }
443        ResolvedValue::Map { entries } => {
444            let mut map = serde_json::Map::new();
445            for MapEntry { key: k, value: v } in entries {
446                if let Some(jv) = resolved_value_to_json_clean(v) {
447                    map.insert(k.clone(), jv);
448                }
449            }
450            Some(serde_json::Value::Object(map))
451        }
452        ResolvedValue::Enum { variants: vals } => {
453            let concrete: Vec<serde_json::Value> = vals.iter().filter_map(resolved_value_to_json_clean).collect();
454            match concrete.len() {
455                0 => None,
456                1 => Some(concrete.into_iter().next().unwrap()),
457                _ => Some(serde_json::Value::Array(concrete)),
458            }
459        }
460        ResolvedValue::Conditional { condition: _, if_true: t, if_false: _ } => resolved_value_to_json_clean(t),
461        ResolvedValue::Dynamic { reason: _ }
462        | ResolvedValue::TypedDynamic { reason: _, param_type: _ }
463        | ResolvedValue::Reference { target: _, kind: _ } => None,
464    }
465}
466
467fn ref_kind_to_str(kind: &RefKind) -> (&'static str, Option<String>) {
468    match kind {
469        RefKind::Ref => (EDGE_KIND_REF, None),
470        RefKind::GetAtt { attr } => (EDGE_KIND_GET_ATT, Some(attr.clone())),
471        RefKind::Sub { var } => (EDGE_KIND_SUB, Some(var.clone())),
472        RefKind::DependsOn => (EDGE_KIND_DEPENDS_ON, None),
473    }
474}
475
476/// Collects GetAtt references that occupy a *string position* of an output
477/// value - the value itself, or a value reached only through `Fn::If` branches.
478/// A GetAtt nested inside a literal list or map is skipped: the container is a
479/// non-string output value reported in its own right, and CloudFormation never
480/// treats the inner GetAtt as the output's string value. `Fn::If` is transparent
481/// (a branch is a string position), mirroring the output value-type check.
482fn collect_getatt_refs_string_position(val: &ResolvedValue, out: &mut Vec<(String, String)>) {
483    match val {
484        ResolvedValue::Reference { target, kind: RefKind::GetAtt { attr } } => out.push((target.clone(), attr.clone())),
485        ResolvedValue::Conditional { condition: _, if_true: t, if_false: f } => {
486            collect_getatt_refs_string_position(t, out);
487            collect_getatt_refs_string_position(f, out);
488        }
489        _ => {}
490    }
491}
492
493/// Whether a GetAtt reference edge whose source path is `source_path` occupies a
494/// string position of the output (see [`collect_getatt_refs_string_position`]).
495/// The path is `Value` optionally followed by segments; a GetAtt is in string
496/// position unless it sits inside a literal list/map - i.e. the path descends
497/// through a bare index or key that is not an argument of a string-building
498/// function. `Fn::If` branch segments are transparent; any other `Fn::…` segment
499/// (e.g. `Fn::Join`, `Fn::Sub`) marks a string-building consumer, so a GetAtt
500/// beneath it stays in scope.
501fn getatt_is_in_string_position(source_path: &str) -> bool {
502    // Edge source paths are section-rooted (`Outputs/<name>/Value...`); take the
503    // part after the terminal `/Value` node. Use last-occurrence splitting so an
504    // output name containing "Value" (e.g. `ValueFoo`) does not consume the
505    // output-name segment as part of the tail.
506    let after_value = source_path
507        .rsplit_once("/Value")
508        .map(|(_, tail)| tail)
509        .or_else(|| source_path.strip_prefix("Value"))
510        .unwrap_or("");
511    let mut segments = after_value.split('.').filter(|s| !s.is_empty());
512    while let Some(segment) = segments.next() {
513        if segment == FN_IF {
514            // Skip the branch selector (`1`/`2`) and keep walking transparently.
515            segments.next();
516            continue;
517        }
518        // A remaining `Fn::…` segment is a string-building function consuming the
519        // GetAtt (Join/Sub/…): the GetAtt is in string position.
520        if segment.starts_with("Fn::") {
521            return true;
522        }
523        // A bare index or key means the GetAtt is inside a literal container.
524        return false;
525    }
526    true
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532    use serde_json::json;
533
534    #[test]
535    fn to_json_concrete() {
536        assert_eq!(resolved_value_to_json(&ResolvedValue::Concrete { value: json!("hi").into() }), json!("hi"));
537        assert_eq!(resolved_value_to_json(&ResolvedValue::Concrete { value: json!(42).into() }), json!(42));
538    }
539
540    #[test]
541    fn to_json_list() {
542        let val = ResolvedValue::List {
543            items: vec![
544                ResolvedValue::Concrete { value: json!(1).into() },
545                ResolvedValue::Concrete { value: json!(2).into() },
546            ],
547        };
548        assert_eq!(resolved_value_to_json(&val), json!([1, 2]));
549    }
550
551    #[test]
552    fn to_json_map() {
553        let val = ResolvedValue::Map {
554            entries: vec![MapEntry { key: "k".into(), value: ResolvedValue::Concrete { value: json!("v").into() } }],
555        };
556        assert_eq!(resolved_value_to_json(&val), json!({"k": "v"}));
557    }
558
559    #[test]
560    fn to_json_enum_uses_marker() {
561        let val = ResolvedValue::Enum { variants: vec![ResolvedValue::Concrete { value: json!("a").into() }] };
562        let j = resolved_value_to_json(&val);
563        assert_ne!(j.get(MARKER_ENUM), None, "expected MARKER_ENUM key in serialized enum");
564    }
565
566    #[test]
567    fn to_json_conditional_uses_markers() {
568        let val = ResolvedValue::Conditional {
569            condition: "C".into(),
570            if_true: Box::new(ResolvedValue::Concrete { value: json!(1).into() }),
571            if_false: Box::new(ResolvedValue::Concrete { value: json!(2).into() }),
572        };
573        let j = resolved_value_to_json(&val);
574        assert_eq!(j[MARKER_CONDITIONAL], "C");
575        assert_eq!(j[MARKER_IF_TRUE], 1);
576        assert_eq!(j[MARKER_IF_FALSE], 2);
577    }
578
579    #[test]
580    fn to_json_reference_uses_markers() {
581        let val = ResolvedValue::Reference { target: "R".into(), kind: RefKind::GetAtt { attr: "Arn".into() } };
582        let j = resolved_value_to_json(&val);
583        assert_eq!(j[MARKER_REF], "R");
584        assert_eq!(j[MARKER_KIND], "getatt:Arn");
585    }
586
587    #[test]
588    fn to_json_dynamic_uses_marker() {
589        let val = ResolvedValue::Dynamic { reason: "reason".into() };
590        assert_eq!(resolved_value_to_json(&val)[MARKER_DYNAMIC], "reason");
591    }
592
593    #[test]
594    fn to_json_typed_dynamic_includes_param_type() {
595        let val = ResolvedValue::TypedDynamic { reason: "reason".into(), param_type: "Number".into() };
596        let j = resolved_value_to_json(&val);
597        assert_eq!(j[MARKER_DYNAMIC], "reason");
598        assert_eq!(j[MARKER_PARAM_TYPE], "Number");
599    }
600
601    #[test]
602    fn to_json_clean_concrete() {
603        assert_eq!(
604            resolved_value_to_json_clean(&ResolvedValue::Concrete { value: json!("x").into() }),
605            Some(json!("x"))
606        );
607    }
608
609    #[test]
610    fn to_json_clean_dynamic_returns_none() {
611        assert_eq!(
612            resolved_value_to_json_clean(&ResolvedValue::Dynamic { reason: "x".into() }),
613            None,
614            "Dynamic should return None"
615        );
616    }
617
618    #[test]
619    fn to_json_clean_reference_returns_none() {
620        assert_eq!(
621            resolved_value_to_json_clean(&ResolvedValue::Reference { target: "R".into(), kind: RefKind::Ref }),
622            None,
623            "Reference should return None"
624        );
625    }
626
627    #[test]
628    fn to_json_clean_conditional_takes_true_branch() {
629        let val = ResolvedValue::Conditional {
630            condition: "C".into(),
631            if_true: Box::new(ResolvedValue::Concrete { value: json!("yes").into() }),
632            if_false: Box::new(ResolvedValue::Concrete { value: json!("no").into() }),
633        };
634        assert_eq!(resolved_value_to_json_clean(&val), Some(json!("yes")));
635    }
636
637    #[test]
638    fn to_json_clean_enum_single_unwraps() {
639        let val = ResolvedValue::Enum { variants: vec![ResolvedValue::Concrete { value: json!("only").into() }] };
640        assert_eq!(resolved_value_to_json_clean(&val), Some(json!("only")));
641    }
642
643    #[test]
644    fn to_json_clean_enum_multiple_returns_array() {
645        let val = ResolvedValue::Enum {
646            variants: vec![
647                ResolvedValue::Concrete { value: json!("a").into() },
648                ResolvedValue::Concrete { value: json!("b").into() },
649            ],
650        };
651        assert_eq!(resolved_value_to_json_clean(&val), Some(json!(["a", "b"])));
652    }
653
654    #[test]
655    fn to_json_clean_enum_all_dynamic_returns_none() {
656        let val = ResolvedValue::Enum { variants: vec![ResolvedValue::Dynamic { reason: "x".into() }] };
657        assert_eq!(resolved_value_to_json_clean(&val), None, "all-dynamic enum should return None");
658    }
659
660    #[test]
661    fn to_json_clean_list_filters_dynamic() {
662        let val = ResolvedValue::List {
663            items: vec![
664                ResolvedValue::Concrete { value: json!(1).into() },
665                ResolvedValue::Dynamic { reason: "x".into() },
666            ],
667        };
668        assert_eq!(resolved_value_to_json_clean(&val), Some(json!([1])));
669    }
670
671    #[test]
672    fn to_json_clean_map_filters_dynamic() {
673        let val = ResolvedValue::Map {
674            entries: vec![
675                MapEntry { key: "a".into(), value: ResolvedValue::Concrete { value: json!(1).into() } },
676                MapEntry { key: "b".into(), value: ResolvedValue::Dynamic { reason: "x".into() } },
677            ],
678        };
679        assert_eq!(resolved_value_to_json_clean(&val), Some(json!({"a": 1})));
680    }
681
682    #[test]
683    fn ref_kind_to_str_all_variants() {
684        assert_eq!(ref_kind_to_str(&RefKind::Ref), ("Ref", None));
685        assert_eq!(ref_kind_to_str(&RefKind::GetAtt { attr: "A".into() }), ("GetAtt", Some("A".into())));
686        assert_eq!(ref_kind_to_str(&RefKind::Sub { var: "V".into() }), ("Sub", Some("V".into())));
687        assert_eq!(ref_kind_to_str(&RefKind::DependsOn), ("DependsOn", None));
688    }
689
690    #[test]
691    fn to_json_reference_sub_kind() {
692        let val = ResolvedValue::Reference { target: "R".into(), kind: RefKind::Sub { var: "V".into() } };
693        let j = resolved_value_to_json(&val);
694        assert_eq!(j[MARKER_KIND], "sub:V");
695    }
696
697    #[test]
698    fn to_json_reference_dependson_kind() {
699        let val = ResolvedValue::Reference { target: "R".into(), kind: RefKind::DependsOn };
700        let j = resolved_value_to_json(&val);
701        assert_eq!(j[MARKER_KIND], "dependson");
702    }
703
704    #[test]
705    fn getatt_string_position_paths() {
706        // Direct, whole-branch, and string-building-function positions keep the
707        // GetAtt in scope for the output value-type check; a bare index/key
708        // beneath the value (a literal container) takes it out of scope, because
709        // the container itself is reported as the non-string value.
710        assert!(getatt_is_in_string_position("Outputs/O/Value"));
711        assert!(getatt_is_in_string_position("Outputs/O/Value.Fn::If.1"));
712        assert!(getatt_is_in_string_position("Outputs/O/Value.Fn::Join.1.0"));
713        assert!(!getatt_is_in_string_position("Outputs/O/Value.0"));
714        assert!(!getatt_is_in_string_position("Outputs/O/Value.k"));
715        assert!(!getatt_is_in_string_position("Outputs/O/Value.Fn::If.2.0"));
716        // Terminal intrinsic segments: GetAtt/Sub at the end of the path
717        assert!(getatt_is_in_string_position("Outputs/O/Value.Fn::GetAtt"));
718        assert!(getatt_is_in_string_position("Outputs/O/Value.Fn::Sub"));
719        assert!(getatt_is_in_string_position("Outputs/O/Value.Fn::Join.1.0.Fn::GetAtt"));
720        // A GetAtt inside a literal container stays out of scope even with terminal
721        assert!(!getatt_is_in_string_position("Outputs/O/Value.0.Fn::GetAtt"));
722    }
723
724    #[test]
725    fn getatt_string_position_value_prefixed_output_names() {
726        // Output names starting with "Value" must not confuse the last-occurrence
727        // split. The terminal `/Value` node is always the property key, never
728        // part of the output name.
729        assert!(getatt_is_in_string_position("Outputs/ValueFoo/Value"));
730        assert!(getatt_is_in_string_position("Outputs/ValueFoo/Value.Fn::Join.1.0"));
731        assert!(getatt_is_in_string_position("Outputs/ValueFoo/Value.Fn::If.1"));
732        assert!(!getatt_is_in_string_position("Outputs/ValueFoo/Value.0"));
733        assert!(!getatt_is_in_string_position("Outputs/ValueFoo/Value.k"));
734        // Ensure "Value" alone as an output name also works
735        assert!(getatt_is_in_string_position("Outputs/Value/Value"));
736        assert!(getatt_is_in_string_position("Outputs/Value/Value.Fn::Join.1.0"));
737        assert!(!getatt_is_in_string_position("Outputs/Value/Value.0"));
738        // Terminal intrinsic segments with Value-prefixed output names
739        assert!(getatt_is_in_string_position("Outputs/ValueFoo/Value.Fn::GetAtt"));
740        assert!(getatt_is_in_string_position("Outputs/ValueFoo/Value.Fn::Join.1.0.Fn::GetAtt"));
741        assert!(getatt_is_in_string_position("Outputs/ValueFoo/Value.Fn::Sub"));
742    }
743}