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}