1
use hashbrown::HashMap;
2
use indicatif::{
3
  HumanDuration,
4
  ProgressBar,
5
  ProgressStyle,
6
};
7
use rand::Rng as _;
8
use schemars::JsonSchema;
9
use serde::{
10
  Deserialize,
11
  Serialize,
12
};
13

            
14
use std::fmt::Write as _;
15
use std::io::BufRead as _;
16
use std::sync::mpsc::{
17
  channel,
18
  Receiver,
19
  Sender,
20
};
21
use std::thread;
22
use std::time::{
23
  Duration,
24
  Instant,
25
};
26

            
27
use super::{
28
  contains_output_reference,
29
  extract_output_references,
30
  interpolate_template_string,
31
  is_shell_command,
32
  CommandRunner,
33
  Precondition,
34
  Shell,
35
  TaskContext,
36
  TaskDependency,
37
};
38
use crate::cache::{
39
  compute_fingerprint,
40
  expand_patterns_in_dir,
41
  CacheEntry,
42
};
43
use crate::defaults::default_verbose;
44
use crate::run_shell_command;
45
use crate::secrets::{
46
  load_secret_env,
47
  merge_optional_secret_settings,
48
  resolve_secret_config,
49
  SecretSettings,
50
};
51
use crate::utils::{
52
  deserialize_environment,
53
  load_env_files_in_dir,
54
  resolve_path,
55
};
56

            
57
4
fn default_cache_enabled() -> bool {
58
4
  true
59
4
}
60

            
61
10
fn default_fail_fast() -> bool {
62
10
  true
63
10
}
64

            
65
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
66
#[serde(rename_all = "snake_case")]
67
pub enum ExecutionMode {
68
  Sequential,
69
  Parallel,
70
}
71

            
72
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
73
pub struct TaskExecution {
74
  #[serde(default)]
75
  pub mode: Option<ExecutionMode>,
76

            
77
  #[serde(default)]
78
  pub max_parallel: Option<usize>,
79

            
80
  #[serde(default = "default_fail_fast")]
81
  pub fail_fast: bool,
82
}
83

            
84
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
85
pub struct TaskCache {
86
  #[serde(default = "default_cache_enabled")]
87
  pub enabled: bool,
88
}
89

            
90
/// This struct represents a task that can be executed. A task can contain multiple
91
/// commands that are executed sequentially. A task can also have preconditions that
92
/// must be met before the task can be executed.
93
#[derive(Debug, Default, Deserialize, JsonSchema)]
94
pub struct TaskArgs {
95
  /// The commands to run
96
  pub commands: Vec<CommandRunner>,
97

            
98
  /// The preconditions that must be met before the task can be executed
99
  #[serde(default)]
100
  pub preconditions: Vec<Precondition>,
101

            
102
  /// The tasks that must be executed before this task can be executed
103
  #[serde(default)]
104
  pub depends_on: Vec<TaskDependency>,
105

            
106
  /// The labels for the task
107
  #[schemars(with = "std::collections::HashMap<String, String>")]
108
  #[serde(default)]
109
  pub labels: HashMap<String, String>,
110

            
111
  /// The description of the task
112
  #[serde(default)]
113
  pub description: String,
114

            
115
  /// The environment variables to set before running the task
116
  #[schemars(with = "std::collections::HashMap<String, String>")]
117
  #[serde(default, deserialize_with = "deserialize_environment")]
118
  pub environment: HashMap<String, String>,
119

            
120
  /// The environment files to load before running the task
121
  #[serde(default)]
122
  pub env_file: Vec<String>,
123

            
124
  /// Secret paths to load as dotenv-style environment entries before running the task
125
  #[serde(default)]
126
  pub secrets_path: Vec<String>,
127

            
128
  /// Canonical task secret settings block.
129
  #[serde(default)]
130
  pub secrets: Option<SecretSettings>,
131

            
132
  /// The path to the secret vault
133
  #[serde(default)]
134
  pub vault_location: Option<String>,
135

            
136
  /// The path to the private keys used for secret decryption
137
  #[serde(default)]
138
  pub keys_location: Option<String>,
139

            
140
  /// The key name to use for secret decryption
141
  #[serde(default)]
142
  pub key_name: Option<String>,
143

            
144
  /// GPG key ID or fingerprint for hardware/passphrase-protected keys (delegates to system gpg)
145
  #[serde(default)]
146
  pub gpg_key_id: Option<String>,
147

            
148
  /// The shell to use when running the task
149
  #[serde(default)]
150
  pub shell: Option<Shell>,
151

            
152
  /// Run the commands in parallel
153
  /// It should only work if the task are local_run commands
154
  #[serde(default)]
155
  pub parallel: Option<bool>,
156

            
157
  /// Richer execution configuration
158
  #[serde(default)]
159
  pub execution: Option<TaskExecution>,
160

            
161
  /// Task caching configuration
162
  #[serde(default)]
163
  pub cache: Option<TaskCache>,
164

            
165
  /// Files or glob patterns that affect task output
166
  #[serde(default)]
167
  pub inputs: Vec<String>,
168

            
169
  /// Files produced by the task
170
  #[serde(default)]
171
  pub outputs: Vec<String>,
172

            
173
  /// Ignore errors if the task fails
174
  #[serde(default)]
175
  pub ignore_errors: Option<bool>,
176

            
177
  /// Show verbose output
178
  #[serde(default)]
179
  pub verbose: Option<bool>,
180

            
181
  /// Original legacy secret scalar fields before normalization.
182
  #[schemars(skip)]
183
  #[serde(skip)]
184
  pub(crate) raw_legacy_secret_settings: Option<SecretSettings>,
185

            
186
  /// Original `secrets` block before normalization.
187
  #[schemars(skip)]
188
  #[serde(skip)]
189
  pub(crate) raw_secrets: Option<SecretSettings>,
190
}
191

            
192
#[derive(Debug, Deserialize, JsonSchema)]
193
#[serde(untagged)]
194
/// A task definition: either a command string shorthand or a full task object.
195
pub enum Task {
196
  String(String),
197
  Task(Box<TaskArgs>),
198
}
199

            
200
#[derive(Debug)]
201
pub struct CommandResult {
202
  index: usize,
203
  success: bool,
204
  message: String,
205
}
206

            
207
impl Task {
208
33
  pub fn run(&self, context: &mut TaskContext) -> anyhow::Result<()> {
209
33
    match self {
210
      Task::String(command) => self.execute(context, command),
211
33
      Task::Task(args) => args.run(context),
212
    }
213
33
  }
214

            
215
  fn execute(&self, context: &mut TaskContext, command: &str) -> anyhow::Result<()> {
216
    assert!(!command.is_empty());
217

            
218
    TaskArgs {
219
      commands: vec![CommandRunner::CommandRun(command.to_string())],
220
      ..Default::default()
221
    }
222
    .run(context)
223
  }
224
}
225

            
226
impl TaskArgs {
227
897
  pub(crate) fn normalized_secret_settings(&self) -> Option<SecretSettings> {
228
897
    merge_optional_secret_settings(
229
897
      Some(SecretSettings::from_legacy(
230
897
        self.vault_location.clone(),
231
897
        self.keys_location.clone(),
232
897
        self.key_name.clone(),
233
897
        self.gpg_key_id.clone(),
234
897
        self.secrets_path.clone(),
235
897
      )),
236
897
      self.secrets.clone(),
237
    )
238
897
    .filter(|settings| !settings.is_empty())
239
897
  }
240

            
241
104
  pub(crate) fn validation_legacy_secret_settings(&self) -> SecretSettings {
242
104
    self.raw_legacy_secret_settings.clone().unwrap_or_else(|| {
243
24
      SecretSettings::from_legacy(
244
24
        self.vault_location.clone(),
245
24
        self.keys_location.clone(),
246
24
        self.key_name.clone(),
247
24
        self.gpg_key_id.clone(),
248
24
        self.secrets_path.clone(),
249
      )
250
24
    })
251
104
  }
252

            
253
881
  pub(crate) fn normalize_secret_settings(&mut self) {
254
881
    if self.raw_legacy_secret_settings.is_none() {
255
881
      self.raw_legacy_secret_settings = Some(SecretSettings::from_legacy(
256
881
        self.vault_location.clone(),
257
881
        self.keys_location.clone(),
258
881
        self.key_name.clone(),
259
881
        self.gpg_key_id.clone(),
260
881
        self.secrets_path.clone(),
261
881
      ));
262
881
    }
263
881
    if self.raw_secrets.is_none() {
264
881
      self.raw_secrets = self.secrets.clone();
265
881
    }
266

            
267
881
    self.secrets = self.normalized_secret_settings();
268
881
    if let Some(secrets) = &self.secrets {
269
28
      self.vault_location = secrets.vault_location.clone();
270
28
      self.keys_location = secrets.keys_location.clone();
271
28
      self.key_name = secrets.key_name.clone();
272
28
      self.gpg_key_id = secrets.gpg_key_id.clone();
273
28
      self.secrets_path = secrets.secrets_path.clone().unwrap_or_default();
274
853
    }
275
881
  }
276

            
277
36
  pub fn run(&self, context: &mut TaskContext) -> anyhow::Result<()> {
278
36
    assert!(!self.commands.is_empty());
279

            
280
    // Validate parallel execution requirements early
281
36
    self.validate_parallel_commands()?;
282

            
283
34
    let started = Instant::now();
284
34
    let tick_interval = Duration::from_millis(80);
285

            
286
34
    if let Some(shell) = &self.shell {
287
      context.set_shell(shell);
288
34
    }
289

            
290
34
    if let Some(ignore_errors) = &self.ignore_errors {
291
      context.set_ignore_errors(*ignore_errors);
292
34
    }
293

            
294
34
    if let Some(verbose) = &self.verbose {
295
      context.set_verbose(*verbose);
296
34
    }
297

            
298
34
    let config_base_dir = self.config_base_dir(context);
299
34
    let task_secret_config = resolve_secret_config(
300
34
      &config_base_dir,
301
34
      None,
302
34
      self.secrets.as_ref(),
303
34
      context.task_root.secrets.as_ref(),
304
    );
305
34
    context.set_secret_config(task_secret_config.clone());
306

            
307
    // Load environment variables from root and task environments and env files.
308
34
    if !context.is_nested {
309
1
      let root_env = context.task_root.environment.clone();
310
1
      let root_env_files = load_env_files_in_dir(&context.task_root.env_file, &config_base_dir)?;
311
1
      context.extend_env_vars(root_env);
312
1
      context.extend_env_vars(root_env_files);
313
33
    }
314

            
315
    // Load environment variables from the task environment and env files field
316
34
    let defined_env = self.load_static_env(context)?;
317
34
    let additional_env = self.load_env_file(context)?;
318
34
    let secret_env = self.load_secret_env(context)?;
319

            
320
34
    context.extend_env_vars(defined_env);
321
34
    context.extend_env_vars(additional_env);
322
34
    context.extend_env_vars(secret_env);
323

            
324
34
    let mut rng = rand::thread_rng();
325
    // Spinners can be found here:
326
    // https://github.com/sindresorhus/cli-spinners/blob/main/spinners.json
327
34
    let pb_style =
328
34
      ProgressStyle::with_template("{spinner:.green} [{prefix:.bold.dim}] {wide_msg:.cyan/blue} ")?
329
34
        .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⦿");
330

            
331
34
    let depends_on_pb = context.multi.add(ProgressBar::new(self.depends_on.len() as u64));
332

            
333
34
    if !self.depends_on.is_empty() {
334
3
      depends_on_pb.set_style(pb_style.clone());
335
3
      depends_on_pb.set_message("Running task dependencies...");
336
3
      depends_on_pb.enable_steady_tick(tick_interval);
337
4
      for (i, dependency) in self.depends_on.iter().enumerate() {
338
4
        thread::sleep(Duration::from_millis(rng.gen_range(40..300)));
339
4
        depends_on_pb.set_prefix(format!("{}/{}", i + 1, self.depends_on.len()));
340
4
        dependency.run(context)?;
341
4
        depends_on_pb.inc(1);
342
      }
343

            
344
3
      let message = format!("Dependencies completed in {}.", HumanDuration(started.elapsed()));
345
3
      if context.is_nested {
346
3
        depends_on_pb.finish_and_clear();
347
3
      } else {
348
        depends_on_pb.finish_with_message(message);
349
      }
350
31
    }
351

            
352
34
    let precondition_pb = context
353
34
      .multi
354
34
      .add(ProgressBar::new(self.preconditions.len() as u64));
355

            
356
34
    if !self.preconditions.is_empty() {
357
      precondition_pb.set_style(pb_style.clone());
358
      precondition_pb.set_message("Running task precondition...");
359
      precondition_pb.enable_steady_tick(tick_interval);
360
      for (i, precondition) in self.preconditions.iter().enumerate() {
361
        thread::sleep(Duration::from_millis(rng.gen_range(40..300)));
362
        precondition_pb.set_prefix(format!("{}/{}", i + 1, self.preconditions.len()));
363
        precondition.execute(context)?;
364
        precondition_pb.inc(1);
365
      }
366

            
367
      let message = format!("Preconditions completed in {}.", HumanDuration(started.elapsed()));
368
      if context.is_nested {
369
        precondition_pb.finish_and_clear();
370
      } else {
371
        precondition_pb.finish_with_message(message);
372
      }
373
34
    }
374

            
375
34
    if self.should_skip_from_cache(context)? {
376
      context.emit_event(&serde_json::json!({
377
        "event": "task_skipped",
378
        "task": context.current_task_name.clone().unwrap_or_else(|| "<task>".to_string()),
379
        "reason": "cache_hit",
380
      }))?;
381
      return Ok(());
382
34
    }
383

            
384
34
    if self.is_parallel() {
385
1
      self.execute_commands_parallel(context)?;
386
    } else {
387
33
      let command_pb = context.multi.add(ProgressBar::new(self.commands.len() as u64));
388
33
      command_pb.set_style(pb_style);
389
33
      command_pb.set_message("Running task command...");
390
33
      command_pb.enable_steady_tick(tick_interval);
391
33
      for (i, command) in self.commands.iter().enumerate() {
392
33
        thread::sleep(Duration::from_millis(rng.gen_range(100..400)));
393
33
        command_pb.set_prefix(format!("{}/{}", i + 1, self.commands.len()));
394
33
        self.refresh_output_env(context)?;
395
33
        command.execute(context)?;
396
33
        command_pb.inc(1);
397
      }
398

            
399
33
      let message = format!("Commands completed in {}.", HumanDuration(started.elapsed()));
400
33
      if context.is_nested {
401
33
        command_pb.finish_and_clear();
402
33
      } else {
403
        command_pb.finish_with_message(message);
404
      }
405
    }
406

            
407
34
    self.update_cache(context)?;
408

            
409
34
    Ok(())
410
36
  }
411

            
412
  /// Validate if the task can be run in parallel
413
36
  fn validate_parallel_commands(&self) -> anyhow::Result<()> {
414
36
    if !self.is_parallel() {
415
33
      return Ok(());
416
3
    }
417

            
418
4
    for command in &self.commands {
419
2
      match command {
420
4
        CommandRunner::LocalRun(local_run) if local_run.is_parallel_safe() => continue,
421
2
        CommandRunner::LocalRun(local_run) if local_run.interactive_enabled() => {
422
1
          return Err(anyhow::anyhow!(
423
1
            "Interactive local commands cannot be run in parallel"
424
1
          ))
425
        },
426
        CommandRunner::LocalRun(_) => {
427
1
          return Err(anyhow::anyhow!(
428
1
            "Local commands with `retrigger: true` cannot be run in parallel"
429
1
          ))
430
        },
431
        _ => {
432
          return Err(anyhow::anyhow!(
433
            "Parallel execution is only supported for non-interactive local commands"
434
          ))
435
        },
436
      }
437
    }
438
1
    Ok(())
439
36
  }
440

            
441
  /// Execute the commands in parallel
442
1
  fn execute_commands_parallel(&self, context: &TaskContext) -> anyhow::Result<()> {
443
1
    context.clear_cancellation();
444
1
    let (tx, rx): (Sender<CommandResult>, Receiver<CommandResult>) = channel();
445
1
    let mut handles = vec![];
446
1
    let command_count = self.commands.len();
447
1
    let max_parallel = self.max_parallel().min(command_count.max(1));
448
1
    let fail_fast = self.fail_fast();
449
1
    let command_pb = context.multi.add(ProgressBar::new(command_count as u64));
450
1
    command_pb.set_style(ProgressStyle::with_template(
451
1
      "{spinner:.green} [{prefix:.bold.dim}] {wide_msg:.cyan/blue} ",
452
    )?);
453
1
    command_pb.set_prefix("?/?");
454
1
    command_pb.set_message("Running task commands in parallel...");
455
1
    command_pb.enable_steady_tick(Duration::from_millis(80));
456
1
    let mut failures = Vec::new();
457

            
458
    // Clone all commands upfront to avoid borrowing issues
459
1
    let commands: Vec<_> = self.commands.to_vec();
460

            
461
    // Track results in order
462
1
    let mut completed = 0;
463

            
464
1
    let mut iter = commands.into_iter().enumerate();
465
1
    let mut running = 0usize;
466
1
    let mut stop_scheduling = false;
467

            
468
3
    while completed < command_count {
469
4
      while !stop_scheduling && running < max_parallel {
470
3
        let Some((i, command)) = iter.next() else {
471
1
          break;
472
        };
473

            
474
2
        let tx = tx.clone();
475
2
        let context = context.clone();
476

            
477
2
        let handle = thread::spawn(move || {
478
2
          let result = match command.execute_cancellable(&context) {
479
2
            Ok(_) => CommandResult {
480
2
              index: i,
481
2
              success: true,
482
2
              message: format!("Command {} completed successfully", i + 1),
483
2
            },
484
            Err(e) => CommandResult {
485
              index: i,
486
              success: false,
487
              message: format!("Command {} failed: {}", i + 1, e),
488
            },
489
          };
490
2
          tx.send(result).unwrap();
491
2
        });
492

            
493
2
        handles.push(handle);
494
2
        running += 1;
495
      }
496

            
497
2
      if running == 0 {
498
        break;
499
2
      }
500

            
501
2
      match rx.recv() {
502
2
        Ok(result) => {
503
2
          running -= 1;
504
2
          let index = result.index;
505
2
          if !result.success && !context.ignore_errors() {
506
            failures.push(result.message);
507
            if fail_fast {
508
              stop_scheduling = true;
509
              context.request_cancellation();
510
            }
511
2
          }
512

            
513
2
          completed += 1;
514
2
          command_pb.set_prefix(format!("{}/{}", completed, command_count));
515
2
          command_pb.inc(1);
516

            
517
2
          command_pb.set_message(format!(
518
            "Running task commands in parallel (completed {})",
519
2
            index + 1
520
          ));
521
        },
522
        Err(e) => {
523
          command_pb.finish_with_message("Error receiving command results");
524
          return Err(anyhow::anyhow!("Channel error: {}", e));
525
        },
526
      }
527
    }
528

            
529
    // Wait for all threads to complete
530
2
    for handle in handles {
531
2
      handle.join().unwrap();
532
2
    }
533

            
534
1
    context.clear_cancellation();
535

            
536
1
    if !failures.is_empty() {
537
      command_pb.finish_with_message("Some commands failed");
538

            
539
      // Sort failures by command index for clearer error reporting
540
      failures.sort();
541
      return Err(anyhow::anyhow!("Failed commands:\n{}", failures.join("\n")));
542
1
    }
543

            
544
1
    let message = "Commands completed in parallel";
545
1
    if context.is_nested {
546
      command_pb.finish_and_clear();
547
1
    } else {
548
1
      command_pb.finish_with_message(message);
549
1
    }
550

            
551
1
    Ok(())
552
1
  }
553

            
554
34
  fn load_static_env(&self, context: &TaskContext) -> anyhow::Result<HashMap<String, String>> {
555
34
    let mut local_env: HashMap<String, String> = HashMap::new();
556
34
    for (key, value) in &self.environment {
557
      if contains_output_reference(value) {
558
        continue;
559
      }
560
      let value = self.get_env_value(context, value)?;
561
      local_env.insert(key.clone(), value);
562
    }
563

            
564
34
    Ok(local_env)
565
34
  }
566

            
567
33
  fn refresh_output_env(&self, context: &mut TaskContext) -> anyhow::Result<()> {
568
33
    let mut updated_env = HashMap::new();
569

            
570
33
    for (key, value) in &self.environment {
571
      let output_refs = extract_output_references(value);
572
      if output_refs.is_empty() {
573
        continue;
574
      }
575

            
576
      let mut all_outputs_ready = true;
577
      for output_name in &output_refs {
578
        if !context.has_task_output(output_name)? {
579
          all_outputs_ready = false;
580
          break;
581
        }
582
      }
583
      if !all_outputs_ready {
584
        continue;
585
      }
586

            
587
      updated_env.insert(key.clone(), self.get_env_value(context, value)?);
588
    }
589

            
590
33
    context.extend_env_vars(updated_env);
591
33
    Ok(())
592
33
  }
593

            
594
34
  fn load_env_file(&self, context: &TaskContext) -> anyhow::Result<HashMap<String, String>> {
595
34
    load_env_files_in_dir(&self.env_file, &self.config_base_dir(context))
596
34
  }
597

            
598
34
  fn load_secret_env(&self, context: &TaskContext) -> anyhow::Result<HashMap<String, String>> {
599
34
    let secret_config = context
600
34
      .secret_config
601
34
      .as_ref()
602
34
      .ok_or_else(|| anyhow::anyhow!("Secret config missing from task context"))?;
603
34
    load_secret_env(secret_config)
604
34
  }
605

            
606
  fn get_env_value(&self, context: &TaskContext, value_in: &str) -> anyhow::Result<String> {
607
    if is_shell_command(value_in)? {
608
      let verbose = self.verbose();
609
      let mut cmd = self
610
        .shell
611
        .as_ref()
612
        .map(|shell| shell.proc())
613
        .unwrap_or_else(|| context.shell().proc());
614
      let output = run_shell_command!(value_in, cmd, verbose);
615
      Ok(output)
616
    } else if super::is_template_command(value_in)? || contains_output_reference(value_in) {
617
      Ok(interpolate_template_string(value_in, context)?)
618
    } else {
619
      Ok(value_in.to_string())
620
    }
621
  }
622

            
623
  fn verbose(&self) -> bool {
624
    self.verbose.unwrap_or(default_verbose())
625
  }
626

            
627
222
  pub(crate) fn execution_mode(&self) -> ExecutionMode {
628
222
    self
629
222
      .execution
630
222
      .as_ref()
631
222
      .and_then(|execution| execution.mode.clone())
632
222
      .or_else(|| {
633
216
        self.parallel.map(|parallel| {
634
4
          if parallel {
635
4
            ExecutionMode::Parallel
636
          } else {
637
            ExecutionMode::Sequential
638
          }
639
4
        })
640
216
      })
641
222
      .unwrap_or(ExecutionMode::Sequential)
642
222
  }
643

            
644
206
  pub(crate) fn is_parallel(&self) -> bool {
645
206
    self.execution_mode().is_parallel()
646
206
  }
647

            
648
1
  pub(crate) fn max_parallel(&self) -> usize {
649
1
    self
650
1
      .execution
651
1
      .as_ref()
652
1
      .and_then(|execution| execution.max_parallel)
653
1
      .unwrap_or(self.commands.len().max(1))
654
1
  }
655

            
656
17
  pub(crate) fn fail_fast(&self) -> bool {
657
17
    self
658
17
      .execution
659
17
      .as_ref()
660
17
      .map(|execution| execution.fail_fast)
661
17
      .unwrap_or(true)
662
17
  }
663

            
664
84
  fn cache_enabled(&self) -> bool {
665
84
    self.cache.as_ref().map(|cache| cache.enabled).unwrap_or(false)
666
84
  }
667

            
668
34
  fn should_skip_from_cache(&self, context: &TaskContext) -> anyhow::Result<bool> {
669
34
    if context.force || !self.cache_enabled() || self.outputs.is_empty() {
670
34
      return Ok(false);
671
    }
672

            
673
    let resolved_outputs = self.resolve_output_paths(context)?;
674
    let outputs_exist = resolved_outputs.iter().all(|output| output.exists());
675
    if !outputs_exist {
676
      return Ok(false);
677
    }
678

            
679
    let env_vars = sorted_env_vars(&context.env_vars);
680
    let inputs = self.resolve_input_paths(context)?;
681
    let mut env_files = self.resolve_env_file_paths(context);
682
    env_files.extend(self.resolve_secret_paths(context));
683
    env_files.sort();
684
    env_files.dedup();
685
    let fingerprint = compute_fingerprint(
686
      &context
687
        .current_task_name
688
        .clone()
689
        .unwrap_or_else(|| "<task>".to_string()),
690
      &stable_task_fingerprint(self),
691
      &env_vars,
692
      &inputs,
693
      &env_files,
694
      &resolved_outputs,
695
    )?;
696

            
697
    let store = context
698
      .cache_store
699
      .lock()
700
      .map_err(|e| anyhow::anyhow!("Failed to lock cache store - {}", e))?;
701
    let Some(entry) = store.tasks.get(&fingerprint_task_key(context, self)) else {
702
      return Ok(false);
703
    };
704

            
705
    Ok(entry.fingerprint == fingerprint)
706
34
  }
707

            
708
34
  fn update_cache(&self, context: &TaskContext) -> anyhow::Result<()> {
709
34
    if !self.cache_enabled() || self.outputs.is_empty() {
710
34
      return Ok(());
711
    }
712

            
713
    let env_vars = sorted_env_vars(&context.env_vars);
714
    let inputs = self.resolve_input_paths(context)?;
715
    let mut env_files = self.resolve_env_file_paths(context);
716
    env_files.extend(self.resolve_secret_paths(context));
717
    env_files.sort();
718
    env_files.dedup();
719
    let resolved_outputs = self.resolve_output_paths(context)?;
720
    let fingerprint = compute_fingerprint(
721
      &context
722
        .current_task_name
723
        .clone()
724
        .unwrap_or_else(|| "<task>".to_string()),
725
      &stable_task_fingerprint(self),
726
      &env_vars,
727
      &inputs,
728
      &env_files,
729
      &resolved_outputs,
730
    )?;
731
    let key = fingerprint_task_key(context, self);
732

            
733
    {
734
      let mut store = context
735
        .cache_store
736
        .lock()
737
        .map_err(|e| anyhow::anyhow!("Failed to lock cache store - {}", e))?;
738
      store.tasks.insert(
739
        key,
740
        CacheEntry {
741
          fingerprint,
742
          outputs: resolved_outputs
743
            .iter()
744
            .map(|path| path.to_string_lossy().into_owned())
745
            .collect(),
746
          updated_at: chrono::Utc::now().to_rfc3339(),
747
        },
748
      );
749
      store.save_in_dir(&context.task_root.cache_base_dir())?;
750
    }
751

            
752
    Ok(())
753
34
  }
754

            
755
110
  pub(crate) fn config_base_dir_from_root(&self, root: &super::TaskRoot) -> std::path::PathBuf {
756
110
    root.config_base_dir()
757
110
  }
758

            
759
42
  pub(crate) fn task_base_dir_from_root(&self, root: &super::TaskRoot) -> std::path::PathBuf {
760
42
    let config_base_dir = self.config_base_dir_from_root(root);
761
42
    let mut work_dirs = self
762
42
      .commands
763
42
      .iter()
764
42
      .filter_map(|command| match command {
765
38
        CommandRunner::LocalRun(local_run) => local_run.work_dir.as_ref(),
766
4
        _ => None,
767
42
      })
768
42
      .map(|work_dir| resolve_path(&config_base_dir, work_dir))
769
42
      .collect::<Vec<_>>();
770

            
771
42
    work_dirs.sort();
772
42
    work_dirs.dedup();
773

            
774
42
    if work_dirs.len() == 1 {
775
      work_dirs.remove(0)
776
    } else {
777
42
      config_base_dir
778
    }
779
42
  }
780

            
781
68
  fn config_base_dir(&self, context: &TaskContext) -> std::path::PathBuf {
782
68
    self.config_base_dir_from_root(&context.task_root)
783
68
  }
784

            
785
  fn task_base_dir(&self, context: &TaskContext) -> std::path::PathBuf {
786
    self.task_base_dir_from_root(&context.task_root)
787
  }
788

            
789
  fn resolve_input_paths(&self, context: &TaskContext) -> anyhow::Result<Vec<std::path::PathBuf>> {
790
    expand_patterns_in_dir(&self.task_base_dir(context), &self.inputs)
791
  }
792

            
793
  fn resolve_output_paths(&self, context: &TaskContext) -> anyhow::Result<Vec<std::path::PathBuf>> {
794
    let base_dir = self.task_base_dir(context);
795
    Ok(
796
      self
797
        .outputs
798
        .iter()
799
        .map(|output| resolve_path(&base_dir, output))
800
        .collect(),
801
    )
802
  }
803

            
804
  fn resolve_env_file_paths(&self, context: &TaskContext) -> Vec<std::path::PathBuf> {
805
    let config_base_dir = self.config_base_dir(context);
806
    let mut env_files = context
807
      .task_root
808
      .env_file
809
      .iter()
810
      .chain(self.env_file.iter())
811
      .map(|env_file| resolve_path(&config_base_dir, env_file))
812
      .collect::<Vec<_>>();
813
    env_files.sort();
814
    env_files.dedup();
815
    env_files
816
  }
817

            
818
  fn resolve_secret_paths(&self, context: &TaskContext) -> Vec<std::path::PathBuf> {
819
    let vault_location = context
820
      .secret_config
821
      .as_ref()
822
      .map(|config| config.vault_location.clone())
823
      .unwrap_or_else(|| resolve_path(&self.config_base_dir(context), "./.mk/vault"));
824
    let mut secret_paths = context
825
      .task_root
826
      .secrets
827
      .as_ref()
828
      .and_then(|settings| settings.secrets_path.as_ref())
829
      .into_iter()
830
      .flatten()
831
      .chain(
832
        context
833
          .secret_config
834
          .as_ref()
835
          .map(|config| config.secrets_path.iter())
836
          .into_iter()
837
          .flatten(),
838
      )
839
      .map(|secret_path| vault_location.join(secret_path))
840
      .collect::<Vec<_>>();
841
    secret_paths.sort();
842
    secret_paths.dedup();
843
    secret_paths
844
  }
845
}
846

            
847
impl ExecutionMode {
848
206
  fn is_parallel(&self) -> bool {
849
206
    matches!(self, ExecutionMode::Parallel)
850
206
  }
851
}
852

            
853
fn sorted_env_vars(env_vars: &HashMap<String, String>) -> Vec<(String, String)> {
854
  let mut pairs: Vec<_> = env_vars
855
    .iter()
856
    .map(|(key, value)| (key.clone(), value.clone()))
857
    .collect();
858
  pairs.sort();
859
  pairs
860
}
861

            
862
fn fingerprint_task_key(context: &TaskContext, task: &TaskArgs) -> String {
863
  context.current_task_name.clone().unwrap_or_else(|| {
864
    if !task.description.is_empty() {
865
      task.description.clone()
866
    } else {
867
      format!("{:?}", task.commands)
868
    }
869
  })
870
}
871

            
872
16
fn stable_task_fingerprint(task: &TaskArgs) -> String {
873
16
  let mut labels: Vec<_> = task
874
16
    .labels
875
16
    .iter()
876
16
    .map(|(key, value)| (key.clone(), value.clone()))
877
16
    .collect();
878
16
  labels.sort();
879

            
880
16
  let mut environment: Vec<_> = task
881
16
    .environment
882
16
    .iter()
883
16
    .map(|(key, value)| (key.clone(), value.clone()))
884
16
    .collect();
885
16
  environment.sort();
886

            
887
16
  let mut secrets_path = task.secrets_path.clone();
888
16
  secrets_path.sort();
889

            
890
16
  let mut out = String::new();
891
16
  write_section(&mut out, "commands", stable_commands_fingerprint(&task.commands));
892
16
  write_section(
893
16
    &mut out,
894
16
    "preconditions",
895
16
    stable_preconditions_fingerprint(&task.preconditions),
896
  );
897
16
  write_section(
898
16
    &mut out,
899
16
    "depends_on",
900
16
    stable_dependencies_fingerprint(&task.depends_on),
901
  );
902
16
  write_section(&mut out, "labels", stable_string_pairs_fingerprint(&labels));
903
16
  write_section(&mut out, "description", json_value(&task.description));
904
16
  write_section(
905
16
    &mut out,
906
16
    "environment",
907
16
    stable_string_pairs_fingerprint(&environment),
908
  );
909
16
  write_section(&mut out, "env_file", stable_strings_fingerprint(&task.env_file));
910
16
  write_section(
911
16
    &mut out,
912
16
    "secrets",
913
16
    stable_secret_settings_fingerprint(task.normalized_secret_settings().as_ref()),
914
  );
915
16
  write_section(&mut out, "shell", stable_shell_fingerprint(task.shell.as_ref()));
916
16
  write_section(
917
16
    &mut out,
918
16
    "execution_mode",
919
16
    json_value(&execution_mode_name(&task.execution_mode())),
920
  );
921
16
  write_section(
922
16
    &mut out,
923
16
    "max_parallel",
924
16
    json_option_usize(
925
16
      task
926
16
        .execution
927
16
        .as_ref()
928
16
        .and_then(|execution| execution.max_parallel),
929
    ),
930
  );
931
16
  write_section(&mut out, "fail_fast", json_bool(task.fail_fast()));
932
16
  write_section(&mut out, "cache_enabled", json_bool(task.cache_enabled()));
933
16
  write_section(&mut out, "inputs", stable_strings_fingerprint(&task.inputs));
934
16
  write_section(&mut out, "outputs", stable_strings_fingerprint(&task.outputs));
935
16
  write_section(&mut out, "ignore_errors", json_option_bool(task.ignore_errors));
936
16
  write_section(&mut out, "verbose", json_option_bool(task.verbose));
937
16
  write_section(
938
16
    &mut out,
939
16
    "legacy_secrets_path",
940
16
    stable_strings_fingerprint(&secrets_path),
941
  );
942
16
  out
943
16
}
944

            
945
288
fn write_section(out: &mut String, name: &str, value: String) {
946
288
  let _ = write!(out, "{name}={value};");
947
288
}
948

            
949
16
fn stable_commands_fingerprint(commands: &[CommandRunner]) -> String {
950
16
  let values = commands
951
16
    .iter()
952
16
    .map(stable_command_fingerprint)
953
16
    .collect::<Vec<_>>();
954
16
  json_value(&values)
955
16
}
956

            
957
16
fn stable_command_fingerprint(command: &CommandRunner) -> String {
958
16
  match command {
959
    CommandRunner::CommandRun(value) => format!("command_run:{}", json_value(value)),
960
16
    CommandRunner::LocalRun(local_run) => format!(
961
      "local_run:command={};shell={};test={};work_dir={};interactive={};retrigger={};ignore_errors={};save_output_as={};verbose={}",
962
16
      json_value(&local_run.command),
963
16
      stable_shell_fingerprint(local_run.shell.as_ref()),
964
16
      json_option_string(local_run.test.as_ref()),
965
16
      json_option_string(local_run.work_dir.as_ref()),
966
16
      json_option_bool(local_run.interactive),
967
16
      json_option_bool(local_run.retrigger),
968
16
      json_option_bool(local_run.ignore_errors),
969
16
      json_option_string(local_run.save_output_as.as_ref()),
970
16
      json_option_bool(local_run.verbose),
971
    ),
972
    CommandRunner::ContainerRun(container_run) => format!(
973
      "container_run:image={};container_command={};mounted_paths={};runtime={};ignore_errors={};verbose={}",
974
      json_value(&container_run.image),
975
      stable_strings_fingerprint(&container_run.container_command),
976
      stable_strings_fingerprint(&container_run.mounted_paths),
977
      json_option_string(container_run.runtime.as_ref().map(|runtime| runtime.name())),
978
      json_option_bool(container_run.ignore_errors),
979
      json_option_bool(container_run.verbose),
980
    ),
981
    CommandRunner::ContainerBuild(container_build) => format!(
982
      "container_build:image_name={};context={};containerfile={};tags={};build_args={};labels={};sbom={};no_cache={};force_rm={};runtime={};verbose={}",
983
      json_value(&container_build.container_build.image_name),
984
      json_value(&container_build.container_build.context),
985
      json_option_string(container_build.container_build.containerfile.as_ref()),
986
      json_option_vec_strings(container_build.container_build.tags.as_ref()),
987
      json_option_vec_strings(container_build.container_build.build_args.as_ref()),
988
      json_option_vec_strings(container_build.container_build.labels.as_ref()),
989
      json_bool(container_build.container_build.sbom),
990
      json_bool(container_build.container_build.no_cache),
991
      json_bool(container_build.container_build.force_rm),
992
      json_option_string(container_build.container_build.runtime.as_ref().map(|runtime| runtime.name())),
993
      json_option_bool(container_build.verbose),
994
    ),
995
    CommandRunner::TaskRun(task_run) => format!(
996
      "task_run:task={};ignore_errors={};verbose={}",
997
      json_value(&task_run.task),
998
      json_option_bool(task_run.ignore_errors),
999
      json_option_bool(task_run.verbose),
    ),
  }
16
}
16
fn stable_preconditions_fingerprint(preconditions: &[Precondition]) -> String {
16
  let values = preconditions
16
    .iter()
16
    .map(|precondition| {
      format!(
        "command={};message={};shell={};work_dir={};verbose={}",
        json_value(&precondition.command),
        json_option_string(precondition.message.as_ref()),
        stable_shell_fingerprint(precondition.shell.as_ref()),
        json_option_string(precondition.work_dir.as_ref()),
        json_option_bool(precondition.verbose),
      )
    })
16
    .collect::<Vec<_>>();
16
  json_value(&values)
16
}
16
fn stable_dependencies_fingerprint(dependencies: &[TaskDependency]) -> String {
16
  let values = dependencies
16
    .iter()
16
    .map(|dependency| dependency.resolve_name().to_string())
16
    .collect::<Vec<_>>();
16
  json_value(&values)
16
}
32
fn stable_string_pairs_fingerprint(values: &[(String, String)]) -> String {
32
  let values = values
32
    .iter()
32
    .map(|(key, value)| format!("{}={}", json_value(key), json_value(value)))
32
    .collect::<Vec<_>>();
32
  json_value(&values)
32
}
64
fn stable_strings_fingerprint(values: &[String]) -> String {
64
  json_value(values)
64
}
16
fn stable_secret_settings_fingerprint(settings: Option<&SecretSettings>) -> String {
16
  let Some(settings) = settings else {
14
    return "null".to_string();
  };
2
  format!(
    "backend={};vault_location={};keys_location={};key_name={};gpg_key_id={};secrets_path={}",
2
    json_option_string(settings.backend.as_ref().map(|backend| match backend {
2
      crate::secrets::SecretBackend::BuiltInPgp => "built_in_pgp",
      crate::secrets::SecretBackend::Gpg => "gpg",
2
    })),
2
    json_option_string(settings.vault_location.as_ref()),
2
    json_option_string(settings.keys_location.as_ref()),
2
    json_option_string(settings.key_name.as_ref()),
2
    json_option_string(settings.gpg_key_id.as_ref()),
2
    json_option_vec_strings(settings.secrets_path.as_ref()),
  )
16
}
32
fn stable_shell_fingerprint(shell: Option<&Shell>) -> String {
2
  match shell {
30
    None => "null".to_string(),
2
    Some(Shell::String(command)) => format!("string:{}", json_value(command)),
    Some(Shell::Shell(args)) => format!(
      "shell:command={};args={}",
      json_value(&args.command),
      json_option_vec_strings(args.args.as_ref()),
    ),
  }
32
}
16
fn execution_mode_name(mode: &ExecutionMode) -> &'static str {
16
  match mode {
15
    ExecutionMode::Sequential => "sequential",
1
    ExecutionMode::Parallel => "parallel",
  }
