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}