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}
38
39impl ShellConfig {
40    /// Create a new shell config with the given command.
41    ///
42    /// # Examples
43    ///
44    /// ```
45    /// use ironflow_engine::config::ShellConfig;
46    ///
47    /// let config = ShellConfig::new("echo hello");
48    /// assert_eq!(config.command, "echo hello");
49    /// ```
50    pub fn new(command: &str) -> Self {
51        Self {
52            command: command.to_string(),
53            timeout_secs: None,
54            dir: None,
55            env: Vec::new(),
56            clean_env: false,
57            outputs: Vec::new(),
58            inputs: Vec::new(),
59        }
60    }
61
62    /// Set the timeout in seconds.
63    pub fn timeout_secs(mut self, secs: u64) -> Self {
64        self.timeout_secs = Some(secs);
65        self
66    }
67
68    /// Set the working directory.
69    pub fn dir(mut self, dir: &str) -> Self {
70        self.dir = Some(dir.to_string());
71        self
72    }
73
74    /// Add an environment variable.
75    pub fn env(mut self, key: &str, value: &str) -> Self {
76        self.env.push((key.to_string(), value.to_string()));
77        self
78    }
79
80    /// Start with a clean environment (no inherited vars).
81    pub fn clean_env(mut self) -> Self {
82        self.clean_env = true;
83        self
84    }
85
86    /// Declare a file the step produces, typed from its name.
87    ///
88    /// `pattern` is a glob resolved against [`dir`](Self::dir). Every match is
89    /// stored as an artifact named after the file. When the step succeeds and
90    /// the pattern matches nothing, the step fails.
91    ///
92    /// # Examples
93    ///
94    /// ```
95    /// use ironflow_engine::config::ShellConfig;
96    ///
97    /// let config = ShellConfig::new("cargo build").output("target/*.log");
98    /// assert_eq!(config.outputs.len(), 1);
99    /// ```
100    pub fn output(mut self, pattern: &str) -> Self {
101        self.outputs.push(ArtifactOutput::new(pattern));
102        self
103    }
104
105    /// Declare a produced file with an explicit MIME type.
106    ///
107    /// # Examples
108    ///
109    /// ```
110    /// use ironflow_engine::config::ShellConfig;
111    ///
112    /// let config = ShellConfig::new("./gen").output_typed("data", "application/json");
113    /// assert_eq!(config.outputs[0].content_type.as_deref(), Some("application/json"));
114    /// ```
115    pub fn output_typed(mut self, pattern: &str, content_type: &str) -> Self {
116        self.outputs
117            .push(ArtifactOutput::typed(pattern, content_type));
118        self
119    }
120
121    /// Consume an artifact produced by an earlier step of the same run.
122    ///
123    /// It is written into the working directory under its own name before the
124    /// command runs. Use [`input_at`](Self::input_at) to choose another path.
125    ///
126    /// # Examples
127    ///
128    /// ```
129    /// use ironflow_engine::config::ShellConfig;
130    ///
131    /// let config = ShellConfig::new("./publish").input("build", "report.html");
132    /// assert_eq!(config.inputs[0].destination(), "report.html");
133    /// ```
134    pub fn input(mut self, step: &str, name: &str) -> Self {
135        self.inputs.push(ArtifactInput::new(step, name));
136        self
137    }
138
139    /// Consume an artifact and write it to an explicit path.
140    ///
141    /// # Examples
142    ///
143    /// ```
144    /// use ironflow_engine::config::ShellConfig;
145    ///
146    /// let config = ShellConfig::new("./publish").input_at("build", "report.html", "in/r.html");
147    /// assert_eq!(config.inputs[0].destination(), "in/r.html");
148    /// ```
149    pub fn input_at(mut self, step: &str, name: &str, dest: &str) -> Self {
150        self.inputs.push(ArtifactInput::new(step, name).at(dest));
151        self
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn builder() {
161        let config = ShellConfig::new("cargo test")
162            .timeout_secs(60)
163            .dir("/app")
164            .env("RUST_LOG", "debug")
165            .clean_env();
166
167        assert_eq!(config.command, "cargo test");
168        assert_eq!(config.timeout_secs, Some(60));
169        assert_eq!(config.dir, Some("/app".to_string()));
170        assert_eq!(
171            config.env,
172            vec![("RUST_LOG".to_string(), "debug".to_string())]
173        );
174        assert!(config.clean_env);
175    }
176
177    #[test]
178    fn a_fresh_config_declares_no_artifact() {
179        let config = ShellConfig::new("echo hi");
180        assert!(config.outputs.is_empty());
181        assert!(config.inputs.is_empty());
182    }
183
184    #[test]
185    fn outputs_and_inputs_accumulate_in_declaration_order() {
186        let config = ShellConfig::new("build")
187            .output("a.txt")
188            .output_typed("b", "text/csv")
189            .input("prev", "c.txt")
190            .input_at("prev", "d.txt", "in/d.txt");
191
192        assert_eq!(config.outputs[0].pattern, "a.txt");
193        assert_eq!(config.outputs[1].content_type.as_deref(), Some("text/csv"));
194        assert_eq!(config.inputs[0].destination(), "c.txt");
195        assert_eq!(config.inputs[1].destination(), "in/d.txt");
196    }
197
198    #[test]
199    fn serde_omits_empty_artifact_declarations() {
200        let json = serde_json::to_string(&ShellConfig::new("echo hi")).expect("serialize");
201        assert!(!json.contains("outputs"));
202        assert!(!json.contains("inputs"));
203    }
204
205    #[test]
206    fn a_config_predating_artifacts_still_deserializes() {
207        let config: ShellConfig = serde_json::from_str(
208            r#"{"command":"echo hi","timeout_secs":null,"dir":null,"env":[],"clean_env":false}"#,
209        )
210        .expect("deserialize");
211
212        assert!(config.outputs.is_empty());
213        assert!(config.inputs.is_empty());
214    }
215}