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