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 shell command to execute.
23    pub command: String,
24    /// Timeout in seconds (default: 300).
25    pub timeout_secs: Option<u64>,
26    /// Working directory.
27    pub dir: Option<String>,
28    /// Environment variables to set.
29    pub env: Vec<(String, String)>,
30    /// If true, start with a clean environment.
31    pub clean_env: bool,
32    /// Files the step promises to produce, collected once it finishes.
33    #[serde(default, skip_serializing_if = "Vec::is_empty")]
34    pub outputs: Vec<ArtifactOutput>,
35    /// Artifacts of earlier steps to place in the working directory first.
36    #[serde(default, skip_serializing_if = "Vec::is_empty")]
37    pub inputs: Vec<ArtifactInput>,
38    /// When `true`, a failure of this step does not fail the run. The step is
39    /// still marked `Failed` but execution continues and the run finishes with
40    /// [`RunStatus::Warning`] instead of `Failed`.
41    #[serde(default)]
42    pub allow_failure: bool,
43    /// When `true`, a non-zero exit code is a normal output instead of an
44    /// error: the step is `Completed` and its output carries the real code.
45    /// Timeout, spawn and input-preparation failures stay errors.
46    #[serde(default)]
47    pub exit_code_as_output: bool,
48    /// Optional step-level retry policy. When set, a transient failure retries
49    /// the step locally with exponential backoff before propagating the error.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub retry: Option<RetryPolicy>,
52}
53
54impl ShellConfig {
55    /// Create a new shell config with the given command.
56    ///
57    /// # Examples
58    ///
59    /// ```
60    /// use ironflow_engine::config::ShellConfig;
61    ///
62    /// let config = ShellConfig::new("echo hello");
63    /// assert_eq!(config.command, "echo hello");
64    /// ```
65    pub fn new(command: &str) -> Self {
66        Self {
67            command: command.to_string(),
68            timeout_secs: None,
69            dir: None,
70            env: Vec::new(),
71            clean_env: false,
72            outputs: Vec::new(),
73            inputs: Vec::new(),
74            allow_failure: false,
75            exit_code_as_output: false,
76            retry: None,
77        }
78    }
79
80    /// Set the timeout in seconds.
81    pub fn timeout_secs(mut self, secs: u64) -> Self {
82        self.timeout_secs = Some(secs);
83        self
84    }
85
86    /// Set the working directory.
87    pub fn dir(mut self, dir: &str) -> Self {
88        self.dir = Some(dir.to_string());
89        self
90    }
91
92    /// Add an environment variable.
93    pub fn env(mut self, key: &str, value: &str) -> Self {
94        self.env.push((key.to_string(), value.to_string()));
95        self
96    }
97
98    /// Start with a clean environment (no inherited vars).
99    pub fn clean_env(mut self) -> Self {
100        self.clean_env = true;
101        self
102    }
103
104    /// Declare a file the step produces, typed from its name.
105    ///
106    /// `pattern` is a glob resolved against [`dir`](Self::dir). Every match is
107    /// stored as an artifact named after the file. When the step succeeds and
108    /// the pattern matches nothing, the step fails.
109    ///
110    /// # Examples
111    ///
112    /// ```
113    /// use ironflow_engine::config::ShellConfig;
114    ///
115    /// let config = ShellConfig::new("cargo build").output("target/*.log");
116    /// assert_eq!(config.outputs.len(), 1);
117    /// ```
118    pub fn output(mut self, pattern: &str) -> Self {
119        self.outputs.push(ArtifactOutput::new(pattern));
120        self
121    }
122
123    /// Declare a produced file with an explicit MIME type.
124    ///
125    /// # Examples
126    ///
127    /// ```
128    /// use ironflow_engine::config::ShellConfig;
129    ///
130    /// let config = ShellConfig::new("./gen").output_typed("data", "application/json");
131    /// assert_eq!(config.outputs[0].content_type.as_deref(), Some("application/json"));
132    /// ```
133    pub fn output_typed(mut self, pattern: &str, content_type: &str) -> Self {
134        self.outputs
135            .push(ArtifactOutput::typed(pattern, content_type));
136        self
137    }
138
139    /// Consume an artifact produced by an earlier step of the same run.
140    ///
141    /// The handle comes from the producing step, see [`ArtifactRef`]. The
142    /// artifact is written into the working directory under its own name
143    /// before the command runs. Use [`input_at`](Self::input_at) to choose
144    /// another path.
145    ///
146    /// # Examples
147    ///
148    /// ```no_run
149    /// use ironflow_engine::config::ShellConfig;
150    /// use ironflow_engine::context::WorkflowContext;
151    /// use ironflow_engine::error::EngineError;
152    ///
153    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
154    /// let build = ctx.shell("build", ShellConfig::new("./gen").output("report.html")).await?;
155    /// let report = build.artifact("report.html")?;
156    /// let config = ShellConfig::new("./publish").input(&report);
157    /// assert_eq!(config.inputs[0].destination(), "report.html");
158    /// # Ok(())
159    /// # }
160    /// ```
161    pub fn input(mut self, artifact: &ArtifactRef) -> Self {
162        self.inputs.push(ArtifactInput::from(artifact));
163        self
164    }
165
166    /// Mark this step as allowed to fail without stopping the run.
167    ///
168    /// # Examples
169    ///
170    /// ```
171    /// use ironflow_engine::config::ShellConfig;
172    ///
173    /// let config = ShellConfig::new("cargo clippy").allow_failure();
174    /// assert!(config.allow_failure);
175    /// ```
176    pub fn allow_failure(mut self) -> Self {
177        self.allow_failure = true;
178        self
179    }
180
181    /// Treat a non-zero exit code as data instead of a failure.
182    ///
183    /// The step is `Completed`: `StepOutput::is_success()` is `false` and
184    /// `exit_code()` returns the real code, so the handler can branch on it
185    /// (a conflicting `git merge`, red tests). The run is not degraded and
186    /// [`allow_failure`](Self::allow_failure) is not triggered. A non-zero
187    /// exit is no longer an error, so a retry policy does not retry it.
188    /// Timeout, spawn failure and input-preparation failure remain errors and
189    /// follow `allow_failure`.
190    ///
191    /// # Examples
192    ///
193    /// ```
194    /// use ironflow_engine::config::ShellConfig;
195    ///
196    /// let config = ShellConfig::new("git merge feature").exit_code_as_output();
197    /// assert!(config.exit_code_as_output);
198    /// ```
199    pub fn exit_code_as_output(mut self) -> Self {
200        self.exit_code_as_output = true;
201        self
202    }
203
204    /// Set a step-level retry policy.
205    ///
206    /// # Examples
207    ///
208    /// ```
209    /// use ironflow_core::retry::RetryPolicy;
210    /// use ironflow_engine::config::ShellConfig;
211    ///
212    /// let config = ShellConfig::new("curl http://api")
213    ///     .retry_policy(RetryPolicy::new(3));
214    /// assert!(config.retry.is_some());
215    /// ```
216    pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
217        self.retry = Some(policy);
218        self
219    }
220
221    /// Consume an artifact and write it to an explicit path.
222    ///
223    /// # Examples
224    ///
225    /// ```no_run
226    /// use ironflow_engine::config::ShellConfig;
227    /// use ironflow_engine::context::WorkflowContext;
228    /// use ironflow_engine::error::EngineError;
229    ///
230    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
231    /// let build = ctx.shell("build", ShellConfig::new("./gen").output("report.html")).await?;
232    /// let report = build.artifact("report.html")?;
233    /// let config = ShellConfig::new("./publish").input_at(&report, "in/r.html");
234    /// assert_eq!(config.inputs[0].destination(), "in/r.html");
235    /// # Ok(())
236    /// # }
237    /// ```
238    pub fn input_at(mut self, artifact: &ArtifactRef, dest: &str) -> Self {
239        self.inputs.push(ArtifactInput::from(artifact).at(dest));
240        self
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn builder() {
250        let config = ShellConfig::new("cargo test")
251            .timeout_secs(60)
252            .dir("/app")
253            .env("RUST_LOG", "debug")
254            .clean_env();
255
256        assert_eq!(config.command, "cargo test");
257        assert_eq!(config.timeout_secs, Some(60));
258        assert_eq!(config.dir, Some("/app".to_string()));
259        assert_eq!(
260            config.env,
261            vec![("RUST_LOG".to_string(), "debug".to_string())]
262        );
263        assert!(config.clean_env);
264    }
265
266    #[test]
267    fn a_fresh_config_declares_no_artifact() {
268        let config = ShellConfig::new("echo hi");
269        assert!(config.outputs.is_empty());
270        assert!(config.inputs.is_empty());
271    }
272
273    #[test]
274    fn outputs_and_inputs_accumulate_in_declaration_order() {
275        let config = ShellConfig::new("build")
276            .output("a.txt")
277            .output_typed("b", "text/csv")
278            .input(&ArtifactRef::new("prev", "c.txt"))
279            .input_at(&ArtifactRef::new("prev", "d.txt"), "in/d.txt");
280
281        assert_eq!(config.outputs[0].pattern, "a.txt");
282        assert_eq!(config.outputs[1].content_type.as_deref(), Some("text/csv"));
283        assert_eq!(config.inputs[0], ArtifactInput::new("prev", "c.txt"));
284        assert_eq!(config.inputs[0].destination(), "c.txt");
285        assert_eq!(config.inputs[1].step, "prev");
286        assert_eq!(config.inputs[1].destination(), "in/d.txt");
287    }
288
289    #[test]
290    fn serde_omits_empty_artifact_declarations() {
291        let json = serde_json::to_string(&ShellConfig::new("echo hi")).expect("serialize");
292        assert!(!json.contains("outputs"));
293        assert!(!json.contains("inputs"));
294    }
295
296    #[test]
297    fn a_config_predating_artifacts_still_deserializes() {
298        let config: ShellConfig = serde_json::from_str(
299            r#"{"command":"echo hi","timeout_secs":null,"dir":null,"env":[],"clean_env":false}"#,
300        )
301        .expect("deserialize");
302
303        assert!(config.outputs.is_empty());
304        assert!(config.inputs.is_empty());
305    }
306
307    #[test]
308    fn a_config_predating_retry_still_deserializes() {
309        let config: ShellConfig = serde_json::from_str(
310            r#"{"command":"echo hi","timeout_secs":null,"dir":null,"env":[],"clean_env":false,"allow_failure":false}"#,
311        )
312        .expect("deserialize");
313
314        assert!(config.retry.is_none());
315    }
316
317    #[test]
318    fn a_config_predating_exit_code_as_output_still_deserializes() {
319        let config: ShellConfig = serde_json::from_str(
320            r#"{"command":"echo hi","timeout_secs":null,"dir":null,"env":[],"clean_env":false,"allow_failure":false}"#,
321        )
322        .expect("deserialize");
323
324        assert!(!config.exit_code_as_output);
325    }
326
327    #[test]
328    fn exit_code_as_output_roundtrip() {
329        assert!(!ShellConfig::new("x").exit_code_as_output);
330
331        let config = ShellConfig::new("git merge x").exit_code_as_output();
332        let json = serde_json::to_string(&config).expect("serialize");
333        let back: ShellConfig = serde_json::from_str(&json).expect("deserialize");
334        assert!(back.exit_code_as_output);
335    }
336
337    #[test]
338    fn retry_policy_roundtrip() {
339        use ironflow_core::retry::RetryPolicy;
340
341        let config = ShellConfig::new("curl http://api").retry_policy(RetryPolicy::new(3));
342        let json = serde_json::to_string(&config).expect("serialize");
343        let back: ShellConfig = serde_json::from_str(&json).expect("deserialize");
344        assert_eq!(back.retry.as_ref().unwrap().max_retries(), 3);
345    }
346}