Skip to main content

alien_core/stack_authoring/
inputs.rs

1//! Stack inputs as a stack file declares them.
2//!
3//! An [`AuthoredInput`] is one input as written (`kind`, `label`, `min-length`, ...). Turning
4//! it into a [`StackInputDefinition`] applies the same rules the TypeScript SDK's `inputs()`
5//! applies, so a stack carries the same definitions whichever frontend described it.
6//!
7//! Errors name the offending field as `input.<id>.<key>`, with the key spelled the way the
8//! definition spells it (`minLength`), so a frontend can find it in the file.
9
10use std::collections::HashMap;
11
12use serde::{Deserialize, Serialize};
13
14use super::{input_default, input_number, input_validation, invalid, InputValue};
15use crate::error::Result;
16use crate::{
17    Platform, StackInputCondition, StackInputDefinition, StackInputEnvironmentMapping,
18    StackInputEnvironmentVariableType, StackInputGenerate, StackInputKind, StackInputProvider,
19    StackInputValidation, STACK_INPUT_GENERATE_MAX_LENGTH, STACK_INPUT_GENERATE_MIN_LENGTH,
20};
21
22/// One environment variable an input is delivered as.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
25#[serde(rename_all = "camelCase", deny_unknown_fields)]
26pub struct AuthoredEnvMapping {
27    /// Environment variable name: uppercase letters, digits and underscores.
28    pub name: String,
29    /// Resource IDs or `prefix*` patterns that get the variable. Every workload when unset.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub target_resources: Option<Vec<String>>,
32    /// Whether the variable is plain or secret. Defaults from the input kind.
33    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
34    pub var_type: Option<StackInputEnvironmentVariableType>,
35}
36
37/// A stack input as written in a stack file.
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
40#[serde(rename_all = "camelCase", deny_unknown_fields)]
41pub struct AuthoredInput {
42    /// Input primitive kind.
43    pub kind: StackInputKind,
44    /// Who can provide the value. The developer when unset.
45    #[serde(default = "default_provided_by")]
46    pub provided_by: Vec<StackInputProvider>,
47    /// Whether a value is needed before deployment can proceed. True when unset.
48    #[serde(default = "default_required")]
49    pub required: bool,
50    /// Human-facing field label.
51    pub label: String,
52    /// Helper text shown where the value is asked for. Required when the input is.
53    #[serde(default)]
54    pub description: String,
55    /// Example shown in the field.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub placeholder: Option<String>,
58    /// Value used when none is provided. Secrets have none.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub default: Option<InputValue>,
61    /// Only applies when a boolean input has this value.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub when: Option<StackInputCondition>,
64    /// Platforms where the input applies.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub platforms: Option<Vec<Platform>>,
67    /// Environment variables the value is delivered as.
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    pub env: Vec<AuthoredEnvMapping>,
70    /// Let Alien generate the value. Secret, developer-provided inputs only.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub generate: Option<StackInputGenerate>,
73    /// Shortest string, for string and secret inputs.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub min_length: Option<u32>,
76    /// Longest string, for string and secret inputs.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub max_length: Option<u32>,
79    /// Whole-value pattern, for string and secret inputs.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub pattern: Option<String>,
82    /// Semantic format such as `url`, for string and secret inputs.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub format: Option<String>,
85    /// Smallest value, for number and integer inputs.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub min: Option<InputValue>,
88    /// Largest value, for number and integer inputs.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub max: Option<InputValue>,
91    /// Accepted values, for enum inputs.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub values: Option<Vec<String>>,
94    /// Fewest items, for string-list inputs.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub min_items: Option<u32>,
97    /// Most items, for string-list inputs.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub max_items: Option<u32>,
100}
101
102fn default_provided_by() -> Vec<StackInputProvider> {
103    vec![StackInputProvider::Developer]
104}
105
106fn default_required() -> bool {
107    true
108}
109
110const TEXT: &[StackInputKind] = &[StackInputKind::String, StackInputKind::Secret];
111const NUMERIC: &[StackInputKind] = &[StackInputKind::Number, StackInputKind::Integer];
112
113/// Turns the inputs of a stack file, in file order, into definitions.
114///
115/// Validates each input, and then the `when` conditions across them.
116pub fn normalize_inputs(
117    inputs: impl IntoIterator<Item = (String, AuthoredInput)>,
118) -> Result<Vec<StackInputDefinition>> {
119    let definitions = inputs
120        .into_iter()
121        .map(|(id, input)| input.into_definition(&id))
122        .collect::<Result<Vec<_>>>()?;
123    validate_conditions(&definitions)?;
124    Ok(definitions)
125}
126
127/// Whether `name` can be an environment variable an input is delivered as.
128pub fn is_input_env_name(name: &str) -> bool {
129    let mut chars = name.chars();
130    chars
131        .next()
132        .is_some_and(|first| first.is_ascii_uppercase() || first == '_')
133        && chars.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
134}
135
136/// Checks the name of an environment variable delivered from input `input_id`.
137pub fn validate_input_env_name(input_id: &str, name: &str) -> Result<()> {
138    if is_input_env_name(name) {
139        return Ok(());
140    }
141    Err(invalid(
142        &format!("input.{input_id}.env"),
143        format!("env name '{name}' must use uppercase letters, digits, and underscores"),
144    ))
145}
146
147impl AuthoredInput {
148    /// The definition this input becomes under `id`.
149    pub fn into_definition(self, id: &str) -> Result<StackInputDefinition> {
150        let field = |key: &str| format!("input.{id}.{key}");
151        validate_input_id(id)?;
152
153        if self.provided_by.is_empty() {
154            return Err(invalid(
155                &field("providedBy"),
156                "must include at least one provider",
157            ));
158        }
159        if self.label.trim().is_empty() {
160            return Err(invalid(&field("label"), "must include a label"));
161        }
162        if self.required && self.description.trim().is_empty() {
163            return Err(invalid(
164                &field("description"),
165                "must include a description when the input is required",
166            ));
167        }
168
169        let validation = self.validation(id)?;
170        validate_validation(id, &self.kind, &validation)?;
171
172        if let Some(generate) = &self.generate {
173            self.validate_generate(id, generate, &validation)?;
174        }
175
176        for mapping in &self.env {
177            validate_input_env_name(id, &mapping.name)?;
178            if mapping.target_resources.as_ref().is_some_and(Vec::is_empty) {
179                return Err(invalid(
180                    &field("env"),
181                    format!(
182                        "env mapping '{}' cannot use an empty target-resources list",
183                        mapping.name
184                    ),
185                ));
186            }
187        }
188
189        let default = self
190            .default
191            .as_ref()
192            .map(|value| {
193                input_default(&self.kind, value).map_err(|error| relocate(error, &field("default")))
194            })
195            .transpose()?;
196
197        Ok(StackInputDefinition {
198            id: id.to_string(),
199            kind: self.kind.clone(),
200            provided_by: self.provided_by,
201            required: self.required,
202            label: self.label,
203            description: self.description,
204            placeholder: self.placeholder,
205            default,
206            platforms: self.platforms,
207            when: self.when,
208            validation: input_validation(&self.kind, validation),
209            generate: self.generate,
210            env: self
211                .env
212                .into_iter()
213                .map(|mapping| StackInputEnvironmentMapping {
214                    name: mapping.name,
215                    target_resources: mapping.target_resources,
216                    var_type: mapping.var_type,
217                })
218                .collect(),
219        })
220    }
221
222    /// The validation block, rejecting constraints the kind cannot have.
223    fn validation(&self, id: &str) -> Result<StackInputValidation> {
224        let only = |key: &str, present: bool, kinds: &[StackInputKind], what: &str| {
225            if present && !kinds.contains(&self.kind) {
226                return Err(invalid(
227                    &format!("input.{id}.{key}"),
228                    format!("applies to {what} inputs only"),
229                ));
230            }
231            Ok(())
232        };
233        only(
234            "minLength",
235            self.min_length.is_some(),
236            TEXT,
237            "string and secret",
238        )?;
239        only(
240            "maxLength",
241            self.max_length.is_some(),
242            TEXT,
243            "string and secret",
244        )?;
245        only("pattern", self.pattern.is_some(), TEXT, "string and secret")?;
246        only("format", self.format.is_some(), TEXT, "string and secret")?;
247        only("min", self.min.is_some(), NUMERIC, "number and integer")?;
248        only("max", self.max.is_some(), NUMERIC, "number and integer")?;
249        only(
250            "values",
251            self.values.is_some(),
252            &[StackInputKind::Enum],
253            "enum",
254        )?;
255        only(
256            "minItems",
257            self.min_items.is_some(),
258            &[StackInputKind::StringList],
259            "string-list",
260        )?;
261        only(
262            "maxItems",
263            self.max_items.is_some(),
264            &[StackInputKind::StringList],
265            "string-list",
266        )?;
267
268        let number = |key: &str, value: &Option<InputValue>| {
269            value
270                .as_ref()
271                .map(|value| {
272                    input_number(key, &self.kind, value)
273                        .map_err(|error| relocate(error, &format!("input.{id}.{key}")))
274                })
275                .transpose()
276        };
277        Ok(StackInputValidation {
278            min_length: self.min_length,
279            max_length: self.max_length,
280            pattern: self.pattern.clone(),
281            format: self.format.clone(),
282            min: number("min", &self.min)?,
283            max: number("max", &self.max)?,
284            values: self.values.clone(),
285            min_items: self.min_items,
286            max_items: self.max_items,
287        })
288    }
289
290    fn validate_generate(
291        &self,
292        id: &str,
293        generate: &StackInputGenerate,
294        validation: &StackInputValidation,
295    ) -> Result<()> {
296        let field = format!("input.{id}.generate");
297        if self.kind != StackInputKind::Secret {
298            return Err(invalid(&field, "is only supported on secret inputs"));
299        }
300        if self.default.is_some() {
301            return Err(invalid(&field, "cannot be combined with a default"));
302        }
303        if self.provided_by != [StackInputProvider::Developer] {
304            return Err(invalid(
305                &field,
306                "a generated input must be provided by the developer only; Alien supplies \
307                 the value, so the deployer is never asked for it",
308            ));
309        }
310        let length = generate.length;
311        if !(STACK_INPUT_GENERATE_MIN_LENGTH..=STACK_INPUT_GENERATE_MAX_LENGTH).contains(&length) {
312            return Err(invalid(
313                &field,
314                format!(
315                    "length must be an integer between {STACK_INPUT_GENERATE_MIN_LENGTH} and \
316                     {STACK_INPUT_GENERATE_MAX_LENGTH}"
317                ),
318            ));
319        }
320        if validation.pattern.is_some() || validation.format.is_some() {
321            return Err(invalid(
322                &field,
323                "cannot be combined with pattern or format; generated values are alphanumeric",
324            ));
325        }
326        if validation.min_length.is_some_and(|min| length < min)
327            || validation.max_length.is_some_and(|max| length > max)
328        {
329            return Err(invalid(
330                &field,
331                format!("length {length} is outside the input's min-length and max-length"),
332            ));
333        }
334        Ok(())
335    }
336}
337
338/// The same error, reported against `field`.
339fn relocate(
340    error: alien_error::AlienError<crate::ErrorData>,
341    field: &str,
342) -> alien_error::AlienError<crate::ErrorData> {
343    match error.error {
344        Some(crate::ErrorData::StackDefinitionInvalid { reason, .. }) => invalid(field, reason),
345        _ => error,
346    }
347}
348
349fn validate_input_id(id: &str) -> Result<()> {
350    let field = format!("input.{id}");
351    let mut chars = id.chars();
352    let valid = chars
353        .next()
354        .is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
355        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
356    if !valid {
357        return Err(invalid(
358            &field,
359            "the ID must start with a letter or underscore and contain only letters, digits, \
360             and underscores",
361        ));
362    }
363    if !id.chars().any(|c| c.is_ascii_lowercase()) {
364        return Err(invalid(
365            &field,
366            "the ID must not be all caps; that style is for environment variables",
367        ));
368    }
369    Ok(())
370}
371
372fn validate_validation(
373    id: &str,
374    kind: &StackInputKind,
375    validation: &StackInputValidation,
376) -> Result<()> {
377    let field = |key: &str| format!("input.{id}.{key}");
378    if let (Some(min), Some(max)) = (validation.min_length, validation.max_length) {
379        if min > max {
380            return Err(invalid(
381                &field("minLength"),
382                "must be less than or equal to max-length",
383            ));
384        }
385    }
386    if let (Some(min), Some(max)) = (validation.min_items, validation.max_items) {
387        if min > max {
388            return Err(invalid(
389                &field("minItems"),
390                "must be less than or equal to max-items",
391            ));
392        }
393    }
394    if *kind == StackInputKind::Enum
395        && validation
396            .values
397            .as_ref()
398            .is_none_or(|values| values.is_empty())
399    {
400        return Err(invalid(
401            &field("values"),
402            "an enum input needs at least one value",
403        ));
404    }
405    if let Some(pattern) = &validation.pattern {
406        validate_portable_pattern(&field("pattern"), pattern)?;
407    }
408    Ok(())
409}
410
411fn validate_portable_pattern(field: &str, pattern: &str) -> Result<()> {
412    if pattern.is_empty() {
413        return Err(invalid(field, "must not be empty"));
414    }
415    if pattern.len() > 1 && pattern.starts_with('/') && pattern.ends_with('/') {
416        return Err(invalid(field, "must not use regex delimiters"));
417    }
418    let bytes = pattern.as_bytes();
419    let backreference_or_property = bytes
420        .windows(2)
421        .any(|pair| pair[0] == b'\\' && matches!(pair[1], b'1'..=b'9' | b'p' | b'P' | b'k'));
422    let nested_class = pattern.match_indices('[').any(|(start, _)| {
423        pattern[start + 1..]
424            .chars()
425            .take_while(|c| *c != ']')
426            .any(|c| c == '[')
427    });
428    if backreference_or_property || pattern.contains("(?") || nested_class {
429        return Err(invalid(
430            field,
431            "is not portable across TypeScript, CloudFormation, and Terraform",
432        ));
433    }
434    Ok(())
435}
436
437/// Checks the `when` conditions of `definitions`, which must all be unconditional booleans
438/// that exist in the same stack.
439fn validate_conditions(definitions: &[StackInputDefinition]) -> Result<()> {
440    let by_id: HashMap<&str, &StackInputDefinition> = definitions
441        .iter()
442        .map(|definition| (definition.id.as_str(), definition))
443        .collect();
444    for definition in definitions {
445        let Some(condition) = &definition.when else {
446            continue;
447        };
448        let reference = by_id.get(condition.input.as_str());
449        let reason = if condition.input == definition.id {
450            Some("cannot reference itself")
451        } else if reference.is_none() {
452            Some("references an unknown input")
453        } else if reference.is_some_and(|reference| reference.kind != StackInputKind::Boolean) {
454            Some("must reference a boolean input")
455        } else if reference.is_some_and(|reference| reference.when.is_some()) {
456            Some("must reference an unconditional input; chains and cycles are unsupported")
457        } else if definition.generate.is_some() {
458            Some("cannot be combined with generate")
459        } else if definition
460            .provided_by
461            .contains(&StackInputProvider::Deployer)
462            && reference.is_some_and(|reference| {
463                !reference
464                    .provided_by
465                    .contains(&StackInputProvider::Deployer)
466            })
467        {
468            Some("on a deployer input must reference a deployer-provided boolean")
469        } else {
470            None
471        };
472        if let Some(reason) = reason {
473            return Err(invalid(
474                &format!("input.{}.when", definition.id),
475                format!("'{}' {reason}", condition.input),
476            ));
477        }
478    }
479    Ok(())
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485    use crate::{ErrorData, StackInputDefaultValue};
486    use alien_error::AlienError;
487
488    fn input(kind: StackInputKind) -> AuthoredInput {
489        AuthoredInput {
490            kind,
491            provided_by: vec![StackInputProvider::Deployer],
492            required: true,
493            label: "Input".to_string(),
494            description: "Input value".to_string(),
495            placeholder: None,
496            default: None,
497            when: None,
498            platforms: None,
499            env: Vec::new(),
500            generate: None,
501            min_length: None,
502            max_length: None,
503            pattern: None,
504            format: None,
505            min: None,
506            max: None,
507            values: None,
508            min_items: None,
509            max_items: None,
510        }
511    }
512
513    fn flag(provided_by: StackInputProvider) -> AuthoredInput {
514        AuthoredInput {
515            provided_by: vec![provided_by],
516            ..input(StackInputKind::Boolean)
517        }
518    }
519
520    fn when(input: &str) -> Option<StackInputCondition> {
521        Some(StackInputCondition {
522            input: input.to_string(),
523            equals: true,
524        })
525    }
526
527    fn normalize(
528        inputs: Vec<(&str, AuthoredInput)>,
529    ) -> std::result::Result<Vec<StackInputDefinition>, AlienError<ErrorData>> {
530        normalize_inputs(
531            inputs
532                .into_iter()
533                .map(|(id, input)| (id.to_string(), input)),
534        )
535    }
536
537    fn rejected(inputs: Vec<(&str, AuthoredInput)>) -> (String, String) {
538        match normalize(inputs).expect_err("should be rejected").error {
539            Some(ErrorData::StackDefinitionInvalid { field, reason }) => (field, reason),
540            other => panic!("unexpected error {other:?}"),
541        }
542    }
543
544    #[test]
545    fn a_boolean_gates_a_string_and_keeps_its_condition() {
546        let definitions = normalize(vec![
547            ("enabled", flag(StackInputProvider::Deployer)),
548            (
549                "bucket",
550                AuthoredInput {
551                    when: when("enabled"),
552                    env: vec![AuthoredEnvMapping {
553                        name: "BUCKET".to_string(),
554                        target_resources: None,
555                        var_type: None,
556                    }],
557                    ..input(StackInputKind::String)
558                },
559            ),
560        ])
561        .unwrap();
562        assert_eq!(definitions.len(), 2);
563        assert_eq!(definitions[0].id, "enabled");
564        assert_eq!(
565            definitions[0].validation, None,
566            "a boolean has no validation block"
567        );
568        assert_eq!(definitions[1].when, when("enabled"));
569        assert_eq!(
570            definitions[1].validation,
571            Some(StackInputValidation::default()),
572            "other kinds always carry one, even when empty"
573        );
574        assert_eq!(definitions[1].env[0].name, "BUCKET");
575    }
576
577    #[test]
578    fn a_developer_controller_is_rejected_for_a_deployer_input_only() {
579        let (field, reason) = rejected(vec![
580            ("enabled", flag(StackInputProvider::Developer)),
581            (
582                "bucket",
583                AuthoredInput {
584                    when: when("enabled"),
585                    ..input(StackInputKind::String)
586                },
587            ),
588        ]);
589        assert_eq!(field, "input.bucket.when");
590        assert!(reason.contains("deployer-provided"), "{reason}");
591
592        normalize(vec![
593            ("enabled", flag(StackInputProvider::Developer)),
594            (
595                "bucket",
596                AuthoredInput {
597                    provided_by: vec![StackInputProvider::Developer],
598                    when: when("enabled"),
599                    ..input(StackInputKind::String)
600                },
601            ),
602        ])
603        .expect("a developer input may follow a developer boolean");
604    }
605
606    #[test]
607    fn a_condition_cannot_be_combined_with_generate() {
608        let (field, reason) = rejected(vec![
609            ("enabled", flag(StackInputProvider::Developer)),
610            (
611                "token",
612                AuthoredInput {
613                    provided_by: vec![StackInputProvider::Developer],
614                    when: when("enabled"),
615                    generate: Some(StackInputGenerate { length: 64 }),
616                    ..input(StackInputKind::Secret)
617                },
618            ),
619        ]);
620        assert_eq!(field, "input.token.when");
621        assert!(reason.contains("generate"), "{reason}");
622    }
623
624    #[test]
625    fn a_condition_must_reference_an_existing_other_input() {
626        for reference in ["missing", "bucket"] {
627            let (field, reason) = rejected(vec![(
628                "bucket",
629                AuthoredInput {
630                    when: when(reference),
631                    ..input(StackInputKind::String)
632                },
633            )]);
634            assert_eq!(field, "input.bucket.when");
635            assert!(
636                reason.contains("itself") || reason.contains("unknown"),
637                "{reason}"
638            );
639        }
640    }
641
642    #[test]
643    fn a_condition_must_reference_an_unconditional_boolean() {
644        let (_, reason) = rejected(vec![
645            ("enabled", input(StackInputKind::String)),
646            (
647                "bucket",
648                AuthoredInput {
649                    when: when("enabled"),
650                    ..input(StackInputKind::String)
651                },
652            ),
653        ]);
654        assert!(reason.contains("boolean"), "{reason}");
655
656        let (_, reason) = rejected(vec![
657            ("outer", flag(StackInputProvider::Deployer)),
658            (
659                "enabled",
660                AuthoredInput {
661                    when: when("outer"),
662                    ..flag(StackInputProvider::Deployer)
663                },
664            ),
665            (
666                "bucket",
667                AuthoredInput {
668                    when: when("enabled"),
669                    ..input(StackInputKind::String)
670                },
671            ),
672        ]);
673        assert!(reason.contains("unconditional"), "{reason}");
674    }
675
676    #[test]
677    fn ids_are_identifiers_with_a_lowercase_letter() {
678        for id in ["1st", "has-dash", "API_KEY", ""] {
679            let (field, _) = rejected(vec![(id, input(StackInputKind::String))]);
680            assert_eq!(field, format!("input.{id}"));
681        }
682        normalize(vec![
683            ("_x", input(StackInputKind::String)),
684            ("apiKey2", input(StackInputKind::String)),
685        ])
686        .unwrap();
687    }
688
689    #[test]
690    fn labels_descriptions_and_providers_are_required() {
691        let (field, _) = rejected(vec![(
692            "a",
693            AuthoredInput {
694                label: " ".to_string(),
695                ..input(StackInputKind::String)
696            },
697        )]);
698        assert_eq!(field, "input.a.label");
699        let (field, _) = rejected(vec![(
700            "a",
701            AuthoredInput {
702                description: String::new(),
703                ..input(StackInputKind::String)
704            },
705        )]);
706        assert_eq!(field, "input.a.description");
707        let optional = normalize(vec![(
708            "a",
709            AuthoredInput {
710                required: false,
711                description: String::new(),
712                ..input(StackInputKind::String)
713            },
714        )])
715        .unwrap();
716        assert_eq!(optional[0].description, "");
717        let (field, _) = rejected(vec![(
718            "a",
719            AuthoredInput {
720                provided_by: vec![],
721                ..input(StackInputKind::String)
722            },
723        )]);
724        assert_eq!(field, "input.a.providedBy");
725    }
726
727    #[test]
728    fn constraints_belong_to_their_kind() {
729        let cases: Vec<(&str, AuthoredInput)> = vec![
730            (
731                "input.a.min",
732                AuthoredInput {
733                    min: Some(InputValue::Integer(1)),
734                    ..input(StackInputKind::String)
735                },
736            ),
737            (
738                "input.a.minLength",
739                AuthoredInput {
740                    min_length: Some(1),
741                    ..input(StackInputKind::Integer)
742                },
743            ),
744            (
745                "input.a.values",
746                AuthoredInput {
747                    values: Some(vec!["x".into()]),
748                    ..input(StackInputKind::String)
749                },
750            ),
751            (
752                "input.a.maxItems",
753                AuthoredInput {
754                    max_items: Some(2),
755                    ..input(StackInputKind::String)
756                },
757            ),
758            (
759                "input.a.values",
760                AuthoredInput {
761                    values: Some(vec![]),
762                    ..input(StackInputKind::Enum)
763                },
764            ),
765            ("input.a.values", input(StackInputKind::Enum)),
766            (
767                "input.a.minLength",
768                AuthoredInput {
769                    min_length: Some(5),
770                    max_length: Some(4),
771                    ..input(StackInputKind::String)
772                },
773            ),
774            (
775                "input.a.minItems",
776                AuthoredInput {
777                    min_items: Some(3),
778                    max_items: Some(2),
779                    ..input(StackInputKind::StringList)
780                },
781            ),
782        ];
783        for (expected, case) in cases {
784            let (field, reason) = rejected(vec![("a", case)]);
785            assert_eq!(field, expected, "{reason}");
786        }
787    }
788
789    #[test]
790    fn numbers_print_the_way_javascript_prints_them() {
791        let definitions = normalize(vec![
792            (
793                "rate",
794                AuthoredInput {
795                    min: Some(InputValue::Integer(0)),
796                    max: Some(InputValue::Float(1.0)),
797                    default: Some(InputValue::Float(0.25)),
798                    ..input(StackInputKind::Number)
799                },
800            ),
801            (
802                "size",
803                AuthoredInput {
804                    min: Some(InputValue::Float(1.0)),
805                    default: Some(InputValue::Integer(100)),
806                    ..input(StackInputKind::Integer)
807                },
808            ),
809        ])
810        .unwrap();
811        let rate = definitions[0].validation.as_ref().unwrap();
812        assert_eq!(rate.min.as_deref(), Some("0"));
813        assert_eq!(rate.max.as_deref(), Some("1"));
814        assert_eq!(
815            definitions[0].default,
816            Some(StackInputDefaultValue::Number("0.25".to_string()))
817        );
818        assert_eq!(
819            definitions[1].validation.as_ref().unwrap().min.as_deref(),
820            Some("1")
821        );
822
823        let (field, reason) = rejected(vec![(
824            "size",
825            AuthoredInput {
826                max: Some(InputValue::Float(1.5)),
827                ..input(StackInputKind::Integer)
828            },
829        )]);
830        assert_eq!(field, "input.size.max");
831        assert!(reason.contains("whole number"), "{reason}");
832    }
833
834    #[test]
835    fn defaults_are_typed_by_kind_and_secrets_have_none() {
836        let (field, _) = rejected(vec![(
837            "token",
838            AuthoredInput {
839                default: Some(InputValue::String("x".into())),
840                ..input(StackInputKind::Secret)
841            },
842        )]);
843        assert_eq!(field, "input.token.default");
844        let (field, reason) = rejected(vec![(
845            "flag",
846            AuthoredInput {
847                default: Some(InputValue::String("yes".into())),
848                ..input(StackInputKind::Boolean)
849            },
850        )]);
851        assert_eq!(field, "input.flag.default");
852        assert!(reason.contains("a boolean"), "{reason}");
853    }
854
855    #[test]
856    fn generated_secrets_follow_their_rules() {
857        let generated = || AuthoredInput {
858            provided_by: vec![StackInputProvider::Developer],
859            generate: Some(StackInputGenerate { length: 32 }),
860            ..input(StackInputKind::Secret)
861        };
862        let definitions = normalize(vec![("token", generated())]).unwrap();
863        assert_eq!(
864            definitions[0].generate,
865            Some(StackInputGenerate { length: 32 })
866        );
867
868        let cases: Vec<AuthoredInput> = vec![
869            AuthoredInput {
870                kind: StackInputKind::String,
871                ..generated()
872            },
873            AuthoredInput {
874                provided_by: vec![StackInputProvider::Developer, StackInputProvider::Deployer],
875                ..generated()
876            },
877            AuthoredInput {
878                generate: Some(StackInputGenerate { length: 15 }),
879                ..generated()
880            },
881            AuthoredInput {
882                generate: Some(StackInputGenerate { length: 257 }),
883                ..generated()
884            },
885            AuthoredInput {
886                pattern: Some("^[a-z]+$".into()),
887                ..generated()
888            },
889            AuthoredInput {
890                format: Some("url".into()),
891                ..generated()
892            },
893            AuthoredInput {
894                min_length: Some(33),
895                ..generated()
896            },
897            AuthoredInput {
898                max_length: Some(31),
899                ..generated()
900            },
901        ];
902        for case in cases {
903            let (field, reason) = rejected(vec![("token", case)]);
904            assert_eq!(field, "input.token.generate", "{reason}");
905        }
906    }
907
908    #[test]
909    fn env_names_and_targets_are_checked() {
910        let with_env = |env: Vec<AuthoredEnvMapping>| {
911            vec![(
912                "a",
913                AuthoredInput {
914                    env,
915                    ..input(StackInputKind::String)
916                },
917            )]
918        };
919        let mapping = |name: &str, targets: Option<Vec<String>>| AuthoredEnvMapping {
920            name: name.to_string(),
921            target_resources: targets,
922            var_type: None,
923        };
924        let (field, reason) = rejected(with_env(vec![mapping("lower", None)]));
925        assert_eq!(field, "input.a.env");
926        assert!(reason.contains("'lower'"), "{reason}");
927        let (field, _) = rejected(with_env(vec![mapping("OK", Some(vec![]))]));
928        assert_eq!(field, "input.a.env");
929        normalize(with_env(vec![
930            mapping("_OK_1", None),
931            mapping("ALSO", Some(vec!["work*".into()])),
932        ]))
933        .unwrap();
934    }
935
936    #[test]
937    fn patterns_must_be_portable() {
938        for pattern in ["", "/abc/", r"(a)\1", r"\p{L}", "(?i)a", "[a[b]]", r"\k<n>"] {
939            let (field, _) = rejected(vec![(
940                "a",
941                AuthoredInput {
942                    pattern: Some(pattern.to_string()),
943                    ..input(StackInputKind::String)
944                },
945            )]);
946            assert_eq!(field, "input.a.pattern", "{pattern}");
947        }
948        for pattern in ["^[a-z0-9-]+$", r"^\d{3}$", "/", "[a-z][0-9]"] {
949            normalize(vec![(
950                "a",
951                AuthoredInput {
952                    pattern: Some(pattern.to_string()),
953                    ..input(StackInputKind::String)
954                },
955            )])
956            .unwrap_or_else(|error| panic!("{pattern}: {error}"));
957        }
958    }
959
960    #[test]
961    fn an_input_deserializes_from_its_canonical_json() {
962        let input: AuthoredInput = serde_json::from_value(serde_json::json!({
963            "kind": "stringList",
964            "label": "Images",
965            "required": false,
966            "default": ["a", "b"],
967            "maxItems": 8,
968            "platforms": ["machines"],
969            "env": [{ "name": "IMAGES", "targetResources": ["w*"], "type": "plain" }]
970        }))
971        .unwrap();
972        assert_eq!(input.provided_by, [StackInputProvider::Developer]);
973        let definition = input.into_definition("images").unwrap();
974        assert_eq!(
975            definition.default,
976            Some(StackInputDefaultValue::StringList(vec![
977                "a".into(),
978                "b".into()
979            ]))
980        );
981        assert_eq!(definition.platforms, Some(vec![Platform::Machines]));
982
983        let error = serde_json::from_value::<AuthoredInput>(serde_json::json!({
984            "kind": "string", "label": "x", "minlength": 1
985        }))
986        .expect_err("unknown key");
987        assert!(error.to_string().contains("minlength"), "{error}");
988    }
989}