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}