Skip to main content

mk_lib/schema/
task.rs

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/// This struct represents a task that can be executed. A task can contain multiple
84/// commands that are executed sequentially. A task can also have preconditions that
85/// must be met before the task can be executed.
86#[derive(Debug, Default, Deserialize)]
87pub struct TaskArgs {
88  /// The commands to run
89  pub commands: Vec<CommandRunner>,
90
91  /// The preconditions that must be met before the task can be executed
92  #[serde(default)]
93  pub preconditions: Vec<Precondition>,
94
95  /// The tasks that must be executed before this task can be executed
96  #[serde(default)]
97  pub depends_on: Vec<TaskDependency>,
98
99  /// The labels for the task
100  #[serde(default)]
101  pub labels: HashMap<String, String>,
102
103  /// The description of the task
104  #[serde(default)]
105  pub description: String,
106
107  /// The environment variables to set before running the task
108  #[serde(default, deserialize_with = "deserialize_environment")]
109  pub environment: HashMap<String, String>,
110
111  /// The environment files to load before running the task
112  #[serde(default)]
113  pub env_file: Vec<String>,
114
115  /// Secret paths to load as dotenv-style environment entries before running the task
116  #[serde(default)]
117  pub secrets_path: Vec<String>,
118
119  /// The path to the secret vault
120  #[serde(default)]
121  pub vault_location: Option<String>,
122
123  /// The path to the private keys used for secret decryption
124  #[serde(default)]
125  pub keys_location: Option<String>,
126
127  /// The key name to use for secret decryption
128  #[serde(default)]
129  pub key_name: Option<String>,
130
131  /// The shell to use when running the task
132  #[serde(default)]
133  pub shell: Option<Shell>,
134
135  /// Run the commands in parallel
136  /// It should only work if the task are local_run commands
137  #[serde(default)]
138  pub parallel: Option<bool>,
139
140  /// Richer execution configuration
141  #[serde(default)]
142  pub execution: Option<TaskExecution>,
143
144  /// Task caching configuration
145  #[serde(default)]
146  pub cache: Option<TaskCache>,
147
148  /// Files or glob patterns that affect task output
149  #[serde(default)]
150  pub inputs: Vec<String>,
151
152  /// Files produced by the task
153  #[serde(default)]
154  pub outputs: Vec<String>,
155
156  /// Ignore errors if the task fails
157  #[serde(default)]
158  pub ignore_errors: Option<bool>,
159
160  /// Show verbose output
161  #[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    // Validate parallel execution requirements early
203    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    // Load environment variables from root and task environments and env files.
247    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    // Load environment variables from the task environment and env files field
264    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    // Spinners can be found here:
283    // https://github.com/sindresorhus/cli-spinners/blob/main/spinners.json
284    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  /// Validate if the task can be run in parallel
361  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  /// Execute the commands in parallel
385  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    // Clone all commands upfront to avoid borrowing issues
401    let commands: Vec<_> = self.commands.to_vec();
402
403    // Track results in order
404    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    // Wait for all threads to complete
471    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      // Sort failures by command index for clearer error reporting
479      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}