1use std::collections::HashMap;
4
5use alien_error::{AlienError, Context, IntoAlienError};
6use serde_json::Value;
7
8use crate::{
9 is_deployer_secret_input, ErrorData, Platform, Result, StackInputDefaultValue,
10 StackInputDefinition, StackInputKind,
11};
12
13pub fn resolve_stack_input_values(
17 inputs: &[StackInputDefinition],
18 values: &HashMap<String, Value>,
19 platform: Platform,
20) -> Result<HashMap<String, Value>> {
21 resolve_selected_stack_input_values(inputs, inputs.iter(), values, platform)
22}
23
24pub fn validate_stack_import_input_values(
27 inputs: &[StackInputDefinition],
28 values: &HashMap<String, Value>,
29 platform: Platform,
30) -> Result<()> {
31 let selected = inputs.iter().filter(|input| {
32 input
33 .provided_by
34 .contains(&crate::StackInputProvider::Deployer)
35 || values.get(&input.id).is_some_and(|value| !value.is_null())
36 });
37 resolve_selected_stack_input_values(inputs, selected, values, platform).map(|_| ())
38}
39
40fn resolve_selected_stack_input_values<'a>(
41 inputs: &[StackInputDefinition],
42 selected: impl Iterator<Item = &'a StackInputDefinition>,
43 values: &HashMap<String, Value>,
44 platform: Platform,
45) -> Result<HashMap<String, Value>> {
46 let mut resolved = HashMap::new();
47 for input in selected {
48 if !input.applies(inputs, values, platform) {
49 continue;
50 }
51 let value = values
52 .get(&input.id)
53 .filter(|value| !value.is_null())
54 .cloned()
55 .or_else(|| {
56 input.default.as_ref().map(|default| match default {
57 StackInputDefaultValue::String(value)
58 | StackInputDefaultValue::Number(value) => Value::String(value.clone()),
59 StackInputDefaultValue::Boolean(value) => Value::Bool(*value),
60 StackInputDefaultValue::StringList(value) => serde_json::json!(value),
61 })
62 });
63 let Some(value) = value else {
64 if input.required && !is_deployer_secret_input(input) && !input.is_generated() {
65 return Err(invalid(input, "a value is required"));
66 }
67 continue;
68 };
69 let unanswered = match input.kind {
70 StackInputKind::StringList => value.as_array().is_some_and(Vec::is_empty),
71 _ => value.as_str() == Some(""),
72 };
73 if !input.required && unanswered {
74 continue;
75 }
76 let value = normalize(input, value)?;
77 if input.required
78 && (value.as_str() == Some("") || value.as_array().is_some_and(Vec::is_empty))
79 {
80 return Err(invalid(input, "a non-empty value is required"));
81 }
82 validate(input, &value)?;
83 resolved.insert(input.id.clone(), value);
84 }
85 Ok(resolved)
86}
87
88fn invalid(input: &StackInputDefinition, reason: &str) -> AlienError<ErrorData> {
89 AlienError::new(ErrorData::StackInputInvalid {
90 input_id: input.id.clone(),
91 reason: reason.into(),
92 })
93}
94
95fn normalize(input: &StackInputDefinition, value: Value) -> Result<Value> {
96 match input.kind {
97 StackInputKind::Boolean => crate::gate_value_as_bool(&value)
98 .map(Value::Bool)
99 .ok_or_else(|| invalid(input, "must be a boolean")),
100 StackInputKind::Integer | StackInputKind::Number => {
101 let number = value
102 .as_f64()
103 .or_else(|| value.as_str().and_then(|value| value.parse::<f64>().ok()))
104 .filter(|number| number.is_finite())
105 .ok_or_else(|| invalid(input, "must be a finite number"))?;
106 if input.kind == StackInputKind::Integer && number.fract() != 0.0 {
107 return Err(invalid(input, "must be an integer"));
108 }
109 if value.is_number() {
110 return Ok(value);
111 }
112 let parsed = value
113 .as_str()
114 .and_then(|value| value.parse::<serde_json::Number>().ok());
115 Ok(parsed
116 .map(Value::Number)
117 .unwrap_or_else(|| serde_json::json!(number)))
118 }
119 StackInputKind::StringList => {
120 if !value
121 .as_array()
122 .is_some_and(|items| items.iter().all(Value::is_string))
123 {
124 return Err(invalid(input, "must be a list of strings"));
125 }
126 Ok(value)
127 }
128 StackInputKind::String | StackInputKind::Secret | StackInputKind::Enum => {
129 if !value.is_string() {
130 return Err(invalid(input, "must be a string"));
131 }
132 Ok(value)
133 }
134 }
135}
136
137fn validate(input: &StackInputDefinition, value: &Value) -> Result<()> {
138 let Some(validation) = &input.validation else {
139 return Ok(());
140 };
141 if let Some(text) = value.as_str() {
142 let length = text.chars().count();
143 if validation
144 .min_length
145 .is_some_and(|min| length < min as usize)
146 || validation
147 .max_length
148 .is_some_and(|max| length > max as usize)
149 {
150 return Err(invalid(input, "length is outside the declared bounds"));
151 }
152 if validation
153 .values
154 .as_ref()
155 .is_some_and(|values| !values.iter().any(|candidate| candidate == text))
156 {
157 return Err(invalid(input, "must be one of the declared values"));
158 }
159 if let Some(pattern) = &validation.pattern {
160 let regex = regex::Regex::new(&format!("^(?:{pattern})$"))
161 .into_alien_error()
162 .context(ErrorData::StackInputInvalid {
163 input_id: input.id.clone(),
164 reason: "declares an invalid pattern".into(),
165 })?;
166 if !regex.is_match(text) {
167 return Err(invalid(input, "does not match the declared pattern"));
168 }
169 }
170 if validation.format.as_deref() == Some("url") && url::Url::parse(text).is_err() {
171 return Err(invalid(input, "must be a URL"));
172 }
173 }
174 if let Some(number) = value.as_f64() {
175 for (bound, minimum) in [(&validation.min, true), (&validation.max, false)] {
176 if let Some(bound) = bound {
177 let bound = bound.parse::<f64>().into_alien_error().context(
178 ErrorData::StackInputInvalid {
179 input_id: input.id.clone(),
180 reason: "declares an invalid numeric bound".into(),
181 },
182 )?;
183 if (minimum && number < bound) || (!minimum && number > bound) {
184 return Err(invalid(input, "number is outside the declared bounds"));
185 }
186 }
187 }
188 }
189 if let Some(items) = value.as_array() {
190 if validation
191 .min_items
192 .is_some_and(|min| items.len() < min as usize)
193 || validation
194 .max_items
195 .is_some_and(|max| items.len() > max as usize)
196 {
197 return Err(invalid(input, "item count is outside the declared bounds"));
198 }
199 }
200 Ok(())
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 fn inputs() -> Vec<StackInputDefinition> {
208 serde_json::from_value(serde_json::json!([
209 {"id":"bucket", "kind":"string", "required":true, "providedBy":["deployer"], "label":"Bucket", "description":"Snapshot bucket", "when":{"input":"enabled","equals":true}, "validation":{"minLength":3}},
210 {"id":"enabled", "kind":"boolean", "required":true, "providedBy":["deployer"], "label":"Enabled", "description":"Enable snapshots", "default":{"type":"boolean","value":true}}
211 ])).unwrap()
212 }
213
214 #[test]
215 fn optional_blank_values_must_match_the_declared_kind() {
216 for kind in ["string", "integer", "number", "boolean", "enum", "secret"] {
217 let definition: StackInputDefinition = serde_json::from_value(serde_json::json!({
218 "id":"answer", "kind":kind, "providedBy":["deployer"], "required":false,
219 "label":"Answer", "description":"Optional answer"
220 }))
221 .unwrap();
222 let values = HashMap::from([("answer".into(), serde_json::json!([]))]);
223 let error =
224 resolve_stack_input_values(&[definition], &values, Platform::Aws).unwrap_err();
225 assert_eq!(error.code, "STACK_INPUT_INVALID", "{kind}");
226 }
227 }
228
229 #[test]
230 fn optional_blank_values_are_unanswered_before_constraints() {
231 let inputs: Vec<StackInputDefinition> = serde_json::from_value(serde_json::json!([
232 {"id":"url", "kind":"string", "providedBy":["deployer"], "required":false,
233 "label":"URL", "description":"Optional URL", "validation":{"format":"url"}},
234 {"id":"items", "kind":"stringList", "providedBy":["deployer"], "required":false,
235 "label":"Items", "description":"Optional items", "validation":{"minItems":1}}
236 ]))
237 .unwrap();
238 let values = HashMap::from([
239 ("url".into(), serde_json::json!("")),
240 ("items".into(), serde_json::json!([])),
241 ]);
242 assert!(resolve_stack_input_values(&inputs, &values, Platform::Aws)
243 .unwrap()
244 .is_empty());
245 }
246
247 #[test]
248 fn inactive_required_input_is_ignored_including_invalid_values() {
249 let inputs = inputs();
250 for value in [Value::Null, serde_json::json!(42), serde_json::json!("x")] {
251 let values = HashMap::from([
252 ("enabled".into(), Value::Bool(false)),
253 ("bucket".into(), value),
254 ]);
255 assert_eq!(
256 resolve_stack_input_values(&inputs, &values, Platform::Aws).unwrap(),
257 HashMap::from([("enabled".into(), Value::Bool(false))])
258 );
259 }
260 }
261
262 #[test]
263 fn active_input_is_required_and_validated_with_provided_or_default_toggle() {
264 for toggle in [None, Some(Value::Bool(true))] {
265 for bucket in [
266 None,
267 Some(serde_json::json!("")),
268 Some(serde_json::json!("x")),
269 Some(serde_json::json!(42)),
270 ] {
271 let mut values = HashMap::new();
272 if let Some(value) = &toggle {
273 values.insert("enabled".into(), value.clone());
274 }
275 if let Some(value) = bucket {
276 values.insert("bucket".into(), value);
277 }
278 let error =
279 resolve_stack_input_values(&inputs(), &values, Platform::Aws).unwrap_err();
280 assert_eq!(error.code, "STACK_INPUT_INVALID");
281 assert!(error.message.contains("bucket"));
282 }
283 }
284 let values = HashMap::from([("bucket".into(), serde_json::json!("snapshots"))]);
285 let resolved = resolve_stack_input_values(&inputs(), &values, Platform::Aws).unwrap();
286 assert_eq!(resolved["bucket"], "snapshots");
287 assert_eq!(resolved["enabled"], true);
288 }
289}