16
}
418
fn json_value<T: Serialize + ?Sized>(value: &T) -> String {
418
  serde_json::to_string(value).expect("task fingerprint serialization should succeed")
418
}
32
fn json_bool(value: bool) -> String {
32
  json_value(&value)
32
}
96
fn json_option_bool(value: Option<bool>) -> String {
96
  json_value(&value)
96
}
16
fn json_option_usize(value: Option<usize>) -> String {
16
  json_value(&value)
16
}
58
fn json_option_string(value: Option<impl AsRef<str>>) -> String {
58
  json_value(&value.as_ref().map(|value| value.as_ref().to_string()))
58
}
2
fn json_option_vec_strings(value: Option<&Vec<String>>) -> String {
2
  json_value(&value.cloned())
2
}
#[cfg(test)]
mod test {
  use super::*;
  #[test]
1
  fn test_task_1() -> anyhow::Result<()> {
    {
1
      let yaml = "
1
        commands:
1
          - command: echo \"Hello, World!\"
1
            ignore_errors: false
1
            verbose: false
1
        depends_on:
1
          - name: task1
1
        description: This is a task
1
        environment:
1
          FOO: bar
1
        env_file:
1
          - test.env
1
          - test2.env
1
      ";
1
      let task = serde_yaml::from_str::<Task>(yaml)?;
1
      if let Task::Task(task) = &task {
1
        if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
1
          assert_eq!(local_run.command, "echo \"Hello, World!\"");
1
          assert_eq!(local_run.work_dir, None);
1
          assert_eq!(local_run.ignore_errors, Some(false));
1
          assert_eq!(local_run.verbose, Some(false));
        }
1
        if let TaskDependency::TaskDependency(args) = &task.depends_on[0] {
1
          assert_eq!(args.name, "task1");
        }
1
        assert_eq!(task.labels.len(), 0);
1
        assert_eq!(task.description, "This is a task");
1
        assert_eq!(task.environment.len(), 1);
1
        assert_eq!(task.env_file.len(), 2);
      } else {
        panic!("Expected Task::Task");
      }
1
      Ok(())
    }
1
  }
  #[test]
