1pub mod backend;
6pub mod discovery;
7pub mod executor;
8pub mod graph;
9pub mod index;
10pub mod io;
11
12pub 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31pub struct Shell {
32 pub command: Option<String>,
34 pub flag: Option<String>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
40pub struct Mapping {
41 pub from: String,
43 pub to: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
49#[serde(untagged)]
50pub enum Input {
51 Path(String),
53 Project(ProjectReference),
55 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
84pub struct ProjectReference {
85 pub project: String,
87 pub task: String,
89 pub map: Vec<Mapping>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
95pub struct TaskOutput {
96 pub task: String,
98 #[serde(default)]
101 pub map: Option<Vec<Mapping>>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
106pub struct SourceLocation {
107 pub file: String,
109 pub line: u32,
111 pub column: u32,
113}
114
115impl SourceLocation {
116 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#[derive(Debug, Clone, Serialize, PartialEq)]
132pub struct Task {
133 #[serde(default)]
135 pub shell: Option<Shell>,
136
137 #[serde(default, skip_serializing_if = "String::is_empty")]
139 pub command: String,
140
141 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub script: Option<String>,
146
147 #[serde(default)]
149 pub args: Vec<String>,
150
151 #[serde(default)]
153 pub env: HashMap<String, serde_json::Value>,
154
155 #[serde(default)]
157 pub dagger: Option<DaggerTaskConfig>,
158
159 #[serde(default = "default_hermetic")]
162 pub hermetic: bool,
163
164 #[serde(default, rename = "dependsOn")]
166 pub depends_on: Vec<String>,
167
168 #[serde(default)]
170 pub inputs: Vec<Input>,
171
172 #[serde(default)]
174 pub outputs: Vec<String>,
175
176 #[serde(default, rename = "inputsFrom")]
178 pub inputs_from: Option<Vec<TaskOutput>>,
179
180 #[serde(default)]
182 pub workspaces: Vec<String>,
183
184 #[serde(default)]
186 pub description: Option<String>,
187
188 #[serde(default)]
190 pub params: Option<TaskParams>,
191
192 #[serde(default)]
195 pub labels: Vec<String>,
196
197 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub task_ref: Option<String>,
202
203 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub project_root: Option<std::path::PathBuf>,
207
208 #[serde(default, rename = "_source", skip_serializing_if = "Option::is_none")]
211 pub source: Option<SourceLocation>,
212
213 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub directory: Option<String>,
217}
218
219impl<'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 #[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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
308pub struct DaggerTaskConfig {
309 #[serde(default)]
312 pub image: Option<String>,
313
314 #[serde(default)]
317 pub from: Option<String>,
318
319 #[serde(default)]
322 pub secrets: Option<Vec<DaggerSecret>>,
323
324 #[serde(default)]
326 pub cache: Option<Vec<DaggerCacheMount>>,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
331pub struct DaggerSecret {
332 pub name: String,
334
335 #[serde(default)]
337 pub path: Option<String>,
338
339 #[serde(default, rename = "envVar")]
341 pub env_var: Option<String>,
342
343 pub resolver: crate::secrets::Secret,
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
349pub struct DaggerCacheMount {
350 pub path: String,
352
353 pub name: String,
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
359pub struct TaskParams {
360 #[serde(default)]
363 pub positional: Vec<ParamDef>,
364
365 #[serde(flatten, default)]
368 pub named: HashMap<String, ParamDef>,
369}
370
371#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
383pub struct ParamDef {
384 #[serde(default)]
386 pub description: Option<String>,
387
388 #[serde(default)]
390 pub required: bool,
391
392 #[serde(default)]
394 pub default: Option<String>,
395
396 #[serde(default, rename = "type")]
398 pub param_type: ParamType,
399
400 #[serde(default)]
402 pub short: Option<String>,
403}
404
405#[derive(Debug, Clone, Default)]
407pub struct ResolvedArgs {
408 pub positional: Vec<String>,
410 pub named: HashMap<String, String>,
412}
413
414impl ResolvedArgs {
415 pub fn new() -> Self {
417 Self::default()
418 }
419
420 pub fn interpolate(&self, template: &str) -> String {
423 let mut result = template.to_string();
424
425 for (i, value) in self.positional.iter().enumerate() {
427 let placeholder = format!("{{{{{}}}}}", i);
428 result = result.replace(&placeholder, value);
429 }
430
431 for (name, value) in &self.named {
433 let placeholder = format!("{{{{{}}}}}", name);
434 result = result.replace(&placeholder, value);
435 }
436
437 result
438 }
439
440 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, 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 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 pub fn is_task_ref(&self) -> bool {
485 self.task_ref.is_some()
486 }
487
488 pub fn description(&self) -> &str {
490 self.description
491 .as_deref()
492 .unwrap_or("No description provided")
493 }
494
495 pub fn iter_path_inputs(&self) -> impl Iterator<Item = &String> {
497 self.inputs.iter().filter_map(Input::as_path)
498 }
499
500 pub fn iter_project_refs(&self) -> impl Iterator<Item = &ProjectReference> {
502 self.inputs.iter().filter_map(Input::as_project)
503 }
504
505 pub fn iter_task_outputs(&self) -> impl Iterator<Item = &TaskOutput> {
507 self.inputs.iter().filter_map(Input::as_task_output)
508 }
509
510 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 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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
542pub struct ParallelGroup {
543 #[serde(flatten)]
545 pub tasks: HashMap<String, TaskDefinition>,
546
547 #[serde(default, rename = "dependsOn")]
549 pub depends_on: Vec<String>,
550}
551
552#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
554#[serde(untagged)]
555pub enum TaskGroup {
556 Sequential(Vec<TaskDefinition>),
558
559 Parallel(ParallelGroup),
561}
562
563#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
565#[serde(untagged)]
566pub enum TaskDefinition {
567 Single(Box<Task>),
569
570 Group(TaskGroup),
572}
573
574#[derive(Debug, Clone, Serialize, Deserialize, Default)]
576pub struct Tasks {
577 #[serde(flatten)]
579 pub tasks: HashMap<String, TaskDefinition>,
580}
581
582impl Tasks {
583 pub fn new() -> Self {
585 Self::default()
586 }
587
588 pub fn get(&self, name: &str) -> Option<&TaskDefinition> {
590 self.tasks.get(name)
591 }
592
593 pub fn list_tasks(&self) -> Vec<&str> {
595 self.tasks.keys().map(|s| s.as_str()).collect()
596 }
597
598 pub fn contains(&self, name: &str) -> bool {
600 self.tasks.contains_key(name)
601 }
602}
603
604impl TaskDefinition {
605 pub fn is_single(&self) -> bool {
607 matches!(self, TaskDefinition::Single(_))
608 }
609
610 pub fn is_group(&self) -> bool {
612 matches!(self, TaskDefinition::Group(_))
613 }
614
615 pub fn as_single(&self) -> Option<&Task> {
617 match self {
618 TaskDefinition::Single(task) => Some(task.as_ref()),
619 _ => None,
620 }
621 }
622
623 pub fn as_group(&self) -> Option<&TaskGroup> {
625 match self {
626 TaskDefinition::Group(group) => Some(group),
627 _ => None,
628 }
629 }
630
631 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 pub fn is_sequential(&self) -> bool {
654 matches!(self, TaskGroup::Sequential(_))
655 }
656
657 pub fn is_parallel(&self) -> bool {
659 matches!(self, TaskGroup::Parallel(_))
660 }
661
662 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 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); }
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()); }
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 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 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 = ¶ms.named["quality"];
965 assert_eq!(quality.default, Some("1080p".to_string()));
966 assert_eq!(quality.short, Some("q".to_string()));
967
968 let verbose = ¶ms.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}