1pub mod backend;
13pub mod executor;
14pub mod graph;
15pub mod index;
16pub mod io;
17
18pub use backend::{
20 BackendFactory, HostBackend, TaskBackend, create_backend, create_backend_with_factory,
21 should_use_dagger,
22};
23pub use executor::*;
24pub use graph::*;
25pub use index::{IndexedTask, TaskIndex, TaskPath, WorkspaceTask};
26
27use serde::{Deserialize, Serialize};
28use std::collections::HashMap;
29use std::path::Path;
30
31fn default_hermetic() -> bool {
32 true
33}
34
35#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
41#[serde(rename_all = "lowercase")]
42pub enum ScriptShell {
43 #[default]
44 Bash,
45 Sh,
46 Zsh,
47 Fish,
48 Powershell,
49 Pwsh,
50 Python,
51 Node,
52 Ruby,
53 Perl,
54}
55
56impl ScriptShell {
57 #[must_use]
59 pub fn command_and_flag(&self) -> (&'static str, &'static str) {
60 match self {
61 ScriptShell::Bash => ("bash", "-c"),
62 ScriptShell::Sh => ("sh", "-c"),
63 ScriptShell::Zsh => ("zsh", "-c"),
64 ScriptShell::Fish => ("fish", "-c"),
65 ScriptShell::Powershell => ("powershell", "-Command"),
66 ScriptShell::Pwsh => ("pwsh", "-Command"),
67 ScriptShell::Python => ("python", "-c"),
68 ScriptShell::Node => ("node", "-e"),
69 ScriptShell::Ruby => ("ruby", "-e"),
70 ScriptShell::Perl => ("perl", "-e"),
71 }
72 }
73
74 #[must_use]
76 pub fn supports_shell_options(&self) -> bool {
77 matches!(self, ScriptShell::Bash | ScriptShell::Sh | ScriptShell::Zsh)
78 }
79}
80
81#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
83pub struct ShellOptions {
84 #[serde(default = "default_true")]
86 pub errexit: bool,
87 #[serde(default = "default_true")]
89 pub nounset: bool,
90 #[serde(default = "default_true")]
92 pub pipefail: bool,
93 #[serde(default)]
95 pub xtrace: bool,
96}
97
98fn default_true() -> bool {
99 true
100}
101
102impl Default for ShellOptions {
103 fn default() -> Self {
104 Self {
105 errexit: true,
106 nounset: true,
107 pipefail: true,
108 xtrace: false,
109 }
110 }
111}
112
113impl ShellOptions {
114 #[must_use]
116 pub fn to_set_commands(&self) -> String {
117 let mut opts = Vec::new();
118 if self.errexit {
119 opts.push("-e");
120 }
121 if self.nounset {
122 opts.push("-u");
123 }
124 if self.pipefail {
125 opts.push("-o pipefail");
126 }
127 if self.xtrace {
128 opts.push("-x");
129 }
130 if opts.is_empty() {
131 String::new()
132 } else {
133 format!("set {}\n", opts.join(" "))
134 }
135 }
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
140pub struct Shell {
141 pub command: Option<String>,
143 pub flag: Option<String>,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
149pub struct Mapping {
150 pub from: String,
152 pub to: String,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
158#[serde(untagged)]
159pub enum Input {
160 Path(String),
162 Project(ProjectReference),
164 Task(TaskOutput),
166}
167
168impl Input {
169 pub fn as_path(&self) -> Option<&String> {
170 match self {
171 Input::Path(path) => Some(path),
172 Input::Project(_) | Input::Task(_) => None,
173 }
174 }
175
176 pub fn as_project(&self) -> Option<&ProjectReference> {
177 match self {
178 Input::Project(reference) => Some(reference),
179 Input::Path(_) | Input::Task(_) => None,
180 }
181 }
182
183 pub fn as_task_output(&self) -> Option<&TaskOutput> {
184 match self {
185 Input::Task(output) => Some(output),
186 Input::Path(_) | Input::Project(_) => None,
187 }
188 }
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
193pub struct ProjectReference {
194 pub project: String,
196 pub task: String,
198 pub map: Vec<Mapping>,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
204pub struct TaskOutput {
205 pub task: String,
207 #[serde(default)]
210 pub map: Option<Vec<Mapping>>,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
215pub struct SourceLocation {
216 pub file: String,
218 pub line: u32,
220 pub column: u32,
222}
223
224impl SourceLocation {
225 pub fn directory(&self) -> Option<&str> {
227 std::path::Path::new(&self.file)
228 .parent()
229 .and_then(|p| p.to_str())
230 .filter(|s| !s.is_empty())
231 }
232}
233
234#[derive(Debug, Clone, Serialize, PartialEq)]
242pub struct TaskDependency {
243 #[serde(rename = "_name")]
246 pub name: String,
247
248 #[serde(flatten)]
250 _rest: serde_json::Value,
251}
252
253impl<'de> serde::Deserialize<'de> for TaskDependency {
254 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
255 where
256 D: serde::Deserializer<'de>,
257 {
258 use serde::de::{self, Visitor};
259
260 struct TaskDependencyVisitor;
261
262 impl<'de> Visitor<'de> for TaskDependencyVisitor {
263 type Value = TaskDependency;
264
265 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
266 formatter.write_str("a string or an object with _name field")
267 }
268
269 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
270 where
271 E: de::Error,
272 {
273 Ok(TaskDependency::from_name(value))
274 }
275
276 fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
277 where
278 E: de::Error,
279 {
280 Ok(TaskDependency::from_name(value))
281 }
282
283 fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
284 where
285 M: de::MapAccess<'de>,
286 {
287 let value: serde_json::Value =
289 serde::Deserialize::deserialize(de::value::MapAccessDeserializer::new(map))?;
290
291 let name = value
292 .get("_name")
293 .and_then(|v| v.as_str())
294 .ok_or_else(|| de::Error::missing_field("_name"))?
295 .to_string();
296
297 Ok(TaskDependency { name, _rest: value })
298 }
299 }
300
301 deserializer.deserialize_any(TaskDependencyVisitor)
302 }
303}
304
305impl TaskDependency {
306 #[must_use]
308 pub fn from_name(name: impl Into<String>) -> Self {
309 Self {
310 name: name.into(),
311 _rest: serde_json::Value::Null,
312 }
313 }
314
315 #[must_use]
317 pub fn task_name(&self) -> &str {
318 &self.name
319 }
320
321 pub fn matches(&self, name: &str) -> bool {
323 self.name == name
324 }
325}
326
327#[derive(Debug, Clone, Serialize, PartialEq)]
338pub struct Task {
339 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub shell: Option<Shell>,
342
343 #[serde(default, skip_serializing_if = "String::is_empty")]
345 pub command: String,
346
347 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub script: Option<String>,
351
352 #[serde(
355 default,
356 rename = "scriptShell",
357 skip_serializing_if = "Option::is_none"
358 )]
359 pub script_shell: Option<ScriptShell>,
360
361 #[serde(
364 default,
365 rename = "shellOptions",
366 skip_serializing_if = "Option::is_none"
367 )]
368 pub shell_options: Option<ShellOptions>,
369
370 #[serde(default)]
372 pub args: Vec<String>,
373
374 #[serde(default)]
376 pub env: HashMap<String, serde_json::Value>,
377
378 #[serde(default)]
381 pub dagger: Option<DaggerTaskConfig>,
382
383 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub runtime: Option<crate::manifest::Runtime>,
386
387 #[serde(default = "default_hermetic")]
390 pub hermetic: bool,
391
392 #[serde(default, rename = "dependsOn")]
396 pub depends_on: Vec<TaskDependency>,
397
398 #[serde(default)]
400 pub inputs: Vec<Input>,
401
402 #[serde(default)]
404 pub outputs: Vec<String>,
405
406 #[serde(default)]
408 pub description: Option<String>,
409
410 #[serde(default)]
412 pub params: Option<TaskParams>,
413
414 #[serde(default)]
417 pub labels: Vec<String>,
418
419 #[serde(default, skip_serializing_if = "Option::is_none")]
421 pub timeout: Option<String>,
422
423 #[serde(default, skip_serializing_if = "Option::is_none")]
425 pub retry: Option<RetryConfig>,
426
427 #[serde(default, rename = "continueOnError")]
429 pub continue_on_error: bool,
430
431 #[serde(default, skip_serializing_if = "Option::is_none")]
435 pub task_ref: Option<String>,
436
437 #[serde(default, skip_serializing_if = "Option::is_none")]
440 pub project_root: Option<std::path::PathBuf>,
441
442 #[serde(default, rename = "_source", skip_serializing_if = "Option::is_none")]
445 pub source: Option<SourceLocation>,
446
447 #[serde(default, rename = "dir", skip_serializing_if = "Option::is_none")]
450 pub directory: Option<String>,
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
455pub struct RetryConfig {
456 #[serde(default = "default_retry_attempts")]
458 pub attempts: u32,
459 #[serde(default, skip_serializing_if = "Option::is_none")]
461 pub delay: Option<String>,
462}
463
464fn default_retry_attempts() -> u32 {
465 3
466}
467
468impl<'de> serde::Deserialize<'de> for Task {
471 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
472 where
473 D: serde::Deserializer<'de>,
474 {
475 #[derive(serde::Deserialize)]
477 struct TaskHelper {
478 #[serde(default)]
479 shell: Option<Shell>,
480 #[serde(default)]
481 command: Option<String>,
482 #[serde(default)]
483 script: Option<String>,
484 #[serde(default, rename = "scriptShell")]
485 script_shell: Option<ScriptShell>,
486 #[serde(default, rename = "shellOptions")]
487 shell_options: Option<ShellOptions>,
488 #[serde(default)]
489 args: Vec<String>,
490 #[serde(default)]
491 env: HashMap<String, serde_json::Value>,
492 #[serde(default)]
493 dagger: Option<DaggerTaskConfig>,
494 #[serde(default)]
495 runtime: Option<crate::manifest::Runtime>,
496 #[serde(default = "default_hermetic")]
497 hermetic: bool,
498 #[serde(default, rename = "dependsOn")]
499 depends_on: Vec<TaskDependency>,
500 #[serde(default)]
501 inputs: Vec<Input>,
502 #[serde(default)]
503 outputs: Vec<String>,
504 #[serde(default)]
505 description: Option<String>,
506 #[serde(default)]
507 params: Option<TaskParams>,
508 #[serde(default)]
509 labels: Vec<String>,
510 #[serde(default)]
511 timeout: Option<String>,
512 #[serde(default)]
513 retry: Option<RetryConfig>,
514 #[serde(default, rename = "continueOnError")]
515 continue_on_error: bool,
516 #[serde(default)]
517 task_ref: Option<String>,
518 #[serde(default)]
519 project_root: Option<std::path::PathBuf>,
520 #[serde(default, rename = "_source")]
521 source: Option<SourceLocation>,
522 #[serde(default, rename = "dir")]
523 directory: Option<String>,
524 }
525
526 let helper = TaskHelper::deserialize(deserializer)?;
527
528 let has_command = helper.command.as_ref().is_some_and(|c| !c.is_empty());
530 let has_script = helper.script.is_some();
531 let has_task_ref = helper.task_ref.is_some();
532
533 if !has_command && !has_script && !has_task_ref {
534 return Err(serde::de::Error::custom(
535 "Task must have either 'command', 'script', or 'task_ref' field",
536 ));
537 }
538
539 Ok(Task {
540 shell: helper.shell,
541 command: helper.command.unwrap_or_default(),
542 script: helper.script,
543 script_shell: helper.script_shell,
544 shell_options: helper.shell_options,
545 args: helper.args,
546 env: helper.env,
547 dagger: helper.dagger,
548 runtime: helper.runtime,
549 hermetic: helper.hermetic,
550 depends_on: helper.depends_on,
551 inputs: helper.inputs,
552 outputs: helper.outputs,
553 description: helper.description,
554 params: helper.params,
555 labels: helper.labels,
556 timeout: helper.timeout,
557 retry: helper.retry,
558 continue_on_error: helper.continue_on_error,
559 task_ref: helper.task_ref,
560 project_root: helper.project_root,
561 source: helper.source,
562 directory: helper.directory,
563 })
564 }
565}
566
567#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
569pub struct DaggerTaskConfig {
570 #[serde(default)]
573 pub image: Option<String>,
574
575 #[serde(default)]
578 pub from: Option<String>,
579
580 #[serde(default)]
583 pub secrets: Option<Vec<DaggerSecret>>,
584
585 #[serde(default)]
587 pub cache: Option<Vec<DaggerCacheMount>>,
588}
589
590#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
592pub struct DaggerSecret {
593 pub name: String,
595
596 #[serde(default)]
598 pub path: Option<String>,
599
600 #[serde(default, rename = "envVar")]
602 pub env_var: Option<String>,
603
604 pub resolver: crate::secrets::Secret,
606}
607
608#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
610pub struct DaggerCacheMount {
611 pub path: String,
613
614 pub name: String,
616}
617
618#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
620pub struct TaskParams {
621 #[serde(default)]
624 pub positional: Vec<ParamDef>,
625
626 #[serde(flatten, default)]
629 pub named: HashMap<String, ParamDef>,
630}
631
632#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
634#[serde(rename_all = "lowercase")]
635pub enum ParamType {
636 #[default]
637 String,
638 Bool,
639 Int,
640}
641
642#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
644pub struct ParamDef {
645 #[serde(default)]
647 pub description: Option<String>,
648
649 #[serde(default)]
651 pub required: bool,
652
653 #[serde(default)]
655 pub default: Option<String>,
656
657 #[serde(default, rename = "type")]
659 pub param_type: ParamType,
660
661 #[serde(default)]
663 pub short: Option<String>,
664}
665
666#[derive(Debug, Clone, Default)]
668pub struct ResolvedArgs {
669 pub positional: Vec<String>,
671 pub named: HashMap<String, String>,
673}
674
675impl ResolvedArgs {
676 pub fn new() -> Self {
678 Self::default()
679 }
680
681 pub fn interpolate(&self, template: &str) -> String {
684 let mut result = template.to_string();
685
686 for (i, value) in self.positional.iter().enumerate() {
688 let placeholder = format!("{{{{{}}}}}", i);
689 result = result.replace(&placeholder, value);
690 }
691
692 for (name, value) in &self.named {
694 let placeholder = format!("{{{{{}}}}}", name);
695 result = result.replace(&placeholder, value);
696 }
697
698 result
699 }
700
701 pub fn interpolate_args(&self, args: &[String]) -> Vec<String> {
703 args.iter().map(|arg| self.interpolate(arg)).collect()
704 }
705}
706
707impl Default for Task {
708 fn default() -> Self {
709 Self {
710 shell: None,
711 command: String::new(),
712 script: None,
713 script_shell: None,
714 shell_options: None,
715 args: vec![],
716 env: HashMap::new(),
717 dagger: None,
718 runtime: None,
719 hermetic: true, depends_on: vec![],
721 inputs: vec![],
722 outputs: vec![],
723 description: None,
724 params: None,
725 labels: vec![],
726 timeout: None,
727 retry: None,
728 continue_on_error: false,
729 task_ref: None,
730 project_root: None,
731 source: None,
732 directory: None,
733 }
734 }
735}
736
737impl Task {
738 pub fn from_task_ref(ref_str: &str) -> Self {
741 Self {
742 task_ref: Some(ref_str.to_string()),
743 description: Some(format!("Reference to {}", ref_str)),
744 ..Default::default()
745 }
746 }
747
748 pub fn is_task_ref(&self) -> bool {
750 self.task_ref.is_some()
751 }
752
753 pub fn dependency_names(&self) -> impl Iterator<Item = &str> {
755 self.depends_on.iter().map(|d| d.task_name())
756 }
757
758 pub fn description(&self) -> &str {
760 self.description
761 .as_deref()
762 .unwrap_or("No description provided")
763 }
764
765 pub fn iter_path_inputs(&self) -> impl Iterator<Item = &String> {
767 self.inputs.iter().filter_map(Input::as_path)
768 }
769
770 pub fn iter_project_refs(&self) -> impl Iterator<Item = &ProjectReference> {
772 self.inputs.iter().filter_map(Input::as_project)
773 }
774
775 pub fn iter_task_outputs(&self) -> impl Iterator<Item = &TaskOutput> {
777 self.inputs.iter().filter_map(Input::as_task_output)
778 }
779
780 pub fn collect_path_inputs_with_prefix(&self, prefix: Option<&Path>) -> Vec<String> {
782 self.iter_path_inputs()
783 .map(|path| apply_prefix(prefix, path))
784 .collect()
785 }
786
787 pub fn collect_project_destinations_with_prefix(&self, prefix: Option<&Path>) -> Vec<String> {
789 self.iter_project_refs()
790 .flat_map(|reference| reference.map.iter().map(|m| apply_prefix(prefix, &m.to)))
791 .collect()
792 }
793
794 pub fn collect_all_inputs_with_prefix(&self, prefix: Option<&Path>) -> Vec<String> {
796 let mut inputs = self.collect_path_inputs_with_prefix(prefix);
797 inputs.extend(self.collect_project_destinations_with_prefix(prefix));
798 inputs
799 }
800}
801
802impl crate::AffectedBy for Task {
803 fn is_affected_by(&self, changed_files: &[std::path::PathBuf], project_root: &Path) -> bool {
810 let inputs: Vec<_> = self.iter_path_inputs().collect();
811
812 if inputs.is_empty() {
814 return true;
815 }
816
817 inputs
819 .iter()
820 .any(|pattern| crate::matches_pattern(changed_files, project_root, pattern))
821 }
822
823 fn input_patterns(&self) -> Vec<&str> {
824 self.iter_path_inputs().map(String::as_str).collect()
825 }
826}
827
828fn apply_prefix(prefix: Option<&Path>, value: &str) -> String {
829 if let Some(prefix) = prefix {
830 prefix.join(value).to_string_lossy().to_string()
831 } else {
832 value.to_string()
833 }
834}
835
836#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
844pub struct TaskGroup {
845 #[serde(rename = "type")]
847 pub type_: String,
848
849 #[serde(default, rename = "dependsOn")]
851 pub depends_on: Vec<TaskDependency>,
852
853 #[serde(default, rename = "maxConcurrency")]
855 pub max_concurrency: Option<u32>,
856
857 #[serde(default)]
859 pub description: Option<String>,
860
861 #[serde(flatten)]
863 pub children: HashMap<String, TaskNode>,
864}
865
866#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
885#[serde(untagged)]
886pub enum TaskNode {
887 Task(Box<Task>),
889 Group(TaskGroup),
891 Sequence(Vec<TaskNode>),
893}
894
895#[deprecated(since = "0.26.0", note = "Use TaskNode instead")]
901pub type TaskDefinition = TaskNode;
902
903#[deprecated(since = "0.26.0", note = "Use Vec<TaskNode> directly")]
905pub type TaskList = Vec<TaskNode>;
906
907#[derive(Debug, Clone, Serialize, Deserialize, Default)]
909pub struct Tasks {
910 #[serde(flatten)]
912 pub tasks: HashMap<String, TaskNode>,
913}
914
915impl Tasks {
916 pub fn new() -> Self {
918 Self::default()
919 }
920
921 pub fn get(&self, name: &str) -> Option<&TaskNode> {
923 self.tasks.get(name)
924 }
925
926 pub fn list_tasks(&self) -> Vec<&str> {
928 self.tasks.keys().map(|s| s.as_str()).collect()
929 }
930
931 pub fn contains(&self, name: &str) -> bool {
933 self.tasks.contains_key(name)
934 }
935}
936
937impl TaskNode {
938 pub fn is_task(&self) -> bool {
940 matches!(self, TaskNode::Task(_))
941 }
942
943 pub fn is_group(&self) -> bool {
945 matches!(self, TaskNode::Group(_))
946 }
947
948 pub fn is_sequence(&self) -> bool {
950 matches!(self, TaskNode::Sequence(_))
951 }
952
953 pub fn as_task(&self) -> Option<&Task> {
955 match self {
956 TaskNode::Task(task) => Some(task.as_ref()),
957 _ => None,
958 }
959 }
960
961 pub fn as_group(&self) -> Option<&TaskGroup> {
963 match self {
964 TaskNode::Group(group) => Some(group),
965 _ => None,
966 }
967 }
968
969 pub fn as_sequence(&self) -> Option<&Vec<TaskNode>> {
971 match self {
972 TaskNode::Sequence(seq) => Some(seq),
973 _ => None,
974 }
975 }
976
977 pub fn depends_on(&self) -> &[TaskDependency] {
979 match self {
980 TaskNode::Task(task) => &task.depends_on,
981 TaskNode::Group(group) => &group.depends_on,
982 TaskNode::Sequence(_) => &[], }
984 }
985
986 pub fn description(&self) -> Option<&str> {
988 match self {
989 TaskNode::Task(task) => task.description.as_deref(),
990 TaskNode::Group(group) => group.description.as_deref(),
991 TaskNode::Sequence(_) => None, }
993 }
994
995 #[deprecated(since = "0.26.0", note = "Use is_task() instead")]
997 pub fn is_single(&self) -> bool {
998 self.is_task()
999 }
1000
1001 #[deprecated(since = "0.26.0", note = "Use as_task() instead")]
1002 pub fn as_single(&self) -> Option<&Task> {
1003 self.as_task()
1004 }
1005
1006 #[deprecated(since = "0.26.0", note = "Use is_sequence() instead")]
1007 pub fn is_list(&self) -> bool {
1008 self.is_sequence()
1009 }
1010
1011 #[deprecated(since = "0.26.0", note = "Use as_sequence() instead")]
1012 pub fn as_list(&self) -> Option<&Vec<TaskNode>> {
1013 self.as_sequence()
1014 }
1015}
1016
1017impl TaskGroup {
1018 pub fn len(&self) -> usize {
1020 self.children.len()
1021 }
1022
1023 pub fn is_empty(&self) -> bool {
1025 self.children.is_empty()
1026 }
1027}
1028
1029impl crate::AffectedBy for TaskGroup {
1030 fn is_affected_by(&self, changed_files: &[std::path::PathBuf], project_root: &Path) -> bool {
1032 self.children
1033 .values()
1034 .any(|node| node.is_affected_by(changed_files, project_root))
1035 }
1036
1037 fn input_patterns(&self) -> Vec<&str> {
1038 self.children
1039 .values()
1040 .flat_map(|node| node.input_patterns())
1041 .collect()
1042 }
1043}
1044
1045impl crate::AffectedBy for TaskNode {
1046 fn is_affected_by(&self, changed_files: &[std::path::PathBuf], project_root: &Path) -> bool {
1047 match self {
1048 TaskNode::Task(task) => task.is_affected_by(changed_files, project_root),
1049 TaskNode::Group(group) => group.is_affected_by(changed_files, project_root),
1050 TaskNode::Sequence(seq) => seq
1051 .iter()
1052 .any(|node| node.is_affected_by(changed_files, project_root)),
1053 }
1054 }
1055
1056 fn input_patterns(&self) -> Vec<&str> {
1057 match self {
1058 TaskNode::Task(task) => task.input_patterns(),
1059 TaskNode::Group(group) => group.input_patterns(),
1060 TaskNode::Sequence(seq) => seq.iter().flat_map(|node| node.input_patterns()).collect(),
1061 }
1062 }
1063}
1064
1065#[cfg(test)]
1066mod tests {
1067 use super::*;
1068
1069 #[test]
1070 fn test_task_default_values() {
1071 let task = Task {
1072 command: "echo".to_string(),
1073 ..Default::default()
1074 };
1075
1076 assert!(task.shell.is_none());
1077 assert_eq!(task.command, "echo");
1078 assert_eq!(task.description(), "No description provided");
1079 assert!(task.args.is_empty());
1080 assert!(task.hermetic); }
1082
1083 #[test]
1084 fn test_task_deserialization() {
1085 let json = r#"{
1086 "command": "echo",
1087 "args": ["Hello", "World"]
1088 }"#;
1089
1090 let task: Task = serde_json::from_str(json).unwrap();
1091 assert_eq!(task.command, "echo");
1092 assert_eq!(task.args, vec!["Hello", "World"]);
1093 assert!(task.shell.is_none()); }
1095
1096 #[test]
1097 fn test_task_script_deserialization() {
1098 let json = r#"{
1100 "script": "echo hello",
1101 "inputs": ["src/main.rs"]
1102 }"#;
1103
1104 let task: Task = serde_json::from_str(json).unwrap();
1105 assert!(task.command.is_empty()); assert_eq!(task.script, Some("echo hello".to_string()));
1107 assert_eq!(task.inputs.len(), 1);
1108 }
1109
1110 #[test]
1111 fn test_task_node_script_variant() {
1112 let json = r#"{
1114 "script": "echo hello"
1115 }"#;
1116
1117 let node: TaskNode = serde_json::from_str(json).unwrap();
1118 assert!(node.is_task());
1119 }
1120
1121 #[test]
1122 fn test_task_group_with_script_task() {
1123 let json = r#"{
1126 "type": "group",
1127 "linux": {
1128 "script": "echo building",
1129 "inputs": ["src/main.rs"]
1130 }
1131 }"#;
1132
1133 let group: TaskGroup = serde_json::from_str(json).unwrap();
1134 assert_eq!(group.len(), 1);
1135 }
1136
1137 #[test]
1138 fn test_full_tasks_map_with_script() {
1139 let json = r#"{
1142 "pwd": { "command": "pwd" },
1143 "cross": {
1144 "type": "group",
1145 "linux": {
1146 "script": "echo building",
1147 "inputs": ["src/main.rs"]
1148 }
1149 }
1150 }"#;
1151
1152 let tasks: HashMap<String, TaskNode> = serde_json::from_str(json).unwrap();
1153 assert_eq!(tasks.len(), 2);
1154 assert!(tasks.contains_key("pwd"));
1155 assert!(tasks.contains_key("cross"));
1156
1157 assert!(tasks.get("pwd").unwrap().is_task());
1159
1160 assert!(tasks.get("cross").unwrap().is_group());
1162 }
1163
1164 #[test]
1165 fn test_complex_nested_tasks_like_cuenv() {
1166 let json = r#"{
1169 "pwd": { "command": "pwd" },
1170 "check": {
1171 "command": "nix",
1172 "args": ["flake", "check"],
1173 "inputs": ["flake.nix"]
1174 },
1175 "fmt": {
1176 "type": "group",
1177 "fix": {
1178 "command": "treefmt",
1179 "inputs": [".config"]
1180 },
1181 "check": {
1182 "command": "treefmt",
1183 "args": ["--fail-on-change"],
1184 "inputs": [".config"]
1185 }
1186 },
1187 "cross": {
1188 "type": "group",
1189 "linux": {
1190 "script": "echo building",
1191 "inputs": ["Cargo.toml"]
1192 }
1193 },
1194 "docs": {
1195 "type": "group",
1196 "build": {
1197 "command": "bash",
1198 "args": ["-c", "bun install"],
1199 "inputs": ["docs"],
1200 "outputs": ["docs/dist"]
1201 },
1202 "deploy": {
1203 "command": "bash",
1204 "args": ["-c", "wrangler deploy"],
1205 "dependsOn": ["docs.build"],
1206 "inputs": [{"task": "docs.build"}]
1207 }
1208 }
1209 }"#;
1210
1211 let result: Result<HashMap<String, TaskNode>, _> = serde_json::from_str(json);
1212 match result {
1213 Ok(tasks) => {
1214 assert_eq!(tasks.len(), 5);
1215 assert!(tasks.get("pwd").unwrap().is_task());
1216 assert!(tasks.get("check").unwrap().is_task());
1217 assert!(tasks.get("fmt").unwrap().is_group());
1218 assert!(tasks.get("cross").unwrap().is_group());
1219 assert!(tasks.get("docs").unwrap().is_group());
1220 }
1221 Err(e) => {
1222 panic!("Failed to deserialize complex tasks: {}", e);
1223 }
1224 }
1225 }
1226
1227 #[test]
1228 fn test_task_list_sequential() {
1229 let task1 = Task {
1230 command: "echo".to_string(),
1231 args: vec!["first".to_string()],
1232 description: Some("First task".to_string()),
1233 ..Default::default()
1234 };
1235
1236 let task2 = Task {
1237 command: "echo".to_string(),
1238 args: vec!["second".to_string()],
1239 description: Some("Second task".to_string()),
1240 ..Default::default()
1241 };
1242
1243 let sequence: Vec<TaskNode> = vec![
1244 TaskNode::Task(Box::new(task1)),
1245 TaskNode::Task(Box::new(task2)),
1246 ];
1247
1248 assert_eq!(sequence.len(), 2);
1249 assert!(!sequence.is_empty());
1250 }
1251
1252 #[test]
1253 fn test_task_group_parallel() {
1254 let task1 = Task {
1255 command: "echo".to_string(),
1256 args: vec!["task1".to_string()],
1257 description: Some("Task 1".to_string()),
1258 ..Default::default()
1259 };
1260
1261 let task2 = Task {
1262 command: "echo".to_string(),
1263 args: vec!["task2".to_string()],
1264 description: Some("Task 2".to_string()),
1265 ..Default::default()
1266 };
1267
1268 let mut parallel_tasks = HashMap::new();
1269 parallel_tasks.insert("task1".to_string(), TaskNode::Task(Box::new(task1)));
1270 parallel_tasks.insert("task2".to_string(), TaskNode::Task(Box::new(task2)));
1271
1272 let group = TaskGroup {
1273 type_: "group".to_string(),
1274 children: parallel_tasks,
1275 depends_on: vec![],
1276 max_concurrency: None,
1277 description: None,
1278 };
1279
1280 assert_eq!(group.len(), 2);
1281 assert!(!group.is_empty());
1282 }
1283
1284 #[test]
1285 fn test_tasks_collection() {
1286 let mut tasks = Tasks::new();
1287 assert!(tasks.list_tasks().is_empty());
1288
1289 let task = Task {
1290 command: "echo".to_string(),
1291 args: vec!["hello".to_string()],
1292 description: Some("Hello task".to_string()),
1293 ..Default::default()
1294 };
1295
1296 tasks
1297 .tasks
1298 .insert("greet".to_string(), TaskNode::Task(Box::new(task)));
1299
1300 assert!(tasks.contains("greet"));
1301 assert!(!tasks.contains("nonexistent"));
1302 assert_eq!(tasks.list_tasks(), vec!["greet"]);
1303
1304 let retrieved = tasks.get("greet").unwrap();
1305 assert!(retrieved.is_task());
1306 }
1307
1308 #[test]
1309 fn test_task_node_helpers() {
1310 let task = Task {
1311 command: "test".to_string(),
1312 description: Some("Test task".to_string()),
1313 ..Default::default()
1314 };
1315
1316 let task_node = TaskNode::Task(Box::new(task.clone()));
1317 assert!(task_node.is_task());
1318 assert!(!task_node.is_group());
1319 assert!(!task_node.is_sequence());
1320 assert_eq!(task_node.as_task().unwrap().command, "test");
1321 assert!(task_node.as_group().is_none());
1322 assert!(task_node.as_sequence().is_none());
1323
1324 let group = TaskNode::Group(TaskGroup {
1325 type_: "group".to_string(),
1326 children: HashMap::new(),
1327 depends_on: vec![],
1328 max_concurrency: None,
1329 description: None,
1330 });
1331 assert!(!group.is_task());
1332 assert!(group.is_group());
1333 assert!(!group.is_sequence());
1334 assert!(group.as_task().is_none());
1335 assert!(group.as_group().is_some());
1336
1337 let sequence = TaskNode::Sequence(vec![]);
1338 assert!(!sequence.is_task());
1339 assert!(!sequence.is_group());
1340 assert!(sequence.is_sequence());
1341 assert!(sequence.as_sequence().is_some());
1342 }
1343
1344 #[test]
1345 fn test_script_shell_command_and_flag() {
1346 assert_eq!(ScriptShell::Bash.command_and_flag(), ("bash", "-c"));
1347 assert_eq!(ScriptShell::Python.command_and_flag(), ("python", "-c"));
1348 assert_eq!(ScriptShell::Node.command_and_flag(), ("node", "-e"));
1349 assert_eq!(
1350 ScriptShell::Powershell.command_and_flag(),
1351 ("powershell", "-Command")
1352 );
1353 }
1354
1355 #[test]
1356 fn test_shell_options_default() {
1357 let opts = ShellOptions::default();
1358 assert!(opts.errexit);
1359 assert!(opts.nounset);
1360 assert!(opts.pipefail);
1361 assert!(!opts.xtrace);
1362 }
1363
1364 #[test]
1365 fn test_shell_options_to_set_commands() {
1366 let opts = ShellOptions::default();
1367 assert_eq!(opts.to_set_commands(), "set -e -u -o pipefail\n");
1368
1369 let debug_opts = ShellOptions {
1370 errexit: true,
1371 nounset: false,
1372 pipefail: true,
1373 xtrace: true,
1374 };
1375 assert_eq!(debug_opts.to_set_commands(), "set -e -o pipefail -x\n");
1376
1377 let no_opts = ShellOptions {
1378 errexit: false,
1379 nounset: false,
1380 pipefail: false,
1381 xtrace: false,
1382 };
1383 assert_eq!(no_opts.to_set_commands(), "");
1384 }
1385
1386 #[test]
1387 fn test_input_deserialization_variants() {
1388 let path_json = r#""src/**/*.rs""#;
1389 let path_input: Input = serde_json::from_str(path_json).unwrap();
1390 assert_eq!(path_input, Input::Path("src/**/*.rs".to_string()));
1391
1392 let project_json = r#"{
1393 "project": "../projB",
1394 "task": "build",
1395 "map": [{"from": "dist/app.txt", "to": "vendor/app.txt"}]
1396 }"#;
1397 let project_input: Input = serde_json::from_str(project_json).unwrap();
1398 match project_input {
1399 Input::Project(reference) => {
1400 assert_eq!(reference.project, "../projB");
1401 assert_eq!(reference.task, "build");
1402 assert_eq!(reference.map.len(), 1);
1403 assert_eq!(reference.map[0].from, "dist/app.txt");
1404 assert_eq!(reference.map[0].to, "vendor/app.txt");
1405 }
1406 other => panic!("Expected project reference, got {:?}", other),
1407 }
1408
1409 let task_json = r#"{"task": "build.deps"}"#;
1411 let task_input: Input = serde_json::from_str(task_json).unwrap();
1412 match task_input {
1413 Input::Task(output) => {
1414 assert_eq!(output.task, "build.deps");
1415 assert!(output.map.is_none());
1416 }
1417 other => panic!("Expected task output reference, got {:?}", other),
1418 }
1419 }
1420
1421 #[test]
1422 fn test_task_input_helpers_collect() {
1423 use std::collections::HashSet;
1424 use std::path::Path;
1425
1426 let task = Task {
1427 inputs: vec![
1428 Input::Path("src".into()),
1429 Input::Project(ProjectReference {
1430 project: "../projB".into(),
1431 task: "build".into(),
1432 map: vec![Mapping {
1433 from: "dist/app.txt".into(),
1434 to: "vendor/app.txt".into(),
1435 }],
1436 }),
1437 ],
1438 ..Default::default()
1439 };
1440
1441 let path_inputs: Vec<String> = task.iter_path_inputs().cloned().collect();
1442 assert_eq!(path_inputs, vec!["src".to_string()]);
1443
1444 let project_refs: Vec<&ProjectReference> = task.iter_project_refs().collect();
1445 assert_eq!(project_refs.len(), 1);
1446 assert_eq!(project_refs[0].project, "../projB");
1447
1448 let prefix = Path::new("prefix");
1449 let collected = task.collect_all_inputs_with_prefix(Some(prefix));
1450 let collected: HashSet<_> = collected
1451 .into_iter()
1452 .map(std::path::PathBuf::from)
1453 .collect();
1454 let expected: HashSet<_> = ["src", "vendor/app.txt"]
1455 .into_iter()
1456 .map(|p| prefix.join(p))
1457 .collect();
1458 assert_eq!(collected, expected);
1459 }
1460
1461 #[test]
1462 fn test_resolved_args_interpolate_positional() {
1463 let args = ResolvedArgs {
1464 positional: vec!["video123".into(), "1080p".into()],
1465 named: HashMap::new(),
1466 };
1467 assert_eq!(args.interpolate("{{0}}"), "video123");
1468 assert_eq!(args.interpolate("{{1}}"), "1080p");
1469 assert_eq!(args.interpolate("--id={{0}}"), "--id=video123");
1470 assert_eq!(args.interpolate("{{0}}-{{1}}"), "video123-1080p");
1471 }
1472
1473 #[test]
1474 fn test_resolved_args_interpolate_named() {
1475 let mut named = HashMap::new();
1476 named.insert("url".into(), "https://example.com".into());
1477 named.insert("quality".into(), "720p".into());
1478 let args = ResolvedArgs {
1479 positional: vec![],
1480 named,
1481 };
1482 assert_eq!(args.interpolate("{{url}}"), "https://example.com");
1483 assert_eq!(args.interpolate("--quality={{quality}}"), "--quality=720p");
1484 }
1485
1486 #[test]
1487 fn test_resolved_args_interpolate_mixed() {
1488 let mut named = HashMap::new();
1489 named.insert("format".into(), "mp4".into());
1490 let args = ResolvedArgs {
1491 positional: vec!["VIDEO_ID".into()],
1492 named,
1493 };
1494 assert_eq!(
1495 args.interpolate("download {{0}} --format={{format}}"),
1496 "download VIDEO_ID --format=mp4"
1497 );
1498 }
1499
1500 #[test]
1501 fn test_resolved_args_no_placeholder_unchanged() {
1502 let args = ResolvedArgs::new();
1503 assert_eq!(
1504 args.interpolate("no placeholders here"),
1505 "no placeholders here"
1506 );
1507 assert_eq!(args.interpolate(""), "");
1508 }
1509
1510 #[test]
1511 fn test_resolved_args_interpolate_args_list() {
1512 let args = ResolvedArgs {
1513 positional: vec!["id123".into()],
1514 named: HashMap::new(),
1515 };
1516 let input = vec!["--id".into(), "{{0}}".into(), "--verbose".into()];
1517 let result = args.interpolate_args(&input);
1518 assert_eq!(result, vec!["--id", "id123", "--verbose"]);
1519 }
1520
1521 #[test]
1522 fn test_task_params_deserialization_with_flatten() {
1523 let json = r#"{
1525 "positional": [{"description": "Video ID", "required": true}],
1526 "quality": {"description": "Quality", "default": "1080p", "short": "q"},
1527 "verbose": {"description": "Verbose output", "type": "bool"}
1528 }"#;
1529 let params: TaskParams = serde_json::from_str(json).unwrap();
1530
1531 assert_eq!(params.positional.len(), 1);
1532 assert_eq!(
1533 params.positional[0].description,
1534 Some("Video ID".to_string())
1535 );
1536 assert!(params.positional[0].required);
1537
1538 assert_eq!(params.named.len(), 2);
1539 assert!(params.named.contains_key("quality"));
1540 assert!(params.named.contains_key("verbose"));
1541
1542 let quality = ¶ms.named["quality"];
1543 assert_eq!(quality.default, Some("1080p".to_string()));
1544 assert_eq!(quality.short, Some("q".to_string()));
1545
1546 let verbose = ¶ms.named["verbose"];
1547 assert_eq!(verbose.param_type, ParamType::Bool);
1548 }
1549
1550 #[test]
1551 fn test_task_params_empty() {
1552 let json = r#"{}"#;
1553 let params: TaskParams = serde_json::from_str(json).unwrap();
1554 assert!(params.positional.is_empty());
1555 assert!(params.named.is_empty());
1556 }
1557
1558 #[test]
1559 fn test_param_def_defaults() {
1560 let def = ParamDef::default();
1561 assert!(def.description.is_none());
1562 assert!(!def.required);
1563 assert!(def.default.is_none());
1564 assert_eq!(def.param_type, ParamType::String);
1565 assert!(def.short.is_none());
1566 }
1567
1568 mod affected_tests {
1573 use super::*;
1574 use crate::AffectedBy;
1575 use std::path::PathBuf;
1576
1577 fn make_task(inputs: Vec<&str>) -> Task {
1578 Task {
1579 inputs: inputs
1580 .into_iter()
1581 .map(|s| Input::Path(s.to_string()))
1582 .collect(),
1583 command: "echo test".to_string(),
1584 ..Default::default()
1585 }
1586 }
1587
1588 #[test]
1589 fn test_task_no_inputs_always_affected() {
1590 let task = make_task(vec![]);
1591 let changed_files: Vec<PathBuf> = vec![];
1592 let root = Path::new(".");
1593
1594 assert!(task.is_affected_by(&changed_files, root));
1596 }
1597
1598 #[test]
1599 fn test_task_with_inputs_matching() {
1600 let task = make_task(vec!["src/**"]);
1601 let changed_files = vec![PathBuf::from("src/lib.rs")];
1602 let root = Path::new(".");
1603
1604 assert!(task.is_affected_by(&changed_files, root));
1605 }
1606
1607 #[test]
1608 fn test_task_with_inputs_not_matching() {
1609 let task = make_task(vec!["src/**"]);
1610 let changed_files = vec![PathBuf::from("docs/readme.md")];
1611 let root = Path::new(".");
1612
1613 assert!(!task.is_affected_by(&changed_files, root));
1614 }
1615
1616 #[test]
1617 fn test_task_with_project_root_path_normalization() {
1618 let task = make_task(vec!["src/**"]);
1619 let changed_files = vec![PathBuf::from("projects/website/src/app.rs")];
1621 let root = Path::new("projects/website");
1622
1623 assert!(task.is_affected_by(&changed_files, root));
1624 }
1625
1626 #[test]
1627 fn test_task_node_delegates_to_task() {
1628 let task = make_task(vec!["src/**"]);
1629 let node = TaskNode::Task(Box::new(task));
1630 let changed_files = vec![PathBuf::from("src/lib.rs")];
1631 let root = Path::new(".");
1632
1633 assert!(node.is_affected_by(&changed_files, root));
1634 }
1635
1636 #[test]
1637 fn test_task_group_any_affected() {
1638 let lint_task = make_task(vec!["src/**"]);
1639 let test_task = make_task(vec!["tests/**"]);
1640
1641 let mut parallel_tasks = HashMap::new();
1642 parallel_tasks.insert("lint".to_string(), TaskNode::Task(Box::new(lint_task)));
1643 parallel_tasks.insert("test".to_string(), TaskNode::Task(Box::new(test_task)));
1644
1645 let group = TaskGroup {
1646 type_: "group".to_string(),
1647 children: parallel_tasks,
1648 depends_on: vec![],
1649 max_concurrency: None,
1650 description: None,
1651 };
1652
1653 let changed_files = vec![PathBuf::from("src/lib.rs")];
1655 let root = Path::new(".");
1656
1657 assert!(group.is_affected_by(&changed_files, root));
1658 }
1659
1660 #[test]
1661 fn test_task_group_none_affected() {
1662 let lint_task = make_task(vec!["src/**"]);
1663 let test_task = make_task(vec!["tests/**"]);
1664
1665 let mut parallel_tasks = HashMap::new();
1666 parallel_tasks.insert("lint".to_string(), TaskNode::Task(Box::new(lint_task)));
1667 parallel_tasks.insert("test".to_string(), TaskNode::Task(Box::new(test_task)));
1668
1669 let group = TaskGroup {
1670 type_: "group".to_string(),
1671 children: parallel_tasks,
1672 depends_on: vec![],
1673 max_concurrency: None,
1674 description: None,
1675 };
1676
1677 let changed_files = vec![PathBuf::from("docs/readme.md")];
1679 let root = Path::new(".");
1680
1681 assert!(!group.is_affected_by(&changed_files, root));
1682 }
1683
1684 #[test]
1685 fn test_task_sequence_any_affected() {
1686 let build_task = make_task(vec!["src/**"]);
1687 let deploy_task = make_task(vec!["deploy/**"]);
1688
1689 let sequence = TaskNode::Sequence(vec![
1690 TaskNode::Task(Box::new(build_task)),
1691 TaskNode::Task(Box::new(deploy_task)),
1692 ]);
1693
1694 let changed_files = vec![PathBuf::from("src/lib.rs")];
1696 let root = Path::new(".");
1697
1698 assert!(sequence.is_affected_by(&changed_files, root));
1699 }
1700
1701 #[test]
1702 fn test_input_patterns_returns_patterns() {
1703 let task = make_task(vec!["src/**", "Cargo.toml"]);
1704 let patterns = task.input_patterns();
1705
1706 assert_eq!(patterns.len(), 2);
1707 assert!(patterns.contains(&"src/**"));
1708 assert!(patterns.contains(&"Cargo.toml"));
1709 }
1710 }
1711}