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(_) => report.push_error(
145                Some(task_name),
146                Some("parallel"),
147                "Parallel execution only supports non-interactive local commands",
148              ),
149              _ => report.push_error(
150                Some(task_name),
151                Some("parallel"),
152                "Parallel execution only supports non-interactive local commands",
153              ),
154            }
155          }
156
157          if task
158            .environment
159            .values()
160            .any(|value| contains_output_reference(value))
161            || task.commands.iter().any(command_uses_task_outputs)
162          {
163            report.push_error(
164              Some(task_name),
165              Some("execution.mode"),
166              "Parallel execution does not support saved command outputs",
167            );
168          }
169        }
170
171        if let Some(execution) = &task.execution {
172          if let Some(max_parallel) = execution.max_parallel {
173            if max_parallel == 0 {
174              report.push_error(
175                Some(task_name),
176                Some("execution.max_parallel"),
177                "execution.max_parallel must be greater than zero",
178              );
179            }
180          }
181        }
182
183        if task.cache.as_ref().map(|cache| cache.enabled).unwrap_or(false) && task.outputs.is_empty() {
184          report.push_warning(
185            Some(task_name),
186            Some("outputs"),
187            "Task cache is enabled without declared outputs; cache hits will not be possible",
188          );
189        }
190
191        for command in &task.commands {
192          self.validate_command(task_name, command, report);
193        }
194
195        self.validate_command_outputs(task_name, task, report);
196      },
197    }
198  }
199
200  fn validate_command(&self, task_name: &str, command: &CommandRunner, report: &mut ValidationReport) {
201    match command {
202      CommandRunner::CommandRun(command) => {
203        if command.trim().is_empty() {
204          report.push_error(Some(task_name), Some("command"), "Command must not be empty");
205        }
206        if contains_output_reference(command) {
207          report.push_error(
208            Some(task_name),
209            Some("command"),
210            "Saved command outputs are only supported by local `command:` entries",
211          );
212        }
213      },
214      CommandRunner::LocalRun(local_run) => {
215        if local_run.command.trim().is_empty() {
216          report.push_error(Some(task_name), Some("command"), "Command must not be empty");
217        }
218        if let Some(save_output_as) = &local_run.save_output_as {
219          if save_output_as.trim().is_empty() {
220            report.push_error(
221              Some(task_name),
222              Some("save_output_as"),
223              "save_output_as must not be empty",
224            );
225          }
226        }
227      },
228      CommandRunner::ContainerRun(container_run) => {
229        if container_run.image.trim().is_empty() {
230          report.push_error(
231            Some(task_name),
232            Some("image"),
233            "Container image must not be empty",
234          );
235        }
236        if container_run.container_command.is_empty() {
237          report.push_error(
238            Some(task_name),
239            Some("container_command"),
240            "Container command must not be empty",
241          );
242        }
243        self.validate_runtime(
244          Some(task_name),
245          Some("runtime"),
246          container_run.runtime.as_ref(),
247          report,
248        );
249      },
250      CommandRunner::ContainerBuild(container_build) => {
251        if container_build.container_build.image_name.trim().is_empty() {
252          report.push_error(
253            Some(task_name),
254            Some("container_build.image_name"),
255            "Container image_name must not be empty",
256          );
257        }
258        if container_build.container_build.context.trim().is_empty() {
259          report.push_error(
260            Some(task_name),
261            Some("container_build.context"),
262            "Container build context must not be empty",
263          );
264        }
265        if container_build.container_build.containerfile.is_none()
266          && !has_default_containerfile(&self.resolve_from_config(&container_build.container_build.context))
267        {
268          report.push_warning(
269            Some(task_name),
270            Some("container_build.containerfile"),
271            "No explicit containerfile set and no Dockerfile or Containerfile was found in the build context",
272          );
273        }
274        self.validate_runtime(
275          Some(task_name),
276          Some("container_build.runtime"),
277          container_build.container_build.runtime.as_ref(),
278          report,
279        );
280      },
281      CommandRunner::TaskRun(task_run) => {
282        if task_run.task.trim().is_empty() {
283          report.push_error(Some(task_name), Some("task"), "Task name must not be empty");
284        } else if !self.tasks.contains_key(&task_run.task) {
285          report.push_error(
286            Some(task_name),
287            Some("task"),
288            format!("Referenced task does not exist: {}", task_run.task),
289          );
290        }
291      },
292    }
293  }
294
295  fn validate_command_outputs(&self, task_name: &str, task: &super::TaskArgs, report: &mut ValidationReport) {
296    let declared_outputs = task
297      .commands
298      .iter()
299      .filter_map(|command| match command {
300        CommandRunner::LocalRun(local_run) => local_run.save_output_as.as_ref(),
301        _ => None,
302      })
303      .map(|name| name.trim().to_string())
304      .filter(|name| !name.is_empty())
305      .collect::<HashSet<_>>();
306
307    for value in task.environment.values() {
308      for output_name in extract_output_references(value) {
309        if !declared_outputs.contains(&output_name) {
310          report.push_error(
311            Some(task_name),
312            Some("environment"),
313            format!("Unknown task output reference: {}", output_name),
314          );
315        }
316      }
317    }
318
319    let mut produced_outputs = HashSet::new();
320    for command in &task.commands {
321      match command {
322        CommandRunner::LocalRun(local_run) => {
323          for output_name in extract_output_references(&local_run.command) {
324            if !produced_outputs.contains(&output_name) {
325              report.push_error(
326                Some(task_name),
327                Some("command"),
328                format!(
329                  "Output reference must come from an earlier command: {}",
330                  output_name
331                ),
332              );
333            }
334          }
335
336          if let Some(test) = &local_run.test {
337            for output_name in extract_output_references(test) {
338              if !produced_outputs.contains(&output_name) {
339                report.push_error(
340                  Some(task_name),
341                  Some("test"),
342                  format!(
343                    "Output reference must come from an earlier command: {}",
344                    output_name
345                  ),
346                );
347              }
348            }
349          }
350
351          if let Some(save_output_as) = &local_run.save_output_as {
352            let save_output_as = save_output_as.trim().to_string();
353            if !save_output_as.is_empty() && !produced_outputs.insert(save_output_as.clone()) {
354              report.push_error(
355                Some(task_name),
356                Some("save_output_as"),
357                format!("Duplicate saved output name: {}", save_output_as),
358              );
359            }
360          }
361        },
362        CommandRunner::ContainerRun(_) | CommandRunner::ContainerBuild(_) | CommandRunner::TaskRun(_) => {},
363        CommandRunner::CommandRun(command) => {
364          for output_name in extract_output_references(command) {
365            if !produced_outputs.contains(&output_name) {
366              report.push_error(
367                Some(task_name),
368                Some("command"),
369                format!(
370                  "Output reference must come from an earlier command: {}",
371                  output_name
372                ),
373              );
374            }
375          }
376        },
377      }
378    }
379  }
380
381  fn validate_use_npm(&self, use_npm: &UseNpm, report: &mut ValidationReport) {
382    let work_dir = match use_npm {
383      UseNpm::Bool(true) => None,
384      UseNpm::UseNpm(args) => args.work_dir.as_deref(),
385      _ => return,
386    };
387
388    let package_json = work_dir
389      .map(|path| self.resolve_from_config(path).join("package.json"))
390      .unwrap_or_else(|| self.resolve_from_config("package.json"));
391
392    if !package_json.is_file() {
393      report.push_error(
394        None,
395        Some("use_npm"),
396        format!("package.json does not exist: {}", package_json.to_string_lossy()),
397      );
398    }
399  }
400
401  fn validate_use_cargo(&self, use_cargo: &UseCargo, report: &mut ValidationReport) {
402    let work_dir = match use_cargo {
403      UseCargo::Bool(true) => None,
404      UseCargo::UseCargo(args) => args.work_dir.as_deref(),
405      _ => return,
406    };
407
408    if let Some(work_dir) = work_dir {
409      let path = self.resolve_from_config(work_dir);
410      if !path.is_dir() {
411        report.push_error(
412          None,
413          Some("use_cargo.work_dir"),
414          format!("Cargo work_dir does not exist: {}", path.to_string_lossy()),
415        );
416      }
417    }
418  }
419
420  fn validate_runtime(
421    &self,
422    task: Option<&str>,
423    field: Option<&str>,
424    runtime: Option<&ContainerRuntime>,
425    report: &mut ValidationReport,
426  ) {
427    if let Some(runtime) = runtime {
428      if ContainerRuntime::resolve(Some(runtime)).is_err() {
429        report.push_error(
430          task,
431          field,
432          format!("Requested container runtime is unavailable: {}", runtime.name()),
433        );
434      }
435    }
436  }
437
438  fn validate_includes(&self, includes: &[Include], report: &mut ValidationReport) {
439    for include in includes {
440      let name = include.name();
441
442      if name.trim().is_empty() {
443        report.push_error(None, Some("include"), "Include name must not be empty");
444        continue;
445      }
446
447      let overwrite_suffix = if include.overwrite() {
448        " (overwrite=true)"
449      } else {
450        ""
451      };
452      report.push_error(
453        None,
454        Some("include"),
455        format!(
456          "`include` is no longer supported. Replace it with `extends`: {}{}",
457          name, overwrite_suffix
458        ),
459      );
460    }
461  }
462
463  fn validate_cycles(&self, report: &mut ValidationReport) {
464    let mut visited = HashSet::new();
465    let mut visiting = Vec::new();
466
467    for task_name in self.tasks.keys() {
468      self.detect_cycle(task_name, &mut visiting, &mut visited, report);
469    }
470  }
471
472  fn detect_cycle(
473    &self,
474    task_name: &str,
475    visiting: &mut Vec<String>,
476    visited: &mut HashSet<String>,
477    report: &mut ValidationReport,
478  ) {
479    if visited.contains(task_name) {
480      return;
481    }
482
483    if let Some(index) = visiting.iter().position(|name| name == task_name) {
484      let mut cycle = visiting[index..].to_vec();
485      cycle.push(task_name.to_string());
486      report.push_error(
487        Some(task_name),
488        Some("depends_on"),
489        format!("Circular dependency detected: {}", cycle.join(" -> ")),
490      );
491      return;
492    }
493
494    visiting.push(task_name.to_string());
495
496    if let Some(Task::Task(task)) = self.tasks.get(task_name) {
497      for dependency in &task.depends_on {
498        self.detect_cycle(dependency.resolve_name(), visiting, visited, report);
499      }
500
501      for command in &task.commands {
502        if let CommandRunner::TaskRun(task_run) = command {
503          self.detect_cycle(&task_run.task, visiting, visited, report);
504        }
505      }
506    }
507
508    visiting.pop();
509    visited.insert(task_name.to_string());
510  }
511}
512
513fn command_uses_task_outputs(command: &CommandRunner) -> bool {
514  match command {
515    CommandRunner::LocalRun(local_run) => {
516      local_run.save_output_as.is_some()
517        || contains_output_reference(&local_run.command)
518        || local_run
519          .test
520          .as_ref()
521          .is_some_and(|test| contains_output_reference(test))
522    },
523    CommandRunner::CommandRun(command) => contains_output_reference(command),
524    CommandRunner::ContainerRun(_) | CommandRunner::ContainerBuild(_) | CommandRunner::TaskRun(_) => false,
525  }
526}
527
528fn has_default_containerfile(context_path: &Path) -> bool {
529  context_path.join("Dockerfile").is_file() || context_path.join("Containerfile").is_file()
530}