Skip to main content

template_model/
resolved_value.rs

1use crate::consts::*;
2use crate::resolver::MapEntry;
3use crate::resolver::ResolvedValue;
4use std::collections::HashMap;
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 (segment, remaining) = segments.split_first()?;
138            let entry = entries.iter().find(|entry| entry.key == *segment)?;
139            scenario_source_path_segments(
140                &entry.value,
141                remaining,
142                conditions,
143                &append_source_path(source_path, segment),
144            )
145        }
146        ResolvedValue::List { items } => {
147            let (segment, remaining) = segments.split_first()?;
148            let index: usize = segment.parse().ok()?;
149            scenario_source_path_segments(
150                items.get(index)?,
151                remaining,
152                conditions,
153                &append_source_path(source_path, segment),
154            )
155        }
156        ResolvedValue::Concrete { value } => json_scenario_source_path(value, segments, source_path),
157        ResolvedValue::Enum { variants } => variants
158            .iter()
159            .find_map(|variant| scenario_source_path_segments(variant, segments, conditions, source_path)),
160        ResolvedValue::Reference { .. } | ResolvedValue::Dynamic { .. } | ResolvedValue::TypedDynamic { .. } => {
161            segments.is_empty().then(|| source_path.to_string())
162        }
163    }
164}
165
166fn json_scenario_source_path(value: &serde_json::Value, segments: &[&str], source_path: &str) -> Option<String> {
167    let Some((segment, remaining)) = segments.split_first() else {
168        return Some(source_path.to_string());
169    };
170    let child = match value {
171        serde_json::Value::Object(map) => map.get(*segment)?,
172        serde_json::Value::Array(items) => items.get(segment.parse::<usize>().ok()?)?,
173        _ => return None,
174    };
175    json_scenario_source_path(child, remaining, &append_source_path(source_path, segment))
176}
177
178fn append_source_path(source_path: &str, segment: &str) -> String {
179    if source_path.is_empty() { segment.to_string() } else { format!("{source_path}.{segment}") }
180}
181
182pub fn collect_condition_refs_from_resolved(val: &ResolvedValue, out: &mut Vec<String>) {
183    match val {
184        ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => {
185            out.push(cond.clone());
186            collect_condition_refs_from_resolved(t, out);
187            collect_condition_refs_from_resolved(f, out);
188        }
189        ResolvedValue::List { items } => {
190            for v in items {
191                collect_condition_refs_from_resolved(v, out);
192            }
193        }
194        ResolvedValue::Map { entries } => {
195            for e in entries {
196                collect_condition_refs_from_resolved(&e.value, out);
197            }
198        }
199        ResolvedValue::Enum { variants: vals } => {
200            for v in vals {
201                collect_condition_refs_from_resolved(v, out);
202            }
203        }
204        _ => {}
205    }
206}
207
208pub fn collect_conditional_nulls(val: &ResolvedValue, path: &str, out: &mut Vec<(String, String, bool)>) {
209    match val {
210        ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => {
211            let t_null = is_resolved_null(t);
212            let f_null = is_resolved_null(f);
213            if t_null {
214                out.push((path.to_string(), cond.clone(), true));
215            }
216            if f_null {
217                out.push((path.to_string(), cond.clone(), false));
218            }
219            if !t_null {
220                collect_conditional_nulls(t, path, out);
221            }
222            if !f_null {
223                collect_conditional_nulls(f, path, out);
224            }
225        }
226        ResolvedValue::Map { entries } => {
227            for e in entries {
228                collect_conditional_nulls(&e.value, &format!("{}.{}", path, e.key), out);
229            }
230        }
231        ResolvedValue::List { items } => {
232            for (i, v) in items.iter().enumerate() {
233                collect_conditional_nulls(v, &format!("{}.{}", path, i), out);
234            }
235        }
236        ResolvedValue::Enum { variants: vals } => {
237            for v in vals {
238                collect_conditional_nulls(v, path, out);
239            }
240        }
241        _ => {}
242    }
243}
244
245fn is_resolved_null(val: &ResolvedValue) -> bool {
246    match val {
247        ResolvedValue::Concrete { value: v } if v.is_null() => true,
248        ResolvedValue::Conditional { if_true: t, if_false: f, .. } => is_resolved_null(t) && is_resolved_null(f),
249        _ => false,
250    }
251}
252
253fn contains_scenario_branching(value: &ResolvedValue) -> bool {
254    match value {
255        ResolvedValue::Conditional { .. } | ResolvedValue::Enum { .. } => true,
256        ResolvedValue::List { items } => items.iter().any(contains_scenario_branching),
257        ResolvedValue::Map { entries } => entries.iter().any(|entry| contains_scenario_branching(&entry.value)),
258        ResolvedValue::Concrete { .. }
259        | ResolvedValue::Reference { .. }
260        | ResolvedValue::Dynamic { .. }
261        | ResolvedValue::TypedDynamic { .. } => false,
262    }
263}
264
265/// Recursively expands scenario branching in a resolved value, collecting all
266/// concrete possibilities with their condition assumptions.
267///
268/// Returns `true` when any expansion within the value tree was curtailed
269/// because the intermediate product exceeded `limit`.
270pub fn collect_scenarios(
271    val: &ResolvedValue,
272    assumptions: &HashMap<String, bool>,
273    limit: usize,
274    results: &mut Vec<(ResolvedValue, HashMap<String, bool>)>,
275) -> bool {
276    match val {
277        ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => {
278            if assumptions.contains_key(cond.as_str()) {
279                if assumptions[cond.as_str()] {
280                    collect_scenarios(t, assumptions, limit, results)
281                } else {
282                    collect_scenarios(f, assumptions, limit, results)
283                }
284            } else {
285                let mut true_assumptions = assumptions.clone();
286                true_assumptions.insert(cond.clone(), true);
287                let true_curtailed = collect_scenarios(t, &true_assumptions, limit, results);
288                if true_curtailed && results.len() >= limit {
289                    return true;
290                }
291                let mut false_assumptions = assumptions.clone();
292                false_assumptions.insert(cond.clone(), false);
293                let false_curtailed = collect_scenarios(f, &false_assumptions, limit, results);
294                true_curtailed || false_curtailed
295            }
296        }
297        ResolvedValue::Enum { variants } => {
298            let mut curtailed = false;
299            for variant in variants {
300                curtailed |= collect_scenarios(variant, assumptions, limit, results);
301                if curtailed && results.len() >= limit {
302                    break;
303                }
304            }
305            curtailed
306        }
307        ResolvedValue::List { items } => {
308            let has_branching = items.iter().any(contains_scenario_branching);
309            if has_branching {
310                expand_list_scenarios(items, assumptions, limit, results)
311            } else {
312                push_scenario(val, assumptions, limit, results)
313            }
314        }
315        ResolvedValue::Map { entries } => {
316            let has_branching = entries.iter().any(|entry| contains_scenario_branching(&entry.value));
317            if has_branching {
318                expand_map_scenarios(entries, assumptions, limit, results)
319            } else {
320                push_scenario(val, assumptions, limit, results)
321            }
322        }
323        _ => push_scenario(val, assumptions, limit, results),
324    }
325}
326
327fn push_scenario(
328    value: &ResolvedValue,
329    assumptions: &HashMap<String, bool>,
330    limit: usize,
331    results: &mut Vec<(ResolvedValue, HashMap<String, bool>)>,
332) -> bool {
333    if results.len() >= limit {
334        true
335    } else {
336        results.push((value.clone(), assumptions.clone()));
337        false
338    }
339}
340
341/// Expands deployment scenarios using the standard per-value limit and reports
342/// whether at least one possible scenario was omitted.
343pub fn collect_scenarios_signaled(
344    value: &ResolvedValue,
345    assumptions: &HashMap<String, bool>,
346    results: &mut Vec<(ResolvedValue, HashMap<String, bool>)>,
347    curtailed: &mut bool,
348) {
349    *curtailed |= collect_scenarios(value, assumptions, MAX_SCENARIO_COMBINATIONS, results);
350}
351
352pub fn contains_dynamic_resolved(rv: &ResolvedValue) -> bool {
353    match rv {
354        ResolvedValue::Dynamic { reason: _ } | ResolvedValue::TypedDynamic { .. } | ResolvedValue::Reference { .. } => {
355            true
356        }
357        ResolvedValue::List { items } => items.iter().any(contains_dynamic_resolved),
358        ResolvedValue::Map { entries } => entries.iter().any(|e| contains_dynamic_resolved(&e.value)),
359        ResolvedValue::Enum { variants: vals } => vals.iter().any(contains_dynamic_resolved),
360        ResolvedValue::Conditional { if_true: t, if_false: f, .. } => {
361            contains_dynamic_resolved(t) || contains_dynamic_resolved(f)
362        }
363        ResolvedValue::Concrete { value: v } => json_contains_markers(v),
364    }
365}
366
367pub fn json_contains_markers(v: &serde_json::Value) -> bool {
368    match v {
369        serde_json::Value::Object(m) => {
370            m.contains_key(MARKER_DYNAMIC)
371                || m.contains_key(MARKER_REF)
372                || m.contains_key(MARKER_INTRINSIC)
373                || m.contains_key(MARKER_CONDITIONAL)
374                || m.contains_key(MARKER_ENUM)
375                || m.values().any(json_contains_markers)
376        }
377        serde_json::Value::Array(a) => a.iter().any(json_contains_markers),
378        _ => false,
379    }
380}
381
382/// The shortest and longest a string value can be at deployment, or `None` when
383/// any possibility is unknown.
384///
385/// A length constraint may only be reported broken when it is broken for every
386/// possibility, so one unknown possibility withdraws the whole estimate instead
387/// of narrowing it. Bounds rather than a single number are what let a caller tell
388/// "every possibility is too long" from "every possibility is too short".
389pub fn estimate_resolved_string_length_bounds(val: &ResolvedValue) -> Option<(usize, usize)> {
390    match val {
391        ResolvedValue::Concrete { value: v } if v.is_string() => {
392            let length = v.as_str()?.chars().count();
393            Some((length, length))
394        }
395        // A partially resolved Sub or Join is a description of what could be
396        // worked out, not the value. Its length is only the value's length when
397        // nothing is still standing in for something unknown: a `${...}`
398        // interpolation expands to any length at deploy time, and a reference
399        // placeholder is internal text whose width says nothing about the value it
400        // stands for. Measuring either would report a length the template never
401        // produces.
402        ResolvedValue::Dynamic { reason: desc } if desc.starts_with(SUB_PARTIAL_PREFIX) => {
403            partial_length_bounds(&desc[SUB_PARTIAL_PREFIX.len()..])
404        }
405        ResolvedValue::Dynamic { reason: desc } if desc.starts_with(JOIN_PARTIAL_PREFIX) => {
406            partial_length_bounds(&desc[JOIN_PARTIAL_PREFIX.len()..])
407        }
408        ResolvedValue::Dynamic { reason: _ } => None,
409        ResolvedValue::Conditional { if_true: t, if_false: f, .. } => {
410            let (true_shortest, true_longest) = estimate_resolved_string_length_bounds(t)?;
411            let (false_shortest, false_longest) = estimate_resolved_string_length_bounds(f)?;
412            Some((true_shortest.min(false_shortest), true_longest.max(false_longest)))
413        }
414        ResolvedValue::Enum { variants } => {
415            let mut bounds: Option<(usize, usize)> = None;
416            for variant in variants {
417                let (shortest, longest) = estimate_resolved_string_length_bounds(variant)?;
418                bounds = Some(match bounds {
419                    Some((known_shortest, known_longest)) => (known_shortest.min(shortest), known_longest.max(longest)),
420                    None => (shortest, longest),
421                });
422            }
423            bounds
424        }
425        _ => None,
426    }
427}
428
429fn partial_length_bounds(partial: &str) -> Option<(usize, usize)> {
430    if has_interpolation_variable(partial)
431        || partial.contains(UNRESOLVED_REF_PLACEHOLDER_PREFIX)
432        || partial.contains(UNRESOLVED_DYNAMIC_PLACEHOLDER)
433    {
434        None
435    } else {
436        let length = partial.chars().count();
437        Some((length, length))
438    }
439}
440
441fn has_interpolation_variable(template: &str) -> bool {
442    let bytes = template.as_bytes();
443    let mut i = 0;
444    while i + 1 < bytes.len() {
445        if bytes[i] == b'$'
446            && bytes[i + 1] == b'{'
447            && let Some(_end) = template[i + 2..].find('}')
448        {
449            return true;
450        }
451        i += 1;
452    }
453    false
454}
455
456fn json_value_at_path<'a>(val: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
457    let mut current = val;
458    for segment in path.split('.') {
459        match current {
460            serde_json::Value::Object(map) => {
461                current = map.get(segment)?;
462            }
463            serde_json::Value::Array(arr) => {
464                if segment == "{}" {
465                    return None;
466                }
467                let idx: usize = segment.parse().ok()?;
468                current = arr.get(idx)?;
469            }
470            _ => return None,
471        }
472    }
473    Some(current)
474}
475
476fn json_values_matching_wildcard_path(val: &serde_json::Value, path: &str) -> Vec<serde_json::Value> {
477    let mut segments: Vec<&str> = path.split('.').collect();
478    if segments.is_empty() {
479        return vec![val.clone()];
480    }
481    let key = segments.remove(0);
482    let remaining = if segments.is_empty() { String::new() } else { segments.join(".") };
483    match val {
484        serde_json::Value::Object(map) => {
485            if let Some(child) = map.get(key) {
486                if remaining.is_empty() {
487                    vec![child.clone()]
488                } else {
489                    json_values_matching_wildcard_path(child, &remaining)
490                }
491            } else {
492                vec![]
493            }
494        }
495        serde_json::Value::Array(arr) => {
496            if key == "{}" {
497                arr.iter()
498                    .flat_map(|item| {
499                        if remaining.is_empty() {
500                            vec![item.clone()]
501                        } else {
502                            json_values_matching_wildcard_path(item, &remaining)
503                        }
504                    })
505                    .collect()
506            } else if let Ok(idx) = key.parse::<usize>() {
507                arr.get(idx)
508                    .map(|child| {
509                        if remaining.is_empty() {
510                            vec![child.clone()]
511                        } else {
512                            json_values_matching_wildcard_path(child, &remaining)
513                        }
514                    })
515                    .unwrap_or_default()
516            } else {
517                vec![]
518            }
519        }
520        _ => vec![],
521    }
522}
523
524/// Generic cartesian product of scenario expansions with conflict detection.
525/// Each item provides its own set of scenarios. The `build_result` closure
526/// assembles the final `ResolvedValue` from the collected per-item values.
527///
528/// Returns `true` when the product had more than `limit` compatible scenarios.
529/// Expansion remains bounded to `limit` partial combinations after every item,
530/// but every retained combination continues through every remaining item so no
531/// returned list or map is structurally incomplete.
532fn expand_cartesian_scenarios<T: Clone>(
533    items: &[(T, Vec<(ResolvedValue, HashMap<String, bool>)>)],
534    base_assumptions: &HashMap<String, bool>,
535    limit: usize,
536    build_result: impl Fn(Vec<(T, ResolvedValue)>) -> ResolvedValue,
537    results: &mut Vec<(ResolvedValue, HashMap<String, bool>)>,
538) -> bool {
539    if limit == 0 {
540        return !items.is_empty();
541    }
542
543    let mut combos: Vec<(Vec<(T, ResolvedValue)>, HashMap<String, bool>)> =
544        vec![(Vec::new(), base_assumptions.clone())];
545    let mut curtailed = false;
546    for (key, item_scenarios) in items {
547        let mut new_combos = Vec::new();
548        'partials: for (partial, partial_assumptions) in &combos {
549            for (value, value_assumptions) in item_scenarios {
550                let mut merged = partial_assumptions.clone();
551                let mut conflict = false;
552                for (condition, expected) in value_assumptions {
553                    if let Some(existing) = merged.get(condition) {
554                        if existing != expected {
555                            conflict = true;
556                            break;
557                        }
558                    } else {
559                        merged.insert(condition.clone(), *expected);
560                    }
561                }
562                if conflict {
563                    continue;
564                }
565                if new_combos.len() == limit {
566                    curtailed = true;
567                    break 'partials;
568                }
569                let mut collected = partial.clone();
570                collected.push((key.clone(), value.clone()));
571                new_combos.push((collected, merged));
572            }
573        }
574        combos = new_combos;
575    }
576
577    results.extend(combos.into_iter().map(|(collected, assumptions)| (build_result(collected), assumptions)));
578    curtailed
579}
580
581fn expand_list_scenarios(
582    items: &[ResolvedValue],
583    base_assumptions: &HashMap<String, bool>,
584    limit: usize,
585    results: &mut Vec<(ResolvedValue, HashMap<String, bool>)>,
586) -> bool {
587    let remaining = limit.saturating_sub(results.len());
588    if remaining == 0 {
589        return true;
590    }
591
592    let mut nested_curtailed = false;
593    let mut prepared = Vec::with_capacity(items.len());
594    for (index, item) in items.iter().enumerate() {
595        let mut scenarios = Vec::new();
596        nested_curtailed |= collect_scenarios(item, base_assumptions, remaining, &mut scenarios);
597        if scenarios.is_empty() {
598            scenarios.push((item.clone(), base_assumptions.clone()));
599        }
600        prepared.push((index, scenarios));
601    }
602    let product_curtailed = expand_cartesian_scenarios(
603        &prepared,
604        base_assumptions,
605        remaining,
606        |collected| ResolvedValue::List { items: collected.into_iter().map(|(_, value)| value).collect() },
607        results,
608    );
609    nested_curtailed || product_curtailed
610}
611
612fn expand_map_scenarios(
613    entries: &[MapEntry],
614    base_assumptions: &HashMap<String, bool>,
615    limit: usize,
616    results: &mut Vec<(ResolvedValue, HashMap<String, bool>)>,
617) -> bool {
618    let remaining = limit.saturating_sub(results.len());
619    if remaining == 0 {
620        return true;
621    }
622
623    let mut nested_curtailed = false;
624    let mut prepared = Vec::with_capacity(entries.len());
625    for entry in entries {
626        let mut scenarios = Vec::new();
627        nested_curtailed |= collect_scenarios(&entry.value, base_assumptions, remaining, &mut scenarios);
628        if scenarios.is_empty() {
629            scenarios.push((entry.value.clone(), base_assumptions.clone()));
630        }
631        prepared.push((entry.key.clone(), scenarios));
632    }
633    let product_curtailed = expand_cartesian_scenarios(
634        &prepared,
635        base_assumptions,
636        remaining,
637        |collected| ResolvedValue::Map {
638            entries: collected.into_iter().map(|(key, value)| MapEntry { key, value }).collect(),
639        },
640        results,
641    );
642    nested_curtailed || product_curtailed
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648    use crate::resolver::{MapEntry, RefKind, ResolvedValue};
649    use serde_json::json;
650
651    #[test]
652    fn path_into_concrete_object() {
653        let val = ResolvedValue::Concrete { value: json!({"a": {"b": "found"}}).into() };
654        match resolved_value_at_path(&val, "a.b") {
655            Some(ResolvedValue::Concrete { value: v }) => assert_eq!(v.0, json!("found")),
656            other => panic!("expected Concrete(\"found\"), got {:?}", other),
657        }
658    }
659
660    #[test]
661    fn path_into_concrete_array() {
662        let val = ResolvedValue::Concrete { value: json!({"items": ["x", "y", "z"]}).into() };
663        match resolved_value_at_path(&val, "items.1") {
664            Some(ResolvedValue::Concrete { value: v }) => assert_eq!(v.0, json!("y")),
665            other => panic!("expected Concrete(\"y\"), got {:?}", other),
666        }
667    }
668
669    #[test]
670    fn path_missing_key_returns_none() {
671        let val = ResolvedValue::Concrete { value: json!({"a": 1}).into() };
672        assert!(resolved_value_at_path(&val, "b").is_none(), "missing key should return None");
673    }
674
675    #[test]
676    fn path_into_resolved_map() {
677        let val = ResolvedValue::Map {
678            entries: vec![MapEntry {
679                key: "key".into(),
680                value: ResolvedValue::Concrete { value: json!("val").into() },
681            }],
682        };
683        match resolved_value_at_path(&val, "key") {
684            Some(ResolvedValue::Concrete { value: v }) => assert_eq!(v.0, json!("val")),
685            other => panic!("expected Concrete, got {:?}", other),
686        }
687    }
688
689    #[test]
690    fn path_into_resolved_list_by_index() {
691        let val = ResolvedValue::List {
692            items: vec![
693                ResolvedValue::Concrete { value: json!("a").into() },
694                ResolvedValue::Concrete { value: json!("b").into() },
695            ],
696        };
697        match resolved_value_at_path(&val, "1") {
698            Some(ResolvedValue::Concrete { value: v }) => assert_eq!(v.0, json!("b")),
699            other => panic!("expected Concrete(\"b\"), got {:?}", other),
700        }
701    }
702
703    #[test]
704    fn path_through_conditional_both_branches() {
705        let val = ResolvedValue::Conditional {
706            condition: "C".into(),
707            if_true: Box::new(ResolvedValue::Concrete { value: json!({"x": "true_val"}).into() }),
708            if_false: Box::new(ResolvedValue::Concrete { value: json!({"x": "false_val"}).into() }),
709        };
710        match resolved_value_at_path(&val, "x") {
711            Some(ResolvedValue::Conditional { condition: c, if_true: t, if_false: f }) => {
712                assert_eq!(c, "C");
713                assert!(matches!(t.as_ref(), ResolvedValue::Concrete { value: v } if v.0 == json!("true_val")));
714                assert!(matches!(f.as_ref(), ResolvedValue::Concrete { value: v } if v.0 == json!("false_val")));
715            }
716            other => panic!("expected Conditional, got {:?}", other),
717        }
718    }
719
720    #[test]
721    fn path_through_conditional_one_branch_missing() {
722        let val = ResolvedValue::Conditional {
723            condition: "C".into(),
724            if_true: Box::new(ResolvedValue::Concrete { value: json!({"x": 1}).into() }),
725            if_false: Box::new(ResolvedValue::Concrete { value: json!({"y": 2}).into() }),
726        };
727        match resolved_value_at_path(&val, "x") {
728            Some(ResolvedValue::Conditional { if_true: t, if_false: f, .. }) => {
729                assert!(matches!(t.as_ref(), ResolvedValue::Concrete { value: v } if v.0 == json!(1)));
730                assert!(matches!(f.as_ref(), ResolvedValue::Dynamic { reason: _ }));
731            }
732            other => panic!("expected Conditional with Dynamic false branch, got {:?}", other),
733        }
734    }
735
736    #[test]
737    fn path_through_enum_collects_variants() {
738        let val = ResolvedValue::Enum {
739            variants: vec![
740                ResolvedValue::Concrete { value: json!({"k": "v1"}).into() },
741                ResolvedValue::Concrete { value: json!({"k": "v2"}).into() },
742            ],
743        };
744        match resolved_value_at_path(&val, "k") {
745            Some(ResolvedValue::Enum { variants: vals }) => assert_eq!(vals.len(), 2),
746            other => panic!("expected Enum, got {:?}", other),
747        }
748    }
749
750    #[test]
751    fn wildcard_path_on_concrete_array() {
752        let val = ResolvedValue::Concrete {
753            value: json!({
754                "items": [{"name": "a"}, {"name": "b"}]
755            })
756            .into(),
757        };
758        match resolved_value_at_path(&val, "items.{}.name") {
759            Some(ResolvedValue::Enum { variants: vals }) => {
760                assert_eq!(vals.len(), 2);
761            }
762            other => panic!("expected Enum from wildcard, got {:?}", other),
763        }
764    }
765
766    #[test]
767    fn wildcard_path_on_resolved_list() {
768        let val = ResolvedValue::List {
769            items: vec![
770                ResolvedValue::Map {
771                    entries: vec![MapEntry {
772                        key: "n".into(),
773                        value: ResolvedValue::Concrete { value: json!("x").into() },
774                    }],
775                },
776                ResolvedValue::Map {
777                    entries: vec![MapEntry {
778                        key: "n".into(),
779                        value: ResolvedValue::Concrete { value: json!("y").into() },
780                    }],
781                },
782            ],
783        };
784        match resolved_value_at_path(&val, "{}.n") {
785            Some(ResolvedValue::Enum { variants: vals }) => assert_eq!(vals.len(), 2),
786            other => panic!("expected Enum from wildcard on List, got {:?}", other),
787        }
788    }
789
790    #[test]
791    fn path_into_dynamic_returns_none() {
792        let val = ResolvedValue::Dynamic { reason: "unknown".into() };
793        assert!(resolved_value_at_path(&val, "anything").is_none(), "path into Dynamic should return None");
794    }
795
796    #[test]
797    fn path_into_reference_returns_none() {
798        let val = ResolvedValue::Reference { target: "R".into(), kind: RefKind::Ref };
799        assert!(resolved_value_at_path(&val, "sub").is_none(), "path into Reference should return None");
800    }
801
802    #[test]
803    fn collect_condition_refs_from_nested() {
804        let val = ResolvedValue::Conditional {
805            condition: "C1".into(),
806            if_true: Box::new(ResolvedValue::List {
807                items: vec![ResolvedValue::Conditional {
808                    condition: "C2".into(),
809                    if_true: Box::new(ResolvedValue::Concrete { value: json!(1).into() }),
810                    if_false: Box::new(ResolvedValue::Concrete { value: json!(2).into() }),
811                }],
812            }),
813            if_false: Box::new(ResolvedValue::Concrete { value: json!(3).into() }),
814        };
815        let mut refs = Vec::new();
816        collect_condition_refs_from_resolved(&val, &mut refs);
817        refs.sort();
818        assert_eq!(refs, vec!["C1", "C2"]);
819    }
820
821    #[test]
822    fn effective_path_removes_conditional_branch_segments() {
823        assert_eq!(
824            effective_path_from_scenario_source_path("Properties.ResourceRecords.Fn::If.1.0"),
825            Some("Properties.ResourceRecords.0".to_string())
826        );
827    }
828
829    #[test]
830    fn effective_path_removes_nested_conditional_branch_segments() {
831        assert_eq!(
832            effective_path_from_scenario_source_path("Properties.Fn::If.2.Records.0.Fn::If.1.Value"),
833            Some("Properties.Records.0.Value".to_string())
834        );
835    }
836
837    #[test]
838    fn effective_path_preserves_other_intrinsic_segments() {
839        assert_eq!(
840            effective_path_from_scenario_source_path("Properties.Value.Fn::GetAtt.1"),
841            Some("Properties.Value.Fn::GetAtt.1".to_string())
842        );
843        assert_eq!(effective_path_from_scenario_source_path("Properties.Value.Fn::If.0"), None);
844        assert_eq!(effective_path_from_scenario_source_path("Properties..Value"), None);
845    }
846
847    #[test]
848    fn scenario_source_path_selects_conditional_list_branch() {
849        let value = ResolvedValue::Conditional {
850            condition: "ChooseFirst".into(),
851            if_true: Box::new(ResolvedValue::Concrete { value: json!(["bad"]).into() }),
852            if_false: Box::new(ResolvedValue::Concrete { value: json!(["good"]).into() }),
853        };
854        let mut conditions = HashMap::new();
855        conditions.insert("ChooseFirst".to_string(), true);
856        assert_eq!(
857            scenario_source_path_at(&value, "0", &conditions, "Properties.ResourceRecords"),
858            Some("Properties.ResourceRecords.Fn::If.1.0".to_string())
859        );
860        conditions.insert("ChooseFirst".to_string(), false);
861        assert_eq!(
862            scenario_source_path_at(&value, "0", &conditions, "Properties.ResourceRecords"),
863            Some("Properties.ResourceRecords.Fn::If.2.0".to_string())
864        );
865    }
866
867    #[test]
868    fn scenario_source_path_selects_conditional_list_item_branch() {
869        let value = ResolvedValue::List {
870            items: vec![ResolvedValue::Conditional {
871                condition: "UseValue".into(),
872                if_true: Box::new(ResolvedValue::Concrete { value: json!("value").into() }),
873                if_false: Box::new(ResolvedValue::Concrete { value: json!(null).into() }),
874            }],
875        };
876        let conditions = HashMap::from([("UseValue".to_string(), true)]);
877        assert_eq!(
878            scenario_source_path_at(&value, "0", &conditions, "Properties.ResourceRecords"),
879            Some("Properties.ResourceRecords.0.Fn::If.1".to_string())
880        );
881    }
882
883    #[test]
884    fn collect_conditional_nulls_detects_null_branch() {
885        let val = ResolvedValue::Conditional {
886            condition: "C".into(),
887            if_true: Box::new(ResolvedValue::Concrete { value: json!(null).into() }),
888            if_false: Box::new(ResolvedValue::Concrete { value: json!("ok").into() }),
889        };
890        let mut nulls = Vec::new();
891        collect_conditional_nulls(&val, "prop", &mut nulls);
892        assert_eq!(nulls.len(), 1);
893        assert_eq!(nulls[0], ("prop".to_string(), "C".to_string(), true));
894    }
895
896    #[test]
897    fn collect_scenarios_concrete_single() {
898        let val = ResolvedValue::Concrete { value: json!("hello").into() };
899        let mut results = Vec::new();
900        collect_scenarios(&val, &HashMap::new(), MAX_SCENARIO_COMBINATIONS, &mut results);
901        assert_eq!(results.len(), 1);
902        assert!(matches!(&results[0].0, ResolvedValue::Concrete { value: v } if v.0 == json!("hello")));
903    }
904
905    #[test]
906    fn collect_scenarios_conditional_splits() {
907        let val = ResolvedValue::Conditional {
908            condition: "C".into(),
909            if_true: Box::new(ResolvedValue::Concrete { value: json!(1).into() }),
910            if_false: Box::new(ResolvedValue::Concrete { value: json!(2).into() }),
911        };
912        let mut results = Vec::new();
913        collect_scenarios(&val, &HashMap::new(), MAX_SCENARIO_COMBINATIONS, &mut results);
914        assert_eq!(results.len(), 2);
915        let (_, conds_true) = &results[0];
916        assert_eq!(conds_true.get("C"), Some(&true));
917        let (_, conds_false) = &results[1];
918        assert_eq!(conds_false.get("C"), Some(&false));
919    }
920
921    #[test]
922    fn collect_scenarios_expands_nested_conditional() {
923        let val = ResolvedValue::Map {
924            entries: vec![MapEntry {
925                key: "Statement".into(),
926                value: ResolvedValue::List {
927                    items: vec![ResolvedValue::Map {
928                        entries: vec![MapEntry {
929                            key: "Resource".into(),
930                            value: ResolvedValue::Conditional {
931                                condition: "C".into(),
932                                if_true: Box::new(ResolvedValue::Concrete { value: json!("first").into() }),
933                                if_false: Box::new(ResolvedValue::Concrete { value: json!("second").into() }),
934                            },
935                        }],
936                    }],
937                },
938            }],
939        };
940        let mut results = Vec::new();
941        collect_scenarios(&val, &HashMap::new(), MAX_SCENARIO_COMBINATIONS, &mut results);
942
943        assert_eq!(results.len(), 2);
944        assert_eq!(results[0].1.get("C"), Some(&true));
945        assert_eq!(results[1].1.get("C"), Some(&false));
946    }
947
948    #[test]
949    fn collect_scenarios_enum_expands() {
950        let val = ResolvedValue::Enum {
951            variants: vec![
952                ResolvedValue::Concrete { value: json!("a").into() },
953                ResolvedValue::Concrete { value: json!("b").into() },
954            ],
955        };
956        let mut results = Vec::new();
957        collect_scenarios(&val, &HashMap::new(), MAX_SCENARIO_COMBINATIONS, &mut results);
958        assert_eq!(results.len(), 2);
959    }
960
961    #[test]
962    fn collect_scenarios_respects_existing_assumptions() {
963        let val = ResolvedValue::Conditional {
964            condition: "C".into(),
965            if_true: Box::new(ResolvedValue::Concrete { value: json!(1).into() }),
966            if_false: Box::new(ResolvedValue::Concrete { value: json!(2).into() }),
967        };
968        let mut assumptions = HashMap::new();
969        assumptions.insert("C".to_string(), true);
970        let mut results = Vec::new();
971        collect_scenarios(&val, &assumptions, MAX_SCENARIO_COMBINATIONS, &mut results);
972        assert_eq!(results.len(), 1);
973        assert!(matches!(&results[0].0, ResolvedValue::Concrete { value: v } if v.0 == json!(1)));
974    }
975
976    #[test]
977    fn direct_enum_at_scenario_limit_is_not_curtailed() {
978        let value = ResolvedValue::Enum {
979            variants: (0..MAX_SCENARIO_COMBINATIONS)
980                .map(|index| ResolvedValue::Concrete { value: json!(index).into() })
981                .collect(),
982        };
983        let mut scenarios = Vec::new();
984        let mut curtailed = false;
985
986        collect_scenarios_signaled(&value, &HashMap::new(), &mut scenarios, &mut curtailed);
987
988        assert_eq!(scenarios.len(), MAX_SCENARIO_COMBINATIONS);
989        assert!(!curtailed, "an exact-fit direct enum must not report omitted scenarios");
990    }
991
992    #[test]
993    fn conditional_branch_one_over_scenario_limit_is_curtailed() {
994        let value = ResolvedValue::Conditional {
995            condition: "UseEnum".into(),
996            if_true: Box::new(ResolvedValue::Enum {
997                variants: (0..MAX_SCENARIO_COMBINATIONS)
998                    .map(|index| ResolvedValue::Concrete { value: json!(index).into() })
999                    .collect(),
1000            }),
1001            if_false: Box::new(ResolvedValue::Concrete { value: json!("fallback").into() }),
1002        };
1003        let mut scenarios = Vec::new();
1004        let mut curtailed = false;
1005
1006        collect_scenarios_signaled(&value, &HashMap::new(), &mut scenarios, &mut curtailed);
1007
1008        assert_eq!(scenarios.len(), MAX_SCENARIO_COMBINATIONS);
1009        assert!(curtailed, "the first omitted conditional-branch scenario must be reported");
1010        assert!(scenarios.iter().all(|(_, conditions)| conditions.get("UseEnum") == Some(&true)));
1011    }
1012
1013    #[test]
1014    fn contains_dynamic_detects_nested_dynamic() {
1015        let val = ResolvedValue::List {
1016            items: vec![
1017                ResolvedValue::Concrete { value: json!("ok").into() },
1018                ResolvedValue::Dynamic { reason: "unknown".into() },
1019            ],
1020        };
1021        assert!(contains_dynamic_resolved(&val));
1022    }
1023
1024    #[test]
1025    fn contains_dynamic_false_for_all_concrete() {
1026        let val = ResolvedValue::Map {
1027            entries: vec![MapEntry { key: "a".into(), value: ResolvedValue::Concrete { value: json!(1).into() } }],
1028        };
1029        assert!(!contains_dynamic_resolved(&val));
1030    }
1031
1032    #[test]
1033    fn contains_dynamic_detects_typed_dynamic() {
1034        let val = ResolvedValue::TypedDynamic { reason: "param".into(), param_type: "String".into() };
1035        assert!(contains_dynamic_resolved(&val));
1036    }
1037
1038    #[test]
1039    fn contains_dynamic_detects_reference() {
1040        let val = ResolvedValue::Reference { target: "R".into(), kind: RefKind::Ref };
1041        assert!(contains_dynamic_resolved(&val));
1042    }
1043
1044    #[test]
1045    fn json_contains_markers_detects_dynamic_marker() {
1046        let val = json!({MARKER_DYNAMIC: "reason"});
1047        assert!(json_contains_markers(&val));
1048    }
1049
1050    #[test]
1051    fn json_contains_markers_false_for_plain() {
1052        assert!(!json_contains_markers(&json!({"key": "value"})));
1053        assert!(!json_contains_markers(&json!("string")));
1054        assert!(!json_contains_markers(&json!(42)));
1055    }
1056
1057    #[test]
1058    fn bounds_of_a_literal_are_its_own_length() {
1059        let val = ResolvedValue::Concrete { value: json!("hello").into() };
1060        assert_eq!(estimate_resolved_string_length_bounds(&val), Some((5, 5)));
1061    }
1062
1063    #[test]
1064    fn bounds_of_a_conditional_span_both_branches() {
1065        let val = ResolvedValue::Conditional {
1066            condition: "C".into(),
1067            if_true: Box::new(ResolvedValue::Concrete { value: json!("short").into() }),
1068            if_false: Box::new(ResolvedValue::Concrete { value: json!("much longer string").into() }),
1069        };
1070        assert_eq!(estimate_resolved_string_length_bounds(&val), Some((5, "much longer string".len())));
1071    }
1072
1073    #[test]
1074    fn bounds_of_a_conditional_are_absent_when_one_branch_is_unknown() {
1075        // The deployment may take the unknown branch, so no length holds for every
1076        // possibility.
1077        let val = ResolvedValue::Conditional {
1078            condition: "C".into(),
1079            if_true: Box::new(ResolvedValue::Concrete { value: json!("short").into() }),
1080            if_false: Box::new(ResolvedValue::Dynamic { reason: "unknown".into() }),
1081        };
1082        assert_eq!(estimate_resolved_string_length_bounds(&val), None);
1083    }
1084
1085    #[test]
1086    fn bounds_of_allowed_values_span_every_choice() {
1087        let val = ResolvedValue::Enum {
1088            variants: vec![
1089                ResolvedValue::Concrete { value: json!("ab").into() },
1090                ResolvedValue::Concrete { value: json!("abcdef").into() },
1091            ],
1092        };
1093        assert_eq!(estimate_resolved_string_length_bounds(&val), Some((2, 6)));
1094    }
1095
1096    #[test]
1097    fn bounds_of_allowed_values_are_absent_when_one_choice_is_unknown() {
1098        let val = ResolvedValue::Enum {
1099            variants: vec![
1100                ResolvedValue::Concrete { value: json!("ab").into() },
1101                ResolvedValue::Dynamic { reason: "unknown".into() },
1102            ],
1103        };
1104        assert_eq!(estimate_resolved_string_length_bounds(&val), None);
1105    }
1106
1107    #[test]
1108    fn bounds_of_a_sub_with_interpolation_are_absent() {
1109        // A `${...}` interpolation expands to any length at deploy time, so the
1110        // literal portion is not a lower bound on the value.
1111        let val = ResolvedValue::Dynamic { reason: "Sub:arn:aws:s3:::${BucketName}".into() };
1112        assert_eq!(estimate_resolved_string_length_bounds(&val), None);
1113    }
1114
1115    #[test]
1116    fn bounds_of_a_fully_substituted_sub_are_its_length() {
1117        let val = ResolvedValue::Dynamic { reason: "Sub:no-variables-here".into() };
1118        assert_eq!(
1119            estimate_resolved_string_length_bounds(&val),
1120            Some(("no-variables-here".len(), "no-variables-here".len()))
1121        );
1122    }
1123
1124    #[test]
1125    fn bounds_are_absent_for_a_non_string_and_an_opaque_value() {
1126        assert_eq!(
1127            estimate_resolved_string_length_bounds(&ResolvedValue::Concrete { value: json!(42).into() }),
1128            None,
1129            "a number has no string length"
1130        );
1131        assert_eq!(
1132            estimate_resolved_string_length_bounds(&ResolvedValue::Dynamic { reason: "unknown".into() }),
1133            None,
1134            "an opaque value has no known length"
1135        );
1136    }
1137
1138    #[test]
1139    fn bounds_of_a_join_with_an_unresolved_reference_are_absent() {
1140        // The placeholder is internal text standing in for a value that is only
1141        // known at deployment. Measuring it reported the width of the placeholder
1142        // as the length of the value, which produced length violations citing a
1143        // number the template never produces.
1144        let val = ResolvedValue::Dynamic {
1145            reason: format!("{JOIN_PARTIAL_PREFIX}prefix-{UNRESOLVED_REF_PLACEHOLDER_PREFIX}Other}}-suffix"),
1146        };
1147        assert_eq!(estimate_resolved_string_length_bounds(&val), None, "a placeholder has no measurable length");
1148    }
1149
1150    #[test]
1151    fn bounds_of_a_join_with_an_opaque_deploy_time_value_are_absent() {
1152        let val = ResolvedValue::Dynamic {
1153            reason: format!("{JOIN_PARTIAL_PREFIX}prefix-{UNRESOLVED_DYNAMIC_PLACEHOLDER}-suffix"),
1154        };
1155        assert_eq!(
1156            estimate_resolved_string_length_bounds(&val),
1157            None,
1158            "a deploy-time placeholder has no measurable length"
1159        );
1160    }
1161
1162    #[test]
1163    fn bounds_of_a_join_of_known_parts_are_its_length() {
1164        let val = ResolvedValue::Dynamic { reason: "Join:prefix-middle-suffix".into() };
1165        assert_eq!(
1166            estimate_resolved_string_length_bounds(&val),
1167            Some(("prefix-middle-suffix".len(), "prefix-middle-suffix".len()))
1168        );
1169    }
1170
1171    #[test]
1172    fn bounds_of_a_sub_with_an_unresolved_reference_are_absent() {
1173        let val = ResolvedValue::Dynamic { reason: "Sub:name-{ref:Other}".into() };
1174        assert_eq!(estimate_resolved_string_length_bounds(&val), None, "a placeholder has no measurable length");
1175    }
1176
1177    // --- Scenario expansion curtailment tests ---
1178
1179    /// Helper: builds a map with `n` entries, each containing an independent
1180    /// conditional so that full expansion produces 2^n scenarios. Uses a small
1181    /// limit to test curtailment without allocating millions of scenarios.
1182    fn make_branching_map(n: usize) -> ResolvedValue {
1183        let entries: Vec<MapEntry> = (0..n)
1184            .map(|i| MapEntry {
1185                key: format!("key{i}"),
1186                value: ResolvedValue::Conditional {
1187                    condition: format!("C{i}"),
1188                    if_true: Box::new(ResolvedValue::Concrete { value: json!(format!("true{i}")).into() }),
1189                    if_false: Box::new(ResolvedValue::Concrete { value: json!(format!("false{i}")).into() }),
1190                },
1191            })
1192            .collect();
1193        ResolvedValue::Map { entries }
1194    }
1195
1196    /// Helper: builds a list with `n` items, each containing an independent
1197    /// conditional so that full expansion produces 2^n scenarios.
1198    fn make_branching_list(n: usize) -> ResolvedValue {
1199        let items: Vec<ResolvedValue> = (0..n)
1200            .map(|i| ResolvedValue::Conditional {
1201                condition: format!("C{i}"),
1202                if_true: Box::new(ResolvedValue::Concrete { value: json!(format!("true{i}")).into() }),
1203                if_false: Box::new(ResolvedValue::Concrete { value: json!(format!("false{i}")).into() }),
1204            })
1205            .collect();
1206        ResolvedValue::List { items }
1207    }
1208
1209    #[test]
1210    fn exact_limit_expansion_does_not_mark_curtailment() {
1211        // 4 independent conditions → 2^4 = 16 scenarios. With limit=16 the
1212        // expansion is exactly at the boundary and must NOT be reported as
1213        // curtailed.
1214        let val = make_branching_map(4);
1215        let mut results = Vec::new();
1216        let curtailed = collect_scenarios(&val, &HashMap::new(), 16, &mut results);
1217        assert!(!curtailed, "exact-limit expansion must not be curtailed");
1218        assert_eq!(results.len(), 16, "all 2^4 scenarios must be produced");
1219    }
1220
1221    #[test]
1222    fn over_limit_expansion_marks_curtailment() {
1223        // 5 independent conditions → 2^5 = 32 scenarios. With limit=16 the
1224        // product exceeds the limit and must be reported as curtailed.
1225        let val = make_branching_map(5);
1226        let mut results = Vec::new();
1227        let curtailed = collect_scenarios(&val, &HashMap::new(), 16, &mut results);
1228        assert!(curtailed, "over-limit expansion must be curtailed");
1229        assert!(results.len() <= 16, "at most `limit` scenarios may be returned; got {}", results.len());
1230    }
1231
1232    #[test]
1233    fn curtailed_map_scenarios_are_structurally_complete() {
1234        // With 5 entries and limit=4, expansion is curtailed but every returned
1235        // scenario must have all 5 map keys present.
1236        let val = make_branching_map(5);
1237        let mut results = Vec::new();
1238        let curtailed = collect_scenarios(&val, &HashMap::new(), 4, &mut results);
1239        assert!(curtailed, "should be curtailed with limit=4 and 5 conditions");
1240        for (scenario, _) in &results {
1241            match scenario {
1242                ResolvedValue::Map { entries } => {
1243                    assert_eq!(
1244                        entries.len(),
1245                        5,
1246                        "every returned map scenario must have all 5 entries; got {}: {:?}",
1247                        entries.len(),
1248                        entries.iter().map(|e| &e.key).collect::<Vec<_>>()
1249                    );
1250                }
1251                other => panic!("expected Map, got {:?}", other),
1252            }
1253        }
1254    }
1255
1256    #[test]
1257    fn curtailed_list_scenarios_are_structurally_complete() {
1258        // With 5 items and limit=4, expansion is curtailed but every returned
1259        // scenario must have all 5 list elements present.
1260        let val = make_branching_list(5);
1261        let mut results = Vec::new();
1262        let curtailed = collect_scenarios(&val, &HashMap::new(), 4, &mut results);
1263        assert!(curtailed, "should be curtailed with limit=4 and 5 conditions");
1264        for (scenario, _) in &results {
1265            match scenario {
1266                ResolvedValue::List { items } => {
1267                    assert_eq!(
1268                        items.len(),
1269                        5,
1270                        "every returned list scenario must have all 5 items; got {}",
1271                        items.len()
1272                    );
1273                }
1274                other => panic!("expected List, got {:?}", other),
1275            }
1276        }
1277    }
1278
1279    #[test]
1280    fn nested_curtailment_is_propagated_to_the_root() {
1281        let val = ResolvedValue::List { items: vec![make_branching_map(3)] };
1282        let mut results = Vec::new();
1283        let curtailed = collect_scenarios(&val, &HashMap::new(), 4, &mut results);
1284        assert!(curtailed, "curtailment inside a nested map must be visible to the root collector");
1285        assert_eq!(results.len(), 4);
1286        for (scenario, _) in results {
1287            let ResolvedValue::List { items } = scenario else {
1288                panic!("expected outer list scenario");
1289            };
1290            assert_eq!(items.len(), 1);
1291            let ResolvedValue::Map { entries } = &items[0] else {
1292                panic!("expected nested map scenario");
1293            };
1294            assert_eq!(entries.len(), 3, "nested scenarios must remain structurally complete");
1295        }
1296    }
1297
1298    #[test]
1299    fn enum_expansion_obeys_limit_and_exact_boundary() {
1300        let four = ResolvedValue::Enum {
1301            variants: (0..4).map(|value| ResolvedValue::Concrete { value: json!(value).into() }).collect(),
1302        };
1303        let mut exact_results = Vec::new();
1304        assert!(!collect_scenarios(&four, &HashMap::new(), 4, &mut exact_results));
1305        assert_eq!(exact_results.len(), 4);
1306
1307        let five = ResolvedValue::Enum {
1308            variants: (0..5).map(|value| ResolvedValue::Concrete { value: json!(value).into() }).collect(),
1309        };
1310        let mut limited_results = Vec::new();
1311        assert!(collect_scenarios(&five, &HashMap::new(), 4, &mut limited_results));
1312        assert_eq!(limited_results.len(), 4);
1313    }
1314
1315    #[test]
1316    fn expand_cartesian_returns_false_at_exact_limit() {
1317        // Directly test the inner helper: 2 items × 2 scenarios each → 4 combos.
1318        // With limit=4 it should NOT be curtailed.
1319        let a_val = ResolvedValue::Concrete { value: json!("a").into() };
1320        let b_val = ResolvedValue::Concrete { value: json!("b").into() };
1321        let items: Vec<(usize, Vec<(ResolvedValue, HashMap<String, bool>)>)> = vec![
1322            (0, vec![(a_val.clone(), HashMap::new()), (b_val.clone(), HashMap::new())]),
1323            (1, vec![(a_val.clone(), HashMap::new()), (b_val.clone(), HashMap::new())]),
1324        ];
1325        let mut results = Vec::new();
1326        let curtailed = expand_cartesian_scenarios(
1327            &items,
1328            &HashMap::new(),
1329            4,
1330            |collected| ResolvedValue::List { items: collected.into_iter().map(|(_, v)| v).collect() },
1331            &mut results,
1332        );
1333        assert!(!curtailed, "4 combos with limit=4 must not be curtailed");
1334        assert_eq!(results.len(), 4);
1335    }
1336
1337    #[test]
1338    fn expand_cartesian_returns_true_over_limit() {
1339        // 3 items × 2 scenarios each → 8 combos. With limit=4, curtailed.
1340        let a_val = ResolvedValue::Concrete { value: json!("a").into() };
1341        let b_val = ResolvedValue::Concrete { value: json!("b").into() };
1342        let items: Vec<(usize, Vec<(ResolvedValue, HashMap<String, bool>)>)> = vec![
1343            (0, vec![(a_val.clone(), HashMap::new()), (b_val.clone(), HashMap::new())]),
1344            (1, vec![(a_val.clone(), HashMap::new()), (b_val.clone(), HashMap::new())]),
1345            (2, vec![(a_val.clone(), HashMap::new()), (b_val.clone(), HashMap::new())]),
1346        ];
1347        let mut results = Vec::new();
1348        let curtailed = expand_cartesian_scenarios(
1349            &items,
1350            &HashMap::new(),
1351            4,
1352            |collected| ResolvedValue::List { items: collected.into_iter().map(|(_, v)| v).collect() },
1353            &mut results,
1354        );
1355        assert!(curtailed, "8 combos with limit=4 must be curtailed");
1356        assert!(results.len() <= 4, "at most limit results; got {}", results.len());
1357        // Structural completeness: every result list must have all 3 items
1358        for (scenario, _) in &results {
1359            match scenario {
1360                ResolvedValue::List { items } => {
1361                    assert_eq!(items.len(), 3, "every scenario must have all 3 items");
1362                }
1363                other => panic!("expected List, got {:?}", other),
1364            }
1365        }
1366    }
1367}