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}