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};
17
18#[derive(Debug, Clone, Serialize)]
19pub struct ValidationIssue {
20  pub severity: ValidationSeverity,
21  pub task: Option<String>,
22  pub field: Option<String>,
23  pub message: String,
24}
25
26#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
27#[serde(rename_all = "snake_case")]
28pub enum ValidationSeverity {
29  Error,
30  Warning,
31}
32
33#[derive(Debug, Default, Serialize)]
34pub struct ValidationReport {
35  pub issues: Vec<ValidationIssue>,
36}
37
38impl ValidationReport {
39  pub fn push_error(&mut self, task: Option<&str>, field: Option<&str>, message: impl Into<String>) {
40    self.issues.push(ValidationIssue {
41      severity: ValidationSeverity::Error,
42      task: task.map(str::to_string),
43      field: field.map(str::to_string),
44      message: message.into(),
45    });
46  }
47
48  pub fn push_warning(&mut self, task: Option<&str>, field: Option<&str>, message: impl Into<String>) {
49    self.issues.push(ValidationIssue {
50      severity: ValidationSeverity::Warning,
51      task: task.map(str::to_string),
52      field: field.map(str::to_string),
53      message: message.into(),
54    });
55  }
56
57  pub fn has_errors(&self) -> bool {
58    self
59      .issues
60      .iter()
61      .any(|issue| issue.severity == ValidationSeverity::Error)
62  }
63}
64
65impl TaskRoot {
66  pub fn validate(&self) -> ValidationReport {
67    let mut report = ValidationReport::default();
68
69    self.validate_root(&mut report);
70
71    for (task_name, task) in &self.tasks {
72      self.validate_task(task_name, task, &mut report);
73    }
74
75    self.validate_cycles(&mut report);
76
77    report
78  }
79
80  fn validate_root(&self, report: &mut ValidationReport) {
81    if let Some(use_npm) = &self.use_npm {
82      self.validate_use_npm(use_npm, report);
83    }
84
85    if let Some(use_cargo) = &self.use_cargo {
86      self.validate_use_cargo(use_cargo, report);
87    }
88
89    if let Some(includes) = &self.include {
90      self.validate_includes(includes, report);
91    }
92
93    self.validate_runtime(
94      None,
95      Some("container_runtime"),
96      self.container_runtime.as_ref(),
97      report,
98    );
99  }
100
101  fn validate_task(&self, task_name: &str, task: &Task, report: &mut ValidationReport) {
102    match task {
103      Task::String(command) => {
104        if command.trim().is_empty() {
105          report.push_error(Some(task_name), Some("commands"), "Command must not be empty");
106        }
107      },
108      Task::Task(task) => {
109        if task.commands.is_empty() {
110          report.push_error(
111            Some(task_name),
112            Some("commands"),
113            "Task must define at least one command",
114          );
115        }
116
117        for dependency in &task.depends_on {
118          let dependency_name = dependency.resolve_name();
119          if dependency_name.is_empty() {
120            report.push_error(
121              Some(task_name),
122              Some("depends_on"),
123              "Dependency name must not be empty",
124            );
125          } else if dependency_name == task_name {
126            report.push_error(
127              Some(task_name),
128              Some("depends_on"),
129              "Task cannot depend on itself",
130            );
131          } else if !self.tasks.contains_key(dependency_name) {
132            report.push_error(
133              Some(task_name),
134              Some("depends_on"),
135              format!("Missing dependency: {}", dependency_name),
136            );
137          }
138        }
139
140        if task.is_parallel() {
141          for command in &task.commands {
142            match command {
143              CommandRunner::LocalRun(local_run) if local_run.is_parallel_safe() => {},
144              CommandRunner::LocalRun(local_run) if local_run.interactive_enabled() => report.push_error(
145                Some(task_name),
146                Some("parallel"),
147                "Parallel execution only supports non-interactive local commands",
148              ),
149              CommandRunner::LocalRun(_) => report.push_error(
150                Some(task_name),
151                Some("parallel"),
152                "Parallel execution does not support local commands with `retrigger: true`",
153              ),
154              _ => report.push_error(
155                Some(task_name),
156                Some("parallel"),
157                "Parallel execution only supports non-interactive local commands",
158              ),
159            }
160          }
161
162          if task
163            .environment
164            .values()
165            .any(|value| contains_output_reference(value))
166            || task.commands.iter().any(command_uses_task_outputs)
167          {
168            report.push_error(
169              Some(task_name),
170              Some("execution.mode"),
171              "Parallel execution does not support saved command outputs",
172            );
173          }
174        }
175
176        if let Some(execution) = &task.execution {
177          if let Some(max_parallel) = execution.max_parallel {
178            if max_parallel == 0 {
179              report.push_error(
180                Some(task_name),
181                Some("execution.max_parallel"),
182                "execution.max_parallel must be greater than zero",
183              );
184            }
185          }
186        }
187
188        if task.cache.as_ref().map(|cache| cache.enabled).unwrap_or(false) && task.outputs.is_empty() {
189          report.push_warning(
190            Some(task_name),
191            Some("outputs"),
192            "Task cache is enabled without declared outputs; cache hits will not be possible",
193          );
194        }
195
196        for command in &task.commands {
197          self.validate_command(task_name, command, report);
198        }
199
200        self.validate_command_outputs(task_name, task, report);
201      },
202    }
203  }
204
205  fn validate_command(&self, task_name: &str, command: &CommandRunner, report: &mut ValidationReport) {
206    match command {
207      CommandRunner::CommandRun(command) => {
208        if command.trim().is_empty() {
209          report.push_error(Some(task_name), Some("command"), "Command must not be empty");
210        }
211        if contains_output_reference(command) {
212          report.push_error(
213            Some(task_name),
214            Some("command"),
215            "Saved command outputs are only supported by local `command:` entries",
216          );
217        }
218      },
219      CommandRunner::LocalRun(local_run) => {
220        if local_run.command.trim().is_empty() {
221          report.push_error(Some(task_name), Some("command"), "Command must not be empty");
222        }
223        if let Some(save_output_as) = &local_run.save_output_as {
224          if save_output_as.trim().is_empty() {
225            report.push_error(
226              Some(task_name),
227              Some("save_output_as"),
228              "save_output_as must not be empty",
229            );
230          }
231        }
232        if local_run.interactive_enabled() && local_run.retrigger_enabled() {
233          report.push_error(
234            Some(task_name),
235            Some("retrigger"),
236            "retrigger is only supported for non-interactive local commands",
237          );
238        }
239      },
240      CommandRunner::ContainerRun(container_run) => {
241        if container_run.image.trim().is_empty() {
242          report.push_error(
243            Some(task_name),
244            Some("image"),
245            "Container image must not be empty",
246          );
247        }
248        if container_run.container_command.is_empty() {
249          report.push_error(
250            Some(task_name),
251            Some("container_command"),
252            "Container command must not be empty",
253          );
254        }
255        self.validate_runtime(
256          Some(task_name),
257          Some("runtime"),
258          container_run.runtime.as_ref(),
259          report,
260        );
261      },
262      CommandRunner::ContainerBuild(container_build) => {
263        if container_build.container_build.image_name.trim().is_empty() {
264          report.push_error(
265            Some(task_name),
266            Some("container_build.image_name"),
267            "Container image_name must not be empty",
268          );
269        }
270        if container_build.container_build.context.trim().is_empty() {
271          report.push_error(
272            Some(task_name),
273            Some("container_build.context"),
274            "Container build context must not be empty",
275          );
276        }
277        if container_build.container_build.containerfile.is_none()
278          && !has_default_containerfile(&self.resolve_from_config(&container_build.container_build.context))
279        {
280          report.push_warning(
281            Some(task_name),
282            Some("container_build.containerfile"),
283            "No explicit containerfile set and no Dockerfile or Containerfile was found in the build context",
284          );
285        }
286        self.validate_runtime(
287          Some(task_name),
288          Some("container_build.runtime"),
289          container_build.container_build.runtime.as_ref(),
290          report,
291        );
292      },
293      CommandRunner::TaskRun(task_run) => {
294        if task_run.task.trim().is_empty() {
295          report.push_error(Some(task_name), Some("task"), "Task name must not be empty");
296        } else if !self.tasks.contains_key(&task_run.task) {
297          report.push_error(
298            Some(task_name),
299            Some("task"),
300            format!("Referenced task does not exist: {}", task_run.task),
301          );
302        }
303      },
304    }
305  }
306
307  fn validate_command_outputs(&self, task_name: &str, task: &super::TaskArgs, report: &mut ValidationReport) {
308    let declared_outputs = task
309      .commands
310      .iter()
311      .filter_map(|command| match command {
312        CommandRunner::LocalRun(local_run) => local_run.save_output_as.as_ref(),
313        _ => None,
314      })
315      .map(|name| name.trim().to_string())
316      .filter(|name| !name.is_empty())
317      .collect::<HashSet<_>>();
318
319    for value in task.environment.values() {
320      for output_name in extract_output_references(value) {
321        if !declared_outputs.contains(&output_name) {
322          report.push_error(
323            Some(task_name),
324            Some("environment"),
325            format!("Unknown task output reference: {}", output_name),
326          );
327        }
328      }
329    }
330
331    let mut produced_outputs = HashSet::new();
332    for command in &task.commands {
333      match command {
334        CommandRunner::LocalRun(local_run) => {
335          for output_name in extract_output_references(&local_run.command) {
336            if !produced_outputs.contains(&output_name) {
337              report.push_error(
338                Some(task_name),
339                Some("command"),
340                format!(
341                  "Output reference must come from an earlier command: {}",
342                  output_name
343                ),
344              );
345            }
346          }
347
348          if let Some(test) = &local_run.test {
349            for output_name in extract_output_references(test) {
350              if !produced_outputs.contains(&output_name) {
351                report.push_error(
352                  Some(task_name),
353                  Some("test"),
354                  format!(
355                    "Output reference must come from an earlier command: {}",
356                    output_name
357                  ),
358                );
359              }
360            }
361          }
362
363          if let Some(save_output_as) = &local_run.save_output_as {
364            let save_output_as = save_output_as.trim().to_string();
365            if !save_output_as.is_empty() && !produced_outputs.insert(save_output_as.clone()) {
366              report.push_error(
367                Some(task_name),
368                Some("save_output_as"),
369                format!("Duplicate saved output name: {}", save_output_as),
370              );
371            }
372          }
373        },
374        CommandRunner::ContainerRun(_) | CommandRunner::ContainerBuild(_) | CommandRunner::TaskRun(_) => {},
375        CommandRunner::CommandRun(command) => {
376          for output_name in extract_output_references(command) {
377            if !produced_outputs.contains(&output_name) {
378              report.push_error(
379                Some(task_name),
380                Some("command"),
381                format!(
382                  "Output reference must come from an earlier command: {}",
383                  output_name
384                ),
385              );
386            }
387          }
388        },
389      }
390    }
391  }
392
393  fn validate_use_npm(&self, use_npm: &UseNpm, report: &mut ValidationReport) {
394    let work_dir = match use_npm {
395      UseNpm::Bool(true) => None,
396      UseNpm::UseNpm(args) => args.work_dir.as_deref(),
397      _ => return,
398    };
399
400    let package_json = work_dir
401      .map(|path| self.resolve_from_config(path).join("package.json"))
402      .unwrap_or_else(|| self.resolve_from_config("package.json"));
403
404    if !package_json.is_file() {
405      report.push_error(
406        None,
407        Some("use_npm"),
408        format!("package.json does not exist: {}", package_json.to_string_lossy()),
409      );
410    }
411  }
412
413  fn validate_use_cargo(&self, use_cargo: &UseCargo, report: &mut ValidationReport) {
414    let work_dir = match use_cargo {
415      UseCargo::Bool(true) => None,
416      UseCargo::UseCargo(args) => args.work_dir.as_deref(),
417      _ => return,
418    };
419
420    if let Some(work_dir) = work_dir {
421      let path = self.resolve_from_config(work_dir);
422      if !path.is_dir() {
423        report.push_error(
424          None,
425          Some("use_cargo.work_dir"),
426          format!("Cargo work_dir does not exist: {}", path.to_string_lossy()),
427        );
428      }
429    }
430  }
431
432  fn validate_runtime(
433    &self,
434    task: Option<&str>,
435    field: Option<&str>,
436    runtime: Option<&ContainerRuntime>,
437    report: &mut ValidationReport,
438  ) {
439    if let Some(runtime) = runtime {
440      if ContainerRuntime::resolve(Some(runtime)).is_err() {
441        report.push_error(
442          task,
443          field,
444          format!("Requested container runtime is unavailable: {}", runtime.name()),
445        );
446      }
447    }
448  }
449
450  fn validate_includes(&self, includes: &[Include], report: &mut ValidationReport) {
451    for include in includes {
452      let name = include.name();
453
454      if name.trim().is_empty() {
455        report.push_error(None, Some("include"), "Include name must not be empty");
456        continue;
457      }
458
459      let overwrite_suffix = if include.overwrite() {
460        " (overwrite=true)"
461      } else {
462        ""
463      };
464      report.push_error(
465        None,
466        Some("include"),
467        format!(
468          "`include` is no longer supported. Replace it with `extends`: {}{}",
469          name, overwrite_suffix
470        ),
471      );
472    }
473  }
474
475  fn validate_cycles(&self, report: &mut ValidationReport) {
476    let mut visited = HashSet::new();
477    let mut visiting = Vec::new();
478
479    for task_name in self.tasks.keys() {
480      self.detect_cycle(task_name, &mut visiting, &mut visited, report);
481    }
482  }
483
484  fn detect_cycle(
485    &self,
486    task_name: &str,
487    visiting: &mut Vec<String>,
488    visited: &mut HashSet<String>,
489    report: &mut ValidationReport,
490  ) {
491    if visited.contains(task_name) {
492      return;
493    }
494
495    if let Some(index) = visiting.iter().position(|name| name == task_name) {
496      let mut cycle = visiting[index..].to_vec();
497      cycle.push(task_name.to_string());
498      report.push_error(
499        Some(task_name),
500        Some("depends_on"),
501        format!("Circular dependency detected: {}", cycle.join(" -> ")),
502      );
503      return;
504    }
505
506    visiting.push(task_name.to_string());
507
508    if let Some(Task::Task(task)) = self.tasks.get(task_name) {
509      for dependency in &task.depends_on {
510        self.detect_cycle(dependency.resolve_name(), visiting, visited, report);
511      }
512
513      for command in &task.commands {
514        if let CommandRunner::TaskRun(task_run) = command {
515          self.detect_cycle(&task_run.task, visiting, visited, report);
516        }
517      }
518    }
519
520    visiting.pop();
521    visited.insert(task_name.to_string());
522  }
523}
524
525fn command_uses_task_outputs(command: &CommandRunner) -> bool {
526  match command {
527    CommandRunner::LocalRun(local_run) => {
528      local_run.save_output_as.is_some()
529        || contains_output_reference(&local_run.command)
530        || local_run
531          .test
532          .as_ref()
533          .is_some_and(|test| contains_output_reference(test))
534    },
535    CommandRunner::CommandRun(command) => contains_output_reference(command),
536    CommandRunner::ContainerRun(_) | CommandRunner::ContainerBuild(_) | CommandRunner::TaskRun(_) => false,
537  }
538}
539
540fn has_default_containerfile(context_path: &Path) -> bool {
541  context_path.join("Dockerfile").is_file() || context_path.join("Containerfile").is_file()
542}
543
544#[cfg(test)]
545mod tests {
546  use super::*;
547
548  #[test]
549  fn test_validate_retrigger_requires_non_interactive_local_run() -> anyhow::Result<()> {
550    let yaml = r#"
551      tasks:
552        dev:
553          commands:
554            - command: "go run ."
555              interactive: true
556              retrigger: true
557    "#;
558
559    let task_root = serde_yaml::from_str::<TaskRoot>(yaml)?;
560    let report = task_root.validate();
561
562    assert!(report.issues.iter().any(|issue| {
563      issue.field.as_deref() == Some("retrigger")
564        && issue.message == "retrigger is only supported for non-interactive local commands"
565    }));
566
567    Ok(())
568  }
569}