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