1
  fn test_task_2() -> anyhow::Result<()> {
    {
1
      let yaml = "
1
        commands:
1
          - command: echo 'Hello, World!'
1
            ignore_errors: false
1
            verbose: false
1
        description: This is a task
1
        environment:
1
          FOO: bar
1
          BAR: foo
1
      ";
1
      let task = serde_yaml::from_str::<Task>(yaml)?;
1
      if let Task::Task(task) = &task {
1
        if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
1
          assert_eq!(local_run.command, "echo 'Hello, World!'");
1
          assert_eq!(local_run.work_dir, None);
1
          assert_eq!(local_run.ignore_errors, Some(false));
1
          assert_eq!(local_run.verbose, Some(false));
        }
1
        assert_eq!(task.description, "This is a task");
1
        assert_eq!(task.depends_on.len(), 0);
1
        assert_eq!(task.labels.len(), 0);
1
        assert_eq!(task.env_file.len(), 0);
1
        assert_eq!(task.environment.len(), 2);
      } else {
        panic!("Expected Task::Task");
      }
1
      Ok(())
    }
1
  }
  #[test]
1
  fn test_task_3() -> anyhow::Result<()> {
    {
1
      let yaml = "
1
        commands:
1
          - command: echo 'Hello, World!'
1
      ";
1
      let task = serde_yaml::from_str::<Task>(yaml)?;
1
      if let Task::Task(task) = &task {
1
        if let CommandRunner::LocalRun(local_run) = &task.commands[0] {
1
          assert_eq!(local_run.command, "echo 'Hello, World!'");
1
          assert_eq!(local_run.work_dir, None);
1
          assert_eq!(local_run.ignore_errors, None);
1
          assert_eq!(local_run.verbose, None);
        }
1
        assert_eq!(task.description.len(), 0);
1
        assert_eq!(task.depends_on.len(), 0);
1
        assert_eq!(task.labels.len(), 0);
1
        assert_eq!(task.env_file.len(), 0);
1
        assert_eq!(task.environment.len(), 0);
      } else {
        panic!("Expected Task::Task");
      }
1
      Ok(())
    }
1
  }
  #[test]
