Skip to main content

cuenv_core/tasks/
mod.rs

1//! Task execution and management module
2//!
3//! This module provides the core types for task execution, matching the CUE schema.
4//!
5//! # Task API v2
6//!
7//! Users annotate tasks with their type to unlock specific semantics:
8//! - [`Task`]: Single command or script
9//! - [`TaskGroup`]: Parallel execution (all children run concurrently)
10//! - [`TaskList`]: Sequential execution (steps run in order)
11
12pub mod backend;
13pub mod cache;
14pub mod captures;
15pub mod executor;
16pub mod graph;
17pub mod index;
18pub mod output_refs;
19pub mod process_registry;
20
21// Re-export executor and graph modules
22pub use backend::{
23    BackendFactory, HostBackend, TaskBackend, TaskExecutionContext, create_backend,
24    create_backend_with_factory, should_use_dagger,
25};
26pub use executor::*;
27pub use graph::*;
28pub use index::{IndexedTask, TaskIndex, TaskPath, WorkspaceTask};
29pub use output_refs::{
30    OutputRefResolver, TaskOutputField, TaskOutputRef, has_output_refs, process_output_refs,
31};
32pub use process_registry::global_registry;
33
34use serde::{Deserialize, Serialize};
35use std::collections::HashMap;
36use std::path::Path;
37
38fn default_hermetic() -> bool {
39    true
40}
41
42// =============================================================================
43// Task Cache Policy
44// =============================================================================
45
46/// Cache mode for task result caching.
47#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
48#[serde(rename_all = "kebab-case")]
49pub enum TaskCacheMode {
50    /// Never read or write cache entries.
51    #[default]
52    Never,
53    /// Read from cache only.
54    Read,
55    /// Write to cache only.
56    Write,
57    /// Read from and write to cache.
58    ReadWrite,
59}
60
61impl TaskCacheMode {
62    /// Returns true when this mode allows cache reads.
63    #[must_use]
64    pub const fn allows_read(self) -> bool {
65        matches!(self, Self::Read | Self::ReadWrite)
66    }
67
68    /// Returns true when this mode allows cache writes.
69    #[must_use]
70    pub const fn allows_write(self) -> bool {
71        matches!(self, Self::Write | Self::ReadWrite)
72    }
73}
74
75/// Cache policy controls for a single task.
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
77#[serde(rename_all = "camelCase")]
78pub struct TaskCachePolicy {
79    /// Cache mode for the task. Default is `never`.
80    #[serde(default)]
81    pub mode: TaskCacheMode,
82    /// Maximum age for cache reads (for example: "1h", "30m", "infinite").
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub max_age: Option<String>,
85}
86
87// =============================================================================
88// Task Captures (Regex Extraction from Output)
89// =============================================================================
90
91/// Source stream for regex capture extraction
92#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
93#[serde(rename_all = "lowercase")]
94pub enum CaptureSource {
95    #[default]
96    Stdout,
97    Stderr,
98}
99
100/// Regex capture definition for extracting values from task output
101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
102#[serde(rename_all = "camelCase")]
103pub struct TaskCapture {
104    /// Regex pattern with capture group - first group's match becomes the value
105    pub pattern: String,
106    /// Which output stream to search (default: stdout)
107    #[serde(default)]
108    pub source: CaptureSource,
109}
110
111/// Reference to a captured value, resolved at runtime
112#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
113#[serde(rename_all = "camelCase")]
114pub struct TaskCaptureRef {
115    pub cuenv_capture_ref: bool,
116    pub cuenv_task: String,
117    pub cuenv_capture: String,
118}
119
120// =============================================================================
121// Script Shell Configuration
122// =============================================================================
123
124/// Shell interpreter for script-based tasks
125#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
126#[serde(rename_all = "lowercase")]
127pub enum ScriptShell {
128    #[default]
129    Bash,
130    Sh,
131    Zsh,
132    Fish,
133    Nu,
134    Powershell,
135    Pwsh,
136    Python,
137    Node,
138    Ruby,
139    Perl,
140}
141
142impl ScriptShell {
143    /// Get the command and flag for this shell
144    #[must_use]
145    pub fn command_and_flag(&self) -> (&'static str, &'static str) {
146        match self {
147            ScriptShell::Bash => ("bash", "-c"),
148            ScriptShell::Sh => ("sh", "-c"),
149            ScriptShell::Zsh => ("zsh", "-c"),
150            ScriptShell::Fish => ("fish", "-c"),
151            ScriptShell::Nu => ("nu", "-c"),
152            ScriptShell::Powershell => ("powershell", "-Command"),
153            ScriptShell::Pwsh => ("pwsh", "-Command"),
154            ScriptShell::Python => ("python", "-c"),
155            ScriptShell::Node => ("node", "-e"),
156            ScriptShell::Ruby => ("ruby", "-e"),
157            ScriptShell::Perl => ("perl", "-e"),
158        }
159    }
160
161    /// Returns true if this shell supports POSIX-style options (errexit, pipefail, etc.)
162    #[must_use]
163    pub fn supports_shell_options(&self) -> bool {
164        matches!(self, ScriptShell::Bash | ScriptShell::Sh | ScriptShell::Zsh)
165    }
166
167    /// Returns true if this shell supports `set -o pipefail`.
168    #[must_use]
169    pub fn supports_pipefail(&self) -> bool {
170        matches!(self, ScriptShell::Bash | ScriptShell::Zsh)
171    }
172
173    /// Parse a shell enum from an executable path or bare command name.
174    #[must_use]
175    pub(crate) fn from_command(command: &str) -> Option<Self> {
176        let file_name = Path::new(command)
177            .file_name()?
178            .to_str()?
179            .to_ascii_lowercase();
180        let normalized = file_name.strip_suffix(".exe").unwrap_or(&file_name);
181
182        match normalized {
183            "bash" => Some(Self::Bash),
184            "sh" => Some(Self::Sh),
185            "zsh" => Some(Self::Zsh),
186            "fish" => Some(Self::Fish),
187            "nu" => Some(Self::Nu),
188            "powershell" => Some(Self::Powershell),
189            "pwsh" => Some(Self::Pwsh),
190            "python" => Some(Self::Python),
191            "node" => Some(Self::Node),
192            "ruby" => Some(Self::Ruby),
193            "perl" => Some(Self::Perl),
194            _ => None,
195        }
196    }
197}
198
199/// Shell options for bash-like shells
200#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
201pub struct ShellOptions {
202    /// -e: exit on error (default: true)
203    #[serde(default = "default_true")]
204    pub errexit: bool,
205    /// -u: error on undefined vars (default: true)
206    #[serde(default = "default_true")]
207    pub nounset: bool,
208    /// -o pipefail: fail on pipe errors (default: true, requires bash or zsh)
209    #[serde(default = "default_true")]
210    pub pipefail: bool,
211    /// -x: debug/trace mode (default: false)
212    #[serde(default)]
213    pub xtrace: bool,
214}
215
216fn default_true() -> bool {
217    true
218}
219
220impl Default for ShellOptions {
221    fn default() -> Self {
222        Self {
223            errexit: true,
224            nounset: true,
225            pipefail: true,
226            xtrace: false,
227        }
228    }
229}
230
231impl ShellOptions {
232    /// Generate the shell options prefix for a script
233    #[must_use]
234    pub fn to_set_commands(&self) -> String {
235        let mut opts = Vec::new();
236        if self.errexit {
237            opts.push("-e");
238        }
239        if self.nounset {
240            opts.push("-u");
241        }
242        if self.pipefail {
243            opts.push("-o pipefail");
244        }
245        if self.xtrace {
246            opts.push("-x");
247        }
248        if opts.is_empty() {
249            String::new()
250        } else {
251            format!("set {}\n", opts.join(" "))
252        }
253    }
254}
255
256/// Shell configuration for task execution (legacy, for backwards compatibility)
257#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
258pub struct Shell {
259    /// Shell executable name (e.g., "bash", "fish", "zsh")
260    pub command: Option<String>,
261    /// Flag for command execution (e.g., "-c", "--command")
262    pub flag: Option<String>,
263}
264
265/// A fully-resolved process invocation for a task.
266#[derive(Debug, Clone, PartialEq, Eq)]
267pub(crate) struct TaskCommandSpec {
268    /// Executable path or command name to spawn.
269    pub program: String,
270    /// Command-line arguments for the program.
271    pub args: Vec<String>,
272}
273
274#[derive(Debug, Clone)]
275struct EffectiveScriptShell {
276    command: String,
277    flag: String,
278    display_name: String,
279    supports_shell_options: bool,
280    supports_pipefail: bool,
281}
282
283/// Mapping of external output to local workspace path
284#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
285pub struct Mapping {
286    /// Path relative to external project root of a declared output from the external task
287    pub from: String,
288    /// Path inside the dependent task’s hermetic workspace where this file/dir will be materialized
289    pub to: String,
290}
291
292/// A single task input definition
293#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
294#[serde(untagged)]
295pub enum Input {
296    /// Local path/glob input
297    Path(String),
298    /// Cross-project reference
299    Project(ProjectReference),
300    /// Same-project task output reference
301    Task(TaskOutput),
302}
303
304impl Input {
305    pub fn as_path(&self) -> Option<&String> {
306        match self {
307            Input::Path(path) => Some(path),
308            Input::Project(_) | Input::Task(_) => None,
309        }
310    }
311
312    pub fn as_project(&self) -> Option<&ProjectReference> {
313        match self {
314            Input::Project(reference) => Some(reference),
315            Input::Path(_) | Input::Task(_) => None,
316        }
317    }
318
319    pub fn as_task_output(&self) -> Option<&TaskOutput> {
320        match self {
321            Input::Task(output) => Some(output),
322            Input::Path(_) | Input::Project(_) => None,
323        }
324    }
325}
326
327/// Cross-project input declaration
328#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
329pub struct ProjectReference {
330    /// Path to project root relative to env.cue or absolute-from-repo-root
331    pub project: String,
332    /// Name of the external task in that project
333    pub task: String,
334    /// Required explicit mappings
335    pub map: Vec<Mapping>,
336}
337
338/// Reference to another task's outputs within the same project
339#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
340pub struct TaskOutput {
341    /// Name of the task whose cached outputs to consume (e.g. "docs.build")
342    pub task: String,
343    /// Optional explicit mapping of outputs. If omitted, all outputs are
344    /// materialized at their original paths in the hermetic workspace.
345    #[serde(default)]
346    pub map: Option<Vec<Mapping>>,
347}
348
349/// Source location metadata from CUE evaluation
350#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
351pub struct SourceLocation {
352    /// Path to source file, relative to cue.mod root
353    pub file: String,
354    /// Line number where the task was defined
355    pub line: u32,
356    /// Column number where the task was defined
357    pub column: u32,
358}
359
360impl SourceLocation {
361    /// Get the directory containing this source file
362    pub fn directory(&self) -> Option<&str> {
363        std::path::Path::new(&self.file)
364            .parent()
365            .and_then(|p| p.to_str())
366            .filter(|s| !s.is_empty())
367    }
368}
369
370/// A task dependency - an embedded task reference with _name field
371/// When tasks reference other tasks directly in CUE (e.g., `dependsOn: [build]`),
372/// the Go bridge injects the `_name` field to identify the dependency.
373///
374/// Supports deserialization from:
375/// - A string: `"taskName"` -> `TaskDependency { name: "taskName" }`
376/// - An object with `_name`: `{ "_name": "taskName", ... }` -> `TaskDependency { name: "taskName" }`
377#[derive(Debug, Clone, Serialize, PartialEq)]
378pub struct TaskDependency {
379    /// The task name (injected by Go bridge based on task path)
380    /// e.g., "build", "test.unit", "deploy.staging"
381    #[serde(rename = "_name")]
382    pub name: String,
383
384    // Other fields are captured but not used - we only need the name
385    #[serde(flatten)]
386    _rest: serde_json::Value,
387}
388
389impl<'de> serde::Deserialize<'de> for TaskDependency {
390    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
391    where
392        D: serde::Deserializer<'de>,
393    {
394        use serde::de::{self, Visitor};
395
396        struct TaskDependencyVisitor;
397
398        impl<'de> Visitor<'de> for TaskDependencyVisitor {
399            type Value = TaskDependency;
400
401            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
402                formatter.write_str("a string or an object with _name field")
403            }
404
405            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
406            where
407                E: de::Error,
408            {
409                Ok(TaskDependency::from_name(value))
410            }
411
412            fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
413            where
414                E: de::Error,
415            {
416                Ok(TaskDependency::from_name(value))
417            }
418
419            fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
420            where
421                M: de::MapAccess<'de>,
422            {
423                // Deserialize as a JSON object and extract _name
424                let value: serde_json::Value =
425                    serde::Deserialize::deserialize(de::value::MapAccessDeserializer::new(map))?;
426
427                let name = value
428                    .get("_name")
429                    .and_then(|v| v.as_str())
430                    .ok_or_else(|| de::Error::missing_field("_name"))?
431                    .to_string();
432
433                Ok(TaskDependency { name, _rest: value })
434            }
435        }
436
437        deserializer.deserialize_any(TaskDependencyVisitor)
438    }
439}
440
441impl TaskDependency {
442    /// Create a new TaskDependency from a task name
443    #[must_use]
444    pub fn from_name(name: impl Into<String>) -> Self {
445        Self {
446            name: name.into(),
447            _rest: serde_json::Value::Null,
448        }
449    }
450
451    /// Get the task name
452    #[must_use]
453    pub fn task_name(&self) -> &str {
454        &self.name
455    }
456
457    /// Check if this dependency matches a given task name
458    pub fn matches(&self, name: &str) -> bool {
459        self.name == name
460    }
461}
462
463// =============================================================================
464// Single Executable Task
465// =============================================================================
466
467/// A single executable task
468///
469/// Note: Custom deserialization is used to ensure that a Task can only be
470/// deserialized when it has a `command` or `script` field. This is necessary
471/// because TaskNode uses untagged enum, and we need to distinguish
472/// between Task, TaskGroup, and TaskList during deserialization.
473#[derive(Debug, Clone, Serialize, PartialEq)]
474pub struct Task {
475    /// Shell configuration for command execution (legacy, for backwards compatibility)
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub shell: Option<Shell>,
478
479    /// Command to execute. Required unless 'script' is provided.
480    #[serde(default, skip_serializing_if = "String::is_empty")]
481    pub command: String,
482
483    /// Inline script to execute (alternative to command).
484    /// When script is provided, shell defaults to bash if not specified.
485    #[serde(default, skip_serializing_if = "Option::is_none")]
486    pub script: Option<String>,
487
488    /// Shell interpreter for script-based tasks (e.g., bash, python, node)
489    /// Only used when `script` is provided.
490    #[serde(
491        default,
492        rename = "scriptShell",
493        skip_serializing_if = "Option::is_none"
494    )]
495    pub script_shell: Option<ScriptShell>,
496
497    /// Shell options for bash-like shells (errexit, nounset, pipefail, xtrace)
498    /// Only used when `script` is provided with a POSIX-compatible shell.
499    #[serde(
500        default,
501        rename = "shellOptions",
502        skip_serializing_if = "Option::is_none"
503    )]
504    pub shell_options: Option<ShellOptions>,
505
506    /// Arguments for the command
507    #[serde(default)]
508    pub args: Vec<String>,
509
510    /// Environment variables for this task
511    #[serde(default)]
512    pub env: HashMap<String, serde_json::Value>,
513
514    /// Dagger-specific configuration for running this task in a container
515    /// DEPRECATED: Use runtime field with Dagger variant instead
516    #[serde(default)]
517    pub dagger: Option<DaggerTaskConfig>,
518
519    /// Runtime override for this task (inherits from project if not set)
520    #[serde(default, skip_serializing_if = "Option::is_none")]
521    pub runtime: Option<crate::manifest::Runtime>,
522
523    /// When true (default), task runs in isolated hermetic directory.
524    /// When false, task runs directly in workspace/project root.
525    #[serde(default = "default_hermetic")]
526    pub hermetic: bool,
527
528    /// Task dependencies - embedded task references with _name field
529    /// In CUE, users write `dependsOn: [build, test]` with direct references.
530    /// The Go bridge injects _name into each embedded task for identification.
531    #[serde(default, rename = "dependsOn")]
532    pub depends_on: Vec<TaskDependency>,
533
534    /// Input files/resources
535    #[serde(default)]
536    pub inputs: Vec<Input>,
537
538    /// Output files/resources
539    #[serde(default)]
540    pub outputs: Vec<String>,
541
542    /// Task result cache policy.
543    #[serde(default, skip_serializing_if = "Option::is_none")]
544    pub cache: Option<TaskCachePolicy>,
545
546    /// Description of the task
547    #[serde(default)]
548    pub description: Option<String>,
549
550    /// Task parameter definitions for CLI arguments
551    #[serde(default)]
552    pub params: Option<TaskParams>,
553
554    /// Labels for task discovery via TaskMatcher
555    /// Example: labels: ["projen", "codegen"]
556    #[serde(default)]
557    pub labels: Vec<String>,
558
559    /// Execution timeout (e.g., "30m", "1h")
560    #[serde(default, skip_serializing_if = "Option::is_none")]
561    pub timeout: Option<String>,
562
563    /// Retry configuration for failed tasks
564    #[serde(default, skip_serializing_if = "Option::is_none")]
565    pub retry: Option<RetryConfig>,
566
567    /// Continue execution even if this task fails (default: false)
568    #[serde(default, rename = "continueOnError")]
569    pub continue_on_error: bool,
570
571    /// Named regex captures extracted from task stdout/stderr after execution
572    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
573    pub captures: HashMap<String, TaskCapture>,
574
575    /// If set, this task is a reference to another project's task
576    /// that should be resolved at runtime using TaskDiscovery.
577    /// Format: "#project-name:task-name"
578    #[serde(default, skip_serializing_if = "Option::is_none")]
579    pub task_ref: Option<String>,
580
581    /// If set, specifies the project root where this task should execute.
582    /// Used for TaskRef resolution to run tasks in their original project.
583    #[serde(default, skip_serializing_if = "Option::is_none")]
584    pub project_root: Option<std::path::PathBuf>,
585
586    /// Source file location where this task was defined (from CUE metadata).
587    /// Used to determine default execution directory and for task listing grouping.
588    #[serde(default, rename = "_source", skip_serializing_if = "Option::is_none")]
589    pub source: Option<SourceLocation>,
590
591    /// Working directory override (relative to cue.mod root).
592    /// Defaults to the directory of the source file if not set.
593    #[serde(default, rename = "dir", skip_serializing_if = "Option::is_none")]
594    pub directory: Option<String>,
595}
596
597/// Retry configuration for failed tasks
598#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
599pub struct RetryConfig {
600    /// Number of retry attempts (default: 3)
601    #[serde(default = "default_retry_attempts")]
602    pub attempts: u32,
603    /// Delay between retries (e.g., "5s")
604    #[serde(default, skip_serializing_if = "Option::is_none")]
605    pub delay: Option<String>,
606}
607
608fn default_retry_attempts() -> u32 {
609    3
610}
611
612// Custom deserialization for Task to ensure either command or script is present.
613// This is necessary for untagged enum deserialization in TaskNode to work correctly.
614impl<'de> serde::Deserialize<'de> for Task {
615    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
616    where
617        D: serde::Deserializer<'de>,
618    {
619        // Helper struct that mirrors Task but with all optional fields
620        #[derive(serde::Deserialize)]
621        struct TaskHelper {
622            #[serde(default)]
623            shell: Option<Shell>,
624            #[serde(default)]
625            command: Option<String>,
626            #[serde(default)]
627            script: Option<String>,
628            #[serde(default, rename = "scriptShell")]
629            script_shell: Option<ScriptShell>,
630            #[serde(default, rename = "shellOptions")]
631            shell_options: Option<ShellOptions>,
632            #[serde(default)]
633            args: Vec<String>,
634            #[serde(default)]
635            env: HashMap<String, serde_json::Value>,
636            #[serde(default)]
637            dagger: Option<DaggerTaskConfig>,
638            #[serde(default)]
639            runtime: Option<crate::manifest::Runtime>,
640            #[serde(default = "default_hermetic")]
641            hermetic: bool,
642            #[serde(default, rename = "dependsOn")]
643            depends_on: Vec<TaskDependency>,
644            #[serde(default)]
645            inputs: Vec<Input>,
646            #[serde(default)]
647            outputs: Vec<String>,
648            #[serde(default)]
649            cache: Option<TaskCachePolicy>,
650            #[serde(default)]
651            description: Option<String>,
652            #[serde(default)]
653            params: Option<TaskParams>,
654            #[serde(default)]
655            labels: Vec<String>,
656            #[serde(default)]
657            timeout: Option<String>,
658            #[serde(default)]
659            retry: Option<RetryConfig>,
660            #[serde(default, rename = "continueOnError")]
661            continue_on_error: bool,
662            #[serde(default)]
663            captures: HashMap<String, TaskCapture>,
664            #[serde(default)]
665            task_ref: Option<String>,
666            #[serde(default)]
667            project_root: Option<std::path::PathBuf>,
668            #[serde(default, rename = "_source")]
669            source: Option<SourceLocation>,
670            #[serde(default, rename = "dir")]
671            directory: Option<String>,
672        }
673
674        let helper = TaskHelper::deserialize(deserializer)?;
675
676        // Validate: either command, script, or task_ref must be present
677        let has_command = helper.command.as_ref().is_some_and(|c| !c.is_empty());
678        let has_script = helper.script.is_some();
679        let has_task_ref = helper.task_ref.is_some();
680
681        if !has_command && !has_script && !has_task_ref {
682            return Err(serde::de::Error::custom(
683                "Task must have either 'command', 'script', or 'task_ref' field",
684            ));
685        }
686
687        Ok(Task {
688            shell: helper.shell,
689            command: helper.command.unwrap_or_default(),
690            script: helper.script,
691            script_shell: helper.script_shell,
692            shell_options: helper.shell_options,
693            args: helper.args,
694            env: helper.env,
695            dagger: helper.dagger,
696            runtime: helper.runtime,
697            hermetic: helper.hermetic,
698            depends_on: helper.depends_on,
699            inputs: helper.inputs,
700            outputs: helper.outputs,
701            cache: helper.cache,
702            description: helper.description,
703            params: helper.params,
704            labels: helper.labels,
705            timeout: helper.timeout,
706            retry: helper.retry,
707            continue_on_error: helper.continue_on_error,
708            captures: helper.captures,
709            task_ref: helper.task_ref,
710            project_root: helper.project_root,
711            source: helper.source,
712            directory: helper.directory,
713        })
714    }
715}
716
717/// Dagger-specific task configuration
718#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
719pub struct DaggerTaskConfig {
720    /// Base container image for running the task (e.g., "ubuntu:22.04")
721    /// Overrides the global backend.options.image if set.
722    #[serde(default)]
723    pub image: Option<String>,
724
725    /// Use container from a previous task as base instead of an image.
726    /// The referenced task must have run first (use dependsOn to ensure ordering).
727    #[serde(default)]
728    pub from: Option<String>,
729
730    /// Secrets to mount or expose as environment variables.
731    /// Secrets are resolved using cuenv's secret resolvers and securely passed to Dagger.
732    #[serde(default)]
733    pub secrets: Option<Vec<DaggerSecret>>,
734
735    /// Cache volumes to mount for persistent build caching.
736    #[serde(default)]
737    pub cache: Option<Vec<DaggerCacheMount>>,
738}
739
740/// Secret configuration for Dagger containers
741#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
742pub struct DaggerSecret {
743    /// Name identifier for the secret in Dagger
744    pub name: String,
745
746    /// Mount secret as a file at this path (e.g., "/root/.npmrc")
747    #[serde(default)]
748    pub path: Option<String>,
749
750    /// Expose secret as an environment variable with this name
751    #[serde(default, rename = "envVar")]
752    pub env_var: Option<String>,
753
754    /// Secret resolver configuration
755    pub resolver: crate::secrets::Secret,
756}
757
758/// Cache volume mount configuration for Dagger
759#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
760pub struct DaggerCacheMount {
761    /// Path inside the container to mount the cache (e.g., "/root/.npm")
762    pub path: String,
763
764    /// Unique name for the cache volume
765    pub name: String,
766}
767
768/// Task parameter definitions for CLI arguments
769#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
770pub struct TaskParams {
771    /// Positional arguments (order matters, consumed left-to-right)
772    /// Referenced in args as {{0}}, {{1}}, etc.
773    #[serde(default)]
774    pub positional: Vec<ParamDef>,
775
776    /// Named arguments (--flag style) as direct fields
777    /// Referenced in args as {{name}} where name matches the field name
778    #[serde(flatten, default)]
779    pub named: HashMap<String, ParamDef>,
780}
781
782/// Parameter type for validation
783#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
784#[serde(rename_all = "lowercase")]
785pub enum ParamType {
786    #[default]
787    String,
788    Bool,
789    Int,
790}
791
792/// Parameter definition for task arguments
793#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
794pub struct ParamDef {
795    /// Human-readable description shown in --help
796    #[serde(default)]
797    pub description: Option<String>,
798
799    /// Whether the argument must be provided (default: false)
800    #[serde(default)]
801    pub required: bool,
802
803    /// Default value if not provided
804    #[serde(default)]
805    pub default: Option<String>,
806
807    /// Type hint for documentation (default: "string", not enforced at runtime)
808    #[serde(default, rename = "type")]
809    pub param_type: ParamType,
810
811    /// Short flag (single character, e.g., "t" for -t)
812    #[serde(default)]
813    pub short: Option<String>,
814}
815
816/// Resolved task arguments ready for interpolation
817#[derive(Debug, Clone, Default)]
818pub struct ResolvedArgs {
819    /// Positional argument values by index
820    pub positional: Vec<String>,
821    /// Named argument values by name
822    pub named: HashMap<String, String>,
823}
824
825impl ResolvedArgs {
826    /// Create empty resolved args
827    pub fn new() -> Self {
828        Self::default()
829    }
830
831    /// Interpolate placeholders in a string
832    /// Supports {{0}}, {{1}} for positional and {{name}} for named args
833    pub fn interpolate(&self, template: &str) -> String {
834        let mut result = template.to_string();
835
836        // Replace positional placeholders {{0}}, {{1}}, etc.
837        for (i, value) in self.positional.iter().enumerate() {
838            let placeholder = format!("{{{{{}}}}}", i);
839            result = result.replace(&placeholder, value);
840        }
841
842        // Replace named placeholders {{name}}
843        for (name, value) in &self.named {
844            let placeholder = format!("{{{{{}}}}}", name);
845            result = result.replace(&placeholder, value);
846        }
847
848        result
849    }
850
851    /// Interpolate all args in a list
852    pub fn interpolate_args(&self, args: &[String]) -> Vec<String> {
853        args.iter().map(|arg| self.interpolate(arg)).collect()
854    }
855}
856
857impl Default for Task {
858    fn default() -> Self {
859        Self {
860            shell: None,
861            command: String::new(),
862            script: None,
863            script_shell: None,
864            shell_options: None,
865            args: vec![],
866            env: HashMap::new(),
867            dagger: None,
868            runtime: None,
869            hermetic: true, // Default to hermetic execution
870            depends_on: vec![],
871            inputs: vec![],
872            outputs: vec![],
873            cache: None,
874            description: None,
875            params: None,
876            labels: vec![],
877            timeout: None,
878            retry: None,
879            continue_on_error: false,
880            captures: HashMap::new(),
881            task_ref: None,
882            project_root: None,
883            source: None,
884            directory: None,
885        }
886    }
887}
888
889impl Task {
890    /// Creates a new TaskRef placeholder task.
891    /// This task will be resolved at runtime using TaskDiscovery.
892    pub fn from_task_ref(ref_str: &str) -> Self {
893        Self {
894            task_ref: Some(ref_str.to_string()),
895            description: Some(format!("Reference to {}", ref_str)),
896            ..Default::default()
897        }
898    }
899
900    /// Returns true if this task is a TaskRef placeholder that needs resolution.
901    pub fn is_task_ref(&self) -> bool {
902        self.task_ref.is_some()
903    }
904
905    /// Returns an iterator over dependency task names.
906    pub fn dependency_names(&self) -> impl Iterator<Item = &str> {
907        self.depends_on.iter().map(|d| d.task_name())
908    }
909
910    /// Returns the effective cache policy for this task.
911    #[must_use]
912    pub fn cache_policy(&self) -> TaskCachePolicy {
913        self.cache.clone().unwrap_or_default()
914    }
915
916    /// Returns the description, or a default if not set.
917    pub fn description(&self) -> &str {
918        self.description
919            .as_deref()
920            .unwrap_or("No description provided")
921    }
922
923    /// Build the executable invocation for this task using the provided command resolver.
924    pub(crate) fn command_spec<F>(&self, mut resolve_command: F) -> crate::Result<TaskCommandSpec>
925    where
926        F: FnMut(&str) -> String,
927    {
928        if let Some(script) = &self.script {
929            let shell = self.effective_script_shell();
930            let script = self.prepare_script(script, &shell)?;
931
932            return Ok(TaskCommandSpec {
933                program: resolve_command(&shell.command),
934                args: vec![shell.flag, script],
935            });
936        }
937
938        if let Some(shell) = &self.shell
939            && let (Some(shell_command), Some(shell_flag)) = (&shell.command, &shell.flag)
940        {
941            let full_command = if self.command.is_empty() {
942                self.args.join(" ")
943            } else if self.args.is_empty() {
944                resolve_command(&self.command)
945            } else {
946                let resolved_command = resolve_command(&self.command);
947                format!("{} {}", resolved_command, self.args.join(" "))
948            };
949
950            return Ok(TaskCommandSpec {
951                program: resolve_command(shell_command),
952                args: vec![shell_flag.clone(), full_command],
953            });
954        }
955
956        Ok(TaskCommandSpec {
957            program: resolve_command(&self.command),
958            args: self.args.clone(),
959        })
960    }
961
962    /// Returns an iterator over local path/glob inputs.
963    pub fn iter_path_inputs(&self) -> impl Iterator<Item = &String> {
964        self.inputs.iter().filter_map(Input::as_path)
965    }
966
967    /// Returns an iterator over project references.
968    pub fn iter_project_refs(&self) -> impl Iterator<Item = &ProjectReference> {
969        self.inputs.iter().filter_map(Input::as_project)
970    }
971
972    /// Returns an iterator over same-project task output references.
973    pub fn iter_task_outputs(&self) -> impl Iterator<Item = &TaskOutput> {
974        self.inputs.iter().filter_map(Input::as_task_output)
975    }
976
977    /// Collects path/glob inputs applying an optional prefix (for workspace roots).
978    pub fn collect_path_inputs_with_prefix(&self, prefix: Option<&Path>) -> Vec<String> {
979        self.iter_path_inputs()
980            .map(|path| apply_prefix(prefix, path))
981            .collect()
982    }
983
984    /// Collects mapped destinations from project references, applying an optional prefix.
985    pub fn collect_project_destinations_with_prefix(&self, prefix: Option<&Path>) -> Vec<String> {
986        self.iter_project_refs()
987            .flat_map(|reference| reference.map.iter().map(|m| apply_prefix(prefix, &m.to)))
988            .collect()
989    }
990
991    /// Collects all input patterns (local + project destinations) with an optional prefix.
992    pub fn collect_all_inputs_with_prefix(&self, prefix: Option<&Path>) -> Vec<String> {
993        let mut inputs = self.collect_path_inputs_with_prefix(prefix);
994        inputs.extend(self.collect_project_destinations_with_prefix(prefix));
995        inputs
996    }
997
998    fn effective_script_shell(&self) -> EffectiveScriptShell {
999        if let Some(script_shell) = self.script_shell {
1000            let (command, flag) = script_shell.command_and_flag();
1001
1002            return EffectiveScriptShell {
1003                command: command.to_string(),
1004                flag: flag.to_string(),
1005                display_name: command.to_string(),
1006                supports_shell_options: script_shell.supports_shell_options(),
1007                supports_pipefail: script_shell.supports_pipefail(),
1008            };
1009        }
1010
1011        if let Some(shell) = &self.shell {
1012            let command = shell.command.clone().unwrap_or_else(|| "bash".to_string());
1013            let flag = shell.flag.clone().unwrap_or_else(|| "-c".to_string());
1014            let (supports_shell_options, supports_pipefail) = ScriptShell::from_command(&command)
1015                .map(|script_shell| {
1016                    (
1017                        script_shell.supports_shell_options(),
1018                        script_shell.supports_pipefail(),
1019                    )
1020                })
1021                .unwrap_or((false, false));
1022
1023            return EffectiveScriptShell {
1024                display_name: command.clone(),
1025                command,
1026                flag,
1027                supports_shell_options,
1028                supports_pipefail,
1029            };
1030        }
1031
1032        let default_shell = ScriptShell::default();
1033        let (command, flag) = default_shell.command_and_flag();
1034
1035        EffectiveScriptShell {
1036            command: command.to_string(),
1037            flag: flag.to_string(),
1038            display_name: command.to_string(),
1039            supports_shell_options: default_shell.supports_shell_options(),
1040            supports_pipefail: default_shell.supports_pipefail(),
1041        }
1042    }
1043
1044    fn prepare_script(&self, script: &str, shell: &EffectiveScriptShell) -> crate::Result<String> {
1045        let Some(shell_options) = self.shell_options else {
1046            return Ok(script.to_string());
1047        };
1048
1049        if !shell.supports_shell_options {
1050            return Err(crate::Error::configuration(format!(
1051                "Task uses shellOptions with unsupported script shell '{}'. \
1052                 Use scriptShell 'bash', 'sh', or 'zsh'.",
1053                shell.display_name
1054            )));
1055        }
1056
1057        if shell_options.pipefail && !shell.supports_pipefail {
1058            return Err(crate::Error::configuration(format!(
1059                "Task uses shellOptions.pipefail with unsupported script shell '{}'. \
1060                 Disable pipefail or use scriptShell 'bash' or 'zsh'.",
1061                shell.display_name
1062            )));
1063        }
1064
1065        let set_commands = shell_options.to_set_commands();
1066        if set_commands.is_empty() {
1067            return Ok(script.to_string());
1068        }
1069
1070        Ok(format!("{set_commands}{script}"))
1071    }
1072}
1073
1074impl crate::AffectedBy for Task {
1075    /// Returns true if this task is affected by the given file changes.
1076    ///
1077    /// # Behavior
1078    ///
1079    /// - Tasks with NO inputs are always considered affected (we can't determine what affects them)
1080    /// - Tasks with inputs are affected if any input pattern matches changed files
1081    fn is_affected_by(&self, changed_files: &[std::path::PathBuf], project_root: &Path) -> bool {
1082        let inputs: Vec<_> = self.iter_path_inputs().collect();
1083
1084        // No inputs = always affected (we can't determine what affects it)
1085        if inputs.is_empty() {
1086            return true;
1087        }
1088
1089        // Check if any input pattern matches any changed file
1090        inputs
1091            .iter()
1092            .any(|pattern| crate::matches_pattern(changed_files, project_root, pattern))
1093    }
1094
1095    fn input_patterns(&self) -> Vec<&str> {
1096        self.iter_path_inputs().map(String::as_str).collect()
1097    }
1098}
1099
1100fn apply_prefix(prefix: Option<&Path>, value: &str) -> String {
1101    if let Some(prefix) = prefix {
1102        prefix.join(value).to_string_lossy().to_string()
1103    } else {
1104        value.to_string()
1105    }
1106}
1107
1108// =============================================================================
1109// Parallel Execution (Task Group)
1110// =============================================================================
1111
1112/// A parallel task group - all children run concurrently
1113///
1114/// Discriminated by the required `type: "group"` field.
1115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1116pub struct TaskGroup {
1117    /// Type discriminator - always "group"
1118    #[serde(rename = "type")]
1119    pub type_: String,
1120
1121    /// Dependencies on other tasks
1122    #[serde(default, rename = "dependsOn")]
1123    pub depends_on: Vec<TaskDependency>,
1124
1125    /// Limit concurrent executions (0 = unlimited)
1126    #[serde(default, rename = "maxConcurrency")]
1127    pub max_concurrency: Option<u32>,
1128
1129    /// Human-readable description
1130    #[serde(default)]
1131    pub description: Option<String>,
1132
1133    /// Named children - all run concurrently (flattened from remaining fields)
1134    #[serde(flatten)]
1135    pub children: HashMap<String, TaskNode>,
1136}
1137
1138// =============================================================================
1139// Sequential Execution (Task Sequence)
1140// =============================================================================
1141
1142// TaskSequence is simply Vec<TaskNode> - no wrapper struct needed.
1143// The sequence is discriminated by being a JSON array.
1144
1145// =============================================================================
1146// Task Node (Union Type)
1147// =============================================================================
1148
1149/// Union of all task types - explicit typing required in CUE
1150///
1151/// This is the recursive type that represents any task node in the tree.
1152/// Discriminated by:
1153/// - [`Task`]: Has `command` or `script` field
1154/// - [`TaskGroup`]: Has `type: "group"` field
1155/// - Sequence: Is a JSON array `[...]`
1156#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1157#[serde(untagged)]
1158pub enum TaskNode {
1159    /// A single executable task
1160    Task(Box<Task>),
1161    /// A parallel task group
1162    Group(TaskGroup),
1163    /// A sequential list of tasks (just an array)
1164    Sequence(Vec<TaskNode>),
1165}
1166
1167// =============================================================================
1168// Legacy Type Aliases (for backwards compatibility)
1169// =============================================================================
1170
1171/// Legacy alias for TaskNode
1172#[deprecated(since = "0.26.0", note = "Use TaskNode instead")]
1173pub type TaskDefinition = TaskNode;
1174
1175/// Legacy alias for TaskList (now just Vec<TaskNode>)
1176#[deprecated(since = "0.26.0", note = "Use Vec<TaskNode> directly")]
1177pub type TaskList = Vec<TaskNode>;
1178
1179/// Root tasks structure from CUE
1180#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1181pub struct Tasks {
1182    /// Map of task names to their definitions
1183    #[serde(flatten)]
1184    pub tasks: HashMap<String, TaskNode>,
1185}
1186
1187impl Tasks {
1188    /// Create a new empty tasks collection
1189    pub fn new() -> Self {
1190        Self::default()
1191    }
1192
1193    /// Get a task node by name
1194    pub fn get(&self, name: &str) -> Option<&TaskNode> {
1195        self.tasks.get(name)
1196    }
1197
1198    /// List all task names
1199    pub fn list_tasks(&self) -> Vec<&str> {
1200        self.tasks.keys().map(|s| s.as_str()).collect()
1201    }
1202
1203    /// Check if a task exists
1204    pub fn contains(&self, name: &str) -> bool {
1205        self.tasks.contains_key(name)
1206    }
1207}
1208
1209impl TaskNode {
1210    /// Check if this is a single task
1211    pub fn is_task(&self) -> bool {
1212        matches!(self, TaskNode::Task(_))
1213    }
1214
1215    /// Check if this is a task group (parallel)
1216    pub fn is_group(&self) -> bool {
1217        matches!(self, TaskNode::Group(_))
1218    }
1219
1220    /// Check if this is a sequence (sequential)
1221    pub fn is_sequence(&self) -> bool {
1222        matches!(self, TaskNode::Sequence(_))
1223    }
1224
1225    /// Get as single task if it is one
1226    pub fn as_task(&self) -> Option<&Task> {
1227        match self {
1228            TaskNode::Task(task) => Some(task.as_ref()),
1229            _ => None,
1230        }
1231    }
1232
1233    /// Get as task group if it is one
1234    pub fn as_group(&self) -> Option<&TaskGroup> {
1235        match self {
1236            TaskNode::Group(group) => Some(group),
1237            _ => None,
1238        }
1239    }
1240
1241    /// Get as sequence if it is one
1242    pub fn as_sequence(&self) -> Option<&Vec<TaskNode>> {
1243        match self {
1244            TaskNode::Sequence(seq) => Some(seq),
1245            _ => None,
1246        }
1247    }
1248
1249    /// Get dependencies for this node
1250    pub fn depends_on(&self) -> &[TaskDependency] {
1251        match self {
1252            TaskNode::Task(task) => &task.depends_on,
1253            TaskNode::Group(group) => &group.depends_on,
1254            TaskNode::Sequence(_) => &[], // Sequences don't have top-level deps
1255        }
1256    }
1257
1258    /// Get description for this node
1259    pub fn description(&self) -> Option<&str> {
1260        match self {
1261            TaskNode::Task(task) => task.description.as_deref(),
1262            TaskNode::Group(group) => group.description.as_deref(),
1263            TaskNode::Sequence(_) => None, // Sequences don't have descriptions
1264        }
1265    }
1266
1267    // Legacy compatibility methods
1268    #[deprecated(since = "0.26.0", note = "Use is_task() instead")]
1269    pub fn is_single(&self) -> bool {
1270        self.is_task()
1271    }
1272
1273    #[deprecated(since = "0.26.0", note = "Use as_task() instead")]
1274    pub fn as_single(&self) -> Option<&Task> {
1275        self.as_task()
1276    }
1277
1278    #[deprecated(since = "0.26.0", note = "Use is_sequence() instead")]
1279    pub fn is_list(&self) -> bool {
1280        self.is_sequence()
1281    }
1282
1283    #[deprecated(since = "0.26.0", note = "Use as_sequence() instead")]
1284    pub fn as_list(&self) -> Option<&Vec<TaskNode>> {
1285        self.as_sequence()
1286    }
1287}
1288
1289impl TaskGroup {
1290    /// Get the number of tasks in this group
1291    pub fn len(&self) -> usize {
1292        self.children.len()
1293    }
1294
1295    /// Check if the group is empty
1296    pub fn is_empty(&self) -> bool {
1297        self.children.is_empty()
1298    }
1299}
1300
1301impl crate::AffectedBy for TaskGroup {
1302    /// A group is affected if ANY of its subtasks are affected.
1303    fn is_affected_by(&self, changed_files: &[std::path::PathBuf], project_root: &Path) -> bool {
1304        self.children
1305            .values()
1306            .any(|node| node.is_affected_by(changed_files, project_root))
1307    }
1308
1309    fn input_patterns(&self) -> Vec<&str> {
1310        self.children
1311            .values()
1312            .flat_map(|node| node.input_patterns())
1313            .collect()
1314    }
1315}
1316
1317impl crate::AffectedBy for TaskNode {
1318    fn is_affected_by(&self, changed_files: &[std::path::PathBuf], project_root: &Path) -> bool {
1319        match self {
1320            TaskNode::Task(task) => task.is_affected_by(changed_files, project_root),
1321            TaskNode::Group(group) => group.is_affected_by(changed_files, project_root),
1322            TaskNode::Sequence(seq) => seq
1323                .iter()
1324                .any(|node| node.is_affected_by(changed_files, project_root)),
1325        }
1326    }
1327
1328    fn input_patterns(&self) -> Vec<&str> {
1329        match self {
1330            TaskNode::Task(task) => task.input_patterns(),
1331            TaskNode::Group(group) => group.input_patterns(),
1332            TaskNode::Sequence(seq) => seq.iter().flat_map(|node| node.input_patterns()).collect(),
1333        }
1334    }
1335}
1336
1337#[cfg(test)]
1338mod tests {
1339    use super::*;
1340
1341    #[test]
1342    fn test_task_default_values() {
1343        let task = Task {
1344            command: "echo".to_string(),
1345            ..Default::default()
1346        };
1347
1348        assert!(task.shell.is_none());
1349        assert_eq!(task.command, "echo");
1350        assert_eq!(task.description(), "No description provided");
1351        assert!(task.args.is_empty());
1352        assert!(task.hermetic); // default is true
1353    }
1354
1355    #[test]
1356    fn test_task_deserialization() {
1357        let json = r#"{
1358            "command": "echo",
1359            "args": ["Hello", "World"]
1360        }"#;
1361
1362        let task: Task = serde_json::from_str(json).unwrap();
1363        assert_eq!(task.command, "echo");
1364        assert_eq!(task.args, vec!["Hello", "World"]);
1365        assert!(task.shell.is_none()); // default value
1366    }
1367
1368    #[test]
1369    fn test_task_script_deserialization() {
1370        // Test that script-only tasks (no command) deserialize correctly
1371        let json = r#"{
1372            "script": "echo hello",
1373            "inputs": ["src/main.rs"]
1374        }"#;
1375
1376        let task: Task = serde_json::from_str(json).unwrap();
1377        assert!(task.command.is_empty()); // No command
1378        assert_eq!(task.script, Some("echo hello".to_string()));
1379        assert_eq!(task.inputs.len(), 1);
1380    }
1381
1382    #[test]
1383    fn test_task_cache_policy_defaults_to_never() {
1384        let task = Task {
1385            command: "echo".to_string(),
1386            ..Default::default()
1387        };
1388
1389        let policy = task.cache_policy();
1390        assert_eq!(policy.mode, TaskCacheMode::Never);
1391        assert!(policy.max_age.is_none());
1392    }
1393
1394    #[test]
1395    fn test_task_cache_policy_deserialization() {
1396        let json = r#"{
1397            "command": "echo",
1398            "cache": {
1399                "mode": "read-write",
1400                "maxAge": "1h"
1401            }
1402        }"#;
1403
1404        let task: Task = serde_json::from_str(json).unwrap();
1405        let policy = task.cache_policy();
1406        assert_eq!(policy.mode, TaskCacheMode::ReadWrite);
1407        assert_eq!(policy.max_age, Some("1h".to_string()));
1408    }
1409
1410    #[test]
1411    fn test_task_node_script_variant() {
1412        // Test that TaskNode::Task correctly deserializes script-only tasks
1413        let json = r#"{
1414            "script": "echo hello"
1415        }"#;
1416
1417        let node: TaskNode = serde_json::from_str(json).unwrap();
1418        assert!(node.is_task());
1419    }
1420
1421    #[test]
1422    fn test_task_group_with_script_task() {
1423        // Test parallel task group containing a script task (mimics cross.linux)
1424        // TaskGroup uses type: "group" discriminator with flattened children
1425        let json = r#"{
1426            "type": "group",
1427            "linux": {
1428                "script": "echo building",
1429                "inputs": ["src/main.rs"]
1430            }
1431        }"#;
1432
1433        let group: TaskGroup = serde_json::from_str(json).unwrap();
1434        assert_eq!(group.len(), 1);
1435    }
1436
1437    #[test]
1438    fn test_full_tasks_map_with_script() {
1439        // Test deserializing a full tasks map like in Project.tasks
1440        // TaskGroup uses type: "group" with flattened children
1441        let json = r#"{
1442            "pwd": { "command": "pwd" },
1443            "cross": {
1444                "type": "group",
1445                "linux": {
1446                    "script": "echo building",
1447                    "inputs": ["src/main.rs"]
1448                }
1449            }
1450        }"#;
1451
1452        let tasks: HashMap<String, TaskNode> = serde_json::from_str(json).unwrap();
1453        assert_eq!(tasks.len(), 2);
1454        assert!(tasks.contains_key("pwd"));
1455        assert!(tasks.contains_key("cross"));
1456
1457        // pwd should be Task
1458        assert!(tasks.get("pwd").unwrap().is_task());
1459
1460        // cross should be Group
1461        assert!(tasks.get("cross").unwrap().is_group());
1462    }
1463
1464    #[test]
1465    fn test_complex_nested_tasks_like_cuenv() {
1466        // Test a more complex structure mimicking cuenv's actual env.cue tasks
1467        // TaskGroup uses type: "group" with flattened children
1468        let json = r#"{
1469            "pwd": { "command": "pwd" },
1470            "check": {
1471                "command": "nix",
1472                "args": ["flake", "check"],
1473                "inputs": ["flake.nix"]
1474            },
1475            "fmt": {
1476                "type": "group",
1477                "fix": {
1478                    "command": "treefmt",
1479                    "inputs": [".config"]
1480                },
1481                "check": {
1482                    "command": "treefmt",
1483                    "args": ["--fail-on-change"],
1484                    "inputs": [".config"]
1485                }
1486            },
1487            "cross": {
1488                "type": "group",
1489                "linux": {
1490                    "script": "echo building",
1491                    "inputs": ["Cargo.toml"]
1492                }
1493            },
1494            "docs": {
1495                "type": "group",
1496                "build": {
1497                    "command": "bash",
1498                    "args": ["-c", "bun install"],
1499                    "inputs": ["docs"],
1500                    "outputs": ["docs/dist"]
1501                },
1502                "deploy": {
1503                    "command": "bash",
1504                    "args": ["-c", "wrangler deploy"],
1505                    "dependsOn": ["docs.build"],
1506                    "inputs": [{"task": "docs.build"}]
1507                }
1508            }
1509        }"#;
1510
1511        let result: Result<HashMap<String, TaskNode>, _> = serde_json::from_str(json);
1512        match result {
1513            Ok(tasks) => {
1514                assert_eq!(tasks.len(), 5);
1515                assert!(tasks.get("pwd").unwrap().is_task());
1516                assert!(tasks.get("check").unwrap().is_task());
1517                assert!(tasks.get("fmt").unwrap().is_group());
1518                assert!(tasks.get("cross").unwrap().is_group());
1519                assert!(tasks.get("docs").unwrap().is_group());
1520            }
1521            Err(e) => {
1522                panic!("Failed to deserialize complex tasks: {}", e);
1523            }
1524        }
1525    }
1526
1527    #[test]
1528    fn test_task_list_sequential() {
1529        let task1 = Task {
1530            command: "echo".to_string(),
1531            args: vec!["first".to_string()],
1532            description: Some("First task".to_string()),
1533            ..Default::default()
1534        };
1535
1536        let task2 = Task {
1537            command: "echo".to_string(),
1538            args: vec!["second".to_string()],
1539            description: Some("Second task".to_string()),
1540            ..Default::default()
1541        };
1542
1543        let sequence: Vec<TaskNode> = vec![
1544            TaskNode::Task(Box::new(task1)),
1545            TaskNode::Task(Box::new(task2)),
1546        ];
1547
1548        assert_eq!(sequence.len(), 2);
1549        assert!(!sequence.is_empty());
1550    }
1551
1552    #[test]
1553    fn test_task_group_parallel() {
1554        let task1 = Task {
1555            command: "echo".to_string(),
1556            args: vec!["task1".to_string()],
1557            description: Some("Task 1".to_string()),
1558            ..Default::default()
1559        };
1560
1561        let task2 = Task {
1562            command: "echo".to_string(),
1563            args: vec!["task2".to_string()],
1564            description: Some("Task 2".to_string()),
1565            ..Default::default()
1566        };
1567
1568        let mut parallel_tasks = HashMap::new();
1569        parallel_tasks.insert("task1".to_string(), TaskNode::Task(Box::new(task1)));
1570        parallel_tasks.insert("task2".to_string(), TaskNode::Task(Box::new(task2)));
1571
1572        let group = TaskGroup {
1573            type_: "group".to_string(),
1574            children: parallel_tasks,
1575            depends_on: vec![],
1576            max_concurrency: None,
1577            description: None,
1578        };
1579
1580        assert_eq!(group.len(), 2);
1581        assert!(!group.is_empty());
1582    }
1583
1584    #[test]
1585    fn test_tasks_collection() {
1586        let mut tasks = Tasks::new();
1587        assert!(tasks.list_tasks().is_empty());
1588
1589        let task = Task {
1590            command: "echo".to_string(),
1591            args: vec!["hello".to_string()],
1592            description: Some("Hello task".to_string()),
1593            ..Default::default()
1594        };
1595
1596        tasks
1597            .tasks
1598            .insert("greet".to_string(), TaskNode::Task(Box::new(task)));
1599
1600        assert!(tasks.contains("greet"));
1601        assert!(!tasks.contains("nonexistent"));
1602        assert_eq!(tasks.list_tasks(), vec!["greet"]);
1603
1604        let retrieved = tasks.get("greet").unwrap();
1605        assert!(retrieved.is_task());
1606    }
1607
1608    #[test]
1609    fn test_task_node_helpers() {
1610        let task = Task {
1611            command: "test".to_string(),
1612            description: Some("Test task".to_string()),
1613            ..Default::default()
1614        };
1615
1616        let task_node = TaskNode::Task(Box::new(task.clone()));
1617        assert!(task_node.is_task());
1618        assert!(!task_node.is_group());
1619        assert!(!task_node.is_sequence());
1620        assert_eq!(task_node.as_task().unwrap().command, "test");
1621        assert!(task_node.as_group().is_none());
1622        assert!(task_node.as_sequence().is_none());
1623
1624        let group = TaskNode::Group(TaskGroup {
1625            type_: "group".to_string(),
1626            children: HashMap::new(),
1627            depends_on: vec![],
1628            max_concurrency: None,
1629            description: None,
1630        });
1631        assert!(!group.is_task());
1632        assert!(group.is_group());
1633        assert!(!group.is_sequence());
1634        assert!(group.as_task().is_none());
1635        assert!(group.as_group().is_some());
1636
1637        let sequence = TaskNode::Sequence(vec![]);
1638        assert!(!sequence.is_task());
1639        assert!(!sequence.is_group());
1640        assert!(sequence.is_sequence());
1641        assert!(sequence.as_sequence().is_some());
1642    }
1643
1644    #[test]
1645    fn test_script_shell_command_and_flag() {
1646        assert_eq!(ScriptShell::Bash.command_and_flag(), ("bash", "-c"));
1647        assert_eq!(ScriptShell::Nu.command_and_flag(), ("nu", "-c"));
1648        assert_eq!(ScriptShell::Python.command_and_flag(), ("python", "-c"));
1649        assert_eq!(ScriptShell::Node.command_and_flag(), ("node", "-e"));
1650        assert_eq!(
1651            ScriptShell::Powershell.command_and_flag(),
1652            ("powershell", "-Command")
1653        );
1654    }
1655
1656    #[test]
1657    fn test_script_shell_from_command() {
1658        assert_eq!(
1659            ScriptShell::from_command("/usr/bin/nu"),
1660            Some(ScriptShell::Nu)
1661        );
1662        assert_eq!(
1663            ScriptShell::from_command("pwsh.exe"),
1664            Some(ScriptShell::Pwsh)
1665        );
1666        assert_eq!(ScriptShell::from_command("custom-shell"), None);
1667    }
1668
1669    #[test]
1670    fn test_shell_options_default() {
1671        let opts = ShellOptions::default();
1672        assert!(opts.errexit);
1673        assert!(opts.nounset);
1674        assert!(opts.pipefail);
1675        assert!(!opts.xtrace);
1676    }
1677
1678    #[test]
1679    fn test_shell_options_to_set_commands() {
1680        let opts = ShellOptions::default();
1681        assert_eq!(opts.to_set_commands(), "set -e -u -o pipefail\n");
1682
1683        let debug_opts = ShellOptions {
1684            errexit: true,
1685            nounset: false,
1686            pipefail: true,
1687            xtrace: true,
1688        };
1689        assert_eq!(debug_opts.to_set_commands(), "set -e -o pipefail -x\n");
1690
1691        let no_opts = ShellOptions {
1692            errexit: false,
1693            nounset: false,
1694            pipefail: false,
1695            xtrace: false,
1696        };
1697        assert_eq!(no_opts.to_set_commands(), "");
1698    }
1699
1700    #[test]
1701    fn test_task_command_spec_uses_script_shell() {
1702        let task = Task {
1703            script: Some("echo hello".to_string()),
1704            script_shell: Some(ScriptShell::Nu),
1705            ..Default::default()
1706        };
1707
1708        let spec = task
1709            .command_spec(|command| format!("resolved:{command}"))
1710            .unwrap();
1711
1712        assert_eq!(spec.program, "resolved:nu");
1713        assert_eq!(spec.args, vec!["-c".to_string(), "echo hello".to_string()]);
1714    }
1715
1716    #[test]
1717    fn test_task_command_spec_prepends_shell_options() {
1718        let task = Task {
1719            script: Some("echo hello".to_string()),
1720            shell_options: Some(ShellOptions {
1721                errexit: true,
1722                nounset: false,
1723                pipefail: false,
1724                xtrace: true,
1725            }),
1726            ..Default::default()
1727        };
1728
1729        let spec = task.command_spec(str::to_string).unwrap();
1730
1731        assert_eq!(spec.program, "bash");
1732        assert_eq!(
1733            spec.args,
1734            vec!["-c".to_string(), "set -e -x\necho hello".to_string()]
1735        );
1736    }
1737
1738    #[test]
1739    fn test_task_command_spec_rejects_pipefail_for_sh() {
1740        let task = Task {
1741            script: Some("echo hello".to_string()),
1742            script_shell: Some(ScriptShell::Sh),
1743            shell_options: Some(ShellOptions::default()),
1744            ..Default::default()
1745        };
1746
1747        let err = task.command_spec(str::to_string).unwrap_err();
1748
1749        assert!(
1750            err.to_string()
1751                .contains("shellOptions.pipefail with unsupported script shell 'sh'"),
1752            "unexpected error: {err}"
1753        );
1754    }
1755
1756    #[test]
1757    fn test_task_command_spec_rejects_shell_options_for_unsupported_shell() {
1758        let task = Task {
1759            script: Some("console.log('hello')".to_string()),
1760            script_shell: Some(ScriptShell::Node),
1761            shell_options: Some(ShellOptions::default()),
1762            ..Default::default()
1763        };
1764
1765        let err = task.command_spec(str::to_string).unwrap_err();
1766
1767        assert!(
1768            err.to_string().contains("unsupported script shell 'node'"),
1769            "unexpected error: {err}"
1770        );
1771    }
1772
1773    #[test]
1774    fn test_task_command_spec_does_not_resolve_empty_command_for_shell_wrapper() {
1775        let task = Task {
1776            args: vec!["echo".to_string(), "hello".to_string()],
1777            shell: Some(Shell {
1778                command: Some("bash".to_string()),
1779                flag: Some("-c".to_string()),
1780            }),
1781            ..Default::default()
1782        };
1783
1784        let mut resolved_commands = Vec::new();
1785        let spec = task
1786            .command_spec(|command| {
1787                resolved_commands.push(command.to_string());
1788                format!("resolved:{command}")
1789            })
1790            .unwrap();
1791
1792        assert_eq!(resolved_commands, vec!["bash".to_string()]);
1793        assert_eq!(spec.program, "resolved:bash");
1794        assert_eq!(spec.args, vec!["-c".to_string(), "echo hello".to_string()]);
1795    }
1796
1797    #[test]
1798    fn test_input_deserialization_variants() {
1799        let path_json = r#""src/**/*.rs""#;
1800        let path_input: Input = serde_json::from_str(path_json).unwrap();
1801        assert_eq!(path_input, Input::Path("src/**/*.rs".to_string()));
1802
1803        let project_json = r#"{
1804            "project": "../projB",
1805            "task": "build",
1806            "map": [{"from": "dist/app.txt", "to": "vendor/app.txt"}]
1807        }"#;
1808        let project_input: Input = serde_json::from_str(project_json).unwrap();
1809        match project_input {
1810            Input::Project(reference) => {
1811                assert_eq!(reference.project, "../projB");
1812                assert_eq!(reference.task, "build");
1813                assert_eq!(reference.map.len(), 1);
1814                assert_eq!(reference.map[0].from, "dist/app.txt");
1815                assert_eq!(reference.map[0].to, "vendor/app.txt");
1816            }
1817            other => panic!("Expected project reference, got {:?}", other),
1818        }
1819
1820        // Test TaskOutput variant (same-project task reference)
1821        let task_json = r#"{"task": "build.deps"}"#;
1822        let task_input: Input = serde_json::from_str(task_json).unwrap();
1823        match task_input {
1824            Input::Task(output) => {
1825                assert_eq!(output.task, "build.deps");
1826                assert!(output.map.is_none());
1827            }
1828            other => panic!("Expected task output reference, got {:?}", other),
1829        }
1830    }
1831
1832    #[test]
1833    fn test_task_input_helpers_collect() {
1834        use std::collections::HashSet;
1835        use std::path::Path;
1836
1837        let task = Task {
1838            inputs: vec![
1839                Input::Path("src".into()),
1840                Input::Project(ProjectReference {
1841                    project: "../projB".into(),
1842                    task: "build".into(),
1843                    map: vec![Mapping {
1844                        from: "dist/app.txt".into(),
1845                        to: "vendor/app.txt".into(),
1846                    }],
1847                }),
1848            ],
1849            ..Default::default()
1850        };
1851
1852        let path_inputs: Vec<String> = task.iter_path_inputs().cloned().collect();
1853        assert_eq!(path_inputs, vec!["src".to_string()]);
1854
1855        let project_refs: Vec<&ProjectReference> = task.iter_project_refs().collect();
1856        assert_eq!(project_refs.len(), 1);
1857        assert_eq!(project_refs[0].project, "../projB");
1858
1859        let prefix = Path::new("prefix");
1860        let collected = task.collect_all_inputs_with_prefix(Some(prefix));
1861        let collected: HashSet<_> = collected
1862            .into_iter()
1863            .map(std::path::PathBuf::from)
1864            .collect();
1865        let expected: HashSet<_> = ["src", "vendor/app.txt"]
1866            .into_iter()
1867            .map(|p| prefix.join(p))
1868            .collect();
1869        assert_eq!(collected, expected);
1870    }
1871
1872    #[test]
1873    fn test_resolved_args_interpolate_positional() {
1874        let args = ResolvedArgs {
1875            positional: vec!["video123".into(), "1080p".into()],
1876            named: HashMap::new(),
1877        };
1878        assert_eq!(args.interpolate("{{0}}"), "video123");
1879        assert_eq!(args.interpolate("{{1}}"), "1080p");
1880        assert_eq!(args.interpolate("--id={{0}}"), "--id=video123");
1881        assert_eq!(args.interpolate("{{0}}-{{1}}"), "video123-1080p");
1882    }
1883
1884    #[test]
1885    fn test_resolved_args_interpolate_named() {
1886        let mut named = HashMap::new();
1887        named.insert("url".into(), "https://example.com".into());
1888        named.insert("quality".into(), "720p".into());
1889        let args = ResolvedArgs {
1890            positional: vec![],
1891            named,
1892        };
1893        assert_eq!(args.interpolate("{{url}}"), "https://example.com");
1894        assert_eq!(args.interpolate("--quality={{quality}}"), "--quality=720p");
1895    }
1896
1897    #[test]
1898    fn test_resolved_args_interpolate_mixed() {
1899        let mut named = HashMap::new();
1900        named.insert("format".into(), "mp4".into());
1901        let args = ResolvedArgs {
1902            positional: vec!["VIDEO_ID".into()],
1903            named,
1904        };
1905        assert_eq!(
1906            args.interpolate("download {{0}} --format={{format}}"),
1907            "download VIDEO_ID --format=mp4"
1908        );
1909    }
1910
1911    #[test]
1912    fn test_resolved_args_no_placeholder_unchanged() {
1913        let args = ResolvedArgs::new();
1914        assert_eq!(
1915            args.interpolate("no placeholders here"),
1916            "no placeholders here"
1917        );
1918        assert_eq!(args.interpolate(""), "");
1919    }
1920
1921    #[test]
1922    fn test_resolved_args_interpolate_args_list() {
1923        let args = ResolvedArgs {
1924            positional: vec!["id123".into()],
1925            named: HashMap::new(),
1926        };
1927        let input = vec!["--id".into(), "{{0}}".into(), "--verbose".into()];
1928        let result = args.interpolate_args(&input);
1929        assert_eq!(result, vec!["--id", "id123", "--verbose"]);
1930    }
1931
1932    #[test]
1933    fn test_task_params_deserialization_with_flatten() {
1934        // Test that named params are flattened (not nested under "named")
1935        let json = r#"{
1936            "positional": [{"description": "Video ID", "required": true}],
1937            "quality": {"description": "Quality", "default": "1080p", "short": "q"},
1938            "verbose": {"description": "Verbose output", "type": "bool"}
1939        }"#;
1940        let params: TaskParams = serde_json::from_str(json).unwrap();
1941
1942        assert_eq!(params.positional.len(), 1);
1943        assert_eq!(
1944            params.positional[0].description,
1945            Some("Video ID".to_string())
1946        );
1947        assert!(params.positional[0].required);
1948
1949        assert_eq!(params.named.len(), 2);
1950        assert!(params.named.contains_key("quality"));
1951        assert!(params.named.contains_key("verbose"));
1952
1953        let quality = &params.named["quality"];
1954        assert_eq!(quality.default, Some("1080p".to_string()));
1955        assert_eq!(quality.short, Some("q".to_string()));
1956
1957        let verbose = &params.named["verbose"];
1958        assert_eq!(verbose.param_type, ParamType::Bool);
1959    }
1960
1961    #[test]
1962    fn test_task_params_empty() {
1963        let json = r#"{}"#;
1964        let params: TaskParams = serde_json::from_str(json).unwrap();
1965        assert!(params.positional.is_empty());
1966        assert!(params.named.is_empty());
1967    }
1968
1969    #[test]
1970    fn test_param_def_defaults() {
1971        let def = ParamDef::default();
1972        assert!(def.description.is_none());
1973        assert!(!def.required);
1974        assert!(def.default.is_none());
1975        assert_eq!(def.param_type, ParamType::String);
1976        assert!(def.short.is_none());
1977    }
1978
1979    // ==========================================================================
1980    // AffectedBy trait tests
1981    // ==========================================================================
1982
1983    mod affected_tests {
1984        use super::*;
1985        use crate::AffectedBy;
1986        use std::path::PathBuf;
1987
1988        fn make_task(inputs: Vec<&str>) -> Task {
1989            Task {
1990                inputs: inputs
1991                    .into_iter()
1992                    .map(|s| Input::Path(s.to_string()))
1993                    .collect(),
1994                command: "echo test".to_string(),
1995                ..Default::default()
1996            }
1997        }
1998
1999        #[test]
2000        fn test_task_no_inputs_always_affected() {
2001            let task = make_task(vec![]);
2002            let changed_files: Vec<PathBuf> = vec![];
2003            let root = Path::new(".");
2004
2005            // Task with no inputs should always be affected
2006            assert!(task.is_affected_by(&changed_files, root));
2007        }
2008
2009        #[test]
2010        fn test_task_with_inputs_matching() {
2011            let task = make_task(vec!["src/**"]);
2012            let changed_files = vec![PathBuf::from("src/lib.rs")];
2013            let root = Path::new(".");
2014
2015            assert!(task.is_affected_by(&changed_files, root));
2016        }
2017
2018        #[test]
2019        fn test_task_with_inputs_not_matching() {
2020            let task = make_task(vec!["src/**"]);
2021            let changed_files = vec![PathBuf::from("docs/readme.md")];
2022            let root = Path::new(".");
2023
2024            assert!(!task.is_affected_by(&changed_files, root));
2025        }
2026
2027        #[test]
2028        fn test_task_with_project_root_path_normalization() {
2029            let task = make_task(vec!["src/**"]);
2030            // File is repo-relative, but matches project-relative pattern
2031            let changed_files = vec![PathBuf::from("projects/website/src/app.rs")];
2032            let root = Path::new("projects/website");
2033
2034            assert!(task.is_affected_by(&changed_files, root));
2035        }
2036
2037        #[test]
2038        fn test_task_node_delegates_to_task() {
2039            let task = make_task(vec!["src/**"]);
2040            let node = TaskNode::Task(Box::new(task));
2041            let changed_files = vec![PathBuf::from("src/lib.rs")];
2042            let root = Path::new(".");
2043
2044            assert!(node.is_affected_by(&changed_files, root));
2045        }
2046
2047        #[test]
2048        fn test_task_group_any_affected() {
2049            let lint_task = make_task(vec!["src/**"]);
2050            let test_task = make_task(vec!["tests/**"]);
2051
2052            let mut parallel_tasks = HashMap::new();
2053            parallel_tasks.insert("lint".to_string(), TaskNode::Task(Box::new(lint_task)));
2054            parallel_tasks.insert("test".to_string(), TaskNode::Task(Box::new(test_task)));
2055
2056            let group = TaskGroup {
2057                type_: "group".to_string(),
2058                children: parallel_tasks,
2059                depends_on: vec![],
2060                max_concurrency: None,
2061                description: None,
2062            };
2063
2064            // Change in src/ should affect the group (because lint is affected)
2065            let changed_files = vec![PathBuf::from("src/lib.rs")];
2066            let root = Path::new(".");
2067
2068            assert!(group.is_affected_by(&changed_files, root));
2069        }
2070
2071        #[test]
2072        fn test_task_group_none_affected() {
2073            let lint_task = make_task(vec!["src/**"]);
2074            let test_task = make_task(vec!["tests/**"]);
2075
2076            let mut parallel_tasks = HashMap::new();
2077            parallel_tasks.insert("lint".to_string(), TaskNode::Task(Box::new(lint_task)));
2078            parallel_tasks.insert("test".to_string(), TaskNode::Task(Box::new(test_task)));
2079
2080            let group = TaskGroup {
2081                type_: "group".to_string(),
2082                children: parallel_tasks,
2083                depends_on: vec![],
2084                max_concurrency: None,
2085                description: None,
2086            };
2087
2088            // Change in docs/ should not affect the group
2089            let changed_files = vec![PathBuf::from("docs/readme.md")];
2090            let root = Path::new(".");
2091
2092            assert!(!group.is_affected_by(&changed_files, root));
2093        }
2094
2095        #[test]
2096        fn test_task_sequence_any_affected() {
2097            let build_task = make_task(vec!["src/**"]);
2098            let deploy_task = make_task(vec!["deploy/**"]);
2099
2100            let sequence = TaskNode::Sequence(vec![
2101                TaskNode::Task(Box::new(build_task)),
2102                TaskNode::Task(Box::new(deploy_task)),
2103            ]);
2104
2105            // Change in src/ should affect the sequence (because build is affected)
2106            let changed_files = vec![PathBuf::from("src/lib.rs")];
2107            let root = Path::new(".");
2108
2109            assert!(sequence.is_affected_by(&changed_files, root));
2110        }
2111
2112        #[test]
2113        fn test_input_patterns_returns_patterns() {
2114            let task = make_task(vec!["src/**", "Cargo.toml"]);
2115            let patterns = task.input_patterns();
2116
2117            assert_eq!(patterns.len(), 2);
2118            assert!(patterns.contains(&"src/**"));
2119            assert!(patterns.contains(&"Cargo.toml"));
2120        }
2121    }
2122}