mk_lib/schema/
task.rs

1use anyhow::Context;
2use hashbrown::HashMap;
3use indicatif::{
4  HumanDuration,
5  ProgressBar,
6  ProgressStyle,
7};
8use rand::Rng as _;
9use serde::Deserialize;
10
11use std::io::BufRead as _;
12use std::sync::mpsc::{
13  channel,
14  Receiver,
15  Sender,
16};
17use std::time::{
18  Duration,
19  Instant,
20};
21use std::{
22  fs,
23  thread,
24};
25
26use super::{
27  is_shell_command,
28  CommandRunner,
29  Precondition,
30  Shell,
31  TaskContext,
32  TaskDependency,
33};
34use crate::defaults::default_verbose;
35use crate::run_shell_command;
36use crate::utils::deserialize_environment;
37
38/// This struct represents a task that can be executed. A task can contain multiple
39/// commands that are executed sequentially. A task can also have preconditions that
40/// must be met before the task can be executed.
41#[derive(Debug, Default, Deserialize)]
42pub struct TaskArgs {
43  /// The commands to run
44  pub commands: Vec<CommandRunner>,
45
46  /// The preconditions that must be met before the task can be executed
47  #[serde(default)]
48  pub preconditions: Vec<Precondition>,
49
50  /// The tasks that must be executed before this task can be executed
51  #[serde(default)]
52  pub depends_on: Vec<TaskDependency>,
53
54  /// The labels for the task
55  #[serde(default)]
56  pub labels: HashMap<String, String>,
57
58  /// The description of the task
59  #[serde(default)]
60  pub description: String,
61
62  /// The environment variables to set before running the task
63  #[serde(default, deserialize_with = "deserialize_environment")]
64  pub environment: HashMap<String, String>,
65
66  /// The environment files to load before running the task
67  #[serde(default)]
68  pub env_file: Vec<String>,
69
70  /// The shell to use when running the task
71  #[serde(default)]
72  pub shell: Option<Shell>,
73
74  /// Run the commands in parallel
75  /// It should only work if the task are local_run commands
76  #[serde(default)]
77  pub parallel: Option<bool>,
78
79  /// Ignore errors if the task fails
80  #[serde(default)]
81  pub ignore_errors: Option<bool>,
82
83  /// Show verbose output
84  #[serde(default)]
85  pub verbose: Option<bool>,
86}
87
88#[derive(Debug, Deserialize)]
89#[serde(untagged)]
90pub enum Task {
91  String(String),
92  Task(Box<TaskArgs>),
93}
94
95#[derive(Debug)]
96pub struct CommandResult {
97  index: usize,
98  success: bool,
99  message: String,
100}
101
102impl Task {
103  pub fn run(&self, context: &mut TaskContext) -> anyhow::Result<()> {
104    match self {
105      Task::String(command) => self.execute(context, command),
106      Task::Task(args) => args.run(context),
107    }
108  }
109
110  fn execute(&self, context: &mut TaskContext, command: &str) -> anyhow::Result<()> {
111    assert!(!command.is_empty());
112
113    TaskArgs {
114      commands: vec![CommandRunner::CommandRun(command.to_string())],
115      ..Default::default()
116    }
117    .run(context)
118  }
119}
120
121impl TaskArgs {
122  pub fn run(&self, context: &mut TaskContext) -> anyhow::Result<()> {
123    assert!(!self.commands.is_empty());
124
125    // Validate parallel execution requirements early
126    self.validate_parallel_commands()?;
127
128    let started = Instant::now();
129    let tick_interval = Duration::from_millis(80);
130
131    if let Some(shell) = &self.shell {
132      context.set_shell(shell);
133    }
134
135    if let Some(ignore_errors) = &self.ignore_errors {
136      context.set_ignore_errors(*ignore_errors);
137    }
138
139    if let Some(verbose) = &self.verbose {
140      context.set_verbose(*verbose);
141    }
142
143    // Load environment variables from the task environment and env files field
144    let defined_env = self.load_env(context)?;
145    let additional_env = self.load_env_file()?;
146
147    context.extend_env_vars(defined_env);
148    context.extend_env_vars(additional_env);
149
150    let mut rng = rand::thread_rng();
151    // Spinners can be found here:
152    // https://github.com/sindresorhus/cli-spinners/blob/main/spinners.json
153    let pb_style =
154      ProgressStyle::with_template("{spinner:.green} [{prefix:.bold.dim}] {wide_msg:.cyan/blue} ")?
155        .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⦿");
156
157    let depends_on_pb = context.multi.add(ProgressBar::new(self.depends_on.len() as u64));
158
159    if !self.depends_on.is_empty() {
160      depends_on_pb.set_style(pb_style.clone());
161      depends_on_pb.set_message("Running task dependencies...");
162      depends_on_pb.enable_steady_tick(tick_interval);
163      for (i, dependency) in self.depends_on.iter().enumerate() {
164        thread::sleep(Duration::from_millis(rng.gen_range(40..300)));
165        depends_on_pb.set_prefix(format!("{}/{}", i + 1, self.depends_on.len()));
166        dependency.run(context)?;
167        depends_on_pb.inc(1);
168      }
169
170      let message = format!("Dependencies completed in {}.", HumanDuration(started.elapsed()));
171      if context.is_nested {
172        depends_on_pb.finish_and_clear();
173      } else {
174        depends_on_pb.finish_with_message(message);
175      }
176    }
177
178    let precondition_pb = context
179      .multi
180      .add(ProgressBar::new(self.preconditions.len() as u64));
181
182    if !self.preconditions.is_empty() {
183      precondition_pb.set_style(pb_style.clone());
184      precondition_pb.set_message("Running task precondition...");
185      precondition_pb.enable_steady_tick(tick_interval);
186      for (i, precondition) in self.preconditions.iter().enumerate() {
187        thread::sleep(Duration::from_millis(rng.gen_range(40..300)));
188        precondition_pb.set_prefix(format!("{}/{}", i + 1, self.preconditions.len()));
189        precondition.execute(context)?;
190        precondition_pb.inc(1);
191      }
192
193      let message = format!("Preconditions completed in {}.", HumanDuration(started.elapsed()));
194      if context.is_nested {
195        precondition_pb.finish_and_clear();
196      } else {
197        precondition_pb.finish_with_message(message);
198      }
199    }
200
201    if self.parallel.unwrap_or(false) {
202      self.execute_commands_parallel(context)?;
203    } else {
204      let command_pb = context.multi.add(ProgressBar::new(self.commands.len() as u64));
205      command_pb.set_style(pb_style);
206      command_pb.set_message("Running task command...");
207      command_pb.enable_steady_tick(tick_interval);
208      for (i, command) in self.commands.iter().enumerate() {
209        thread::sleep(Duration::from_millis(rng.gen_range(100..400)));
210        command_pb.set_prefix(format!("{}/{}", i + 1, self.commands.len()));
211        command.execute(context)?;
212        command_pb.inc(1);
213      }
214
215      let message = format!("Commands completed in {}.", HumanDuration(started.elapsed()));
216      if context.is_nested {
217        command_pb.finish_and_clear();
218      } else {
219        command_pb.finish_with_message(message);
220      }
221    }
222
223    Ok(())
224  }
225
226  /// Validate if the task can be run in parallel
227  fn validate_parallel_commands(&self) -> anyhow::Result<()> {
228    if !self.parallel.unwrap_or(false) {
229      return Ok(());
230    }
231
232    for command in &self.commands {
233      match command {
234        CommandRunner::LocalRun(local_run) if local_run.is_parallel_safe() => continue,
235        CommandRunner::LocalRun(_) => {
236          return Err(anyhow::anyhow!(
237            "Interactive local commands cannot be run in parallel"
238          ))
239        },
240        _ => {
241          return Err(anyhow::anyhow!(
242            "Parallel execution is only supported for non-interactive local commands"
243          ))
244        },
245      }
246    }
247    Ok(())
248  }
249
250  /// Execute the commands in parallel
251  fn execute_commands_parallel(&self, context: &TaskContext) -> anyhow::Result<()> {
252    let (tx, rx): (Sender<CommandResult>, Receiver<CommandResult>) = channel();
253    let mut handles = vec![];
254    let command_count = self.commands.len();
255
256    // Clone all commands upfront to avoid borrowing issues
257    let commands: Vec<_> = self.commands.to_vec();
258
259    // Track results in order
260    let mut completed = 0;
261
262    for (i, command) in commands.into_iter().enumerate() {
263      let tx = tx.clone();
264      let context = context.clone();
265
266      let handle = thread::spawn(move || {
267        let result = match command.execute(&context) {
268          Ok(_) => CommandResult {
269            index: i,
270            success: true,
271            message: format!("Command {} completed successfully", i + 1),
272          },
273          Err(e) => CommandResult {
274            index: i,
275            success: false,
276            message: format!("Command {} failed: {}", i + 1, e),
277          },
278        };
279        tx.send(result).unwrap();
280      });
281
282      handles.push(handle);
283    }
284
285    // Set up progress bar
286    let command_pb = context.multi.add(ProgressBar::new(command_count as u64));
287    command_pb.set_style(ProgressStyle::with_template(
288      "{spinner:.green} [{prefix:.bold.dim}] {wide_msg:.cyan/blue} ",
289    )?);
290    command_pb.set_prefix("?/?");
291    command_pb.set_message("Running task commands in parallel...");
292    command_pb.enable_steady_tick(Duration::from_millis(80));
293
294    // Process results as they come in
295    let mut failures = Vec::new();
296
297    while completed < command_count {
298      match rx.recv() {
299        Ok(result) => {
300          let index = result.index;
301          if !result.success && !context.ignore_errors() {
302            failures.push(result.message);
303          }
304
305          completed += 1;
306          command_pb.set_prefix(format!("{}/{}", completed, command_count));
307          command_pb.inc(1);
308
309          // Update progress message with latest completed command
310          command_pb.set_message(format!(
311            "Running task commands in parallel (completed {})",
312            index + 1
313          ));
314        },
315        Err(e) => {
316          command_pb.finish_with_message("Error receiving command results");
317          return Err(anyhow::anyhow!("Channel error: {}", e));
318        },
319      }
320    }
321
322    // Wait for all threads to complete
323    for handle in handles {
324      handle.join().unwrap();
325    }
326
327    if !failures.is_empty() {
328      command_pb.finish_with_message("Some commands failed");
329
330      // Sort failures by command index for clearer error reporting
331      failures.sort();
332      return Err(anyhow::anyhow!("Failed commands:\n{}", failures.join("\n")));
333    }
334
335    let message = "Commands completed in parallel";
336    if context.is_nested {
337      command_pb.finish_and_clear();
338    } else {
339      command_pb.finish_with_message(message);
340    }
341
342    Ok(())
343  }
344
345  fn load_env(&self, context: &TaskContext) -> anyhow::Result<HashMap<String, String>> {
346    let mut local_env: HashMap<String, String> = HashMap::new();
347    for (key, value) in &self.environment {
348      let value = self.get_env_value(context, value)?;
349      local_env.insert(key.clone(), value);
350    }
351
352    Ok(local_env)
353  }
354
355  fn load_env_file(&self) -> anyhow::Result<HashMap<String, String>> {
356    let mut local_env: HashMap<String, String> = HashMap::new();
357    for env_file in &self.env_file {
358      let contents =
359        fs::read_to_string(env_file).with_context(|| format!("Failed to read env file - {}", env_file))?;
360
361      for line in contents.lines() {
362        if let Some((key, value)) = line.split_once('=') {
363          local_env.insert(key.trim().to_string(), value.trim().to_string());
364        }
365      }
366    }
367
368    Ok(local_env)
369  }
370
371  fn get_env_value(&self, context: &TaskContext, value_in: &str) -> anyhow::Result<String> {
372    if is_shell_command(value_in)? {
373      let verbose = self.verbose();
374      let mut cmd = self
375        .shell
376        .as_ref()
377        .map(|shell| shell.proc())
378        .unwrap_or_else(|| context.shell().proc());
379      let output = run_shell_command!(value_in, cmd, verbose);
380      Ok(output)
381    } else {
382      Ok(value_in.to_string())
383    }
384  }
385
386  fn verbose(&self) -> bool {
387    self.verbose.unwrap_or(default_verbose())
388  }
389}
390
391#[cfg(test)]
392mod test {
393  use super::*;
394
395  #[test]
396  fn test_task_1() -> anyhow::Result<()> {
397    {
398      let yaml = "
399        commands:
400          - command: echo \"Hello, World!\"
401            ignore_errors: false
402            verbose: false
403        depends_on:
404          - name: task1
405        description: This is a task
406        environment:
407          FOO: bar
408        env_file:
409          - test.env
410          - test2.env
411      ";
412
413      let task = serde_yaml::from_str::<Task>(yaml)?;
414
415      if let Task::Task(task) = &task {
416        if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
417          assert_eq!(local_run.command, "echo \"Hello, World!\"");
418          assert_eq!(local_run.work_dir, None);
419          assert_eq!(local_run.ignore_errors, Some(false));
420          assert_eq!(local_run.verbose, Some(false));
421        }
422
423        if let TaskDependency::TaskDependency(args) = &task.depends_on[0] {
424          assert_eq!(args.name, "task1");
425        }
426
427        assert_eq!(task.labels.len(), 0);
428        assert_eq!(task.description, "This is a task");
429        assert_eq!(task.environment.len(), 1);
430        assert_eq!(task.env_file.len(), 2);
431      } else {
432        panic!("Expected Task::Task");
433      }
434
435      Ok(())
436    }
437  }
438
439  #[test]
440  fn test_task_2() -> anyhow::Result<()> {
441    {
442      let yaml = "
443        commands:
444          - command: echo 'Hello, World!'
445            ignore_errors: false
446            verbose: false
447        description: This is a task
448        environment:
449          FOO: bar
450          BAR: foo
451      ";
452
453      let task = serde_yaml::from_str::<Task>(yaml)?;
454
455      if let Task::Task(task) = &task {
456        if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
457          assert_eq!(local_run.command, "echo 'Hello, World!'");
458          assert_eq!(local_run.work_dir, None);
459          assert_eq!(local_run.ignore_errors, Some(false));
460          assert_eq!(local_run.verbose, Some(false));
461        }
462
463        assert_eq!(task.description, "This is a task");
464        assert_eq!(task.depends_on.len(), 0);
465        assert_eq!(task.labels.len(), 0);
466        assert_eq!(task.env_file.len(), 0);
467        assert_eq!(task.environment.len(), 2);
468      } else {
469        panic!("Expected Task::Task");
470      }
471
472      Ok(())
473    }
474  }
475
476  #[test]
477  fn test_task_3() -> anyhow::Result<()> {
478    {
479      let yaml = "
480        commands:
481          - command: echo 'Hello, World!'
482      ";
483
484      let task = serde_yaml::from_str::<Task>(yaml)?;
485
486      if let Task::Task(task) = &task {
487        if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
488          assert_eq!(local_run.command, "echo 'Hello, World!'");
489          assert_eq!(local_run.work_dir, None);
490          assert_eq!(local_run.ignore_errors, None);
491          assert_eq!(local_run.verbose, None);
492        }
493
494        assert_eq!(task.description.len(), 0);
495        assert_eq!(task.depends_on.len(), 0);
496        assert_eq!(task.labels.len(), 0);
497        assert_eq!(task.env_file.len(), 0);
498        assert_eq!(task.environment.len(), 0);
499      } else {
500        panic!("Expected Task::Task");
501      }
502
503      Ok(())
504    }
505  }
506
507  #[test]
508  fn test_task_4() -> anyhow::Result<()> {
509    {
510      let yaml = "
511        commands:
512          - container_command:
513              - echo
514              - Hello, World!
515            image: docker.io/library/hello-world:latest
516      ";
517
518      let task = serde_yaml::from_str::<Task>(yaml)?;
519
520      if let Task::Task(task) = &task {
521        if let CommandRunner::ContainerRun(container_run) = &task.commands[0] {
522          assert_eq!(container_run.container_command.len(), 2);
523          assert_eq!(container_run.container_command[0], "echo");
524          assert_eq!(container_run.container_command[1], "Hello, World!");
525          assert_eq!(container_run.image, "docker.io/library/hello-world:latest");
526          assert_eq!(container_run.mounted_paths, Vec::<String>::new());
527          assert_eq!(container_run.ignore_errors, None);
528          assert_eq!(container_run.verbose, None);
529        }
530
531        assert_eq!(task.description.len(), 0);
532        assert_eq!(task.depends_on.len(), 0);
533        assert_eq!(task.labels.len(), 0);
534        assert_eq!(task.env_file.len(), 0);
535        assert_eq!(task.environment.len(), 0);
536      } else {
537        panic!("Expected Task::Task");
538      }
539
540      Ok(())
541    }
542  }
543
544  #[test]
545  fn test_task_5() -> anyhow::Result<()> {
546    {
547      let yaml = "
548        commands:
549          - container_command:
550              - echo
551              - Hello, World!
552            image: docker.io/library/hello-world:latest
553            mounted_paths:
554              - /tmp
555              - /var/tmp
556      ";
557
558      let task = serde_yaml::from_str::<Task>(yaml)?;
559
560      if let Task::Task(task) = &task {
561        if let CommandRunner::ContainerRun(container_run) = &task.commands[0] {
562          assert_eq!(container_run.container_command.len(), 2);
563          assert_eq!(container_run.container_command[0], "echo");
564          assert_eq!(container_run.container_command[1], "Hello, World!");
565          assert_eq!(container_run.image, "docker.io/library/hello-world:latest");
566          assert_eq!(container_run.mounted_paths, vec!["/tmp", "/var/tmp"]);
567          assert_eq!(container_run.ignore_errors, None);
568          assert_eq!(container_run.verbose, None);
569        }
570
571        assert_eq!(task.description.len(), 0);
572        assert_eq!(task.depends_on.len(), 0);
573        assert_eq!(task.labels.len(), 0);
574        assert_eq!(task.env_file.len(), 0);
575        assert_eq!(task.environment.len(), 0);
576      } else {
577        panic!("Expected Task::Task");
578      }
579
580      Ok(())
581    }
582  }
583
584  #[test]
585  fn test_task_6() -> anyhow::Result<()> {
586    {
587      let yaml = "
588        commands:
589          - container_command:
590              - echo
591              - Hello, World!
592            image: docker.io/library/hello-world:latest
593            mounted_paths:
594              - /tmp
595              - /var/tmp
596            ignore_errors: true
597      ";
598
599      let task = serde_yaml::from_str::<Task>(yaml)?;
600
601      if let Task::Task(task) = &task {
602        if let CommandRunner::ContainerRun(container_run) = &task.commands[0] {
603          assert_eq!(container_run.container_command.len(), 2);
604          assert_eq!(container_run.container_command[0], "echo");
605          assert_eq!(container_run.container_command[1], "Hello, World!");
606          assert_eq!(container_run.image, "docker.io/library/hello-world:latest");
607          assert_eq!(container_run.mounted_paths, vec!["/tmp", "/var/tmp"]);
608          assert_eq!(container_run.ignore_errors, Some(true));
609          assert_eq!(container_run.verbose, None);
610        }
611
612        assert_eq!(task.description.len(), 0);
613        assert_eq!(task.depends_on.len(), 0);
614        assert_eq!(task.labels.len(), 0);
615        assert_eq!(task.env_file.len(), 0);
616        assert_eq!(task.environment.len(), 0);
617      } else {
618        panic!("Expected Task::Task");
619      }
620
621      Ok(())
622    }
623  }
624
625  #[test]
626  fn test_task_7() -> anyhow::Result<()> {
627    {
628      let yaml = "
629        commands:
630          - container_command:
631              - echo
632              - Hello, World!
633            image: docker.io/library/hello-world:latest
634            verbose: false
635      ";
636
637      let task = serde_yaml::from_str::<Task>(yaml)?;
638
639      if let Task::Task(task) = &task {
640        if let CommandRunner::ContainerRun(container_run) = &task.commands[0] {
641          assert_eq!(container_run.container_command.len(), 2);
642          assert_eq!(container_run.container_command[0], "echo");
643          assert_eq!(container_run.container_command[1], "Hello, World!");
644          assert_eq!(container_run.image, "docker.io/library/hello-world:latest");
645          assert_eq!(container_run.mounted_paths, Vec::<String>::new());
646          assert_eq!(container_run.ignore_errors, None);
647          assert_eq!(container_run.verbose, Some(false));
648        }
649
650        assert_eq!(task.description.len(), 0);
651        assert_eq!(task.depends_on.len(), 0);
652        assert_eq!(task.labels.len(), 0);
653        assert_eq!(task.env_file.len(), 0);
654        assert_eq!(task.environment.len(), 0);
655      } else {
656        panic!("Expected Task::Task");
657      }
658
659      Ok(())
660    }
661  }
662
663  #[test]
664  fn test_task_8() -> anyhow::Result<()> {
665    {
666      let yaml = "
667        commands:
668          - task: task1
669      ";
670
671      let task = serde_yaml::from_str::<Task>(yaml)?;
672
673      if let Task::Task(task) = &task {
674        if let CommandRunner::TaskRun(task_run) = &task.commands[0] {
675          assert_eq!(task_run.task, "task1");
676          assert_eq!(task_run.ignore_errors, None);
677          assert_eq!(task_run.verbose, None);
678        }
679
680        assert_eq!(task.description.len(), 0);
681        assert_eq!(task.depends_on.len(), 0);
682        assert_eq!(task.labels.len(), 0);
683        assert_eq!(task.env_file.len(), 0);
684        assert_eq!(task.environment.len(), 0);
685      } else {
686        panic!("Expected Task::Task");
687      }
688
689      Ok(())
690    }
691  }
692
693  #[test]
694  fn test_task_9() -> anyhow::Result<()> {
695    {
696      let yaml = "
697        commands:
698          - task: task1
699            verbose: true
700      ";
701
702      let task = serde_yaml::from_str::<Task>(yaml)?;
703
704      if let Task::Task(task) = &task {
705        if let CommandRunner::TaskRun(task_run) = &task.commands[0] {
706          assert_eq!(task_run.task, "task1");
707          assert_eq!(task_run.ignore_errors, None);
708          assert_eq!(task_run.verbose, Some(true));
709        }
710
711        assert_eq!(task.description.len(), 0);
712        assert_eq!(task.depends_on.len(), 0);
713        assert_eq!(task.labels.len(), 0);
714        assert_eq!(task.env_file.len(), 0);
715        assert_eq!(task.environment.len(), 0);
716      } else {
717        panic!("Expected Task::Task");
718      }
719
720      Ok(())
721    }
722  }
723
724  #[test]
725  fn test_task_10() -> anyhow::Result<()> {
726    {
727      let yaml = "
728        commands:
729          - task: task1
730            ignore_errors: true
731      ";
732
733      let task = serde_yaml::from_str::<Task>(yaml)?;
734
735      if let Task::Task(task) = &task {
736        if let CommandRunner::TaskRun(task_run) = &task.commands[0] {
737          assert_eq!(task_run.task, "task1");
738          assert_eq!(task_run.ignore_errors, Some(true));
739          assert_eq!(task_run.verbose, None);
740        }
741
742        assert_eq!(task.description.len(), 0);
743        assert_eq!(task.depends_on.len(), 0);
744        assert_eq!(task.labels.len(), 0);
745        assert_eq!(task.env_file.len(), 0);
746        assert_eq!(task.environment.len(), 0);
747      } else {
748        panic!("Expected Task::Task");
749      }
750
751      Ok(())
752    }
753  }
754
755  #[test]
756  fn test_task_11() -> anyhow::Result<()> {
757    {
758      let yaml = "
759        echo 'Hello, World!'
760      ";
761
762      let task = serde_yaml::from_str::<Task>(yaml)?;
763
764      if let Task::String(task) = &task {
765        assert_eq!(task, "echo 'Hello, World!'");
766      } else {
767        panic!("Expected Task::String");
768      }
769
770      Ok(())
771    }
772  }
773
774  #[test]
775  fn test_task_12() -> anyhow::Result<()> {
776    {
777      let yaml = "
778        'true'
779      ";
780
781      let task = serde_yaml::from_str::<Task>(yaml)?;
782
783      if let Task::String(task) = &task {
784        assert_eq!(task, "true");
785      } else {
786        panic!("Expected Task::String");
787      }
788
789      Ok(())
790    }
791  }
792
793  #[test]
794  fn test_task_13() -> anyhow::Result<()> {
795    {
796      let yaml = "
797        commands: []
798        environment:
799          FOO: bar
800          BAR: foo
801          KEY: 42
802          PIS: 3.14
803      ";
804
805      let task = serde_yaml::from_str::<Task>(yaml)?;
806
807      if let Task::Task(task) = &task {
808        assert_eq!(task.environment.len(), 4);
809        assert_eq!(task.environment.get("FOO").unwrap(), "bar");
810        assert_eq!(task.environment.get("BAR").unwrap(), "foo");
811        assert_eq!(task.environment.get("KEY").unwrap(), "42");
812      } else {
813        panic!("Expected Task::Task");
814      }
815
816      Ok(())
817    }
818  }
819
820  #[test]
821  fn test_parallel_interactive_rejected() -> anyhow::Result<()> {
822    let yaml = r#"
823          commands:
824            - command: "echo hello"
825              interactive: true
826            - command: "echo world"
827          parallel: true
828      "#;
829
830    let task = serde_yaml::from_str::<Task>(yaml)?;
831    let mut context = TaskContext::empty();
832
833    if let Task::Task(task) = task {
834      let result = task.run(&mut context);
835      assert!(result.is_err());
836      assert!(result
837        .unwrap_err()
838        .to_string()
839        .contains("Interactive local commands cannot be run in parallel"));
840    }
841
842    Ok(())
843  }
844
845  #[test]
846  fn test_parallel_non_interactive_accepted() -> anyhow::Result<()> {
847    let yaml = r#"
848          commands:
849            - command: "echo hello"
850              interactive: false
851            - command: "echo world"
852          parallel: true
853      "#;
854
855    let task = serde_yaml::from_str::<Task>(yaml)?;
856    let mut context = TaskContext::empty();
857
858    if let Task::Task(task) = task {
859      let result = task.run(&mut context);
860      assert!(result.is_ok());
861    }
862
863    Ok(())
864  }
865}