1
  fn test_task_4() -> anyhow::Result<()> {
    {
1
      let yaml = "
1
        commands:
1
          - container_command:
1
              - echo
1
              - Hello, World!
1
            image: docker.io/library/hello-world:latest
1
      ";
1
      let task = serde_yaml::from_str::<Task>(yaml)?;
1
      if let Task::Task(task) = &task {
1
        if let CommandRunner::ContainerRun(container_run) = &task.commands[0] {
1
          assert_eq!(container_run.container_command.len(), 2);
1
          assert_eq!(container_run.container_command[0], "echo");
1
          assert_eq!(container_run.container_command[1], "Hello, World!");
1
          assert_eq!(container_run.image, "docker.io/library/hello-world:latest");
1
          assert_eq!(container_run.mounted_paths, Vec::<String>::new());
1
          assert_eq!(container_run.ignore_errors, None);
1
          assert_eq!(container_run.verbose, None);
        }
1
        assert_eq!(task.description.len(), 0);
1
        assert_eq!(task.depends_on.len(), 0);
1
        assert_eq!(task.labels.len(), 0);
1
        assert_eq!(task.env_file.len(), 0);
1
        assert_eq!(task.environment.len(), 0);
      } else {
        panic!("Expected Task::Task");
      }
1
      Ok(())
    }
1
  }
  #[test]
