1use crate::errors::{AtentoError, Result};
2use crate::executor::CommandExecutor;
3use crate::input::Input;
4use crate::interpreter::Interpreter;
5use crate::output::Output;
6use regex::Regex;
7use serde::{Deserialize, Serialize};
8use std::collections::{HashMap, HashSet};
9
10const INPUT_PLACEHOLDER_PATTERN: &str = r"\{\{\s*inputs\.(\w+)\s*\}\}";
11const DEFAULT_STEP_TIMEOUT: u64 = 60;
12
13fn default_step_timeout() -> u64 {
15 DEFAULT_STEP_TIMEOUT
16}
17
18#[derive(Debug, Deserialize)]
19pub struct Step {
20 pub name: Option<String>,
21 #[serde(default = "default_step_timeout")]
22 pub timeout: u64,
23 #[serde(default)]
24 pub inputs: HashMap<String, Input>,
25 #[serde(rename = "type")]
26 pub interpreter: String,
27 pub script: String,
28 #[serde(default)]
29 pub outputs: HashMap<String, Output>,
30}
31
32#[derive(Debug, Serialize)]
33pub struct StepResult {
34 pub name: Option<String>,
35 pub duration_ms: u128,
36 pub exit_code: i32,
37 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
38 pub inputs: HashMap<String, String>,
39 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
40 pub outputs: HashMap<String, String>,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub stdout: Option<String>,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub stderr: Option<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub error: Option<AtentoError>,
47}
48
49impl Step {
50 #[cfg(test)]
52 #[must_use]
53 pub fn new(interpreter: &str) -> Self {
54 Step {
55 name: None,
56 timeout: default_step_timeout(),
57 inputs: HashMap::new(),
58 interpreter: interpreter.to_string(),
59 script: String::new(),
60 outputs: HashMap::new(),
61 }
62 }
63
64 pub fn validate(&self, id: &str) -> Result<()> {
69 let step_name = self.name.as_deref().unwrap_or(id);
70
71 #[allow(clippy::expect_used)]
72 let input_ref_regex = Regex::new(INPUT_PLACEHOLDER_PATTERN)
73 .expect("Input placeholder regex pattern is valid");
74
75 let mut used_inputs: HashSet<String> = HashSet::new();
76
77 for cap in input_ref_regex.captures_iter(&self.script) {
78 let ref_key = &cap[1];
79 if !self.inputs.contains_key(ref_key) {
80 return Err(AtentoError::Validation(format!(
81 "Step '{step_name}' script references input '{ref_key}' that is not declared"
82 )));
83 }
84 used_inputs.insert(ref_key.to_string());
85 }
86
87 for input_name in self.inputs.keys() {
88 if !used_inputs.contains(input_name) {
89 return Err(AtentoError::Validation(format!(
90 "Step '{step_name}' has input '{input_name}' that is declared but never used in the script"
91 )));
92 }
93 }
94
95 for (out_name, out) in &self.outputs {
96 if out.pattern.trim().is_empty() {
97 return Err(AtentoError::Validation(format!(
98 "Output '{out_name}' in step '{step_name}' has empty capture pattern"
99 )));
100 }
101
102 Regex::new(&out.pattern).map_err(|e| {
103 AtentoError::Validation(format!(
104 "Output '{}' in step '{}' has invalid regex pattern '{}': {}",
105 out_name, step_name, out.pattern, e
106 ))
107 })?;
108 }
109
110 Ok(())
111 }
112
113 #[must_use]
115 pub fn calculate_timeout(&self, time_left: u64) -> u64 {
116 if self.timeout > 0 && time_left > 0 {
117 std::cmp::min(self.timeout, time_left)
119 } else {
120 std::cmp::max(self.timeout, time_left)
123 }
124 }
125
126 #[must_use]
128 pub fn build_script(&self, inputs: &HashMap<String, String>) -> String {
129 if self.script.is_empty() {
130 return String::new();
131 }
132
133 if inputs.is_empty() {
134 return self.script.clone();
135 }
136
137 #[allow(clippy::expect_used)]
138 let re = Regex::new(INPUT_PLACEHOLDER_PATTERN).expect("Valid regex pattern");
139
140 re.replace_all(&self.script, |caps: ®ex::Captures| {
141 let key = &caps[1];
142 inputs
143 .get(key)
144 .cloned()
145 .unwrap_or_else(|| caps[0].to_string())
146 })
147 .to_string()
148 }
149
150 pub fn extract_outputs(&self, stdout: &mut String) -> Result<HashMap<String, String>> {
151 if self.outputs.is_empty() {
152 return Ok(HashMap::new());
153 }
154
155 let mut step_outputs = HashMap::new();
156
157 for (out_name, out) in &self.outputs {
158 let re = Regex::new(&out.pattern).map_err(|e| {
159 AtentoError::Execution(format!("Invalid regex for output '{out_name}': {e}"))
160 })?;
161
162 let caps = re.captures(stdout).ok_or_else(|| {
163 AtentoError::Execution(format!(
164 "Output '{}' pattern '{}' did not match stdout",
165 out_name, out.pattern
166 ))
167 })?;
168
169 if caps.len() <= 1 {
170 return Err(AtentoError::Execution(format!(
171 "Output '{}' regex '{}' did not capture a group",
172 out_name, out.pattern
173 )));
174 }
175
176 step_outputs.insert(out_name.clone(), caps[1].to_string());
177 *stdout = stdout.replace(&caps[0], "");
178 }
179
180 Ok(step_outputs)
181 }
182
183 pub fn run<E: CommandExecutor>(
188 &self,
189 executor: &E,
190 inputs: &HashMap<String, String>,
191 time_left: u64,
192 interpreter: &Interpreter,
193 ) -> StepResult {
194 let script = self.build_script(inputs);
195
196 let timeout = self.calculate_timeout(time_left);
197
198 let start_time = std::time::Instant::now();
199 match executor.execute(&script, interpreter, timeout) {
200 Ok(result) => {
201 let duration_ms = start_time.elapsed().as_millis();
202
203 let mut stdout = result.stdout;
204 let step_outputs = match self.extract_outputs(&mut stdout) {
205 Ok(outputs) => outputs,
206 Err(e) => {
207 return StepResult {
208 name: self.name.clone(),
209 duration_ms,
210 exit_code: result.exit_code,
211 stdout: Some(stdout.trim().to_string()).filter(|s| !s.is_empty()),
212 stderr: Some(result.stderr).filter(|s| !s.is_empty()),
213 inputs: inputs.clone(),
214 outputs: HashMap::new(),
215 error: Some(e),
216 };
217 }
218 };
219
220 StepResult {
221 name: self.name.clone(),
222 duration_ms,
223 exit_code: result.exit_code,
224 stdout: Some(stdout.trim().to_string()).filter(|s| !s.is_empty()),
225 stderr: Some(result.stderr).filter(|s| !s.is_empty()),
226 inputs: inputs.clone(),
227 outputs: step_outputs,
228 error: None,
229 }
230 }
231 Err(e) => {
232 let duration_ms = start_time.elapsed().as_millis();
233 StepResult {
234 name: self.name.clone(),
235 duration_ms,
236 exit_code: 1,
237 stdout: None,
238 stderr: None,
239 inputs: inputs.clone(),
240 outputs: HashMap::new(),
241 error: Some(e),
242 }
243 }
244 }
245 }
246}