Skip to main content

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, 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
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 const 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    ///
121    /// Phase tasks with provider hints (e.g., GitHub Actions) are allowed to have
122    /// empty commands since they execute via the provider's native mechanism.
123    fn validate_command(task: &Task) -> Result<(), ValidationError> {
124        // Phase tasks with provider hints don't need commands
125        // They execute via provider-native actions (e.g., GitHub Actions uses:)
126        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        // If shell is false, command must be properly structured for direct execve
137        // (already an array, so this is satisfied by the type system)
138
139        Ok(())
140    }
141
142    /// Validate task graph has no cycles using DFS
143    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    /// Detect cycles using DFS, returns path if cycle found
160    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                    // Found a cycle
177                    return Some(format!("{task_id} -> {dep}"));
178                }
179            }
180        }
181
182        rec_stack.remove(task_id);
183        None
184    }
185
186    /// Validate deployment task dependency rules
187    fn validate_deployment_dependencies(
188        &self,
189        _task_index: &HashMap<&str, &Task>,
190    ) -> Result<(), Vec<ValidationError>> {
191        let mut errors = Vec::new();
192
193        // Find all deployment tasks
194        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        // Check that no non-deployment task depends on a deployment task
203        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; // Invalid!
309        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        // Phase tasks (contributed by CI providers) can have provider_hints
438        // instead of commands - e.g., GitHub Actions with uses:
439        let mut ir = IntermediateRepresentation::new("test");
440        let mut phase_task = create_test_task("install-nix", &[]);
441        phase_task.command = vec![]; // Empty command
442        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        // Phase tasks without provider_hints still need commands
460        let mut ir = IntermediateRepresentation::new("test");
461        let mut phase_task = create_test_task("run-script", &[]);
462        phase_task.command = vec![]; // Empty command
463        phase_task.phase = Some(crate::ir::BuildStage::Setup);
464        // No provider_hints
465        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}