1
  fn test_task_5() -> anyhow::Result<()> {
    {
1
      let yaml = "
1
        commands:
1
          - container_command:
1
              - echo
1
              - Hello, World!
1
            image: docker.io/library/hello-world:latest
1
            mounted_paths:
1
              - /tmp
1
              - /var/tmp
1
      ";
1
      let task = serde_yaml::from_str::<Task>(yaml)?;
1
      if let Task::Task(task) = &task {
1
        if let CommandRunner::ContainerRun(container_run) = &task.commands[0] {
1
          assert_eq!(container_run.container_command.len(), 2);
1
          assert_eq!(container_run.container_command[0], "echo");
1
          assert_eq!(container_run.container_command[1], "Hello, World!");
1
          assert_eq!(container_run.image, "docker.io/library/hello-world:latest");
1
          assert_eq!(container_run.mounted_paths, vec!["/tmp", "/var/tmp"]);
1
          assert_eq!(container_run.ignore_errors, None);
1
          assert_eq!(container_run.verbose, None);
        }
1
        assert_eq!(task.description.len(), 0);
1
        assert_eq!(task.depends_on.len(), 0);
1
        assert_eq!(task.labels.len(), 0);
1
        assert_eq!(task.env_file.len(), 0);
1
        assert_eq!(task.environment.len(), 0);
      } else {
        panic!("Expected Task::Task");
      }
1
      Ok(())
    }
1
  }
  #[test]
