1
use std::collections::HashSet;
2
use std::path::Path;
3

            
4
use serde::Serialize;
5

            
6
use super::{
7
  contains_output_reference,
8
  extract_output_references,
9
  CommandRunner,
10
  ContainerRuntime,
11
  Include,
12
  Task,
13
  TaskRoot,
14
  UseCargo,
15
  UseNpm,
16
};
17
use crate::schema::Precondition;
18
use crate::secrets::{
19
  merge_optional_secret_settings,
20
  SecretBackend,
21
  SecretSettings,
22
};
23

            
24
#[derive(Debug, Clone, Serialize)]
25
pub struct ValidationIssue {
26
  pub severity: ValidationSeverity,
27
  pub task: Option<String>,
28
  pub field: Option<String>,
29
  pub message: String,
30
}
31

            
32
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
33
#[serde(rename_all = "snake_case")]
34
pub enum ValidationSeverity {
35
  Error,
36
  Warning,
37
}
38

            
39
#[derive(Debug, Default, Serialize)]
40
pub struct ValidationReport {
41
  pub issues: Vec<ValidationIssue>,
42
}
43

            
44
impl ValidationReport {
45
34
  pub fn push_error(&mut self, task: Option<&str>, field: Option<&str>, message: impl Into<String>) {
46
34
    self.issues.push(ValidationIssue {
47
34
      severity: ValidationSeverity::Error,
48
34
      task: task.map(str::to_string),
49
34
      field: field.map(str::to_string),
50
34
      message: message.into(),
51
34
    });
52
34
  }
53

            
54
9
  pub fn push_warning(&mut self, task: Option<&str>, field: Option<&str>, message: impl Into<String>) {
55
9
    self.issues.push(ValidationIssue {
56
9
      severity: ValidationSeverity::Warning,
57
9
      task: task.map(str::to_string),
58
9
      field: field.map(str::to_string),
59
9
      message: message.into(),
60
9
    });
61
9
  }
62

            
63
56
  pub fn has_errors(&self) -> bool {
64
56
    self
65
56
      .issues
66
56
      .iter()
67
56
      .any(|issue| issue.severity == ValidationSeverity::Error)
68
56
  }
69

            
70
44
  pub fn sort_issues(&mut self) {
71
44
    self.issues.sort_by(|left, right| {
72
7
      severity_rank(&left.severity)
73
7
        .cmp(&severity_rank(&right.severity))
74
7
        .then_with(|| {
75
1
          left
76
1
            .task
77
1
            .as_deref()
78
1
            .unwrap_or("")
79
1
            .cmp(right.task.as_deref().unwrap_or(""))
80
1
        })
81
7
        .then_with(|| {
82
1
          left
83
1
            .field
84
1
            .as_deref()
85
1
            .unwrap_or("")
86
1
            .cmp(right.field.as_deref().unwrap_or(""))
87
1
        })
88
7
        .then_with(|| left.message.cmp(&right.message))
89
7
    });
90
44
  }
91
}
92

            
93
14
fn severity_rank(severity: &ValidationSeverity) -> u8 {
94
14
  match severity {
95
8
    ValidationSeverity::Error => 0,
96
6
    ValidationSeverity::Warning => 1,
97
  }
98
14
}
99

            
100
impl TaskRoot {
101
44
  pub fn validate(&self) -> ValidationReport {
102
44
    let mut report = ValidationReport::default();
103

            
104
44
    self.validate_root(&mut report);
105

            
106
52
    for (task_name, task) in &self.tasks {
107
52
      self.validate_task(task_name, task, &mut report);
108
52
    }
109

            
110
44
    self.validate_cycles(&mut report);
111
44
    report.sort_issues();
112

            
113
44
    report
114
44
  }
115

            
116
44
  fn validate_root(&self, report: &mut ValidationReport) {
117
44
    if let Some(use_npm) = &self.use_npm {
118
      self.validate_use_npm(use_npm, report);
119
44
    }
120

            
121
44
    if let Some(use_cargo) = &self.use_cargo {
122
      self.validate_use_cargo(use_cargo, report);
123
44
    }
124

            
125
44
    if let Some(includes) = &self.include {
126
      self.validate_includes(includes, report);
127
44
    }
128

            
129
44
    self.validate_runtime(
130
44
      None,
131
44
      Some("container_runtime"),
132
44
      self.container_runtime.as_ref(),
133
44
      report,
134
    );
135

            
136
44
    self.validate_legacy_secret_settings_usage(None, &self.validation_legacy_secret_settings(), report);
137

            
138
44
    self.validate_secret_setting_combinations(
139
44
      None,
140
44
      self.raw_secrets.as_ref().or(self.secrets.as_ref()),
141
44
      &self.validation_legacy_secret_settings(),
142
44
      report,
143
    );
144
44
  }
145

            
146
52
  fn validate_task(&self, task_name: &str, task: &Task, report: &mut ValidationReport) {
147
52
    match task {
148
      Task::String(command) => {
149
        if command.trim().is_empty() {
150
          report.push_error(Some(task_name), Some("commands"), "Command must not be empty");
151
        }
152
      },
153
52
      Task::Task(task) => {
154
52
        if task.commands.is_empty() {
155
          report.push_error(
156
            Some(task_name),
157
            Some("commands"),
158
            "Task must define at least one command",
159
          );
160
52
        }
161

            
162
52
        for dependency in &task.depends_on {
163
16
          let dependency_name = dependency.resolve_name();
164
16
          if dependency_name.is_empty() {
165
            report.push_error(
166
              Some(task_name),
167
              Some("depends_on"),
168
              "Dependency name must not be empty",
169
            );
170
16
          } else if dependency_name == task_name {
171
            report.push_error(
172
              Some(task_name),
173
              Some("depends_on"),
174
              "Task cannot depend on itself",
175
            );
176
16
          } else if !self.tasks.contains_key(dependency_name) {
177
8
            report.push_error(
178
8
              Some(task_name),
179
8
              Some("depends_on"),
180
8
              format!("Missing dependency: {}", dependency_name),
181
8
            );
182
8
          }
183
        }
184

            
185
52
        if task.is_parallel() {
186
4
          for command in &task.commands {
187
            match command {
188
4
              CommandRunner::LocalRun(local_run) if local_run.is_parallel_safe() => {},
189
              CommandRunner::LocalRun(local_run) if local_run.interactive_enabled() => report.push_error(
190
                Some(task_name),
191
                Some("parallel"),
192
                "Parallel execution only supports non-interactive local commands",
193
              ),
194
              CommandRunner::LocalRun(_) => report.push_error(
195
                Some(task_name),
196
                Some("parallel"),
197
                "Parallel execution does not support local commands with `retrigger: true`",
198
              ),
199
              _ => report.push_error(
200
                Some(task_name),
201
                Some("parallel"),
202
                "Parallel execution only supports non-interactive local commands",
203
              ),
204
            }
205
          }
206

            
207
4
          if task
208
4
            .environment
209
4
            .values()
210
4
            .any(|value| contains_output_reference(value))
211
4
            || task.commands.iter().any(command_uses_task_outputs)
212
4
          {
213
4
            report.push_error(
214
4
              Some(task_name),
215
4
              Some("execution.mode"),
216
4
              "Parallel execution does not support saved command outputs",
217
4
            );
218
4
          }
219
48
        }
220

            
221
52
        if let Some(execution) = &task.execution {
222
4
          if let Some(max_parallel) = execution.max_parallel {
223
            if max_parallel == 0 {
224
              report.push_error(
225
                Some(task_name),
226
                Some("execution.max_parallel"),
227
                "execution.max_parallel must be greater than zero",
228
              );
229
            }
230
4
          }
231
48
        }
232

            
233
52
        if task.cache.as_ref().map(|cache| cache.enabled).unwrap_or(false) && task.outputs.is_empty() {
234
          report.push_warning(
235
            Some(task_name),
236
            Some("outputs"),
237
            "Task cache is enabled without declared outputs; cache hits will not be possible",
238
          );
239
52
        }
240

            
241
52
        if task.cache.as_ref().map(|cache| cache.enabled).unwrap_or(false)
242
6
          && !task.depends_on.is_empty()
243
4
          && task.inputs.is_empty()
244
4
        {
245
4
          report.push_warning(
246
4
            Some(task_name),
247
4
            Some("inputs"),
248
4
            "Cached task depends_on other tasks but declares no inputs; dependency side effects may bypass cache invalidation",
249
4
          );
250
48
        }
251

            
252
52
        if task.cache.as_ref().map(|cache| cache.enabled).unwrap_or(false)
253
6
          && task.inputs.is_empty()
254
5
          && task_uses_dynamic_runtime_inputs(task)
255
1
        {
256
1
          report.push_warning(
257
1
            Some(task_name),
258
1
            Some("inputs"),
259
1
            "Cached task contains shell-derived or runtime-derived command inputs but declares no inputs; cache invalidation may miss external changes",
260
1
          );
261
51
        }
262

            
263
60
        for command in &task.commands {
264
60
          self.validate_command(task_name, command, report);
265
60
        }
266

            
267
52
        self.validate_legacy_secret_settings_usage(
268
52
          Some(task_name),
269
52
          &task.validation_legacy_secret_settings(),
270
52
          report,
271
        );
272

            
273
52
        self.validate_secret_setting_combinations(
274
52
          Some(task_name),
275
52
          task.raw_secrets.as_ref().or(task.secrets.as_ref()),
276
52
          &task.validation_legacy_secret_settings(),
277
52
          report,
278
        );
279

            
280
52
        self.validate_command_outputs(task_name, task, report);
281
52
        self.validate_labels(task_name, task, report);
282
      },
283
    }
284
52
  }
285

            
286
52
  fn validate_labels(&self, task_name: &str, task: &super::TaskArgs, report: &mut ValidationReport) {
287
52
    for (key, value) in &task.labels {
288
5
      if key.trim().is_empty() {
289
1
        report.push_warning(Some(task_name), Some("labels"), "Label key must not be empty");
290
4
      } else if key.starts_with("mk.") {
291
1
        report.push_warning(
292
1
          Some(task_name),
293
1
          Some("labels"),
294
1
          format!("Label key '{}' uses reserved 'mk.' prefix", key),
295
1
        );
296
3
      }
297

            
298
5
      if value.trim().is_empty() {
299
1
        report.push_warning(
300
1
          Some(task_name),
301
1
          Some("labels"),
302
1
          format!("Label '{}' has an empty value", key),
303
1
        );
304
4
      }
305
    }
306
52
  }
307

            
308
96
  fn validate_secret_setting_combinations(
309
96
    &self,
310
96
    task_name: Option<&str>,
311
96
    secrets_block: Option<&SecretSettings>,
312
96
    legacy: &SecretSettings,
313
96
    report: &mut ValidationReport,
314
96
  ) {
315
96
    self.validate_legacy_secret_conflicts(task_name, secrets_block, legacy, report);
316

            
317
96
    let effective = merge_optional_secret_settings(Some(legacy.clone()), secrets_block.cloned());
318
96
    let Some(effective) = effective.filter(|settings| !settings.is_empty()) else {
319
91
      return;
320
    };
321

            
322
5
    let backend = effective.resolved_backend();
323
5
    let explicit_key_name = secrets_block
324
5
      .and_then(|settings| settings.key_name.as_ref())
325
5
      .or(legacy.key_name.as_ref());
326
5
    let explicit_keys_location = secrets_block
327
5
      .and_then(|settings| settings.keys_location.as_ref())
328
5
      .or(legacy.keys_location.as_ref());
329

            
330
5
    match backend {
331
      SecretBackend::Gpg => {
332
2
        if effective.gpg_key_id.is_none() {
333
1
          report.push_error(
334
1
            task_name,
335
1
            Some("secrets.gpg_key_id"),
336
1
            "GPG backend requires gpg_key_id",
337
1
          );
338
1
        }
339

            
340
2
        if explicit_key_name.is_some() || explicit_keys_location.is_some() {
341
1
          report.push_error(
342
1
            task_name,
343
1
            Some("secrets"),
344
1
            "GPG backend cannot be combined with PGP-only settings: key_name, keys_location",
345
1
          );
346
1
        }
347
      },
348
      SecretBackend::BuiltInPgp => {
349
3
        if effective.key_name.is_none() {
350
2
          report.push_error(
351
2
            task_name,
352
2
            Some("secrets.key_name"),
353
2
            "PGP backend requires key_name",
354
2
          );
355
2
        }
356

            
357
3
        if effective.keys_location.is_none() && !pgp_default_keys_location_applies() {
358
          report.push_error(
359
            task_name,
360
            Some("secrets.keys_location"),
361
            "PGP backend requires keys_location when no default applies",
362
          );
363
3
        }
364
      },
365
    }
366
96
  }
367

            
368
96
  fn validate_legacy_secret_conflicts(
369
96
    &self,
370
96
    task_name: Option<&str>,
371
96
    secrets_block: Option<&SecretSettings>,
372
96
    legacy: &SecretSettings,
373
96
    report: &mut ValidationReport,
374
96
  ) {
375
96
    let Some(secrets_block) = secrets_block else {
376
91
      return;
377
    };
378

            
379
5
    validate_secret_field_conflict(
380
5
      task_name,
381
5
      "vault_location",
382
5
      legacy.vault_location.as_ref(),
383
5
      secrets_block.vault_location.as_ref(),
384
5
      report,
385
    );
386
5
    validate_secret_field_conflict(
387
5
      task_name,
388
5
      "keys_location",
389
5
      legacy.keys_location.as_ref(),
390
5
      secrets_block.keys_location.as_ref(),
391
5
      report,
392
    );
393
5
    validate_secret_field_conflict(
394
5
      task_name,
395
5
      "key_name",
396
5
      legacy.key_name.as_ref(),
397
5
      secrets_block.key_name.as_ref(),
398
5
      report,
399
    );
400
5
    validate_secret_field_conflict(
401
5
      task_name,
402
5
      "gpg_key_id",
403
5
      legacy.gpg_key_id.as_ref(),
404
5
      secrets_block.gpg_key_id.as_ref(),
405
5
      report,
406
    );
407
5
    validate_secret_field_conflict(
408
5
      task_name,
409
5
      "secrets_path",
410
5
      legacy.secrets_path.as_ref(),
411
5
      secrets_block.secrets_path.as_ref(),
412
5
      report,
413
    );
414
96
  }
415

            
416
96
  fn validate_legacy_secret_settings_usage(
417
96
    &self,
418
96
    task_name: Option<&str>,
419
96
    legacy: &SecretSettings,
420
96
    report: &mut ValidationReport,
421
96
  ) {
422
96
    if legacy.is_empty() {
423
95
      return;
424
1
    }
425

            
426
1
    report.push_warning(
427
1
      task_name,
428
1
      Some("secrets"),
429
      "Legacy secret fields are deprecated; prefer the `secrets` block",
430
    );
431
96
  }
432

            
433
60
  fn validate_command(&self, task_name: &str, command: &CommandRunner, report: &mut ValidationReport) {
434
60
    match command {
435
      CommandRunner::CommandRun(command) => {
436
        if command.trim().is_empty() {
437
          report.push_error(Some(task_name), Some("command"), "Command must not be empty");
438
        }
439
        if contains_output_reference(command) {
440
          report.push_error(
441
            Some(task_name),
442
            Some("command"),
443
            "Saved command outputs are only supported by local `command:` entries",
444
          );
445
        }
446
      },
447
60
      CommandRunner::LocalRun(local_run) => {
448
60
        if local_run.command.trim().is_empty() {
449
          report.push_error(Some(task_name), Some("command"), "Command must not be empty");
450
60
        }
451
60
        if let Some(save_output_as) = &local_run.save_output_as {
452
16
          if save_output_as.trim().is_empty() {
453
            report.push_error(
454
              Some(task_name),
455
              Some("save_output_as"),
456
              "save_output_as must not be empty",
457
            );
458
16
          }
459
44
        }
460
60
        if local_run.interactive_enabled() && local_run.retrigger_enabled() {
461
1
          report.push_error(
462
1
            Some(task_name),
463
1
            Some("retrigger"),
464
1
            "retrigger is only supported for non-interactive local commands",
465
1
          );
466
59
        }
467
      },
468
      CommandRunner::ContainerRun(container_run) => {
469
        if container_run.image.trim().is_empty() {
470
          report.push_error(
471
            Some(task_name),
472
            Some("image"),
473
            "Container image must not be empty",
474
          );
475
        }
476
        if container_run.container_command.is_empty() {
477
          report.push_error(
478
            Some(task_name),
479
            Some("container_command"),
480
            "Container command must not be empty",
481
          );
482
        }
483
        self.validate_runtime(
484
          Some(task_name),
485
          Some("runtime"),
486
          container_run.runtime.as_ref(),
487
          report,
488
        );
489
      },
490
      CommandRunner::ContainerBuild(container_build) => {
491
        if container_build.container_build.image_name.trim().is_empty() {
492
          report.push_error(
493
            Some(task_name),
494
            Some("container_build.image_name"),
495
            "Container image_name must not be empty",
496
          );
497
        }
498
        if container_build.container_build.context.trim().is_empty() {
499
          report.push_error(
500
            Some(task_name),
501
            Some("container_build.context"),
502
            "Container build context must not be empty",
503
          );
504
        }
505
        if container_build.container_build.containerfile.is_none()
506
          && !has_default_containerfile(&self.resolve_from_config(&container_build.container_build.context))
507
        {
508
          report.push_warning(
509
            Some(task_name),
510
            Some("container_build.containerfile"),
511
            "No explicit containerfile set and no Dockerfile or Containerfile was found in the build context",
512
          );
513
        }
514
        self.validate_runtime(
515
          Some(task_name),
516
          Some("container_build.runtime"),
517
          container_build.container_build.runtime.as_ref(),
518
          report,
519
        );
520
      },
521
      CommandRunner::TaskRun(task_run) => {
522
        if task_run.task.trim().is_empty() {
523
          report.push_error(Some(task_name), Some("task"), "Task name must not be empty");
524
        } else if !self.tasks.contains_key(&task_run.task) {
525
          report.push_error(
526
            Some(task_name),
527
            Some("task"),
528
            format!("Referenced task does not exist: {}", task_run.task),
529
          );
530
        }
531
      },
532
    }
533
60
  }
534

            
535
52
  fn validate_command_outputs(&self, task_name: &str, task: &super::TaskArgs, report: &mut ValidationReport) {
536
52
    let declared_outputs = task
537
52
      .commands
538
52
      .iter()
539
60
      .filter_map(|command| match command {
540
60
        CommandRunner::LocalRun(local_run) => local_run.save_output_as.as_ref(),
541
        _ => None,
542
60
      })
543
52
      .map(|name| name.trim().to_string())
544
52
      .filter(|name| !name.is_empty())
545
52
      .collect::<HashSet<_>>();
546

            
547
52
    for value in task.environment.values() {
548
4
      for output_name in extract_output_references(value) {
549
4
        if !declared_outputs.contains(&output_name) {
550
4
          report.push_error(
551
4
            Some(task_name),
552
4
            Some("environment"),
553
4
            format!("Unknown task output reference: {}", output_name),
554
4
          );
555
4
        }
556
      }
557
    }
558

            
559
52
    let mut produced_outputs = HashSet::new();
560
60
    for command in &task.commands {
561
60
      match command {
562
60
        CommandRunner::LocalRun(local_run) => {
563
60
          for output_name in extract_output_references(&local_run.command) {
564
4
            if !produced_outputs.contains(&output_name) {
565
4
              report.push_error(
566
4
                Some(task_name),
567
4
                Some("command"),
568
4
                format!(
569
4
                  "Output reference must come from an earlier command: {}",
570
4
                  output_name
571
4
                ),
572
4
              );
573
4
            }
574
          }
575

            
576
60
          if let Some(test) = &local_run.test {
577
            for output_name in extract_output_references(test) {
578
              if !produced_outputs.contains(&output_name) {
579
                report.push_error(
580
                  Some(task_name),
581
                  Some("test"),
582
                  format!(
583
                    "Output reference must come from an earlier command: {}",
584
                    output_name
585
                  ),
586
                );
587
              }
588
            }
589
60
          }
590

            
591
60
          if let Some(save_output_as) = &local_run.save_output_as {
592
16
            let save_output_as = save_output_as.trim().to_string();
593
16
            if !save_output_as.is_empty() && !produced_outputs.insert(save_output_as.clone()) {
594
4
              report.push_error(
595
4
                Some(task_name),
596
4
                Some("save_output_as"),
597
4
                format!("Duplicate saved output name: {}", save_output_as),
598
4
              );
599
12
            }
600
44
          }
601
        },
602
        CommandRunner::ContainerRun(_) | CommandRunner::ContainerBuild(_) | CommandRunner::TaskRun(_) => {},
603
        CommandRunner::CommandRun(command) => {
604
          for output_name in extract_output_references(command) {
605
            if !produced_outputs.contains(&output_name) {
606
              report.push_error(
607
                Some(task_name),
608
                Some("command"),
609
                format!(
610
                  "Output reference must come from an earlier command: {}",
611
                  output_name
612
                ),
613
              );
614
            }
615
          }
616
        },
617
      }
618
    }
619
52
  }
620

            
621
  fn validate_use_npm(&self, use_npm: &UseNpm, report: &mut ValidationReport) {
622
    let work_dir = match use_npm {
623
      UseNpm::Bool(true) => None,
624
      UseNpm::UseNpm(args) => args.work_dir.as_deref(),
625
      _ => return,
626
    };
627

            
628
    let package_json = work_dir
629
      .map(|path| self.resolve_from_config(path).join("package.json"))
630
      .unwrap_or_else(|| self.resolve_from_config("package.json"));
631

            
632
    if !package_json.is_file() {
633
      report.push_error(
634
        None,
635
        Some("use_npm"),
636
        format!("package.json does not exist: {}", package_json.to_string_lossy()),
637
      );
638
    }
639
  }
640

            
641
  fn validate_use_cargo(&self, use_cargo: &UseCargo, report: &mut ValidationReport) {
642
    let work_dir = match use_cargo {
643
      UseCargo::Bool(true) => None,
644
      UseCargo::UseCargo(args) => args.work_dir.as_deref(),
645
      _ => return,
646
    };
647

            
648
    if let Some(work_dir) = work_dir {
649
      let path = self.resolve_from_config(work_dir);
650
      if !path.is_dir() {
651
        report.push_error(
652
          None,
653
          Some("use_cargo.work_dir"),
654
          format!("Cargo work_dir does not exist: {}", path.to_string_lossy()),
655
        );
656
      }
657
    }
658
  }
659

            
660
44
  fn validate_runtime(
661
44
    &self,
662
44
    task: Option<&str>,
663
44
    field: Option<&str>,
664
44
    runtime: Option<&ContainerRuntime>,
665
44
    report: &mut ValidationReport,
666
44
  ) {
667
44
    if let Some(runtime) = runtime {
668
      if ContainerRuntime::resolve(Some(runtime)).is_err() {
669
        report.push_error(
670
          task,
671
          field,
672
          format!("Requested container runtime is unavailable: {}", runtime.name()),
673
        );
674
      }
675
44
    }
676
44
  }
677

            
678
  fn validate_includes(&self, includes: &[Include], report: &mut ValidationReport) {
679
    for include in includes {
680
      let name = include.name();
681

            
682
      if name.trim().is_empty() {
683
        report.push_error(None, Some("include"), "Include name must not be empty");
684
        continue;
685
      }
686

            
687
      let overwrite_suffix = if include.overwrite() {
688
        " (overwrite=true)"
689
      } else {
690
        ""
691
      };
692
      report.push_error(
693
        None,
694
        Some("include"),
695
        format!(
696
          "`include` is no longer supported. Replace it with `extends`: {}{}",
697
          name, overwrite_suffix
698
        ),
699
      );
700
    }
701
  }
702

            
703
44
  fn validate_cycles(&self, report: &mut ValidationReport) {
704
44
    let mut visited = HashSet::new();
705
44
    let mut visiting = Vec::new();
706

            
707
52
    for task_name in self.tasks.keys() {
708
52
      self.detect_cycle(task_name, &mut visiting, &mut visited, report);
709
52
    }
710
44
  }
711

            
712
68
  fn detect_cycle(
713
68
    &self,
714
68
    task_name: &str,
715
68
    visiting: &mut Vec<String>,
716
68
    visited: &mut HashSet<String>,
717
68
    report: &mut ValidationReport,
718
68
  ) {
719
68
    if visited.contains(task_name) {
720
4
      return;
721
64
    }
722

            
723
64
    if let Some(index) = visiting.iter().position(|name| name == task_name) {
724
4
      let mut cycle = visiting[index..].to_vec();
725
4
      cycle.push(task_name.to_string());
726
4
      report.push_error(
727
4
        Some(task_name),
728
4
        Some("depends_on"),
729
4
        format!("Circular dependency detected: {}", cycle.join(" -> ")),
730
      );
731
4
      return;
732
60
    }
733

            
734
60
    visiting.push(task_name.to_string());
735

            
736
60
    if let Some(Task::Task(task)) = self.tasks.get(task_name) {
737
52
      for dependency in &task.depends_on {
738
16
        self.detect_cycle(dependency.resolve_name(), visiting, visited, report);
739
16
      }
740

            
741
60
      for command in &task.commands {
742
60
        if let CommandRunner::TaskRun(task_run) = command {
743
          self.detect_cycle(&task_run.task, visiting, visited, report);
744
60
        }
745
      }
746
8
    }
747

            
748
60
    visiting.pop();
749
60
    visited.insert(task_name.to_string());
750
68
  }
751
}
752

            
753
25
fn validate_secret_field_conflict<T: PartialEq>(
754
25
  task_name: Option<&str>,
755
25
  field_name: &str,
756
25
  legacy: Option<&T>,
757
25
  secrets_block: Option<&T>,
758
25
  report: &mut ValidationReport,
759
25
) {
760
25
  if legacy.is_some() && secrets_block.is_some() && legacy != secrets_block {
761
1
    report.push_error(
762
1
      task_name,
763
1
      Some("secrets"),
764
1
      format!(
765
1
        "Legacy secret field '{}' conflicts with `secrets.{}`",
766
1
        field_name, field_name
767
1
      ),
768
1
    );
769
24
  }
770
25
}
771

            
772
2
fn pgp_default_keys_location_applies() -> bool {
773
2
  true
774
2
}
775

            
776
4
fn command_uses_task_outputs(command: &CommandRunner) -> bool {
777
4
  match command {
778
4
    CommandRunner::LocalRun(local_run) => {
779
4
      local_run.save_output_as.is_some()
780
        || contains_output_reference(&local_run.command)
781
        || local_run
782
          .test
783
          .as_ref()
784
          .is_some_and(|test| contains_output_reference(test))
785
    },
786
    CommandRunner::CommandRun(command) => contains_output_reference(command),
787
    CommandRunner::ContainerRun(_) | CommandRunner::ContainerBuild(_) | CommandRunner::TaskRun(_) => false,
788
  }
789
4
}
790

            
791
5
fn task_uses_dynamic_runtime_inputs(task: &super::TaskArgs) -> bool {
792
5
  task.commands.iter().any(command_uses_dynamic_runtime_inputs)
793
4
    || task
794
4
      .preconditions
795
4
      .iter()
796
4
      .any(precondition_uses_dynamic_runtime_inputs)
797
5
}
798

            
799
5
fn command_uses_dynamic_runtime_inputs(command: &CommandRunner) -> bool {
800
5
  match command {
801
    CommandRunner::CommandRun(command) => contains_dynamic_runtime_fragment(command),
802
5
    CommandRunner::LocalRun(local_run) => {
803
5
      contains_dynamic_runtime_fragment(&local_run.command)
804
4
        || local_run
805
4
          .test
806
4
          .as_ref()
807
4
          .is_some_and(|test| contains_dynamic_runtime_fragment(test))
808
    },
809
    CommandRunner::ContainerRun(container_run) => {
810
      contains_dynamic_runtime_fragment(&container_run.image)
811
        || container_run
812
          .container_command
813
          .iter()
814
          .any(|value| contains_dynamic_runtime_fragment(value))
815
        || container_run
816
          .mounted_paths
817
          .iter()
818
          .any(|value| contains_dynamic_runtime_fragment(value))
819
    },
820
    CommandRunner::ContainerBuild(container_build) => {
821
      contains_dynamic_runtime_fragment(&container_build.container_build.image_name)
822
        || contains_dynamic_runtime_fragment(&container_build.container_build.context)
823
        || container_build
824
          .container_build
825
          .containerfile
826
          .as_ref()
827
          .is_some_and(|value| contains_dynamic_runtime_fragment(value))
828
        || container_build
829
          .container_build
830
          .tags
831
          .as_ref()
832
          .is_some_and(|values| {
833
            values
834
              .iter()
835
              .any(|value| contains_dynamic_runtime_fragment(value))
836
          })
837
        || container_build
838
          .container_build
839
          .build_args
840
          .as_ref()
841
          .is_some_and(|values| {
842
            values
843
              .iter()
844
              .any(|value| contains_dynamic_runtime_fragment(value))
845
          })
846
        || container_build
847
          .container_build
848
          .labels
849
          .as_ref()
850
          .is_some_and(|values| {
851
            values
852
              .iter()
853
              .any(|value| contains_dynamic_runtime_fragment(value))
854
          })
855
    },
856
    CommandRunner::TaskRun(_) => false,
857
  }
858
5
}
859

            
860
fn precondition_uses_dynamic_runtime_inputs(precondition: &Precondition) -> bool {
861
  contains_dynamic_runtime_fragment(&precondition.command)
862
}
863

            
864
5
fn contains_dynamic_runtime_fragment(value: &str) -> bool {
865
5
  value.contains("$(")
866
4
    || value.contains('`')
867
4
    || value.contains("MK_NOW")
868
4
    || value.contains("MK_GIT_REVISION")
869
4
    || value.contains("MK_GIT_REMOTE_ORIGIN")
870
5
}
871

            
872
fn has_default_containerfile(context_path: &Path) -> bool {
873
  context_path.join("Dockerfile").is_file() || context_path.join("Containerfile").is_file()
874
}
875

            
876
#[cfg(test)]
877
mod tests {
878
  use super::*;
879

            
880
4
  fn has_error(report: &ValidationReport, field: &str, message: &str) -> bool {
881
4
    report.issues.iter().any(|issue| {
882
4
      issue.severity == ValidationSeverity::Error
883
4
        && issue.field.as_deref() == Some(field)
884
4
        && issue.message == message
885
4
    })
886
4
  }
887

            
888
  #[test]
889
1
  fn test_validate_retrigger_requires_non_interactive_local_run() -> anyhow::Result<()> {
890
1
    let yaml = r#"
891
1
      tasks:
892
1
        dev:
893
1
          commands:
894
1
            - command: "go run ."
895
1
              interactive: true
896
1
              retrigger: true
897
1
    "#;
898

            
899
1
    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
900
1
    let report = task_root.validate();
901

            
902
1
    assert!(report.issues.iter().any(|issue| {
903
1
      issue.field.as_deref() == Some("retrigger")
904
1
        && issue.message == "retrigger is only supported for non-interactive local commands"
905
1
    }));
906

            
907
1
    Ok(())
908
1
  }
909

            
910
  #[test]
911
1
  fn test_validate_rejects_gpg_backend_without_gpg_key_id() -> anyhow::Result<()> {
912
1
    let yaml = r#"
913
1
      secrets:
914
1
        backend: gpg
915
1
      tasks:
916
1
        demo:
917
1
          commands:
918
1
            - command: echo ready
919
1
    "#;
920

            
921
1
    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
922
1
    let report = task_root.validate();
923

            
924
1
    assert!(has_error(
925
1
      &report,
926
1
      "secrets.gpg_key_id",
927
1
      "GPG backend requires gpg_key_id"
928
    ));
929
1
    Ok(())
930
1
  }
931

            
932
  #[test]
933
1
  fn test_validate_rejects_pgp_backend_without_key_name() -> anyhow::Result<()> {
934
1
    let yaml = r#"
935
1
      secrets:
936
1
        backend: built_in_pgp
937
1
        keys_location: ./.mk/keys
938
1
      tasks:
939
1
        demo:
940
1
          commands:
941
1
            - command: echo ready
942
1
    "#;
943

            
944
1
    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
945
1
    let report = task_root.validate();
946

            
947
1
    assert!(has_error(
948
1
      &report,
949
1
      "secrets.key_name",
950
1
      "PGP backend requires key_name"
951
    ));
952
1
    Ok(())
953
1
  }
954

            
955
  #[test]
956
1
  fn test_validate_allows_pgp_backend_without_keys_location_when_default_applies() -> anyhow::Result<()> {
957
1
    let yaml = r#"
958
1
      secrets:
959
1
        backend: built_in_pgp
960
1
        key_name: team
961
1
      tasks:
962
1
        demo:
963
1
          commands:
964
1
            - command: echo ready
965
1
    "#;
966

            
967
1
    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
968
1
    let report = task_root.validate();
969

            
970
1
    assert!(!report
971
1
      .issues
972
1
      .iter()
973
1
      .any(|issue| issue.field.as_deref() == Some("secrets.keys_location")));
974
1
    Ok(())
975
1
  }
976

            
977
  #[test]
978
1
  fn test_validate_rejects_gpg_backend_with_pgp_only_settings() -> anyhow::Result<()> {
979
1
    let yaml = r#"
980
1
      tasks:
981
1
        demo:
982
1
          secrets:
983
1
            backend: gpg
984
1
            gpg_key_id: TEAMKEY
985
1
            key_name: team
986
1
            keys_location: ./.mk/keys
987
1
          commands:
988
1
            - command: echo ready
989
1
    "#;
990

            
991
1
    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
992
1
    let report = task_root.validate();
993

            
994
1
    assert!(has_error(
995
1
      &report,
996
1
      "secrets",
997
1
      "GPG backend cannot be combined with PGP-only settings: key_name, keys_location"
998
    ));
999
1
    Ok(())
1
  }
  #[test]
1
  fn test_validate_rejects_conflicting_legacy_and_secrets_block_values() -> anyhow::Result<()> {
1
    let yaml = r#"
1
      vault_location: ./.mk/legacy-vault
1
      secrets:
1
        vault_location: ./.mk/new-vault
1
      tasks:
1
        demo:
1
          commands:
1
            - command: echo ready
1
    "#;
1
    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
1
    let report = task_root.validate();
1
    assert!(has_error(
1
      &report,
1
      "secrets",
1
      "Legacy secret field 'vault_location' conflicts with `secrets.vault_location`"
    ));
1
    Ok(())
1
  }
5
  fn has_warning(report: &ValidationReport, field: &str, message: &str) -> bool {
5
    report.issues.iter().any(|issue| {
4
      issue.severity == ValidationSeverity::Warning
4
        && issue.field.as_deref() == Some(field)
4
        && issue.message == message
4
    })
5
  }
  #[test]
1
  fn test_validate_warns_on_empty_label_key() -> anyhow::Result<()> {
1
    let yaml = r#"
1
      tasks:
1
        demo:
1
          commands:
1
            - command: echo ready
1
          labels:
1
            "": present
1
    "#;
1
    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
1
    let report = task_root.validate();
1
    assert!(has_warning(&report, "labels", "Label key must not be empty"));
1
    Ok(())
1
  }
  #[test]
1
  fn test_validate_warns_on_empty_label_value() -> anyhow::Result<()> {
1
    let yaml = r#"
1
      tasks:
1
        demo:
1
          commands:
1
            - command: echo ready
1
          labels:
1
            area: ""
1
    "#;
1
    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
1
    let report = task_root.validate();
1
    assert!(has_warning(&report, "labels", "Label 'area' has an empty value"));
1
    Ok(())
1
  }
  #[test]
1
  fn test_validate_warns_on_reserved_mk_prefix() -> anyhow::Result<()> {
1
    let yaml = r#"
1
      tasks:
1
        demo:
1
          commands:
1
            - command: echo ready
1
          labels:
1
            mk.internal: reserved
1
    "#;
1
    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
1
    let report = task_root.validate();
1
    assert!(has_warning(
1
      &report,
1
      "labels",
1
      "Label key 'mk.internal' uses reserved 'mk.' prefix"
    ));
1
    Ok(())
1
  }
  #[test]
1
  fn test_validate_allows_valid_labels() -> anyhow::Result<()> {
1
    let yaml = r#"
1
      tasks:
1
        demo:
1
          commands:
1
            - command: echo ready
1
          labels:
1
            area: ci
1
            kind: test
1
    "#;
1
    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
1
    let report = task_root.validate();
1
    assert!(!report
1
      .issues
1
      .iter()
1
      .any(|issue| issue.field.as_deref() == Some("labels")));
1
    Ok(())
1
  }
  #[test]
1
  fn test_validate_warns_for_cached_dynamic_command_without_inputs() -> anyhow::Result<()> {
1
    let yaml = r#"
1
      tasks:
1
        build:
1
          outputs:
1
            - output.txt
1
          cache:
1
            enabled: true
1
          commands:
1
            - command: echo $(git rev-parse HEAD) > output.txt
1
    "#;
1
    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
1
    let report = task_root.validate();
1
    assert!(has_warning(
1
      &report,
1
      "inputs",
1
      "Cached task contains shell-derived or runtime-derived command inputs but declares no inputs; cache invalidation may miss external changes"
    ));
1
    Ok(())
1
  }
  #[test]
1
  fn test_validate_does_not_warn_for_cached_dynamic_command_with_inputs() -> anyhow::Result<()> {
1
    let yaml = r#"
1
      tasks:
1
        build:
1
          inputs:
1
            - .git/HEAD
1
          outputs:
1
            - output.txt
1
          cache:
1
            enabled: true
1
          commands:
1
            - command: echo $(git rev-parse HEAD) > output.txt
1
    "#;
1
    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
1
    let report = task_root.validate();
1
    assert!(!has_warning(
1
      &report,
1
      "inputs",
1
      "Cached task contains shell-derived or runtime-derived command inputs but declares no inputs; cache invalidation may miss external changes"
1
    ));
1
    Ok(())
1
  }
}