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#[derive(Debug, Default, Deserialize)]
42pub struct TaskArgs {
43 pub commands: Vec<CommandRunner>,
45
46 #[serde(default)]
48 pub preconditions: Vec<Precondition>,
49
50 #[serde(default)]
52 pub depends_on: Vec<TaskDependency>,
53
54 #[serde(default)]
56 pub labels: HashMap<String, String>,
57
58 #[serde(default)]
60 pub description: String,
61
62 #[serde(default, deserialize_with = "deserialize_environment")]
64 pub environment: HashMap<String, String>,
65
66 #[serde(default)]
68 pub env_file: Vec<String>,
69
70 #[serde(default)]
72 pub shell: Option<Shell>,
73
74 #[serde(default)]
77 pub parallel: Option<bool>,
78
79 #[serde(default)]
81 pub ignore_errors: Option<bool>,
82
83 #[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 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 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 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 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 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 let commands: Vec<_> = self.commands.to_vec();
258
259 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 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 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 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 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 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}