1use super::schema::{CachePolicy, IntermediateRepresentation, Task};
6use std::collections::{HashMap, HashSet};
7use thiserror::Error;
8
9#[derive(Debug, Error, PartialEq)]
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 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> {
121 if task.command.is_empty() {
122 return Err(ValidationError::EmptyCommand {
123 task: task.id.clone(),
124 });
125 }
126
127 Ok(())
131 }
132
133 fn validate_no_cycles(&self, task_index: &HashMap<&str, &Task>) -> Result<(), ValidationError> {
135 let mut visited = HashSet::new();
136 let mut rec_stack = HashSet::new();
137
138 for task in &self.ir.tasks {
139 if !visited.contains(task.id.as_str())
140 && let Some(cycle) =
141 Self::detect_cycle(task.id.as_str(), task_index, &mut visited, &mut rec_stack)
142 {
143 return Err(ValidationError::CyclicDependency(cycle));
144 }
145 }
146
147 Ok(())
148 }
149
150 fn detect_cycle(
152 task_id: &str,
153 task_index: &HashMap<&str, &Task>,
154 visited: &mut HashSet<String>,
155 rec_stack: &mut HashSet<String>,
156 ) -> Option<String> {
157 visited.insert(task_id.to_string());
158 rec_stack.insert(task_id.to_string());
159
160 if let Some(task) = task_index.get(task_id) {
161 for dep in &task.depends_on {
162 if !visited.contains(dep.as_str()) {
163 if let Some(cycle) = Self::detect_cycle(dep, task_index, visited, rec_stack) {
164 return Some(format!("{task_id} -> {cycle}"));
165 }
166 } else if rec_stack.contains(dep.as_str()) {
167 return Some(format!("{task_id} -> {dep}"));
169 }
170 }
171 }
172
173 rec_stack.remove(task_id);
174 None
175 }
176
177 fn validate_deployment_dependencies(
179 &self,
180 _task_index: &HashMap<&str, &Task>,
181 ) -> Result<(), Vec<ValidationError>> {
182 let mut errors = Vec::new();
183
184 let deployment_tasks: HashSet<&str> = self
186 .ir
187 .tasks
188 .iter()
189 .filter(|t| t.deployment)
190 .map(|t| t.id.as_str())
191 .collect();
192
193 for task in &self.ir.tasks {
195 if !task.deployment {
196 for dep in &task.depends_on {
197 if deployment_tasks.contains(dep.as_str()) {
198 errors.push(ValidationError::InvalidDeploymentDependency {
199 deployment: dep.clone(),
200 dependent: task.id.clone(),
201 });
202 }
203 }
204 }
205 }
206
207 if errors.is_empty() {
208 Ok(())
209 } else {
210 Err(errors)
211 }
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use crate::ir::{PurityMode, Runtime};
219
220 fn create_test_task(id: &str, depends_on: &[&str]) -> Task {
221 Task {
222 id: id.to_string(),
223 runtime: None,
224 command: vec!["echo".to_string()],
225 shell: false,
226 env: HashMap::new(),
227 secrets: HashMap::new(),
228 resources: None,
229 concurrency_group: None,
230 inputs: vec![],
231 outputs: vec![],
232 depends_on: depends_on.iter().map(|s| (*s).to_string()).collect(),
233 cache_policy: CachePolicy::Normal,
234 deployment: false,
235 manual_approval: false,
236 }
237 }
238
239 #[test]
240 fn test_valid_ir() {
241 let mut ir = IntermediateRepresentation::new("test");
242 ir.tasks.push(create_test_task("task1", &[]));
243 ir.tasks.push(create_test_task("task2", &["task1"]));
244
245 let validator = IrValidator::new(&ir);
246 assert!(validator.validate().is_ok());
247 }
248
249 #[test]
250 fn test_cyclic_dependency() {
251 let mut ir = IntermediateRepresentation::new("test");
252 ir.tasks.push(create_test_task("task1", &["task2"]));
253 ir.tasks.push(create_test_task("task2", &["task1"]));
254
255 let validator = IrValidator::new(&ir);
256 let result = validator.validate();
257 assert!(result.is_err());
258
259 let errors = result.unwrap_err();
260 assert!(
261 errors
262 .iter()
263 .any(|e| matches!(e, ValidationError::CyclicDependency(_)))
264 );
265 }
266
267 #[test]
268 fn test_missing_dependency() {
269 let mut ir = IntermediateRepresentation::new("test");
270 ir.tasks.push(create_test_task("task1", &["nonexistent"]));
271
272 let validator = IrValidator::new(&ir);
273 let result = validator.validate();
274 assert!(result.is_err());
275
276 let errors = result.unwrap_err();
277 assert_eq!(errors.len(), 1);
278 assert!(matches!(
279 errors[0],
280 ValidationError::MissingDependency { .. }
281 ));
282 }
283
284 #[test]
285 fn test_deployment_task_must_have_disabled_cache() {
286 let mut ir = IntermediateRepresentation::new("test");
287 let mut deploy_task = create_test_task("deploy", &[]);
288 deploy_task.deployment = true;
289 deploy_task.cache_policy = CachePolicy::Normal; ir.tasks.push(deploy_task);
291
292 let validator = IrValidator::new(&ir);
293 let result = validator.validate();
294 assert!(result.is_err());
295
296 let errors = result.unwrap_err();
297 assert!(
298 errors
299 .iter()
300 .any(|e| matches!(e, ValidationError::InvalidDeploymentCachePolicy { .. }))
301 );
302 }
303
304 #[test]
305 fn test_deployment_task_valid_with_disabled_cache() {
306 let mut ir = IntermediateRepresentation::new("test");
307 let mut deploy_task = create_test_task("deploy", &[]);
308 deploy_task.deployment = true;
309 deploy_task.cache_policy = CachePolicy::Disabled;
310 ir.tasks.push(deploy_task);
311
312 let validator = IrValidator::new(&ir);
313 assert!(validator.validate().is_ok());
314 }
315
316 #[test]
317 fn test_non_deployment_cannot_depend_on_deployment() {
318 let mut ir = IntermediateRepresentation::new("test");
319
320 let mut deploy_task = create_test_task("deploy", &[]);
321 deploy_task.deployment = true;
322 deploy_task.cache_policy = CachePolicy::Disabled;
323 ir.tasks.push(deploy_task);
324
325 let build_task = create_test_task("build", &["deploy"]);
326 ir.tasks.push(build_task);
327
328 let validator = IrValidator::new(&ir);
329 let result = validator.validate();
330 assert!(result.is_err());
331
332 let errors = result.unwrap_err();
333 assert!(
334 errors
335 .iter()
336 .any(|e| matches!(e, ValidationError::InvalidDeploymentDependency { .. }))
337 );
338 }
339
340 #[test]
341 fn test_deployment_can_depend_on_deployment() {
342 let mut ir = IntermediateRepresentation::new("test");
343
344 let mut deploy1 = create_test_task("deploy-staging", &[]);
345 deploy1.deployment = true;
346 deploy1.cache_policy = CachePolicy::Disabled;
347 ir.tasks.push(deploy1);
348
349 let mut deploy2 = create_test_task("deploy-prod", &["deploy-staging"]);
350 deploy2.deployment = true;
351 deploy2.cache_policy = CachePolicy::Disabled;
352 ir.tasks.push(deploy2);
353
354 let validator = IrValidator::new(&ir);
355 assert!(validator.validate().is_ok());
356 }
357
358 #[test]
359 fn test_empty_command() {
360 let mut ir = IntermediateRepresentation::new("test");
361 let mut task = create_test_task("task1", &[]);
362 task.command = vec![];
363 ir.tasks.push(task);
364
365 let validator = IrValidator::new(&ir);
366 let result = validator.validate();
367 assert!(result.is_err());
368
369 let errors = result.unwrap_err();
370 assert!(
371 errors
372 .iter()
373 .any(|e| matches!(e, ValidationError::EmptyCommand { .. }))
374 );
375 }
376
377 #[test]
378 fn test_missing_runtime() {
379 let mut ir = IntermediateRepresentation::new("test");
380 let mut task = create_test_task("task1", &[]);
381 task.runtime = Some("nonexistent".to_string());
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::MissingRuntime { .. }))
393 );
394 }
395
396 #[test]
397 fn test_valid_runtime_reference() {
398 let mut ir = IntermediateRepresentation::new("test");
399 ir.runtimes.push(Runtime {
400 id: "nix".to_string(),
401 flake: "github:NixOS/nixpkgs/nixos-unstable".to_string(),
402 output: "devShells.x86_64-linux.default".to_string(),
403 system: "x86_64-linux".to_string(),
404 digest: "sha256:abc".to_string(),
405 purity: PurityMode::Strict,
406 });
407
408 let mut task = create_test_task("task1", &[]);
409 task.runtime = Some("nix".to_string());
410 ir.tasks.push(task);
411
412 let validator = IrValidator::new(&ir);
413 assert!(validator.validate().is_ok());
414 }
415}