cuenv_core/tasks/
backend.rs

1//! Task backend abstraction for different execution environments
2//!
3//! This module provides a pluggable backend system for task execution.
4//! The default backend is `Host`, which runs tasks directly on the host machine.
5//! For Dagger container execution, use the `cuenv-dagger` crate.
6
7use super::{Task, TaskResult};
8use crate::config::BackendConfig;
9use crate::environment::Environment;
10use crate::{Error, Result};
11use async_trait::async_trait;
12use std::path::Path;
13use std::process::Stdio;
14use std::sync::Arc;
15use tokio::process::Command;
16
17/// Trait for task execution backends
18#[async_trait]
19pub trait TaskBackend: Send + Sync {
20    /// Execute a single task and return the result
21    async fn execute(
22        &self,
23        name: &str,
24        task: &Task,
25        environment: &Environment,
26        project_root: &Path,
27        capture_output: bool,
28    ) -> Result<TaskResult>;
29
30    /// Get the name of the backend
31    fn name(&self) -> &'static str;
32}
33
34/// Host backend - executes tasks directly on the host machine
35pub struct HostBackend;
36
37impl Default for HostBackend {
38    fn default() -> Self {
39        Self
40    }
41}
42
43impl HostBackend {
44    pub fn new() -> Self {
45        Self
46    }
47}
48
49#[async_trait]
50impl TaskBackend for HostBackend {
51    async fn execute(
52        &self,
53        name: &str,
54        task: &Task,
55        environment: &Environment,
56        project_root: &Path,
57        capture_output: bool,
58    ) -> Result<TaskResult> {
59        tracing::info!(
60            task = %name,
61            backend = "host",
62            "Executing task on host"
63        );
64
65        // Resolve command path using the environment's PATH
66        let resolved_command = environment.resolve_command(&task.command);
67
68        // Build command
69        let mut cmd = if let Some(shell) = &task.shell {
70            let mut c = Command::new(shell.command.as_deref().unwrap_or("bash"));
71            if let Some(flag) = &shell.flag {
72                c.arg(flag);
73            } else {
74                c.arg("-c");
75            }
76            // Append the task command string to the shell invocation
77            c.arg(&task.command);
78            c
79        } else {
80            let mut c = Command::new(resolved_command);
81            c.args(&task.args);
82            c
83        };
84
85        // Set working directory
86        cmd.current_dir(project_root);
87
88        // Set environment variables
89        cmd.env_clear();
90        for (k, v) in &environment.vars {
91            cmd.env(k, v);
92        }
93
94        // Execute - always capture output for consistent behavior
95        if capture_output {
96            let output = cmd
97                .stdout(Stdio::piped())
98                .stderr(Stdio::piped())
99                .output()
100                .await
101                .map_err(|e| Error::Io {
102                    source: e,
103                    path: None,
104                    operation: format!("spawn task {}", name),
105                })?;
106
107            let stdout = String::from_utf8_lossy(&output.stdout).to_string();
108            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
109            let exit_code = output.status.code().unwrap_or(-1);
110            let success = output.status.success();
111
112            if !success {
113                tracing::warn!(task = %name, exit = exit_code, "Task failed");
114            }
115
116            Ok(TaskResult {
117                name: name.to_string(),
118                exit_code: Some(exit_code),
119                stdout,
120                stderr,
121                success,
122            })
123        } else {
124            // Stream output directly to terminal (interactive mode)
125            let status = cmd
126                .stdout(Stdio::inherit())
127                .stderr(Stdio::inherit())
128                .status()
129                .await
130                .map_err(|e| Error::Io {
131                    source: e,
132                    path: None,
133                    operation: format!("spawn task {}", name),
134                })?;
135
136            let exit_code = status.code().unwrap_or(-1);
137            let success = status.success();
138
139            if !success {
140                tracing::warn!(task = %name, exit = exit_code, "Task failed");
141            }
142
143            Ok(TaskResult {
144                name: name.to_string(),
145                exit_code: Some(exit_code),
146                stdout: String::new(), // Output went to terminal
147                stderr: String::new(),
148                success,
149            })
150        }
151    }
152
153    fn name(&self) -> &'static str {
154        "host"
155    }
156}
157
158/// Type alias for a backend factory function
159pub type BackendFactory = fn(Option<&BackendConfig>, std::path::PathBuf) -> Arc<dyn TaskBackend>;
160
161/// Create a backend based on configuration.
162///
163/// This function only handles the `host` backend. For `dagger` backend support,
164/// use `create_backend_with_factory` and provide a factory from `cuenv-dagger`.
165pub fn create_backend(
166    config: Option<&BackendConfig>,
167    project_root: std::path::PathBuf,
168    cli_backend: Option<&str>,
169) -> Arc<dyn TaskBackend> {
170    create_backend_with_factory(config, project_root, cli_backend, None)
171}
172
173/// Create a backend with an optional factory for non-host backends.
174///
175/// The `dagger_factory` parameter should be `Some(cuenv_dagger::create_dagger_backend)`
176/// when the dagger backend is available.
177pub fn create_backend_with_factory(
178    config: Option<&BackendConfig>,
179    project_root: std::path::PathBuf,
180    cli_backend: Option<&str>,
181    dagger_factory: Option<BackendFactory>,
182) -> Arc<dyn TaskBackend> {
183    // CLI override takes precedence, then config, then default to host
184    let backend_type = if let Some(b) = cli_backend {
185        b.to_string()
186    } else if let Some(c) = config {
187        c.backend_type.clone()
188    } else {
189        "host".to_string()
190    };
191
192    match backend_type.as_str() {
193        "dagger" => {
194            if let Some(factory) = dagger_factory {
195                factory(config, project_root)
196            } else {
197                tracing::error!(
198                    "Dagger backend requested but not available. \
199                     Add cuenv-dagger dependency to enable it. \
200                     Falling back to host backend."
201                );
202                Arc::new(HostBackend::new())
203            }
204        }
205        _ => Arc::new(HostBackend::new()),
206    }
207}
208
209/// Check if the dagger backend should be used based on configuration
210pub fn should_use_dagger(config: Option<&BackendConfig>, cli_backend: Option<&str>) -> bool {
211    let backend_type = if let Some(b) = cli_backend {
212        b
213    } else if let Some(c) = config {
214        &c.backend_type
215    } else {
216        "host"
217    };
218
219    backend_type == "dagger"
220}