Skip to main content

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::OutputCapture;
9use crate::config::BackendConfig;
10use crate::environment::Environment;
11use crate::{Error, Result};
12use async_trait::async_trait;
13use std::path::Path;
14use std::process::Stdio;
15use std::sync::Arc;
16use tokio::process::Command;
17
18/// Context for a single task execution, grouping all parameters
19/// needed by [`TaskBackend::execute`].
20pub struct TaskExecutionContext<'a> {
21    /// Name of the task being executed
22    pub name: &'a str,
23    /// Task definition
24    pub task: &'a Task,
25    /// Environment variables for the task
26    pub environment: &'a Environment,
27    /// Root directory of the project
28    pub project_root: &'a Path,
29    /// Whether to capture or stream output
30    pub capture_output: OutputCapture,
31}
32
33/// Trait for task execution backends
34#[async_trait]
35pub trait TaskBackend: Send + Sync {
36    /// Execute a single task and return the result
37    async fn execute(&self, ctx: &TaskExecutionContext<'_>) -> Result<TaskResult>;
38
39    /// Get the name of the backend
40    fn name(&self) -> &'static str;
41}
42
43/// Host backend - executes tasks directly on the host machine
44pub struct HostBackend;
45
46impl Default for HostBackend {
47    fn default() -> Self {
48        Self
49    }
50}
51
52impl HostBackend {
53    pub fn new() -> Self {
54        Self
55    }
56}
57
58#[async_trait]
59impl TaskBackend for HostBackend {
60    async fn execute(&self, ctx: &TaskExecutionContext<'_>) -> Result<TaskResult> {
61        tracing::info!(
62            task = %ctx.name,
63            backend = "host",
64            "Executing task on host"
65        );
66
67        // Resolve command path using the environment's PATH
68        let resolved_command = ctx.environment.resolve_command(&ctx.task.command);
69
70        // Build command
71        let mut cmd = if let Some(shell) = &ctx.task.shell {
72            let mut c = Command::new(shell.command.as_deref().unwrap_or("bash"));
73            if let Some(flag) = &shell.flag {
74                c.arg(flag);
75            } else {
76                c.arg("-c");
77            }
78            // Append the task command string to the shell invocation
79            c.arg(&ctx.task.command);
80            c
81        } else {
82            let mut c = Command::new(resolved_command);
83            c.args(&ctx.task.args);
84            c
85        };
86
87        // Set working directory
88        cmd.current_dir(ctx.project_root);
89
90        // Set environment variables
91        cmd.env_clear();
92        for (k, v) in &ctx.environment.vars {
93            cmd.env(k, v);
94        }
95
96        // Execute - always capture output for consistent behavior
97        if ctx.capture_output.should_capture() {
98            let output = cmd
99                .stdout(Stdio::piped())
100                .stderr(Stdio::piped())
101                .output()
102                .await
103                .map_err(|e| Error::Io {
104                    source: e,
105                    path: None,
106                    operation: format!("spawn task {}", ctx.name),
107                })?;
108
109            let stdout = String::from_utf8_lossy(&output.stdout).to_string();
110            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
111            let exit_code = output.status.code().unwrap_or(-1);
112            let success = output.status.success();
113
114            if !success {
115                tracing::warn!(task = %ctx.name, exit = exit_code, "Task failed");
116            }
117
118            Ok(TaskResult {
119                name: ctx.name.to_string(),
120                exit_code: Some(exit_code),
121                stdout,
122                stderr,
123                success,
124            })
125        } else {
126            // Stream output directly to terminal (interactive mode)
127            let status = cmd
128                .stdout(Stdio::inherit())
129                .stderr(Stdio::inherit())
130                .status()
131                .await
132                .map_err(|e| Error::Io {
133                    source: e,
134                    path: None,
135                    operation: format!("spawn task {}", ctx.name),
136                })?;
137
138            let exit_code = status.code().unwrap_or(-1);
139            let success = status.success();
140
141            if !success {
142                tracing::warn!(task = %ctx.name, exit = exit_code, "Task failed");
143            }
144
145            Ok(TaskResult {
146                name: ctx.name.to_string(),
147                exit_code: Some(exit_code),
148                stdout: String::new(), // Output went to terminal
149                stderr: String::new(),
150                success,
151            })
152        }
153    }
154
155    fn name(&self) -> &'static str {
156        "host"
157    }
158}
159
160/// Type alias for a backend factory function
161pub type BackendFactory = fn(Option<&BackendConfig>, std::path::PathBuf) -> Arc<dyn TaskBackend>;
162
163/// Create a backend based on configuration.
164///
165/// This function only handles the `host` backend. For `dagger` backend support,
166/// use `create_backend_with_factory` and provide a factory from `cuenv-dagger`.
167pub fn create_backend(
168    config: Option<&BackendConfig>,
169    project_root: std::path::PathBuf,
170    cli_backend: Option<&str>,
171) -> Arc<dyn TaskBackend> {
172    create_backend_with_factory(config, project_root, cli_backend, None)
173}
174
175/// Create a backend with an optional factory for non-host backends.
176///
177/// The `dagger_factory` parameter should be `Some(cuenv_dagger::create_dagger_backend)`
178/// when the dagger backend is available.
179pub fn create_backend_with_factory(
180    config: Option<&BackendConfig>,
181    project_root: std::path::PathBuf,
182    cli_backend: Option<&str>,
183    dagger_factory: Option<BackendFactory>,
184) -> Arc<dyn TaskBackend> {
185    // CLI override takes precedence, then config, then default to host
186    let backend_type = if let Some(b) = cli_backend {
187        b.to_string()
188    } else if let Some(c) = config {
189        c.backend_type.clone()
190    } else {
191        "host".to_string()
192    };
193
194    match backend_type.as_str() {
195        "dagger" => {
196            if let Some(factory) = dagger_factory {
197                factory(config, project_root)
198            } else {
199                tracing::error!(
200                    "Dagger backend requested but not available. \
201                     Add cuenv-dagger dependency to enable it. \
202                     Falling back to host backend."
203                );
204                Arc::new(HostBackend::new())
205            }
206        }
207        _ => Arc::new(HostBackend::new()),
208    }
209}
210
211/// Check if the dagger backend should be used based on configuration
212pub fn should_use_dagger(config: Option<&BackendConfig>, cli_backend: Option<&str>) -> bool {
213    let backend_type = if let Some(b) = cli_backend {
214        b
215    } else if let Some(c) = config {
216        &c.backend_type
217    } else {
218        "host"
219    };
220
221    backend_type == "dagger"
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn test_host_backend_new() {
230        let backend = HostBackend::new();
231        assert_eq!(backend.name(), "host");
232    }
233
234    #[test]
235    fn test_host_backend_default() {
236        let backend = HostBackend;
237        assert_eq!(backend.name(), "host");
238    }
239
240    #[test]
241    fn test_host_backend_name() {
242        let backend = HostBackend;
243        assert_eq!(backend.name(), "host");
244    }
245
246    #[test]
247    fn test_should_use_dagger_cli_override_dagger() {
248        // CLI override takes precedence
249        assert!(should_use_dagger(None, Some("dagger")));
250    }
251
252    #[test]
253    fn test_should_use_dagger_cli_override_host() {
254        // CLI override to host
255        assert!(!should_use_dagger(None, Some("host")));
256    }
257
258    #[test]
259    fn test_should_use_dagger_config_dagger() {
260        let config = BackendConfig {
261            backend_type: "dagger".to_string(),
262            options: None,
263        };
264        assert!(should_use_dagger(Some(&config), None));
265    }
266
267    #[test]
268    fn test_should_use_dagger_config_host() {
269        let config = BackendConfig {
270            backend_type: "host".to_string(),
271            options: None,
272        };
273        assert!(!should_use_dagger(Some(&config), None));
274    }
275
276    #[test]
277    fn test_should_use_dagger_default() {
278        // No config, no CLI - defaults to host
279        assert!(!should_use_dagger(None, None));
280    }
281
282    #[test]
283    fn test_should_use_dagger_cli_overrides_config() {
284        let config = BackendConfig {
285            backend_type: "dagger".to_string(),
286            options: None,
287        };
288        // CLI override to host, even though config says dagger
289        assert!(!should_use_dagger(Some(&config), Some("host")));
290    }
291
292    #[test]
293    fn test_create_backend_defaults_to_host() {
294        let backend = create_backend(None, std::path::PathBuf::from("."), None);
295        assert_eq!(backend.name(), "host");
296    }
297
298    #[test]
299    fn test_create_backend_with_cli_host() {
300        let backend = create_backend(None, std::path::PathBuf::from("."), Some("host"));
301        assert_eq!(backend.name(), "host");
302    }
303
304    #[test]
305    fn test_create_backend_with_config_host() {
306        let config = BackendConfig {
307            backend_type: "host".to_string(),
308            options: None,
309        };
310        let backend = create_backend(Some(&config), std::path::PathBuf::from("."), None);
311        assert_eq!(backend.name(), "host");
312    }
313
314    #[test]
315    fn test_create_backend_unknown_type_defaults_to_host() {
316        let config = BackendConfig {
317            backend_type: "unknown".to_string(),
318            options: None,
319        };
320        let backend = create_backend(Some(&config), std::path::PathBuf::from("."), None);
321        // Unknown backend types fall back to host
322        assert_eq!(backend.name(), "host");
323    }
324
325    #[test]
326    fn test_create_backend_dagger_without_factory() {
327        let config = BackendConfig {
328            backend_type: "dagger".to_string(),
329            options: None,
330        };
331        // Without factory, dagger falls back to host
332        let backend = create_backend(Some(&config), std::path::PathBuf::from("."), None);
333        assert_eq!(backend.name(), "host");
334    }
335
336    #[test]
337    fn test_create_backend_with_factory_dagger() {
338        // Create a mock factory that returns a host backend (for testing)
339        fn mock_dagger_factory(
340            _config: Option<&BackendConfig>,
341            _project_root: std::path::PathBuf,
342        ) -> Arc<dyn TaskBackend> {
343            Arc::new(HostBackend::new())
344        }
345
346        let config = BackendConfig {
347            backend_type: "dagger".to_string(),
348            options: None,
349        };
350
351        let backend = create_backend_with_factory(
352            Some(&config),
353            std::path::PathBuf::from("."),
354            None,
355            Some(mock_dagger_factory),
356        );
357        // The mock factory returns a host backend, but the factory was called
358        assert_eq!(backend.name(), "host");
359    }
360
361    #[test]
362    fn test_create_backend_with_factory_cli_overrides_to_dagger() {
363        fn mock_dagger_factory(
364            _config: Option<&BackendConfig>,
365            _project_root: std::path::PathBuf,
366        ) -> Arc<dyn TaskBackend> {
367            Arc::new(HostBackend::new())
368        }
369
370        // CLI says dagger, even with no config
371        let backend = create_backend_with_factory(
372            None,
373            std::path::PathBuf::from("."),
374            Some("dagger"),
375            Some(mock_dagger_factory),
376        );
377        assert_eq!(backend.name(), "host"); // Mock returns host
378    }
379
380    #[test]
381    fn test_create_backend_with_factory_cli_overrides_config() {
382        fn mock_dagger_factory(
383            _config: Option<&BackendConfig>,
384            _project_root: std::path::PathBuf,
385        ) -> Arc<dyn TaskBackend> {
386            Arc::new(HostBackend::new())
387        }
388
389        let config = BackendConfig {
390            backend_type: "dagger".to_string(),
391            options: None,
392        };
393
394        // CLI says host, config says dagger - CLI wins
395        let backend = create_backend_with_factory(
396            Some(&config),
397            std::path::PathBuf::from("."),
398            Some("host"),
399            Some(mock_dagger_factory),
400        );
401        assert_eq!(backend.name(), "host");
402    }
403
404    #[test]
405    fn test_backend_config_debug() {
406        let config = BackendConfig {
407            backend_type: "host".to_string(),
408            options: None,
409        };
410        let debug_str = format!("{:?}", config);
411        assert!(debug_str.contains("host"));
412    }
413}