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}