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)]
158 pub dagger: Option<DaggerTaskConfig>,
159
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub runtime: Option<crate::manifest::Runtime>,
163
164 #[serde(default = "default_hermetic")]
167 pub hermetic: bool,
168
169 #[serde(default, rename = "dependsOn")]
171 pub depends_on: Vec<String>,
172
173 #[serde(default)]
175 pub inputs: Vec<Input>,
176
177 #[serde(default)]
179 pub outputs: Vec<String>,
180
181 #[serde(default, rename = "inputsFrom")]
183 pub inputs_from: Option<Vec<TaskOutput>>,
184
185 #[serde(default)]
187 pub workspaces: Vec<String>,
188
189 #[serde(default)]
191 pub description: Option<String>,
192
193 #[serde(default)]
195 pub params: Option<TaskParams>,
196
197 #[serde(default)]
200 pub labels: Vec<String>,
201
202 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub task_ref: Option<String>,
207
208 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub project_root: Option<std::path::PathBuf>,
212
213 #[serde(default, rename = "_source", skip_serializing_if = "Option::is_none")]
216 pub source: Option<SourceLocation>,
217
218 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub directory: Option<String>,
222}
223
224impl<'de> serde::Deserialize<'de> for Task {
227 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
228 where
229 D: serde::Deserializer<'de>,
230 {
231 #[derive(serde::Deserialize)]
233 struct TaskHelper {
234 #[serde(default)]
235 shell: Option<Shell>,
236 #[serde(default)]
237 command: Option<String>,
238 #[serde(default)]
239 script: Option<String>,
240 #[serde(default)]
241 args: Vec<String>,
242 #[serde(default)]
243 env: HashMap<String, serde_json::Value>,
244 #[serde(default)]
245 dagger: Option<DaggerTaskConfig>,
246 #[serde(default)]
247 runtime: Option<crate::manifest::Runtime>,
248 #[serde(default = "default_hermetic")]
249 hermetic: bool,
250 #[serde(default, rename = "dependsOn")]
251 depends_on: Vec<String>,
252 #[serde(default)]
253 inputs: Vec<Input>,
254 #[serde(default)]
255 outputs: Vec<String>,
256 #[serde(default, rename = "inputsFrom")]
257 inputs_from: Option<Vec<TaskOutput>>,
258 #[serde(default)]
259 workspaces: Vec<String>,
260 #[serde(default)]
261 description: Option<String>,
262 #[serde(default)]
263 params: Option<TaskParams>,
264 #[serde(default)]
265 labels: Vec<String>,
266 #[serde(default)]
267 task_ref: Option<String>,
268 #[serde(default)]
269 project_root: Option<std::path::PathBuf>,
270 #[serde(default, rename = "_source")]
271 source: Option<SourceLocation>,
272 #[serde(default)]
273 directory: Option<String>,
274 }
275
276 let helper = TaskHelper::deserialize(deserializer)?;
277
278 let has_command = helper.command.as_ref().is_some_and(|c| !c.is_empty());
280 let has_script = helper.script.is_some();
281 let has_task_ref = helper.task_ref.is_some();
282
283 if !has_command && !has_script && !has_task_ref {
284 return Err(serde::de::Error::custom(
285 "Task must have either 'command', 'script', or 'task_ref' field",
286 ));
287 }
288
289 Ok(Task {
290 shell: helper.shell,
291 command: helper.command.unwrap_or_default(),
292 script: helper.script,
293 args: helper.args,
294 env: helper.env,
295 dagger: helper.dagger,
296 runtime: helper.runtime,
297 hermetic: helper.hermetic,
298 depends_on: helper.depends_on,
299 inputs: helper.inputs,
300 outputs: helper.outputs,
301 inputs_from: helper.inputs_from,
302 workspaces: helper.workspaces,
303 description: helper.description,
304 params: helper.params,
305 labels: helper.labels,
306 task_ref: helper.task_ref,
307 project_root: helper.project_root,
308 source: helper.source,
309 directory: helper.directory,
310 })
311 }
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
316pub struct DaggerTaskConfig {
317 #[serde(default)]
320 pub image: Option<String>,
321
322 #[serde(default)]
325 pub from: Option<String>,
326
327 #[serde(default)]
330 pub secrets: Option<Vec<DaggerSecret>>,
331
332 #[serde(default)]
334 pub cache: Option<Vec<DaggerCacheMount>>,
335}
336
337#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
339pub struct DaggerSecret {
340 pub name: String,
342
343 #[serde(default)]
345 pub path: Option<String>,
346
347 #[serde(default, rename = "envVar")]
349 pub env_var: Option<String>,
350
351 pub resolver: crate::secrets::Secret,
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
357pub struct DaggerCacheMount {
358 pub path: String,
360
361 pub name: String,
363}
364
365#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
367pub struct TaskParams {
368 #[serde(default)]
371 pub positional: Vec<ParamDef>,
372
373 #[serde(flatten, default)]
376 pub named: HashMap<String, ParamDef>,
377}
378
379#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
381#[serde(rename_all = "lowercase")]
382pub enum ParamType {
383 #[default]
384 String,
385 Bool,
386 Int,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
391pub struct ParamDef {
392 #[serde(default)]
394 pub description: Option<String>,
395
396 #[serde(default)]
398 pub required: bool,
399
400 #[serde(default)]
402 pub default: Option<String>,
403
404 #[serde(default, rename = "type")]
406 pub param_type: ParamType,
407
408 #[serde(default)]
410 pub short: Option<String>,
411}
412
413#[derive(Debug, Clone, Default)]
415pub struct ResolvedArgs {
416 pub positional: Vec<String>,
418 pub named: HashMap<String, String>,
420}
421
422impl ResolvedArgs {
423 pub fn new() -> Self {
425 Self::default()
426 }
427
428 pub fn interpolate(&self, template: &str) -> String {
431 let mut result = template.to_string();
432
433 for (i, value) in self.positional.iter().enumerate() {
435 let placeholder = format!("{{{{{}}}}}", i);
436 result = result.replace(&placeholder, value);
437 }
438
439 for (name, value) in &self.named {
441 let placeholder = format!("{{{{{}}}}}", name);
442 result = result.replace(&placeholder, value);
443 }
444
445 result
446 }
447
448 pub fn interpolate_args(&self, args: &[String]) -> Vec<String> {
450 args.iter().map(|arg| self.interpolate(arg)).collect()
451 }
452}
453
454impl Default for Task {
455 fn default() -> Self {
456 Self {
457 shell: None,
458 command: String::new(),
459 script: None,
460 args: vec![],
461 env: HashMap::new(),
462 dagger: None,
463 runtime: None,
464 hermetic: true, depends_on: vec![],
466 inputs: vec![],
467 outputs: vec![],
468 inputs_from: None,
469 workspaces: vec![],
470 description: None,
471 params: None,
472 labels: vec![],
473 task_ref: None,
474 project_root: None,
475 source: None,
476 directory: None,
477 }
478 }
479}
480
481impl Task {
482 pub fn from_task_ref(ref_str: &str) -> Self {
485 Self {
486 task_ref: Some(ref_str.to_string()),
487 description: Some(format!("Reference to {}", ref_str)),
488 ..Default::default()
489 }
490 }
491
492 pub fn is_task_ref(&self) -> bool {
494 self.task_ref.is_some()
495 }
496
497 pub fn description(&self) -> &str {
499 self.description
500 .as_deref()
501 .unwrap_or("No description provided")
502 }
503
504 pub fn iter_path_inputs(&self) -> impl Iterator<Item = &String> {
506 self.inputs.iter().filter_map(Input::as_path)
507 }
508
509 pub fn iter_project_refs(&self) -> impl Iterator<Item = &ProjectReference> {
511 self.inputs.iter().filter_map(Input::as_project)
512 }
513
514 pub fn iter_task_outputs(&self) -> impl Iterator<Item = &TaskOutput> {
516 self.inputs.iter().filter_map(Input::as_task_output)
517 }
518
519 pub fn collect_path_inputs_with_prefix(&self, prefix: Option<&Path>) -> Vec<String> {
521 self.iter_path_inputs()
522 .map(|path| apply_prefix(prefix, path))
523 .collect()
524 }
525
526 pub fn collect_project_destinations_with_prefix(&self, prefix: Option<&Path>) -> Vec<String> {
528 self.iter_project_refs()
529 .flat_map(|reference| reference.map.iter().map(|m| apply_prefix(prefix, &m.to)))
530 .collect()
531 }
532
533 pub fn collect_all_inputs_with_prefix(&self, prefix: Option<&Path>) -> Vec<String> {
535 let mut inputs = self.collect_path_inputs_with_prefix(prefix);
536 inputs.extend(self.collect_project_destinations_with_prefix(prefix));
537 inputs
538 }
539}
540
541fn apply_prefix(prefix: Option<&Path>, value: &str) -> String {
542 if let Some(prefix) = prefix {
543 prefix.join(value).to_string_lossy().to_string()
544 } else {
545 value.to_string()
546 }
547}
548
549#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
551pub struct ParallelGroup {
552 #[serde(flatten)]
554 pub tasks: HashMap<String, TaskDefinition>,
555
556 #[serde(default, rename = "dependsOn")]
558 pub depends_on: Vec<String>,
559}
560
561#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
563#[serde(untagged)]
564pub enum TaskGroup {
565 Sequential(Vec<TaskDefinition>),
567
568 Parallel(ParallelGroup),
570}
571
572#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
574#[serde(untagged)]
575pub enum TaskDefinition {
576 Single(Box<Task>),
578
579 Group(TaskGroup),
581}
582
583#[derive(Debug, Clone, Serialize, Deserialize, Default)]
585pub struct Tasks {
586 #[serde(flatten)]
588 pub tasks: HashMap<String, TaskDefinition>,
589}
590
591impl Tasks {
592 pub fn new() -> Self {
594 Self::default()
595 }
596
597 pub fn get(&self, name: &str) -> Option<&TaskDefinition> {
599 self.tasks.get(name)
600 }
601
602 pub fn list_tasks(&self) -> Vec<&str> {
604 self.tasks.keys().map(|s| s.as_str()).collect()
605 }
606
607 pub fn contains(&self, name: &str) -> bool {
609 self.tasks.contains_key(name)
610 }
611}
612
613impl TaskDefinition {
614 pub fn is_single(&self) -> bool {
616 matches!(self, TaskDefinition::Single(_))
617 }
618
619 pub fn is_group(&self) -> bool {
621 matches!(self, TaskDefinition::Group(_))
622 }
623
624 pub fn as_single(&self) -> Option<&Task> {
626 match self {
627 TaskDefinition::Single(task) => Some(task.as_ref()),
628 _ => None,
629 }
630 }
631
632 pub fn as_group(&self) -> Option<&TaskGroup> {
634 match self {
635 TaskDefinition::Group(group) => Some(group),
636 _ => None,
637 }
638 }
639
640 pub fn uses_workspace(&self, workspace_name: &str) -> bool {
645 match self {
646 TaskDefinition::Single(task) => task.workspaces.contains(&workspace_name.to_string()),
647 TaskDefinition::Group(group) => match group {
648 TaskGroup::Sequential(tasks) => {
649 tasks.iter().any(|t| t.uses_workspace(workspace_name))
650 }
651 TaskGroup::Parallel(parallel) => parallel
652 .tasks
653 .values()
654 .any(|t| t.uses_workspace(workspace_name)),
655 },
656 }
657 }
658}
659
660impl TaskGroup {
661 pub fn is_sequential(&self) -> bool {
663 matches!(self, TaskGroup::Sequential(_))
664 }
665
666 pub fn is_parallel(&self) -> bool {
668 matches!(self, TaskGroup::Parallel(_))
669 }
670
671 pub fn len(&self) -> usize {
673 match self {
674 TaskGroup::Sequential(tasks) => tasks.len(),
675 TaskGroup::Parallel(group) => group.tasks.len(),
676 }
677 }
678
679 pub fn is_empty(&self) -> bool {
681 self.len() == 0
682 }
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688
689 #[test]
690 fn test_task_default_values() {
691 let task = Task {
692 command: "echo".to_string(),
693 ..Default::default()
694 };
695
696 assert!(task.shell.is_none());
697 assert_eq!(task.command, "echo");
698 assert_eq!(task.description(), "No description provided");
699 assert!(task.args.is_empty());
700 assert!(task.hermetic); }
702
703 #[test]
704 fn test_task_deserialization() {
705 let json = r#"{
706 "command": "echo",
707 "args": ["Hello", "World"]
708 }"#;
709
710 let task: Task = serde_json::from_str(json).unwrap();
711 assert_eq!(task.command, "echo");
712 assert_eq!(task.args, vec!["Hello", "World"]);
713 assert!(task.shell.is_none()); }
715
716 #[test]
717 fn test_task_script_deserialization() {
718 let json = r#"{
720 "script": "echo hello",
721 "inputs": ["src/main.rs"]
722 }"#;
723
724 let task: Task = serde_json::from_str(json).unwrap();
725 assert!(task.command.is_empty()); assert_eq!(task.script, Some("echo hello".to_string()));
727 assert_eq!(task.inputs.len(), 1);
728 }
729
730 #[test]
731 fn test_task_definition_script_variant() {
732 let json = r#"{
734 "script": "echo hello"
735 }"#;
736
737 let def: TaskDefinition = serde_json::from_str(json).unwrap();
738 assert!(def.is_single());
739 }
740
741 #[test]
742 fn test_task_group_with_script_task() {
743 let json = r#"{
745 "linux": {
746 "script": "echo building",
747 "inputs": ["src/main.rs"]
748 }
749 }"#;
750
751 let group: TaskGroup = serde_json::from_str(json).unwrap();
752 assert!(group.is_parallel());
753 }
754
755 #[test]
756 fn test_full_tasks_map_with_script() {
757 let json = r#"{
760 "pwd": { "command": "pwd" },
761 "cross": {
762 "linux": {
763 "script": "echo building",
764 "inputs": ["src/main.rs"]
765 }
766 }
767 }"#;
768
769 let tasks: HashMap<String, TaskDefinition> = serde_json::from_str(json).unwrap();
770 assert_eq!(tasks.len(), 2);
771 assert!(tasks.contains_key("pwd"));
772 assert!(tasks.contains_key("cross"));
773
774 assert!(tasks.get("pwd").unwrap().is_single());
776
777 assert!(tasks.get("cross").unwrap().is_group());
779 }
780
781 #[test]
782 fn test_complex_nested_tasks_like_cuenv() {
783 let json = r#"{
785 "pwd": { "command": "pwd" },
786 "check": {
787 "command": "nix",
788 "args": ["flake", "check"],
789 "inputs": ["flake.nix"]
790 },
791 "fmt": {
792 "fix": {
793 "command": "treefmt",
794 "inputs": [".config"]
795 },
796 "check": {
797 "command": "treefmt",
798 "args": ["--fail-on-change"],
799 "inputs": [".config"]
800 }
801 },
802 "cross": {
803 "linux": {
804 "script": "echo building",
805 "inputs": ["Cargo.toml"]
806 }
807 },
808 "docs": {
809 "build": {
810 "command": "bash",
811 "args": ["-c", "bun install"],
812 "inputs": ["docs"],
813 "outputs": ["docs/dist"]
814 },
815 "deploy": {
816 "command": "bash",
817 "args": ["-c", "wrangler deploy"],
818 "dependsOn": ["docs.build"],
819 "inputsFrom": [{"task": "docs.build"}]
820 }
821 }
822 }"#;
823
824 let result: Result<HashMap<String, TaskDefinition>, _> = serde_json::from_str(json);
825 match result {
826 Ok(tasks) => {
827 assert_eq!(tasks.len(), 5);
828 assert!(tasks.get("pwd").unwrap().is_single());
829 assert!(tasks.get("check").unwrap().is_single());
830 assert!(tasks.get("fmt").unwrap().is_group());
831 assert!(tasks.get("cross").unwrap().is_group());
832 assert!(tasks.get("docs").unwrap().is_group());
833 }
834 Err(e) => {
835 panic!("Failed to deserialize complex tasks: {}", e);
836 }
837 }
838 }
839
840 #[test]
841 fn test_task_group_sequential() {
842 let task1 = Task {
843 command: "echo".to_string(),
844 args: vec!["first".to_string()],
845 description: Some("First task".to_string()),
846 ..Default::default()
847 };
848
849 let task2 = Task {
850 command: "echo".to_string(),
851 args: vec!["second".to_string()],
852 description: Some("Second task".to_string()),
853 ..Default::default()
854 };
855
856 let group = TaskGroup::Sequential(vec![
857 TaskDefinition::Single(Box::new(task1)),
858 TaskDefinition::Single(Box::new(task2)),
859 ]);
860
861 assert!(group.is_sequential());
862 assert!(!group.is_parallel());
863 assert_eq!(group.len(), 2);
864 }
865
866 #[test]
867 fn test_task_group_parallel() {
868 let task1 = Task {
869 command: "echo".to_string(),
870 args: vec!["task1".to_string()],
871 description: Some("Task 1".to_string()),
872 ..Default::default()
873 };
874
875 let task2 = Task {
876 command: "echo".to_string(),
877 args: vec!["task2".to_string()],
878 description: Some("Task 2".to_string()),
879 ..Default::default()
880 };
881
882 let mut parallel_tasks = HashMap::new();
883 parallel_tasks.insert("task1".to_string(), TaskDefinition::Single(Box::new(task1)));
884 parallel_tasks.insert("task2".to_string(), TaskDefinition::Single(Box::new(task2)));
885
886 let group = TaskGroup::Parallel(ParallelGroup {
887 tasks: parallel_tasks,
888 depends_on: vec![],
889 });
890
891 assert!(!group.is_sequential());
892 assert!(group.is_parallel());
893 assert_eq!(group.len(), 2);
894 }
895
896 #[test]
897 fn test_tasks_collection() {
898 let mut tasks = Tasks::new();
899 assert!(tasks.list_tasks().is_empty());
900
901 let task = Task {
902 command: "echo".to_string(),
903 args: vec!["hello".to_string()],
904 description: Some("Hello task".to_string()),
905 ..Default::default()
906 };
907
908 tasks
909 .tasks
910 .insert("greet".to_string(), TaskDefinition::Single(Box::new(task)));
911
912 assert!(tasks.contains("greet"));
913 assert!(!tasks.contains("nonexistent"));
914 assert_eq!(tasks.list_tasks(), vec!["greet"]);
915
916 let retrieved = tasks.get("greet").unwrap();
917 assert!(retrieved.is_single());
918 }
919
920 #[test]
921 fn test_task_definition_helpers() {
922 let task = Task {
923 command: "test".to_string(),
924 description: Some("Test task".to_string()),
925 ..Default::default()
926 };
927
928 let single = TaskDefinition::Single(Box::new(task.clone()));
929 assert!(single.is_single());
930 assert!(!single.is_group());
931 assert_eq!(single.as_single().unwrap().command, "test");
932 assert!(single.as_group().is_none());
933
934 let group = TaskDefinition::Group(TaskGroup::Sequential(vec![]));
935 assert!(!group.is_single());
936 assert!(group.is_group());
937 assert!(group.as_single().is_none());
938 assert!(group.as_group().is_some());
939 }
940
941 #[test]
942 fn test_input_deserialization_variants() {
943 let path_json = r#""src/**/*.rs""#;
944 let path_input: Input = serde_json::from_str(path_json).unwrap();
945 assert_eq!(path_input, Input::Path("src/**/*.rs".to_string()));
946
947 let project_json = r#"{
948 "project": "../projB",
949 "task": "build",
950 "map": [{"from": "dist/app.txt", "to": "vendor/app.txt"}]
951 }"#;
952 let project_input: Input = serde_json::from_str(project_json).unwrap();
953 match project_input {
954 Input::Project(reference) => {
955 assert_eq!(reference.project, "../projB");
956 assert_eq!(reference.task, "build");
957 assert_eq!(reference.map.len(), 1);
958 assert_eq!(reference.map[0].from, "dist/app.txt");
959 assert_eq!(reference.map[0].to, "vendor/app.txt");
960 }
961 other => panic!("Expected project reference, got {:?}", other),
962 }
963
964 let task_json = r#"{"task": "build.deps"}"#;
966 let task_input: Input = serde_json::from_str(task_json).unwrap();
967 match task_input {
968 Input::Task(output) => {
969 assert_eq!(output.task, "build.deps");
970 assert!(output.map.is_none());
971 }
972 other => panic!("Expected task output reference, got {:?}", other),
973 }
974 }
975
976 #[test]
977 fn test_task_input_helpers_collect() {
978 use std::collections::HashSet;
979 use std::path::Path;
980
981 let task = Task {
982 inputs: vec![
983 Input::Path("src".into()),
984 Input::Project(ProjectReference {
985 project: "../projB".into(),
986 task: "build".into(),
987 map: vec![Mapping {
988 from: "dist/app.txt".into(),
989 to: "vendor/app.txt".into(),
990 }],
991 }),
992 ],
993 ..Default::default()
994 };
995
996 let path_inputs: Vec<String> = task.iter_path_inputs().cloned().collect();
997 assert_eq!(path_inputs, vec!["src".to_string()]);
998
999 let project_refs: Vec<&ProjectReference> = task.iter_project_refs().collect();
1000 assert_eq!(project_refs.len(), 1);
1001 assert_eq!(project_refs[0].project, "../projB");
1002
1003 let prefix = Path::new("prefix");
1004 let collected = task.collect_all_inputs_with_prefix(Some(prefix));
1005 let collected: HashSet<_> = collected
1006 .into_iter()
1007 .map(std::path::PathBuf::from)
1008 .collect();
1009 let expected: HashSet<_> = ["src", "vendor/app.txt"]
1010 .into_iter()
1011 .map(|p| prefix.join(p))
1012 .collect();
1013 assert_eq!(collected, expected);
1014 }
1015
1016 #[test]
1017 fn test_resolved_args_interpolate_positional() {
1018 let args = ResolvedArgs {
1019 positional: vec!["video123".into(), "1080p".into()],
1020 named: HashMap::new(),
1021 };
1022 assert_eq!(args.interpolate("{{0}}"), "video123");
1023 assert_eq!(args.interpolate("{{1}}"), "1080p");
1024 assert_eq!(args.interpolate("--id={{0}}"), "--id=video123");
1025 assert_eq!(args.interpolate("{{0}}-{{1}}"), "video123-1080p");
1026 }
1027
1028 #[test]
1029 fn test_resolved_args_interpolate_named() {
1030 let mut named = HashMap::new();
1031 named.insert("url".into(), "https://example.com".into());
1032 named.insert("quality".into(), "720p".into());
1033 let args = ResolvedArgs {
1034 positional: vec![],
1035 named,
1036 };
1037 assert_eq!(args.interpolate("{{url}}"), "https://example.com");
1038 assert_eq!(args.interpolate("--quality={{quality}}"), "--quality=720p");
1039 }
1040
1041 #[test]
1042 fn test_resolved_args_interpolate_mixed() {
1043 let mut named = HashMap::new();
1044 named.insert("format".into(), "mp4".into());
1045 let args = ResolvedArgs {
1046 positional: vec!["VIDEO_ID".into()],
1047 named,
1048 };
1049 assert_eq!(
1050 args.interpolate("download {{0}} --format={{format}}"),
1051 "download VIDEO_ID --format=mp4"
1052 );
1053 }
1054
1055 #[test]
1056 fn test_resolved_args_no_placeholder_unchanged() {
1057 let args = ResolvedArgs::new();
1058 assert_eq!(
1059 args.interpolate("no placeholders here"),
1060 "no placeholders here"
1061 );
1062 assert_eq!(args.interpolate(""), "");
1063 }
1064
1065 #[test]
1066 fn test_resolved_args_interpolate_args_list() {
1067 let args = ResolvedArgs {
1068 positional: vec!["id123".into()],
1069 named: HashMap::new(),
1070 };
1071 let input = vec!["--id".into(), "{{0}}".into(), "--verbose".into()];
1072 let result = args.interpolate_args(&input);
1073 assert_eq!(result, vec!["--id", "id123", "--verbose"]);
1074 }
1075
1076 #[test]
1077 fn test_task_params_deserialization_with_flatten() {
1078 let json = r#"{
1080 "positional": [{"description": "Video ID", "required": true}],
1081 "quality": {"description": "Quality", "default": "1080p", "short": "q"},
1082 "verbose": {"description": "Verbose output", "type": "bool"}
1083 }"#;
1084 let params: TaskParams = serde_json::from_str(json).unwrap();
1085
1086 assert_eq!(params.positional.len(), 1);
1087 assert_eq!(
1088 params.positional[0].description,
1089 Some("Video ID".to_string())
1090 );
1091 assert!(params.positional[0].required);
1092
1093 assert_eq!(params.named.len(), 2);
1094 assert!(params.named.contains_key("quality"));
1095 assert!(params.named.contains_key("verbose"));
1096
1097 let quality = ¶ms.named["quality"];
1098 assert_eq!(quality.default, Some("1080p".to_string()));
1099 assert_eq!(quality.short, Some("q".to_string()));
1100
1101 let verbose = ¶ms.named["verbose"];
1102 assert_eq!(verbose.param_type, ParamType::Bool);
1103 }
1104
1105 #[test]
1106 fn test_task_params_empty() {
1107 let json = r#"{}"#;
1108 let params: TaskParams = serde_json::from_str(json).unwrap();
1109 assert!(params.positional.is_empty());
1110 assert!(params.named.is_empty());
1111 }
1112
1113 #[test]
1114 fn test_param_def_defaults() {
1115 let def = ParamDef::default();
1116 assert!(def.description.is_none());
1117 assert!(!def.required);
1118 assert!(def.default.is_none());
1119 assert_eq!(def.param_type, ParamType::String);
1120 assert!(def.short.is_none());
1121 }
1122}