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