1use super::schema::{CachePolicy, IntermediateRepresentation, Task};
6use std::collections::{HashMap, HashSet};
7use thiserror::Error;
8
9#[derive(Debug, Error, PartialEq, Eq)]
11pub enum ValidationError {
12 #[error("Task graph contains cycle: {0}")]
13 CyclicDependency(String),
14
15 #[error("Task '{task}' depends on non-existent task '{dependency}'")]
16 MissingDependency { task: String, dependency: String },
17
18 #[error("Task '{task}' references non-existent runtime '{runtime}'")]
19 MissingRuntime { task: String, runtime: String },
20
21 #[error("Deployment task '{deployment}' has non-deployment dependent '{dependent}'")]
22 InvalidDeploymentDependency {
23 deployment: String,
24 dependent: String,
25 },
26
27 #[error("Task '{task}' has shell=false with string command (must be array)")]
28 InvalidShellCommand { task: String },
29
30 #[error("Task '{task}' has empty command")]
31 EmptyCommand { task: String },
32
33 #[error("Deployment task '{task}' has cache_policy={policy:?} (must be disabled)")]
34 InvalidDeploymentCachePolicy { task: String, policy: CachePolicy },
35
36 #[error("Task '{task}' declares input '{input}' that does not exist at compile time")]
37 MissingInput { task: String, input: String },
38}
39
40pub struct IrValidator<'a> {
42 ir: &'a IntermediateRepresentation,
43}
44
45impl<'a> IrValidator<'a> {
46 #[must_use]
48 pub const fn new(ir: &'a IntermediateRepresentation) -> Self {
49 Self { ir }
50 }
51
52 pub fn validate(&self) -> Result<(), Vec<ValidationError>> {
58 let mut errors = Vec::new();
59
60 let task_index: HashMap<&str, &Task> =
62 self.ir.tasks.iter().map(|t| (t.id.as_str(), t)).collect();
63
64 let runtime_ids: HashSet<&str> = self.ir.runtimes.iter().map(|r| r.id.as_str()).collect();
66
67 for task in &self.ir.tasks {
68 if let Err(e) = Self::validate_command(task) {
70 errors.push(e);
71 }
72
73 if let Some(runtime) = &task.runtime
75 && !runtime_ids.contains(runtime.as_str())
76 {
77 errors.push(ValidationError::MissingRuntime {
78 task: task.id.clone(),
79 runtime: runtime.clone(),
80 });
81 }
82
83 for dep in &task.depends_on {
85 if !task_index.contains_key(dep.as_str()) {
86 errors.push(ValidationError::MissingDependency {
87 task: task.id.clone(),
88 dependency: dep.clone(),
89 });
90 }
91 }
92
93 if task.deployment && task.cache_policy != CachePolicy::Disabled {
95 errors.push(ValidationError::InvalidDeploymentCachePolicy {
96 task: task.id.clone(),
97 policy: task.cache_policy,
98 });
99 }
100 }
101
102 if let Err(e) = self.validate_no_cycles(&task_index) {
104 errors.push(e);
105 }
106
107 if let Err(mut e) = self.validate_deployment_dependencies(&task_index) {
109 errors.append(&mut e);
110 }
111
112 if errors.is_empty() {
113 Ok(())
114 } else {
115 Err(errors)
116 }
117 }
118
119 fn validate_command(task: &Task) -> Result<(), ValidationError> {
124 if task.phase.is_some() && task.provider_hints.is_some() {
127 return Ok(());
128 }
129
130 if task.command.is_empty() {
131 return Err(ValidationError::EmptyCommand {
132 task: task.id.clone(),
133 });
134 }
135
136 Ok(())
140 }
141
142 fn validate_no_cycles(&self, task_index: &HashMap<&str, &Task>) -> Result<(), ValidationError> {
144 let mut visited = HashSet::new();
145 let mut rec_stack = HashSet::new();
146
147 for task in &self.ir.tasks {
148 if !visited.contains(task.id.as_str())
149 && let Some(cycle) =
150 Self::detect_cycle(task.id.as_str(), task_index, &mut visited, &mut rec_stack)
151 {
152 return Err(ValidationError::CyclicDependency(cycle));
153 }
154 }
155
156 Ok(())
157 }
158
159 fn detect_cycle(
161 task_id: &str,
162 task_index: &HashMap<&str, &Task>,
163 visited: &mut HashSet<String>,
164 rec_stack: &mut HashSet<String>,
165 ) -> Option<String> {
166 visited.insert(task_id.to_string());
167 rec_stack.insert(task_id.to_string());
168
169 if let Some(task) = task_index.get(task_id) {
170 for dep in &task.depends_on {
171 if !visited.contains(dep.as_str()) {
172 if let Some(cycle) = Self::detect_cycle(dep, task_index, visited, rec_stack) {
173 return Some(format!("{task_id} -> {cycle}"));
174 }
175 } else if rec_stack.contains(dep.as_str()) {
176 return Some(format!("{task_id} -> {dep}"));
178 }
179 }
180 }
181
182 rec_stack.remove(task_id);
183 None
184 }
185
186 fn validate_deployment_dependencies(
188 &self,
189 _task_index: &HashMap<&str, &Task>,
190 ) -> Result<(), Vec<ValidationError>> {
191 let mut errors = Vec::new();
192
193 let deployment_tasks: HashSet<&str> = self
195 .ir
196 .tasks
197 .iter()
198 .filter(|t| t.deployment)
199 .map(|t| t.id.as_str())
200 .collect();
201
202 for task in &self.ir.tasks {
204 if !task.deployment {
205 for dep in &task.depends_on {
206 if deployment_tasks.contains(dep.as_str()) {
207 errors.push(ValidationError::InvalidDeploymentDependency {
208 deployment: dep.clone(),
209 dependent: task.id.clone(),
210 });
211 }
212 }
213 }
214 }
215
216 if errors.is_empty() {
217 Ok(())
218 } else {
219 Err(errors)
220 }
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use crate::ir::{PurityMode, Runtime};
228 use std::collections::BTreeMap;
229
230 fn create_test_task(id: &str, depends_on: &[&str]) -> Task {
231 Task {
232 id: id.to_string(),
233 runtime: None,
234 command: vec!["echo".to_string()],
235 shell: false,
236 env: BTreeMap::new(),
237 secrets: BTreeMap::new(),
238 resources: None,
239 concurrency_group: None,
240 inputs: vec![],
241 outputs: vec![],
242 depends_on: depends_on.iter().map(|s| (*s).to_string()).collect(),
243 cache_policy: CachePolicy::Normal,
244 deployment: false,
245 manual_approval: false,
246 matrix: None,
247 artifact_downloads: vec![],
248 params: BTreeMap::new(),
249 phase: None,
250 label: None,
251 priority: None,
252 contributor: None,
253 condition: None,
254 provider_hints: None,
255 }
256 }
257
258 #[test]
259 fn test_valid_ir() {
260 let mut ir = IntermediateRepresentation::new("test");
261 ir.tasks.push(create_test_task("task1", &[]));
262 ir.tasks.push(create_test_task("task2", &["task1"]));
263
264 let validator = IrValidator::new(&ir);
265 assert!(validator.validate().is_ok());
266 }
267
268 #[test]
269 fn test_cyclic_dependency() {
270 let mut ir = IntermediateRepresentation::new("test");
271 ir.tasks.push(create_test_task("task1", &["task2"]));
272 ir.tasks.push(create_test_task("task2", &["task1"]));
273
274 let validator = IrValidator::new(&ir);
275 let result = validator.validate();
276 assert!(result.is_err());
277
278 let errors = result.unwrap_err();
279 assert!(
280 errors
281 .iter()
282 .any(|e| matches!(e, ValidationError::CyclicDependency(_)))
283 );
284 }
285
286 #[test]
287 fn test_missing_dependency() {
288 let mut ir = IntermediateRepresentation::new("test");
289 ir.tasks.push(create_test_task("task1", &["nonexistent"]));
290
291 let validator = IrValidator::new(&ir);
292 let result = validator.validate();
293 assert!(result.is_err());
294
295 let errors = result.unwrap_err();
296 assert_eq!(errors.len(), 1);
297 assert!(matches!(
298 errors[0],
299 ValidationError::MissingDependency { .. }
300 ));
301 }
302
303 #[test]
304 fn test_deployment_task_must_have_disabled_cache() {
305 let mut ir = IntermediateRepresentation::new("test");
306 let mut deploy_task = create_test_task("deploy", &[]);
307 deploy_task.deployment = true;
308 deploy_task.cache_policy = CachePolicy::Normal; ir.tasks.push(deploy_task);
310
311 let validator = IrValidator::new(&ir);
312 let result = validator.validate();
313 assert!(result.is_err());
314
315 let errors = result.unwrap_err();
316 assert!(
317 errors
318 .iter()
319 .any(|e| matches!(e, ValidationError::InvalidDeploymentCachePolicy { .. }))
320 );
321 }
322
323 #[test]
324 fn test_deployment_task_valid_with_disabled_cache() {
325 let mut ir = IntermediateRepresentation::new("test");
326 let mut deploy_task = create_test_task("deploy", &[]);
327 deploy_task.deployment = true;
328 deploy_task.cache_policy = CachePolicy::Disabled;
329 ir.tasks.push(deploy_task);
330
331 let validator = IrValidator::new(&ir);
332 assert!(validator.validate().is_ok());
333 }
334
335 #[test]
336 fn test_non_deployment_cannot_depend_on_deployment() {
337 let mut ir = IntermediateRepresentation::new("test");
338
339 let mut deploy_task = create_test_task("deploy", &[]);
340 deploy_task.deployment = true;
341 deploy_task.cache_policy = CachePolicy::Disabled;
342 ir.tasks.push(deploy_task);
343
344 let build_task = create_test_task("build", &["deploy"]);
345 ir.tasks.push(build_task);
346
347 let validator = IrValidator::new(&ir);
348 let result = validator.validate();
349 assert!(result.is_err());
350
351 let errors = result.unwrap_err();
352 assert!(
353 errors
354 .iter()
355 .any(|e| matches!(e, ValidationError::InvalidDeploymentDependency { .. }))
356 );
357 }
358
359 #[test]
360 fn test_deployment_can_depend_on_deployment() {
361 let mut ir = IntermediateRepresentation::new("test");
362
363 let mut deploy1 = create_test_task("deploy-staging", &[]);
364 deploy1.deployment = true;
365 deploy1.cache_policy = CachePolicy::Disabled;
366 ir.tasks.push(deploy1);
367
368 let mut deploy2 = create_test_task("deploy-prod", &["deploy-staging"]);
369 deploy2.deployment = true;
370 deploy2.cache_policy = CachePolicy::Disabled;
371 ir.tasks.push(deploy2);
372
373 let validator = IrValidator::new(&ir);
374 assert!(validator.validate().is_ok());
375 }
376
377 #[test]
378 fn test_empty_command() {
379 let mut ir = IntermediateRepresentation::new("test");
380 let mut task = create_test_task("task1", &[]);
381 task.command = vec![];
382 ir.tasks.push(task);
383
384 let validator = IrValidator::new(&ir);
385 let result = validator.validate();
386 assert!(result.is_err());
387
388 let errors = result.unwrap_err();
389 assert!(
390 errors
391 .iter()
392 .any(|e| matches!(e, ValidationError::EmptyCommand { .. }))
393 );
394 }
395
396 #[test]
397 fn test_missing_runtime() {
398 let mut ir = IntermediateRepresentation::new("test");
399 let mut task = create_test_task("task1", &[]);
400 task.runtime = Some("nonexistent".to_string());
401 ir.tasks.push(task);
402
403 let validator = IrValidator::new(&ir);
404 let result = validator.validate();
405 assert!(result.is_err());
406
407 let errors = result.unwrap_err();
408 assert!(
409 errors
410 .iter()
411 .any(|e| matches!(e, ValidationError::MissingRuntime { .. }))
412 );
413 }
414
415 #[test]
416 fn test_valid_runtime_reference() {
417 let mut ir = IntermediateRepresentation::new("test");
418 ir.runtimes.push(Runtime {
419 id: "nix".to_string(),
420 flake: "github:NixOS/nixpkgs/nixos-unstable".to_string(),
421 output: "devShells.x86_64-linux.default".to_string(),
422 system: "x86_64-linux".to_string(),
423 digest: "sha256:abc".to_string(),
424 purity: PurityMode::Strict,
425 });
426
427 let mut task = create_test_task("task1", &[]);
428 task.runtime = Some("nix".to_string());
429 ir.tasks.push(task);
430
431 let validator = IrValidator::new(&ir);
432 assert!(validator.validate().is_ok());
433 }
434
435 #[test]
436 fn test_phase_task_with_provider_hints_allowed_empty_command() {
437 let mut ir = IntermediateRepresentation::new("test");
440 let mut phase_task = create_test_task("install-nix", &[]);
441 phase_task.command = vec![]; phase_task.phase = Some(crate::ir::BuildStage::Bootstrap);
443 phase_task.provider_hints = Some(serde_json::json!({
444 "github_action": {
445 "uses": "DeterminateSystems/nix-installer-action@v16"
446 }
447 }));
448 ir.tasks.push(phase_task);
449
450 let validator = IrValidator::new(&ir);
451 assert!(
452 validator.validate().is_ok(),
453 "Phase tasks with provider_hints should be allowed to have empty commands"
454 );
455 }
456
457 #[test]
458 fn test_phase_task_without_provider_hints_requires_command() {
459 let mut ir = IntermediateRepresentation::new("test");
461 let mut phase_task = create_test_task("run-script", &[]);
462 phase_task.command = vec![]; phase_task.phase = Some(crate::ir::BuildStage::Setup);
464 ir.tasks.push(phase_task);
466
467 let validator = IrValidator::new(&ir);
468 let result = validator.validate();
469 assert!(result.is_err());
470
471 let errors = result.unwrap_err();
472 assert!(
473 errors
474 .iter()
475 .any(|e| matches!(e, ValidationError::EmptyCommand { .. }))
476 );
477 }
478}