Skip to main content

openjd_model/template/
step.rs

1// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2// Copyright by contributors to this project.
3// SPDX-License-Identifier: (Apache-2.0 OR MIT)
4
5//! Step types per spec §3.
6
7use super::actions::{Action, CancelationMode, StepActions};
8use super::constrained_strings::Description;
9use super::environment::{EmbeddedFile, Environment};
10use super::host_requirements::HostRequirements;
11use super::task_parameters::StepParameterSpaceDefinition;
12use crate::format_string::FormatString;
13use serde::Deserialize;
14
15/// SimpleAction syntax sugar (FEATURE_BUNDLE_1).
16/// Allows specifying a script interpreter directly instead of a full StepScript.
17#[derive(Debug, Clone, Deserialize)]
18#[serde(rename_all = "camelCase", deny_unknown_fields)]
19pub struct SimpleAction {
20    /// Let bindings evaluated once per task (requires EXPR extension).
21    #[serde(rename = "let")]
22    pub let_bindings: Option<Vec<String>>,
23    /// The script content to execute. Required.
24    pub script: String,
25    /// Additional arguments to pass to the interpreter.
26    pub args: Option<Vec<FormatString>>,
27    /// Maximum allowed runtime in seconds.
28    pub timeout: Option<FormatString>,
29    /// How to cancel the action.
30    pub cancelation: Option<CancelationMode>,
31}
32
33/// §3 StepTemplate
34#[derive(Debug, Clone, Deserialize)]
35#[serde(rename_all = "camelCase", deny_unknown_fields)]
36pub struct StepTemplate {
37    pub name: String,
38    pub description: Option<Description>,
39    #[serde(rename = "let")]
40    pub let_bindings: Option<Vec<String>>,
41    pub dependencies: Option<Vec<StepDependency>>,
42    pub step_environments: Option<Vec<Environment>>,
43    pub host_requirements: Option<HostRequirements>,
44    pub parameter_space: Option<StepParameterSpaceDefinition>,
45    pub script: Option<StepScript>,
46    // SimpleAction syntax sugar (§3.5, FEATURE_BUNDLE_1)
47    pub bash: Option<SimpleAction>,
48    pub python: Option<SimpleAction>,
49    pub cmd: Option<SimpleAction>,
50    pub powershell: Option<SimpleAction>,
51    pub node: Option<SimpleAction>,
52}
53
54impl StepTemplate {
55    /// De-sugar SimpleAction syntax into equivalent StepScript.
56    /// If the step already has a `script` field, returns `Ok(Some(clone))`.
57    /// If it uses a SimpleAction (bash/python/cmd/powershell/node), transforms
58    /// it into a StepScript with an embedded file and onRun action.
59    /// Returns `Err` if the SimpleAction script contains malformed format string syntax.
60    pub fn resolve_syntax_sugar(&self) -> Result<Option<StepScript>, crate::ModelError> {
61        if let Some(script) = &self.script {
62            return Ok(Some(script.clone()));
63        }
64
65        let interpreters: &[(&str, &str, &[&str], Option<&SimpleAction>)] = &[
66            ("python", ".py", &[], self.python.as_ref()),
67            ("bash", ".sh", &[], self.bash.as_ref()),
68            ("cmd", ".bat", &["/C"], self.cmd.as_ref()),
69            ("powershell", ".ps1", &["-File"], self.powershell.as_ref()),
70            ("node", ".js", &[], self.node.as_ref()),
71        ];
72
73        for &(command, ext, arg_prefix, sa_opt) in interpreters {
74            let Some(sa) = sa_opt else { continue };
75
76            let safe_name: String = self
77                .name
78                .chars()
79                .map(|c| if c.is_alphanumeric() { c } else { '_' })
80                .take(200)
81                .collect();
82            let safe_name = if safe_name.starts_with(|c: char| c.is_ascii_digit()) {
83                format!("_{safe_name}")
84            } else {
85                safe_name
86            };
87            let embedded_name = format!("{safe_name}_script");
88            let filename = format!("{embedded_name}{ext}");
89            let file_ref = format!("{{{{Task.File.{embedded_name}}}}}");
90
91            let mut args = Vec::new();
92            for prefix_arg in arg_prefix {
93                args.push(FormatString::new(prefix_arg).unwrap());
94            }
95            args.push(FormatString::new(&file_ref).unwrap());
96            if let Some(user_args) = &sa.args {
97                args.extend(user_args.iter().cloned());
98            }
99
100            return Ok(Some(StepScript {
101                let_bindings: sa.let_bindings.clone(),
102                actions: StepActions {
103                    on_run: Action {
104                        command: FormatString::new(command).unwrap(),
105                        args: Some(args),
106                        cancelation: sa.cancelation.clone(),
107                        timeout: sa.timeout.clone(),
108                    },
109                },
110                embedded_files: Some(vec![EmbeddedFile {
111                    name: embedded_name,
112                    file_type: crate::types::FileType::Text,
113                    filename: Some(FormatString::new(&filename).unwrap()),
114                    data: Some(FormatString::new(&sa.script).map_err(|e| {
115                        crate::ModelError::DecodeValidation(format!(
116                            "SimpleAction script format string error: {e}"
117                        ))
118                    })?),
119                    runnable: Some(true),
120                    end_of_line: None,
121                }]),
122            }));
123        }
124
125        Ok(None)
126    }
127}
128
129/// §3.2 StepDependency
130#[derive(Debug, Clone, Deserialize)]
131#[serde(rename_all = "camelCase", deny_unknown_fields)]
132pub struct StepDependency {
133    pub depends_on: String,
134}
135
136/// §3.5 StepScript
137#[derive(Debug, Clone, Deserialize)]
138#[serde(rename_all = "camelCase", deny_unknown_fields)]
139pub struct StepScript {
140    #[serde(rename = "let")]
141    pub let_bindings: Option<Vec<String>>,
142    pub actions: StepActions,
143    pub embedded_files: Option<Vec<EmbeddedFile>>,
144}
145
146#[cfg(test)]
147mod tests {
148    use super::StepTemplate;
149
150    #[test]
151    fn resolve_syntax_sugar_returns_error_for_malformed_format_string() {
152        let step: StepTemplate = serde_saphyr::from_str(
153            r#"
154            name: TestStep
155            bash:
156              script: "echo '{{broken'"
157            "#,
158        )
159        .unwrap();
160
161        let result = step.resolve_syntax_sugar();
162        assert!(
163            result.is_err(),
164            "resolve_syntax_sugar should return Err for malformed format string"
165        );
166    }
167
168    #[test]
169    fn resolve_syntax_sugar_ok_for_valid_script() {
170        let step: StepTemplate = serde_saphyr::from_str(
171            r#"
172            name: TestStep
173            bash:
174              script: "echo hello"
175            "#,
176        )
177        .unwrap();
178
179        let result = step.resolve_syntax_sugar();
180        assert!(result.is_ok(), "valid script should succeed");
181        assert!(
182            result.unwrap().is_some(),
183            "bash step should produce a StepScript"
184        );
185    }
186}