atento_core/
step.rs

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
13// Helper function to provide the custom default for serde
14fn 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    /// Creates a new Step with basic defaults for testing purposes
51    #[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    /// Validates the step configuration.
65    ///
66    /// # Errors
67    /// Returns validation errors for unused inputs, undeclared inputs, or invalid output patterns.
68    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    /// Calculates the effective timeout for this step.
114    #[must_use]
115    pub fn calculate_timeout(&self, time_left: u64) -> u64 {
116        if self.timeout > 0 && time_left > 0 {
117            // Case 1: If both are greater than 0, take the smallest (minimum).
118            std::cmp::min(self.timeout, time_left)
119        } else {
120            // Otherwise (if at least one is 0), take the largest (maximum).
121            // The maximum will be the single non-zero value, or 0 if both are 0.
122            std::cmp::max(self.timeout, time_left)
123        }
124    }
125
126    /// Builds the script with input substitution.
127    #[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: &regex::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    /// Runs this step using the provided executor and inputs.
184    ///
185    /// # Errors
186    /// Returns an error if script execution fails or output extraction fails.
187    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}