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