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