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