Skip to main content

ironflow_engine/config/
shell.rs

1//! [`ShellConfig`] — serializable configuration for a shell step.
2
3use serde::{Deserialize, Serialize};
4
5use super::artifact::{ArtifactInput, ArtifactOutput};
6
7/// Serializable configuration for a shell step.
8///
9/// # Examples
10///
11/// ```
12/// use ironflow_engine::config::ShellConfig;
13///
14/// let config = ShellConfig::new("cargo build --release")
15///     .timeout_secs(300)
16///     .dir("/app")
17///     .output("target/report.html");
18/// ```
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct ShellConfig {
21    /// The shell command to execute.
22    pub command: String,
23    /// Timeout in seconds (default: 300).
24    pub timeout_secs: Option<u64>,
25    /// Working directory.
26    pub dir: Option<String>,
27    /// Environment variables to set.
28    pub env: Vec<(String, String)>,
29    /// If true, start with a clean environment.
30    pub clean_env: bool,
31    /// Files the step promises to produce, collected once it finishes.
32    #[serde(default, skip_serializing_if = "Vec::is_empty")]
33    pub outputs: Vec<ArtifactOutput>,
34    /// Artifacts of earlier steps to place in the working directory first.
35    #[serde(default, skip_serializing_if = "Vec::is_empty")]
36    pub inputs: Vec<ArtifactInput>,
37    /// When `true`, a failure of this step does not fail the run. The step is
38    /// still marked `Failed` but execution continues and the run finishes with
39    /// [`RunStatus::Warning`] instead of `Failed`.
40    #[serde(default)]
41    pub allow_failure: bool,
42}
43
44impl ShellConfig {
45    /// Create a new shell config with the given command.
46    ///
47    /// # Examples
48    ///
49    /// ```
50    /// use ironflow_engine::config::ShellConfig;
51    ///
52    /// let config = ShellConfig::new("echo hello");
53    /// assert_eq!(config.command, "echo hello");
54    /// ```
55    pub fn new(command: &str) -> Self {
56        Self {
57            command: command.to_string(),
58            timeout_secs: None,
59            dir: None,
60            env: Vec::new(),
61            clean_env: false,
62            outputs: Vec::new(),
63            inputs: Vec::new(),
64            allow_failure: false,
65        }
66    }
67
68    /// Set the timeout in seconds.
69    pub fn timeout_secs(mut self, secs: u64) -> Self {
70        self.timeout_secs = Some(secs);
71        self
72    }
73
74    /// Set the working directory.
75    pub fn dir(mut self, dir: &str) -> Self {
76        self.dir = Some(dir.to_string());
77        self
78    }
79
80    /// Add an environment variable.
81    pub fn env(mut self, key: &str, value: &str) -> Self {
82        self.env.push((key.to_string(), value.to_string()));
83        self
84    }
85
86    /// Start with a clean environment (no inherited vars).
87    pub fn clean_env(mut self) -> Self {
88        self.clean_env = true;
89        self
90    }
91
92    /// Declare a file the step produces, typed from its name.
93    ///
94    /// `pattern` is a glob resolved against [`dir`](Self::dir). Every match is
95    /// stored as an artifact named after the file. When the step succeeds and
96    /// the pattern matches nothing, the step fails.
97    ///
98    /// # Examples
99    ///
100    /// ```
101    /// use ironflow_engine::config::ShellConfig;
102    ///
103    /// let config = ShellConfig::new("cargo build").output("target/*.log");
104    /// assert_eq!(config.outputs.len(), 1);
105    /// ```
106    pub fn output(mut self, pattern: &str) -> Self {
107        self.outputs.push(ArtifactOutput::new(pattern));
108        self
109    }
110
111    /// Declare a produced file with an explicit MIME type.
112    ///
113    /// # Examples
114    ///
115    /// ```
116    /// use ironflow_engine::config::ShellConfig;
117    ///
118    /// let config = ShellConfig::new("./gen").output_typed("data", "application/json");
119    /// assert_eq!(config.outputs[0].content_type.as_deref(), Some("application/json"));
120    /// ```
121    pub fn output_typed(mut self, pattern: &str, content_type: &str) -> Self {
122        self.outputs
123            .push(ArtifactOutput::typed(pattern, content_type));
124        self
125    }
126
127    /// Consume an artifact produced by an earlier step of the same run.
128    ///
129    /// It is written into the working directory under its own name before the
130    /// command runs. Use [`input_at`](Self::input_at) to choose another path.
131    ///
132    /// # Examples
133    ///
134    /// ```
135    /// use ironflow_engine::config::ShellConfig;
136    ///
137    /// let config = ShellConfig::new("./publish").input("build", "report.html");
138    /// assert_eq!(config.inputs[0].destination(), "report.html");
139    /// ```
140    pub fn input(mut self, step: &str, name: &str) -> Self {
141        self.inputs.push(ArtifactInput::new(step, name));
142        self
143    }
144
145    /// Mark this step as allowed to fail without stopping the run.
146    ///
147    /// # Examples
148    ///
149    /// ```
150    /// use ironflow_engine::config::ShellConfig;
151    ///
152    /// let config = ShellConfig::new("cargo clippy").allow_failure();
153    /// assert!(config.allow_failure);
154    /// ```
155    pub fn allow_failure(mut self) -> Self {
156        self.allow_failure = true;
157        self
158    }
159
160    /// Consume an artifact and write it to an explicit path.
161    ///
162    /// # Examples
163    ///
164    /// ```
165    /// use ironflow_engine::config::ShellConfig;
166    ///
167    /// let config = ShellConfig::new("./publish").input_at("build", "report.html", "in/r.html");
168    /// assert_eq!(config.inputs[0].destination(), "in/r.html");
169    /// ```
170    pub fn input_at(mut self, step: &str, name: &str, dest: &str) -> Self {
171        self.inputs.push(ArtifactInput::new(step, name).at(dest));
172        self
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn builder() {
182        let config = ShellConfig::new("cargo test")
183            .timeout_secs(60)
184            .dir("/app")
185            .env("RUST_LOG", "debug")
186            .clean_env();
187
188        assert_eq!(config.command, "cargo test");
189        assert_eq!(config.timeout_secs, Some(60));
190        assert_eq!(config.dir, Some("/app".to_string()));
191        assert_eq!(
192            config.env,
193            vec![("RUST_LOG".to_string(), "debug".to_string())]
194        );
195        assert!(config.clean_env);
196    }
197
198    #[test]
199    fn a_fresh_config_declares_no_artifact() {
200        let config = ShellConfig::new("echo hi");
201        assert!(config.outputs.is_empty());
202        assert!(config.inputs.is_empty());
203    }
204
205    #[test]
206    fn outputs_and_inputs_accumulate_in_declaration_order() {
207        let config = ShellConfig::new("build")
208            .output("a.txt")
209            .output_typed("b", "text/csv")
210            .input("prev", "c.txt")
211            .input_at("prev", "d.txt", "in/d.txt");
212
213        assert_eq!(config.outputs[0].pattern, "a.txt");
214        assert_eq!(config.outputs[1].content_type.as_deref(), Some("text/csv"));
215        assert_eq!(config.inputs[0].destination(), "c.txt");
216        assert_eq!(config.inputs[1].destination(), "in/d.txt");
217    }
218
219    #[test]
220    fn serde_omits_empty_artifact_declarations() {
221        let json = serde_json::to_string(&ShellConfig::new("echo hi")).expect("serialize");
222        assert!(!json.contains("outputs"));
223        assert!(!json.contains("inputs"));
224    }
225
226    #[test]
227    fn a_config_predating_artifacts_still_deserializes() {
228        let config: ShellConfig = serde_json::from_str(
229            r#"{"command":"echo hi","timeout_secs":null,"dir":null,"env":[],"clean_env":false}"#,
230        )
231        .expect("deserialize");
232
233        assert!(config.outputs.is_empty());
234        assert!(config.inputs.is_empty());
235    }
236}