cuenv_ci/ir/
validation.rs

1//! IR Validation
2//!
3//! Validates IR documents for correctness according to PRD v1.3 rules.
4
5use super::schema::{CachePolicy, IntermediateRepresentation, Task};
6use std::collections::{HashMap, HashSet};
7use thiserror::Error;
8
9/// Validation errors for IR documents
10#[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
40/// Validator for IR documents
41pub struct IrValidator<'a> {
42    ir: &'a IntermediateRepresentation,
43}
44
45impl<'a> IrValidator<'a> {
46    /// Create a new validator for the given IR
47    #[must_use]
48    pub fn new(ir: &'a IntermediateRepresentation) -> Self {
49        Self { ir }
50    }
51
52    /// Validate the entire IR document
53    ///
54    /// # Errors
55    ///
56    /// Returns a list of `ValidationError`s if validation fails.
57    pub fn validate(&self) -> Result<(), Vec<ValidationError>> {
58        let mut errors = Vec::new();
59
60        // Build task index
61        let task_index: HashMap<&str, &Task> =
62            self.ir.tasks.iter().map(|t| (t.id.as_str(), t)).collect();
63
64        // Build runtime index
65        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            // Validate command
69            if let Err(e) = Self::validate_command(task) {
70                errors.push(e);
71            }
72
73            // Validate runtime reference
74            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            // Validate dependencies exist
84            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            // Validate deployment task constraints
94            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        // Validate no cycles in task graph
103        if let Err(e) = self.validate_no_cycles(&task_index) {
104            errors.push(e);
105        }
106
107        // Validate deployment dependencies
108        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    /// Validate task command is well-formed
120    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        // If shell is false, command must be properly structured for direct execve
128        // (already an array, so this is satisfied by the type system)
129
130        Ok(())
131    }
132
133    /// Validate task graph has no cycles using DFS
134    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    /// Detect cycles using DFS, returns path if cycle found
151    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                    // Found a cycle
168                    return Some(format!("{task_id} -> {dep}"));
169                }
170            }
171        }
172
173        rec_stack.remove(task_id);
174        None
175    }
176
177    /// Validate deployment task dependency rules
178    fn validate_deployment_dependencies(
179        &self,
180        _task_index: &HashMap<&str, &Task>,
181    ) -> Result<(), Vec<ValidationError>> {
182        let mut errors = Vec::new();
183
184        // Find all deployment tasks
185        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        // Check that no non-deployment task depends on a deployment task
194        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; // Invalid!
290        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}