Skip to main content

ironflow_engine/config/
shell.rs

1//! [`ShellConfig`] — serializable configuration for a shell step.
2
3use ironflow_core::retry::RetryPolicy;
4use serde::{Deserialize, Serialize};
5
6use super::artifact::{ArtifactInput, ArtifactOutput, ArtifactRef};
7
8/// Serializable configuration for a shell step.
9///
10/// # Examples
11///
12/// ```
13/// use ironflow_engine::config::ShellConfig;
14///
15/// let config = ShellConfig::new("cargo build --release")
16///     .timeout_secs(300)
17///     .dir("/app")
18///     .output("target/report.html");
19/// ```
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ShellConfig {
22    /// The command line passed to `sh -c`, or the program to run when
23    /// [`args`](Self::args) is set.
24    pub command: String,
25    /// Arguments of [`command`](Self::command) when it runs without a shell.
26    ///
27    /// `Some` means exec mode: `command` is spawned directly with these
28    /// arguments, none of them is interpreted. `None` means `command` goes
29    /// through `sh -c`. Set by [`ShellConfig::exec`].
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub args: Option<Vec<String>>,
32    /// Timeout in seconds (default: 300).
33    pub timeout_secs: Option<u64>,
34    /// Working directory.
35    pub dir: Option<String>,
36    /// Environment variables to set.
37    pub env: Vec<(String, String)>,
38    /// If true, start with a clean environment.
39    pub clean_env: bool,
40    /// Files the step promises to produce, collected once it finishes.
41    #[serde(default, skip_serializing_if = "Vec::is_empty")]
42    pub outputs: Vec<ArtifactOutput>,
43    /// Artifacts of earlier steps to place in the working directory first.
44    #[serde(default, skip_serializing_if = "Vec::is_empty")]
45    pub inputs: Vec<ArtifactInput>,
46    /// When `true`, a failure of this step does not fail the run. The step is
47    /// still marked `Failed` but execution continues and the run finishes with
48    /// `RunStatus::Warning` instead of `Failed`.
49    #[serde(default)]
50    pub allow_failure: bool,
51    /// When `true`, a non-zero exit code is a normal output instead of an
52    /// error: the step is `Completed` and its output carries the real code.
53    /// Timeout, spawn and input-preparation failures stay errors.
54    #[serde(default)]
55    pub exit_code_as_output: bool,
56    /// Optional step-level retry policy. When set, a transient failure retries
57    /// the step locally with exponential backoff before propagating the error.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub retry: Option<RetryPolicy>,
60}
61
62impl ShellConfig {
63    /// Create a new shell config with the given command.
64    ///
65    /// The command is passed to `sh -c`, so pipes, redirects and globs work.
66    ///
67    /// # Security
68    ///
69    /// Never build the command from data the workflow does not control (its
70    /// input, a webhook payload, a human answer, an agent output): a quote or a
71    /// `;` in that data runs arbitrary commands on the worker. Use
72    /// [`ShellConfig::exec`] to pass such data as arguments, or
73    /// [`env`](Self::env) to hand it to a script as a variable.
74    ///
75    /// # Examples
76    ///
77    /// ```
78    /// use ironflow_engine::config::ShellConfig;
79    ///
80    /// let config = ShellConfig::new("echo hello");
81    /// assert_eq!(config.command, "echo hello");
82    /// ```
83    pub fn new(command: &str) -> Self {
84        Self {
85            command: command.to_string(),
86            args: None,
87            timeout_secs: None,
88            dir: None,
89            env: Vec::new(),
90            clean_env: false,
91            outputs: Vec::new(),
92            inputs: Vec::new(),
93            allow_failure: false,
94            exit_code_as_output: false,
95            retry: None,
96        }
97    }
98
99    /// Create a config that runs `program` directly, without a shell.
100    ///
101    /// Each argument reaches the program as is: quotes, `;`, `$(..)`,
102    /// backticks and globs are plain text. This is the way to run a command
103    /// built from untrusted data. The program is looked up in `PATH`.
104    ///
105    /// # Examples
106    ///
107    /// ```
108    /// use ironflow_engine::config::ShellConfig;
109    ///
110    /// let name = "Ada'; rm -rf / #";
111    /// let config = ShellConfig::exec("printf", &["Hello, %s!\n", name]);
112    /// assert_eq!(config.command, "printf");
113    /// assert_eq!(config.args.as_deref().map(<[String]>::len), Some(2));
114    /// ```
115    pub fn exec(program: &str, args: &[&str]) -> Self {
116        Self {
117            args: Some(args.iter().map(|arg| (*arg).to_string()).collect()),
118            ..Self::new(program)
119        }
120    }
121
122    /// Set the timeout in seconds.
123    pub fn timeout_secs(mut self, secs: u64) -> Self {
124        self.timeout_secs = Some(secs);
125        self
126    }
127
128    /// Set the working directory.
129    pub fn dir(mut self, dir: &str) -> Self {
130        self.dir = Some(dir.to_string());
131        self
132    }
133
134    /// Add an environment variable.
135    pub fn env(mut self, key: &str, value: &str) -> Self {
136        self.env.push((key.to_string(), value.to_string()));
137        self
138    }
139
140    /// Start with a clean environment (no inherited vars).
141    pub fn clean_env(mut self) -> Self {
142        self.clean_env = true;
143        self
144    }
145
146    /// Declare a file the step produces, typed from its name.
147    ///
148    /// `pattern` is a glob resolved against [`dir`](Self::dir). Every match is
149    /// stored as an artifact named after the file. When the step succeeds and
150    /// the pattern matches nothing, the step fails.
151    ///
152    /// # Examples
153    ///
154    /// ```
155    /// use ironflow_engine::config::ShellConfig;
156    ///
157    /// let config = ShellConfig::new("cargo build").output("target/*.log");
158    /// assert_eq!(config.outputs.len(), 1);
159    /// ```
160    pub fn output(mut self, pattern: &str) -> Self {
161        self.outputs.push(ArtifactOutput::new(pattern));
162        self
163    }
164
165    /// Declare a produced file with an explicit MIME type.
166    ///
167    /// # Examples
168    ///
169    /// ```
170    /// use ironflow_engine::config::ShellConfig;
171    ///
172    /// let config = ShellConfig::new("./gen").output_typed("data", "application/json");
173    /// assert_eq!(config.outputs[0].content_type.as_deref(), Some("application/json"));
174    /// ```
175    pub fn output_typed(mut self, pattern: &str, content_type: &str) -> Self {
176        self.outputs
177            .push(ArtifactOutput::typed(pattern, content_type));
178        self
179    }
180
181    /// Consume an artifact produced by an earlier step of the same run.
182    ///
183    /// The handle comes from the producing step, see [`ArtifactRef`]. The
184    /// artifact is written into the working directory under its own name
185    /// before the command runs. Use [`input_at`](Self::input_at) to choose
186    /// another path.
187    ///
188    /// # Examples
189    ///
190    /// ```no_run
191    /// use ironflow_engine::config::ShellConfig;
192    /// use ironflow_engine::context::WorkflowContext;
193    /// use ironflow_engine::error::EngineError;
194    ///
195    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
196    /// let build = ctx.shell("build", ShellConfig::new("./gen").output("report.html")).await?;
197    /// let report = build.artifact("report.html")?;
198    /// let config = ShellConfig::new("./publish").input(&report);
199    /// assert_eq!(config.inputs[0].destination(), "report.html");
200    /// # Ok(())
201    /// # }
202    /// ```
203    pub fn input(mut self, artifact: &ArtifactRef) -> Self {
204        self.inputs.push(ArtifactInput::from(artifact));
205        self
206    }
207
208    /// Mark this step as allowed to fail without stopping the run.
209    ///
210    /// # Examples
211    ///
212    /// ```
213    /// use ironflow_engine::config::ShellConfig;
214    ///
215    /// let config = ShellConfig::new("cargo clippy").allow_failure();
216    /// assert!(config.allow_failure);
217    /// ```
218    pub fn allow_failure(mut self) -> Self {
219        self.allow_failure = true;
220        self
221    }
222
223    /// Treat a non-zero exit code as data instead of a failure.
224    ///
225    /// The step is `Completed`: `StepOutput::is_success()` is `false` and
226    /// `exit_code()` returns the real code, so the handler can branch on it
227    /// (a conflicting `git merge`, red tests). The run is not degraded and
228    /// [`allow_failure`](Self::allow_failure) is not triggered. A non-zero
229    /// exit is no longer an error, so a retry policy does not retry it.
230    /// Timeout, spawn failure and input-preparation failure remain errors and
231    /// follow `allow_failure`.
232    ///
233    /// # Examples
234    ///
235    /// ```
236    /// use ironflow_engine::config::ShellConfig;
237    ///
238    /// let config = ShellConfig::new("git merge feature").exit_code_as_output();
239    /// assert!(config.exit_code_as_output);
240    /// ```
241    pub fn exit_code_as_output(mut self) -> Self {
242        self.exit_code_as_output = true;
243        self
244    }
245
246    /// Set a step-level retry policy.
247    ///
248    /// # Examples
249    ///
250    /// ```
251    /// use ironflow_core::retry::RetryPolicy;
252    /// use ironflow_engine::config::ShellConfig;
253    ///
254    /// let config = ShellConfig::new("curl http://api")
255    ///     .retry_policy(RetryPolicy::new(3));
256    /// assert!(config.retry.is_some());
257    /// ```
258    pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
259        self.retry = Some(policy);
260        self
261    }
262
263    /// Consume an artifact and write it to an explicit path.
264    ///
265    /// # Examples
266    ///
267    /// ```no_run
268    /// use ironflow_engine::config::ShellConfig;
269    /// use ironflow_engine::context::WorkflowContext;
270    /// use ironflow_engine::error::EngineError;
271    ///
272    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
273    /// let build = ctx.shell("build", ShellConfig::new("./gen").output("report.html")).await?;
274    /// let report = build.artifact("report.html")?;
275    /// let config = ShellConfig::new("./publish").input_at(&report, "in/r.html");
276    /// assert_eq!(config.inputs[0].destination(), "in/r.html");
277    /// # Ok(())
278    /// # }
279    /// ```
280    pub fn input_at(mut self, artifact: &ArtifactRef, dest: &str) -> Self {
281        self.inputs.push(ArtifactInput::from(artifact).at(dest));
282        self
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn builder() {
292        let config = ShellConfig::new("cargo test")
293            .timeout_secs(60)
294            .dir("/app")
295            .env("RUST_LOG", "debug")
296            .clean_env();
297
298        assert_eq!(config.command, "cargo test");
299        assert_eq!(config.timeout_secs, Some(60));
300        assert_eq!(config.dir, Some("/app".to_string()));
301        assert_eq!(
302            config.env,
303            vec![("RUST_LOG".to_string(), "debug".to_string())]
304        );
305        assert!(config.clean_env);
306    }
307
308    #[test]
309    fn a_fresh_config_declares_no_artifact() {
310        let config = ShellConfig::new("echo hi");
311        assert!(config.outputs.is_empty());
312        assert!(config.inputs.is_empty());
313    }
314
315    #[test]
316    fn outputs_and_inputs_accumulate_in_declaration_order() {
317        let config = ShellConfig::new("build")
318            .output("a.txt")
319            .output_typed("b", "text/csv")
320            .input(&ArtifactRef::new("prev", "c.txt"))
321            .input_at(&ArtifactRef::new("prev", "d.txt"), "in/d.txt");
322
323        assert_eq!(config.outputs[0].pattern, "a.txt");
324        assert_eq!(config.outputs[1].content_type.as_deref(), Some("text/csv"));
325        assert_eq!(config.inputs[0], ArtifactInput::new("prev", "c.txt"));
326        assert_eq!(config.inputs[0].destination(), "c.txt");
327        assert_eq!(config.inputs[1].step, "prev");
328        assert_eq!(config.inputs[1].destination(), "in/d.txt");
329    }
330
331    #[test]
332    fn serde_omits_empty_artifact_declarations() {
333        let json = serde_json::to_string(&ShellConfig::new("echo hi")).expect("serialize");
334        assert!(!json.contains("outputs"));
335        assert!(!json.contains("inputs"));
336    }
337
338    #[test]
339    fn a_config_predating_artifacts_still_deserializes() {
340        let config: ShellConfig = serde_json::from_str(
341            r#"{"command":"echo hi","timeout_secs":null,"dir":null,"env":[],"clean_env":false}"#,
342        )
343        .expect("deserialize");
344
345        assert!(config.outputs.is_empty());
346        assert!(config.inputs.is_empty());
347    }
348
349    #[test]
350    fn a_config_predating_retry_still_deserializes() {
351        let config: ShellConfig = serde_json::from_str(
352            r#"{"command":"echo hi","timeout_secs":null,"dir":null,"env":[],"clean_env":false,"allow_failure":false}"#,
353        )
354        .expect("deserialize");
355
356        assert!(config.retry.is_none());
357    }
358
359    #[test]
360    fn a_config_predating_exit_code_as_output_still_deserializes() {
361        let config: ShellConfig = serde_json::from_str(
362            r#"{"command":"echo hi","timeout_secs":null,"dir":null,"env":[],"clean_env":false,"allow_failure":false}"#,
363        )
364        .expect("deserialize");
365
366        assert!(!config.exit_code_as_output);
367    }
368
369    #[test]
370    fn exit_code_as_output_roundtrip() {
371        assert!(!ShellConfig::new("x").exit_code_as_output);
372
373        let config = ShellConfig::new("git merge x").exit_code_as_output();
374        let json = serde_json::to_string(&config).expect("serialize");
375        let back: ShellConfig = serde_json::from_str(&json).expect("deserialize");
376        assert!(back.exit_code_as_output);
377    }
378
379    #[test]
380    fn exec_keeps_the_program_and_each_argument_apart() {
381        let config = ShellConfig::exec("printf", &["%s\n", "a b; rm -rf /"]);
382
383        assert_eq!(config.command, "printf");
384        assert_eq!(
385            config.args,
386            Some(vec!["%s\n".to_string(), "a b; rm -rf /".to_string()])
387        );
388    }
389
390    #[test]
391    fn exec_with_no_argument_is_still_exec_mode() {
392        let config = ShellConfig::exec("true", &[]);
393        assert_eq!(config.args, Some(Vec::new()));
394    }
395
396    #[test]
397    fn new_runs_through_the_shell() {
398        assert!(ShellConfig::new("echo hi").args.is_none());
399    }
400
401    #[test]
402    fn exec_args_roundtrip_and_are_omitted_in_shell_mode() {
403        let shell = serde_json::to_string(&ShellConfig::new("echo hi")).expect("serialize");
404        assert!(!shell.contains("args"));
405
406        let json =
407            serde_json::to_string(&ShellConfig::exec("git", &["log", "-1"])).expect("serialize");
408        let back: ShellConfig = serde_json::from_str(&json).expect("deserialize");
409        assert_eq!(back.command, "git");
410        assert_eq!(back.args, Some(vec!["log".to_string(), "-1".to_string()]));
411    }
412
413    #[test]
414    fn a_config_predating_exec_still_deserializes_in_shell_mode() {
415        let config: ShellConfig = serde_json::from_str(
416            r#"{"command":"echo hi","timeout_secs":null,"dir":null,"env":[],"clean_env":false,"allow_failure":false}"#,
417        )
418        .expect("deserialize");
419
420        assert!(config.args.is_none());
421    }
422
423    #[test]
424    fn retry_policy_roundtrip() {
425        use ironflow_core::retry::RetryPolicy;
426
427        let config = ShellConfig::new("curl http://api").retry_policy(RetryPolicy::new(3));
428        let json = serde_json::to_string(&config).expect("serialize");
429        let back: ShellConfig = serde_json::from_str(&json).expect("deserialize");
430        assert_eq!(back.retry.as_ref().unwrap().max_retries(), 3);
431    }
432}