1pub mod backend;
13pub mod cache;
14pub mod captures;
15pub mod executor;
16pub mod graph;
17pub mod index;
18pub mod output_refs;
19pub mod process_registry;
20
21pub use backend::{
23 BackendFactory, HostBackend, TaskBackend, TaskExecutionContext, create_backend,
24 create_backend_with_factory, should_use_dagger,
25};
26pub use executor::*;
27pub use graph::*;
28pub use index::{IndexedTask, TaskIndex, TaskPath, WorkspaceTask};
29pub use output_refs::{
30 OutputRefResolver, TaskOutputField, TaskOutputRef, has_output_refs, process_output_refs,
31};
32pub use process_registry::global_registry;
33
34use serde::{Deserialize, Serialize};
35use std::collections::HashMap;
36use std::path::Path;
37
38fn default_hermetic() -> bool {
39 true
40}
41
42#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
48#[serde(rename_all = "kebab-case")]
49pub enum TaskCacheMode {
50 #[default]
52 Never,
53 Read,
55 Write,
57 ReadWrite,
59}
60
61impl TaskCacheMode {
62 #[must_use]
64 pub const fn allows_read(self) -> bool {
65 matches!(self, Self::Read | Self::ReadWrite)
66 }
67
68 #[must_use]
70 pub const fn allows_write(self) -> bool {
71 matches!(self, Self::Write | Self::ReadWrite)
72 }
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
77#[serde(rename_all = "camelCase")]
78pub struct TaskCachePolicy {
79 #[serde(default)]
81 pub mode: TaskCacheMode,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub max_age: Option<String>,
85}
86
87#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
93#[serde(rename_all = "lowercase")]
94pub enum CaptureSource {
95 #[default]
96 Stdout,
97 Stderr,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
102#[serde(rename_all = "camelCase")]
103pub struct TaskCapture {
104 pub pattern: String,
106 #[serde(default)]
108 pub source: CaptureSource,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
113#[serde(rename_all = "camelCase")]
114pub struct TaskCaptureRef {
115 pub cuenv_capture_ref: bool,
116 pub cuenv_task: String,
117 pub cuenv_capture: String,
118}
119
120#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
126#[serde(rename_all = "lowercase")]
127pub enum ScriptShell {
128 #[default]
129 Bash,
130 Sh,
131 Zsh,
132 Fish,
133 Nu,
134 Powershell,
135 Pwsh,
136 Python,
137 Node,
138 Ruby,
139 Perl,
140}
141
142impl ScriptShell {
143 #[must_use]
145 pub fn command_and_flag(&self) -> (&'static str, &'static str) {
146 match self {
147 ScriptShell::Bash => ("bash", "-c"),
148 ScriptShell::Sh => ("sh", "-c"),
149 ScriptShell::Zsh => ("zsh", "-c"),
150 ScriptShell::Fish => ("fish", "-c"),
151 ScriptShell::Nu => ("nu", "-c"),
152 ScriptShell::Powershell => ("powershell", "-Command"),
153 ScriptShell::Pwsh => ("pwsh", "-Command"),
154 ScriptShell::Python => ("python", "-c"),
155 ScriptShell::Node => ("node", "-e"),
156 ScriptShell::Ruby => ("ruby", "-e"),
157 ScriptShell::Perl => ("perl", "-e"),
158 }
159 }
160
161 #[must_use]
163 pub fn supports_shell_options(&self) -> bool {
164 matches!(self, ScriptShell::Bash | ScriptShell::Sh | ScriptShell::Zsh)
165 }
166
167 #[must_use]
169 pub fn supports_pipefail(&self) -> bool {
170 matches!(self, ScriptShell::Bash | ScriptShell::Zsh)
171 }
172
173 #[must_use]
175 pub(crate) fn from_command(command: &str) -> Option<Self> {
176 let file_name = Path::new(command)
177 .file_name()?
178 .to_str()?
179 .to_ascii_lowercase();
180 let normalized = file_name.strip_suffix(".exe").unwrap_or(&file_name);
181
182 match normalized {
183 "bash" => Some(Self::Bash),
184 "sh" => Some(Self::Sh),
185 "zsh" => Some(Self::Zsh),
186 "fish" => Some(Self::Fish),
187 "nu" => Some(Self::Nu),
188 "powershell" => Some(Self::Powershell),
189 "pwsh" => Some(Self::Pwsh),
190 "python" => Some(Self::Python),
191 "node" => Some(Self::Node),
192 "ruby" => Some(Self::Ruby),
193 "perl" => Some(Self::Perl),
194 _ => None,
195 }
196 }
197}
198
199#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
201pub struct ShellOptions {
202 #[serde(default = "default_true")]
204 pub errexit: bool,
205 #[serde(default = "default_true")]
207 pub nounset: bool,
208 #[serde(default = "default_true")]
210 pub pipefail: bool,
211 #[serde(default)]
213 pub xtrace: bool,
214}
215
216fn default_true() -> bool {
217 true
218}
219
220impl Default for ShellOptions {
221 fn default() -> Self {
222 Self {
223 errexit: true,
224 nounset: true,
225 pipefail: true,
226 xtrace: false,
227 }
228 }
229}
230
231impl ShellOptions {
232 #[must_use]
234 pub fn to_set_commands(&self) -> String {
235 let mut opts = Vec::new();
236 if self.errexit {
237 opts.push("-e");
238 }
239 if self.nounset {
240 opts.push("-u");
241 }
242 if self.pipefail {
243 opts.push("-o pipefail");
244 }
245 if self.xtrace {
246 opts.push("-x");
247 }
248 if opts.is_empty() {
249 String::new()
250 } else {
251 format!("set {}\n", opts.join(" "))
252 }
253 }
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
258pub struct Shell {
259 pub command: Option<String>,
261 pub flag: Option<String>,
263}
264
265#[derive(Debug, Clone, PartialEq, Eq)]
267pub(crate) struct TaskCommandSpec {
268 pub program: String,
270 pub args: Vec<String>,
272}
273
274#[derive(Debug, Clone)]
275struct EffectiveScriptShell {
276 command: String,
277 flag: String,
278 display_name: String,
279 supports_shell_options: bool,
280 supports_pipefail: bool,
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
285pub struct Mapping {
286 pub from: String,
288 pub to: String,
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
294#[serde(untagged)]
295pub enum Input {
296 Path(String),
298 Project(ProjectReference),
300 Task(TaskOutput),
302}
303
304impl Input {
305 pub fn as_path(&self) -> Option<&String> {
306 match self {
307 Input::Path(path) => Some(path),
308 Input::Project(_) | Input::Task(_) => None,
309 }
310 }
311
312 pub fn as_project(&self) -> Option<&ProjectReference> {
313 match self {
314 Input::Project(reference) => Some(reference),
315 Input::Path(_) | Input::Task(_) => None,
316 }
317 }
318
319 pub fn as_task_output(&self) -> Option<&TaskOutput> {
320 match self {
321 Input::Task(output) => Some(output),
322 Input::Path(_) | Input::Project(_) => None,
323 }
324 }
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
329pub struct ProjectReference {
330 pub project: String,
332 pub task: String,
334 pub map: Vec<Mapping>,
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
340pub struct TaskOutput {
341 pub task: String,
343 #[serde(default)]
346 pub map: Option<Vec<Mapping>>,
347}
348
349#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
351pub struct SourceLocation {
352 pub file: String,
354 pub line: u32,
356 pub column: u32,
358}
359
360impl SourceLocation {
361 pub fn directory(&self) -> Option<&str> {
363 std::path::Path::new(&self.file)
364 .parent()
365 .and_then(|p| p.to_str())
366 .filter(|s| !s.is_empty())
367 }
368}
369
370#[derive(Debug, Clone, Serialize, PartialEq)]
378pub struct TaskDependency {
379 #[serde(rename = "_name")]
382 pub name: String,
383
384 #[serde(flatten)]
386 _rest: serde_json::Value,
387}
388
389impl<'de> serde::Deserialize<'de> for TaskDependency {
390 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
391 where
392 D: serde::Deserializer<'de>,
393 {
394 use serde::de::{self, Visitor};
395
396 struct TaskDependencyVisitor;
397
398 impl<'de> Visitor<'de> for TaskDependencyVisitor {
399 type Value = TaskDependency;
400
401 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
402 formatter.write_str("a string or an object with _name field")
403 }
404
405 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
406 where
407 E: de::Error,
408 {
409 Ok(TaskDependency::from_name(value))
410 }
411
412 fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
413 where
414 E: de::Error,
415 {
416 Ok(TaskDependency::from_name(value))
417 }
418
419 fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
420 where
421 M: de::MapAccess<'de>,
422 {
423 let value: serde_json::Value =
425 serde::Deserialize::deserialize(de::value::MapAccessDeserializer::new(map))?;
426
427 let name = value
428 .get("_name")
429 .and_then(|v| v.as_str())
430 .ok_or_else(|| de::Error::missing_field("_name"))?
431 .to_string();
432
433 Ok(TaskDependency { name, _rest: value })
434 }
435 }
436
437 deserializer.deserialize_any(TaskDependencyVisitor)
438 }
439}
440
441impl TaskDependency {
442 #[must_use]
444 pub fn from_name(name: impl Into<String>) -> Self {
445 Self {
446 name: name.into(),
447 _rest: serde_json::Value::Null,
448 }
449 }
450
451 #[must_use]
453 pub fn task_name(&self) -> &str {
454 &self.name
455 }
456
457 pub fn matches(&self, name: &str) -> bool {
459 self.name == name
460 }
461}
462
463#[derive(Debug, Clone, Serialize, PartialEq)]
474pub struct Task {
475 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub shell: Option<Shell>,
478
479 #[serde(default, skip_serializing_if = "String::is_empty")]
481 pub command: String,
482
483 #[serde(default, skip_serializing_if = "Option::is_none")]
486 pub script: Option<String>,
487
488 #[serde(
491 default,
492 rename = "scriptShell",
493 skip_serializing_if = "Option::is_none"
494 )]
495 pub script_shell: Option<ScriptShell>,
496
497 #[serde(
500 default,
501 rename = "shellOptions",
502 skip_serializing_if = "Option::is_none"
503 )]
504 pub shell_options: Option<ShellOptions>,
505
506 #[serde(default)]
508 pub args: Vec<String>,
509
510 #[serde(default)]
512 pub env: HashMap<String, serde_json::Value>,
513
514 #[serde(default)]
517 pub dagger: Option<DaggerTaskConfig>,
518
519 #[serde(default, skip_serializing_if = "Option::is_none")]
521 pub runtime: Option<crate::manifest::Runtime>,
522
523 #[serde(default = "default_hermetic")]
526 pub hermetic: bool,
527
528 #[serde(default, rename = "dependsOn")]
532 pub depends_on: Vec<TaskDependency>,
533
534 #[serde(default)]
536 pub inputs: Vec<Input>,
537
538 #[serde(default)]
540 pub outputs: Vec<String>,
541
542 #[serde(default, skip_serializing_if = "Option::is_none")]
544 pub cache: Option<TaskCachePolicy>,
545
546 #[serde(default)]
548 pub description: Option<String>,
549
550 #[serde(default)]
552 pub params: Option<TaskParams>,
553
554 #[serde(default)]
557 pub labels: Vec<String>,
558
559 #[serde(default, skip_serializing_if = "Option::is_none")]
561 pub timeout: Option<String>,
562
563 #[serde(default, skip_serializing_if = "Option::is_none")]
565 pub retry: Option<RetryConfig>,
566
567 #[serde(default, rename = "continueOnError")]
569 pub continue_on_error: bool,
570
571 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
573 pub captures: HashMap<String, TaskCapture>,
574
575 #[serde(default, skip_serializing_if = "Option::is_none")]
579 pub task_ref: Option<String>,
580
581 #[serde(default, skip_serializing_if = "Option::is_none")]
584 pub project_root: Option<std::path::PathBuf>,
585
586 #[serde(default, rename = "_source", skip_serializing_if = "Option::is_none")]
589 pub source: Option<SourceLocation>,
590
591 #[serde(default, rename = "dir", skip_serializing_if = "Option::is_none")]
594 pub directory: Option<String>,
595}
596
597#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
599pub struct RetryConfig {
600 #[serde(default = "default_retry_attempts")]
602 pub attempts: u32,
603 #[serde(default, skip_serializing_if = "Option::is_none")]
605 pub delay: Option<String>,
606}
607
608fn default_retry_attempts() -> u32 {
609 3
610}
611
612impl<'de> serde::Deserialize<'de> for Task {
615 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
616 where
617 D: serde::Deserializer<'de>,
618 {
619 #[derive(serde::Deserialize)]
621 struct TaskHelper {
622 #[serde(default)]
623 shell: Option<Shell>,
624 #[serde(default)]
625 command: Option<String>,
626 #[serde(default)]
627 script: Option<String>,
628 #[serde(default, rename = "scriptShell")]
629 script_shell: Option<ScriptShell>,
630 #[serde(default, rename = "shellOptions")]
631 shell_options: Option<ShellOptions>,
632 #[serde(default)]
633 args: Vec<String>,
634 #[serde(default)]
635 env: HashMap<String, serde_json::Value>,
636 #[serde(default)]
637 dagger: Option<DaggerTaskConfig>,
638 #[serde(default)]
639 runtime: Option<crate::manifest::Runtime>,
640 #[serde(default = "default_hermetic")]
641 hermetic: bool,
642 #[serde(default, rename = "dependsOn")]
643 depends_on: Vec<TaskDependency>,
644 #[serde(default)]
645 inputs: Vec<Input>,
646 #[serde(default)]
647 outputs: Vec<String>,
648 #[serde(default)]
649 cache: Option<TaskCachePolicy>,
650 #[serde(default)]
651 description: Option<String>,
652 #[serde(default)]
653 params: Option<TaskParams>,
654 #[serde(default)]
655 labels: Vec<String>,
656 #[serde(default)]
657 timeout: Option<String>,
658 #[serde(default)]
659 retry: Option<RetryConfig>,
660 #[serde(default, rename = "continueOnError")]
661 continue_on_error: bool,
662 #[serde(default)]
663 captures: HashMap<String, TaskCapture>,
664 #[serde(default)]
665 task_ref: Option<String>,
666 #[serde(default)]
667 project_root: Option<std::path::PathBuf>,
668 #[serde(default, rename = "_source")]
669 source: Option<SourceLocation>,
670 #[serde(default, rename = "dir")]
671 directory: Option<String>,
672 }
673
674 let helper = TaskHelper::deserialize(deserializer)?;
675
676 let has_command = helper.command.as_ref().is_some_and(|c| !c.is_empty());
678 let has_script = helper.script.is_some();
679 let has_task_ref = helper.task_ref.is_some();
680
681 if !has_command && !has_script && !has_task_ref {
682 return Err(serde::de::Error::custom(
683 "Task must have either 'command', 'script', or 'task_ref' field",
684 ));
685 }
686
687 Ok(Task {
688 shell: helper.shell,
689 command: helper.command.unwrap_or_default(),
690 script: helper.script,
691 script_shell: helper.script_shell,
692 shell_options: helper.shell_options,
693 args: helper.args,
694 env: helper.env,
695 dagger: helper.dagger,
696 runtime: helper.runtime,
697 hermetic: helper.hermetic,
698 depends_on: helper.depends_on,
699 inputs: helper.inputs,
700 outputs: helper.outputs,
701 cache: helper.cache,
702 description: helper.description,
703 params: helper.params,
704 labels: helper.labels,
705 timeout: helper.timeout,
706 retry: helper.retry,
707 continue_on_error: helper.continue_on_error,
708 captures: helper.captures,
709 task_ref: helper.task_ref,
710 project_root: helper.project_root,
711 source: helper.source,
712 directory: helper.directory,
713 })
714 }
715}
716
717#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
719pub struct DaggerTaskConfig {
720 #[serde(default)]
723 pub image: Option<String>,
724
725 #[serde(default)]
728 pub from: Option<String>,
729
730 #[serde(default)]
733 pub secrets: Option<Vec<DaggerSecret>>,
734
735 #[serde(default)]
737 pub cache: Option<Vec<DaggerCacheMount>>,
738}
739
740#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
742pub struct DaggerSecret {
743 pub name: String,
745
746 #[serde(default)]
748 pub path: Option<String>,
749
750 #[serde(default, rename = "envVar")]
752 pub env_var: Option<String>,
753
754 pub resolver: crate::secrets::Secret,
756}
757
758#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
760pub struct DaggerCacheMount {
761 pub path: String,
763
764 pub name: String,
766}
767
768#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
770pub struct TaskParams {
771 #[serde(default)]
774 pub positional: Vec<ParamDef>,
775
776 #[serde(flatten, default)]
779 pub named: HashMap<String, ParamDef>,
780}
781
782#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
784#[serde(rename_all = "lowercase")]
785pub enum ParamType {
786 #[default]
787 String,
788 Bool,
789 Int,
790}
791
792#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
794pub struct ParamDef {
795 #[serde(default)]
797 pub description: Option<String>,
798
799 #[serde(default)]
801 pub required: bool,
802
803 #[serde(default)]
805 pub default: Option<String>,
806
807 #[serde(default, rename = "type")]
809 pub param_type: ParamType,
810
811 #[serde(default)]
813 pub short: Option<String>,
814}
815
816#[derive(Debug, Clone, Default)]
818pub struct ResolvedArgs {
819 pub positional: Vec<String>,
821 pub named: HashMap<String, String>,
823}
824
825impl ResolvedArgs {
826 pub fn new() -> Self {
828 Self::default()
829 }
830
831 pub fn interpolate(&self, template: &str) -> String {
834 let mut result = template.to_string();
835
836 for (i, value) in self.positional.iter().enumerate() {
838 let placeholder = format!("{{{{{}}}}}", i);
839 result = result.replace(&placeholder, value);
840 }
841
842 for (name, value) in &self.named {
844 let placeholder = format!("{{{{{}}}}}", name);
845 result = result.replace(&placeholder, value);
846 }
847
848 result
849 }
850
851 pub fn interpolate_args(&self, args: &[String]) -> Vec<String> {
853 args.iter().map(|arg| self.interpolate(arg)).collect()
854 }
855}
856
857impl Default for Task {
858 fn default() -> Self {
859 Self {
860 shell: None,
861 command: String::new(),
862 script: None,
863 script_shell: None,
864 shell_options: None,
865 args: vec![],
866 env: HashMap::new(),
867 dagger: None,
868 runtime: None,
869 hermetic: true, depends_on: vec![],
871 inputs: vec![],
872 outputs: vec![],
873 cache: None,
874 description: None,
875 params: None,
876 labels: vec![],
877 timeout: None,
878 retry: None,
879 continue_on_error: false,
880 captures: HashMap::new(),
881 task_ref: None,
882 project_root: None,
883 source: None,
884 directory: None,
885 }
886 }
887}
888
889impl Task {
890 pub fn from_task_ref(ref_str: &str) -> Self {
893 Self {
894 task_ref: Some(ref_str.to_string()),
895 description: Some(format!("Reference to {}", ref_str)),
896 ..Default::default()
897 }
898 }
899
900 pub fn is_task_ref(&self) -> bool {
902 self.task_ref.is_some()
903 }
904
905 pub fn dependency_names(&self) -> impl Iterator<Item = &str> {
907 self.depends_on.iter().map(|d| d.task_name())
908 }
909
910 #[must_use]
912 pub fn cache_policy(&self) -> TaskCachePolicy {
913 self.cache.clone().unwrap_or_default()
914 }
915
916 pub fn description(&self) -> &str {
918 self.description
919 .as_deref()
920 .unwrap_or("No description provided")
921 }
922
923 pub(crate) fn command_spec<F>(&self, mut resolve_command: F) -> crate::Result<TaskCommandSpec>
925 where
926 F: FnMut(&str) -> String,
927 {
928 if let Some(script) = &self.script {
929 let shell = self.effective_script_shell();
930 let script = self.prepare_script(script, &shell)?;
931
932 return Ok(TaskCommandSpec {
933 program: resolve_command(&shell.command),
934 args: vec![shell.flag, script],
935 });
936 }
937
938 if let Some(shell) = &self.shell
939 && let (Some(shell_command), Some(shell_flag)) = (&shell.command, &shell.flag)
940 {
941 let full_command = if self.command.is_empty() {
942 self.args.join(" ")
943 } else if self.args.is_empty() {
944 resolve_command(&self.command)
945 } else {
946 let resolved_command = resolve_command(&self.command);
947 format!("{} {}", resolved_command, self.args.join(" "))
948 };
949
950 return Ok(TaskCommandSpec {
951 program: resolve_command(shell_command),
952 args: vec![shell_flag.clone(), full_command],
953 });
954 }
955
956 Ok(TaskCommandSpec {
957 program: resolve_command(&self.command),
958 args: self.args.clone(),
959 })
960 }
961
962 pub fn iter_path_inputs(&self) -> impl Iterator<Item = &String> {
964 self.inputs.iter().filter_map(Input::as_path)
965 }
966
967 pub fn iter_project_refs(&self) -> impl Iterator<Item = &ProjectReference> {
969 self.inputs.iter().filter_map(Input::as_project)
970 }
971
972 pub fn iter_task_outputs(&self) -> impl Iterator<Item = &TaskOutput> {
974 self.inputs.iter().filter_map(Input::as_task_output)
975 }
976
977 pub fn collect_path_inputs_with_prefix(&self, prefix: Option<&Path>) -> Vec<String> {
979 self.iter_path_inputs()
980 .map(|path| apply_prefix(prefix, path))
981 .collect()
982 }
983
984 pub fn collect_project_destinations_with_prefix(&self, prefix: Option<&Path>) -> Vec<String> {
986 self.iter_project_refs()
987 .flat_map(|reference| reference.map.iter().map(|m| apply_prefix(prefix, &m.to)))
988 .collect()
989 }
990
991 pub fn collect_all_inputs_with_prefix(&self, prefix: Option<&Path>) -> Vec<String> {
993 let mut inputs = self.collect_path_inputs_with_prefix(prefix);
994 inputs.extend(self.collect_project_destinations_with_prefix(prefix));
995 inputs
996 }
997
998 fn effective_script_shell(&self) -> EffectiveScriptShell {
999 if let Some(script_shell) = self.script_shell {
1000 let (command, flag) = script_shell.command_and_flag();
1001
1002 return EffectiveScriptShell {
1003 command: command.to_string(),
1004 flag: flag.to_string(),
1005 display_name: command.to_string(),
1006 supports_shell_options: script_shell.supports_shell_options(),
1007 supports_pipefail: script_shell.supports_pipefail(),
1008 };
1009 }
1010
1011 if let Some(shell) = &self.shell {
1012 let command = shell.command.clone().unwrap_or_else(|| "bash".to_string());
1013 let flag = shell.flag.clone().unwrap_or_else(|| "-c".to_string());
1014 let (supports_shell_options, supports_pipefail) = ScriptShell::from_command(&command)
1015 .map(|script_shell| {
1016 (
1017 script_shell.supports_shell_options(),
1018 script_shell.supports_pipefail(),
1019 )
1020 })
1021 .unwrap_or((false, false));
1022
1023 return EffectiveScriptShell {
1024 display_name: command.clone(),
1025 command,
1026 flag,
1027 supports_shell_options,
1028 supports_pipefail,
1029 };
1030 }
1031
1032 let default_shell = ScriptShell::default();
1033 let (command, flag) = default_shell.command_and_flag();
1034
1035 EffectiveScriptShell {
1036 command: command.to_string(),
1037 flag: flag.to_string(),
1038 display_name: command.to_string(),
1039 supports_shell_options: default_shell.supports_shell_options(),
1040 supports_pipefail: default_shell.supports_pipefail(),
1041 }
1042 }
1043
1044 fn prepare_script(&self, script: &str, shell: &EffectiveScriptShell) -> crate::Result<String> {
1045 let Some(shell_options) = self.shell_options else {
1046 return Ok(script.to_string());
1047 };
1048
1049 if !shell.supports_shell_options {
1050 return Err(crate::Error::configuration(format!(
1051 "Task uses shellOptions with unsupported script shell '{}'. \
1052 Use scriptShell 'bash', 'sh', or 'zsh'.",
1053 shell.display_name
1054 )));
1055 }
1056
1057 if shell_options.pipefail && !shell.supports_pipefail {
1058 return Err(crate::Error::configuration(format!(
1059 "Task uses shellOptions.pipefail with unsupported script shell '{}'. \
1060 Disable pipefail or use scriptShell 'bash' or 'zsh'.",
1061 shell.display_name
1062 )));
1063 }
1064
1065 let set_commands = shell_options.to_set_commands();
1066 if set_commands.is_empty() {
1067 return Ok(script.to_string());
1068 }
1069
1070 Ok(format!("{set_commands}{script}"))
1071 }
1072}
1073
1074impl crate::AffectedBy for Task {
1075 fn is_affected_by(&self, changed_files: &[std::path::PathBuf], project_root: &Path) -> bool {
1082 let inputs: Vec<_> = self.iter_path_inputs().collect();
1083
1084 if inputs.is_empty() {
1086 return true;
1087 }
1088
1089 inputs
1091 .iter()
1092 .any(|pattern| crate::matches_pattern(changed_files, project_root, pattern))
1093 }
1094
1095 fn input_patterns(&self) -> Vec<&str> {
1096 self.iter_path_inputs().map(String::as_str).collect()
1097 }
1098}
1099
1100fn apply_prefix(prefix: Option<&Path>, value: &str) -> String {
1101 if let Some(prefix) = prefix {
1102 prefix.join(value).to_string_lossy().to_string()
1103 } else {
1104 value.to_string()
1105 }
1106}
1107
1108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1116pub struct TaskGroup {
1117 #[serde(rename = "type")]
1119 pub type_: String,
1120
1121 #[serde(default, rename = "dependsOn")]
1123 pub depends_on: Vec<TaskDependency>,
1124
1125 #[serde(default, rename = "maxConcurrency")]
1127 pub max_concurrency: Option<u32>,
1128
1129 #[serde(default)]
1131 pub description: Option<String>,
1132
1133 #[serde(flatten)]
1135 pub children: HashMap<String, TaskNode>,
1136}
1137
1138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1157#[serde(untagged)]
1158pub enum TaskNode {
1159 Task(Box<Task>),
1161 Group(TaskGroup),
1163 Sequence(Vec<TaskNode>),
1165}
1166
1167#[deprecated(since = "0.26.0", note = "Use TaskNode instead")]
1173pub type TaskDefinition = TaskNode;
1174
1175#[deprecated(since = "0.26.0", note = "Use Vec<TaskNode> directly")]
1177pub type TaskList = Vec<TaskNode>;
1178
1179#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1181pub struct Tasks {
1182 #[serde(flatten)]
1184 pub tasks: HashMap<String, TaskNode>,
1185}
1186
1187impl Tasks {
1188 pub fn new() -> Self {
1190 Self::default()
1191 }
1192
1193 pub fn get(&self, name: &str) -> Option<&TaskNode> {
1195 self.tasks.get(name)
1196 }
1197
1198 pub fn list_tasks(&self) -> Vec<&str> {
1200 self.tasks.keys().map(|s| s.as_str()).collect()
1201 }
1202
1203 pub fn contains(&self, name: &str) -> bool {
1205 self.tasks.contains_key(name)
1206 }
1207}
1208
1209impl TaskNode {
1210 pub fn is_task(&self) -> bool {
1212 matches!(self, TaskNode::Task(_))
1213 }
1214
1215 pub fn is_group(&self) -> bool {
1217 matches!(self, TaskNode::Group(_))
1218 }
1219
1220 pub fn is_sequence(&self) -> bool {
1222 matches!(self, TaskNode::Sequence(_))
1223 }
1224
1225 pub fn as_task(&self) -> Option<&Task> {
1227 match self {
1228 TaskNode::Task(task) => Some(task.as_ref()),
1229 _ => None,
1230 }
1231 }
1232
1233 pub fn as_group(&self) -> Option<&TaskGroup> {
1235 match self {
1236 TaskNode::Group(group) => Some(group),
1237 _ => None,
1238 }
1239 }
1240
1241 pub fn as_sequence(&self) -> Option<&Vec<TaskNode>> {
1243 match self {
1244 TaskNode::Sequence(seq) => Some(seq),
1245 _ => None,
1246 }
1247 }
1248
1249 pub fn depends_on(&self) -> &[TaskDependency] {
1251 match self {
1252 TaskNode::Task(task) => &task.depends_on,
1253 TaskNode::Group(group) => &group.depends_on,
1254 TaskNode::Sequence(_) => &[], }
1256 }
1257
1258 pub fn description(&self) -> Option<&str> {
1260 match self {
1261 TaskNode::Task(task) => task.description.as_deref(),
1262 TaskNode::Group(group) => group.description.as_deref(),
1263 TaskNode::Sequence(_) => None, }
1265 }
1266
1267 #[deprecated(since = "0.26.0", note = "Use is_task() instead")]
1269 pub fn is_single(&self) -> bool {
1270 self.is_task()
1271 }
1272
1273 #[deprecated(since = "0.26.0", note = "Use as_task() instead")]
1274 pub fn as_single(&self) -> Option<&Task> {
1275 self.as_task()
1276 }
1277
1278 #[deprecated(since = "0.26.0", note = "Use is_sequence() instead")]
1279 pub fn is_list(&self) -> bool {
1280 self.is_sequence()
1281 }
1282
1283 #[deprecated(since = "0.26.0", note = "Use as_sequence() instead")]
1284 pub fn as_list(&self) -> Option<&Vec<TaskNode>> {
1285 self.as_sequence()
1286 }
1287}
1288
1289impl TaskGroup {
1290 pub fn len(&self) -> usize {
1292 self.children.len()
1293 }
1294
1295 pub fn is_empty(&self) -> bool {
1297 self.children.is_empty()
1298 }
1299}
1300
1301impl crate::AffectedBy for TaskGroup {
1302 fn is_affected_by(&self, changed_files: &[std::path::PathBuf], project_root: &Path) -> bool {
1304 self.children
1305 .values()
1306 .any(|node| node.is_affected_by(changed_files, project_root))
1307 }
1308
1309 fn input_patterns(&self) -> Vec<&str> {
1310 self.children
1311 .values()
1312 .flat_map(|node| node.input_patterns())
1313 .collect()
1314 }
1315}
1316
1317impl crate::AffectedBy for TaskNode {
1318 fn is_affected_by(&self, changed_files: &[std::path::PathBuf], project_root: &Path) -> bool {
1319 match self {
1320 TaskNode::Task(task) => task.is_affected_by(changed_files, project_root),
1321 TaskNode::Group(group) => group.is_affected_by(changed_files, project_root),
1322 TaskNode::Sequence(seq) => seq
1323 .iter()
1324 .any(|node| node.is_affected_by(changed_files, project_root)),
1325 }
1326 }
1327
1328 fn input_patterns(&self) -> Vec<&str> {
1329 match self {
1330 TaskNode::Task(task) => task.input_patterns(),
1331 TaskNode::Group(group) => group.input_patterns(),
1332 TaskNode::Sequence(seq) => seq.iter().flat_map(|node| node.input_patterns()).collect(),
1333 }
1334 }
1335}
1336
1337#[cfg(test)]
1338mod tests {
1339 use super::*;
1340
1341 #[test]
1342 fn test_task_default_values() {
1343 let task = Task {
1344 command: "echo".to_string(),
1345 ..Default::default()
1346 };
1347
1348 assert!(task.shell.is_none());
1349 assert_eq!(task.command, "echo");
1350 assert_eq!(task.description(), "No description provided");
1351 assert!(task.args.is_empty());
1352 assert!(task.hermetic); }
1354
1355 #[test]
1356 fn test_task_deserialization() {
1357 let json = r#"{
1358 "command": "echo",
1359 "args": ["Hello", "World"]
1360 }"#;
1361
1362 let task: Task = serde_json::from_str(json).unwrap();
1363 assert_eq!(task.command, "echo");
1364 assert_eq!(task.args, vec!["Hello", "World"]);
1365 assert!(task.shell.is_none()); }
1367
1368 #[test]
1369 fn test_task_script_deserialization() {
1370 let json = r#"{
1372 "script": "echo hello",
1373 "inputs": ["src/main.rs"]
1374 }"#;
1375
1376 let task: Task = serde_json::from_str(json).unwrap();
1377 assert!(task.command.is_empty()); assert_eq!(task.script, Some("echo hello".to_string()));
1379 assert_eq!(task.inputs.len(), 1);
1380 }
1381
1382 #[test]
1383 fn test_task_cache_policy_defaults_to_never() {
1384 let task = Task {
1385 command: "echo".to_string(),
1386 ..Default::default()
1387 };
1388
1389 let policy = task.cache_policy();
1390 assert_eq!(policy.mode, TaskCacheMode::Never);
1391 assert!(policy.max_age.is_none());
1392 }
1393
1394 #[test]
1395 fn test_task_cache_policy_deserialization() {
1396 let json = r#"{
1397 "command": "echo",
1398 "cache": {
1399 "mode": "read-write",
1400 "maxAge": "1h"
1401 }
1402 }"#;
1403
1404 let task: Task = serde_json::from_str(json).unwrap();
1405 let policy = task.cache_policy();
1406 assert_eq!(policy.mode, TaskCacheMode::ReadWrite);
1407 assert_eq!(policy.max_age, Some("1h".to_string()));
1408 }
1409
1410 #[test]
1411 fn test_task_node_script_variant() {
1412 let json = r#"{
1414 "script": "echo hello"
1415 }"#;
1416
1417 let node: TaskNode = serde_json::from_str(json).unwrap();
1418 assert!(node.is_task());
1419 }
1420
1421 #[test]
1422 fn test_task_group_with_script_task() {
1423 let json = r#"{
1426 "type": "group",
1427 "linux": {
1428 "script": "echo building",
1429 "inputs": ["src/main.rs"]
1430 }
1431 }"#;
1432
1433 let group: TaskGroup = serde_json::from_str(json).unwrap();
1434 assert_eq!(group.len(), 1);
1435 }
1436
1437 #[test]
1438 fn test_full_tasks_map_with_script() {
1439 let json = r#"{
1442 "pwd": { "command": "pwd" },
1443 "cross": {
1444 "type": "group",
1445 "linux": {
1446 "script": "echo building",
1447 "inputs": ["src/main.rs"]
1448 }
1449 }
1450 }"#;
1451
1452 let tasks: HashMap<String, TaskNode> = serde_json::from_str(json).unwrap();
1453 assert_eq!(tasks.len(), 2);
1454 assert!(tasks.contains_key("pwd"));
1455 assert!(tasks.contains_key("cross"));
1456
1457 assert!(tasks.get("pwd").unwrap().is_task());
1459
1460 assert!(tasks.get("cross").unwrap().is_group());
1462 }
1463
1464 #[test]
1465 fn test_complex_nested_tasks_like_cuenv() {
1466 let json = r#"{
1469 "pwd": { "command": "pwd" },
1470 "check": {
1471 "command": "nix",
1472 "args": ["flake", "check"],
1473 "inputs": ["flake.nix"]
1474 },
1475 "fmt": {
1476 "type": "group",
1477 "fix": {
1478 "command": "treefmt",
1479 "inputs": [".config"]
1480 },
1481 "check": {
1482 "command": "treefmt",
1483 "args": ["--fail-on-change"],
1484 "inputs": [".config"]
1485 }
1486 },
1487 "cross": {
1488 "type": "group",
1489 "linux": {
1490 "script": "echo building",
1491 "inputs": ["Cargo.toml"]
1492 }
1493 },
1494 "docs": {
1495 "type": "group",
1496 "build": {
1497 "command": "bash",
1498 "args": ["-c", "bun install"],
1499 "inputs": ["docs"],
1500 "outputs": ["docs/dist"]
1501 },
1502 "deploy": {
1503 "command": "bash",
1504 "args": ["-c", "wrangler deploy"],
1505 "dependsOn": ["docs.build"],
1506 "inputs": [{"task": "docs.build"}]
1507 }
1508 }
1509 }"#;
1510
1511 let result: Result<HashMap<String, TaskNode>, _> = serde_json::from_str(json);
1512 match result {
1513 Ok(tasks) => {
1514 assert_eq!(tasks.len(), 5);
1515 assert!(tasks.get("pwd").unwrap().is_task());
1516 assert!(tasks.get("check").unwrap().is_task());
1517 assert!(tasks.get("fmt").unwrap().is_group());
1518 assert!(tasks.get("cross").unwrap().is_group());
1519 assert!(tasks.get("docs").unwrap().is_group());
1520 }
1521 Err(e) => {
1522 panic!("Failed to deserialize complex tasks: {}", e);
1523 }
1524 }
1525 }
1526
1527 #[test]
1528 fn test_task_list_sequential() {
1529 let task1 = Task {
1530 command: "echo".to_string(),
1531 args: vec!["first".to_string()],
1532 description: Some("First task".to_string()),
1533 ..Default::default()
1534 };
1535
1536 let task2 = Task {
1537 command: "echo".to_string(),
1538 args: vec!["second".to_string()],
1539 description: Some("Second task".to_string()),
1540 ..Default::default()
1541 };
1542
1543 let sequence: Vec<TaskNode> = vec![
1544 TaskNode::Task(Box::new(task1)),
1545 TaskNode::Task(Box::new(task2)),
1546 ];
1547
1548 assert_eq!(sequence.len(), 2);
1549 assert!(!sequence.is_empty());
1550 }
1551
1552 #[test]
1553 fn test_task_group_parallel() {
1554 let task1 = Task {
1555 command: "echo".to_string(),
1556 args: vec!["task1".to_string()],
1557 description: Some("Task 1".to_string()),
1558 ..Default::default()
1559 };
1560
1561 let task2 = Task {
1562 command: "echo".to_string(),
1563 args: vec!["task2".to_string()],
1564 description: Some("Task 2".to_string()),
1565 ..Default::default()
1566 };
1567
1568 let mut parallel_tasks = HashMap::new();
1569 parallel_tasks.insert("task1".to_string(), TaskNode::Task(Box::new(task1)));
1570 parallel_tasks.insert("task2".to_string(), TaskNode::Task(Box::new(task2)));
1571
1572 let group = TaskGroup {
1573 type_: "group".to_string(),
1574 children: parallel_tasks,
1575 depends_on: vec![],
1576 max_concurrency: None,
1577 description: None,
1578 };
1579
1580 assert_eq!(group.len(), 2);
1581 assert!(!group.is_empty());
1582 }
1583
1584 #[test]
1585 fn test_tasks_collection() {
1586 let mut tasks = Tasks::new();
1587 assert!(tasks.list_tasks().is_empty());
1588
1589 let task = Task {
1590 command: "echo".to_string(),
1591 args: vec!["hello".to_string()],
1592 description: Some("Hello task".to_string()),
1593 ..Default::default()
1594 };
1595
1596 tasks
1597 .tasks
1598 .insert("greet".to_string(), TaskNode::Task(Box::new(task)));
1599
1600 assert!(tasks.contains("greet"));
1601 assert!(!tasks.contains("nonexistent"));
1602 assert_eq!(tasks.list_tasks(), vec!["greet"]);
1603
1604 let retrieved = tasks.get("greet").unwrap();
1605 assert!(retrieved.is_task());
1606 }
1607
1608 #[test]
1609 fn test_task_node_helpers() {
1610 let task = Task {
1611 command: "test".to_string(),
1612 description: Some("Test task".to_string()),
1613 ..Default::default()
1614 };
1615
1616 let task_node = TaskNode::Task(Box::new(task.clone()));
1617 assert!(task_node.is_task());
1618 assert!(!task_node.is_group());
1619 assert!(!task_node.is_sequence());
1620 assert_eq!(task_node.as_task().unwrap().command, "test");
1621 assert!(task_node.as_group().is_none());
1622 assert!(task_node.as_sequence().is_none());
1623
1624 let group = TaskNode::Group(TaskGroup {
1625 type_: "group".to_string(),
1626 children: HashMap::new(),
1627 depends_on: vec![],
1628 max_concurrency: None,
1629 description: None,
1630 });
1631 assert!(!group.is_task());
1632 assert!(group.is_group());
1633 assert!(!group.is_sequence());
1634 assert!(group.as_task().is_none());
1635 assert!(group.as_group().is_some());
1636
1637 let sequence = TaskNode::Sequence(vec![]);
1638 assert!(!sequence.is_task());
1639 assert!(!sequence.is_group());
1640 assert!(sequence.is_sequence());
1641 assert!(sequence.as_sequence().is_some());
1642 }
1643
1644 #[test]
1645 fn test_script_shell_command_and_flag() {
1646 assert_eq!(ScriptShell::Bash.command_and_flag(), ("bash", "-c"));
1647 assert_eq!(ScriptShell::Nu.command_and_flag(), ("nu", "-c"));
1648 assert_eq!(ScriptShell::Python.command_and_flag(), ("python", "-c"));
1649 assert_eq!(ScriptShell::Node.command_and_flag(), ("node", "-e"));
1650 assert_eq!(
1651 ScriptShell::Powershell.command_and_flag(),
1652 ("powershell", "-Command")
1653 );
1654 }
1655
1656 #[test]
1657 fn test_script_shell_from_command() {
1658 assert_eq!(
1659 ScriptShell::from_command("/usr/bin/nu"),
1660 Some(ScriptShell::Nu)
1661 );
1662 assert_eq!(
1663 ScriptShell::from_command("pwsh.exe"),
1664 Some(ScriptShell::Pwsh)
1665 );
1666 assert_eq!(ScriptShell::from_command("custom-shell"), None);
1667 }
1668
1669 #[test]
1670 fn test_shell_options_default() {
1671 let opts = ShellOptions::default();
1672 assert!(opts.errexit);
1673 assert!(opts.nounset);
1674 assert!(opts.pipefail);
1675 assert!(!opts.xtrace);
1676 }
1677
1678 #[test]
1679 fn test_shell_options_to_set_commands() {
1680 let opts = ShellOptions::default();
1681 assert_eq!(opts.to_set_commands(), "set -e -u -o pipefail\n");
1682
1683 let debug_opts = ShellOptions {
1684 errexit: true,
1685 nounset: false,
1686 pipefail: true,
1687 xtrace: true,
1688 };
1689 assert_eq!(debug_opts.to_set_commands(), "set -e -o pipefail -x\n");
1690
1691 let no_opts = ShellOptions {
1692 errexit: false,
1693 nounset: false,
1694 pipefail: false,
1695 xtrace: false,
1696 };
1697 assert_eq!(no_opts.to_set_commands(), "");
1698 }
1699
1700 #[test]
1701 fn test_task_command_spec_uses_script_shell() {
1702 let task = Task {
1703 script: Some("echo hello".to_string()),
1704 script_shell: Some(ScriptShell::Nu),
1705 ..Default::default()
1706 };
1707
1708 let spec = task
1709 .command_spec(|command| format!("resolved:{command}"))
1710 .unwrap();
1711
1712 assert_eq!(spec.program, "resolved:nu");
1713 assert_eq!(spec.args, vec!["-c".to_string(), "echo hello".to_string()]);
1714 }
1715
1716 #[test]
1717 fn test_task_command_spec_prepends_shell_options() {
1718 let task = Task {
1719 script: Some("echo hello".to_string()),
1720 shell_options: Some(ShellOptions {
1721 errexit: true,
1722 nounset: false,
1723 pipefail: false,
1724 xtrace: true,
1725 }),
1726 ..Default::default()
1727 };
1728
1729 let spec = task.command_spec(str::to_string).unwrap();
1730
1731 assert_eq!(spec.program, "bash");
1732 assert_eq!(
1733 spec.args,
1734 vec!["-c".to_string(), "set -e -x\necho hello".to_string()]
1735 );
1736 }
1737
1738 #[test]
1739 fn test_task_command_spec_rejects_pipefail_for_sh() {
1740 let task = Task {
1741 script: Some("echo hello".to_string()),
1742 script_shell: Some(ScriptShell::Sh),
1743 shell_options: Some(ShellOptions::default()),
1744 ..Default::default()
1745 };
1746
1747 let err = task.command_spec(str::to_string).unwrap_err();
1748
1749 assert!(
1750 err.to_string()
1751 .contains("shellOptions.pipefail with unsupported script shell 'sh'"),
1752 "unexpected error: {err}"
1753 );
1754 }
1755
1756 #[test]
1757 fn test_task_command_spec_rejects_shell_options_for_unsupported_shell() {
1758 let task = Task {
1759 script: Some("console.log('hello')".to_string()),
1760 script_shell: Some(ScriptShell::Node),
1761 shell_options: Some(ShellOptions::default()),
1762 ..Default::default()
1763 };
1764
1765 let err = task.command_spec(str::to_string).unwrap_err();
1766
1767 assert!(
1768 err.to_string().contains("unsupported script shell 'node'"),
1769 "unexpected error: {err}"
1770 );
1771 }
1772
1773 #[test]
1774 fn test_task_command_spec_does_not_resolve_empty_command_for_shell_wrapper() {
1775 let task = Task {
1776 args: vec!["echo".to_string(), "hello".to_string()],
1777 shell: Some(Shell {
1778 command: Some("bash".to_string()),
1779 flag: Some("-c".to_string()),
1780 }),
1781 ..Default::default()
1782 };
1783
1784 let mut resolved_commands = Vec::new();
1785 let spec = task
1786 .command_spec(|command| {
1787 resolved_commands.push(command.to_string());
1788 format!("resolved:{command}")
1789 })
1790 .unwrap();
1791
1792 assert_eq!(resolved_commands, vec!["bash".to_string()]);
1793 assert_eq!(spec.program, "resolved:bash");
1794 assert_eq!(spec.args, vec!["-c".to_string(), "echo hello".to_string()]);
1795 }
1796
1797 #[test]
1798 fn test_input_deserialization_variants() {
1799 let path_json = r#""src/**/*.rs""#;
1800 let path_input: Input = serde_json::from_str(path_json).unwrap();
1801 assert_eq!(path_input, Input::Path("src/**/*.rs".to_string()));
1802
1803 let project_json = r#"{
1804 "project": "../projB",
1805 "task": "build",
1806 "map": [{"from": "dist/app.txt", "to": "vendor/app.txt"}]
1807 }"#;
1808 let project_input: Input = serde_json::from_str(project_json).unwrap();
1809 match project_input {
1810 Input::Project(reference) => {
1811 assert_eq!(reference.project, "../projB");
1812 assert_eq!(reference.task, "build");
1813 assert_eq!(reference.map.len(), 1);
1814 assert_eq!(reference.map[0].from, "dist/app.txt");
1815 assert_eq!(reference.map[0].to, "vendor/app.txt");
1816 }
1817 other => panic!("Expected project reference, got {:?}", other),
1818 }
1819
1820 let task_json = r#"{"task": "build.deps"}"#;
1822 let task_input: Input = serde_json::from_str(task_json).unwrap();
1823 match task_input {
1824 Input::Task(output) => {
1825 assert_eq!(output.task, "build.deps");
1826 assert!(output.map.is_none());
1827 }
1828 other => panic!("Expected task output reference, got {:?}", other),
1829 }
1830 }
1831
1832 #[test]
1833 fn test_task_input_helpers_collect() {
1834 use std::collections::HashSet;
1835 use std::path::Path;
1836
1837 let task = Task {
1838 inputs: vec![
1839 Input::Path("src".into()),
1840 Input::Project(ProjectReference {
1841 project: "../projB".into(),
1842 task: "build".into(),
1843 map: vec![Mapping {
1844 from: "dist/app.txt".into(),
1845 to: "vendor/app.txt".into(),
1846 }],
1847 }),
1848 ],
1849 ..Default::default()
1850 };
1851
1852 let path_inputs: Vec<String> = task.iter_path_inputs().cloned().collect();
1853 assert_eq!(path_inputs, vec!["src".to_string()]);
1854
1855 let project_refs: Vec<&ProjectReference> = task.iter_project_refs().collect();
1856 assert_eq!(project_refs.len(), 1);
1857 assert_eq!(project_refs[0].project, "../projB");
1858
1859 let prefix = Path::new("prefix");
1860 let collected = task.collect_all_inputs_with_prefix(Some(prefix));
1861 let collected: HashSet<_> = collected
1862 .into_iter()
1863 .map(std::path::PathBuf::from)
1864 .collect();
1865 let expected: HashSet<_> = ["src", "vendor/app.txt"]
1866 .into_iter()
1867 .map(|p| prefix.join(p))
1868 .collect();
1869 assert_eq!(collected, expected);
1870 }
1871
1872 #[test]
1873 fn test_resolved_args_interpolate_positional() {
1874 let args = ResolvedArgs {
1875 positional: vec!["video123".into(), "1080p".into()],
1876 named: HashMap::new(),
1877 };
1878 assert_eq!(args.interpolate("{{0}}"), "video123");
1879 assert_eq!(args.interpolate("{{1}}"), "1080p");
1880 assert_eq!(args.interpolate("--id={{0}}"), "--id=video123");
1881 assert_eq!(args.interpolate("{{0}}-{{1}}"), "video123-1080p");
1882 }
1883
1884 #[test]
1885 fn test_resolved_args_interpolate_named() {
1886 let mut named = HashMap::new();
1887 named.insert("url".into(), "https://example.com".into());
1888 named.insert("quality".into(), "720p".into());
1889 let args = ResolvedArgs {
1890 positional: vec![],
1891 named,
1892 };
1893 assert_eq!(args.interpolate("{{url}}"), "https://example.com");
1894 assert_eq!(args.interpolate("--quality={{quality}}"), "--quality=720p");
1895 }
1896
1897 #[test]
1898 fn test_resolved_args_interpolate_mixed() {
1899 let mut named = HashMap::new();
1900 named.insert("format".into(), "mp4".into());
1901 let args = ResolvedArgs {
1902 positional: vec!["VIDEO_ID".into()],
1903 named,
1904 };
1905 assert_eq!(
1906 args.interpolate("download {{0}} --format={{format}}"),
1907 "download VIDEO_ID --format=mp4"
1908 );
1909 }
1910
1911 #[test]
1912 fn test_resolved_args_no_placeholder_unchanged() {
1913 let args = ResolvedArgs::new();
1914 assert_eq!(
1915 args.interpolate("no placeholders here"),
1916 "no placeholders here"
1917 );
1918 assert_eq!(args.interpolate(""), "");
1919 }
1920
1921 #[test]
1922 fn test_resolved_args_interpolate_args_list() {
1923 let args = ResolvedArgs {
1924 positional: vec!["id123".into()],
1925 named: HashMap::new(),
1926 };
1927 let input = vec!["--id".into(), "{{0}}".into(), "--verbose".into()];
1928 let result = args.interpolate_args(&input);
1929 assert_eq!(result, vec!["--id", "id123", "--verbose"]);
1930 }
1931
1932 #[test]
1933 fn test_task_params_deserialization_with_flatten() {
1934 let json = r#"{
1936 "positional": [{"description": "Video ID", "required": true}],
1937 "quality": {"description": "Quality", "default": "1080p", "short": "q"},
1938 "verbose": {"description": "Verbose output", "type": "bool"}
1939 }"#;
1940 let params: TaskParams = serde_json::from_str(json).unwrap();
1941
1942 assert_eq!(params.positional.len(), 1);
1943 assert_eq!(
1944 params.positional[0].description,
1945 Some("Video ID".to_string())
1946 );
1947 assert!(params.positional[0].required);
1948
1949 assert_eq!(params.named.len(), 2);
1950 assert!(params.named.contains_key("quality"));
1951 assert!(params.named.contains_key("verbose"));
1952
1953 let quality = ¶ms.named["quality"];
1954 assert_eq!(quality.default, Some("1080p".to_string()));
1955 assert_eq!(quality.short, Some("q".to_string()));
1956
1957 let verbose = ¶ms.named["verbose"];
1958 assert_eq!(verbose.param_type, ParamType::Bool);
1959 }
1960
1961 #[test]
1962 fn test_task_params_empty() {
1963 let json = r#"{}"#;
1964 let params: TaskParams = serde_json::from_str(json).unwrap();
1965 assert!(params.positional.is_empty());
1966 assert!(params.named.is_empty());
1967 }
1968
1969 #[test]
1970 fn test_param_def_defaults() {
1971 let def = ParamDef::default();
1972 assert!(def.description.is_none());
1973 assert!(!def.required);
1974 assert!(def.default.is_none());
1975 assert_eq!(def.param_type, ParamType::String);
1976 assert!(def.short.is_none());
1977 }
1978
1979 mod affected_tests {
1984 use super::*;
1985 use crate::AffectedBy;
1986 use std::path::PathBuf;
1987
1988 fn make_task(inputs: Vec<&str>) -> Task {
1989 Task {
1990 inputs: inputs
1991 .into_iter()
1992 .map(|s| Input::Path(s.to_string()))
1993 .collect(),
1994 command: "echo test".to_string(),
1995 ..Default::default()
1996 }
1997 }
1998
1999 #[test]
2000 fn test_task_no_inputs_always_affected() {
2001 let task = make_task(vec![]);
2002 let changed_files: Vec<PathBuf> = vec![];
2003 let root = Path::new(".");
2004
2005 assert!(task.is_affected_by(&changed_files, root));
2007 }
2008
2009 #[test]
2010 fn test_task_with_inputs_matching() {
2011 let task = make_task(vec!["src/**"]);
2012 let changed_files = vec![PathBuf::from("src/lib.rs")];
2013 let root = Path::new(".");
2014
2015 assert!(task.is_affected_by(&changed_files, root));
2016 }
2017
2018 #[test]
2019 fn test_task_with_inputs_not_matching() {
2020 let task = make_task(vec!["src/**"]);
2021 let changed_files = vec![PathBuf::from("docs/readme.md")];
2022 let root = Path::new(".");
2023
2024 assert!(!task.is_affected_by(&changed_files, root));
2025 }
2026
2027 #[test]
2028 fn test_task_with_project_root_path_normalization() {
2029 let task = make_task(vec!["src/**"]);
2030 let changed_files = vec![PathBuf::from("projects/website/src/app.rs")];
2032 let root = Path::new("projects/website");
2033
2034 assert!(task.is_affected_by(&changed_files, root));
2035 }
2036
2037 #[test]
2038 fn test_task_node_delegates_to_task() {
2039 let task = make_task(vec!["src/**"]);
2040 let node = TaskNode::Task(Box::new(task));
2041 let changed_files = vec![PathBuf::from("src/lib.rs")];
2042 let root = Path::new(".");
2043
2044 assert!(node.is_affected_by(&changed_files, root));
2045 }
2046
2047 #[test]
2048 fn test_task_group_any_affected() {
2049 let lint_task = make_task(vec!["src/**"]);
2050 let test_task = make_task(vec!["tests/**"]);
2051
2052 let mut parallel_tasks = HashMap::new();
2053 parallel_tasks.insert("lint".to_string(), TaskNode::Task(Box::new(lint_task)));
2054 parallel_tasks.insert("test".to_string(), TaskNode::Task(Box::new(test_task)));
2055
2056 let group = TaskGroup {
2057 type_: "group".to_string(),
2058 children: parallel_tasks,
2059 depends_on: vec![],
2060 max_concurrency: None,
2061 description: None,
2062 };
2063
2064 let changed_files = vec![PathBuf::from("src/lib.rs")];
2066 let root = Path::new(".");
2067
2068 assert!(group.is_affected_by(&changed_files, root));
2069 }
2070
2071 #[test]
2072 fn test_task_group_none_affected() {
2073 let lint_task = make_task(vec!["src/**"]);
2074 let test_task = make_task(vec!["tests/**"]);
2075
2076 let mut parallel_tasks = HashMap::new();
2077 parallel_tasks.insert("lint".to_string(), TaskNode::Task(Box::new(lint_task)));
2078 parallel_tasks.insert("test".to_string(), TaskNode::Task(Box::new(test_task)));
2079
2080 let group = TaskGroup {
2081 type_: "group".to_string(),
2082 children: parallel_tasks,
2083 depends_on: vec![],
2084 max_concurrency: None,
2085 description: None,
2086 };
2087
2088 let changed_files = vec![PathBuf::from("docs/readme.md")];
2090 let root = Path::new(".");
2091
2092 assert!(!group.is_affected_by(&changed_files, root));
2093 }
2094
2095 #[test]
2096 fn test_task_sequence_any_affected() {
2097 let build_task = make_task(vec!["src/**"]);
2098 let deploy_task = make_task(vec!["deploy/**"]);
2099
2100 let sequence = TaskNode::Sequence(vec![
2101 TaskNode::Task(Box::new(build_task)),
2102 TaskNode::Task(Box::new(deploy_task)),
2103 ]);
2104
2105 let changed_files = vec![PathBuf::from("src/lib.rs")];
2107 let root = Path::new(".");
2108
2109 assert!(sequence.is_affected_by(&changed_files, root));
2110 }
2111
2112 #[test]
2113 fn test_input_patterns_returns_patterns() {
2114 let task = make_task(vec!["src/**", "Cargo.toml"]);
2115 let patterns = task.input_patterns();
2116
2117 assert_eq!(patterns.len(), 2);
2118 assert!(patterns.contains(&"src/**"));
2119 assert!(patterns.contains(&"Cargo.toml"));
2120 }
2121 }
2122}