Skip to main content

template_model/
resolved_value.rs

1use crate::consts::*;
2use crate::resolver::MapEntry;
3use crate::resolver::ResolvedValue;
4use std::collections::{HashMap, HashSet};
5
6pub fn resolved_value_at_path(val: &ResolvedValue, path: &str) -> Option<ResolvedValue> {
7    let mut segments = path.splitn(2, '.');
8    let key = segments.next()?;
9    let remaining = segments.next();
10
11    let child = match val {
12        ResolvedValue::Concrete { value: json_val } => {
13            if path.contains("{}") {
14                let results = json_values_matching_wildcard_path(json_val, path);
15                return if results.is_empty() {
16                    None
17                } else {
18                    Some(ResolvedValue::Enum {
19                        variants: results
20                            .into_iter()
21                            .map(|v| ResolvedValue::Concrete { value: v.clone().into() })
22                            .collect(),
23                    })
24                };
25            }
26            return json_value_at_path(json_val, path).map(|v| ResolvedValue::Concrete { value: v.clone().into() });
27        }
28        ResolvedValue::List { items } => {
29            if key == "{}" {
30                let walked: Vec<ResolvedValue> = items
31                    .iter()
32                    .filter_map(|item| match remaining {
33                        Some(rest) if !rest.is_empty() => resolved_value_at_path(item, rest),
34                        _ => Some(item.clone()),
35                    })
36                    .collect();
37                return if walked.is_empty() { None } else { Some(ResolvedValue::Enum { variants: walked }) };
38            }
39            let idx: usize = key.parse().ok()?;
40            items.get(idx)?.clone()
41        }
42        ResolvedValue::Map { entries } => entries.iter().find(|e| e.key == key).map(|e| e.value.clone())?,
43        ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => {
44            let t_walked = resolved_value_at_path(t, path);
45            let f_walked = resolved_value_at_path(f, path);
46            return match (t_walked, f_walked) {
47                (Some(tw), Some(fw)) => Some(ResolvedValue::Conditional {
48                    condition: cond.clone(),
49                    if_true: Box::new(tw),
50                    if_false: Box::new(fw),
51                }),
52                (Some(tw), None) => Some(ResolvedValue::Conditional {
53                    condition: cond.clone(),
54                    if_true: Box::new(tw),
55                    if_false: Box::new(ResolvedValue::Dynamic { reason: "path not found in false branch".into() }),
56                }),
57                (None, Some(fw)) => Some(ResolvedValue::Conditional {
58                    condition: cond.clone(),
59                    if_true: Box::new(ResolvedValue::Dynamic { reason: "path not found in true branch".into() }),
60                    if_false: Box::new(fw),
61                }),
62                (None, None) => None,
63            };
64        }
65        ResolvedValue::Enum { variants: vals } => {
66            let walked: Vec<ResolvedValue> = vals.iter().filter_map(|v| resolved_value_at_path(v, path)).collect();
67            return if walked.is_empty() { None } else { Some(ResolvedValue::Enum { variants: walked }) };
68        }
69        _ => return None,
70    };
71
72    match remaining {
73        Some(rest) if !rest.is_empty() => resolved_value_at_path(&child, rest),
74        _ => Some(child),
75    }
76}
77
78/// Converts an authored scenario source path into its public effective path.
79/// Every `Fn::If` branch selector is an implementation detail of the authored
80/// syntax and is removed; malformed selectors are rejected rather than treated
81/// as ordinary property segments.
82pub fn effective_path_from_scenario_source_path(source_path: &str) -> Option<String> {
83    if source_path.is_empty() {
84        return Some(String::new());
85    }
86    let segments: Vec<&str> = source_path.split('.').collect();
87    if segments.iter().any(|segment| segment.is_empty()) {
88        return None;
89    }
90    let mut effective_segments = Vec::with_capacity(segments.len());
91    let mut index = 0;
92    while index < segments.len() {
93        if segments[index] == FN_IF {
94            match segments.get(index + 1).copied() {
95                Some("1" | "2") => {
96                    index += 2;
97                    continue;
98                }
99                _ => return None,
100            }
101        }
102        effective_segments.push(segments[index]);
103        index += 1;
104    }
105    Some(effective_segments.join("."))
106}
107
108pub fn scenario_source_path_at(
109    value: &ResolvedValue,
110    effective_path: &str,
111    conditions: &HashMap<String, bool>,
112    source_path: &str,
113) -> Option<String> {
114    let segments: Vec<&str> = effective_path.split('.').filter(|segment| !segment.is_empty()).collect();
115    scenario_source_path_segments(value, &segments, conditions, source_path)
116}
117
118fn scenario_source_path_segments(
119    value: &ResolvedValue,
120    segments: &[&str],
121    conditions: &HashMap<String, bool>,
122    source_path: &str,
123) -> Option<String> {
124    match value {
125        ResolvedValue::Conditional { condition, if_true, if_false } => {
126            let is_true = *conditions.get(condition)?;
127            let branch_index = if is_true { 1 } else { 2 };
128            let branch = if is_true { if_true } else { if_false };
129            scenario_source_path_segments(
130                branch,
131                segments,
132                conditions,
133                &append_source_path(source_path, &format!("Fn::If.{branch_index}")),
134            )
135        }
136        ResolvedValue::Map { entries } => {
137            let Some((segment, remaining)) = segments.split_first() else {
138                return Some(source_path.to_string());
139            };
140            let entry = entries.iter().find(|entry| entry.key == *segment)?;
141            scenario_source_path_segments(
142                &entry.value,
143                remaining,
144                conditions,
145                &append_source_path(source_path, segment),
146            )
147        }
148        ResolvedValue::List { items } => {
149            let Some((segment, remaining)) = segments.split_first() else {
150                return Some(source_path.to_string());
151            };
152            let index: usize = segment.parse().ok()?;
153            scenario_source_path_segments(
154                items.get(index)?,
155                remaining,
156                conditions,
157                &append_source_path(source_path, segment),
158            )
159        }
160        ResolvedValue::Concrete { value } => json_scenario_source_path(value, segments, source_path),
161        ResolvedValue::Enum { variants } => variants
162            .iter()
163            .find_map(|variant| scenario_source_path_segments(variant, segments, conditions, source_path)),
164        ResolvedValue::Reference { .. } | ResolvedValue::Dynamic { .. } | ResolvedValue::TypedDynamic { .. } => {
165            segments.is_empty().then(|| source_path.to_string())
166        }
167    }
168}
169
170fn json_scenario_source_path(value: &serde_json::Value, segments: &[&str], source_path: &str) -> Option<String> {
171    let Some((segment, remaining)) = segments.split_first() else {
172        return Some(source_path.to_string());
173    };
174    let child = match value {
175        serde_json::Value::Object(map) => map.get(*segment)?,
176        serde_json::Value::Array(items) => items.get(segment.parse::<usize>().ok()?)?,
177        _ => return None,
178    };
179    json_scenario_source_path(child, remaining, &append_source_path(source_path, segment))
180}
181
182fn append_source_path(source_path: &str, segment: &str) -> String {
183    if source_path.is_empty() { segment.to_string() } else { format!("{source_path}.{segment}") }
184}
185
186pub fn collect_condition_refs_from_resolved(val: &ResolvedValue, out: &mut Vec<String>) {
187    match val {
188        ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => {
189            out.push(cond.clone());
190            collect_condition_refs_from_resolved(t, out);
191            collect_condition_refs_from_resolved(f, out);
192        }
193        ResolvedValue::List { items } => {
194            for v in items {
195                collect_condition_refs_from_resolved(v, out);
196            }
197        }
198        ResolvedValue::Map { entries } => {
199            for e in entries {
200                collect_condition_refs_from_resolved(&e.value, out);
201            }
202        }
203        ResolvedValue::Enum { variants: vals } => {
204            for v in vals {
205                collect_condition_refs_from_resolved(v, out);
206            }
207        }
208        _ => {}
209    }
210}
211
212pub fn collect_conditional_nulls(val: &ResolvedValue, path: &str, out: &mut Vec<(String, String, bool)>) {
213    match val {
214        ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => {
215            let t_null = is_resolved_null(t);
216            let f_null = is_resolved_null(f);
217            if t_null {
218                out.push((path.to_string(), cond.clone(), true));
219            }
220            if f_null {
221                out.push((path.to_string(), cond.clone(), false));
222            }
223            if !t_null {
224                collect_conditional_nulls(t, path, out);
225            }
226            if !f_null {
227                collect_conditional_nulls(f, path, out);
228            }
229        }
230        ResolvedValue::Map { entries } => {
231            for e in entries {
232                collect_conditional_nulls(&e.value, &format!("{}.{}", path, e.key), out);
233            }
234        }
235        ResolvedValue::List { items } => {
236            for (i, v) in items.iter().enumerate() {
237                collect_conditional_nulls(v, &format!("{}.{}", path, i), out);
238            }
239        }
240        ResolvedValue::Enum { variants: vals } => {
241            for v in vals {
242                collect_conditional_nulls(v, path, out);
243            }
244        }
245        _ => {}
246    }
247}
248
249pub fn is_resolved_null(val: &ResolvedValue) -> bool {
250    match val {
251        ResolvedValue::Concrete { value: v } if v.is_null() => true,
252        ResolvedValue::Conditional { if_true: t, if_false: f, .. } => is_resolved_null(t) && is_resolved_null(f),
253        _ => false,
254    }
255}
256
257fn contains_scenario_branching(value: &ResolvedValue) -> bool {
258    match value {
259        ResolvedValue::Conditional { .. } | ResolvedValue::Enum { .. } => true,
260        ResolvedValue::List { items } => items.iter().any(contains_scenario_branching),
261        ResolvedValue::Map { entries } => entries.iter().any(|entry| contains_scenario_branching(&entry.value)),
262        ResolvedValue::Concrete { .. }
263        | ResolvedValue::Reference { .. }
264        | ResolvedValue::Dynamic { .. }
265        | ResolvedValue::TypedDynamic { .. } => false,
266    }
267}
268
269/// Recursively expands scenario branching in a resolved value, collecting all
270/// concrete possibilities with their condition assumptions.
271///
272/// Returns `true` when any expansion within the value tree was curtailed
273/// because the intermediate product exceeded `limit`.
274pub fn collect_scenarios(
275    val: &ResolvedValue,
276    assumptions: &HashMap<String, bool>,
277    limit: usize,
278    results: &mut Vec<(ResolvedValue, HashMap<String, bool>)>,
279) -> bool {
280    let mut used_covering = false;
281    collect_scenarios_with_covering(val, assumptions, limit, results, &mut used_covering)
282}
283
284pub fn collect_scenarios_with_covering(
285    val: &ResolvedValue,
286    assumptions: &HashMap<String, bool>,
287    limit: usize,
288    results: &mut Vec<(ResolvedValue, HashMap<String, bool>)>,
289    used_covering: &mut bool,
290) -> bool {
291    collect_scenarios_reachable_with_covering(val, assumptions, limit, results, &|_| true, used_covering)
292}
293
294#[cfg(test)]
295pub(crate) fn collect_scenarios_reachable(
296    val: &ResolvedValue,
297    assumptions: &HashMap<String, bool>,
298    limit: usize,
299    results: &mut Vec<(ResolvedValue, HashMap<String, bool>)>,
300    is_reachable: &dyn Fn(&HashMap<String, bool>) -> bool,
301) -> bool {
302    let mut used_covering = false;
303    collect_scenarios_reachable_with_covering(val, assumptions, limit, results, is_reachable, &mut used_covering)
304}
305
306pub(crate) fn collect_scenarios_reachable_with_covering(
307    val: &ResolvedValue,
308    assumptions: &HashMap<String, bool>,
309    limit: usize,
310    results: &mut Vec<(ResolvedValue, HashMap<String, bool>)>,
311    is_reachable: &dyn Fn(&HashMap<String, bool>) -> bool,
312    used_covering: &mut bool,
313) -> bool {
314    match val {
315        ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => {
316            if assumptions.contains_key(cond.as_str()) {
317                if assumptions[cond.as_str()] {
318                    collect_scenarios_reachable_with_covering(
319                        t,
320                        assumptions,
321                        limit,
322                        results,
323                        is_reachable,
324                        used_covering,
325                    )
326                } else {
327                    collect_scenarios_reachable_with_covering(
328                        f,
329                        assumptions,
330                        limit,
331                        results,
332                        is_reachable,
333                        used_covering,
334                    )
335                }
336            } else {
337                let mut true_assumptions = assumptions.clone();
338                true_assumptions.insert(cond.clone(), true);
339                let true_curtailed = collect_scenarios_reachable_with_covering(
340                    t,
341                    &true_assumptions,
342                    limit,
343                    results,
344                    is_reachable,
345                    used_covering,
346                );
347                if true_curtailed && results.len() >= limit {
348                    return true;
349                }
350                let mut false_assumptions = assumptions.clone();
351                false_assumptions.insert(cond.clone(), false);
352                let false_curtailed = collect_scenarios_reachable_with_covering(
353                    f,
354                    &false_assumptions,
355                    limit,
356                    results,
357                    is_reachable,
358                    used_covering,
359                );
360                true_curtailed || false_curtailed
361            }
362        }
363        ResolvedValue::Enum { variants } => {
364            let mut curtailed = false;
365            for variant in variants {
366                curtailed |= collect_scenarios_reachable_with_covering(
367                    variant,
368                    assumptions,
369                    limit,
370                    results,
371                    is_reachable,
372                    used_covering,
373                );
374                if curtailed && results.len() >= limit {
375                    break;
376                }
377            }
378            curtailed
379        }
380        ResolvedValue::List { items } => {
381            let has_branching = items.iter().any(contains_scenario_branching);
382            if has_branching {
383                expand_list_scenarios(items, assumptions, limit, results, is_reachable, used_covering)
384            } else {
385                push_scenario(val, assumptions, limit, results)
386            }
387        }
388        ResolvedValue::Map { entries } => {
389            let has_branching = entries.iter().any(|entry| contains_scenario_branching(&entry.value));
390            if has_branching {
391                expand_map_scenarios(entries, assumptions, limit, results, is_reachable, used_covering)
392            } else {
393                push_scenario(val, assumptions, limit, results)
394            }
395        }
396        _ => push_scenario(val, assumptions, limit, results),
397    }
398}
399
400fn push_scenario(
401    value: &ResolvedValue,
402    assumptions: &HashMap<String, bool>,
403    limit: usize,
404    results: &mut Vec<(ResolvedValue, HashMap<String, bool>)>,
405) -> bool {
406    if results.len() >= limit {
407        true
408    } else {
409        results.push((value.clone(), assumptions.clone()));
410        false
411    }
412}
413
414/// Expands deployment scenarios using the standard per-value limit and reports
415/// whether at least one possible scenario was omitted.
416pub fn collect_scenarios_signaled(
417    value: &ResolvedValue,
418    assumptions: &HashMap<String, bool>,
419    results: &mut Vec<(ResolvedValue, HashMap<String, bool>)>,
420    curtailed: &mut bool,
421) {
422    *curtailed |= collect_scenarios(value, assumptions, MAX_SCENARIO_COMBINATIONS, results);
423}
424
425pub fn contains_dynamic_resolved(rv: &ResolvedValue) -> bool {
426    match rv {
427        ResolvedValue::Dynamic { reason: _ } | ResolvedValue::TypedDynamic { .. } | ResolvedValue::Reference { .. } => {
428            true
429        }
430        ResolvedValue::List { items } => items.iter().any(contains_dynamic_resolved),
431        ResolvedValue::Map { entries } => entries.iter().any(|e| contains_dynamic_resolved(&e.value)),
432        ResolvedValue::Enum { variants: vals } => vals.iter().any(contains_dynamic_resolved),
433        ResolvedValue::Conditional { if_true: t, if_false: f, .. } => {
434            contains_dynamic_resolved(t) || contains_dynamic_resolved(f)
435        }
436        ResolvedValue::Concrete { value: v } => json_contains_markers(v),
437    }
438}
439
440pub fn json_contains_markers(v: &serde_json::Value) -> bool {
441    match v {
442        serde_json::Value::Object(m) => {
443            m.contains_key(MARKER_DYNAMIC)
444                || m.contains_key(MARKER_REF)
445                || m.contains_key(MARKER_INTRINSIC)
446                || m.contains_key(MARKER_CONDITIONAL)
447                || m.contains_key(MARKER_ENUM)
448                || m.values().any(json_contains_markers)
449        }
450        serde_json::Value::Array(a) => a.iter().any(json_contains_markers),
451        _ => false,
452    }
453}
454
455/// The shortest and longest a string value can be at deployment, or `None` when
456/// any possibility is unknown.
457///
458/// A length constraint may only be reported broken when it is broken for every
459/// possibility, so one unknown possibility withdraws the whole estimate instead
460/// of narrowing it. Bounds rather than a single number are what let a caller tell
461/// "every possibility is too long" from "every possibility is too short".
462pub fn estimate_resolved_string_length_bounds(val: &ResolvedValue) -> Option<(usize, usize)> {
463    match val {
464        ResolvedValue::Concrete { value: v } if v.is_string() => {
465            let length = v.as_str()?.chars().count();
466            Some((length, length))
467        }
468        // A partially resolved Sub or Join is a description of what could be
469        // worked out, not the value. Its length is only the value's length when
470        // nothing is still standing in for something unknown: a `${...}`
471        // interpolation expands to any length at deploy time, and a reference
472        // placeholder is internal text whose width says nothing about the value it
473        // stands for. Measuring either would report a length the template never
474        // produces.
475        ResolvedValue::Dynamic { reason: desc } if desc.starts_with(SUB_PARTIAL_PREFIX) => {
476            partial_length_bounds(&desc[SUB_PARTIAL_PREFIX.len()..])
477        }
478        ResolvedValue::Dynamic { reason: desc } if desc.starts_with(JOIN_PARTIAL_PREFIX) => {
479            partial_length_bounds(&desc[JOIN_PARTIAL_PREFIX.len()..])
480        }
481        ResolvedValue::Dynamic { reason: _ } => None,
482        ResolvedValue::Conditional { if_true: t, if_false: f, .. } => {
483            let (true_shortest, true_longest) = estimate_resolved_string_length_bounds(t)?;
484            let (false_shortest, false_longest) = estimate_resolved_string_length_bounds(f)?;
485            Some((true_shortest.min(false_shortest), true_longest.max(false_longest)))
486        }
487        ResolvedValue::Enum { variants } => {
488            let mut bounds: Option<(usize, usize)> = None;
489            for variant in variants {
490                let (shortest, longest) = estimate_resolved_string_length_bounds(variant)?;
491                bounds = Some(match bounds {
492                    Some((known_shortest, known_longest)) => (known_shortest.min(shortest), known_longest.max(longest)),
493                    None => (shortest, longest),
494                });
495            }
496            bounds
497        }
498        _ => None,
499    }
500}
501
502fn partial_length_bounds(partial: &str) -> Option<(usize, usize)> {
503    if has_interpolation_variable(partial)
504        || partial.contains(UNRESOLVED_REF_PLACEHOLDER_PREFIX)
505        || partial.contains(UNRESOLVED_DYNAMIC_PLACEHOLDER)
506    {
507        None
508    } else {
509        let length = partial.chars().count();
510        Some((length, length))
511    }
512}
513
514fn has_interpolation_variable(template: &str) -> bool {
515    let bytes = template.as_bytes();
516    let mut i = 0;
517    while i + 1 < bytes.len() {
518        if bytes[i] == b'$'
519            && bytes[i + 1] == b'{'
520            && let Some(_end) = template[i + 2..].find('}')
521        {
522            return true;
523        }
524        i += 1;
525    }
526    false
527}
528
529fn json_value_at_path<'a>(val: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
530    let mut current = val;
531    for segment in path.split('.') {
532        match current {
533            serde_json::Value::Object(map) => {
534                current = map.get(segment)?;
535            }
536            serde_json::Value::Array(arr) => {
537                if segment == "{}" {
538                    return None;
539                }
540                let idx: usize = segment.parse().ok()?;
541                current = arr.get(idx)?;
542            }
543            _ => return None,
544        }
545    }
546    Some(current)
547}
548
549fn json_values_matching_wildcard_path(val: &serde_json::Value, path: &str) -> Vec<serde_json::Value> {
550    let mut segments: Vec<&str> = path.split('.').collect();
551    if segments.is_empty() {
552        return vec![val.clone()];
553    }
554    let key = segments.remove(0);
555    let remaining = if segments.is_empty() { String::new() } else { segments.join(".") };
556    match val {
557        serde_json::Value::Object(map) => {
558            if let Some(child) = map.get(key) {
559                if remaining.is_empty() {
560                    vec![child.clone()]
561                } else {
562                    json_values_matching_wildcard_path(child, &remaining)
563                }
564            } else {
565                vec![]
566            }
567        }
568        serde_json::Value::Array(arr) => {
569            if key == "{}" {
570                arr.iter()
571                    .flat_map(|item| {
572                        if remaining.is_empty() {
573                            vec![item.clone()]
574                        } else {
575                            json_values_matching_wildcard_path(item, &remaining)
576                        }
577                    })
578                    .collect()
579            } else if let Ok(idx) = key.parse::<usize>() {
580                arr.get(idx)
581                    .map(|child| {
582                        if remaining.is_empty() {
583                            vec![child.clone()]
584                        } else {
585                            json_values_matching_wildcard_path(child, &remaining)
586                        }
587                    })
588                    .unwrap_or_default()
589            } else {
590                vec![]
591            }
592        }
593        _ => vec![],
594    }
595}
596
597/// Generic cartesian product of scenario expansions with conflict detection.
598/// Each item provides its own set of scenarios. The `build_result` closure
599/// assembles the final `ResolvedValue` from the collected per-item values.
600///
601/// Returns `true` when the product had more than `limit` compatible scenarios.
602/// Expansion remains bounded to `limit` partial combinations after every item,
603/// but every retained combination continues through every remaining item so no
604/// returned list or map is structurally incomplete.
605///
606/// A product larger than `MAX_COMPLETE_SCENARIO_PRODUCT` combinations or
607/// `MAX_SCENARIO_PRODUCT_NODES` nodes is not enumerated; every member
608/// alternative is covered instead, keeping growth linear in the number of
609/// alternatives.
610fn expand_cartesian_scenarios<T: Clone>(
611    items: &[(T, Vec<(ResolvedValue, HashMap<String, bool>)>)],
612    base_assumptions: &HashMap<String, bool>,
613    limit: usize,
614    build_result: impl Fn(Vec<(T, ResolvedValue)>) -> ResolvedValue,
615    results: &mut Vec<(ResolvedValue, HashMap<String, bool>)>,
616    is_reachable: &dyn Fn(&HashMap<String, bool>) -> bool,
617    used_covering: &mut bool,
618) -> bool {
619    if limit == 0 {
620        return !items.is_empty();
621    }
622    if !product_is_enumerable(items) {
623        *used_covering = true;
624        return cover_cartesian_scenarios(items, base_assumptions, limit, build_result, results, is_reachable);
625    }
626
627    let mut combos: Vec<(Vec<(T, ResolvedValue)>, HashMap<String, bool>)> =
628        vec![(Vec::new(), base_assumptions.clone())];
629    let mut curtailed = false;
630    for (key, item_scenarios) in items {
631        let mut new_combos = Vec::new();
632        'partials: for (partial, partial_assumptions) in &combos {
633            for (value, value_assumptions) in item_scenarios {
634                let mut merged = partial_assumptions.clone();
635                let mut conflict = false;
636                for (condition, expected) in value_assumptions {
637                    if let Some(existing) = merged.get(condition) {
638                        if existing != expected {
639                            conflict = true;
640                            break;
641                        }
642                    } else {
643                        merged.insert(condition.clone(), *expected);
644                    }
645                }
646                if conflict {
647                    continue;
648                }
649                if new_combos.len() == limit {
650                    curtailed = true;
651                    break 'partials;
652                }
653                let mut collected = partial.clone();
654                collected.push((key.clone(), value.clone()));
655                new_combos.push((collected, merged));
656            }
657        }
658        combos = new_combos;
659    }
660
661    results.extend(combos.into_iter().map(|(collected, assumptions)| (build_result(collected), assumptions)));
662    curtailed
663}
664
665/// Whether the joint product of the member scenarios is small enough, in
666/// combinations and in materialized nodes, to enumerate in full.
667fn product_is_enumerable<T>(items: &[(T, Vec<(ResolvedValue, HashMap<String, bool>)>)]) -> bool {
668    let mut combinations = 1usize;
669    let mut nodes_per_combination = 0usize;
670    for (_, scenarios) in items {
671        combinations = combinations.saturating_mul(scenarios.len().max(1));
672        nodes_per_combination += scenarios.iter().map(|(value, _)| node_count(value)).max().unwrap_or(0);
673    }
674    combinations <= MAX_COMPLETE_SCENARIO_PRODUCT
675        && combinations.saturating_mul(nodes_per_combination.max(1)) <= MAX_SCENARIO_PRODUCT_NODES
676}
677
678/// Number of `ResolvedValue` nodes in a value, the unit the materialization
679/// bound counts.
680pub fn node_count(value: &ResolvedValue) -> usize {
681    match value {
682        ResolvedValue::List { items } => 1 + items.iter().map(node_count).sum::<usize>(),
683        ResolvedValue::Map { entries } => 1 + entries.iter().map(|entry| node_count(&entry.value)).sum::<usize>(),
684        ResolvedValue::Enum { variants } => 1 + variants.iter().map(node_count).sum::<usize>(),
685        ResolvedValue::Conditional { if_true, if_false, .. } => 1 + node_count(if_true) + node_count(if_false),
686        ResolvedValue::Concrete { .. }
687        | ResolvedValue::Reference { .. }
688        | ResolvedValue::Dynamic { .. }
689        | ResolvedValue::TypedDynamic { .. } => 1,
690    }
691}
692
693fn merge_scenario_assumptions(
694    assumptions: &HashMap<String, bool>,
695    additions: &HashMap<String, bool>,
696) -> Option<HashMap<String, bool>> {
697    let mut merged = assumptions.clone();
698    for (condition, value) in additions {
699        if merged.get(condition).is_some_and(|existing| existing != value) {
700            return None;
701        }
702        merged.insert(condition.clone(), *value);
703    }
704    Some(merged)
705}
706
707/// Alternative-covering replacement for a joint product too large to enumerate.
708///
709/// The result includes the all-true, all-false, and single-flip assignments used
710/// for pairwise condition coverage, plus an assignment that selects every
711/// scenario of every member at least once. Other members are selected greedily
712/// while preserving satisfiability, so each reachable alternative has a
713/// reachable full-container witness. Forcing the selected member scenario is
714/// necessary when two alternatives share the same condition assignment, as
715/// parameter enums can. This keeps the result linear in the sum of member
716/// alternatives without hiding a branch that requires three or more literals.
717fn cover_cartesian_scenarios<T: Clone>(
718    items: &[(T, Vec<(ResolvedValue, HashMap<String, bool>)>)],
719    base_assumptions: &HashMap<String, bool>,
720    limit: usize,
721    build_result: impl Fn(Vec<(T, ResolvedValue)>) -> ResolvedValue,
722    results: &mut Vec<(ResolvedValue, HashMap<String, bool>)>,
723    is_reachable: &dyn Fn(&HashMap<String, bool>) -> bool,
724) -> bool {
725    let mut conditions: Vec<&str> = items
726        .iter()
727        .flat_map(|(_, scenarios)| scenarios.iter().flat_map(|(_, assumptions)| assumptions.keys()))
728        .filter(|condition| !base_assumptions.contains_key(condition.as_str()))
729        .map(String::as_str)
730        .collect();
731    conditions.sort_unstable();
732    conditions.dedup();
733
734    let assignment = |decide: &dyn Fn(&str) -> bool| -> HashMap<String, bool> {
735        let mut assumptions = base_assumptions.clone();
736        assumptions.extend(conditions.iter().map(|condition| (condition.to_string(), decide(condition))));
737        assumptions
738    };
739
740    let mut seen: HashSet<(Vec<(String, bool)>, Vec<usize>)> = HashSet::new();
741    let mut curtailed = false;
742    let mut add_assignment = |seed: HashMap<String, bool>, forced: Option<(usize, usize)>| -> bool {
743        if !is_reachable(&seed) {
744            return false;
745        }
746        let mut merged = seed;
747        let mut collected = Vec::with_capacity(items.len());
748        let mut selected_indices = Vec::with_capacity(items.len());
749        for (item_index, (key, scenarios)) in items.iter().enumerate() {
750            let selected = if let Some((_, forced_scenario)) = forced.filter(|(item, _)| *item == item_index) {
751                scenarios.get(forced_scenario).and_then(|(value, assumptions)| {
752                    merge_scenario_assumptions(&merged, assumptions)
753                        .filter(|candidate| is_reachable(candidate))
754                        .map(|candidate| (forced_scenario, value, candidate))
755                })
756            } else {
757                scenarios.iter().enumerate().find_map(|(scenario_index, (value, assumptions))| {
758                    merge_scenario_assumptions(&merged, assumptions)
759                        .filter(|candidate| is_reachable(candidate))
760                        .map(|candidate| (scenario_index, value, candidate))
761                })
762            };
763            let Some((scenario_index, value, candidate)) = selected else {
764                curtailed = true;
765                return false;
766            };
767            merged = candidate;
768            collected.push((key.clone(), value.clone()));
769            selected_indices.push(scenario_index);
770        }
771
772        let mut canonical: Vec<(String, bool)> =
773            merged.iter().map(|(condition, value)| (condition.clone(), *value)).collect();
774        canonical.sort_unstable();
775        if !seen.insert((canonical, selected_indices)) {
776            return false;
777        }
778        if results.len() >= limit {
779            curtailed = true;
780            return true;
781        }
782        results.push((build_result(collected), merged));
783        false
784    };
785
786    if add_assignment(assignment(&|_| true), None) || add_assignment(assignment(&|_| false), None) {
787        return true;
788    }
789    for flipped in &conditions {
790        if add_assignment(assignment(&|condition| condition != *flipped), None) {
791            return true;
792        }
793    }
794
795    for (item_index, (_, scenarios)) in items.iter().enumerate() {
796        for (scenario_index, (_, scenario_assumptions)) in scenarios.iter().enumerate() {
797            let Some(seed) = merge_scenario_assumptions(base_assumptions, scenario_assumptions) else {
798                continue;
799            };
800            if add_assignment(seed, Some((item_index, scenario_index))) {
801                return true;
802            }
803        }
804    }
805    curtailed
806}
807
808fn expand_list_scenarios(
809    items: &[ResolvedValue],
810    base_assumptions: &HashMap<String, bool>,
811    limit: usize,
812    results: &mut Vec<(ResolvedValue, HashMap<String, bool>)>,
813    is_reachable: &dyn Fn(&HashMap<String, bool>) -> bool,
814    used_covering: &mut bool,
815) -> bool {
816    let remaining = limit.saturating_sub(results.len());
817    if remaining == 0 {
818        return true;
819    }
820
821    let mut nested_curtailed = false;
822    let mut prepared = Vec::with_capacity(items.len());
823    for (index, item) in items.iter().enumerate() {
824        let mut scenarios = Vec::new();
825        nested_curtailed |= collect_scenarios_reachable_with_covering(
826            item,
827            base_assumptions,
828            remaining,
829            &mut scenarios,
830            is_reachable,
831            used_covering,
832        );
833        if scenarios.is_empty() {
834            scenarios.push((item.clone(), base_assumptions.clone()));
835        }
836        prepared.push((index, scenarios));
837    }
838    let product_curtailed = expand_cartesian_scenarios(
839        &prepared,
840        base_assumptions,
841        remaining,
842        |collected| ResolvedValue::List { items: collected.into_iter().map(|(_, value)| value).collect() },
843        results,
844        is_reachable,
845        used_covering,
846    );
847    nested_curtailed || product_curtailed
848}
849
850fn expand_map_scenarios(
851    entries: &[MapEntry],
852    base_assumptions: &HashMap<String, bool>,
853    limit: usize,
854    results: &mut Vec<(ResolvedValue, HashMap<String, bool>)>,
855    is_reachable: &dyn Fn(&HashMap<String, bool>) -> bool,
856    used_covering: &mut bool,
857) -> bool {
858    let remaining = limit.saturating_sub(results.len());
859    if remaining == 0 {
860        return true;
861    }
862
863    let mut nested_curtailed = false;
864    let mut prepared = Vec::with_capacity(entries.len());
865    for entry in entries {
866        let mut scenarios = Vec::new();
867        nested_curtailed |= collect_scenarios_reachable_with_covering(
868            &entry.value,
869            base_assumptions,
870            remaining,
871            &mut scenarios,
872            is_reachable,
873            used_covering,
874        );
875        if scenarios.is_empty() {
876            scenarios.push((entry.value.clone(), base_assumptions.clone()));
877        }
878        prepared.push((entry.key.clone(), scenarios));
879    }
880    let product_curtailed = expand_cartesian_scenarios(
881        &prepared,
882        base_assumptions,
883        remaining,
884        |collected| ResolvedValue::Map {
885            entries: collected.into_iter().map(|(key, value)| MapEntry { key, value }).collect(),
886        },
887        results,
888        is_reachable,
889        used_covering,
890    );
891    nested_curtailed || product_curtailed
892}
893
894#[cfg(test)]
895mod tests {
896    use super::*;
897    use crate::resolver::{MapEntry, RefKind, ResolvedValue};
898    use serde_json::json;
899
900    #[test]
901    fn path_into_concrete_object() {
902        let val = ResolvedValue::Concrete { value: json!({"a": {"b": "found"}}).into() };
903        match resolved_value_at_path(&val, "a.b") {
904            Some(ResolvedValue::Concrete { value: v }) => assert_eq!(v.0, json!("found")),
905            other => panic!("expected Concrete(\"found\"), got {:?}", other),
906        }
907    }
908
909    #[test]
910    fn path_into_concrete_array() {
911        let val = ResolvedValue::Concrete { value: json!({"items": ["x", "y", "z"]}).into() };
912        match resolved_value_at_path(&val, "items.1") {
913            Some(ResolvedValue::Concrete { value: v }) => assert_eq!(v.0, json!("y")),
914            other => panic!("expected Concrete(\"y\"), got {:?}", other),
915        }
916    }
917
918    #[test]
919    fn path_missing_key_returns_none() {
920        let val = ResolvedValue::Concrete { value: json!({"a": 1}).into() };
921        assert!(resolved_value_at_path(&val, "b").is_none(), "missing key should return None");
922    }
923
924    #[test]
925    fn path_into_resolved_map() {
926        let val = ResolvedValue::Map {
927            entries: vec![MapEntry {
928                key: "key".into(),
929                value: ResolvedValue::Concrete { value: json!("val").into() },
930            }],
931        };
932        match resolved_value_at_path(&val, "key") {
933            Some(ResolvedValue::Concrete { value: v }) => assert_eq!(v.0, json!("val")),
934            other => panic!("expected Concrete, got {:?}", other),
935        }
936    }
937
938    #[test]
939    fn path_into_resolved_list_by_index() {
940        let val = ResolvedValue::List {
941            items: vec![
942                ResolvedValue::Concrete { value: json!("a").into() },
943                ResolvedValue::Concrete { value: json!("b").into() },
944            ],
945        };
946        match resolved_value_at_path(&val, "1") {
947            Some(ResolvedValue::Concrete { value: v }) => assert_eq!(v.0, json!("b")),
948            other => panic!("expected Concrete(\"b\"), got {:?}", other),
949        }
950    }
951
952    #[test]
953    fn path_through_conditional_both_branches() {
954        let val = ResolvedValue::Conditional {
955            condition: "C".into(),
956            if_true: Box::new(ResolvedValue::Concrete { value: json!({"x": "true_val"}).into() }),
957            if_false: Box::new(ResolvedValue::Concrete { value: json!({"x": "false_val"}).into() }),
958        };
959        match resolved_value_at_path(&val, "x") {
960            Some(ResolvedValue::Conditional { condition: c, if_true: t, if_false: f }) => {
961                assert_eq!(c, "C");
962                assert!(matches!(t.as_ref(), ResolvedValue::Concrete { value: v } if v.0 == json!("true_val")));
963                assert!(matches!(f.as_ref(), ResolvedValue::Concrete { value: v } if v.0 == json!("false_val")));
964            }
965            other => panic!("expected Conditional, got {:?}", other),
966        }
967    }
968
969    #[test]
970    fn path_through_conditional_one_branch_missing() {
971        let val = ResolvedValue::Conditional {
972            condition: "C".into(),
973            if_true: Box::new(ResolvedValue::Concrete { value: json!({"x": 1}).into() }),
974            if_false: Box::new(ResolvedValue::Concrete { value: json!({"y": 2}).into() }),
975        };
976        match resolved_value_at_path(&val, "x") {
977            Some(ResolvedValue::Conditional { if_true: t, if_false: f, .. }) => {
978                assert!(matches!(t.as_ref(), ResolvedValue::Concrete { value: v } if v.0 == json!(1)));
979                assert!(matches!(f.as_ref(), ResolvedValue::Dynamic { reason: _ }));
980            }
981            other => panic!("expected Conditional with Dynamic false branch, got {:?}", other),
982        }
983    }
984
985    #[test]
986    fn path_through_enum_collects_variants() {
987        let val = ResolvedValue::Enum {
988            variants: vec![
989                ResolvedValue::Concrete { value: json!({"k": "v1"}).into() },
990                ResolvedValue::Concrete { value: json!({"k": "v2"}).into() },
991            ],
992        };
993        match resolved_value_at_path(&val, "k") {
994            Some(ResolvedValue::Enum { variants: vals }) => assert_eq!(vals.len(), 2),
995            other => panic!("expected Enum, got {:?}", other),
996        }
997    }
998
999    #[test]
1000    fn wildcard_path_on_concrete_array() {
1001        let val = ResolvedValue::Concrete {
1002            value: json!({
1003                "items": [{"name": "a"}, {"name": "b"}]
1004            })
1005            .into(),
1006        };
1007        match resolved_value_at_path(&val, "items.{}.name") {
1008            Some(ResolvedValue::Enum { variants: vals }) => {
1009                assert_eq!(vals.len(), 2);
1010            }
1011            other => panic!("expected Enum from wildcard, got {:?}", other),
1012        }
1013    }
1014
1015    #[test]
1016    fn wildcard_path_on_resolved_list() {
1017        let val = ResolvedValue::List {
1018            items: vec![
1019                ResolvedValue::Map {
1020                    entries: vec![MapEntry {
1021                        key: "n".into(),
1022                        value: ResolvedValue::Concrete { value: json!("x").into() },
1023                    }],
1024                },
1025                ResolvedValue::Map {
1026                    entries: vec![MapEntry {
1027                        key: "n".into(),
1028                        value: ResolvedValue::Concrete { value: json!("y").into() },
1029                    }],
1030                },
1031            ],
1032        };
1033        match resolved_value_at_path(&val, "{}.n") {
1034            Some(ResolvedValue::Enum { variants: vals }) => assert_eq!(vals.len(), 2),
1035            other => panic!("expected Enum from wildcard on List, got {:?}", other),
1036        }
1037    }
1038
1039    #[test]
1040    fn path_into_dynamic_returns_none() {
1041        let val = ResolvedValue::Dynamic { reason: "unknown".into() };
1042        assert!(resolved_value_at_path(&val, "anything").is_none(), "path into Dynamic should return None");
1043    }
1044
1045    #[test]
1046    fn path_into_reference_returns_none() {
1047        let val = ResolvedValue::Reference { target: "R".into(), kind: RefKind::Ref };
1048        assert!(resolved_value_at_path(&val, "sub").is_none(), "path into Reference should return None");
1049    }
1050
1051    #[test]
1052    fn collect_condition_refs_from_nested() {
1053        let val = ResolvedValue::Conditional {
1054            condition: "C1".into(),
1055            if_true: Box::new(ResolvedValue::List {
1056                items: vec![ResolvedValue::Conditional {
1057                    condition: "C2".into(),
1058                    if_true: Box::new(ResolvedValue::Concrete { value: json!(1).into() }),
1059                    if_false: Box::new(ResolvedValue::Concrete { value: json!(2).into() }),
1060                }],
1061            }),
1062            if_false: Box::new(ResolvedValue::Concrete { value: json!(3).into() }),
1063        };
1064        let mut refs = Vec::new();
1065        collect_condition_refs_from_resolved(&val, &mut refs);
1066        refs.sort();
1067        assert_eq!(refs, vec!["C1", "C2"]);
1068    }
1069
1070    #[test]
1071    fn effective_path_removes_conditional_branch_segments() {
1072        assert_eq!(
1073            effective_path_from_scenario_source_path("Properties.ResourceRecords.Fn::If.1.0"),
1074            Some("Properties.ResourceRecords.0".to_string())
1075        );
1076    }
1077
1078    #[test]
1079    fn effective_path_removes_nested_conditional_branch_segments() {
1080        assert_eq!(
1081            effective_path_from_scenario_source_path("Properties.Fn::If.2.Records.0.Fn::If.1.Value"),
1082            Some("Properties.Records.0.Value".to_string())
1083        );
1084    }
1085
1086    #[test]
1087    fn effective_path_preserves_other_intrinsic_segments() {
1088        assert_eq!(
1089            effective_path_from_scenario_source_path("Properties.Value.Fn::GetAtt.1"),
1090            Some("Properties.Value.Fn::GetAtt.1".to_string())
1091        );
1092        assert_eq!(effective_path_from_scenario_source_path("Properties.Value.Fn::If.0"), None);
1093        assert_eq!(effective_path_from_scenario_source_path("Properties..Value"), None);
1094    }
1095
1096    #[test]
1097    fn scenario_source_path_selects_conditional_list_branch() {
1098        let value = ResolvedValue::Conditional {
1099            condition: "ChooseFirst".into(),
1100            if_true: Box::new(ResolvedValue::Concrete { value: json!(["bad"]).into() }),
1101            if_false: Box::new(ResolvedValue::Concrete { value: json!(["good"]).into() }),
1102        };
1103        let mut conditions = HashMap::new();
1104        conditions.insert("ChooseFirst".to_string(), true);
1105        assert_eq!(
1106            scenario_source_path_at(&value, "0", &conditions, "Properties.ResourceRecords"),
1107            Some("Properties.ResourceRecords.Fn::If.1.0".to_string())
1108        );
1109        conditions.insert("ChooseFirst".to_string(), false);
1110        assert_eq!(
1111            scenario_source_path_at(&value, "0", &conditions, "Properties.ResourceRecords"),
1112            Some("Properties.ResourceRecords.Fn::If.2.0".to_string())
1113        );
1114    }
1115
1116    #[test]
1117    fn scenario_source_path_selects_conditional_list_item_branch() {
1118        let value = ResolvedValue::List {
1119            items: vec![ResolvedValue::Conditional {
1120                condition: "UseValue".into(),
1121                if_true: Box::new(ResolvedValue::Concrete { value: json!("value").into() }),
1122                if_false: Box::new(ResolvedValue::Concrete { value: json!(null).into() }),
1123            }],
1124        };
1125        let conditions = HashMap::from([("UseValue".to_string(), true)]);
1126        assert_eq!(
1127            scenario_source_path_at(&value, "0", &conditions, "Properties.ResourceRecords"),
1128            Some("Properties.ResourceRecords.0.Fn::If.1".to_string())
1129        );
1130    }
1131
1132    #[test]
1133    fn collect_conditional_nulls_detects_null_branch() {
1134        let val = ResolvedValue::Conditional {
1135            condition: "C".into(),
1136            if_true: Box::new(ResolvedValue::Concrete { value: json!(null).into() }),
1137            if_false: Box::new(ResolvedValue::Concrete { value: json!("ok").into() }),
1138        };
1139        let mut nulls = Vec::new();
1140        collect_conditional_nulls(&val, "prop", &mut nulls);
1141        assert_eq!(nulls.len(), 1);
1142        assert_eq!(nulls[0], ("prop".to_string(), "C".to_string(), true));
1143    }
1144
1145    #[test]
1146    fn collect_scenarios_concrete_single() {
1147        let val = ResolvedValue::Concrete { value: json!("hello").into() };
1148        let mut results = Vec::new();
1149        collect_scenarios(&val, &HashMap::new(), MAX_SCENARIO_COMBINATIONS, &mut results);
1150        assert_eq!(results.len(), 1);
1151        assert!(matches!(&results[0].0, ResolvedValue::Concrete { value: v } if v.0 == json!("hello")));
1152    }
1153
1154    #[test]
1155    fn collect_scenarios_conditional_splits() {
1156        let val = ResolvedValue::Conditional {
1157            condition: "C".into(),
1158            if_true: Box::new(ResolvedValue::Concrete { value: json!(1).into() }),
1159            if_false: Box::new(ResolvedValue::Concrete { value: json!(2).into() }),
1160        };
1161        let mut results = Vec::new();
1162        collect_scenarios(&val, &HashMap::new(), MAX_SCENARIO_COMBINATIONS, &mut results);
1163        assert_eq!(results.len(), 2);
1164        let (_, conds_true) = &results[0];
1165        assert_eq!(conds_true.get("C"), Some(&true));
1166        let (_, conds_false) = &results[1];
1167        assert_eq!(conds_false.get("C"), Some(&false));
1168    }
1169
1170    #[test]
1171    fn collect_scenarios_expands_nested_conditional() {
1172        let val = ResolvedValue::Map {
1173            entries: vec![MapEntry {
1174                key: "Statement".into(),
1175                value: ResolvedValue::List {
1176                    items: vec![ResolvedValue::Map {
1177                        entries: vec![MapEntry {
1178                            key: "Resource".into(),
1179                            value: ResolvedValue::Conditional {
1180                                condition: "C".into(),
1181                                if_true: Box::new(ResolvedValue::Concrete { value: json!("first").into() }),
1182                                if_false: Box::new(ResolvedValue::Concrete { value: json!("second").into() }),
1183                            },
1184                        }],
1185                    }],
1186                },
1187            }],
1188        };
1189        let mut results = Vec::new();
1190        collect_scenarios(&val, &HashMap::new(), MAX_SCENARIO_COMBINATIONS, &mut results);
1191
1192        assert_eq!(results.len(), 2);
1193        assert_eq!(results[0].1.get("C"), Some(&true));
1194        assert_eq!(results[1].1.get("C"), Some(&false));
1195    }
1196
1197    #[test]
1198    fn collect_scenarios_enum_expands() {
1199        let val = ResolvedValue::Enum {
1200            variants: vec![
1201                ResolvedValue::Concrete { value: json!("a").into() },
1202                ResolvedValue::Concrete { value: json!("b").into() },
1203            ],
1204        };
1205        let mut results = Vec::new();
1206        collect_scenarios(&val, &HashMap::new(), MAX_SCENARIO_COMBINATIONS, &mut results);
1207        assert_eq!(results.len(), 2);
1208    }
1209
1210    #[test]
1211    fn collect_scenarios_respects_existing_assumptions() {
1212        let val = ResolvedValue::Conditional {
1213            condition: "C".into(),
1214            if_true: Box::new(ResolvedValue::Concrete { value: json!(1).into() }),
1215            if_false: Box::new(ResolvedValue::Concrete { value: json!(2).into() }),
1216        };
1217        let mut assumptions = HashMap::new();
1218        assumptions.insert("C".to_string(), true);
1219        let mut results = Vec::new();
1220        collect_scenarios(&val, &assumptions, MAX_SCENARIO_COMBINATIONS, &mut results);
1221        assert_eq!(results.len(), 1);
1222        assert!(matches!(&results[0].0, ResolvedValue::Concrete { value: v } if v.0 == json!(1)));
1223    }
1224
1225    #[test]
1226    fn direct_enum_at_scenario_limit_is_not_curtailed() {
1227        let value = ResolvedValue::Enum {
1228            variants: (0..MAX_SCENARIO_COMBINATIONS)
1229                .map(|index| ResolvedValue::Concrete { value: json!(index).into() })
1230                .collect(),
1231        };
1232        let mut scenarios = Vec::new();
1233        let mut curtailed = false;
1234
1235        collect_scenarios_signaled(&value, &HashMap::new(), &mut scenarios, &mut curtailed);
1236
1237        assert_eq!(scenarios.len(), MAX_SCENARIO_COMBINATIONS);
1238        assert!(!curtailed, "an exact-fit direct enum must not report omitted scenarios");
1239    }
1240
1241    #[test]
1242    fn conditional_branch_one_over_scenario_limit_is_curtailed() {
1243        let value = ResolvedValue::Conditional {
1244            condition: "UseEnum".into(),
1245            if_true: Box::new(ResolvedValue::Enum {
1246                variants: (0..MAX_SCENARIO_COMBINATIONS)
1247                    .map(|index| ResolvedValue::Concrete { value: json!(index).into() })
1248                    .collect(),
1249            }),
1250            if_false: Box::new(ResolvedValue::Concrete { value: json!("fallback").into() }),
1251        };
1252        let mut scenarios = Vec::new();
1253        let mut curtailed = false;
1254
1255        collect_scenarios_signaled(&value, &HashMap::new(), &mut scenarios, &mut curtailed);
1256
1257        assert_eq!(scenarios.len(), MAX_SCENARIO_COMBINATIONS);
1258        assert!(curtailed, "the first omitted conditional-branch scenario must be reported");
1259        assert!(scenarios.iter().all(|(_, conditions)| conditions.get("UseEnum") == Some(&true)));
1260    }
1261
1262    #[test]
1263    fn contains_dynamic_detects_nested_dynamic() {
1264        let val = ResolvedValue::List {
1265            items: vec![
1266                ResolvedValue::Concrete { value: json!("ok").into() },
1267                ResolvedValue::Dynamic { reason: "unknown".into() },
1268            ],
1269        };
1270        assert!(contains_dynamic_resolved(&val));
1271    }
1272
1273    #[test]
1274    fn contains_dynamic_false_for_all_concrete() {
1275        let val = ResolvedValue::Map {
1276            entries: vec![MapEntry { key: "a".into(), value: ResolvedValue::Concrete { value: json!(1).into() } }],
1277        };
1278        assert!(!contains_dynamic_resolved(&val));
1279    }
1280
1281    #[test]
1282    fn contains_dynamic_detects_typed_dynamic() {
1283        let val = ResolvedValue::TypedDynamic { reason: "param".into(), param_type: "String".into() };
1284        assert!(contains_dynamic_resolved(&val));
1285    }
1286
1287    #[test]
1288    fn contains_dynamic_detects_reference() {
1289        let val = ResolvedValue::Reference { target: "R".into(), kind: RefKind::Ref };
1290        assert!(contains_dynamic_resolved(&val));
1291    }
1292
1293    #[test]
1294    fn json_contains_markers_detects_dynamic_marker() {
1295        let val = json!({MARKER_DYNAMIC: "reason"});
1296        assert!(json_contains_markers(&val));
1297    }
1298
1299    #[test]
1300    fn json_contains_markers_false_for_plain() {
1301        assert!(!json_contains_markers(&json!({"key": "value"})));
1302        assert!(!json_contains_markers(&json!("string")));
1303        assert!(!json_contains_markers(&json!(42)));
1304    }
1305
1306    #[test]
1307    fn bounds_of_a_literal_are_its_own_length() {
1308        let val = ResolvedValue::Concrete { value: json!("hello").into() };
1309        assert_eq!(estimate_resolved_string_length_bounds(&val), Some((5, 5)));
1310    }
1311
1312    #[test]
1313    fn bounds_of_a_conditional_span_both_branches() {
1314        let val = ResolvedValue::Conditional {
1315            condition: "C".into(),
1316            if_true: Box::new(ResolvedValue::Concrete { value: json!("short").into() }),
1317            if_false: Box::new(ResolvedValue::Concrete { value: json!("much longer string").into() }),
1318        };
1319        assert_eq!(estimate_resolved_string_length_bounds(&val), Some((5, "much longer string".len())));
1320    }
1321
1322    #[test]
1323    fn bounds_of_a_conditional_are_absent_when_one_branch_is_unknown() {
1324        // The deployment may take the unknown branch, so no length holds for every
1325        // possibility.
1326        let val = ResolvedValue::Conditional {
1327            condition: "C".into(),
1328            if_true: Box::new(ResolvedValue::Concrete { value: json!("short").into() }),
1329            if_false: Box::new(ResolvedValue::Dynamic { reason: "unknown".into() }),
1330        };
1331        assert_eq!(estimate_resolved_string_length_bounds(&val), None);
1332    }
1333
1334    #[test]
1335    fn bounds_of_allowed_values_span_every_choice() {
1336        let val = ResolvedValue::Enum {
1337            variants: vec![
1338                ResolvedValue::Concrete { value: json!("ab").into() },
1339                ResolvedValue::Concrete { value: json!("abcdef").into() },
1340            ],
1341        };
1342        assert_eq!(estimate_resolved_string_length_bounds(&val), Some((2, 6)));
1343    }
1344
1345    #[test]
1346    fn bounds_of_allowed_values_are_absent_when_one_choice_is_unknown() {
1347        let val = ResolvedValue::Enum {
1348            variants: vec![
1349                ResolvedValue::Concrete { value: json!("ab").into() },
1350                ResolvedValue::Dynamic { reason: "unknown".into() },
1351            ],
1352        };
1353        assert_eq!(estimate_resolved_string_length_bounds(&val), None);
1354    }
1355
1356    #[test]
1357    fn bounds_of_a_sub_with_interpolation_are_absent() {
1358        // A `${...}` interpolation expands to any length at deploy time, so the
1359        // literal portion is not a lower bound on the value.
1360        let val = ResolvedValue::Dynamic { reason: "Sub:arn:aws:s3:::${BucketName}".into() };
1361        assert_eq!(estimate_resolved_string_length_bounds(&val), None);
1362    }
1363
1364    #[test]
1365    fn bounds_of_a_fully_substituted_sub_are_its_length() {
1366        let val = ResolvedValue::Dynamic { reason: "Sub:no-variables-here".into() };
1367        assert_eq!(
1368            estimate_resolved_string_length_bounds(&val),
1369            Some(("no-variables-here".len(), "no-variables-here".len()))
1370        );
1371    }
1372
1373    #[test]
1374    fn bounds_are_absent_for_a_non_string_and_an_opaque_value() {
1375        assert_eq!(
1376            estimate_resolved_string_length_bounds(&ResolvedValue::Concrete { value: json!(42).into() }),
1377            None,
1378            "a number has no string length"
1379        );
1380        assert_eq!(
1381            estimate_resolved_string_length_bounds(&ResolvedValue::Dynamic { reason: "unknown".into() }),
1382            None,
1383            "an opaque value has no known length"
1384        );
1385    }
1386
1387    #[test]
1388    fn bounds_of_a_join_with_an_unresolved_reference_are_absent() {
1389        // The placeholder is internal text standing in for a value that is only
1390        // known at deployment. Measuring it reported the width of the placeholder
1391        // as the length of the value, which produced length violations citing a
1392        // number the template never produces.
1393        let val = ResolvedValue::Dynamic {
1394            reason: format!("{JOIN_PARTIAL_PREFIX}prefix-{UNRESOLVED_REF_PLACEHOLDER_PREFIX}Other}}-suffix"),
1395        };
1396        assert_eq!(estimate_resolved_string_length_bounds(&val), None, "a placeholder has no measurable length");
1397    }
1398
1399    #[test]
1400    fn bounds_of_a_join_with_an_opaque_deploy_time_value_are_absent() {
1401        let val = ResolvedValue::Dynamic {
1402            reason: format!("{JOIN_PARTIAL_PREFIX}prefix-{UNRESOLVED_DYNAMIC_PLACEHOLDER}-suffix"),
1403        };
1404        assert_eq!(
1405            estimate_resolved_string_length_bounds(&val),
1406            None,
1407            "a deploy-time placeholder has no measurable length"
1408        );
1409    }
1410
1411    #[test]
1412    fn bounds_of_a_join_of_known_parts_are_its_length() {
1413        let val = ResolvedValue::Dynamic { reason: "Join:prefix-middle-suffix".into() };
1414        assert_eq!(
1415            estimate_resolved_string_length_bounds(&val),
1416            Some(("prefix-middle-suffix".len(), "prefix-middle-suffix".len()))
1417        );
1418    }
1419
1420    #[test]
1421    fn bounds_of_a_sub_with_an_unresolved_reference_are_absent() {
1422        let val = ResolvedValue::Dynamic { reason: "Sub:name-{ref:Other}".into() };
1423        assert_eq!(estimate_resolved_string_length_bounds(&val), None, "a placeholder has no measurable length");
1424    }
1425
1426    // --- Scenario expansion curtailment tests ---
1427
1428    /// Helper: builds a map with `n` entries, each containing an independent
1429    /// conditional so that full expansion produces 2^n scenarios. Uses a small
1430    /// limit to test curtailment without allocating millions of scenarios.
1431    fn make_branching_map(n: usize) -> ResolvedValue {
1432        let entries: Vec<MapEntry> = (0..n)
1433            .map(|i| MapEntry {
1434                key: format!("key{i}"),
1435                value: ResolvedValue::Conditional {
1436                    condition: format!("C{i}"),
1437                    if_true: Box::new(ResolvedValue::Concrete { value: json!(format!("true{i}")).into() }),
1438                    if_false: Box::new(ResolvedValue::Concrete { value: json!(format!("false{i}")).into() }),
1439                },
1440            })
1441            .collect();
1442        ResolvedValue::Map { entries }
1443    }
1444
1445    /// Helper: builds a list with `n` items, each containing an independent
1446    /// conditional so that full expansion produces 2^n scenarios.
1447    fn make_branching_list(n: usize) -> ResolvedValue {
1448        let items: Vec<ResolvedValue> = (0..n)
1449            .map(|i| ResolvedValue::Conditional {
1450                condition: format!("C{i}"),
1451                if_true: Box::new(ResolvedValue::Concrete { value: json!(format!("true{i}")).into() }),
1452                if_false: Box::new(ResolvedValue::Concrete { value: json!(format!("false{i}")).into() }),
1453            })
1454            .collect();
1455        ResolvedValue::List { items }
1456    }
1457
1458    #[test]
1459    fn exact_limit_expansion_does_not_mark_curtailment() {
1460        // 4 independent conditions → 2^4 = 16 scenarios. With limit=16 the
1461        // expansion is exactly at the boundary and must NOT be reported as
1462        // curtailed.
1463        let val = make_branching_map(4);
1464        let mut results = Vec::new();
1465        let curtailed = collect_scenarios(&val, &HashMap::new(), 16, &mut results);
1466        assert!(!curtailed, "exact-limit expansion must not be curtailed");
1467        assert_eq!(results.len(), 16, "all 2^4 scenarios must be produced");
1468    }
1469
1470    #[test]
1471    fn over_limit_expansion_marks_curtailment() {
1472        // 5 independent conditions → 2^5 = 32 scenarios. With limit=16 the
1473        // product exceeds the limit and must be reported as curtailed.
1474        let val = make_branching_map(5);
1475        let mut results = Vec::new();
1476        let curtailed = collect_scenarios(&val, &HashMap::new(), 16, &mut results);
1477        assert!(curtailed, "over-limit expansion must be curtailed");
1478        assert!(results.len() <= 16, "at most `limit` scenarios may be returned; got {}", results.len());
1479    }
1480
1481    #[test]
1482    fn curtailed_map_scenarios_are_structurally_complete() {
1483        // With 5 entries and limit=4, expansion is curtailed but every returned
1484        // scenario must have all 5 map keys present.
1485        let val = make_branching_map(5);
1486        let mut results = Vec::new();
1487        let curtailed = collect_scenarios(&val, &HashMap::new(), 4, &mut results);
1488        assert!(curtailed, "should be curtailed with limit=4 and 5 conditions");
1489        for (scenario, _) in &results {
1490            match scenario {
1491                ResolvedValue::Map { entries } => {
1492                    assert_eq!(
1493                        entries.len(),
1494                        5,
1495                        "every returned map scenario must have all 5 entries; got {}: {:?}",
1496                        entries.len(),
1497                        entries.iter().map(|e| &e.key).collect::<Vec<_>>()
1498                    );
1499                }
1500                other => panic!("expected Map, got {:?}", other),
1501            }
1502        }
1503    }
1504
1505    #[test]
1506    fn curtailed_list_scenarios_are_structurally_complete() {
1507        // With 5 items and limit=4, expansion is curtailed but every returned
1508        // scenario must have all 5 list elements present.
1509        let val = make_branching_list(5);
1510        let mut results = Vec::new();
1511        let curtailed = collect_scenarios(&val, &HashMap::new(), 4, &mut results);
1512        assert!(curtailed, "should be curtailed with limit=4 and 5 conditions");
1513        for (scenario, _) in &results {
1514            match scenario {
1515                ResolvedValue::List { items } => {
1516                    assert_eq!(
1517                        items.len(),
1518                        5,
1519                        "every returned list scenario must have all 5 items; got {}",
1520                        items.len()
1521                    );
1522                }
1523                other => panic!("expected List, got {:?}", other),
1524            }
1525        }
1526    }
1527
1528    #[test]
1529    fn nested_curtailment_is_propagated_to_the_root() {
1530        let val = ResolvedValue::List { items: vec![make_branching_map(3)] };
1531        let mut results = Vec::new();
1532        let curtailed = collect_scenarios(&val, &HashMap::new(), 4, &mut results);
1533        assert!(curtailed, "curtailment inside a nested map must be visible to the root collector");
1534        assert_eq!(results.len(), 4);
1535        for (scenario, _) in results {
1536            let ResolvedValue::List { items } = scenario else {
1537                panic!("expected outer list scenario");
1538            };
1539            assert_eq!(items.len(), 1);
1540            let ResolvedValue::Map { entries } = &items[0] else {
1541                panic!("expected nested map scenario");
1542            };
1543            assert_eq!(entries.len(), 3, "nested scenarios must remain structurally complete");
1544        }
1545    }
1546
1547    #[test]
1548    fn enum_expansion_obeys_limit_and_exact_boundary() {
1549        let four = ResolvedValue::Enum {
1550            variants: (0..4).map(|value| ResolvedValue::Concrete { value: json!(value).into() }).collect(),
1551        };
1552        let mut exact_results = Vec::new();
1553        assert!(!collect_scenarios(&four, &HashMap::new(), 4, &mut exact_results));
1554        assert_eq!(exact_results.len(), 4);
1555
1556        let five = ResolvedValue::Enum {
1557            variants: (0..5).map(|value| ResolvedValue::Concrete { value: json!(value).into() }).collect(),
1558        };
1559        let mut limited_results = Vec::new();
1560        assert!(collect_scenarios(&five, &HashMap::new(), 4, &mut limited_results));
1561        assert_eq!(limited_results.len(), 4);
1562    }
1563
1564    #[test]
1565    fn expand_cartesian_returns_false_at_exact_limit() {
1566        // Directly test the inner helper: 2 items × 2 scenarios each → 4 combos.
1567        // With limit=4 it should NOT be curtailed.
1568        let a_val = ResolvedValue::Concrete { value: json!("a").into() };
1569        let b_val = ResolvedValue::Concrete { value: json!("b").into() };
1570        let items: Vec<(usize, Vec<(ResolvedValue, HashMap<String, bool>)>)> = vec![
1571            (0, vec![(a_val.clone(), HashMap::new()), (b_val.clone(), HashMap::new())]),
1572            (1, vec![(a_val.clone(), HashMap::new()), (b_val.clone(), HashMap::new())]),
1573        ];
1574        let mut results = Vec::new();
1575        let mut used_covering = false;
1576        let curtailed = expand_cartesian_scenarios(
1577            &items,
1578            &HashMap::new(),
1579            4,
1580            |collected| ResolvedValue::List { items: collected.into_iter().map(|(_, v)| v).collect() },
1581            &mut results,
1582            &|_| true,
1583            &mut used_covering,
1584        );
1585        assert!(!curtailed, "4 combos with limit=4 must not be curtailed");
1586        assert!(!used_covering);
1587        assert_eq!(results.len(), 4);
1588    }
1589
1590    #[test]
1591    fn expand_cartesian_returns_true_over_limit() {
1592        // 3 items × 2 scenarios each → 8 combos. With limit=4, curtailed.
1593        let a_val = ResolvedValue::Concrete { value: json!("a").into() };
1594        let b_val = ResolvedValue::Concrete { value: json!("b").into() };
1595        let items: Vec<(usize, Vec<(ResolvedValue, HashMap<String, bool>)>)> = vec![
1596            (0, vec![(a_val.clone(), HashMap::new()), (b_val.clone(), HashMap::new())]),
1597            (1, vec![(a_val.clone(), HashMap::new()), (b_val.clone(), HashMap::new())]),
1598            (2, vec![(a_val.clone(), HashMap::new()), (b_val.clone(), HashMap::new())]),
1599        ];
1600        let mut results = Vec::new();
1601        let mut used_covering = false;
1602        let curtailed = expand_cartesian_scenarios(
1603            &items,
1604            &HashMap::new(),
1605            4,
1606            |collected| ResolvedValue::List { items: collected.into_iter().map(|(_, v)| v).collect() },
1607            &mut results,
1608            &|_| true,
1609            &mut used_covering,
1610        );
1611        assert!(curtailed, "8 combos with limit=4 must be curtailed");
1612        assert!(!used_covering);
1613        assert!(results.len() <= 4, "at most limit results; got {}", results.len());
1614        // Structural completeness: every result list must have all 3 items
1615        for (scenario, _) in &results {
1616            match scenario {
1617                ResolvedValue::List { items } => {
1618                    assert_eq!(items.len(), 3, "every scenario must have all 3 items");
1619                }
1620                other => panic!("expected List, got {:?}", other),
1621            }
1622        }
1623    }
1624
1625    fn optional_item(condition: &str, value: &str) -> ResolvedValue {
1626        ResolvedValue::Conditional {
1627            condition: condition.to_string(),
1628            if_true: Box::new(ResolvedValue::Concrete { value: json!(value).into() }),
1629            if_false: Box::new(ResolvedValue::Concrete { value: json!(null).into() }),
1630        }
1631    }
1632
1633    fn present_items(scenario: &ResolvedValue) -> Vec<String> {
1634        let ResolvedValue::List { items } = scenario else { panic!("expected a list, got {scenario:?}") };
1635        items
1636            .iter()
1637            .filter_map(|item| match item {
1638                ResolvedValue::Concrete { value } => value.as_str().map(str::to_string),
1639                other => panic!("expected a concrete item, got {other:?}"),
1640            })
1641            .collect()
1642    }
1643
1644    #[test]
1645    fn independent_conditions_below_the_product_bound_are_enumerated_in_full() {
1646        let list = ResolvedValue::List { items: (0..4).map(|i| optional_item(&format!("C{i}"), "v")).collect() };
1647        let mut scenarios = Vec::new();
1648        let curtailed = collect_scenarios(&list, &HashMap::new(), MAX_SCENARIO_COMBINATIONS, &mut scenarios);
1649        assert!(!curtailed);
1650        assert_eq!(scenarios.len(), 16, "2^4 joint assignments");
1651    }
1652
1653    #[test]
1654    fn independent_conditions_above_the_product_bound_preserve_pairwise_condition_states() {
1655        let count = 13;
1656        let list = ResolvedValue::List {
1657            items: (0..count).map(|i| optional_item(&format!("C{i:02}"), &format!("v{i:02}"))).collect(),
1658        };
1659        let mut scenarios = Vec::new();
1660        let curtailed = collect_scenarios(&list, &HashMap::new(), MAX_SCENARIO_COMBINATIONS, &mut scenarios);
1661        assert!(!curtailed, "a covering set is a complete analysis strategy, not a curtailment");
1662        assert_eq!(scenarios.len(), count + 2, "all-true, all-false and all-true with one condition flipped");
1663
1664        let all_present = scenarios.iter().find(|(value, _)| present_items(value).len() == count).expect("all-present");
1665        assert!(all_present.1.values().all(|value| *value));
1666        let all_absent = scenarios.iter().find(|(value, _)| present_items(value).is_empty()).expect("all-absent");
1667        assert!(all_absent.1.values().all(|value| !*value));
1668        for i in 0..count {
1669            let flipped = format!("C{i:02}");
1670            let single_flip = scenarios
1671                .iter()
1672                .find(|(_, assumptions)| assumptions.iter().all(|(condition, value)| *value == (condition != &flipped)))
1673                .unwrap_or_else(|| panic!("a scenario with only {flipped} false"));
1674            let present = present_items(&single_flip.0);
1675            assert_eq!(present.len(), count - 1);
1676            assert!(!present.contains(&format!("v{i:02}")));
1677        }
1678        for (_, assumptions) in &scenarios {
1679            assert_eq!(assumptions.len(), count, "every scenario decides every condition");
1680        }
1681    }
1682
1683    #[test]
1684    fn covering_set_deduplicates_assignments_that_coincide() {
1685        // One shared condition gates every item, so all-true-with-flip equals all-false.
1686        let list = ResolvedValue::List { items: (0..3).map(|_| optional_item("Shared", "v")).collect() };
1687        let mut scenarios = Vec::new();
1688        collect_scenarios(&list, &HashMap::new(), MAX_SCENARIO_COMBINATIONS, &mut scenarios);
1689        assert_eq!(scenarios.len(), 2, "shared conditions never multiply");
1690    }
1691
1692    #[test]
1693    fn covering_set_includes_an_alternative_requiring_three_literals() {
1694        let selected_value = ResolvedValue::Conditional {
1695            condition: "CA".to_string(),
1696            if_true: Box::new(ResolvedValue::Concrete { value: json!("a").into() }),
1697            if_false: Box::new(ResolvedValue::Conditional {
1698                condition: "CB".to_string(),
1699                if_true: Box::new(ResolvedValue::Concrete { value: json!("b").into() }),
1700                if_false: Box::new(ResolvedValue::Conditional {
1701                    condition: "CC".to_string(),
1702                    if_true: Box::new(ResolvedValue::Concrete { value: json!("selected").into() }),
1703                    if_false: Box::new(ResolvedValue::Concrete { value: json!("c").into() }),
1704                }),
1705            }),
1706        };
1707        let mut items = vec![selected_value];
1708        items.extend((0..11).map(|index| optional_item(&format!("C{index:02}"), "sibling")));
1709        let list = ResolvedValue::List { items };
1710
1711        let mut scenarios = Vec::new();
1712        let curtailed = collect_scenarios(&list, &HashMap::new(), MAX_SCENARIO_COMBINATIONS, &mut scenarios);
1713
1714        assert!(!curtailed);
1715        let selected = scenarios
1716            .iter()
1717            .find(|(scenario, _)| present_items(scenario).first().is_some_and(|value| value == "selected"))
1718            .expect("the three-literal alternative must be represented");
1719        assert_eq!(selected.1.get("CA"), Some(&false));
1720        assert_eq!(selected.1.get("CB"), Some(&false));
1721        assert_eq!(selected.1.get("CC"), Some(&true));
1722    }
1723
1724    #[test]
1725    fn covering_set_finds_a_reachable_completion_for_each_alternative() {
1726        let selected_value = ResolvedValue::Conditional {
1727            condition: "A".to_string(),
1728            if_true: Box::new(ResolvedValue::Concrete { value: json!("other").into() }),
1729            if_false: Box::new(ResolvedValue::Concrete { value: json!("selected").into() }),
1730        };
1731        let correlated_value = ResolvedValue::Conditional {
1732            condition: "B".to_string(),
1733            if_true: Box::new(ResolvedValue::Concrete { value: json!("b-true").into() }),
1734            if_false: Box::new(ResolvedValue::Concrete { value: json!("b-false").into() }),
1735        };
1736        let mut items = vec![selected_value, correlated_value];
1737        items.extend((0..11).map(|index| optional_item(&format!("C{index:02}"), "sibling")));
1738        let list = ResolvedValue::List { items };
1739        let is_reachable = |assumptions: &HashMap<String, bool>| match (assumptions.get("A"), assumptions.get("B")) {
1740            (Some(a), Some(b)) => a == b,
1741            _ => true,
1742        };
1743
1744        let mut scenarios = Vec::new();
1745        let curtailed = collect_scenarios_reachable(
1746            &list,
1747            &HashMap::new(),
1748            MAX_SCENARIO_COMBINATIONS,
1749            &mut scenarios,
1750            &is_reachable,
1751        );
1752
1753        assert!(!curtailed);
1754        assert!(scenarios.iter().all(|(_, assumptions)| is_reachable(assumptions)));
1755        let selected = scenarios
1756            .iter()
1757            .find(|(scenario, _)| present_items(scenario).first().is_some_and(|value| value == "selected"))
1758            .expect("the reachable false alternative must be represented");
1759        assert_eq!(selected.1.get("A"), Some(&false));
1760        assert_eq!(selected.1.get("B"), Some(&false));
1761    }
1762
1763    #[test]
1764    fn covering_set_is_chosen_by_materialized_size_as_well_as_count() {
1765        let wide_leaf = ResolvedValue::List {
1766            items: (0..600).map(|i| ResolvedValue::Concrete { value: json!(i).into() }).collect(),
1767        };
1768        let optional_wide = |condition: &str| ResolvedValue::Conditional {
1769            condition: condition.to_string(),
1770            if_true: Box::new(wide_leaf.clone()),
1771            if_false: Box::new(ResolvedValue::Concrete { value: json!(null).into() }),
1772        };
1773        let items: Vec<(usize, Vec<(ResolvedValue, HashMap<String, bool>)>)> = (0..11)
1774            .map(|i| {
1775                let mut scenarios = Vec::new();
1776                collect_scenarios(&optional_wide(&format!("C{i:02}")), &HashMap::new(), 16, &mut scenarios);
1777                (i, scenarios)
1778            })
1779            .collect();
1780        assert!(!product_is_enumerable(&items), "2^11 combos of ~600-node members exceed the node bound");
1781        let small: Vec<(usize, Vec<(ResolvedValue, HashMap<String, bool>)>)> = items
1782            .iter()
1783            .map(|(i, scenarios)| {
1784                (
1785                    *i,
1786                    scenarios
1787                        .iter()
1788                        .map(|(_, a)| (ResolvedValue::Concrete { value: json!(1).into() }, a.clone()))
1789                        .collect(),
1790                )
1791            })
1792            .collect();
1793        assert!(product_is_enumerable(&small), "the same 2^11 combos of scalar members are enumerable");
1794    }
1795
1796    #[test]
1797    fn node_count_counts_every_resolved_node_once() {
1798        let value = ResolvedValue::Map {
1799            entries: vec![MapEntry {
1800                key: "k".into(),
1801                value: ResolvedValue::List {
1802                    items: vec![optional_item("C", "v"), ResolvedValue::Concrete { value: json!(1).into() }],
1803                },
1804            }],
1805        };
1806        assert_eq!(node_count(&value), 6, "map + list + conditional + its two leaves + the concrete item");
1807    }
1808
1809    #[test]
1810    fn scenario_source_path_selects_resolved_container_branch() {
1811        let list = ResolvedValue::Conditional {
1812            condition: "ChooseFirst".into(),
1813            if_true: Box::new(ResolvedValue::List {
1814                items: vec![ResolvedValue::Concrete { value: json!("first").into() }],
1815            }),
1816            if_false: Box::new(ResolvedValue::List {
1817                items: vec![ResolvedValue::Concrete { value: json!("second").into() }],
1818            }),
1819        };
1820        let object = ResolvedValue::Conditional {
1821            condition: "ChooseFirst".into(),
1822            if_true: Box::new(ResolvedValue::Map {
1823                entries: vec![MapEntry {
1824                    key: "Name".into(),
1825                    value: ResolvedValue::Concrete { value: json!("first").into() },
1826                }],
1827            }),
1828            if_false: Box::new(ResolvedValue::Map {
1829                entries: vec![MapEntry {
1830                    key: "Name".into(),
1831                    value: ResolvedValue::Concrete { value: json!("second").into() },
1832                }],
1833            }),
1834        };
1835        let conditions = HashMap::from([("ChooseFirst".to_string(), false)]);
1836
1837        assert_eq!(
1838            scenario_source_path_at(&list, "", &conditions, "Properties.Values"),
1839            Some("Properties.Values.Fn::If.2".to_string())
1840        );
1841        assert_eq!(
1842            scenario_source_path_at(&object, "", &conditions, "Properties.Configuration"),
1843            Some("Properties.Configuration.Fn::If.2".to_string())
1844        );
1845    }
1846}