mk_lib/schema/
task.rs

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