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
78pub 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
265pub 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
341pub 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
382pub 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 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
524fn 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 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 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 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 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 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 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 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 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 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 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 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 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}