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
5pub mod backend;
6pub mod discovery;
7pub mod executor;
8pub mod graph;
9pub mod index;
10pub mod io;
11
12// Re-export executor and graph modules
13pub use backend::{
14    BackendFactory, HostBackend, TaskBackend, create_backend, create_backend_with_factory,
15    should_use_dagger,
16};
17pub use executor::*;
18pub use graph::*;
19pub use index::{IndexedTask, TaskIndex, TaskPath, WorkspaceTask};
20
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23use std::path::Path;
24
25fn default_hermetic() -> bool {
26    true
27}
28
29/// Shell configuration for task execution
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31pub struct Shell {
32    /// Shell executable name (e.g., "bash", "fish", "zsh")
33    pub command: Option<String>,
34    /// Flag for command execution (e.g., "-c", "--command")
35    pub flag: Option<String>,
36}
37
38/// Mapping of external output to local workspace path
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
40pub struct Mapping {
41    /// Path relative to external project root of a declared output from the external task
42    pub from: String,
43    /// Path inside the dependent task’s hermetic workspace where this file/dir will be materialized
44    pub to: String,
45}
46
47/// A single task input definition
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
49#[serde(untagged)]
50pub enum Input {
51    /// Local path/glob input
52    Path(String),
53    /// Cross-project reference
54    Project(ProjectReference),
55    /// Same-project task output reference
56    Task(TaskOutput),
57}
58
59impl Input {
60    pub fn as_path(&self) -> Option<&String> {
61        match self {
62            Input::Path(path) => Some(path),
63            Input::Project(_) | Input::Task(_) => None,
64        }
65    }
66
67    pub fn as_project(&self) -> Option<&ProjectReference> {
68        match self {
69            Input::Project(reference) => Some(reference),
70            Input::Path(_) | Input::Task(_) => None,
71        }
72    }
73
74    pub fn as_task_output(&self) -> Option<&TaskOutput> {
75        match self {
76            Input::Task(output) => Some(output),
77            Input::Path(_) | Input::Project(_) => None,
78        }
79    }
80}
81
82/// Cross-project input declaration
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
84pub struct ProjectReference {
85    /// Path to project root relative to env.cue or absolute-from-repo-root
86    pub project: String,
87    /// Name of the external task in that project
88    pub task: String,
89    /// Required explicit mappings
90    pub map: Vec<Mapping>,
91}
92
93/// Reference to another task's outputs within the same project
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
95pub struct TaskOutput {
96    /// Name of the task whose cached outputs to consume (e.g. "docs.build")
97    pub task: String,
98    /// Optional explicit mapping of outputs. If omitted, all outputs are
99    /// materialized at their original paths in the hermetic workspace.
100    #[serde(default)]
101    pub map: Option<Vec<Mapping>>,
102}
103
104/// Source location metadata from CUE evaluation
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
106pub struct SourceLocation {
107    /// Path to source file, relative to cue.mod root
108    pub file: String,
109    /// Line number where the task was defined
110    pub line: u32,
111    /// Column number where the task was defined
112    pub column: u32,
113}
114
115impl SourceLocation {
116    /// Get the directory containing this source file
117    pub fn directory(&self) -> Option<&str> {
118        std::path::Path::new(&self.file)
119            .parent()
120            .and_then(|p| p.to_str())
121            .filter(|s| !s.is_empty())
122    }
123}
124
125/// A single executable task
126///
127/// Note: Custom deserialization is used to ensure that a Task can only be
128/// deserialized when it has a `command` or `script` field. This is necessary
129/// because TaskDefinition uses untagged enum, and we need to distinguish
130/// between a Task and a TaskGroup during deserialization.
131#[derive(Debug, Clone, Serialize, PartialEq)]
132pub struct Task {
133    /// Shell configuration for command execution (optional)
134    #[serde(default)]
135    pub shell: Option<Shell>,
136
137    /// Command to execute. Required unless 'script' is provided.
138    #[serde(default, skip_serializing_if = "String::is_empty")]
139    pub command: String,
140
141    /// Inline script to execute (alternative to command).
142    /// When script is provided, shell defaults to bash if not specified.
143    /// Supports multiline strings and shebang lines for polyglot scripts.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub script: Option<String>,
146
147    /// Arguments for the command
148    #[serde(default)]
149    pub args: Vec<String>,
150
151    /// Environment variables for this task
152    #[serde(default)]
153    pub env: HashMap<String, serde_json::Value>,
154
155    /// Dagger-specific configuration for running this task in a container
156    /// DEPRECATED: Use runtime field with Dagger variant instead
157    #[serde(default)]
158    pub dagger: Option<DaggerTaskConfig>,
159
160    /// Runtime override for this task (inherits from project if not set)
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub runtime: Option<crate::manifest::Runtime>,
163
164    /// When true (default), task runs in isolated hermetic directory.
165    /// When false, task runs directly in workspace/project root.
166    #[serde(default = "default_hermetic")]
167    pub hermetic: bool,
168
169    /// Task dependencies (names of tasks that must run first)
170    #[serde(default, rename = "dependsOn")]
171    pub depends_on: Vec<String>,
172
173    /// Input files/resources
174    #[serde(default)]
175    pub inputs: Vec<Input>,
176
177    /// Output files/resources
178    #[serde(default)]
179    pub outputs: Vec<String>,
180
181    /// Consume cached outputs from other tasks in the same project
182    #[serde(default, rename = "inputsFrom")]
183    pub inputs_from: Option<Vec<TaskOutput>>,
184
185    /// Workspaces to mount/enable for this task
186    #[serde(default)]
187    pub workspaces: Vec<String>,
188
189    /// Description of the task
190    #[serde(default)]
191    pub description: Option<String>,
192
193    /// Task parameter definitions for CLI arguments
194    #[serde(default)]
195    pub params: Option<TaskParams>,
196
197    /// Labels for task discovery via TaskMatcher
198    /// Example: labels: ["projen", "codegen"]
199    #[serde(default)]
200    pub labels: Vec<String>,
201
202    /// If set, this task is a reference to another project's task
203    /// that should be resolved at runtime using TaskDiscovery.
204    /// Format: "#project-name:task-name"
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub task_ref: Option<String>,
207
208    /// If set, specifies the project root where this task should execute.
209    /// Used for TaskRef resolution to run tasks in their original project.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub project_root: Option<std::path::PathBuf>,
212
213    /// Source file location where this task was defined (from CUE metadata).
214    /// Used to determine default execution directory and for task listing grouping.
215    #[serde(default, rename = "_source", skip_serializing_if = "Option::is_none")]
216    pub source: Option<SourceLocation>,
217
218    /// Working directory override (relative to cue.mod root).
219    /// Defaults to the directory of the source file if not set.
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub directory: Option<String>,
222}
223
224// Custom deserialization for Task to ensure either command or script is present.
225// This is necessary for untagged enum deserialization in TaskDefinition to work correctly.
226impl<'de> serde::Deserialize<'de> for Task {
227    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
228    where
229        D: serde::Deserializer<'de>,
230    {
231        // Helper struct that mirrors Task but with all optional fields
232        #[derive(serde::Deserialize)]
233        struct TaskHelper {
234            #[serde(default)]
235            shell: Option<Shell>,
236            #[serde(default)]
237            command: Option<String>,
238            #[serde(default)]
239            script: Option<String>,
240            #[serde(default)]
241            args: Vec<String>,
242            #[serde(default)]
243            env: HashMap<String, serde_json::Value>,
244            #[serde(default)]
245            dagger: Option<DaggerTaskConfig>,
246            #[serde(default)]
247            runtime: Option<crate::manifest::Runtime>,
248            #[serde(default = "default_hermetic")]
249            hermetic: bool,
250            #[serde(default, rename = "dependsOn")]
251            depends_on: Vec<String>,
252            #[serde(default)]
253            inputs: Vec<Input>,
254            #[serde(default)]
255            outputs: Vec<String>,
256            #[serde(default, rename = "inputsFrom")]
257            inputs_from: Option<Vec<TaskOutput>>,
258            #[serde(default)]
259            workspaces: Vec<String>,
260            #[serde(default)]
261            description: Option<String>,
262            #[serde(default)]
263            params: Option<TaskParams>,
264            #[serde(default)]
265            labels: Vec<String>,
266            #[serde(default)]
267            task_ref: Option<String>,
268            #[serde(default)]
269            project_root: Option<std::path::PathBuf>,
270            #[serde(default, rename = "_source")]
271            source: Option<SourceLocation>,
272            #[serde(default)]
273            directory: Option<String>,
274        }
275
276        let helper = TaskHelper::deserialize(deserializer)?;
277
278        // Validate: either command, script, or task_ref must be present
279        let has_command = helper.command.as_ref().is_some_and(|c| !c.is_empty());
280        let has_script = helper.script.is_some();
281        let has_task_ref = helper.task_ref.is_some();
282
283        if !has_command && !has_script && !has_task_ref {
284            return Err(serde::de::Error::custom(
285                "Task must have either 'command', 'script', or 'task_ref' field",
286            ));
287        }
288
289        Ok(Task {
290            shell: helper.shell,
291            command: helper.command.unwrap_or_default(),
292            script: helper.script,
293            args: helper.args,
294            env: helper.env,
295            dagger: helper.dagger,
296            runtime: helper.runtime,
297            hermetic: helper.hermetic,
298            depends_on: helper.depends_on,
299            inputs: helper.inputs,
300            outputs: helper.outputs,
301            inputs_from: helper.inputs_from,
302            workspaces: helper.workspaces,
303            description: helper.description,
304            params: helper.params,
305            labels: helper.labels,
306            task_ref: helper.task_ref,
307            project_root: helper.project_root,
308            source: helper.source,
309            directory: helper.directory,
310        })
311    }
312}
313
314/// Dagger-specific task configuration
315#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
316pub struct DaggerTaskConfig {
317    /// Base container image for running the task (e.g., "ubuntu:22.04")
318    /// Overrides the global backend.options.image if set.
319    #[serde(default)]
320    pub image: Option<String>,
321
322    /// Use container from a previous task as base instead of an image.
323    /// The referenced task must have run first (use dependsOn to ensure ordering).
324    #[serde(default)]
325    pub from: Option<String>,
326
327    /// Secrets to mount or expose as environment variables.
328    /// Secrets are resolved using cuenv's secret resolvers and securely passed to Dagger.
329    #[serde(default)]
330    pub secrets: Option<Vec<DaggerSecret>>,
331
332    /// Cache volumes to mount for persistent build caching.
333    #[serde(default)]
334    pub cache: Option<Vec<DaggerCacheMount>>,
335}
336
337/// Secret configuration for Dagger containers
338#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
339pub struct DaggerSecret {
340    /// Name identifier for the secret in Dagger
341    pub name: String,
342
343    /// Mount secret as a file at this path (e.g., "/root/.npmrc")
344    #[serde(default)]
345    pub path: Option<String>,
346
347    /// Expose secret as an environment variable with this name
348    #[serde(default, rename = "envVar")]
349    pub env_var: Option<String>,
350
351    /// Secret resolver configuration
352    pub resolver: crate::secrets::Secret,
353}
354
355/// Cache volume mount configuration for Dagger
356#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
357pub struct DaggerCacheMount {
358    /// Path inside the container to mount the cache (e.g., "/root/.npm")
359    pub path: String,
360
361    /// Unique name for the cache volume
362    pub name: String,
363}
364
365/// Task parameter definitions for CLI arguments
366#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
367pub struct TaskParams {
368    /// Positional arguments (order matters, consumed left-to-right)
369    /// Referenced in args as {{0}}, {{1}}, etc.
370    #[serde(default)]
371    pub positional: Vec<ParamDef>,
372
373    /// Named arguments (--flag style) as direct fields
374    /// Referenced in args as {{name}} where name matches the field name
375    #[serde(flatten, default)]
376    pub named: HashMap<String, ParamDef>,
377}
378
379/// Parameter type for validation
380#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
381#[serde(rename_all = "lowercase")]
382pub enum ParamType {
383    #[default]
384    String,
385    Bool,
386    Int,
387}
388
389/// Parameter definition for task arguments
390#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
391pub struct ParamDef {
392    /// Human-readable description shown in --help
393    #[serde(default)]
394    pub description: Option<String>,
395
396    /// Whether the argument must be provided (default: false)
397    #[serde(default)]
398    pub required: bool,
399
400    /// Default value if not provided
401    #[serde(default)]
402    pub default: Option<String>,
403
404    /// Type hint for documentation (default: "string", not enforced at runtime)
405    #[serde(default, rename = "type")]
406    pub param_type: ParamType,
407
408    /// Short flag (single character, e.g., "t" for -t)
409    #[serde(default)]
410    pub short: Option<String>,
411}
412
413/// Resolved task arguments ready for interpolation
414#[derive(Debug, Clone, Default)]
415pub struct ResolvedArgs {
416    /// Positional argument values by index
417    pub positional: Vec<String>,
418    /// Named argument values by name
419    pub named: HashMap<String, String>,
420}
421
422impl ResolvedArgs {
423    /// Create empty resolved args
424    pub fn new() -> Self {
425        Self::default()
426    }
427
428    /// Interpolate placeholders in a string
429    /// Supports {{0}}, {{1}} for positional and {{name}} for named args
430    pub fn interpolate(&self, template: &str) -> String {
431        let mut result = template.to_string();
432
433        // Replace positional placeholders {{0}}, {{1}}, etc.
434        for (i, value) in self.positional.iter().enumerate() {
435            let placeholder = format!("{{{{{}}}}}", i);
436            result = result.replace(&placeholder, value);
437        }
438
439        // Replace named placeholders {{name}}
440        for (name, value) in &self.named {
441            let placeholder = format!("{{{{{}}}}}", name);
442            result = result.replace(&placeholder, value);
443        }
444
445        result
446    }
447
448    /// Interpolate all args in a list
449    pub fn interpolate_args(&self, args: &[String]) -> Vec<String> {
450        args.iter().map(|arg| self.interpolate(arg)).collect()
451    }
452}
453
454impl Default for Task {
455    fn default() -> Self {
456        Self {
457            shell: None,
458            command: String::new(),
459            script: None,
460            args: vec![],
461            env: HashMap::new(),
462            dagger: None,
463            runtime: None,
464            hermetic: true, // Default to hermetic execution
465            depends_on: vec![],
466            inputs: vec![],
467            outputs: vec![],
468            inputs_from: None,
469            workspaces: vec![],
470            description: None,
471            params: None,
472            labels: vec![],
473            task_ref: None,
474            project_root: None,
475            source: None,
476            directory: None,
477        }
478    }
479}
480
481impl Task {
482    /// Creates a new TaskRef placeholder task.
483    /// This task will be resolved at runtime using TaskDiscovery.
484    pub fn from_task_ref(ref_str: &str) -> Self {
485        Self {
486            task_ref: Some(ref_str.to_string()),
487            description: Some(format!("Reference to {}", ref_str)),
488            ..Default::default()
489        }
490    }
491
492    /// Returns true if this task is a TaskRef placeholder that needs resolution.
493    pub fn is_task_ref(&self) -> bool {
494        self.task_ref.is_some()
495    }
496
497    /// Returns the description, or a default if not set.
498    pub fn description(&self) -> &str {
499        self.description
500            .as_deref()
501            .unwrap_or("No description provided")
502    }
503
504    /// Returns an iterator over local path/glob inputs.
505    pub fn iter_path_inputs(&self) -> impl Iterator<Item = &String> {
506        self.inputs.iter().filter_map(Input::as_path)
507    }
508
509    /// Returns an iterator over project references.
510    pub fn iter_project_refs(&self) -> impl Iterator<Item = &ProjectReference> {
511        self.inputs.iter().filter_map(Input::as_project)
512    }
513
514    /// Returns an iterator over same-project task output references.
515    pub fn iter_task_outputs(&self) -> impl Iterator<Item = &TaskOutput> {
516        self.inputs.iter().filter_map(Input::as_task_output)
517    }
518
519    /// Collects path/glob inputs applying an optional prefix (for workspace roots).
520    pub fn collect_path_inputs_with_prefix(&self, prefix: Option<&Path>) -> Vec<String> {
521        self.iter_path_inputs()
522            .map(|path| apply_prefix(prefix, path))
523            .collect()
524    }
525
526    /// Collects mapped destinations from project references, applying an optional prefix.
527    pub fn collect_project_destinations_with_prefix(&self, prefix: Option<&Path>) -> Vec<String> {
528        self.iter_project_refs()
529            .flat_map(|reference| reference.map.iter().map(|m| apply_prefix(prefix, &m.to)))
530            .collect()
531    }
532
533    /// Collects all input patterns (local + project destinations) with an optional prefix.
534    pub fn collect_all_inputs_with_prefix(&self, prefix: Option<&Path>) -> Vec<String> {
535        let mut inputs = self.collect_path_inputs_with_prefix(prefix);
536        inputs.extend(self.collect_project_destinations_with_prefix(prefix));
537        inputs
538    }
539}
540
541fn apply_prefix(prefix: Option<&Path>, value: &str) -> String {
542    if let Some(prefix) = prefix {
543        prefix.join(value).to_string_lossy().to_string()
544    } else {
545        value.to_string()
546    }
547}
548
549/// A parallel task group with optional shared dependencies
550#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
551pub struct ParallelGroup {
552    /// Named tasks that can run concurrently
553    #[serde(flatten)]
554    pub tasks: HashMap<String, TaskDefinition>,
555
556    /// Optional group-level dependencies applied to all subtasks
557    #[serde(default, rename = "dependsOn")]
558    pub depends_on: Vec<String>,
559}
560
561/// Represents a group of tasks with execution mode
562#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
563#[serde(untagged)]
564pub enum TaskGroup {
565    /// Sequential execution: array of tasks executed in order
566    Sequential(Vec<TaskDefinition>),
567
568    /// Parallel execution: named tasks that can run concurrently
569    Parallel(ParallelGroup),
570}
571
572/// A task definition can be either a single task or a group of tasks
573#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
574#[serde(untagged)]
575pub enum TaskDefinition {
576    /// A single task
577    Single(Box<Task>),
578
579    /// A group of tasks
580    Group(TaskGroup),
581}
582
583/// Root tasks structure from CUE
584#[derive(Debug, Clone, Serialize, Deserialize, Default)]
585pub struct Tasks {
586    /// Map of task names to their definitions
587    #[serde(flatten)]
588    pub tasks: HashMap<String, TaskDefinition>,
589}
590
591impl Tasks {
592    /// Create a new empty tasks collection
593    pub fn new() -> Self {
594        Self::default()
595    }
596
597    /// Get a task definition by name
598    pub fn get(&self, name: &str) -> Option<&TaskDefinition> {
599        self.tasks.get(name)
600    }
601
602    /// List all task names
603    pub fn list_tasks(&self) -> Vec<&str> {
604        self.tasks.keys().map(|s| s.as_str()).collect()
605    }
606
607    /// Check if a task exists
608    pub fn contains(&self, name: &str) -> bool {
609        self.tasks.contains_key(name)
610    }
611}
612
613impl TaskDefinition {
614    /// Check if this is a single task
615    pub fn is_single(&self) -> bool {
616        matches!(self, TaskDefinition::Single(_))
617    }
618
619    /// Check if this is a task group
620    pub fn is_group(&self) -> bool {
621        matches!(self, TaskDefinition::Group(_))
622    }
623
624    /// Get as single task if it is one
625    pub fn as_single(&self) -> Option<&Task> {
626        match self {
627            TaskDefinition::Single(task) => Some(task.as_ref()),
628            _ => None,
629        }
630    }
631
632    /// Get as task group if it is one
633    pub fn as_group(&self) -> Option<&TaskGroup> {
634        match self {
635            TaskDefinition::Group(group) => Some(group),
636            _ => None,
637        }
638    }
639
640    /// Check if this task definition uses a specific workspace
641    ///
642    /// Returns true if any task within this definition (including nested tasks
643    /// in groups) has the specified workspace in its `workspaces` field.
644    pub fn uses_workspace(&self, workspace_name: &str) -> bool {
645        match self {
646            TaskDefinition::Single(task) => task.workspaces.contains(&workspace_name.to_string()),
647            TaskDefinition::Group(group) => match group {
648                TaskGroup::Sequential(tasks) => {
649                    tasks.iter().any(|t| t.uses_workspace(workspace_name))
650                }
651                TaskGroup::Parallel(parallel) => parallel
652                    .tasks
653                    .values()
654                    .any(|t| t.uses_workspace(workspace_name)),
655            },
656        }
657    }
658}
659
660impl TaskGroup {
661    /// Check if this group is sequential
662    pub fn is_sequential(&self) -> bool {
663        matches!(self, TaskGroup::Sequential(_))
664    }
665
666    /// Check if this group is parallel
667    pub fn is_parallel(&self) -> bool {
668        matches!(self, TaskGroup::Parallel(_))
669    }
670
671    /// Get the number of tasks in this group
672    pub fn len(&self) -> usize {
673        match self {
674            TaskGroup::Sequential(tasks) => tasks.len(),
675            TaskGroup::Parallel(group) => group.tasks.len(),
676        }
677    }
678
679    /// Check if the group is empty
680    pub fn is_empty(&self) -> bool {
681        self.len() == 0
682    }
683}
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688
689    #[test]
690    fn test_task_default_values() {
691        let task = Task {
692            command: "echo".to_string(),
693            ..Default::default()
694        };
695
696        assert!(task.shell.is_none());
697        assert_eq!(task.command, "echo");
698        assert_eq!(task.description(), "No description provided");
699        assert!(task.args.is_empty());
700        assert!(task.hermetic); // default is true
701    }
702
703    #[test]
704    fn test_task_deserialization() {
705        let json = r#"{
706            "command": "echo",
707            "args": ["Hello", "World"]
708        }"#;
709
710        let task: Task = serde_json::from_str(json).unwrap();
711        assert_eq!(task.command, "echo");
712        assert_eq!(task.args, vec!["Hello", "World"]);
713        assert!(task.shell.is_none()); // default value
714    }
715
716    #[test]
717    fn test_task_script_deserialization() {
718        // Test that script-only tasks (no command) deserialize correctly
719        let json = r#"{
720            "script": "echo hello",
721            "inputs": ["src/main.rs"]
722        }"#;
723
724        let task: Task = serde_json::from_str(json).unwrap();
725        assert!(task.command.is_empty()); // No command
726        assert_eq!(task.script, Some("echo hello".to_string()));
727        assert_eq!(task.inputs.len(), 1);
728    }
729
730    #[test]
731    fn test_task_definition_script_variant() {
732        // Test that TaskDefinition::Single correctly deserializes script-only tasks
733        let json = r#"{
734            "script": "echo hello"
735        }"#;
736
737        let def: TaskDefinition = serde_json::from_str(json).unwrap();
738        assert!(def.is_single());
739    }
740
741    #[test]
742    fn test_task_group_with_script_task() {
743        // Test parallel task group containing a script task (mimics cross.linux)
744        let json = r#"{
745            "linux": {
746                "script": "echo building",
747                "inputs": ["src/main.rs"]
748            }
749        }"#;
750
751        let group: TaskGroup = serde_json::from_str(json).unwrap();
752        assert!(group.is_parallel());
753    }
754
755    #[test]
756    fn test_full_tasks_map_with_script() {
757        // Test deserializing a full tasks map like in Project.tasks
758        // This mimics the structure: tasks: { cross: { linux: { script: ... } } }
759        let json = r#"{
760            "pwd": { "command": "pwd" },
761            "cross": {
762                "linux": {
763                    "script": "echo building",
764                    "inputs": ["src/main.rs"]
765                }
766            }
767        }"#;
768
769        let tasks: HashMap<String, TaskDefinition> = serde_json::from_str(json).unwrap();
770        assert_eq!(tasks.len(), 2);
771        assert!(tasks.contains_key("pwd"));
772        assert!(tasks.contains_key("cross"));
773
774        // pwd should be Single
775        assert!(tasks.get("pwd").unwrap().is_single());
776
777        // cross should be Group (Parallel)
778        assert!(tasks.get("cross").unwrap().is_group());
779    }
780
781    #[test]
782    fn test_complex_nested_tasks_like_cuenv() {
783        // Test a more complex structure mimicking cuenv's actual env.cue tasks
784        let json = r#"{
785            "pwd": { "command": "pwd" },
786            "check": {
787                "command": "nix",
788                "args": ["flake", "check"],
789                "inputs": ["flake.nix"]
790            },
791            "fmt": {
792                "fix": {
793                    "command": "treefmt",
794                    "inputs": [".config"]
795                },
796                "check": {
797                    "command": "treefmt",
798                    "args": ["--fail-on-change"],
799                    "inputs": [".config"]
800                }
801            },
802            "cross": {
803                "linux": {
804                    "script": "echo building",
805                    "inputs": ["Cargo.toml"]
806                }
807            },
808            "docs": {
809                "build": {
810                    "command": "bash",
811                    "args": ["-c", "bun install"],
812                    "inputs": ["docs"],
813                    "outputs": ["docs/dist"]
814                },
815                "deploy": {
816                    "command": "bash",
817                    "args": ["-c", "wrangler deploy"],
818                    "dependsOn": ["docs.build"],
819                    "inputsFrom": [{"task": "docs.build"}]
820                }
821            }
822        }"#;
823
824        let result: Result<HashMap<String, TaskDefinition>, _> = serde_json::from_str(json);
825        match result {
826            Ok(tasks) => {
827                assert_eq!(tasks.len(), 5);
828                assert!(tasks.get("pwd").unwrap().is_single());
829                assert!(tasks.get("check").unwrap().is_single());
830                assert!(tasks.get("fmt").unwrap().is_group());
831                assert!(tasks.get("cross").unwrap().is_group());
832                assert!(tasks.get("docs").unwrap().is_group());
833            }
834            Err(e) => {
835                panic!("Failed to deserialize complex tasks: {}", e);
836            }
837        }
838    }
839
840    #[test]
841    fn test_task_group_sequential() {
842        let task1 = Task {
843            command: "echo".to_string(),
844            args: vec!["first".to_string()],
845            description: Some("First task".to_string()),
846            ..Default::default()
847        };
848
849        let task2 = Task {
850            command: "echo".to_string(),
851            args: vec!["second".to_string()],
852            description: Some("Second task".to_string()),
853            ..Default::default()
854        };
855
856        let group = TaskGroup::Sequential(vec![
857            TaskDefinition::Single(Box::new(task1)),
858            TaskDefinition::Single(Box::new(task2)),
859        ]);
860
861        assert!(group.is_sequential());
862        assert!(!group.is_parallel());
863        assert_eq!(group.len(), 2);
864    }
865
866    #[test]
867    fn test_task_group_parallel() {
868        let task1 = Task {
869            command: "echo".to_string(),
870            args: vec!["task1".to_string()],
871            description: Some("Task 1".to_string()),
872            ..Default::default()
873        };
874
875        let task2 = Task {
876            command: "echo".to_string(),
877            args: vec!["task2".to_string()],
878            description: Some("Task 2".to_string()),
879            ..Default::default()
880        };
881
882        let mut parallel_tasks = HashMap::new();
883        parallel_tasks.insert("task1".to_string(), TaskDefinition::Single(Box::new(task1)));
884        parallel_tasks.insert("task2".to_string(), TaskDefinition::Single(Box::new(task2)));
885
886        let group = TaskGroup::Parallel(ParallelGroup {
887            tasks: parallel_tasks,
888            depends_on: vec![],
889        });
890
891        assert!(!group.is_sequential());
892        assert!(group.is_parallel());
893        assert_eq!(group.len(), 2);
894    }
895
896    #[test]
897    fn test_tasks_collection() {
898        let mut tasks = Tasks::new();
899        assert!(tasks.list_tasks().is_empty());
900
901        let task = Task {
902            command: "echo".to_string(),
903            args: vec!["hello".to_string()],
904            description: Some("Hello task".to_string()),
905            ..Default::default()
906        };
907
908        tasks
909            .tasks
910            .insert("greet".to_string(), TaskDefinition::Single(Box::new(task)));
911
912        assert!(tasks.contains("greet"));
913        assert!(!tasks.contains("nonexistent"));
914        assert_eq!(tasks.list_tasks(), vec!["greet"]);
915
916        let retrieved = tasks.get("greet").unwrap();
917        assert!(retrieved.is_single());
918    }
919
920    #[test]
921    fn test_task_definition_helpers() {
922        let task = Task {
923            command: "test".to_string(),
924            description: Some("Test task".to_string()),
925            ..Default::default()
926        };
927
928        let single = TaskDefinition::Single(Box::new(task.clone()));
929        assert!(single.is_single());
930        assert!(!single.is_group());
931        assert_eq!(single.as_single().unwrap().command, "test");
932        assert!(single.as_group().is_none());
933
934        let group = TaskDefinition::Group(TaskGroup::Sequential(vec![]));
935        assert!(!group.is_single());
936        assert!(group.is_group());
937        assert!(group.as_single().is_none());
938        assert!(group.as_group().is_some());
939    }
940
941    #[test]
942    fn test_input_deserialization_variants() {
943        let path_json = r#""src/**/*.rs""#;
944        let path_input: Input = serde_json::from_str(path_json).unwrap();
945        assert_eq!(path_input, Input::Path("src/**/*.rs".to_string()));
946
947        let project_json = r#"{
948            "project": "../projB",
949            "task": "build",
950            "map": [{"from": "dist/app.txt", "to": "vendor/app.txt"}]
951        }"#;
952        let project_input: Input = serde_json::from_str(project_json).unwrap();
953        match project_input {
954            Input::Project(reference) => {
955                assert_eq!(reference.project, "../projB");
956                assert_eq!(reference.task, "build");
957                assert_eq!(reference.map.len(), 1);
958                assert_eq!(reference.map[0].from, "dist/app.txt");
959                assert_eq!(reference.map[0].to, "vendor/app.txt");
960            }
961            other => panic!("Expected project reference, got {:?}", other),
962        }
963
964        // Test TaskOutput variant (same-project task reference)
965        let task_json = r#"{"task": "build.deps"}"#;
966        let task_input: Input = serde_json::from_str(task_json).unwrap();
967        match task_input {
968            Input::Task(output) => {
969                assert_eq!(output.task, "build.deps");
970                assert!(output.map.is_none());
971            }
972            other => panic!("Expected task output reference, got {:?}", other),
973        }
974    }
975
976    #[test]
977    fn test_task_input_helpers_collect() {
978        use std::collections::HashSet;
979        use std::path::Path;
980
981        let task = Task {
982            inputs: vec![
983                Input::Path("src".into()),
984                Input::Project(ProjectReference {
985                    project: "../projB".into(),
986                    task: "build".into(),
987                    map: vec![Mapping {
988                        from: "dist/app.txt".into(),
989                        to: "vendor/app.txt".into(),
990                    }],
991                }),
992            ],
993            ..Default::default()
994        };
995
996        let path_inputs: Vec<String> = task.iter_path_inputs().cloned().collect();
997        assert_eq!(path_inputs, vec!["src".to_string()]);
998
999        let project_refs: Vec<&ProjectReference> = task.iter_project_refs().collect();
1000        assert_eq!(project_refs.len(), 1);
1001        assert_eq!(project_refs[0].project, "../projB");
1002
1003        let prefix = Path::new("prefix");
1004        let collected = task.collect_all_inputs_with_prefix(Some(prefix));
1005        let collected: HashSet<_> = collected
1006            .into_iter()
1007            .map(std::path::PathBuf::from)
1008            .collect();
1009        let expected: HashSet<_> = ["src", "vendor/app.txt"]
1010            .into_iter()
1011            .map(|p| prefix.join(p))
1012            .collect();
1013        assert_eq!(collected, expected);
1014    }
1015
1016    #[test]
1017    fn test_resolved_args_interpolate_positional() {
1018        let args = ResolvedArgs {
1019            positional: vec!["video123".into(), "1080p".into()],
1020            named: HashMap::new(),
1021        };
1022        assert_eq!(args.interpolate("{{0}}"), "video123");
1023        assert_eq!(args.interpolate("{{1}}"), "1080p");
1024        assert_eq!(args.interpolate("--id={{0}}"), "--id=video123");
1025        assert_eq!(args.interpolate("{{0}}-{{1}}"), "video123-1080p");
1026    }
1027
1028    #[test]
1029    fn test_resolved_args_interpolate_named() {
1030        let mut named = HashMap::new();
1031        named.insert("url".into(), "https://example.com".into());
1032        named.insert("quality".into(), "720p".into());
1033        let args = ResolvedArgs {
1034            positional: vec![],
1035            named,
1036        };
1037        assert_eq!(args.interpolate("{{url}}"), "https://example.com");
1038        assert_eq!(args.interpolate("--quality={{quality}}"), "--quality=720p");
1039    }
1040
1041    #[test]
1042    fn test_resolved_args_interpolate_mixed() {
1043        let mut named = HashMap::new();
1044        named.insert("format".into(), "mp4".into());
1045        let args = ResolvedArgs {
1046            positional: vec!["VIDEO_ID".into()],
1047            named,
1048        };
1049        assert_eq!(
1050            args.interpolate("download {{0}} --format={{format}}"),
1051            "download VIDEO_ID --format=mp4"
1052        );
1053    }
1054
1055    #[test]
1056    fn test_resolved_args_no_placeholder_unchanged() {
1057        let args = ResolvedArgs::new();
1058        assert_eq!(
1059            args.interpolate("no placeholders here"),
1060            "no placeholders here"
1061        );
1062        assert_eq!(args.interpolate(""), "");
1063    }
1064
1065    #[test]
1066    fn test_resolved_args_interpolate_args_list() {
1067        let args = ResolvedArgs {
1068            positional: vec!["id123".into()],
1069            named: HashMap::new(),
1070        };
1071        let input = vec!["--id".into(), "{{0}}".into(), "--verbose".into()];
1072        let result = args.interpolate_args(&input);
1073        assert_eq!(result, vec!["--id", "id123", "--verbose"]);
1074    }
1075
1076    #[test]
1077    fn test_task_params_deserialization_with_flatten() {
1078        // Test that named params are flattened (not nested under "named")
1079        let json = r#"{
1080            "positional": [{"description": "Video ID", "required": true}],
1081            "quality": {"description": "Quality", "default": "1080p", "short": "q"},
1082            "verbose": {"description": "Verbose output", "type": "bool"}
1083        }"#;
1084        let params: TaskParams = serde_json::from_str(json).unwrap();
1085
1086        assert_eq!(params.positional.len(), 1);
1087        assert_eq!(
1088            params.positional[0].description,
1089            Some("Video ID".to_string())
1090        );
1091        assert!(params.positional[0].required);
1092
1093        assert_eq!(params.named.len(), 2);
1094        assert!(params.named.contains_key("quality"));
1095        assert!(params.named.contains_key("verbose"));
1096
1097        let quality = &params.named["quality"];
1098        assert_eq!(quality.default, Some("1080p".to_string()));
1099        assert_eq!(quality.short, Some("q".to_string()));
1100
1101        let verbose = &params.named["verbose"];
1102        assert_eq!(verbose.param_type, ParamType::Bool);
1103    }
1104
1105    #[test]
1106    fn test_task_params_empty() {
1107        let json = r#"{}"#;
1108        let params: TaskParams = serde_json::from_str(json).unwrap();
1109        assert!(params.positional.is_empty());
1110        assert!(params.named.is_empty());
1111    }
1112
1113    #[test]
1114    fn test_param_def_defaults() {
1115        let def = ParamDef::default();
1116        assert!(def.description.is_none());
1117        assert!(!def.required);
1118        assert!(def.default.is_none());
1119        assert_eq!(def.param_type, ParamType::String);
1120        assert!(def.short.is_none());
1121    }
1122}