1
  fn test_task_6() -> anyhow::Result<()> {
    {
1
      let yaml = "
1
        commands:
1
          - container_command:
1
              - echo
1
              - Hello, World!
1
            image: docker.io/library/hello-world:latest
1
            mounted_paths:
1
              - /tmp
1
              - /var/tmp
1
            ignore_errors: true
1
      ";
1
      let task = serde_yaml::from_str::<Task>(yaml)?;
1
      if let Task::Task(task) = &task {
1
        if let CommandRunner::ContainerRun(container_run) = &task.commands[0] {
1
          assert_eq!(container_run.container_command.len(), 2);
1
          assert_eq!(container_run.container_command[0], "echo");
1
          assert_eq!(container_run.container_command[1], "Hello, World!");
1
          assert_eq!(container_run.image, "docker.io/library/hello-world:latest");
1
          assert_eq!(container_run.mounted_paths, vec!["/tmp", "/var/tmp"]);
1
          assert_eq!(container_run.ignore_errors, Some(true));
1
          assert_eq!(container_run.verbose, None);
        }
1
        assert_eq!(task.description.len(), 0);
1
        assert_eq!(task.depends_on.len(), 0);
1
        assert_eq!(task.labels.len(), 0);
1
        assert_eq!(task.env_file.len(), 0);
1
        assert_eq!(task.environment.len(), 0);
      } else {
        panic!("Expected Task::Task");
      }
1
      Ok(())
    }
1
  }
  #[test]
