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