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 executor;
7pub mod graph;
8pub mod index;
9pub mod io;
10
11// Re-export executor and graph modules
12pub use backend::{
13    BackendFactory, HostBackend, TaskBackend, create_backend, create_backend_with_factory,
14    should_use_dagger,
15};
16pub use executor::*;
17pub use graph::*;
18pub use index::{IndexedTask, TaskIndex, TaskPath};
19
20use schemars::JsonSchema;
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, JsonSchema, 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, JsonSchema, 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, JsonSchema, 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, JsonSchema, 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, JsonSchema, 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/// A single executable task
105#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
106pub struct Task {
107    /// Shell configuration for command execution (optional)
108    #[serde(default)]
109    pub shell: Option<Shell>,
110
111    /// Command to execute
112    pub command: String,
113
114    /// Arguments for the command
115    #[serde(default)]
116    pub args: Vec<String>,
117
118    /// Environment variables for this task
119    #[serde(default)]
120    pub env: HashMap<String, serde_json::Value>,
121
122    /// Dagger-specific configuration for running this task in a container
123    #[serde(default)]
124    pub dagger: Option<DaggerTaskConfig>,
125
126    /// When true (default), task runs in isolated hermetic directory.
127    /// When false, task runs directly in workspace/project root.
128    #[serde(default = "default_hermetic")]
129    pub hermetic: bool,
130
131    /// Task dependencies (names of tasks that must run first)
132    #[serde(default, rename = "dependsOn")]
133    pub depends_on: Vec<String>,
134
135    /// Input files/resources
136    #[serde(default)]
137    pub inputs: Vec<Input>,
138
139    /// Output files/resources
140    #[serde(default)]
141    pub outputs: Vec<String>,
142
143    /// Consume cached outputs from other tasks in the same project
144    #[serde(default, rename = "inputsFrom")]
145    pub inputs_from: Option<Vec<TaskOutput>>,
146
147    /// Workspaces to mount/enable for this task
148    #[serde(default)]
149    pub workspaces: Vec<String>,
150
151    /// Description of the task
152    #[serde(default)]
153    pub description: Option<String>,
154
155    /// Task parameter definitions for CLI arguments
156    #[serde(default)]
157    pub params: Option<TaskParams>,
158}
159
160/// Dagger-specific task configuration
161#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
162pub struct DaggerTaskConfig {
163    /// Base container image for running the task (e.g., "ubuntu:22.04")
164    /// Overrides the global backend.options.image if set.
165    #[serde(default)]
166    pub image: Option<String>,
167
168    /// Use container from a previous task as base instead of an image.
169    /// The referenced task must have run first (use dependsOn to ensure ordering).
170    #[serde(default)]
171    pub from: Option<String>,
172
173    /// Secrets to mount or expose as environment variables.
174    /// Secrets are resolved using cuenv's secret resolvers and securely passed to Dagger.
175    #[serde(default)]
176    pub secrets: Option<Vec<DaggerSecret>>,
177
178    /// Cache volumes to mount for persistent build caching.
179    #[serde(default)]
180    pub cache: Option<Vec<DaggerCacheMount>>,
181}
182
183/// Secret configuration for Dagger containers
184#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
185pub struct DaggerSecret {
186    /// Name identifier for the secret in Dagger
187    pub name: String,
188
189    /// Mount secret as a file at this path (e.g., "/root/.npmrc")
190    #[serde(default)]
191    pub path: Option<String>,
192
193    /// Expose secret as an environment variable with this name
194    #[serde(default, rename = "envVar")]
195    pub env_var: Option<String>,
196
197    /// Secret resolver configuration
198    pub resolver: crate::secrets::Secret,
199}
200
201/// Cache volume mount configuration for Dagger
202#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
203pub struct DaggerCacheMount {
204    /// Path inside the container to mount the cache (e.g., "/root/.npm")
205    pub path: String,
206
207    /// Unique name for the cache volume
208    pub name: String,
209}
210
211/// Task parameter definitions for CLI arguments
212#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
213pub struct TaskParams {
214    /// Positional arguments (order matters, consumed left-to-right)
215    /// Referenced in args as {{0}}, {{1}}, etc.
216    #[serde(default)]
217    pub positional: Vec<ParamDef>,
218
219    /// Named arguments (--flag style) as direct fields
220    /// Referenced in args as {{name}} where name matches the field name
221    #[serde(flatten, default)]
222    pub named: HashMap<String, ParamDef>,
223}
224
225/// Parameter type for validation
226#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
227#[serde(rename_all = "lowercase")]
228pub enum ParamType {
229    #[default]
230    String,
231    Bool,
232    Int,
233}
234
235/// Parameter definition for task arguments
236#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
237pub struct ParamDef {
238    /// Human-readable description shown in --help
239    #[serde(default)]
240    pub description: Option<String>,
241
242    /// Whether the argument must be provided (default: false)
243    #[serde(default)]
244    pub required: bool,
245
246    /// Default value if not provided
247    #[serde(default)]
248    pub default: Option<String>,
249
250    /// Type hint for documentation (default: "string", not enforced at runtime)
251    #[serde(default, rename = "type")]
252    pub param_type: ParamType,
253
254    /// Short flag (single character, e.g., "t" for -t)
255    #[serde(default)]
256    pub short: Option<String>,
257}
258
259/// Resolved task arguments ready for interpolation
260#[derive(Debug, Clone, Default)]
261pub struct ResolvedArgs {
262    /// Positional argument values by index
263    pub positional: Vec<String>,
264    /// Named argument values by name
265    pub named: HashMap<String, String>,
266}
267
268impl ResolvedArgs {
269    /// Create empty resolved args
270    pub fn new() -> Self {
271        Self::default()
272    }
273
274    /// Interpolate placeholders in a string
275    /// Supports {{0}}, {{1}} for positional and {{name}} for named args
276    pub fn interpolate(&self, template: &str) -> String {
277        let mut result = template.to_string();
278
279        // Replace positional placeholders {{0}}, {{1}}, etc.
280        for (i, value) in self.positional.iter().enumerate() {
281            let placeholder = format!("{{{{{}}}}}", i);
282            result = result.replace(&placeholder, value);
283        }
284
285        // Replace named placeholders {{name}}
286        for (name, value) in &self.named {
287            let placeholder = format!("{{{{{}}}}}", name);
288            result = result.replace(&placeholder, value);
289        }
290
291        result
292    }
293
294    /// Interpolate all args in a list
295    pub fn interpolate_args(&self, args: &[String]) -> Vec<String> {
296        args.iter().map(|arg| self.interpolate(arg)).collect()
297    }
298}
299
300impl Default for Task {
301    fn default() -> Self {
302        Self {
303            shell: None,
304            command: String::new(),
305            args: vec![],
306            env: HashMap::new(),
307            dagger: None,
308            hermetic: true, // Default to hermetic execution
309            depends_on: vec![],
310            inputs: vec![],
311            outputs: vec![],
312            inputs_from: None,
313            workspaces: vec![],
314            description: None,
315            params: None,
316        }
317    }
318}
319
320impl Task {
321    /// Returns the description, or a default if not set.
322    pub fn description(&self) -> &str {
323        self.description
324            .as_deref()
325            .unwrap_or("No description provided")
326    }
327
328    /// Returns an iterator over local path/glob inputs.
329    pub fn iter_path_inputs(&self) -> impl Iterator<Item = &String> {
330        self.inputs.iter().filter_map(Input::as_path)
331    }
332
333    /// Returns an iterator over project references.
334    pub fn iter_project_refs(&self) -> impl Iterator<Item = &ProjectReference> {
335        self.inputs.iter().filter_map(Input::as_project)
336    }
337
338    /// Returns an iterator over same-project task output references.
339    pub fn iter_task_outputs(&self) -> impl Iterator<Item = &TaskOutput> {
340        self.inputs.iter().filter_map(Input::as_task_output)
341    }
342
343    /// Collects path/glob inputs applying an optional prefix (for workspace roots).
344    pub fn collect_path_inputs_with_prefix(&self, prefix: Option<&Path>) -> Vec<String> {
345        self.iter_path_inputs()
346            .map(|path| apply_prefix(prefix, path))
347            .collect()
348    }
349
350    /// Collects mapped destinations from project references, applying an optional prefix.
351    pub fn collect_project_destinations_with_prefix(&self, prefix: Option<&Path>) -> Vec<String> {
352        self.iter_project_refs()
353            .flat_map(|reference| reference.map.iter().map(|m| apply_prefix(prefix, &m.to)))
354            .collect()
355    }
356
357    /// Collects all input patterns (local + project destinations) with an optional prefix.
358    pub fn collect_all_inputs_with_prefix(&self, prefix: Option<&Path>) -> Vec<String> {
359        let mut inputs = self.collect_path_inputs_with_prefix(prefix);
360        inputs.extend(self.collect_project_destinations_with_prefix(prefix));
361        inputs
362    }
363}
364
365fn apply_prefix(prefix: Option<&Path>, value: &str) -> String {
366    if let Some(prefix) = prefix {
367        prefix.join(value).to_string_lossy().to_string()
368    } else {
369        value.to_string()
370    }
371}
372
373/// Represents a group of tasks with execution mode
374#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
375#[serde(untagged)]
376pub enum TaskGroup {
377    /// Sequential execution: array of tasks executed in order
378    Sequential(Vec<TaskDefinition>),
379
380    /// Parallel execution: named tasks that can run concurrently
381    Parallel(HashMap<String, TaskDefinition>),
382}
383
384/// A task definition can be either a single task or a group of tasks
385#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
386#[serde(untagged)]
387pub enum TaskDefinition {
388    /// A single task
389    Single(Box<Task>),
390
391    /// A group of tasks
392    Group(TaskGroup),
393}
394
395/// Root tasks structure from CUE
396#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
397pub struct Tasks {
398    /// Map of task names to their definitions
399    #[serde(flatten)]
400    pub tasks: HashMap<String, TaskDefinition>,
401}
402
403impl Tasks {
404    /// Create a new empty tasks collection
405    pub fn new() -> Self {
406        Self::default()
407    }
408
409    /// Get a task definition by name
410    pub fn get(&self, name: &str) -> Option<&TaskDefinition> {
411        self.tasks.get(name)
412    }
413
414    /// List all task names
415    pub fn list_tasks(&self) -> Vec<&str> {
416        self.tasks.keys().map(|s| s.as_str()).collect()
417    }
418
419    /// Check if a task exists
420    pub fn contains(&self, name: &str) -> bool {
421        self.tasks.contains_key(name)
422    }
423}
424
425impl TaskDefinition {
426    /// Check if this is a single task
427    pub fn is_single(&self) -> bool {
428        matches!(self, TaskDefinition::Single(_))
429    }
430
431    /// Check if this is a task group
432    pub fn is_group(&self) -> bool {
433        matches!(self, TaskDefinition::Group(_))
434    }
435
436    /// Get as single task if it is one
437    pub fn as_single(&self) -> Option<&Task> {
438        match self {
439            TaskDefinition::Single(task) => Some(task.as_ref()),
440            _ => None,
441        }
442    }
443
444    /// Get as task group if it is one
445    pub fn as_group(&self) -> Option<&TaskGroup> {
446        match self {
447            TaskDefinition::Group(group) => Some(group),
448            _ => None,
449        }
450    }
451}
452
453impl TaskGroup {
454    /// Check if this group is sequential
455    pub fn is_sequential(&self) -> bool {
456        matches!(self, TaskGroup::Sequential(_))
457    }
458
459    /// Check if this group is parallel
460    pub fn is_parallel(&self) -> bool {
461        matches!(self, TaskGroup::Parallel(_))
462    }
463
464    /// Get the number of tasks in this group
465    pub fn len(&self) -> usize {
466        match self {
467            TaskGroup::Sequential(tasks) => tasks.len(),
468            TaskGroup::Parallel(tasks) => tasks.len(),
469        }
470    }
471
472    /// Check if the group is empty
473    pub fn is_empty(&self) -> bool {
474        self.len() == 0
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    #[test]
483    fn test_task_default_values() {
484        let task = Task {
485            command: "echo".to_string(),
486            ..Default::default()
487        };
488
489        assert!(task.shell.is_none());
490        assert_eq!(task.command, "echo");
491        assert_eq!(task.description(), "No description provided");
492        assert!(task.args.is_empty());
493        assert!(task.hermetic); // default is true
494    }
495
496    #[test]
497    fn test_task_deserialization() {
498        let json = r#"{
499            "command": "echo",
500            "args": ["Hello", "World"]
501        }"#;
502
503        let task: Task = serde_json::from_str(json).unwrap();
504        assert_eq!(task.command, "echo");
505        assert_eq!(task.args, vec!["Hello", "World"]);
506        assert!(task.shell.is_none()); // default value
507    }
508
509    #[test]
510    fn test_task_group_sequential() {
511        let task1 = Task {
512            command: "echo".to_string(),
513            args: vec!["first".to_string()],
514            description: Some("First task".to_string()),
515            ..Default::default()
516        };
517
518        let task2 = Task {
519            command: "echo".to_string(),
520            args: vec!["second".to_string()],
521            description: Some("Second task".to_string()),
522            ..Default::default()
523        };
524
525        let group = TaskGroup::Sequential(vec![
526            TaskDefinition::Single(Box::new(task1)),
527            TaskDefinition::Single(Box::new(task2)),
528        ]);
529
530        assert!(group.is_sequential());
531        assert!(!group.is_parallel());
532        assert_eq!(group.len(), 2);
533    }
534
535    #[test]
536    fn test_task_group_parallel() {
537        let task1 = Task {
538            command: "echo".to_string(),
539            args: vec!["task1".to_string()],
540            description: Some("Task 1".to_string()),
541            ..Default::default()
542        };
543
544        let task2 = Task {
545            command: "echo".to_string(),
546            args: vec!["task2".to_string()],
547            description: Some("Task 2".to_string()),
548            ..Default::default()
549        };
550
551        let mut parallel_tasks = HashMap::new();
552        parallel_tasks.insert("task1".to_string(), TaskDefinition::Single(Box::new(task1)));
553        parallel_tasks.insert("task2".to_string(), TaskDefinition::Single(Box::new(task2)));
554
555        let group = TaskGroup::Parallel(parallel_tasks);
556
557        assert!(!group.is_sequential());
558        assert!(group.is_parallel());
559        assert_eq!(group.len(), 2);
560    }
561
562    #[test]
563    fn test_tasks_collection() {
564        let mut tasks = Tasks::new();
565        assert!(tasks.list_tasks().is_empty());
566
567        let task = Task {
568            command: "echo".to_string(),
569            args: vec!["hello".to_string()],
570            description: Some("Hello task".to_string()),
571            ..Default::default()
572        };
573
574        tasks
575            .tasks
576            .insert("greet".to_string(), TaskDefinition::Single(Box::new(task)));
577
578        assert!(tasks.contains("greet"));
579        assert!(!tasks.contains("nonexistent"));
580        assert_eq!(tasks.list_tasks(), vec!["greet"]);
581
582        let retrieved = tasks.get("greet").unwrap();
583        assert!(retrieved.is_single());
584    }
585
586    #[test]
587    fn test_task_definition_helpers() {
588        let task = Task {
589            command: "test".to_string(),
590            description: Some("Test task".to_string()),
591            ..Default::default()
592        };
593
594        let single = TaskDefinition::Single(Box::new(task.clone()));
595        assert!(single.is_single());
596        assert!(!single.is_group());
597        assert_eq!(single.as_single().unwrap().command, "test");
598        assert!(single.as_group().is_none());
599
600        let group = TaskDefinition::Group(TaskGroup::Sequential(vec![]));
601        assert!(!group.is_single());
602        assert!(group.is_group());
603        assert!(group.as_single().is_none());
604        assert!(group.as_group().is_some());
605    }
606
607    #[test]
608    fn test_input_deserialization_variants() {
609        let path_json = r#""src/**/*.rs""#;
610        let path_input: Input = serde_json::from_str(path_json).unwrap();
611        assert_eq!(path_input, Input::Path("src/**/*.rs".to_string()));
612
613        let project_json = r#"{
614            "project": "../projB",
615            "task": "build",
616            "map": [{"from": "dist/app.txt", "to": "vendor/app.txt"}]
617        }"#;
618        let project_input: Input = serde_json::from_str(project_json).unwrap();
619        match project_input {
620            Input::Project(reference) => {
621                assert_eq!(reference.project, "../projB");
622                assert_eq!(reference.task, "build");
623                assert_eq!(reference.map.len(), 1);
624                assert_eq!(reference.map[0].from, "dist/app.txt");
625                assert_eq!(reference.map[0].to, "vendor/app.txt");
626            }
627            other => panic!("Expected project reference, got {:?}", other),
628        }
629
630        // Test TaskOutput variant (same-project task reference)
631        let task_json = r#"{"task": "build.deps"}"#;
632        let task_input: Input = serde_json::from_str(task_json).unwrap();
633        match task_input {
634            Input::Task(output) => {
635                assert_eq!(output.task, "build.deps");
636                assert!(output.map.is_none());
637            }
638            other => panic!("Expected task output reference, got {:?}", other),
639        }
640    }
641
642    #[test]
643    fn test_task_input_helpers_collect() {
644        use std::collections::HashSet;
645        use std::path::Path;
646
647        let task = Task {
648            inputs: vec![
649                Input::Path("src".into()),
650                Input::Project(ProjectReference {
651                    project: "../projB".into(),
652                    task: "build".into(),
653                    map: vec![Mapping {
654                        from: "dist/app.txt".into(),
655                        to: "vendor/app.txt".into(),
656                    }],
657                }),
658            ],
659            ..Default::default()
660        };
661
662        let path_inputs: Vec<String> = task.iter_path_inputs().cloned().collect();
663        assert_eq!(path_inputs, vec!["src".to_string()]);
664
665        let project_refs: Vec<&ProjectReference> = task.iter_project_refs().collect();
666        assert_eq!(project_refs.len(), 1);
667        assert_eq!(project_refs[0].project, "../projB");
668
669        let prefix = Path::new("prefix");
670        let collected = task.collect_all_inputs_with_prefix(Some(prefix));
671        let collected: HashSet<_> = collected
672            .into_iter()
673            .map(std::path::PathBuf::from)
674            .collect();
675        let expected: HashSet<_> = ["src", "vendor/app.txt"]
676            .into_iter()
677            .map(|p| prefix.join(p))
678            .collect();
679        assert_eq!(collected, expected);
680    }
681
682    #[test]
683    fn test_resolved_args_interpolate_positional() {
684        let args = ResolvedArgs {
685            positional: vec!["video123".into(), "1080p".into()],
686            named: HashMap::new(),
687        };
688        assert_eq!(args.interpolate("{{0}}"), "video123");
689        assert_eq!(args.interpolate("{{1}}"), "1080p");
690        assert_eq!(args.interpolate("--id={{0}}"), "--id=video123");
691        assert_eq!(args.interpolate("{{0}}-{{1}}"), "video123-1080p");
692    }
693
694    #[test]
695    fn test_resolved_args_interpolate_named() {
696        let mut named = HashMap::new();
697        named.insert("url".into(), "https://example.com".into());
698        named.insert("quality".into(), "720p".into());
699        let args = ResolvedArgs {
700            positional: vec![],
701            named,
702        };
703        assert_eq!(args.interpolate("{{url}}"), "https://example.com");
704        assert_eq!(args.interpolate("--quality={{quality}}"), "--quality=720p");
705    }
706
707    #[test]
708    fn test_resolved_args_interpolate_mixed() {
709        let mut named = HashMap::new();
710        named.insert("format".into(), "mp4".into());
711        let args = ResolvedArgs {
712            positional: vec!["VIDEO_ID".into()],
713            named,
714        };
715        assert_eq!(
716            args.interpolate("download {{0}} --format={{format}}"),
717            "download VIDEO_ID --format=mp4"
718        );
719    }
720
721    #[test]
722    fn test_resolved_args_no_placeholder_unchanged() {
723        let args = ResolvedArgs::new();
724        assert_eq!(
725            args.interpolate("no placeholders here"),
726            "no placeholders here"
727        );
728        assert_eq!(args.interpolate(""), "");
729    }
730
731    #[test]
732    fn test_resolved_args_interpolate_args_list() {
733        let args = ResolvedArgs {
734            positional: vec!["id123".into()],
735            named: HashMap::new(),
736        };
737        let input = vec!["--id".into(), "{{0}}".into(), "--verbose".into()];
738        let result = args.interpolate_args(&input);
739        assert_eq!(result, vec!["--id", "id123", "--verbose"]);
740    }
741
742    #[test]
743    fn test_task_params_deserialization_with_flatten() {
744        // Test that named params are flattened (not nested under "named")
745        let json = r#"{
746            "positional": [{"description": "Video ID", "required": true}],
747            "quality": {"description": "Quality", "default": "1080p", "short": "q"},
748            "verbose": {"description": "Verbose output", "type": "bool"}
749        }"#;
750        let params: TaskParams = serde_json::from_str(json).unwrap();
751
752        assert_eq!(params.positional.len(), 1);
753        assert_eq!(
754            params.positional[0].description,
755            Some("Video ID".to_string())
756        );
757        assert!(params.positional[0].required);
758
759        assert_eq!(params.named.len(), 2);
760        assert!(params.named.contains_key("quality"));
761        assert!(params.named.contains_key("verbose"));
762
763        let quality = &params.named["quality"];
764        assert_eq!(quality.default, Some("1080p".to_string()));
765        assert_eq!(quality.short, Some("q".to_string()));
766
767        let verbose = &params.named["verbose"];
768        assert_eq!(verbose.param_type, ParamType::Bool);
769    }
770
771    #[test]
772    fn test_task_params_empty() {
773        let json = r#"{}"#;
774        let params: TaskParams = serde_json::from_str(json).unwrap();
775        assert!(params.positional.is_empty());
776        assert!(params.named.is_empty());
777    }
778
779    #[test]
780    fn test_param_def_defaults() {
781        let def = ParamDef::default();
782        assert!(def.description.is_none());
783        assert!(!def.required);
784        assert!(def.default.is_none());
785        assert_eq!(def.param_type, ParamType::String);
786        assert!(def.short.is_none());
787    }
788}