1
  fn test_task_7() -> anyhow::Result<()> {
    {
1
      let yaml = "
1
        commands:
1
          - container_command:
1
              - echo
1
              - Hello, World!
1
            image: docker.io/library/hello-world:latest
1
            verbose: false
1
      ";
1
      let task = serde_yaml::from_str::<Task>(yaml)?;
1
      if let Task::Task(task) = &task {
1
        if let CommandRunner::ContainerRun(container_run) = &task.commands[0] {
1
          assert_eq!(container_run.container_command.len(), 2);
1
          assert_eq!(container_run.container_command[0], "echo");
1
          assert_eq!(container_run.container_command[1], "Hello, World!");
1
          assert_eq!(container_run.image, "docker.io/library/hello-world:latest");
1
          assert_eq!(container_run.mounted_paths, Vec::<String>::new());
1
          assert_eq!(container_run.ignore_errors, None);
1
          assert_eq!(container_run.verbose, Some(false));
        }
1
        assert_eq!(task.description.len(), 0);
1
        assert_eq!(task.depends_on.len(), 0);
1
        assert_eq!(task.labels.len(), 0);
1
        assert_eq!(task.env_file.len(), 0);
1
        assert_eq!(task.environment.len(), 0);
      } else {
        panic!("Expected Task::Task");
      }
1
      Ok(())
    }
1
  }
  #[test]
1
  fn test_task_8() -> anyhow::Result<()> {
    {
1
      let yaml = "
1
        commands:
1
          - task: task1
1
      ";
1
      let task = serde_yaml::from_str::<Task>(yaml)?;
1
      if let Task::Task(task) = &task {
1
        if let CommandRunner::TaskRun(task_run) = &task.commands[0] {
1
          assert_eq!(task_run.task, "task1");
1
          assert_eq!(task_run.ignore_errors, None);
1
          assert_eq!(task_run.verbose, None);
        }
1
        assert_eq!(task.description.len(), 0);
1
        assert_eq!(task.depends_on.len(), 0);
1
        assert_eq!(task.labels.len(), 0);
1
        assert_eq!(task.env_file.len(), 0);
1
        assert_eq!(task.environment.len(), 0);
      } else {
        panic!("Expected Task::Task");
      }
1
      Ok(())
    }
1
  }
  #[test]
1
  fn test_task_9() -> anyhow::Result<()> {
    {
1
      let yaml = "
1
        commands:
1
          - task: task1
1
            verbose: true
1
      ";
1
      let task = serde_yaml::from_str::<Task>(yaml)?;
1
      if let Task::Task(task) = &task {
1
        if let CommandRunner::TaskRun(task_run) = &task.commands[0] {
1
          assert_eq!(task_run.task, "task1");
1
          assert_eq!(task_run.ignore_errors, None);
1
          assert_eq!(task_run.verbose, Some(true));
        }
1
        assert_eq!(task.description.len(), 0);
1
        assert_eq!(task.depends_on.len(), 0);
1
        assert_eq!(task.labels.len(), 0);
1
        assert_eq!(task.env_file.len(), 0);
1
        assert_eq!(task.environment.len(), 0);
      } else {
        panic!("Expected Task::Task");
      }
1
      Ok(())
    }
1
  }
  #[test]
1
  fn test_task_10() -> anyhow::Result<()> {
    {
1
      let yaml = "
1
        commands:
1
          - task: task1
1
            ignore_errors: true
1
      ";
1
      let task = serde_yaml::from_str::<Task>(yaml)?;
1
      if let Task::Task(task) = &task {
1
        if let CommandRunner::TaskRun(task_run) = &task.commands[0] {
1
          assert_eq!(task_run.task, "task1");
1
          assert_eq!(task_run.ignore_errors, Some(true));
1
          assert_eq!(task_run.verbose, None);
        }
1
        assert_eq!(task.description.len(), 0);
1
        assert_eq!(task.depends_on.len(), 0);
1
        assert_eq!(task.labels.len(), 0);
1
        assert_eq!(task.env_file.len(), 0);
1
        assert_eq!(task.environment.len(), 0);
      } else {
        panic!("Expected Task::Task");
      }
1
      Ok(())
    }
1
  }
  #[test]
1
  fn test_task_11() -> anyhow::Result<()> {
    {
1
      let yaml = "
1
        echo 'Hello, World!'
1
      ";
1
      let task = serde_yaml::from_str::<Task>(yaml)?;
1
      if let Task::String(task) = &task {
1
        assert_eq!(task, "echo 'Hello, World!'");
      } else {
        panic!("Expected Task::String");
      }
1
      Ok(())
    }
1
  }
  #[test]
1
  fn test_task_12() -> anyhow::Result<()> {
    {
1
      let yaml = "
1
        'true'
1
      ";
1
      let task = serde_yaml::from_str::<Task>(yaml)?;
1
      if let Task::String(task) = &task {
1
        assert_eq!(task, "true");
      } else {
        panic!("Expected Task::String");
      }
1
      Ok(())
    }
1
  }
  #[test]
1
  fn test_task_13() -> anyhow::Result<()> {
    {
1
      let yaml = "
1
        commands: []
1
        environment:
1
          FOO: bar
1
          BAR: foo
1
          KEY: 42
1
          PIS: 3.14
1
      ";
1
      let task = serde_yaml::from_str::<Task>(yaml)?;
1
      if let Task::Task(task) = &task {
1
        assert_eq!(task.environment.len(), 4);
1
        assert_eq!(task.environment.get("FOO").unwrap(), "bar");
1
        assert_eq!(task.environment.get("BAR").unwrap(), "foo");
1
        assert_eq!(task.environment.get("KEY").unwrap(), "42");
      } else {
        panic!("Expected Task::Task");
      }
1
      Ok(())
    }
1
  }
  #[test]
