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    /// Optional step-level retry policy. When set, a transient failure retries
44    /// the step locally with exponential backoff before propagating the error.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub retry: Option<RetryPolicy>,
47}
48
49impl ShellConfig {
50    /// Create a new shell config with the given command.
51    ///
52    /// # Examples
53    ///
54    /// ```
55    /// use ironflow_engine::config::ShellConfig;
56    ///
57    /// let config = ShellConfig::new("echo hello");
58    /// assert_eq!(config.command, "echo hello");
59    /// ```
60    pub fn new(command: &str) -> Self {
61        Self {
62            command: command.to_string(),
63            timeout_secs: None,
64            dir: None,
65            env: Vec::new(),
66            clean_env: false,
67            outputs: Vec::new(),
68            inputs: Vec::new(),
69            allow_failure: false,
70            retry: None,
71        }
72    }
73
74    /// Set the timeout in seconds.
75    pub fn timeout_secs(mut self, secs: u64) -> Self {
76        self.timeout_secs = Some(secs);
77        self
78    }
79
80    /// Set the working directory.
81    pub fn dir(mut self, dir: &str) -> Self {
82        self.dir = Some(dir.to_string());
83        self
84    }
85
86    /// Add an environment variable.
87    pub fn env(mut self, key: &str, value: &str) -> Self {
88        self.env.push((key.to_string(), value.to_string()));
89        self
90    }
91
92    /// Start with a clean environment (no inherited vars).
93    pub fn clean_env(mut self) -> Self {
94        self.clean_env = true;
95        self
96    }
97
98    /// Declare a file the step produces, typed from its name.
99    ///
100    /// `pattern` is a glob resolved against [`dir`](Self::dir). Every match is
101    /// stored as an artifact named after the file. When the step succeeds and
102    /// the pattern matches nothing, the step fails.
103    ///
104    /// # Examples
105    ///
106    /// ```
107    /// use ironflow_engine::config::ShellConfig;
108    ///
109    /// let config = ShellConfig::new("cargo build").output("target/*.log");
110    /// assert_eq!(config.outputs.len(), 1);
111    /// ```
112    pub fn output(mut self, pattern: &str) -> Self {
113        self.outputs.push(ArtifactOutput::new(pattern));
114        self
115    }
116
117    /// Declare a produced file with an explicit MIME type.
118    ///
119    /// # Examples
120    ///
121    /// ```
122    /// use ironflow_engine::config::ShellConfig;
123    ///
124    /// let config = ShellConfig::new("./gen").output_typed("data", "application/json");
125    /// assert_eq!(config.outputs[0].content_type.as_deref(), Some("application/json"));
126    /// ```
127    pub fn output_typed(mut self, pattern: &str, content_type: &str) -> Self {
128        self.outputs
129            .push(ArtifactOutput::typed(pattern, content_type));
130        self
131    }
132
133    /// Consume an artifact produced by an earlier step of the same run.
134    ///
135    /// The handle comes from the producing step, see [`ArtifactRef`]. The
136    /// artifact is written into the working directory under its own name
137    /// before the command runs. Use [`input_at`](Self::input_at) to choose
138    /// another path.
139    ///
140    /// # Examples
141    ///
142    /// ```no_run
143    /// use ironflow_engine::config::ShellConfig;
144    /// use ironflow_engine::context::WorkflowContext;
145    /// use ironflow_engine::error::EngineError;
146    ///
147    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
148    /// let build = ctx.shell("build", ShellConfig::new("./gen").output("report.html")).await?;
149    /// let report = build.artifact("report.html")?;
150    /// let config = ShellConfig::new("./publish").input(&report);
151    /// assert_eq!(config.inputs[0].destination(), "report.html");
152    /// # Ok(())
153    /// # }
154    /// ```
155    pub fn input(mut self, artifact: &ArtifactRef) -> Self {
156        self.inputs.push(ArtifactInput::from(artifact));
157        self
158    }
159
160    /// Mark this step as allowed to fail without stopping the run.
161    ///
162    /// # Examples
163    ///
164    /// ```
165    /// use ironflow_engine::config::ShellConfig;
166    ///
167    /// let config = ShellConfig::new("cargo clippy").allow_failure();
168    /// assert!(config.allow_failure);
169    /// ```
170    pub fn allow_failure(mut self) -> Self {
171        self.allow_failure = true;
172        self
173    }
174
175    /// Set a step-level retry policy.
176    ///
177    /// # Examples
178    ///
179    /// ```
180    /// use ironflow_core::retry::RetryPolicy;
181    /// use ironflow_engine::config::ShellConfig;
182    ///
183    /// let config = ShellConfig::new("curl http://api")
184    ///     .retry_policy(RetryPolicy::new(3));
185    /// assert!(config.retry.is_some());
186    /// ```
187    pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
188        self.retry = Some(policy);
189        self
190    }
191
192    /// Consume an artifact and write it to an explicit path.
193    ///
194    /// # Examples
195    ///
196    /// ```no_run
197    /// use ironflow_engine::config::ShellConfig;
198    /// use ironflow_engine::context::WorkflowContext;
199    /// use ironflow_engine::error::EngineError;
200    ///
201    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
202    /// let build = ctx.shell("build", ShellConfig::new("./gen").output("report.html")).await?;
203    /// let report = build.artifact("report.html")?;
204    /// let config = ShellConfig::new("./publish").input_at(&report, "in/r.html");
205    /// assert_eq!(config.inputs[0].destination(), "in/r.html");
206    /// # Ok(())
207    /// # }
208    /// ```
209    pub fn input_at(mut self, artifact: &ArtifactRef, dest: &str) -> Self {
210        self.inputs.push(ArtifactInput::from(artifact).at(dest));
211        self
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn builder() {
221        let config = ShellConfig::new("cargo test")
222            .timeout_secs(60)
223            .dir("/app")
224            .env("RUST_LOG", "debug")
225            .clean_env();
226
227        assert_eq!(config.command, "cargo test");
228        assert_eq!(config.timeout_secs, Some(60));
229        assert_eq!(config.dir, Some("/app".to_string()));
230        assert_eq!(
231            config.env,
232            vec![("RUST_LOG".to_string(), "debug".to_string())]
233        );
234        assert!(config.clean_env);
235    }
236
237    #[test]
238    fn a_fresh_config_declares_no_artifact() {
239        let config = ShellConfig::new("echo hi");
240        assert!(config.outputs.is_empty());
241        assert!(config.inputs.is_empty());
242    }
243
244    #[test]
245    fn outputs_and_inputs_accumulate_in_declaration_order() {
246        let config = ShellConfig::new("build")
247            .output("a.txt")
248            .output_typed("b", "text/csv")
249            .input(&ArtifactRef::new("prev", "c.txt"))
250            .input_at(&ArtifactRef::new("prev", "d.txt"), "in/d.txt");
251
252        assert_eq!(config.outputs[0].pattern, "a.txt");
253        assert_eq!(config.outputs[1].content_type.as_deref(), Some("text/csv"));
254        assert_eq!(config.inputs[0], ArtifactInput::new("prev", "c.txt"));
255        assert_eq!(config.inputs[0].destination(), "c.txt");
256        assert_eq!(config.inputs[1].step, "prev");
257        assert_eq!(config.inputs[1].destination(), "in/d.txt");
258    }
259
260    #[test]
261    fn serde_omits_empty_artifact_declarations() {
262        let json = serde_json::to_string(&ShellConfig::new("echo hi")).expect("serialize");
263        assert!(!json.contains("outputs"));
264        assert!(!json.contains("inputs"));
265    }
266
267    #[test]
268    fn a_config_predating_artifacts_still_deserializes() {
269        let config: ShellConfig = serde_json::from_str(
270            r#"{"command":"echo hi","timeout_secs":null,"dir":null,"env":[],"clean_env":false}"#,
271        )
272        .expect("deserialize");
273
274        assert!(config.outputs.is_empty());
275        assert!(config.inputs.is_empty());
276    }
277
278    #[test]
279    fn a_config_predating_retry_still_deserializes() {
280        let config: ShellConfig = serde_json::from_str(
281            r#"{"command":"echo hi","timeout_secs":null,"dir":null,"env":[],"clean_env":false,"allow_failure":false}"#,
282        )
283        .expect("deserialize");
284
285        assert!(config.retry.is_none());
286    }
287
288    #[test]
289    fn retry_policy_roundtrip() {
290        use ironflow_core::retry::RetryPolicy;
291
292        let config = ShellConfig::new("curl http://api").retry_policy(RetryPolicy::new(3));
293        let json = serde_json::to_string(&config).expect("serialize");
294        let back: ShellConfig = serde_json::from_str(&json).expect("deserialize");
295        assert_eq!(back.retry.as_ref().unwrap().max_retries(), 3);
296    }
297}