cuenv_ci/executor/
runner.rs

1//! IR Task Runner
2//!
3//! Executes individual IR tasks with proper command handling, environment
4//! injection, and output capture.
5
6use crate::ir::Task as IRTask;
7use std::collections::HashMap;
8use std::path::PathBuf;
9use std::process::Stdio;
10use thiserror::Error;
11use tokio::process::Command;
12
13/// Error types for task execution
14#[derive(Debug, Error)]
15pub enum RunnerError {
16    /// Task command is empty
17    #[error("Task '{task}' has empty command")]
18    EmptyCommand { task: String },
19
20    /// Process spawn failed
21    #[error("Failed to spawn task '{task}': {source}")]
22    SpawnFailed {
23        task: String,
24        #[source]
25        source: std::io::Error,
26    },
27
28    /// Process execution failed
29    #[error("Task '{task}' execution failed: {source}")]
30    ExecutionFailed {
31        task: String,
32        #[source]
33        source: std::io::Error,
34    },
35}
36
37/// Output from task execution
38#[derive(Debug, Clone)]
39pub struct TaskOutput {
40    /// Task ID
41    pub task_id: String,
42    /// Process exit code
43    pub exit_code: i32,
44    /// Captured stdout
45    pub stdout: String,
46    /// Captured stderr
47    pub stderr: String,
48    /// Whether the task succeeded
49    pub success: bool,
50    /// Whether result was from cache
51    pub from_cache: bool,
52    /// Execution duration in milliseconds
53    pub duration_ms: u64,
54}
55
56impl TaskOutput {
57    /// Create a cached result (no actual execution)
58    #[must_use]
59    pub fn from_cache(task_id: String, duration_ms: u64) -> Self {
60        Self {
61            task_id,
62            exit_code: 0,
63            stdout: String::new(),
64            stderr: String::new(),
65            success: true,
66            from_cache: true,
67            duration_ms,
68        }
69    }
70
71    /// Create a dry-run result
72    #[must_use]
73    pub fn dry_run(task_id: String) -> Self {
74        Self {
75            task_id,
76            exit_code: 0,
77            stdout: String::new(),
78            stderr: String::new(),
79            success: true,
80            from_cache: false,
81            duration_ms: 0,
82        }
83    }
84}
85
86/// Default shell path for task execution
87pub const DEFAULT_SHELL: &str = "/bin/sh";
88
89/// Runner for executing IR tasks
90pub struct IRTaskRunner {
91    /// Working directory for task execution
92    project_root: PathBuf,
93    /// Whether to capture output
94    capture_output: bool,
95    /// Shell path for shell-mode execution
96    shell_path: String,
97}
98
99impl IRTaskRunner {
100    /// Create a new task runner with default shell
101    #[must_use]
102    pub fn new(project_root: PathBuf, capture_output: bool) -> Self {
103        Self {
104            project_root,
105            capture_output,
106            shell_path: DEFAULT_SHELL.to_string(),
107        }
108    }
109
110    /// Create a new task runner with custom shell path
111    #[must_use]
112    pub fn with_shell(
113        project_root: PathBuf,
114        capture_output: bool,
115        shell_path: impl Into<String>,
116    ) -> Self {
117        Self {
118            project_root,
119            capture_output,
120            shell_path: shell_path.into(),
121        }
122    }
123
124    /// Execute a single IR task
125    ///
126    /// # Arguments
127    /// * `task` - The IR task definition
128    /// * `env` - Environment variables to inject (includes resolved secrets)
129    ///
130    /// # Errors
131    /// Returns error if the task command is empty or execution fails
132    #[tracing::instrument(
133        name = "execute_task",
134        fields(task_id = %task.id, shell = task.shell),
135        skip(self, env)
136    )]
137    pub async fn execute(
138        &self,
139        task: &IRTask,
140        env: HashMap<String, String>,
141    ) -> Result<TaskOutput, RunnerError> {
142        if task.command.is_empty() {
143            return Err(RunnerError::EmptyCommand {
144                task: task.id.clone(),
145            });
146        }
147
148        let start = std::time::Instant::now();
149
150        // Build command based on shell mode
151        let mut cmd = if task.shell {
152            // Shell mode: wrap command in shell -c
153            let shell_cmd = task.command.join(" ");
154            tracing::debug!(shell_cmd = %shell_cmd, shell = %self.shell_path, "Running in shell mode");
155
156            let mut c = Command::new(&self.shell_path);
157            c.arg("-c");
158            c.arg(&shell_cmd);
159            c
160        } else {
161            // Direct mode: execve
162            tracing::debug!(cmd = ?task.command, "Running in direct mode");
163
164            let mut c = Command::new(&task.command[0]);
165            if task.command.len() > 1 {
166                c.args(&task.command[1..]);
167            }
168            c
169        };
170
171        // Set working directory
172        cmd.current_dir(&self.project_root);
173
174        // Clear environment and inject our variables
175        cmd.env_clear();
176        for (k, v) in &env {
177            cmd.env(k, v);
178        }
179
180        // Also inject essential env vars
181        if let Ok(path) = std::env::var("PATH") {
182            cmd.env("PATH", path);
183        }
184        if let Ok(home) = std::env::var("HOME") {
185            cmd.env("HOME", home);
186        }
187
188        // Configure output capture
189        if self.capture_output {
190            cmd.stdout(Stdio::piped());
191            cmd.stderr(Stdio::piped());
192        } else {
193            cmd.stdout(Stdio::inherit());
194            cmd.stderr(Stdio::inherit());
195        }
196
197        // Execute
198        tracing::info!(task = %task.id, "Starting task execution");
199
200        let output = cmd
201            .output()
202            .await
203            .map_err(|e| RunnerError::ExecutionFailed {
204                task: task.id.clone(),
205                source: e,
206            })?;
207
208        let duration = start.elapsed();
209        let exit_code = output.status.code().unwrap_or(-1);
210        let success = output.status.success();
211
212        let duration_ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
213        tracing::info!(
214            task = %task.id,
215            exit_code = exit_code,
216            success = success,
217            duration_ms,
218            "Task execution completed"
219        );
220
221        Ok(TaskOutput {
222            task_id: task.id.clone(),
223            exit_code,
224            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
225            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
226            success,
227            from_cache: false,
228            duration_ms,
229        })
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::ir::CachePolicy;
237    use tempfile::TempDir;
238
239    fn make_task(id: &str, command: &[&str], shell: bool) -> IRTask {
240        IRTask {
241            id: id.to_string(),
242            runtime: None,
243            command: command.iter().map(|s| (*s).to_string()).collect(),
244            shell,
245            env: HashMap::new(),
246            secrets: HashMap::new(),
247            resources: None,
248            concurrency_group: None,
249            inputs: vec![],
250            outputs: vec![],
251            depends_on: vec![],
252            cache_policy: CachePolicy::Normal,
253            deployment: false,
254            manual_approval: false,
255        }
256    }
257
258    #[tokio::test]
259    async fn test_simple_command() {
260        let tmp = TempDir::new().unwrap();
261        let runner = IRTaskRunner::new(tmp.path().to_path_buf(), true);
262        let task = make_task("test", &["echo", "hello"], false);
263
264        let result = runner.execute(&task, HashMap::new()).await.unwrap();
265
266        assert!(result.success);
267        assert_eq!(result.exit_code, 0);
268        assert!(result.stdout.contains("hello"));
269        assert!(!result.from_cache);
270    }
271
272    #[tokio::test]
273    async fn test_shell_mode() {
274        let tmp = TempDir::new().unwrap();
275        let runner = IRTaskRunner::new(tmp.path().to_path_buf(), true);
276        let task = make_task("test", &["echo", "hello", "&&", "echo", "world"], true);
277
278        let result = runner.execute(&task, HashMap::new()).await.unwrap();
279
280        assert!(result.success);
281        assert!(result.stdout.contains("hello"));
282        assert!(result.stdout.contains("world"));
283    }
284
285    #[tokio::test]
286    async fn test_env_injection() {
287        let tmp = TempDir::new().unwrap();
288        let runner = IRTaskRunner::new(tmp.path().to_path_buf(), true);
289        let task = make_task("test", &["printenv", "MY_VAR"], false);
290
291        let env = HashMap::from([("MY_VAR".to_string(), "test_value".to_string())]);
292        let result = runner.execute(&task, env).await.unwrap();
293
294        assert!(result.success);
295        assert!(result.stdout.contains("test_value"));
296    }
297
298    #[tokio::test]
299    async fn test_failing_command() {
300        let tmp = TempDir::new().unwrap();
301        let runner = IRTaskRunner::new(tmp.path().to_path_buf(), true);
302        let task = make_task("test", &["false"], false);
303
304        let result = runner.execute(&task, HashMap::new()).await.unwrap();
305
306        assert!(!result.success);
307        assert_ne!(result.exit_code, 0);
308    }
309
310    #[tokio::test]
311    async fn test_empty_command_error() {
312        let tmp = TempDir::new().unwrap();
313        let runner = IRTaskRunner::new(tmp.path().to_path_buf(), true);
314        let task = make_task("test", &[], false);
315
316        let result = runner.execute(&task, HashMap::new()).await;
317        assert!(matches!(result, Err(RunnerError::EmptyCommand { .. })));
318    }
319
320    #[test]
321    fn test_cached_output() {
322        let output = TaskOutput::from_cache("test".to_string(), 100);
323        assert!(output.success);
324        assert!(output.from_cache);
325        assert_eq!(output.duration_ms, 100);
326    }
327
328    #[test]
329    fn test_dry_run_output() {
330        let output = TaskOutput::dry_run("test".to_string());
331        assert!(output.success);
332        assert!(!output.from_cache);
333        assert_eq!(output.duration_ms, 0);
334    }
335
336    #[tokio::test]
337    #[ignore = "requires /bin/bash which may not exist in sandboxed builds"]
338    async fn test_custom_shell() {
339        let tmp = TempDir::new().unwrap();
340        // Use /bin/bash (available on most Unix systems)
341        let runner = IRTaskRunner::with_shell(tmp.path().to_path_buf(), true, "/bin/bash");
342        let task = make_task("test", &["echo", "$BASH_VERSION"], true);
343
344        let result = runner.execute(&task, HashMap::new()).await.unwrap();
345
346        // On systems with bash, this should succeed and output something
347        assert!(result.success);
348    }
349
350    #[test]
351    fn test_runner_default_shell() {
352        let tmp = TempDir::new().unwrap();
353        let runner = IRTaskRunner::new(tmp.path().to_path_buf(), true);
354        assert_eq!(runner.shell_path, "/bin/sh");
355    }
356}