Skip to main content

mk_lib/schema/
validation.rs

1use std::collections::HashSet;
2use std::path::Path;
3
4use serde::Serialize;
5
6use super::{
7  contains_output_reference,
8  extract_output_references,
9  CommandRunner,
10  ContainerRuntime,
11  Include,
12  Task,
13  TaskRoot,
14  UseCargo,
15  UseNpm,
16};
17use crate::secrets::{
18  merge_optional_secret_settings,
19  SecretBackend,
20  SecretSettings,
21};
22
23#[derive(Debug, Clone, Serialize)]
24pub struct ValidationIssue {
25  pub severity: ValidationSeverity,
26  pub task: Option<String>,
27  pub field: Option<String>,
28  pub message: String,
29}
30
31#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
32#[serde(rename_all = "snake_case")]
33pub enum ValidationSeverity {
34  Error,
35  Warning,
36}
37
38#[derive(Debug, Default, Serialize)]
39pub struct ValidationReport {
40  pub issues: Vec<ValidationIssue>,
41}
42
43impl ValidationReport {
44  pub fn push_error(&mut self, task: Option<&str>, field: Option<&str>, message: impl Into<String>) {
45    self.issues.push(ValidationIssue {
46      severity: ValidationSeverity::Error,
47      task: task.map(str::to_string),
48      field: field.map(str::to_string),
49      message: message.into(),
50    });
51  }
52
53  pub fn push_warning(&mut self, task: Option<&str>, field: Option<&str>, message: impl Into<String>) {
54    self.issues.push(ValidationIssue {
55      severity: ValidationSeverity::Warning,
56      task: task.map(str::to_string),
57      field: field.map(str::to_string),
58      message: message.into(),
59    });
60  }
61
62  pub fn has_errors(&self) -> bool {
63    self
64      .issues
65      .iter()
66      .any(|issue| issue.severity == ValidationSeverity::Error)
67  }
68
69  pub fn sort_issues(&mut self) {
70    self.issues.sort_by(|left, right| {
71      severity_rank(&left.severity)
72        .cmp(&severity_rank(&right.severity))
73        .then_with(|| {
74          left
75            .task
76            .as_deref()
77            .unwrap_or("")
78            .cmp(right.task.as_deref().unwrap_or(""))
79        })
80        .then_with(|| {
81          left
82            .field
83            .as_deref()
84            .unwrap_or("")
85            .cmp(right.field.as_deref().unwrap_or(""))
86        })
87        .then_with(|| left.message.cmp(&right.message))
88    });
89  }
90}
91
92fn severity_rank(severity: &ValidationSeverity) -> u8 {
93  match severity {
94    ValidationSeverity::Error => 0,
95    ValidationSeverity::Warning => 1,
96  }
97}
98
99impl TaskRoot {
100  pub fn validate(&self) -> ValidationReport {
101    let mut report = ValidationReport::default();
102
103    self.validate_root(&mut report);
104
105    for (task_name, task) in &self.tasks {
106      self.validate_task(task_name, task, &mut report);
107    }
108
109    self.validate_cycles(&mut report);
110    report.sort_issues();
111
112    report
113  }
114
115  fn validate_root(&self, report: &mut ValidationReport) {
116    if let Some(use_npm) = &self.use_npm {
117      self.validate_use_npm(use_npm, report);
118    }
119
120    if let Some(use_cargo) = &self.use_cargo {
121      self.validate_use_cargo(use_cargo, report);
122    }
123
124    if let Some(includes) = &self.include {
125      self.validate_includes(includes, report);
126    }
127
128    self.validate_runtime(
129      None,
130      Some("container_runtime"),
131      self.container_runtime.as_ref(),
132      report,
133    );
134
135    self.validate_legacy_secret_settings_usage(None, &self.validation_legacy_secret_settings(), report);
136
137    self.validate_secret_setting_combinations(
138      None,
139      self.raw_secrets.as_ref().or(self.secrets.as_ref()),
140      &self.validation_legacy_secret_settings(),
141      report,
142    );
143  }
144
145  fn validate_task(&self, task_name: &str, task: &Task, report: &mut ValidationReport) {
146    match task {
147      Task::String(command) => {
148        if command.trim().is_empty() {
149          report.push_error(Some(task_name), Some("commands"), "Command must not be empty");
150        }
151      },
152      Task::Task(task) => {
153        if task.commands.is_empty() {
154          report.push_error(
155            Some(task_name),
156            Some("commands"),
157            "Task must define at least one command",
158          );
159        }
160
161        for dependency in &task.depends_on {
162          let dependency_name = dependency.resolve_name();
163          if dependency_name.is_empty() {
164            report.push_error(
165              Some(task_name),
166              Some("depends_on"),
167              "Dependency name must not be empty",
168            );
169          } else if dependency_name == task_name {
170            report.push_error(
171              Some(task_name),
172              Some("depends_on"),
173              "Task cannot depend on itself",
174            );
175          } else if !self.tasks.contains_key(dependency_name) {
176            report.push_error(
177              Some(task_name),
178              Some("depends_on"),
179              format!("Missing dependency: {}", dependency_name),
180            );
181          }
182        }
183
184        if task.is_parallel() {
185          for command in &task.commands {
186            match command {
187              CommandRunner::LocalRun(local_run) if local_run.is_parallel_safe() => {},
188              CommandRunner::LocalRun(local_run) if local_run.interactive_enabled() => report.push_error(
189                Some(task_name),
190                Some("parallel"),
191                "Parallel execution only supports non-interactive local commands",
192              ),
193              CommandRunner::LocalRun(_) => report.push_error(
194                Some(task_name),
195                Some("parallel"),
196                "Parallel execution does not support local commands with `retrigger: true`",
197              ),
198              _ => report.push_error(
199                Some(task_name),
200                Some("parallel"),
201                "Parallel execution only supports non-interactive local commands",
202              ),
203            }
204          }
205
206          if task
207            .environment
208            .values()
209            .any(|value| contains_output_reference(value))
210            || task.commands.iter().any(command_uses_task_outputs)
211          {
212            report.push_error(
213              Some(task_name),
214              Some("execution.mode"),
215              "Parallel execution does not support saved command outputs",
216            );
217          }
218        }
219
220        if let Some(execution) = &task.execution {
221          if let Some(max_parallel) = execution.max_parallel {
222            if max_parallel == 0 {
223              report.push_error(
224                Some(task_name),
225                Some("execution.max_parallel"),
226                "execution.max_parallel must be greater than zero",
227              );
228            }
229          }
230        }
231
232        if task.cache.as_ref().map(|cache| cache.enabled).unwrap_or(false) && task.outputs.is_empty() {
233          report.push_warning(
234            Some(task_name),
235            Some("outputs"),
236            "Task cache is enabled without declared outputs; cache hits will not be possible",
237          );
238        }
239
240        if task.cache.as_ref().map(|cache| cache.enabled).unwrap_or(false)
241          && !task.depends_on.is_empty()
242          && task.inputs.is_empty()
243        {
244          report.push_warning(
245            Some(task_name),
246            Some("inputs"),
247            "Cached task depends_on other tasks but declares no inputs; dependency side effects may bypass cache invalidation",
248          );
249        }
250
251        for command in &task.commands {
252          self.validate_command(task_name, command, report);
253        }
254
255        self.validate_legacy_secret_settings_usage(
256          Some(task_name),
257          &task.validation_legacy_secret_settings(),
258          report,
259        );
260
261        self.validate_secret_setting_combinations(
262          Some(task_name),
263          task.raw_secrets.as_ref().or(task.secrets.as_ref()),
264          &task.validation_legacy_secret_settings(),
265          report,
266        );
267
268        self.validate_command_outputs(task_name, task, report);
269        self.validate_labels(task_name, task, report);
270      },
271    }
272  }
273
274  fn validate_labels(&self, task_name: &str, task: &super::TaskArgs, report: &mut ValidationReport) {
275    for (key, value) in &task.labels {
276      if key.trim().is_empty() {
277        report.push_warning(Some(task_name), Some("labels"), "Label key must not be empty");
278      } else if key.starts_with("mk.") {
279        report.push_warning(
280          Some(task_name),
281          Some("labels"),
282          format!("Label key '{}' uses reserved 'mk.' prefix", key),
283        );
284      }
285
286      if value.trim().is_empty() {
287        report.push_warning(
288          Some(task_name),
289          Some("labels"),
290          format!("Label '{}' has an empty value", key),
291        );
292      }
293    }
294  }
295
296  fn validate_secret_setting_combinations(
297    &self,
298    task_name: Option<&str>,
299    secrets_block: Option<&SecretSettings>,
300    legacy: &SecretSettings,
301    report: &mut ValidationReport,
302  ) {
303    self.validate_legacy_secret_conflicts(task_name, secrets_block, legacy, report);
304
305    let effective = merge_optional_secret_settings(Some(legacy.clone()), secrets_block.cloned());
306    let Some(effective) = effective.filter(|settings| !settings.is_empty()) else {
307      return;
308    };
309
310    let backend = effective.resolved_backend();
311    let explicit_key_name = secrets_block
312      .and_then(|settings| settings.key_name.as_ref())
313      .or(legacy.key_name.as_ref());
314    let explicit_keys_location = secrets_block
315      .and_then(|settings| settings.keys_location.as_ref())
316      .or(legacy.keys_location.as_ref());
317
318    match backend {
319      SecretBackend::Gpg => {
320        if effective.gpg_key_id.is_none() {
321          report.push_error(
322            task_name,
323            Some("secrets.gpg_key_id"),
324            "GPG backend requires gpg_key_id",
325          );
326        }
327
328        if explicit_key_name.is_some() || explicit_keys_location.is_some() {
329          report.push_error(
330            task_name,
331            Some("secrets"),
332            "GPG backend cannot be combined with PGP-only settings: key_name, keys_location",
333          );
334        }
335      },
336      SecretBackend::BuiltInPgp => {
337        if effective.key_name.is_none() {
338          report.push_error(
339            task_name,
340            Some("secrets.key_name"),
341            "PGP backend requires key_name",
342          );
343        }
344
345        if effective.keys_location.is_none() && !pgp_default_keys_location_applies() {
346          report.push_error(
347            task_name,
348            Some("secrets.keys_location"),
349            "PGP backend requires keys_location when no default applies",
350          );
351        }
352      },
353    }
354  }
355
356  fn validate_legacy_secret_conflicts(
357    &self,
358    task_name: Option<&str>,
359    secrets_block: Option<&SecretSettings>,
360    legacy: &SecretSettings,
361    report: &mut ValidationReport,
362  ) {
363    let Some(secrets_block) = secrets_block else {
364      return;
365    };
366
367    validate_secret_field_conflict(
368      task_name,
369      "vault_location",
370      legacy.vault_location.as_ref(),
371      secrets_block.vault_location.as_ref(),
372      report,
373    );
374    validate_secret_field_conflict(
375      task_name,
376      "keys_location",
377      legacy.keys_location.as_ref(),
378      secrets_block.keys_location.as_ref(),
379      report,
380    );
381    validate_secret_field_conflict(
382      task_name,
383      "key_name",
384      legacy.key_name.as_ref(),
385      secrets_block.key_name.as_ref(),
386      report,
387    );
388    validate_secret_field_conflict(
389      task_name,
390      "gpg_key_id",
391      legacy.gpg_key_id.as_ref(),
392      secrets_block.gpg_key_id.as_ref(),
393      report,
394    );
395    validate_secret_field_conflict(
396      task_name,
397      "secrets_path",
398      legacy.secrets_path.as_ref(),
399      secrets_block.secrets_path.as_ref(),
400      report,
401    );
402  }
403
404  fn validate_legacy_secret_settings_usage(
405    &self,
406    task_name: Option<&str>,
407    legacy: &SecretSettings,
408    report: &mut ValidationReport,
409  ) {
410    if legacy.is_empty() {
411      return;
412    }
413
414    report.push_warning(
415      task_name,
416      Some("secrets"),
417      "Legacy secret fields are deprecated; prefer the `secrets` block",
418    );
419  }
420
421  fn validate_command(&self, task_name: &str, command: &CommandRunner, report: &mut ValidationReport) {
422    match command {
423      CommandRunner::CommandRun(command) => {
424        if command.trim().is_empty() {
425          report.push_error(Some(task_name), Some("command"), "Command must not be empty");
426        }
427        if contains_output_reference(command) {
428          report.push_error(
429            Some(task_name),
430            Some("command"),
431            "Saved command outputs are only supported by local `command:` entries",
432          );
433        }
434      },
435      CommandRunner::LocalRun(local_run) => {
436        if local_run.command.trim().is_empty() {
437          report.push_error(Some(task_name), Some("command"), "Command must not be empty");
438        }
439        if let Some(save_output_as) = &local_run.save_output_as {
440          if save_output_as.trim().is_empty() {
441            report.push_error(
442              Some(task_name),
443              Some("save_output_as"),
444              "save_output_as must not be empty",
445            );
446          }
447        }
448        if local_run.interactive_enabled() && local_run.retrigger_enabled() {
449          report.push_error(
450            Some(task_name),
451            Some("retrigger"),
452            "retrigger is only supported for non-interactive local commands",
453          );
454        }
455      },
456      CommandRunner::ContainerRun(container_run) => {
457        if container_run.image.trim().is_empty() {
458          report.push_error(
459            Some(task_name),
460            Some("image"),
461            "Container image must not be empty",
462          );
463        }
464        if container_run.container_command.is_empty() {
465          report.push_error(
466            Some(task_name),
467            Some("container_command"),
468            "Container command must not be empty",
469          );
470        }
471        self.validate_runtime(
472          Some(task_name),
473          Some("runtime"),
474          container_run.runtime.as_ref(),
475          report,
476        );
477      },
478      CommandRunner::ContainerBuild(container_build) => {
479        if container_build.container_build.image_name.trim().is_empty() {
480          report.push_error(
481            Some(task_name),
482            Some("container_build.image_name"),
483            "Container image_name must not be empty",
484          );
485        }
486        if container_build.container_build.context.trim().is_empty() {
487          report.push_error(
488            Some(task_name),
489            Some("container_build.context"),
490            "Container build context must not be empty",
491          );
492        }
493        if container_build.container_build.containerfile.is_none()
494          && !has_default_containerfile(&self.resolve_from_config(&container_build.container_build.context))
495        {
496          report.push_warning(
497            Some(task_name),
498            Some("container_build.containerfile"),
499            "No explicit containerfile set and no Dockerfile or Containerfile was found in the build context",
500          );
501        }
502        self.validate_runtime(
503          Some(task_name),
504          Some("container_build.runtime"),
505          container_build.container_build.runtime.as_ref(),
506          report,
507        );
508      },
509      CommandRunner::TaskRun(task_run) => {
510        if task_run.task.trim().is_empty() {
511          report.push_error(Some(task_name), Some("task"), "Task name must not be empty");
512        } else if !self.tasks.contains_key(&task_run.task) {
513          report.push_error(
514            Some(task_name),
515            Some("task"),
516            format!("Referenced task does not exist: {}", task_run.task),
517          );
518        }
519      },
520    }
521  }
522
523  fn validate_command_outputs(&self, task_name: &str, task: &super::TaskArgs, report: &mut ValidationReport) {
524    let declared_outputs = task
525      .commands
526      .iter()
527      .filter_map(|command| match command {
528        CommandRunner::LocalRun(local_run) => local_run.save_output_as.as_ref(),
529        _ => None,
530      })
531      .map(|name| name.trim().to_string())
532      .filter(|name| !name.is_empty())
533      .collect::<HashSet<_>>();
534
535    for value in task.environment.values() {
536      for output_name in extract_output_references(value) {
537        if !declared_outputs.contains(&output_name) {
538          report.push_error(
539            Some(task_name),
540            Some("environment"),
541            format!("Unknown task output reference: {}", output_name),
542          );
543        }
544      }
545    }
546
547    let mut produced_outputs = HashSet::new();
548    for command in &task.commands {
549      match command {
550        CommandRunner::LocalRun(local_run) => {
551          for output_name in extract_output_references(&local_run.command) {
552            if !produced_outputs.contains(&output_name) {
553              report.push_error(
554                Some(task_name),
555                Some("command"),
556                format!(
557                  "Output reference must come from an earlier command: {}",
558                  output_name
559                ),
560              );
561            }
562          }
563
564          if let Some(test) = &local_run.test {
565            for output_name in extract_output_references(test) {
566              if !produced_outputs.contains(&output_name) {
567                report.push_error(
568                  Some(task_name),
569                  Some("test"),
570                  format!(
571                    "Output reference must come from an earlier command: {}",
572                    output_name
573                  ),
574                );
575              }
576            }
577          }
578
579          if let Some(save_output_as) = &local_run.save_output_as {
580            let save_output_as = save_output_as.trim().to_string();
581            if !save_output_as.is_empty() && !produced_outputs.insert(save_output_as.clone()) {
582              report.push_error(
583                Some(task_name),
584                Some("save_output_as"),
585                format!("Duplicate saved output name: {}", save_output_as),
586              );
587            }
588          }
589        },
590        CommandRunner::ContainerRun(_) | CommandRunner::ContainerBuild(_) | CommandRunner::TaskRun(_) => {},
591        CommandRunner::CommandRun(command) => {
592          for output_name in extract_output_references(command) {
593            if !produced_outputs.contains(&output_name) {
594              report.push_error(
595                Some(task_name),
596                Some("command"),
597                format!(
598                  "Output reference must come from an earlier command: {}",
599                  output_name
600                ),
601              );
602            }
603          }
604        },
605      }
606    }
607  }
608
609  fn validate_use_npm(&self, use_npm: &UseNpm, report: &mut ValidationReport) {
610    let work_dir = match use_npm {
611      UseNpm::Bool(true) => None,
612      UseNpm::UseNpm(args) => args.work_dir.as_deref(),
613      _ => return,
614    };
615
616    let package_json = work_dir
617      .map(|path| self.resolve_from_config(path).join("package.json"))
618      .unwrap_or_else(|| self.resolve_from_config("package.json"));
619
620    if !package_json.is_file() {
621      report.push_error(
622        None,
623        Some("use_npm"),
624        format!("package.json does not exist: {}", package_json.to_string_lossy()),
625      );
626    }
627  }
628
629  fn validate_use_cargo(&self, use_cargo: &UseCargo, report: &mut ValidationReport) {
630    let work_dir = match use_cargo {
631      UseCargo::Bool(true) => None,
632      UseCargo::UseCargo(args) => args.work_dir.as_deref(),
633      _ => return,
634    };
635
636    if let Some(work_dir) = work_dir {
637      let path = self.resolve_from_config(work_dir);
638      if !path.is_dir() {
639        report.push_error(
640          None,
641          Some("use_cargo.work_dir"),
642          format!("Cargo work_dir does not exist: {}", path.to_string_lossy()),
643        );
644      }
645    }
646  }
647
648  fn validate_runtime(
649    &self,
650    task: Option<&str>,
651    field: Option<&str>,
652    runtime: Option<&ContainerRuntime>,
653    report: &mut ValidationReport,
654  ) {
655    if let Some(runtime) = runtime {
656      if ContainerRuntime::resolve(Some(runtime)).is_err() {
657        report.push_error(
658          task,
659          field,
660          format!("Requested container runtime is unavailable: {}", runtime.name()),
661        );
662      }
663    }
664  }
665
666  fn validate_includes(&self, includes: &[Include], report: &mut ValidationReport) {
667    for include in includes {
668      let name = include.name();
669
670      if name.trim().is_empty() {
671        report.push_error(None, Some("include"), "Include name must not be empty");
672        continue;
673      }
674
675      let overwrite_suffix = if include.overwrite() {
676        " (overwrite=true)"
677      } else {
678        ""
679      };
680      report.push_error(
681        None,
682        Some("include"),
683        format!(
684          "`include` is no longer supported. Replace it with `extends`: {}{}",
685          name, overwrite_suffix
686        ),
687      );
688    }
689  }
690
691  fn validate_cycles(&self, report: &mut ValidationReport) {
692    let mut visited = HashSet::new();
693    let mut visiting = Vec::new();
694
695    for task_name in self.tasks.keys() {
696      self.detect_cycle(task_name, &mut visiting, &mut visited, report);
697    }
698  }
699
700  fn detect_cycle(
701    &self,
702    task_name: &str,
703    visiting: &mut Vec<String>,
704    visited: &mut HashSet<String>,
705    report: &mut ValidationReport,
706  ) {
707    if visited.contains(task_name) {
708      return;
709    }
710
711    if let Some(index) = visiting.iter().position(|name| name == task_name) {
712      let mut cycle = visiting[index..].to_vec();
713      cycle.push(task_name.to_string());
714      report.push_error(
715        Some(task_name),
716        Some("depends_on"),
717        format!("Circular dependency detected: {}", cycle.join(" -> ")),
718      );
719      return;
720    }
721
722    visiting.push(task_name.to_string());
723
724    if let Some(Task::Task(task)) = self.tasks.get(task_name) {
725      for dependency in &task.depends_on {
726        self.detect_cycle(dependency.resolve_name(), visiting, visited, report);
727      }
728
729      for command in &task.commands {
730        if let CommandRunner::TaskRun(task_run) = command {
731          self.detect_cycle(&task_run.task, visiting, visited, report);
732        }
733      }
734    }
735
736    visiting.pop();
737    visited.insert(task_name.to_string());
738  }
739}
740
741fn validate_secret_field_conflict<T: PartialEq>(
742  task_name: Option<&str>,
743  field_name: &str,
744  legacy: Option<&T>,
745  secrets_block: Option<&T>,
746  report: &mut ValidationReport,
747) {
748  if legacy.is_some() && secrets_block.is_some() && legacy != secrets_block {
749    report.push_error(
750      task_name,
751      Some("secrets"),
752      format!(
753        "Legacy secret field '{}' conflicts with `secrets.{}`",
754        field_name, field_name
755      ),
756    );
757  }
758}
759
760fn pgp_default_keys_location_applies() -> bool {
761  true
762}
763
764fn command_uses_task_outputs(command: &CommandRunner) -> bool {
765  match command {
766    CommandRunner::LocalRun(local_run) => {
767      local_run.save_output_as.is_some()
768        || contains_output_reference(&local_run.command)
769        || local_run
770          .test
771          .as_ref()
772          .is_some_and(|test| contains_output_reference(test))
773    },
774    CommandRunner::CommandRun(command) => contains_output_reference(command),
775    CommandRunner::ContainerRun(_) | CommandRunner::ContainerBuild(_) | CommandRunner::TaskRun(_) => false,
776  }
777}
778
779fn has_default_containerfile(context_path: &Path) -> bool {
780  context_path.join("Dockerfile").is_file() || context_path.join("Containerfile").is_file()
781}
782
783#[cfg(test)]
784mod tests {
785  use super::*;
786
787  fn has_error(report: &ValidationReport, field: &str, message: &str) -> bool {
788    report.issues.iter().any(|issue| {
789      issue.severity == ValidationSeverity::Error
790        && issue.field.as_deref() == Some(field)
791        && issue.message == message
792    })
793  }
794
795  #[test]
796  fn test_validate_retrigger_requires_non_interactive_local_run() -> anyhow::Result<()> {
797    let yaml = r#"
798      tasks:
799        dev:
800          commands:
801            - command: "go run ."
802              interactive: true
803              retrigger: true
804    "#;
805
806    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
807    let report = task_root.validate();
808
809    assert!(report.issues.iter().any(|issue| {
810      issue.field.as_deref() == Some("retrigger")
811        && issue.message == "retrigger is only supported for non-interactive local commands"
812    }));
813
814    Ok(())
815  }
816
817  #[test]
818  fn test_validate_rejects_gpg_backend_without_gpg_key_id() -> anyhow::Result<()> {
819    let yaml = r#"
820      secrets:
821        backend: gpg
822      tasks:
823        demo:
824          commands:
825            - command: echo ready
826    "#;
827
828    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
829    let report = task_root.validate();
830
831    assert!(has_error(
832      &report,
833      "secrets.gpg_key_id",
834      "GPG backend requires gpg_key_id"
835    ));
836    Ok(())
837  }
838
839  #[test]
840  fn test_validate_rejects_pgp_backend_without_key_name() -> anyhow::Result<()> {
841    let yaml = r#"
842      secrets:
843        backend: built_in_pgp
844        keys_location: ./.mk/keys
845      tasks:
846        demo:
847          commands:
848            - command: echo ready
849    "#;
850
851    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
852    let report = task_root.validate();
853
854    assert!(has_error(
855      &report,
856      "secrets.key_name",
857      "PGP backend requires key_name"
858    ));
859    Ok(())
860  }
861
862  #[test]
863  fn test_validate_allows_pgp_backend_without_keys_location_when_default_applies() -> anyhow::Result<()> {
864    let yaml = r#"
865      secrets:
866        backend: built_in_pgp
867        key_name: team
868      tasks:
869        demo:
870          commands:
871            - command: echo ready
872    "#;
873
874    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
875    let report = task_root.validate();
876
877    assert!(!report
878      .issues
879      .iter()
880      .any(|issue| issue.field.as_deref() == Some("secrets.keys_location")));
881    Ok(())
882  }
883
884  #[test]
885  fn test_validate_rejects_gpg_backend_with_pgp_only_settings() -> anyhow::Result<()> {
886    let yaml = r#"
887      tasks:
888        demo:
889          secrets:
890            backend: gpg
891            gpg_key_id: TEAMKEY
892            key_name: team
893            keys_location: ./.mk/keys
894          commands:
895            - command: echo ready
896    "#;
897
898    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
899    let report = task_root.validate();
900
901    assert!(has_error(
902      &report,
903      "secrets",
904      "GPG backend cannot be combined with PGP-only settings: key_name, keys_location"
905    ));
906    Ok(())
907  }
908
909  #[test]
910  fn test_validate_rejects_conflicting_legacy_and_secrets_block_values() -> anyhow::Result<()> {
911    let yaml = r#"
912      vault_location: ./.mk/legacy-vault
913      secrets:
914        vault_location: ./.mk/new-vault
915      tasks:
916        demo:
917          commands:
918            - command: echo ready
919    "#;
920
921    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
922    let report = task_root.validate();
923
924    assert!(has_error(
925      &report,
926      "secrets",
927      "Legacy secret field 'vault_location' conflicts with `secrets.vault_location`"
928    ));
929    Ok(())
930  }
931
932  fn has_warning(report: &ValidationReport, field: &str, message: &str) -> bool {
933    report.issues.iter().any(|issue| {
934      issue.severity == ValidationSeverity::Warning
935        && issue.field.as_deref() == Some(field)
936        && issue.message == message
937    })
938  }
939
940  #[test]
941  fn test_validate_warns_on_empty_label_key() -> anyhow::Result<()> {
942    let yaml = r#"
943      tasks:
944        demo:
945          commands:
946            - command: echo ready
947          labels:
948            "": present
949    "#;
950
951    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
952    let report = task_root.validate();
953
954    assert!(has_warning(&report, "labels", "Label key must not be empty"));
955    Ok(())
956  }
957
958  #[test]
959  fn test_validate_warns_on_empty_label_value() -> anyhow::Result<()> {
960    let yaml = r#"
961      tasks:
962        demo:
963          commands:
964            - command: echo ready
965          labels:
966            area: ""
967    "#;
968
969    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
970    let report = task_root.validate();
971
972    assert!(has_warning(&report, "labels", "Label 'area' has an empty value"));
973    Ok(())
974  }
975
976  #[test]
977  fn test_validate_warns_on_reserved_mk_prefix() -> anyhow::Result<()> {
978    let yaml = r#"
979      tasks:
980        demo:
981          commands:
982            - command: echo ready
983          labels:
984            mk.internal: reserved
985    "#;
986
987    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
988    let report = task_root.validate();
989
990    assert!(has_warning(
991      &report,
992      "labels",
993      "Label key 'mk.internal' uses reserved 'mk.' prefix"
994    ));
995    Ok(())
996  }
997
998  #[test]
999  fn test_validate_allows_valid_labels() -> anyhow::Result<()> {
1000    let yaml = r#"
1001      tasks:
1002        demo:
1003          commands:
1004            - command: echo ready
1005          labels:
1006            area: ci
1007            kind: test
1008    "#;
1009
1010    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
1011    let report = task_root.validate();
1012
1013    assert!(!report
1014      .issues
1015      .iter()
1016      .any(|issue| issue.field.as_deref() == Some("labels")));
1017    Ok(())
1018  }
1019}