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(id)
155                .iter()
156                .map(|e| {
157                    let (kind, attr) = ref_kind_to_str(&e.kind);
158                    OutgoingRef {
159                        source_path: e.source_path.clone(),
160                        target: e.target.clone(),
161                        kind: kind.to_string(),
162                        attr,
163                        condition_context: e.condition_context.clone(),
164                    }
165                })
166                .collect();
167
168            let incoming = graph
169                .incoming(id)
170                .iter()
171                .map(|e| {
172                    let (kind, attr) = ref_kind_to_str(&e.kind);
173                    IncomingRef {
174                        source: e.source_resource.clone(),
175                        source_path: e.source_path.clone(),
176                        kind: kind.to_string(),
177                        attr,
178                    }
179                })
180                .collect();
181
182            (
183                id.clone(),
184                DiagnosticResource {
185                    resource_type: res.resource_type.clone(),
186                    condition: res.condition.clone(),
187                    depends_on: res.depends_on.clone(),
188                    deletion_policy: res.deletion_policy.as_ref().map(|v| JsonValue(resolved_value_to_json(v))),
189                    update_replace_policy: res
190                        .update_replace_policy
191                        .as_ref()
192                        .map(|v| JsonValue(resolved_value_to_json(v))),
193                    creation_policy: res.creation_policy.as_ref().map(|v| JsonValue(serde_json::json!(v))),
194                    update_policy: res.update_policy.as_ref().map(|v| JsonValue(serde_json::json!(v))),
195                    properties: props,
196                    outgoing_refs: outgoing,
197                    incoming_refs: incoming,
198                    find_in_map_refs: res.diagnostics.find_in_map_refs.clone(),
199                    simple_subs: res
200                        .diagnostics
201                        .simple_subs
202                        .iter()
203                        .map(|s| PathVariable { path: s.path.clone(), variable: s.value.clone() })
204                        .collect(),
205                    redundant_subs: res.diagnostics.redundant_subs.clone(),
206                    empty_joins: res.diagnostics.empty_joins.clone(),
207                    hardcoded_partition_arns: res.diagnostics.hardcoded_partition_arns.clone(),
208                    conditionally_null_props: res
209                        .diagnostics
210                        .conditionally_null_props
211                        .iter()
212                        .map(|s| ConditionalNull {
213                            path: s.path.clone(),
214                            condition: s.condition.clone(),
215                            null_in_true: s.null_in_true_branch,
216                        })
217                        .collect(),
218                    condition_refs: res.diagnostics.condition_refs.clone(),
219                    for_each_expansions: res
220                        .diagnostics
221                        .foreach_expansions
222                        .iter()
223                        .map(|fe| DiagnosticForEachExpansion {
224                            path: fe.property_path.clone(),
225                            identifier: fe.identifier.clone(),
226                            collection: fe.collection_source.clone(),
227                        })
228                        .collect(),
229                    unsubstituted_variables: res
230                        .diagnostics
231                        .unsubstituted_variables
232                        .iter()
233                        .map(|s| PathVariable { path: s.path.clone(), variable: s.value.clone() })
234                        .collect(),
235                    unused_sub_keys: res
236                        .diagnostics
237                        .unused_sub_keys
238                        .iter()
239                        .map(|s| PathVariable { path: s.path.clone(), variable: s.value.clone() })
240                        .collect(),
241                    raw_pseudo_params: res
242                        .diagnostics
243                        .raw_pseudo_params
244                        .iter()
245                        .map(|s| PathVariable { path: s.path.clone(), variable: s.value.clone() })
246                        .collect(),
247                    secretsmanager_ref_paths: res.diagnostics.secretsmanager_ref_paths.clone(),
248                    invalid_refs: res
249                        .diagnostics
250                        .invalid_refs
251                        .iter()
252                        .map(|s| PathTarget { path: s.path.clone(), target: s.value.clone() })
253                        .collect(),
254                },
255            )
256        })
257        .collect()
258}
259
260fn build_conditions(conditions: &crate::conditions::ConditionModel) -> HashMap<String, DiagnosticCondition> {
261    let mut out = HashMap::new();
262    for name in conditions.names().filter(|n| !n.starts_with("__")) {
263        let (expression, deps) = if let Some(expr) = conditions.get(name) {
264            let mut d = Vec::new();
265            crate::conditions::collect_condition_deps(expr, &mut d);
266            d.sort();
267            d.dedup();
268            (Some(crate::conditions::format_condition_expr(expr)), if d.is_empty() { None } else { Some(d) })
269        } else {
270            (None, None)
271        };
272
273        let mut mutex_with: Vec<String> = Vec::new();
274        for group in &conditions.mutex_groups {
275            if group.conditions.contains(&name.to_string()) {
276                for peer in &group.conditions {
277                    if peer != name {
278                        mutex_with.push(peer.clone());
279                    }
280                }
281            }
282        }
283
284        out.insert(
285            name.to_string(),
286            DiagnosticCondition {
287                expression,
288                deps,
289                mutex_with: if mutex_with.is_empty() { None } else { Some(mutex_with) },
290            },
291        );
292    }
293    out
294}
295
296fn build_edges(graph: &crate::graph::ReferenceGraph) -> Vec<ReferenceEdge> {
297    graph
298        .edges
299        .iter()
300        .map(|e| {
301            let (kind, attr) = ref_kind_to_str(&e.kind);
302            ReferenceEdge {
303                source: e.source_resource.clone(),
304                source_path: e.source_path.clone(),
305                target: e.target.clone(),
306                kind: kind.to_string(),
307                attr,
308                condition_context: e.condition_context.clone(),
309            }
310        })
311        .collect()
312}
313
314fn build_outputs(
315    outputs: &HashMap<String, ResolvedOutput>,
316    graph: &crate::graph::ReferenceGraph,
317) -> HashMap<String, DiagnosticOutput> {
318    let mut output_getatt_refs: HashMap<String, Vec<(String, String)>> = HashMap::new();
319    for edge in &graph.edges {
320        if let Some(output_name) = edge.source_resource.strip_prefix(OUTPUT_PSEUDO_RESOURCE_PREFIX)
321            && let RefKind::GetAtt { attr } = &edge.kind
322            && getatt_is_in_string_position(&edge.source_path)
323        {
324            output_getatt_refs.entry(output_name.to_string()).or_default().push((edge.target.clone(), attr.clone()));
325        }
326    }
327
328    outputs
329        .iter()
330        .map(|(name, output)| {
331            // A GetAtt sitting directly inside a literal list/map output value is
332            // not collected for the string-type check: the enclosing container is
333            // itself a non-string value and is reported on its own, so descending
334            // into it would double-report the same defect. GetAtts reached only
335            // through Fn::If branches or string-building functions stay in scope.
336            let mut getatt_refs = Vec::new();
337            collect_getatt_refs_string_position(&output.value, &mut getatt_refs);
338            if let Some(edge_refs) = output_getatt_refs.get(name) {
339                for (t, a) in edge_refs {
340                    if !getatt_refs.iter().any(|(rt, ra)| rt == t && ra == a) {
341                        getatt_refs.push((t.clone(), a.clone()));
342                    }
343                }
344            }
345            (
346                name.clone(),
347                DiagnosticOutput {
348                    value: JsonValue(resolved_value_to_json(&output.value)),
349                    description: output.description.clone(),
350                    condition: output.condition.clone(),
351                    export_name: output.export_name.as_ref().map(|v| JsonValue(resolved_value_to_json(v))),
352                    getatt_refs: getatt_refs
353                        .into_iter()
354                        .map(|(r, a)| GetAttRef { resource: r, attribute: a })
355                        .collect(),
356                    condition_refs: {
357                        let mut crefs = Vec::new();
358                        collect_condition_refs_from_resolved(&output.value, &mut crefs);
359                        crefs.sort();
360                        crefs.dedup();
361                        crefs
362                    },
363                },
364            )
365        })
366        .collect()
367}
368
369fn build_rule(r: &TemplateRule) -> DiagnosticRule {
370    DiagnosticRule {
371        name: r.name.clone(),
372        condition: r.condition.as_ref().map(|v| JsonValue(v.clone())),
373        assertions: r
374            .assertions
375            .iter()
376            .map(|a| DiagnosticRuleAssertion {
377                assert_expr: JsonValue(a.assert.clone()),
378                assert_description: a.description.clone(),
379            })
380            .collect(),
381    }
382}
383
384fn filter_sam_cycles(
385    raw_cycles: &[Vec<String>],
386    transforms: &[String],
387    resources: &HashMap<String, ResolvedResource>,
388) -> Vec<Vec<String>> {
389    let has_sam = transforms.iter().any(|t| t == TRANSFORM_SERVERLESS);
390    if has_sam {
391        raw_cycles
392            .iter()
393            .filter(|cycle| {
394                !cycle.iter().any(|rid| {
395                    resources.get(rid).map(|r| r.resource_type.starts_with(SAM_SERVERLESS_TYPE_PREFIX)).unwrap_or(false)
396                })
397            })
398            .cloned()
399            .collect()
400    } else {
401        raw_cycles.to_vec()
402    }
403}
404
405pub fn resolved_value_to_json(val: &ResolvedValue) -> serde_json::Value {
406    match val {
407        ResolvedValue::Concrete { value: v } => v.0.clone(),
408        ResolvedValue::List { items } => serde_json::Value::Array(items.iter().map(resolved_value_to_json).collect()),
409        ResolvedValue::Map { entries } => {
410            let mut map = serde_json::Map::new();
411            for MapEntry { key: k, value: v } in entries {
412                map.insert(k.clone(), resolved_value_to_json(v));
413            }
414            serde_json::Value::Object(map)
415        }
416        ResolvedValue::Enum { variants: vals } => {
417            serde_json::json!({MARKER_ENUM: vals.iter().map(resolved_value_to_json).collect::<Vec<_>>()})
418        }
419        ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => {
420            serde_json::json!({MARKER_CONDITIONAL: cond, MARKER_IF_TRUE: resolved_value_to_json(t), MARKER_IF_FALSE: resolved_value_to_json(f)})
421        }
422        ResolvedValue::Reference { target, kind } => {
423            let kind_str = match kind {
424                RefKind::Ref => "resource".to_string(),
425                RefKind::GetAtt { attr: a } => format!("getatt:{}", a),
426                RefKind::Sub { var: v } => format!("sub:{}", v),
427                RefKind::DependsOn => "dependson".to_string(),
428            };
429            serde_json::json!({MARKER_REF: target, MARKER_KIND: kind_str})
430        }
431        ResolvedValue::Dynamic { reason } => serde_json::json!({MARKER_DYNAMIC: reason}),
432        ResolvedValue::TypedDynamic { reason, param_type } => {
433            serde_json::json!({MARKER_DYNAMIC: reason, MARKER_PARAM_TYPE: param_type})
434        }
435    }
436}
437
438pub fn resolved_value_to_json_clean(val: &ResolvedValue) -> Option<serde_json::Value> {
439    match val {
440        ResolvedValue::Concrete { value: v } => Some(v.0.clone()),
441        ResolvedValue::List { items } => {
442            Some(serde_json::Value::Array(items.iter().filter_map(resolved_value_to_json_clean).collect()))
443        }
444        ResolvedValue::Map { entries } => {
445            let mut map = serde_json::Map::new();
446            for MapEntry { key: k, value: v } in entries {
447                if let Some(jv) = resolved_value_to_json_clean(v) {
448                    map.insert(k.clone(), jv);
449                }
450            }
451            Some(serde_json::Value::Object(map))
452        }
453        ResolvedValue::Enum { variants: vals } => {
454            let concrete: Vec<serde_json::Value> = vals.iter().filter_map(resolved_value_to_json_clean).collect();
455            match concrete.len() {
456                0 => None,
457                1 => Some(concrete.into_iter().next().unwrap()),
458                _ => Some(serde_json::Value::Array(concrete)),
459            }
460        }
461        ResolvedValue::Conditional { condition: _, if_true: t, if_false: _ } => resolved_value_to_json_clean(t),
462        ResolvedValue::Dynamic { reason: _ }
463        | ResolvedValue::TypedDynamic { reason: _, param_type: _ }
464        | ResolvedValue::Reference { target: _, kind: _ } => None,
465    }
466}
467
468fn ref_kind_to_str(kind: &RefKind) -> (&'static str, Option<String>) {
469    match kind {
470        RefKind::Ref => (EDGE_KIND_REF, None),
471        RefKind::GetAtt { attr } => (EDGE_KIND_GET_ATT, Some(attr.clone())),
472        RefKind::Sub { var } => (EDGE_KIND_SUB, Some(var.clone())),
473        RefKind::DependsOn => (EDGE_KIND_DEPENDS_ON, None),
474    }
475}
476
477/// Collects GetAtt references that occupy a *string position* of an output
478/// value - the value itself, or a value reached only through `Fn::If` branches.
479/// A GetAtt nested inside a literal list or map is skipped: the container is a
480/// non-string output value reported in its own right, and CloudFormation never
481/// treats the inner GetAtt as the output's string value. `Fn::If` is transparent
482/// (a branch is a string position), mirroring the output value-type check.
483fn collect_getatt_refs_string_position(val: &ResolvedValue, out: &mut Vec<(String, String)>) {
484    match val {
485        ResolvedValue::Reference { target, kind: RefKind::GetAtt { attr } } => out.push((target.clone(), attr.clone())),
486        ResolvedValue::Conditional { condition: _, if_true: t, if_false: f } => {
487            collect_getatt_refs_string_position(t, out);
488            collect_getatt_refs_string_position(f, out);
489        }
490        _ => {}
491    }
492}
493
494/// Whether a GetAtt reference edge whose source path is `source_path` occupies a
495/// string position of the output (see [`collect_getatt_refs_string_position`]).
496/// The path is `Value` optionally followed by segments; a GetAtt is in string
497/// position unless it sits inside a literal list/map - i.e. the path descends
498/// through a bare index or key that is not an argument of a string-building
499/// function. `Fn::If` branch segments are transparent; any other `Fn::…` segment
500/// (e.g. `Fn::Join`, `Fn::Sub`) marks a string-building consumer, so a GetAtt
501/// beneath it stays in scope.
502fn getatt_is_in_string_position(source_path: &str) -> bool {
503    // Edge source paths are section-rooted (`Outputs/<name>/Value...`); take the
504    // part after the terminal `/Value` node. Use last-occurrence splitting so an
505    // output name containing "Value" (e.g. `ValueFoo`) does not consume the
506    // output-name segment as part of the tail.
507    let after_value = source_path
508        .rsplit_once("/Value")
509        .map(|(_, tail)| tail)
510        .or_else(|| source_path.strip_prefix("Value"))
511        .unwrap_or("");
512    let mut segments = after_value.split('.').filter(|s| !s.is_empty());
513    while let Some(segment) = segments.next() {
514        if segment == FN_IF {
515            // Skip the branch selector (`1`/`2`) and keep walking transparently.
516            segments.next();
517            continue;
518        }
519        // A remaining `Fn::…` segment is a string-building function consuming the
520        // GetAtt (Join/Sub/…): the GetAtt is in string position.
521        if segment.starts_with("Fn::") {
522            return true;
523        }
524        // A bare index or key means the GetAtt is inside a literal container.
525        return false;
526    }
527    true
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use serde_json::json;
534
535    #[test]
536    fn to_json_concrete() {
537        assert_eq!(resolved_value_to_json(&ResolvedValue::Concrete { value: json!("hi").into() }), json!("hi"));
538        assert_eq!(resolved_value_to_json(&ResolvedValue::Concrete { value: json!(42).into() }), json!(42));
539    }
540
541    #[test]
542    fn to_json_list() {
543        let val = ResolvedValue::List {
544            items: vec![
545                ResolvedValue::Concrete { value: json!(1).into() },
546                ResolvedValue::Concrete { value: json!(2).into() },
547            ],
548        };
549        assert_eq!(resolved_value_to_json(&val), json!([1, 2]));
550    }
551
552    #[test]
553    fn to_json_map() {
554        let val = ResolvedValue::Map {
555            entries: vec![MapEntry { key: "k".into(), value: ResolvedValue::Concrete { value: json!("v").into() } }],
556        };
557        assert_eq!(resolved_value_to_json(&val), json!({"k": "v"}));
558    }
559
560    #[test]
561    fn to_json_enum_uses_marker() {
562        let val = ResolvedValue::Enum { variants: vec![ResolvedValue::Concrete { value: json!("a").into() }] };
563        let j = resolved_value_to_json(&val);
564        assert_ne!(j.get(MARKER_ENUM), None, "expected MARKER_ENUM key in serialized enum");
565    }
566
567    #[test]
568    fn to_json_conditional_uses_markers() {
569        let val = ResolvedValue::Conditional {
570            condition: "C".into(),
571            if_true: Box::new(ResolvedValue::Concrete { value: json!(1).into() }),
572            if_false: Box::new(ResolvedValue::Concrete { value: json!(2).into() }),
573        };
574        let j = resolved_value_to_json(&val);
575        assert_eq!(j[MARKER_CONDITIONAL], "C");
576        assert_eq!(j[MARKER_IF_TRUE], 1);
577        assert_eq!(j[MARKER_IF_FALSE], 2);
578    }
579
580    #[test]
581    fn to_json_reference_uses_markers() {
582        let val = ResolvedValue::Reference { target: "R".into(), kind: RefKind::GetAtt { attr: "Arn".into() } };
583        let j = resolved_value_to_json(&val);
584        assert_eq!(j[MARKER_REF], "R");
585        assert_eq!(j[MARKER_KIND], "getatt:Arn");
586    }
587
588    #[test]
589    fn to_json_dynamic_uses_marker() {
590        let val = ResolvedValue::Dynamic { reason: "reason".into() };
591        assert_eq!(resolved_value_to_json(&val)[MARKER_DYNAMIC], "reason");
592    }
593
594    #[test]
595    fn to_json_typed_dynamic_includes_param_type() {
596        let val = ResolvedValue::TypedDynamic { reason: "reason".into(), param_type: "Number".into() };
597        let j = resolved_value_to_json(&val);
598        assert_eq!(j[MARKER_DYNAMIC], "reason");
599        assert_eq!(j[MARKER_PARAM_TYPE], "Number");
600    }
601
602    #[test]
603    fn to_json_clean_concrete() {
604        assert_eq!(
605            resolved_value_to_json_clean(&ResolvedValue::Concrete { value: json!("x").into() }),
606            Some(json!("x"))
607        );
608    }
609
610    #[test]
611    fn to_json_clean_dynamic_returns_none() {
612        assert_eq!(
613            resolved_value_to_json_clean(&ResolvedValue::Dynamic { reason: "x".into() }),
614            None,
615            "Dynamic should return None"
616        );
617    }
618
619    #[test]
620    fn to_json_clean_reference_returns_none() {
621        assert_eq!(
622            resolved_value_to_json_clean(&ResolvedValue::Reference { target: "R".into(), kind: RefKind::Ref }),
623            None,
624            "Reference should return None"
625        );
626    }
627
628    #[test]
629    fn to_json_clean_conditional_takes_true_branch() {
630        let val = ResolvedValue::Conditional {
631            condition: "C".into(),
632            if_true: Box::new(ResolvedValue::Concrete { value: json!("yes").into() }),
633            if_false: Box::new(ResolvedValue::Concrete { value: json!("no").into() }),
634        };
635        assert_eq!(resolved_value_to_json_clean(&val), Some(json!("yes")));
636    }
637
638    #[test]
639    fn to_json_clean_enum_single_unwraps() {
640        let val = ResolvedValue::Enum { variants: vec![ResolvedValue::Concrete { value: json!("only").into() }] };
641        assert_eq!(resolved_value_to_json_clean(&val), Some(json!("only")));
642    }
643
644    #[test]
645    fn to_json_clean_enum_multiple_returns_array() {
646        let val = ResolvedValue::Enum {
647            variants: vec![
648                ResolvedValue::Concrete { value: json!("a").into() },
649                ResolvedValue::Concrete { value: json!("b").into() },
650            ],
651        };
652        assert_eq!(resolved_value_to_json_clean(&val), Some(json!(["a", "b"])));
653    }
654
655    #[test]
656    fn to_json_clean_enum_all_dynamic_returns_none() {
657        let val = ResolvedValue::Enum { variants: vec![ResolvedValue::Dynamic { reason: "x".into() }] };
658        assert_eq!(resolved_value_to_json_clean(&val), None, "all-dynamic enum should return None");
659    }
660
661    #[test]
662    fn to_json_clean_list_filters_dynamic() {
663        let val = ResolvedValue::List {
664            items: vec![
665                ResolvedValue::Concrete { value: json!(1).into() },
666                ResolvedValue::Dynamic { reason: "x".into() },
667            ],
668        };
669        assert_eq!(resolved_value_to_json_clean(&val), Some(json!([1])));
670    }
671
672    #[test]
673    fn to_json_clean_map_filters_dynamic() {
674        let val = ResolvedValue::Map {
675            entries: vec![
676                MapEntry { key: "a".into(), value: ResolvedValue::Concrete { value: json!(1).into() } },
677                MapEntry { key: "b".into(), value: ResolvedValue::Dynamic { reason: "x".into() } },
678            ],
679        };
680        assert_eq!(resolved_value_to_json_clean(&val), Some(json!({"a": 1})));
681    }
682
683    #[test]
684    fn ref_kind_to_str_all_variants() {
685        assert_eq!(ref_kind_to_str(&RefKind::Ref), ("Ref", None));
686        assert_eq!(ref_kind_to_str(&RefKind::GetAtt { attr: "A".into() }), ("GetAtt", Some("A".into())));
687        assert_eq!(ref_kind_to_str(&RefKind::Sub { var: "V".into() }), ("Sub", Some("V".into())));
688        assert_eq!(ref_kind_to_str(&RefKind::DependsOn), ("DependsOn", None));
689    }
690
691    #[test]
692    fn to_json_reference_sub_kind() {
693        let val = ResolvedValue::Reference { target: "R".into(), kind: RefKind::Sub { var: "V".into() } };
694        let j = resolved_value_to_json(&val);
695        assert_eq!(j[MARKER_KIND], "sub:V");
696    }
697
698    #[test]
699    fn to_json_reference_dependson_kind() {
700        let val = ResolvedValue::Reference { target: "R".into(), kind: RefKind::DependsOn };
701        let j = resolved_value_to_json(&val);
702        assert_eq!(j[MARKER_KIND], "dependson");
703    }
704
705    #[test]
706    fn getatt_string_position_paths() {
707        // Direct, whole-branch, and string-building-function positions keep the
708        // GetAtt in scope for the output value-type check; a bare index/key
709        // beneath the value (a literal container) takes it out of scope, because
710        // the container itself is reported as the non-string value.
711        assert!(getatt_is_in_string_position("Outputs/O/Value"));
712        assert!(getatt_is_in_string_position("Outputs/O/Value.Fn::If.1"));
713        assert!(getatt_is_in_string_position("Outputs/O/Value.Fn::Join.1.0"));
714        assert!(!getatt_is_in_string_position("Outputs/O/Value.0"));
715        assert!(!getatt_is_in_string_position("Outputs/O/Value.k"));
716        assert!(!getatt_is_in_string_position("Outputs/O/Value.Fn::If.2.0"));
717        // Terminal intrinsic segments: GetAtt/Sub at the end of the path
718        assert!(getatt_is_in_string_position("Outputs/O/Value.Fn::GetAtt"));
719        assert!(getatt_is_in_string_position("Outputs/O/Value.Fn::Sub"));
720        assert!(getatt_is_in_string_position("Outputs/O/Value.Fn::Join.1.0.Fn::GetAtt"));
721        // A GetAtt inside a literal container stays out of scope even with terminal
722        assert!(!getatt_is_in_string_position("Outputs/O/Value.0.Fn::GetAtt"));
723    }
724
725    #[test]
726    fn getatt_string_position_value_prefixed_output_names() {
727        // Output names starting with "Value" must not confuse the last-occurrence
728        // split. The terminal `/Value` node is always the property key, never
729        // part of the output name.
730        assert!(getatt_is_in_string_position("Outputs/ValueFoo/Value"));
731        assert!(getatt_is_in_string_position("Outputs/ValueFoo/Value.Fn::Join.1.0"));
732        assert!(getatt_is_in_string_position("Outputs/ValueFoo/Value.Fn::If.1"));
733        assert!(!getatt_is_in_string_position("Outputs/ValueFoo/Value.0"));
734        assert!(!getatt_is_in_string_position("Outputs/ValueFoo/Value.k"));
735        // Ensure "Value" alone as an output name also works
736        assert!(getatt_is_in_string_position("Outputs/Value/Value"));
737        assert!(getatt_is_in_string_position("Outputs/Value/Value.Fn::Join.1.0"));
738        assert!(!getatt_is_in_string_position("Outputs/Value/Value.0"));
739        // Terminal intrinsic segments with Value-prefixed output names
740        assert!(getatt_is_in_string_position("Outputs/ValueFoo/Value.Fn::GetAtt"));
741        assert!(getatt_is_in_string_position("Outputs/ValueFoo/Value.Fn::Join.1.0.Fn::GetAtt"));
742        assert!(getatt_is_in_string_position("Outputs/ValueFoo/Value.Fn::Sub"));
743    }
744}