Skip to main content

template_model/
member_presence.rs

1//! Presence analysis for the members of a resolved list or map.
2//!
3//! A member gated by `Fn::If` over `AWS::NoValue` is present in exactly the
4//! deployments where its condition literals hold. Knowing each member's
5//! presence literals lets a consumer reason about cardinality and co-presence
6//! without enumerating the 2^N joint assignments of N independent conditions.
7
8use std::collections::HashMap;
9
10use crate::resolved_value::{contains_dynamic_resolved, is_resolved_null};
11use crate::resolver::ResolvedValue;
12
13/// When a container member survives to deployment.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum MemberPresence {
16    /// The member is present in every deployment.
17    Always,
18    /// The member resolves to `AWS::NoValue` in every deployment.
19    Never,
20    /// The member is present exactly when every listed `(condition, value)`
21    /// literal holds.
22    When(Vec<(String, bool)>),
23    /// Presence depends on conditions in a shape that has no single literal
24    /// conjunction (both branches partly present, or a parameter-driven `Enum`
25    /// mixing null and non-null variants).
26    Complex,
27}
28
29/// Classifies one member of a resolved container.
30#[must_use]
31pub fn member_presence(member: &ResolvedValue) -> MemberPresence {
32    match member {
33        ResolvedValue::Conditional { condition, if_true, if_false } => {
34            conditional_presence(condition, member_presence(if_true), member_presence(if_false))
35        }
36        ResolvedValue::Enum { variants } => {
37            let null_variants = variants.iter().filter(|variant| is_resolved_null(variant)).count();
38            if null_variants == 0 {
39                MemberPresence::Always
40            } else if null_variants == variants.len() {
41                MemberPresence::Never
42            } else {
43                MemberPresence::Complex
44            }
45        }
46        other if is_resolved_null(other) => MemberPresence::Never,
47        _ => MemberPresence::Always,
48    }
49}
50
51fn conditional_presence(condition: &str, if_true: MemberPresence, if_false: MemberPresence) -> MemberPresence {
52    let conjoin_literal =
53        |mut literals: Vec<(String, bool)>, expected: bool| match literals.iter().find(|(name, _)| name == condition) {
54            Some((_, existing)) if *existing != expected => MemberPresence::Never,
55            Some(_) => MemberPresence::When(literals),
56            None => {
57                literals.insert(0, (condition.to_string(), expected));
58                MemberPresence::When(literals)
59            }
60        };
61    match (if_true, if_false) {
62        (MemberPresence::Always, MemberPresence::Always) => MemberPresence::Always,
63        (MemberPresence::Never, MemberPresence::Never) => MemberPresence::Never,
64        (MemberPresence::Always, MemberPresence::Never) => MemberPresence::When(vec![(condition.to_string(), true)]),
65        (MemberPresence::Never, MemberPresence::Always) => MemberPresence::When(vec![(condition.to_string(), false)]),
66        (MemberPresence::When(literals), MemberPresence::Never) => conjoin_literal(literals, true),
67        (MemberPresence::Never, MemberPresence::When(literals)) => conjoin_literal(literals, false),
68        _ => MemberPresence::Complex,
69    }
70}
71
72/// One container member with its presence and whether a present value would
73/// carry an unresolved reference or dynamic placeholder.
74#[derive(Debug, Clone)]
75pub struct ContainerMember<'a> {
76    pub value: &'a ResolvedValue,
77    pub presence: MemberPresence,
78    pub is_dynamic: bool,
79}
80
81/// Classifies every member of a list, or every entry value of a map.
82#[must_use]
83pub fn classify_members(members: &[ResolvedValue]) -> Vec<ContainerMember<'_>> {
84    members
85        .iter()
86        .map(|value| ContainerMember {
87            value,
88            presence: member_presence(value),
89            is_dynamic: contains_dynamic_resolved(value),
90        })
91        .collect()
92}
93
94/// Every way a member resolves to `AWS::NoValue`, constrained by decisions
95/// already made by an enclosing conditional.
96pub(crate) fn absent_alternatives(
97    member: &ResolvedValue,
98    established_literals: &[(String, bool)],
99) -> Vec<Vec<(String, bool)>> {
100    let mut alternatives = Vec::new();
101    let mut literals = established_literals.to_vec();
102    collect_absent_alternatives(member, &mut literals, &mut alternatives);
103    alternatives
104}
105
106fn collect_absent_alternatives(
107    value: &ResolvedValue,
108    literals: &mut Vec<(String, bool)>,
109    alternatives: &mut Vec<Vec<(String, bool)>>,
110) {
111    match value {
112        ResolvedValue::Conditional { condition, if_true, if_false } => {
113            for (branch, holds) in [(if_true, true), (if_false, false)] {
114                match literals.iter().find(|(name, _)| name == condition) {
115                    Some((_, decided)) if *decided == holds => {
116                        collect_absent_alternatives(branch, literals, alternatives);
117                    }
118                    Some(_) => {}
119                    None => {
120                        literals.push((condition.clone(), holds));
121                        collect_absent_alternatives(branch, literals, alternatives);
122                        literals.pop();
123                    }
124                }
125            }
126        }
127        ResolvedValue::Enum { variants } => {
128            for variant in variants {
129                collect_absent_alternatives(variant, literals, alternatives);
130            }
131        }
132        other if is_resolved_null(other) => alternatives.push(literals.clone()),
133        _ => {}
134    }
135}
136
137/// Adds `literals` to `assignment` unless one contradicts an existing entry.
138/// Returns whether the literals were compatible; on `false` the assignment is
139/// left unchanged.
140pub fn extend_assignment(assignment: &mut HashMap<String, bool>, literals: &[(String, bool)]) -> bool {
141    let mut additions = HashMap::with_capacity(literals.len());
142    for (condition, value) in literals {
143        if assignment.get(condition).is_some_and(|existing| existing != value)
144            || additions.insert(condition.as_str(), *value).is_some_and(|existing| existing != *value)
145        {
146            return false;
147        }
148    }
149    assignment.extend(additions.into_iter().map(|(condition, value)| (condition.to_string(), value)));
150    true
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    fn concrete(value: serde_json::Value) -> ResolvedValue {
158        ResolvedValue::Concrete { value: value.into() }
159    }
160
161    fn no_value() -> ResolvedValue {
162        concrete(serde_json::Value::Null)
163    }
164
165    fn conditional(condition: &str, if_true: ResolvedValue, if_false: ResolvedValue) -> ResolvedValue {
166        ResolvedValue::Conditional {
167            condition: condition.to_string(),
168            if_true: Box::new(if_true),
169            if_false: Box::new(if_false),
170        }
171    }
172
173    #[test]
174    fn optional_member_over_no_value_is_present_when_its_condition_holds() {
175        let member = conditional("HasTag", concrete(serde_json::json!({"Key": "k"})), no_value());
176        assert_eq!(member_presence(&member), MemberPresence::When(vec![("HasTag".to_string(), true)]));
177    }
178
179    #[test]
180    fn reversed_branches_negate_the_literal() {
181        let member = conditional("Skip", no_value(), concrete(serde_json::json!("x")));
182        assert_eq!(member_presence(&member), MemberPresence::When(vec![("Skip".to_string(), false)]));
183    }
184
185    #[test]
186    fn nested_optional_member_conjoins_literals_outermost_first() {
187        let inner = conditional("Inner", concrete(serde_json::json!("x")), no_value());
188        let member = conditional("Outer", inner, no_value());
189        assert_eq!(
190            member_presence(&member),
191            MemberPresence::When(vec![("Outer".to_string(), true), ("Inner".to_string(), true)])
192        );
193    }
194
195    #[test]
196    fn conditional_choosing_between_two_values_is_always_present() {
197        let member = conditional("Pick", concrete(serde_json::json!("a")), concrete(serde_json::json!("b")));
198        assert_eq!(member_presence(&member), MemberPresence::Always);
199    }
200
201    #[test]
202    fn conditional_whose_branches_are_both_partly_present_is_complex() {
203        let left = conditional("A", concrete(serde_json::json!("a")), no_value());
204        let right = conditional("B", concrete(serde_json::json!("b")), no_value());
205        assert_eq!(member_presence(&conditional("Pick", left, right)), MemberPresence::Complex);
206    }
207
208    #[test]
209    fn enum_mixing_null_and_values_is_complex_and_all_null_is_never() {
210        let mixed = ResolvedValue::Enum { variants: vec![no_value(), concrete(serde_json::json!("a"))] };
211        assert_eq!(member_presence(&mixed), MemberPresence::Complex);
212        let all_null = ResolvedValue::Enum { variants: vec![no_value(), no_value()] };
213        assert_eq!(member_presence(&all_null), MemberPresence::Never);
214    }
215
216    #[test]
217    fn absent_alternatives_lists_the_null_leaves() {
218        let member = conditional("HasTag", concrete(serde_json::json!({"Key": "k"})), no_value());
219        assert_eq!(absent_alternatives(&member, &[]), vec![vec![("HasTag".to_string(), false)]]);
220        assert!(absent_alternatives(&concrete(serde_json::json!([])), &[]).is_empty());
221        assert_eq!(absent_alternatives(&no_value(), &[]), vec![Vec::new()]);
222    }
223
224    #[test]
225    fn classify_members_marks_reference_values_dynamic() {
226        let members = vec![
227            ResolvedValue::Reference { target: "P".to_string(), kind: crate::resolver::RefKind::Ref },
228            concrete(serde_json::json!("a")),
229        ];
230        let classified = classify_members(&members);
231        assert!(classified[0].is_dynamic && classified[0].presence == MemberPresence::Always);
232        assert!(!classified[1].is_dynamic);
233    }
234
235    #[test]
236    fn extend_assignment_rejects_contradicting_literals_without_mutating() {
237        let mut assignment = HashMap::from([("A".to_string(), true)]);
238        assert!(!extend_assignment(&mut assignment, &[("B".to_string(), true), ("A".to_string(), false)]));
239        assert_eq!(assignment.len(), 1);
240        assert!(extend_assignment(&mut assignment, &[("B".to_string(), true)]));
241        assert_eq!(assignment.get("B"), Some(&true));
242    }
243
244    #[test]
245    fn repeated_condition_that_requires_both_values_is_never_present() {
246        let impossible =
247            conditional("Same", conditional("Same", no_value(), concrete(serde_json::json!("dead"))), no_value());
248        assert_eq!(member_presence(&impossible), MemberPresence::Never);
249    }
250
251    #[test]
252    fn absent_alternatives_honor_established_condition_decisions() {
253        let member = conditional("Same", concrete(serde_json::json!("present")), no_value());
254        assert!(absent_alternatives(&member, &[("Same".to_string(), true)]).is_empty());
255        assert_eq!(
256            absent_alternatives(&member, &[("Same".to_string(), false)]),
257            vec![vec![("Same".to_string(), false)]]
258        );
259    }
260
261    #[test]
262    fn extend_assignment_rejects_contradictions_within_new_literals() {
263        let mut assignment = HashMap::new();
264        assert!(!extend_assignment(&mut assignment, &[("A".to_string(), true), ("A".to_string(), false)]));
265        assert!(assignment.is_empty());
266    }
267}