1use hashbrown::HashMap;
2use indicatif::{
3 HumanDuration,
4 ProgressBar,
5 ProgressStyle,
6};
7use rand::Rng as _;
8use serde::{
9 Deserialize,
10 Serialize,
11};
12
13use std::io::BufRead as _;
14use std::sync::mpsc::{
15 channel,
16 Receiver,
17 Sender,
18};
19use std::thread;
20use std::time::{
21 Duration,
22 Instant,
23};
24
25use super::{
26 contains_output_reference,
27 extract_output_references,
28 interpolate_template_string,
29 is_shell_command,
30 CommandRunner,
31 Precondition,
32 Shell,
33 TaskContext,
34 TaskDependency,
35};
36use crate::cache::{
37 compute_fingerprint,
38 expand_patterns_in_dir,
39 CacheEntry,
40};
41use crate::defaults::default_verbose;
42use crate::run_shell_command;
43use crate::secrets::load_secret_env;
44use crate::utils::{
45 deserialize_environment,
46 load_env_files_in_dir,
47 resolve_path,
48};
49
50fn default_cache_enabled() -> bool {
51 true
52}
53
54fn default_fail_fast() -> bool {
55 true
56}
57
58#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
59#[serde(rename_all = "snake_case")]
60pub enum ExecutionMode {
61 Sequential,
62 Parallel,
63}
64
65#[derive(Debug, Clone, Deserialize, Serialize)]
66pub struct TaskExecution {
67 #[serde(default)]
68 pub mode: Option<ExecutionMode>,
69
70 #[serde(default)]
71 pub max_parallel: Option<usize>,
72
73 #[serde(default = "default_fail_fast")]
74 pub fail_fast: bool,
75}
76
77#[derive(Debug, Clone, Deserialize, Serialize)]
78pub struct TaskCache {
79 #[serde(default = "default_cache_enabled")]
80 pub enabled: bool,
81}
82
83#[derive(Debug, Default, Deserialize)]
87pub struct TaskArgs {
88 pub commands: Vec<CommandRunner>,
90
91 #[serde(default)]
93 pub preconditions: Vec<Precondition>,
94
95 #[serde(default)]
97 pub depends_on: Vec<TaskDependency>,
98
99 #[serde(default)]
101 pub labels: HashMap<String, String>,
102
103 #[serde(default)]
105 pub description: String,
106
107 #[serde(default, deserialize_with = "deserialize_environment")]
109 pub environment: HashMap<String, String>,
110
111 #[serde(default)]
113 pub env_file: Vec<String>,
114
115 #[serde(default)]
117 pub secrets_path: Vec<String>,
118
119 #[serde(default)]
121 pub vault_location: Option<String>,
122
123 #[serde(default)]
125 pub keys_location: Option<String>,
126
127 #[serde(default)]
129 pub key_name: Option<String>,
130
131 #[serde(default)]
133 pub shell: Option<Shell>,
134
135 #[serde(default)]
138 pub parallel: Option<bool>,
139
140 #[serde(default)]
142 pub execution: Option<TaskExecution>,
143
144 #[serde(default)]
146 pub cache: Option<TaskCache>,
147
148 #[serde(default)]
150 pub inputs: Vec<String>,
151
152 #[serde(default)]
154 pub outputs: Vec<String>,
155
156 #[serde(default)]
158 pub ignore_errors: Option<bool>,
159
160 #[serde(default)]
162 pub verbose: Option<bool>,
163}
164
165#[derive(Debug, Deserialize)]
166#[serde(untagged)]
167pub enum Task {
168 String(String),
169 Task(Box<TaskArgs>),
170}
171
172#[derive(Debug)]
173pub struct CommandResult {
174 index: usize,
175 success: bool,
176 message: String,
177}
178
179impl Task {
180 pub fn run(&self, context: &mut TaskContext) -> anyhow::Result<()> {
181 match self {
182 Task::String(command) => self.execute(context, command),
183 Task::Task(args) => args.run(context),
184 }
185 }
186
187 fn execute(&self, context: &mut TaskContext, command: &str) -> anyhow::Result<()> {
188 assert!(!command.is_empty());
189
190 TaskArgs {
191 commands: vec![CommandRunner::CommandRun(command.to_string())],
192 ..Default::default()
193 }
194 .run(context)
195 }
196}
197
198impl TaskArgs {
199 pub fn run(&self, context: &mut TaskContext) -> anyhow::Result<()> {
200 assert!(!self.commands.is_empty());
201
202 self.validate_parallel_commands()?;
204
205 let started = Instant::now();
206 let tick_interval = Duration::from_millis(80);
207
208 if let Some(shell) = &self.shell {
209 context.set_shell(shell);
210 }
211
212 if let Some(ignore_errors) = &self.ignore_errors {
213 context.set_ignore_errors(*ignore_errors);
214 }
215
216 if let Some(verbose) = &self.verbose {
217 context.set_verbose(*verbose);
218 }
219
220 if !context.is_nested {
221 if let Some(vault_location) = &context.task_root.vault_location {
222 context.set_secret_vault_location(vault_location.clone());
223 }
224
225 if let Some(keys_location) = &context.task_root.keys_location {
226 context.set_secret_keys_location(keys_location.clone());
227 }
228
229 if let Some(key_name) = &context.task_root.key_name {
230 context.set_secret_key_name(key_name.clone());
231 }
232 }
233
234 if let Some(vault_location) = &self.vault_location {
235 context.set_secret_vault_location(vault_location.clone());
236 }
237
238 if let Some(keys_location) = &self.keys_location {
239 context.set_secret_keys_location(keys_location.clone());
240 }
241
242 if let Some(key_name) = &self.key_name {
243 context.set_secret_key_name(key_name.clone());
244 }
245
246 if !context.is_nested {
248 let config_base_dir = self.config_base_dir(context);
249 let root_env = context.task_root.environment.clone();
250 let root_env_files = load_env_files_in_dir(&context.task_root.env_file, &config_base_dir)?;
251 let root_secret_env = load_secret_env(
252 &context.task_root.secrets_path,
253 &config_base_dir,
254 context.secret_vault_location.as_deref(),
255 context.secret_keys_location.as_deref(),
256 context.secret_key_name.as_deref(),
257 )?;
258 context.extend_env_vars(root_env);
259 context.extend_env_vars(root_env_files);
260 context.extend_env_vars(root_secret_env);
261 }
262
263 let defined_env = self.load_static_env(context)?;
265 let additional_env = self.load_env_file(context)?;
266 let secret_env = self.load_secret_env(context)?;
267
268 context.extend_env_vars(defined_env);
269 context.extend_env_vars(additional_env);
270 context.extend_env_vars(secret_env);
271
272 if self.should_skip_from_cache(context)? {
273 context.emit_event(&serde_json::json!({
274 "event": "task_skipped",
275 "task": context.current_task_name.clone().unwrap_or_else(|| "<task>".to_string()),
276 "reason": "cache_hit",
277 }))?;
278 return Ok(());
279 }
280
281 let mut rng = rand::thread_rng();
282 let pb_style =
285 ProgressStyle::with_template("{spinner:.green} [{prefix:.bold.dim}] {wide_msg:.cyan/blue} ")?
286 .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⦿");
287
288 let depends_on_pb = context.multi.add(ProgressBar::new(self.depends_on.len() as u64));
289
290 if !self.depends_on.is_empty() {
291 depends_on_pb.set_style(pb_style.clone());
292 depends_on_pb.set_message("Running task dependencies...");
293 depends_on_pb.enable_steady_tick(tick_interval);
294 for (i, dependency) in self.depends_on.iter().enumerate() {
295 thread::sleep(Duration::from_millis(rng.gen_range(40..300)));
296 depends_on_pb.set_prefix(format!("{}/{}", i + 1, self.depends_on.len()));
297 dependency.run(context)?;
298 depends_on_pb.inc(1);
299 }
300
301 let message = format!("Dependencies completed in {}.", HumanDuration(started.elapsed()));
302 if context.is_nested {
303 depends_on_pb.finish_and_clear();
304 } else {
305 depends_on_pb.finish_with_message(message);
306 }
307 }
308
309 let precondition_pb = context
310 .multi
311 .add(ProgressBar::new(self.preconditions.len() as u64));
312
313 if !self.preconditions.is_empty() {
314 precondition_pb.set_style(pb_style.clone());
315 precondition_pb.set_message("Running task precondition...");
316 precondition_pb.enable_steady_tick(tick_interval);
317 for (i, precondition) in self.preconditions.iter().enumerate() {
318 thread::sleep(Duration::from_millis(rng.gen_range(40..300)));
319 precondition_pb.set_prefix(format!("{}/{}", i + 1, self.preconditions.len()));
320 precondition.execute(context)?;
321 precondition_pb.inc(1);
322 }
323
324 let message = format!("Preconditions completed in {}.", HumanDuration(started.elapsed()));
325 if context.is_nested {
326 precondition_pb.finish_and_clear();
327 } else {
328 precondition_pb.finish_with_message(message);
329 }
330 }
331
332 if self.is_parallel() {
333 self.execute_commands_parallel(context)?;
334 } else {
335 let command_pb = context.multi.add(ProgressBar::new(self.commands.len() as u64));
336 command_pb.set_style(pb_style);
337 command_pb.set_message("Running task command...");
338 command_pb.enable_steady_tick(tick_interval);
339 for (i, command) in self.commands.iter().enumerate() {
340 thread::sleep(Duration::from_millis(rng.gen_range(100..400)));
341 command_pb.set_prefix(format!("{}/{}", i + 1, self.commands.len()));
342 self.refresh_output_env(context)?;
343 command.execute(context)?;
344 command_pb.inc(1);
345 }
346
347 let message = format!("Commands completed in {}.", HumanDuration(started.elapsed()));
348 if context.is_nested {
349 command_pb.finish_and_clear();
350 } else {
351 command_pb.finish_with_message(message);
352 }
353 }
354
355 self.update_cache(context)?;
356
357 Ok(())
358 }
359
360 fn validate_parallel_commands(&self) -> anyhow::Result<()> {
362 if !self.is_parallel() {
363 return Ok(());
364 }
365
366 for command in &self.commands {
367 match command {
368 CommandRunner::LocalRun(local_run) if local_run.is_parallel_safe() => continue,
369 CommandRunner::LocalRun(_) => {
370 return Err(anyhow::anyhow!(
371 "Interactive local commands cannot be run in parallel"
372 ))
373 },
374 _ => {
375 return Err(anyhow::anyhow!(
376 "Parallel execution is only supported for non-interactive local commands"
377 ))
378 },
379 }
380 }
381 Ok(())
382 }
383
384 fn execute_commands_parallel(&self, context: &TaskContext) -> anyhow::Result<()> {
386 let (tx, rx): (Sender<CommandResult>, Receiver<CommandResult>) = channel();
387 let mut handles = vec![];
388 let command_count = self.commands.len();
389 let max_parallel = self.max_parallel().min(command_count.max(1));
390 let fail_fast = self.fail_fast();
391 let command_pb = context.multi.add(ProgressBar::new(command_count as u64));
392 command_pb.set_style(ProgressStyle::with_template(
393 "{spinner:.green} [{prefix:.bold.dim}] {wide_msg:.cyan/blue} ",
394 )?);
395 command_pb.set_prefix("?/?");
396 command_pb.set_message("Running task commands in parallel...");
397 command_pb.enable_steady_tick(Duration::from_millis(80));
398 let mut failures = Vec::new();
399
400 let commands: Vec<_> = self.commands.to_vec();
402
403 let mut completed = 0;
405
406 let mut iter = commands.into_iter().enumerate();
407 let mut running = 0usize;
408 let mut stop_scheduling = false;
409
410 while completed < command_count {
411 while !stop_scheduling && running < max_parallel {
412 let Some((i, command)) = iter.next() else {
413 break;
414 };
415
416 let tx = tx.clone();
417 let context = context.clone();
418
419 let handle = thread::spawn(move || {
420 let result = match command.execute(&context) {
421 Ok(_) => CommandResult {
422 index: i,
423 success: true,
424 message: format!("Command {} completed successfully", i + 1),
425 },
426 Err(e) => CommandResult {
427 index: i,
428 success: false,
429 message: format!("Command {} failed: {}", i + 1, e),
430 },
431 };
432 tx.send(result).unwrap();
433 });
434
435 handles.push(handle);
436 running += 1;
437 }
438
439 if running == 0 {
440 break;
441 }
442
443 match rx.recv() {
444 Ok(result) => {
445 running -= 1;
446 let index = result.index;
447 if !result.success && !context.ignore_errors() {
448 failures.push(result.message);
449 if fail_fast {
450 stop_scheduling = true;
451 }
452 }
453
454 completed += 1;
455 command_pb.set_prefix(format!("{}/{}", completed, command_count));
456 command_pb.inc(1);
457
458 command_pb.set_message(format!(
459 "Running task commands in parallel (completed {})",
460 index + 1
461 ));
462 },
463 Err(e) => {
464 command_pb.finish_with_message("Error receiving command results");
465 return Err(anyhow::anyhow!("Channel error: {}", e));
466 },
467 }
468 }
469
470 for handle in handles {
472 handle.join().unwrap();
473 }
474
475 if !failures.is_empty() {
476 command_pb.finish_with_message("Some commands failed");
477
478 failures.sort();
480 return Err(anyhow::anyhow!("Failed commands:\n{}", failures.join("\n")));
481 }
482
483 let message = "Commands completed in parallel";
484 if context.is_nested {
485 command_pb.finish_and_clear();
486 } else {
487 command_pb.finish_with_message(message);
488 }
489
490 Ok(())
491 }
492
493 fn load_static_env(&self, context: &TaskContext) -> anyhow::Result<HashMap<String, String>> {
494 let mut local_env: HashMap<String, String> = HashMap::new();
495 for (key, value) in &self.environment {
496 if contains_output_reference(value) {
497 continue;
498 }
499 let value = self.get_env_value(context, value)?;
500 local_env.insert(key.clone(), value);
501 }
502
503 Ok(local_env)
504 }
505
506 fn refresh_output_env(&self, context: &mut TaskContext) -> anyhow::Result<()> {
507 let mut updated_env = HashMap::new();
508
509 for (key, value) in &self.environment {
510 let output_refs = extract_output_references(value);
511 if output_refs.is_empty() {
512 continue;
513 }
514
515 let mut all_outputs_ready = true;
516 for output_name in &output_refs {
517 if !context.has_task_output(output_name)? {
518 all_outputs_ready = false;
519 break;
520 }
521 }
522 if !all_outputs_ready {
523 continue;
524 }
525
526 updated_env.insert(key.clone(), self.get_env_value(context, value)?);
527 }
528
529 context.extend_env_vars(updated_env);
530 Ok(())
531 }
532
533 fn load_env_file(&self, context: &TaskContext) -> anyhow::Result<HashMap<String, String>> {
534 load_env_files_in_dir(&self.env_file, &self.config_base_dir(context))
535 }
536
537 fn load_secret_env(&self, context: &TaskContext) -> anyhow::Result<HashMap<String, String>> {
538 load_secret_env(
539 &self.secrets_path,
540 &self.config_base_dir(context),
541 context.secret_vault_location.as_deref(),
542 context.secret_keys_location.as_deref(),
543 context.secret_key_name.as_deref(),
544 )
545 }
546
547 fn get_env_value(&self, context: &TaskContext, value_in: &str) -> anyhow::Result<String> {
548 if is_shell_command(value_in)? {
549 let verbose = self.verbose();
550 let mut cmd = self
551 .shell
552 .as_ref()
553 .map(|shell| shell.proc())
554 .unwrap_or_else(|| context.shell().proc());
555 let output = run_shell_command!(value_in, cmd, verbose);
556 Ok(output)
557 } else if super::is_template_command(value_in)? || contains_output_reference(value_in) {
558 Ok(interpolate_template_string(value_in, context)?)
559 } else {
560 Ok(value_in.to_string())
561 }
562 }
563
564 fn verbose(&self) -> bool {
565 self.verbose.unwrap_or(default_verbose())
566 }
567
568 pub(crate) fn execution_mode(&self) -> ExecutionMode {
569 self
570 .execution
571 .as_ref()
572 .and_then(|execution| execution.mode.clone())
573 .or_else(|| {
574 self.parallel.map(|parallel| {
575 if parallel {
576 ExecutionMode::Parallel
577 } else {
578 ExecutionMode::Sequential
579 }
580 })
581 })
582 .unwrap_or(ExecutionMode::Sequential)
583 }
584
585 pub(crate) fn is_parallel(&self) -> bool {
586 self.execution_mode().is_parallel()
587 }
588
589 pub(crate) fn max_parallel(&self) -> usize {
590 self
591 .execution
592 .as_ref()
593 .and_then(|execution| execution.max_parallel)
594 .unwrap_or(self.commands.len().max(1))
595 }
596
597 pub(crate) fn fail_fast(&self) -> bool {
598 self
599 .execution
600 .as_ref()
601 .map(|execution| execution.fail_fast)
602 .unwrap_or(true)
603 }
604
605 fn cache_enabled(&self) -> bool {
606 self.cache.as_ref().map(|cache| cache.enabled).unwrap_or(false)
607 }
608
609 fn should_skip_from_cache(&self, context: &TaskContext) -> anyhow::Result<bool> {
610 if context.force || !self.cache_enabled() || self.outputs.is_empty() {
611 return Ok(false);
612 }
613
614 let resolved_outputs = self.resolve_output_paths(context)?;
615 let outputs_exist = self
616 .resolve_output_paths(context)?
617 .iter()
618 .all(|output| output.exists());
619 if !outputs_exist {
620 return Ok(false);
621 }
622
623 let env_vars = sorted_env_vars(&context.env_vars);
624 let inputs = self.resolve_input_paths(context)?;
625 let mut env_files = self.resolve_env_file_paths(context);
626 env_files.extend(self.resolve_secret_paths(context));
627 env_files.sort();
628 env_files.dedup();
629 let fingerprint = compute_fingerprint(
630 &context
631 .current_task_name
632 .clone()
633 .unwrap_or_else(|| "<task>".to_string()),
634 &stable_task_debug(self),
635 &env_vars,
636 &inputs,
637 &env_files,
638 &resolved_outputs,
639 )?;
640
641 let store = context
642 .cache_store
643 .lock()
644 .map_err(|e| anyhow::anyhow!("Failed to lock cache store - {}", e))?;
645 let Some(entry) = store.tasks.get(&fingerprint_task_key(context, self)) else {
646 return Ok(false);
647 };
648
649 Ok(entry.fingerprint == fingerprint)
650 }
651
652 fn update_cache(&self, context: &TaskContext) -> anyhow::Result<()> {
653 if !self.cache_enabled() || self.outputs.is_empty() {
654 return Ok(());
655 }
656
657 let env_vars = sorted_env_vars(&context.env_vars);
658 let inputs = self.resolve_input_paths(context)?;
659 let mut env_files = self.resolve_env_file_paths(context);
660 env_files.extend(self.resolve_secret_paths(context));
661 env_files.sort();
662 env_files.dedup();
663 let resolved_outputs = self.resolve_output_paths(context)?;
664 let fingerprint = compute_fingerprint(
665 &context
666 .current_task_name
667 .clone()
668 .unwrap_or_else(|| "<task>".to_string()),
669 &stable_task_debug(self),
670 &env_vars,
671 &inputs,
672 &env_files,
673 &resolved_outputs,
674 )?;
675 let key = fingerprint_task_key(context, self);
676
677 {
678 let mut store = context
679 .cache_store
680 .lock()
681 .map_err(|e| anyhow::anyhow!("Failed to lock cache store - {}", e))?;
682 store.tasks.insert(
683 key,
684 CacheEntry {
685 fingerprint,
686 outputs: resolved_outputs
687 .iter()
688 .map(|path| path.to_string_lossy().into_owned())
689 .collect(),
690 updated_at: chrono::Utc::now().to_rfc3339(),
691 },
692 );
693 store.save_in_dir(&context.task_root.cache_base_dir())?;
694 }
695
696 Ok(())
697 }
698
699 pub(crate) fn config_base_dir_from_root(&self, root: &super::TaskRoot) -> std::path::PathBuf {
700 root.config_base_dir()
701 }
702
703 pub(crate) fn task_base_dir_from_root(&self, root: &super::TaskRoot) -> std::path::PathBuf {
704 let config_base_dir = self.config_base_dir_from_root(root);
705 let mut work_dirs = self
706 .commands
707 .iter()
708 .filter_map(|command| match command {
709 CommandRunner::LocalRun(local_run) => local_run.work_dir.as_ref(),
710 _ => None,
711 })
712 .map(|work_dir| resolve_path(&config_base_dir, work_dir))
713 .collect::<Vec<_>>();
714
715 work_dirs.sort();
716 work_dirs.dedup();
717
718 if work_dirs.len() == 1 {
719 work_dirs.remove(0)
720 } else {
721 config_base_dir
722 }
723 }
724
725 fn config_base_dir(&self, context: &TaskContext) -> std::path::PathBuf {
726 self.config_base_dir_from_root(&context.task_root)
727 }
728
729 fn task_base_dir(&self, context: &TaskContext) -> std::path::PathBuf {
730 self.task_base_dir_from_root(&context.task_root)
731 }
732
733 fn resolve_input_paths(&self, context: &TaskContext) -> anyhow::Result<Vec<std::path::PathBuf>> {
734 expand_patterns_in_dir(&self.task_base_dir(context), &self.inputs)
735 }
736
737 fn resolve_output_paths(&self, context: &TaskContext) -> anyhow::Result<Vec<std::path::PathBuf>> {
738 let base_dir = self.task_base_dir(context);
739 Ok(
740 self
741 .outputs
742 .iter()
743 .map(|output| resolve_path(&base_dir, output))
744 .collect(),
745 )
746 }
747
748 fn resolve_env_file_paths(&self, context: &TaskContext) -> Vec<std::path::PathBuf> {
749 let config_base_dir = self.config_base_dir(context);
750 let mut env_files = context
751 .task_root
752 .env_file
753 .iter()
754 .chain(self.env_file.iter())
755 .map(|env_file| resolve_path(&config_base_dir, env_file))
756 .collect::<Vec<_>>();
757 env_files.sort();
758 env_files.dedup();
759 env_files
760 }
761
762 fn resolve_secret_paths(&self, context: &TaskContext) -> Vec<std::path::PathBuf> {
763 let config_base_dir = self.config_base_dir(context);
764 let vault_location = context
765 .secret_vault_location
766 .as_deref()
767 .map(|path| resolve_path(&config_base_dir, path))
768 .unwrap_or_else(|| resolve_path(&config_base_dir, "./.mk/vault"));
769 let mut secret_paths = context
770 .task_root
771 .secrets_path
772 .iter()
773 .chain(self.secrets_path.iter())
774 .map(|secret_path| vault_location.join(secret_path))
775 .collect::<Vec<_>>();
776 secret_paths.sort();
777 secret_paths.dedup();
778 secret_paths
779 }
780}
781
782impl ExecutionMode {
783 fn is_parallel(&self) -> bool {
784 matches!(self, ExecutionMode::Parallel)
785 }
786}
787
788fn sorted_env_vars(env_vars: &HashMap<String, String>) -> Vec<(String, String)> {
789 let mut pairs: Vec<_> = env_vars
790 .iter()
791 .map(|(key, value)| (key.clone(), value.clone()))
792 .collect();
793 pairs.sort();
794 pairs
795}
796
797fn fingerprint_task_key(context: &TaskContext, task: &TaskArgs) -> String {
798 context.current_task_name.clone().unwrap_or_else(|| {
799 if !task.description.is_empty() {
800 task.description.clone()
801 } else {
802 format!("{:?}", task.commands)
803 }
804 })
805}
806
807fn stable_task_debug(task: &TaskArgs) -> String {
808 let mut labels: Vec<_> = task
809 .labels
810 .iter()
811 .map(|(key, value)| (key.clone(), value.clone()))
812 .collect();
813 labels.sort();
814
815 let mut environment: Vec<_> = task
816 .environment
817 .iter()
818 .map(|(key, value)| (key.clone(), value.clone()))
819 .collect();
820 environment.sort();
821
822 let mut secrets_path = task.secrets_path.clone();
823 secrets_path.sort();
824
825 format!(
826 "commands={:?};preconditions={:?};depends_on={:?};labels={:?};description={:?};environment={:?};env_file={:?};secrets_path={:?};vault_location={:?};keys_location={:?};key_name={:?};shell={:?};execution_mode={:?};max_parallel={:?};fail_fast={};cache_enabled={};inputs={:?};outputs={:?};ignore_errors={:?};verbose={:?}",
827 task.commands,
828 task.preconditions,
829 task.depends_on,
830 labels,
831 task.description,
832 environment,
833 task.env_file,
834 secrets_path,
835 task.vault_location,
836 task.keys_location,
837 task.key_name,
838 task.shell,
839 task.execution_mode(),
840 task.execution.as_ref().and_then(|execution| execution.max_parallel),
841 task.fail_fast(),
842 task.cache_enabled(),
843 task.inputs,
844 task.outputs,
845 task.ignore_errors,
846 task.verbose
847 )
848}
849
850#[cfg(test)]
851mod test {
852 use super::*;
853
854 #[test]
855 fn test_task_1() -> anyhow::Result<()> {
856 {
857 let yaml = "
858 commands:
859 - command: echo \"Hello, World!\"
860 ignore_errors: false
861 verbose: false
862 depends_on:
863 - name: task1
864 description: This is a task
865 environment:
866 FOO: bar
867 env_file:
868 - test.env
869 - test2.env
870 ";
871
872 let task = serde_yaml::from_str::<Task>(yaml)?;
873
874 if let Task::Task(task) = &task {
875 if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
876 assert_eq!(local_run.command, "echo \"Hello, World!\"");
877 assert_eq!(local_run.work_dir, None);
878 assert_eq!(local_run.ignore_errors, Some(false));
879 assert_eq!(local_run.verbose, Some(false));
880 }
881
882 if let TaskDependency::TaskDependency(args) = &task.depends_on[0] {
883 assert_eq!(args.name, "task1");
884 }
885
886 assert_eq!(task.labels.len(), 0);
887 assert_eq!(task.description, "This is a task");
888 assert_eq!(task.environment.len(), 1);
889 assert_eq!(task.env_file.len(), 2);
890 } else {
891 panic!("Expected Task::Task");
892 }
893
894 Ok(())
895 }
896 }
897
898 #[test]
899 fn test_task_2() -> anyhow::Result<()> {
900 {
901 let yaml = "
902 commands:
903 - command: echo 'Hello, World!'
904 ignore_errors: false
905 verbose: false
906 description: This is a task
907 environment:
908 FOO: bar
909 BAR: foo
910 ";
911
912 let task = serde_yaml::from_str::<Task>(yaml)?;
913
914 if let Task::Task(task) = &task {
915 if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
916 assert_eq!(local_run.command, "echo 'Hello, World!'");
917 assert_eq!(local_run.work_dir, None);
918 assert_eq!(local_run.ignore_errors, Some(false));
919 assert_eq!(local_run.verbose, Some(false));
920 }
921
922 assert_eq!(task.description, "This is a task");
923 assert_eq!(task.depends_on.len(), 0);
924 assert_eq!(task.labels.len(), 0);
925 assert_eq!(task.env_file.len(), 0);
926 assert_eq!(task.environment.len(), 2);
927 } else {
928 panic!("Expected Task::Task");
929 }
930
931 Ok(())
932 }
933 }
934
935 #[test]
936 fn test_task_3() -> anyhow::Result<()> {
937 {
938 let yaml = "
939 commands:
940 - command: echo 'Hello, World!'
941 ";
942
943 let task = serde_yaml::from_str::<Task>(yaml)?;
944
945 if let Task::Task(task) = &task {
946 if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
947 assert_eq!(local_run.command, "echo 'Hello, World!'");
948 assert_eq!(local_run.work_dir, None);
949 assert_eq!(local_run.ignore_errors, None);
950 assert_eq!(local_run.verbose, None);
951 }
952
953 assert_eq!(task.description.len(), 0);
954 assert_eq!(task.depends_on.len(), 0);
955 assert_eq!(task.labels.len(), 0);
956 assert_eq!(task.env_file.len(), 0);
957 assert_eq!(task.environment.len(), 0);
958 } else {
959 panic!("Expected Task::Task");
960 }
961
962 Ok(())
963 }
964 }
965
966 #[test]
967 fn test_task_4() -> anyhow::Result<()> {
968 {
969 let yaml = "
970 commands:
971 - container_command:
972 - echo
973 - Hello, World!
974 image: docker.io/library/hello-world:latest
975 ";
976
977 let task = serde_yaml::from_str::<Task>(yaml)?;
978
979 if let Task::Task(task) = &task {
980 if let CommandRunner::ContainerRun(container_run) = &task.commands[0] {
981 assert_eq!(container_run.container_command.len(), 2);
982 assert_eq!(container_run.container_command[0], "echo");
983 assert_eq!(container_run.container_command[1], "Hello, World!");
984 assert_eq!(container_run.image, "docker.io/library/hello-world:latest");
985 assert_eq!(container_run.mounted_paths, Vec::<String>::new());
986 assert_eq!(container_run.ignore_errors, None);
987 assert_eq!(container_run.verbose, None);
988 }
989
990 assert_eq!(task.description.len(), 0);
991 assert_eq!(task.depends_on.len(), 0);
992 assert_eq!(task.labels.len(), 0);
993 assert_eq!(task.env_file.len(), 0);
994 assert_eq!(task.environment.len(), 0);
995 } else {
996 panic!("Expected Task::Task");
997 }
998
999 Ok(())
1000 }
1001 }
1002
1003 #[test]
1004 fn test_task_5() -> anyhow::Result<()> {
1005 {
1006 let yaml = "
1007 commands:
1008 - container_command:
1009 - echo
1010 - Hello, World!
1011 image: docker.io/library/hello-world:latest
1012 mounted_paths:
1013 - /tmp
1014 - /var/tmp
1015 ";
1016
1017 let task = serde_yaml::from_str::<Task>(yaml)?;
1018
1019 if let Task::Task(task) = &task {
1020 if let CommandRunner::ContainerRun(container_run) = &task.commands[0] {
1021 assert_eq!(container_run.container_command.len(), 2);
1022 assert_eq!(container_run.container_command[0], "echo");
1023 assert_eq!(container_run.container_command[1], "Hello, World!");
1024 assert_eq!(container_run.image, "docker.io/library/hello-world:latest");
1025 assert_eq!(container_run.mounted_paths, vec!["/tmp", "/var/tmp"]);
1026 assert_eq!(container_run.ignore_errors, None);
1027 assert_eq!(container_run.verbose, None);
1028 }
1029
1030 assert_eq!(task.description.len(), 0);
1031 assert_eq!(task.depends_on.len(), 0);
1032 assert_eq!(task.labels.len(), 0);
1033 assert_eq!(task.env_file.len(), 0);
1034 assert_eq!(task.environment.len(), 0);
1035 } else {
1036 panic!("Expected Task::Task");
1037 }
1038
1039 Ok(())
1040 }
1041 }
1042
1043 #[test]
1044 fn test_task_6() -> anyhow::Result<()> {
1045 {
1046 let yaml = "
1047 commands:
1048 - container_command:
1049 - echo
1050 - Hello, World!
1051 image: docker.io/library/hello-world:latest
1052 mounted_paths:
1053 - /tmp
1054 - /var/tmp
1055 ignore_errors: true
1056 ";
1057
1058 let task = serde_yaml::from_str::<Task>(yaml)?;
1059
1060 if let Task::Task(task) = &task {
1061 if let CommandRunner::ContainerRun(container_run) = &task.commands[0] {
1062 assert_eq!(container_run.container_command.len(), 2);
1063 assert_eq!(container_run.container_command[0], "echo");
1064 assert_eq!(container_run.container_command[1], "Hello, World!");
1065 assert_eq!(container_run.image, "docker.io/library/hello-world:latest");
1066 assert_eq!(container_run.mounted_paths, vec!["/tmp", "/var/tmp"]);
1067 assert_eq!(container_run.ignore_errors, Some(true));
1068 assert_eq!(container_run.verbose, None);
1069 }
1070
1071 assert_eq!(task.description.len(), 0);
1072 assert_eq!(task.depends_on.len(), 0);
1073 assert_eq!(task.labels.len(), 0);
1074 assert_eq!(task.env_file.len(), 0);
1075 assert_eq!(task.environment.len(), 0);
1076 } else {
1077 panic!("Expected Task::Task");
1078 }
1079
1080 Ok(())
1081 }
1082 }
1083
1084 #[test]
1085 fn test_task_7() -> anyhow::Result<()> {
1086 {
1087 let yaml = "
1088 commands:
1089 - container_command:
1090 - echo
1091 - Hello, World!
1092 image: docker.io/library/hello-world:latest
1093 verbose: false
1094 ";
1095
1096 let task = serde_yaml::from_str::<Task>(yaml)?;
1097
1098 if let Task::Task(task) = &task {
1099 if let CommandRunner::ContainerRun(container_run) = &task.commands[0] {
1100 assert_eq!(container_run.container_command.len(), 2);
1101 assert_eq!(container_run.container_command[0], "echo");
1102 assert_eq!(container_run.container_command[1], "Hello, World!");
1103 assert_eq!(container_run.image, "docker.io/library/hello-world:latest");
1104 assert_eq!(container_run.mounted_paths, Vec::<String>::new());
1105 assert_eq!(container_run.ignore_errors, None);
1106 assert_eq!(container_run.verbose, Some(false));
1107 }
1108
1109 assert_eq!(task.description.len(), 0);
1110 assert_eq!(task.depends_on.len(), 0);
1111 assert_eq!(task.labels.len(), 0);
1112 assert_eq!(task.env_file.len(), 0);
1113 assert_eq!(task.environment.len(), 0);
1114 } else {
1115 panic!("Expected Task::Task");
1116 }
1117
1118 Ok(())
1119 }
1120 }
1121
1122 #[test]
1123 fn test_task_8() -> anyhow::Result<()> {
1124 {
1125 let yaml = "
1126 commands:
1127 - task: task1
1128 ";
1129
1130 let task = serde_yaml::from_str::<Task>(yaml)?;
1131
1132 if let Task::Task(task) = &task {
1133 if let CommandRunner::TaskRun(task_run) = &task.commands[0] {
1134 assert_eq!(task_run.task, "task1");
1135 assert_eq!(task_run.ignore_errors, None);
1136 assert_eq!(task_run.verbose, None);
1137 }
1138
1139 assert_eq!(task.description.len(), 0);
1140 assert_eq!(task.depends_on.len(), 0);
1141 assert_eq!(task.labels.len(), 0);
1142 assert_eq!(task.env_file.len(), 0);
1143 assert_eq!(task.environment.len(), 0);
1144 } else {
1145 panic!("Expected Task::Task");
1146 }
1147
1148 Ok(())
1149 }
1150 }
1151
1152 #[test]
1153 fn test_task_9() -> anyhow::Result<()> {
1154 {
1155 let yaml = "
1156 commands:
1157 - task: task1
1158 verbose: true
1159 ";
1160
1161 let task = serde_yaml::from_str::<Task>(yaml)?;
1162
1163 if let Task::Task(task) = &task {
1164 if let CommandRunner::TaskRun(task_run) = &task.commands[0] {
1165 assert_eq!(task_run.task, "task1");
1166 assert_eq!(task_run.ignore_errors, None);
1167 assert_eq!(task_run.verbose, Some(true));
1168 }
1169
1170 assert_eq!(task.description.len(), 0);
1171 assert_eq!(task.depends_on.len(), 0);
1172 assert_eq!(task.labels.len(), 0);
1173 assert_eq!(task.env_file.len(), 0);
1174 assert_eq!(task.environment.len(), 0);
1175 } else {
1176 panic!("Expected Task::Task");
1177 }
1178
1179 Ok(())
1180 }
1181 }
1182
1183 #[test]
1184 fn test_task_10() -> anyhow::Result<()> {
1185 {
1186 let yaml = "
1187 commands:
1188 - task: task1
1189 ignore_errors: true
1190 ";
1191
1192 let task = serde_yaml::from_str::<Task>(yaml)?;
1193
1194 if let Task::Task(task) = &task {
1195 if let CommandRunner::TaskRun(task_run) = &task.commands[0] {
1196 assert_eq!(task_run.task, "task1");
1197 assert_eq!(task_run.ignore_errors, Some(true));
1198 assert_eq!(task_run.verbose, None);
1199 }
1200
1201 assert_eq!(task.description.len(), 0);
1202 assert_eq!(task.depends_on.len(), 0);
1203 assert_eq!(task.labels.len(), 0);
1204 assert_eq!(task.env_file.len(), 0);
1205 assert_eq!(task.environment.len(), 0);
1206 } else {
1207 panic!("Expected Task::Task");
1208 }
1209
1210 Ok(())
1211 }
1212 }
1213
1214 #[test]
1215 fn test_task_11() -> anyhow::Result<()> {
1216 {
1217 let yaml = "
1218 echo 'Hello, World!'
1219 ";
1220
1221 let task = serde_yaml::from_str::<Task>(yaml)?;
1222
1223 if let Task::String(task) = &task {
1224 assert_eq!(task, "echo 'Hello, World!'");
1225 } else {
1226 panic!("Expected Task::String");
1227 }
1228
1229 Ok(())
1230 }
1231 }
1232
1233 #[test]
1234 fn test_task_12() -> anyhow::Result<()> {
1235 {
1236 let yaml = "
1237 'true'
1238 ";
1239
1240 let task = serde_yaml::from_str::<Task>(yaml)?;
1241
1242 if let Task::String(task) = &task {
1243 assert_eq!(task, "true");
1244 } else {
1245 panic!("Expected Task::String");
1246 }
1247
1248 Ok(())
1249 }
1250 }
1251
1252 #[test]
1253 fn test_task_13() -> anyhow::Result<()> {
1254 {
1255 let yaml = "
1256 commands: []
1257 environment:
1258 FOO: bar
1259 BAR: foo
1260 KEY: 42
1261 PIS: 3.14
1262 ";
1263
1264 let task = serde_yaml::from_str::<Task>(yaml)?;
1265
1266 if let Task::Task(task) = &task {
1267 assert_eq!(task.environment.len(), 4);
1268 assert_eq!(task.environment.get("FOO").unwrap(), "bar");
1269 assert_eq!(task.environment.get("BAR").unwrap(), "foo");
1270 assert_eq!(task.environment.get("KEY").unwrap(), "42");
1271 } else {
1272 panic!("Expected Task::Task");
1273 }
1274
1275 Ok(())
1276 }
1277 }
1278
1279 #[test]
1280 fn test_task_14() -> anyhow::Result<()> {
1281 let yaml = "
1282 commands: []
1283 secrets_path:
1284 - app/common
1285 vault_location: ./.mk/vault
1286 keys_location: ./.mk/keys
1287 key_name: team
1288 environment:
1289 SECRET_VALUE: ${{ secrets.app/password }}
1290 ";
1291
1292 let task = serde_yaml::from_str::<Task>(yaml)?;
1293
1294 if let Task::Task(task) = &task {
1295 assert_eq!(task.secrets_path, vec!["app/common"]);
1296 assert_eq!(task.vault_location.as_deref(), Some("./.mk/vault"));
1297 assert_eq!(task.keys_location.as_deref(), Some("./.mk/keys"));
1298 assert_eq!(task.key_name.as_deref(), Some("team"));
1299 assert_eq!(
1300 task.environment.get("SECRET_VALUE").map(String::as_str),
1301 Some("${{ secrets.app/password }}")
1302 );
1303 } else {
1304 panic!("Expected Task::Task");
1305 }
1306
1307 Ok(())
1308 }
1309
1310 #[test]
1311 fn test_parallel_interactive_rejected() -> anyhow::Result<()> {
1312 let yaml = r#"
1313 commands:
1314 - command: "echo hello"
1315 interactive: true
1316 - command: "echo world"
1317 parallel: true
1318 "#;
1319
1320 let task = serde_yaml::from_str::<Task>(yaml)?;
1321 let mut context = TaskContext::empty();
1322
1323 if let Task::Task(task) = task {
1324 let result = task.run(&mut context);
1325 assert!(result.is_err());
1326 assert!(result
1327 .unwrap_err()
1328 .to_string()
1329 .contains("Interactive local commands cannot be run in parallel"));
1330 }
1331
1332 Ok(())
1333 }
1334
1335 #[test]
1336 fn test_parallel_non_interactive_accepted() -> anyhow::Result<()> {
1337 let yaml = r#"
1338 commands:
1339 - command: "echo hello"
1340 interactive: false
1341 - command: "echo world"
1342 parallel: true
1343 "#;
1344
1345 let task = serde_yaml::from_str::<Task>(yaml)?;
1346 let mut context = TaskContext::empty();
1347
1348 if let Task::Task(task) = task {
1349 let result = task.run(&mut context);
1350 assert!(result.is_ok());
1351 }
1352
1353 Ok(())
1354 }
1355}