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}