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};
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    /// It is written into the working directory under its own name before the
136    /// command runs. Use [`input_at`](Self::input_at) to choose another path.
137    ///
138    /// # Examples
139    ///
140    /// ```
141    /// use ironflow_engine::config::ShellConfig;
142    ///
143    /// let config = ShellConfig::new("./publish").input("build", "report.html");
144    /// assert_eq!(config.inputs[0].destination(), "report.html");
145    /// ```
146    pub fn input(mut self, step: &str, name: &str) -> Self {
147        self.inputs.push(ArtifactInput::new(step, name));
148        self
149    }
150
151    /// Mark this step as allowed to fail without stopping the run.
152    ///
153    /// # Examples
154    ///
155    /// ```
156    /// use ironflow_engine::config::ShellConfig;
157    ///
158    /// let config = ShellConfig::new("cargo clippy").allow_failure();
159    /// assert!(config.allow_failure);
160    /// ```
161    pub fn allow_failure(mut self) -> Self {
162        self.allow_failure = true;
163        self
164    }
165
166    /// Set a step-level retry policy.
167    ///
168    /// # Examples
169    ///
170    /// ```
171    /// use ironflow_core::retry::RetryPolicy;
172    /// use ironflow_engine::config::ShellConfig;
173    ///
174    /// let config = ShellConfig::new("curl http://api")
175    ///     .retry_policy(RetryPolicy::new(3));
176    /// assert!(config.retry.is_some());
177    /// ```
178    pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
179        self.retry = Some(policy);
180        self
181    }
182
183    /// Consume an artifact and write it to an explicit path.
184    ///
185    /// # Examples
186    ///
187    /// ```
188    /// use ironflow_engine::config::ShellConfig;
189    ///
190    /// let config = ShellConfig::new("./publish").input_at("build", "report.html", "in/r.html");
191    /// assert_eq!(config.inputs[0].destination(), "in/r.html");
192    /// ```
193    pub fn input_at(mut self, step: &str, name: &str, dest: &str) -> Self {
194        self.inputs.push(ArtifactInput::new(step, name).at(dest));
195        self
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn builder() {
205        let config = ShellConfig::new("cargo test")
206            .timeout_secs(60)
207            .dir("/app")
208            .env("RUST_LOG", "debug")
209            .clean_env();
210
211        assert_eq!(config.command, "cargo test");
212        assert_eq!(config.timeout_secs, Some(60));
213        assert_eq!(config.dir, Some("/app".to_string()));
214        assert_eq!(
215            config.env,
216            vec![("RUST_LOG".to_string(), "debug".to_string())]
217        );
218        assert!(config.clean_env);
219    }
220
221    #[test]
222    fn a_fresh_config_declares_no_artifact() {
223        let config = ShellConfig::new("echo hi");
224        assert!(config.outputs.is_empty());
225        assert!(config.inputs.is_empty());
226    }
227
228    #[test]
229    fn outputs_and_inputs_accumulate_in_declaration_order() {
230        let config = ShellConfig::new("build")
231            .output("a.txt")
232            .output_typed("b", "text/csv")
233            .input("prev", "c.txt")
234            .input_at("prev", "d.txt", "in/d.txt");
235
236        assert_eq!(config.outputs[0].pattern, "a.txt");
237        assert_eq!(config.outputs[1].content_type.as_deref(), Some("text/csv"));
238        assert_eq!(config.inputs[0].destination(), "c.txt");
239        assert_eq!(config.inputs[1].destination(), "in/d.txt");
240    }
241
242    #[test]
243    fn serde_omits_empty_artifact_declarations() {
244        let json = serde_json::to_string(&ShellConfig::new("echo hi")).expect("serialize");
245        assert!(!json.contains("outputs"));
246        assert!(!json.contains("inputs"));
247    }
248
249    #[test]
250    fn a_config_predating_artifacts_still_deserializes() {
251        let config: ShellConfig = serde_json::from_str(
252            r#"{"command":"echo hi","timeout_secs":null,"dir":null,"env":[],"clean_env":false}"#,
253        )
254        .expect("deserialize");
255
256        assert!(config.outputs.is_empty());
257        assert!(config.inputs.is_empty());
258    }
259
260    #[test]
261    fn a_config_predating_retry_still_deserializes() {
262        let config: ShellConfig = serde_json::from_str(
263            r#"{"command":"echo hi","timeout_secs":null,"dir":null,"env":[],"clean_env":false,"allow_failure":false}"#,
264        )
265        .expect("deserialize");
266
267        assert!(config.retry.is_none());
268    }
269
270    #[test]
271    fn retry_policy_roundtrip() {
272        use ironflow_core::retry::RetryPolicy;
273
274        let config = ShellConfig::new("curl http://api").retry_policy(RetryPolicy::new(3));
275        let json = serde_json::to_string(&config).expect("serialize");
276        let back: ShellConfig = serde_json::from_str(&json).expect("deserialize");
277        assert_eq!(back.retry.as_ref().unwrap().max_retries(), 3);
278    }
279}