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 schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23use std::collections::HashMap;
24use std::path::Path;
25
26fn default_hermetic() -> bool {
27 true
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
32pub struct Shell {
33 pub command: Option<String>,
35 pub flag: Option<String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
41pub struct Mapping {
42 pub from: String,
44 pub to: String,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
50#[serde(untagged)]
51pub enum Input {
52 Path(String),
54 Project(ProjectReference),
56 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
85pub struct ProjectReference {
86 pub project: String,
88 pub task: String,
90 pub map: Vec<Mapping>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
96pub struct TaskOutput {
97 pub task: String,
99 #[serde(default)]
102 pub map: Option<Vec<Mapping>>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
107pub struct SourceLocation {
108 pub file: String,
110 pub line: u32,
112 pub column: u32,
114}
115
116impl SourceLocation {
117 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#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq)]
133pub struct Task {
134 #[serde(default)]
136 pub shell: Option<Shell>,
137
138 #[serde(default, skip_serializing_if = "String::is_empty")]
140 pub command: String,
141
142 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub script: Option<String>,
147
148 #[serde(default)]
150 pub args: Vec<String>,
151
152 #[serde(default)]
154 pub env: HashMap<String, serde_json::Value>,
155
156 #[serde(default)]
158 pub dagger: Option<DaggerTaskConfig>,
159
160 #[serde(default = "default_hermetic")]
163 pub hermetic: bool,
164
165 #[serde(default, rename = "dependsOn")]
167 pub depends_on: Vec<String>,
168
169 #[serde(default)]
171 pub inputs: Vec<Input>,
172
173 #[serde(default)]
175 pub outputs: Vec<String>,
176
177 #[serde(default, rename = "inputsFrom")]
179 pub inputs_from: Option<Vec<TaskOutput>>,
180
181 #[serde(default)]
183 pub workspaces: Vec<String>,
184
185 #[serde(default)]
187 pub description: Option<String>,
188
189 #[serde(default)]
191 pub params: Option<TaskParams>,
192
193 #[serde(default)]
196 pub labels: Vec<String>,
197
198 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub task_ref: Option<String>,
203
204 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub project_root: Option<std::path::PathBuf>,
208
209 #[serde(default, rename = "_source", skip_serializing_if = "Option::is_none")]
212 pub source: Option<SourceLocation>,
213
214 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub directory: Option<String>,
218}
219
220impl<'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 #[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 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
309pub struct DaggerTaskConfig {
310 #[serde(default)]
313 pub image: Option<String>,
314
315 #[serde(default)]
318 pub from: Option<String>,
319
320 #[serde(default)]
323 pub secrets: Option<Vec<DaggerSecret>>,
324
325 #[serde(default)]
327 pub cache: Option<Vec<DaggerCacheMount>>,
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
332pub struct DaggerSecret {
333 pub name: String,
335
336 #[serde(default)]
338 pub path: Option<String>,
339
340 #[serde(default, rename = "envVar")]
342 pub env_var: Option<String>,
343
344 pub resolver: crate::secrets::Secret,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
350pub struct DaggerCacheMount {
351 pub path: String,
353
354 pub name: String,
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
360pub struct TaskParams {
361 #[serde(default)]
364 pub positional: Vec<ParamDef>,
365
366 #[serde(flatten, default)]
369 pub named: HashMap<String, ParamDef>,
370}
371
372#[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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
384pub struct ParamDef {
385 #[serde(default)]
387 pub description: Option<String>,
388
389 #[serde(default)]
391 pub required: bool,
392
393 #[serde(default)]
395 pub default: Option<String>,
396
397 #[serde(default, rename = "type")]
399 pub param_type: ParamType,
400
401 #[serde(default)]
403 pub short: Option<String>,
404}
405
406#[derive(Debug, Clone, Default)]
408pub struct ResolvedArgs {
409 pub positional: Vec<String>,
411 pub named: HashMap<String, String>,
413}
414
415impl ResolvedArgs {
416 pub fn new() -> Self {
418 Self::default()
419 }
420
421 pub fn interpolate(&self, template: &str) -> String {
424 let mut result = template.to_string();
425
426 for (i, value) in self.positional.iter().enumerate() {
428 let placeholder = format!("{{{{{}}}}}", i);
429 result = result.replace(&placeholder, value);
430 }
431
432 for (name, value) in &self.named {
434 let placeholder = format!("{{{{{}}}}}", name);
435 result = result.replace(&placeholder, value);
436 }
437
438 result
439 }
440
441 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, 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 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 pub fn is_task_ref(&self) -> bool {
486 self.task_ref.is_some()
487 }
488
489 pub fn description(&self) -> &str {
491 self.description
492 .as_deref()
493 .unwrap_or("No description provided")
494 }
495
496 pub fn iter_path_inputs(&self) -> impl Iterator<Item = &String> {
498 self.inputs.iter().filter_map(Input::as_path)
499 }
500
501 pub fn iter_project_refs(&self) -> impl Iterator<Item = &ProjectReference> {
503 self.inputs.iter().filter_map(Input::as_project)
504 }
505
506 pub fn iter_task_outputs(&self) -> impl Iterator<Item = &TaskOutput> {
508 self.inputs.iter().filter_map(Input::as_task_output)
509 }
510
511 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 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 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
543pub struct ParallelGroup {
544 #[serde(flatten)]
546 pub tasks: HashMap<String, TaskDefinition>,
547
548 #[serde(default, rename = "dependsOn")]
550 pub depends_on: Vec<String>,
551}
552
553#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
555#[serde(untagged)]
556pub enum TaskGroup {
557 Sequential(Vec<TaskDefinition>),
559
560 Parallel(ParallelGroup),
562}
563
564#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
566#[serde(untagged)]
567pub enum TaskDefinition {
568 Single(Box<Task>),
570
571 Group(TaskGroup),
573}
574
575#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
577pub struct Tasks {
578 #[serde(flatten)]
580 pub tasks: HashMap<String, TaskDefinition>,
581}
582
583impl Tasks {
584 pub fn new() -> Self {
586 Self::default()
587 }
588
589 pub fn get(&self, name: &str) -> Option<&TaskDefinition> {
591 self.tasks.get(name)
592 }
593
594 pub fn list_tasks(&self) -> Vec<&str> {
596 self.tasks.keys().map(|s| s.as_str()).collect()
597 }
598
599 pub fn contains(&self, name: &str) -> bool {
601 self.tasks.contains_key(name)
602 }
603}
604
605impl TaskDefinition {
606 pub fn is_single(&self) -> bool {
608 matches!(self, TaskDefinition::Single(_))
609 }
610
611 pub fn is_group(&self) -> bool {
613 matches!(self, TaskDefinition::Group(_))
614 }
615
616 pub fn as_single(&self) -> Option<&Task> {
618 match self {
619 TaskDefinition::Single(task) => Some(task.as_ref()),
620 _ => None,
621 }
622 }
623
624 pub fn as_group(&self) -> Option<&TaskGroup> {
626 match self {
627 TaskDefinition::Group(group) => Some(group),
628 _ => None,
629 }
630 }
631
632 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 pub fn is_sequential(&self) -> bool {
655 matches!(self, TaskGroup::Sequential(_))
656 }
657
658 pub fn is_parallel(&self) -> bool {
660 matches!(self, TaskGroup::Parallel(_))
661 }
662
663 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 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); }
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()); }
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 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 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 = ¶ms.named["quality"];
966 assert_eq!(quality.default, Some("1080p".to_string()));
967 assert_eq!(quality.short, Some("q".to_string()));
968
969 let verbose = ¶ms.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}