1
  fn test_task_14() -> anyhow::Result<()> {
1
    let yaml = "
1
      commands: []
1
      secrets_path:
1
        - app/common
1
      vault_location: ./.mk/vault
1
      keys_location: ./.mk/keys
1
      key_name: team
1
      environment:
1
        SECRET_VALUE: ${{ secrets.app/password }}
1
    ";
1
    let task = serde_yaml::from_str::<Task>(yaml)?;
1
    if let Task::Task(task) = &task {
1
      assert_eq!(task.secrets_path, vec!["app/common"]);
1
      assert_eq!(task.vault_location.as_deref(), Some("./.mk/vault"));
1
      assert_eq!(task.keys_location.as_deref(), Some("./.mk/keys"));
1
      assert_eq!(task.key_name.as_deref(), Some("team"));
1
      assert_eq!(
1
        task.environment.get("SECRET_VALUE").map(String::as_str),
        Some("${{ secrets.app/password }}")
      );
    } else {
      panic!("Expected Task::Task");
    }
1
    Ok(())
1
  }
  #[test]
1
  fn test_parallel_interactive_rejected() -> anyhow::Result<()> {
1
    let yaml = r#"
1
          commands:
1
            - command: "echo hello"
1
              interactive: true
1
            - command: "echo world"
1
          parallel: true
1
      "#;
1
    let task = serde_yaml::from_str::<Task>(yaml)?;
1
    let mut context = TaskContext::empty();
1
    if let Task::Task(task) = task {
1
      let result = task.run(&mut context);
1
      assert!(result.is_err());
1
      assert!(result
1
        .unwrap_err()
1
        .to_string()
1
        .contains("Interactive local commands cannot be run in parallel"));
    }
1
    Ok(())
1
  }
  #[test]
1
  fn test_parallel_non_interactive_accepted() -> anyhow::Result<()> {
1
    let yaml = r#"
1
          commands:
1
            - command: "echo hello"
1
              interactive: false
1
            - command: "echo world"
1
          parallel: true
1
      "#;
1
    let task = serde_yaml::from_str::<Task>(yaml)?;
1
    let mut context = TaskContext::empty();
1
    if let Task::Task(task) = task {
1
      let result = task.run(&mut context);
1
      assert!(result.is_ok());
    }
1
    Ok(())
1
  }
  #[test]
1
  fn test_parallel_retrigger_rejected() -> anyhow::Result<()> {
1
    let yaml = r#"
1
          commands:
1
            - command: "go run ."
1
              retrigger: true
1
          parallel: true
1
      "#;
1
    let task = serde_yaml::from_str::<Task>(yaml)?;
1
    let mut context = TaskContext::empty();
1
    if let Task::Task(task) = task {
1
      let result = task.run(&mut context);
1
      assert!(result.is_err());
1
      assert!(result
1
        .unwrap_err()
1
        .to_string()
1
        .contains("Local commands with `retrigger: true` cannot be run in parallel"));
    }
1
    Ok(())
1
  }
16
  fn parse_task_args(yaml: &str) -> anyhow::Result<Box<TaskArgs>> {
16
    let task = serde_yaml::from_str::<Task>(yaml)?;
16
    match task {
16
      Task::Task(task) => Ok(task),
      Task::String(_) => anyhow::bail!("expected full task"),
    }
16
  }
  #[test]
1
  fn test_stable_task_fingerprint_ignores_map_insertion_order() -> anyhow::Result<()> {
1
    let first = parse_task_args(
1
      r#"
1
      commands:
1
        - command: echo hi
1
      labels:
1
        b: two
1
        a: one
1
      environment:
1
        BAR: two
1
        FOO: one
1
      "#,
    )?;
1
    let second = parse_task_args(
1
      r#"
1
      commands:
1
        - command: echo hi
1
      labels:
1
        a: one
1
        b: two
1
      environment:
1
        FOO: one
1
        BAR: two
1
      "#,
    )?;
1
    assert_eq!(stable_task_fingerprint(&first), stable_task_fingerprint(&second));
1
    Ok(())
1
  }
  #[test]
1
  fn test_stable_task_fingerprint_treats_dependency_forms_equally() -> anyhow::Result<()> {
1
    let first = parse_task_args(
1
      r#"
1
      commands:
1
        - command: echo hi
1
      depends_on:
1
        - build
1
      "#,
    )?;
1
    let second = parse_task_args(
1
      r#"
1
      commands:
1
        - command: echo hi
1
      depends_on:
1
        - name: build
1
      "#,
    )?;
1
    assert_eq!(stable_task_fingerprint(&first), stable_task_fingerprint(&second));
1
    Ok(())
1
  }
  #[test]
1
  fn test_stable_task_fingerprint_changes_when_command_changes() -> anyhow::Result<()> {
1
    let first = parse_task_args(
1
      r#"
1
      commands:
1
        - command: echo one
1
      "#,
    )?;
1
    let second = parse_task_args(
1
      r#"
1
      commands:
1
        - command: echo two
1
      "#,
    )?;
1
    assert_ne!(stable_task_fingerprint(&first), stable_task_fingerprint(&second));
1
    Ok(())
1
  }
  #[test]
1
  fn test_stable_task_fingerprint_changes_when_outputs_change() -> anyhow::Result<()> {
1
    let first = parse_task_args(
1
      r#"
1
      commands:
1
        - command: echo hi
1
      outputs:
1
        - one.txt
1
      "#,
    )?;
1
    let second = parse_task_args(
1
      r#"
1
      commands:
1
        - command: echo hi
1
      outputs:
1
        - two.txt
1
      "#,
    )?;
1
    assert_ne!(stable_task_fingerprint(&first), stable_task_fingerprint(&second));
1
    Ok(())
1
  }
  #[test]
1
  fn test_stable_task_fingerprint_changes_when_env_changes() -> anyhow::Result<()> {
1
    let first = parse_task_args(
1
      r#"
1
      commands:
1
        - command: echo hi
1
      environment:
1
        FOO: one
1
      "#,
    )?;
1
    let second = parse_task_args(
1
      r#"
1
      commands:
1
        - command: echo hi
1
      environment:
1
        FOO: two
1
      "#,
    )?;
1
    assert_ne!(stable_task_fingerprint(&first), stable_task_fingerprint(&second));
1
    Ok(())
1
  }
  #[test]
1
  fn test_stable_task_fingerprint_changes_when_shell_changes() -> anyhow::Result<()> {
1
    let first = parse_task_args(
1
      r#"
1
      commands:
1
        - command: echo hi
1
      shell: sh
1
      "#,
    )?;
1
    let second = parse_task_args(
1
      r#"
1
      commands:
1
        - command: echo hi
1
      shell: bash
1
      "#,
    )?;
1
    assert_ne!(stable_task_fingerprint(&first), stable_task_fingerprint(&second));
1
    Ok(())
1
  }
  #[test]
1
  fn test_stable_task_fingerprint_changes_when_execution_mode_changes() -> anyhow::Result<()> {
1
    let first = parse_task_args(
1
      r#"
1
      commands:
1
        - command: echo hi
1
      execution:
1
        mode: sequential
1
      "#,
    )?;
1
    let second = parse_task_args(
1
      r#"
1
      commands:
1
        - command: echo hi
1
      execution:
1
        mode: parallel
1
      "#,
    )?;
1
    assert_ne!(stable_task_fingerprint(&first), stable_task_fingerprint(&second));
1
    Ok(())
1
  }
  #[test]
1
  fn test_stable_task_fingerprint_changes_when_secret_path_changes() -> anyhow::Result<()> {
1
    let first = parse_task_args(
1
      r#"
1
      commands:
1
        - command: echo hi
1
      secrets:
1
        backend: built_in_pgp
1
        keys_location: ./.mk/keys
1
        key_name: team
1
        secrets_path:
1
          - app/one
1
      "#,
    )?;
1
    let second = parse_task_args(
1
      r#"
1
      commands:
1
        - command: echo hi
1
      secrets:
1
        backend: built_in_pgp
1
        keys_location: ./.mk/keys
1
        key_name: team
1
        secrets_path:
1
          - app/two
1
      "#,
    )?;
1
    assert_ne!(stable_task_fingerprint(&first), stable_task_fingerprint(&second));
1
    Ok(())
1
  }
}