Skip to main content

batuta/comply/rules/
ci_workflow.rs

1//! CI Workflow Parity Rule
2//!
3//! Ensures consistent GitHub Actions workflow configuration across PAIML stack projects.
4
5use crate::comply::rule::{
6    FixResult, RuleCategory, RuleResult, RuleViolation, StackComplianceRule, Suggestion,
7    ViolationLevel,
8};
9use std::path::Path;
10
11/// Extract string values from a YAML sequence into a vec.
12fn extract_string_seq(node: Option<&serde_yaml_ng::Value>, out: &mut Vec<String>) {
13    if let Some(seq) = node.and_then(|n| n.as_sequence()) {
14        for item in seq {
15            if let Some(s) = item.as_str() {
16                out.push(s.to_string());
17            }
18        }
19    }
20}
21
22/// CI workflow parity rule
23#[derive(Debug)]
24pub struct CiWorkflowRule {
25    /// Required workflow file names (any match is OK)
26    workflow_files: Vec<String>,
27    /// Required jobs in the workflow
28    required_jobs: Vec<String>,
29}
30
31impl Default for CiWorkflowRule {
32    fn default() -> Self {
33        Self::new()
34    }
35}
36
37impl CiWorkflowRule {
38    /// Create a new CI workflow rule with default configuration
39    pub fn new() -> Self {
40        Self {
41            workflow_files: vec![
42                "ci.yml".to_string(),
43                "ci.yaml".to_string(),
44                "rust.yml".to_string(),
45                "rust.yaml".to_string(),
46                "test.yml".to_string(),
47                "test.yaml".to_string(),
48            ],
49            required_jobs: vec!["fmt".to_string(), "clippy".to_string(), "test".to_string()],
50        }
51    }
52
53    /// Find the CI workflow file
54    fn find_workflow(&self, project_path: &Path) -> Option<std::path::PathBuf> {
55        let workflows_dir = project_path.join(".github").join("workflows");
56
57        if !workflows_dir.exists() {
58            return None;
59        }
60
61        for name in &self.workflow_files {
62            let path = workflows_dir.join(name);
63            if path.exists() {
64                return Some(path);
65            }
66        }
67
68        None
69    }
70
71    /// Parse workflow and extract jobs
72    fn parse_workflow(&self, path: &Path) -> anyhow::Result<WorkflowData> {
73        let content = std::fs::read_to_string(path)?;
74        let yaml: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content)?;
75
76        let mut jobs = Vec::new();
77        let mut matrix_os = Vec::new();
78        let mut matrix_rust = Vec::new();
79        let mut uses_nextest = false;
80        let mut uses_llvm_cov = false;
81
82        if let Some(jobs_map) = yaml.get("jobs").and_then(|j| j.as_mapping()) {
83            for (job_name, job_value) in jobs_map {
84                if let Some(name) = job_name.as_str() {
85                    jobs.push(name.to_string());
86
87                    // Check for matrix
88                    if let Some(matrix) = job_value.get("strategy").and_then(|s| s.get("matrix")) {
89                        extract_string_seq(matrix.get("os"), &mut matrix_os);
90                        let rust = matrix.get("rust").or_else(|| matrix.get("toolchain"));
91                        extract_string_seq(rust, &mut matrix_rust);
92                    }
93
94                    // Check steps for specific tools
95                    let run_cmds = job_value
96                        .get("steps")
97                        .and_then(|s| s.as_sequence())
98                        .into_iter()
99                        .flatten()
100                        .filter_map(|step| step.get("run").and_then(|r| r.as_str()));
101                    for run in run_cmds {
102                        uses_nextest |= run.contains("nextest");
103                        uses_llvm_cov |= run.contains("llvm-cov");
104                    }
105                }
106            }
107        }
108
109        Ok(WorkflowData { jobs, matrix_os, matrix_rust, uses_nextest, uses_llvm_cov })
110    }
111
112    /// Return an error result when no workflow file is found.
113    fn no_workflow_result(&self, project_path: &Path) -> anyhow::Result<RuleResult> {
114        let workflows_dir = project_path.join(".github").join("workflows");
115        if !workflows_dir.exists() {
116            return Ok(RuleResult::fail(vec![RuleViolation::new(
117                "CI-001",
118                "No .github/workflows directory found",
119            )
120            .with_severity(ViolationLevel::Error)
121            .with_location(project_path.display().to_string())]));
122        }
123        Ok(RuleResult::fail(vec![RuleViolation::new(
124            "CI-002",
125            format!(
126                "No CI workflow file found (expected one of: {})",
127                self.workflow_files.join(", ")
128            ),
129        )
130        .with_severity(ViolationLevel::Error)
131        .with_location(workflows_dir.display().to_string())]))
132    }
133
134    /// Check that all required job types exist in the workflow.
135    fn check_required_jobs(&self, data: &WorkflowData, workflow_path: &Path) -> Vec<RuleViolation> {
136        self.required_jobs
137            .iter()
138            .filter(|required_job| {
139                !data.jobs.iter().any(|j| {
140                    j.to_lowercase().contains(&required_job.to_lowercase())
141                        || j.to_lowercase().contains(&required_job.replace('-', "_").to_lowercase())
142                })
143            })
144            .map(|required_job| {
145                RuleViolation::new("CI-003", format!("Missing required job type: {required_job}"))
146                    .with_severity(ViolationLevel::Error)
147                    .with_location(workflow_path.display().to_string())
148            })
149            .collect()
150    }
151
152    /// Collect improvement suggestions for the workflow.
153    fn collect_suggestions(&self, data: &WorkflowData, workflow_path: &Path) -> Vec<Suggestion> {
154        let mut suggestions = Vec::new();
155        if !data.uses_nextest {
156            suggestions.push(
157                Suggestion::new("Consider using cargo-nextest for faster test execution")
158                    .with_location(workflow_path.display().to_string())
159                    .with_fix("cargo nextest run".to_string()),
160            );
161        }
162        if !data.uses_llvm_cov {
163            suggestions.push(
164                Suggestion::new("Consider using cargo-llvm-cov for coverage (not tarpaulin)")
165                    .with_location(workflow_path.display().to_string())
166                    .with_fix("cargo llvm-cov --html".to_string()),
167            );
168        }
169        if !data.matrix_rust.is_empty() && !data.matrix_rust.contains(&"stable".to_string()) {
170            suggestions.push(
171                Suggestion::new("Consider including 'stable' in Rust toolchain matrix")
172                    .with_location(workflow_path.display().to_string()),
173            );
174        }
175        suggestions
176    }
177}
178
179#[derive(Debug)]
180struct WorkflowData {
181    jobs: Vec<String>,
182    matrix_os: Vec<String>,
183    matrix_rust: Vec<String>,
184    uses_nextest: bool,
185    uses_llvm_cov: bool,
186}
187
188impl StackComplianceRule for CiWorkflowRule {
189    fn id(&self) -> &'static str {
190        "ci-workflow-parity"
191    }
192
193    fn description(&self) -> &'static str {
194        "Ensures consistent CI workflow configuration across stack projects"
195    }
196
197    fn help(&self) -> Option<&str> {
198        Some(
199            "Required jobs: fmt, clippy, test\n\
200             Recommended: nextest for testing, llvm-cov for coverage",
201        )
202    }
203
204    fn category(&self) -> RuleCategory {
205        RuleCategory::Ci
206    }
207
208    fn check(&self, project_path: &Path) -> anyhow::Result<RuleResult> {
209        let workflow_path = match self.find_workflow(project_path) {
210            Some(p) => p,
211            None => return self.no_workflow_result(project_path),
212        };
213
214        let data = self.parse_workflow(&workflow_path)?;
215        let violations = self.check_required_jobs(&data, &workflow_path);
216        let suggestions = self.collect_suggestions(&data, &workflow_path);
217
218        if violations.is_empty() {
219            if suggestions.is_empty() {
220                Ok(RuleResult::pass())
221            } else {
222                Ok(RuleResult::pass_with_suggestions(suggestions))
223            }
224        } else {
225            let mut result = RuleResult::fail(violations);
226            result.suggestions = suggestions;
227            Ok(result)
228        }
229    }
230
231    fn can_fix(&self) -> bool {
232        false // CI workflow changes need manual review
233    }
234
235    fn fix(&self, _project_path: &Path) -> anyhow::Result<FixResult> {
236        Ok(FixResult::failure("Auto-fix not supported for CI workflows - manual review required"))
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use tempfile::TempDir;
244
245    fn create_workflow_dir(temp: &TempDir) -> std::path::PathBuf {
246        let workflows_dir = temp.path().join(".github").join("workflows");
247        std::fs::create_dir_all(&workflows_dir).unwrap();
248        workflows_dir
249    }
250
251    #[test]
252    fn test_ci_workflow_rule_creation() {
253        let rule = CiWorkflowRule::new();
254        assert_eq!(rule.id(), "ci-workflow-parity");
255        assert!(rule.required_jobs.contains(&"test".to_string()));
256    }
257
258    #[test]
259    fn test_missing_workflows_dir() {
260        let temp = TempDir::new().unwrap();
261        let rule = CiWorkflowRule::new();
262        let result = rule.check(temp.path()).unwrap();
263        assert!(!result.passed);
264        assert_eq!(result.violations[0].code, "CI-001");
265    }
266
267    #[test]
268    fn test_missing_ci_file() {
269        let temp = TempDir::new().unwrap();
270        create_workflow_dir(&temp);
271
272        let rule = CiWorkflowRule::new();
273        let result = rule.check(temp.path()).unwrap();
274        assert!(!result.passed);
275        assert_eq!(result.violations[0].code, "CI-002");
276    }
277
278    #[test]
279    fn test_valid_ci_workflow() {
280        let temp = TempDir::new().unwrap();
281        let workflows_dir = create_workflow_dir(&temp);
282        let ci_file = workflows_dir.join("ci.yml");
283
284        let content = r#"
285name: CI
286
287on: [push, pull_request]
288
289jobs:
290  fmt:
291    runs-on: ubuntu-latest
292    steps:
293      - uses: actions/checkout@v4
294      - run: cargo fmt --check
295
296  clippy:
297    runs-on: ubuntu-latest
298    steps:
299      - uses: actions/checkout@v4
300      - run: cargo clippy -- -D warnings
301
302  test:
303    runs-on: ubuntu-latest
304    steps:
305      - uses: actions/checkout@v4
306      - run: cargo nextest run
307"#;
308        std::fs::write(&ci_file, content).unwrap();
309
310        let rule = CiWorkflowRule::new();
311        let result = rule.check(temp.path()).unwrap();
312        assert!(result.passed, "Should pass: {:?}", result.violations);
313        // Should have suggestion for llvm-cov
314        assert!(!result.suggestions.is_empty());
315    }
316
317    #[test]
318    fn test_missing_job() {
319        let temp = TempDir::new().unwrap();
320        let workflows_dir = create_workflow_dir(&temp);
321        let ci_file = workflows_dir.join("ci.yml");
322
323        let content = r#"
324name: CI
325
326jobs:
327  test:
328    runs-on: ubuntu-latest
329    steps:
330      - run: cargo test
331"#;
332        std::fs::write(&ci_file, content).unwrap();
333
334        let rule = CiWorkflowRule::new();
335        let result = rule.check(temp.path()).unwrap();
336        assert!(!result.passed);
337        // Should have violations for missing fmt and clippy
338        assert!(result.violations.len() >= 2);
339    }
340
341    // -------------------------------------------------------------------------
342    // Additional Coverage Tests
343    // -------------------------------------------------------------------------
344
345    #[test]
346    fn test_ci_workflow_rule_default() {
347        let rule = CiWorkflowRule::default();
348        assert_eq!(rule.id(), "ci-workflow-parity");
349    }
350
351    #[test]
352    fn test_ci_workflow_description() {
353        let rule = CiWorkflowRule::new();
354        assert!(rule.description().contains("CI workflow"));
355    }
356
357    #[test]
358    fn test_ci_workflow_help() {
359        let rule = CiWorkflowRule::new();
360        let help = rule.help();
361        assert!(help.is_some());
362        assert!(help.unwrap().contains("fmt"));
363        assert!(help.unwrap().contains("clippy"));
364    }
365
366    #[test]
367    fn test_ci_workflow_category() {
368        let rule = CiWorkflowRule::new();
369        assert_eq!(rule.category(), RuleCategory::Ci);
370    }
371
372    #[test]
373    fn test_ci_workflow_can_fix() {
374        let rule = CiWorkflowRule::new();
375        assert!(!rule.can_fix());
376    }
377
378    #[test]
379    fn test_ci_workflow_fix() {
380        let temp = TempDir::new().unwrap();
381        let rule = CiWorkflowRule::new();
382        let result = rule.fix(temp.path()).unwrap();
383        assert!(!result.success);
384    }
385
386    #[test]
387    fn test_ci_workflow_rule_debug() {
388        let rule = CiWorkflowRule::new();
389        let debug_str = format!("{:?}", rule);
390        assert!(debug_str.contains("CiWorkflowRule"));
391    }
392
393    #[test]
394    fn test_find_workflow_rust_yml() {
395        let temp = TempDir::new().unwrap();
396        let workflows_dir = create_workflow_dir(&temp);
397        std::fs::write(workflows_dir.join("rust.yml"), "name: Rust").unwrap();
398
399        let rule = CiWorkflowRule::new();
400        let path = rule.find_workflow(temp.path());
401        assert!(path.is_some());
402        assert!(path.unwrap().ends_with("rust.yml"));
403    }
404
405    #[test]
406    fn test_find_workflow_test_yaml() {
407        let temp = TempDir::new().unwrap();
408        let workflows_dir = create_workflow_dir(&temp);
409        std::fs::write(workflows_dir.join("test.yaml"), "name: Test").unwrap();
410
411        let rule = CiWorkflowRule::new();
412        let path = rule.find_workflow(temp.path());
413        assert!(path.is_some());
414    }
415
416    #[test]
417    fn test_find_workflow_none() {
418        let temp = TempDir::new().unwrap();
419        let rule = CiWorkflowRule::new();
420        let path = rule.find_workflow(temp.path());
421        assert!(path.is_none());
422    }
423
424    #[test]
425    fn test_workflow_with_matrix() {
426        let temp = TempDir::new().unwrap();
427        let workflows_dir = create_workflow_dir(&temp);
428        let ci_file = workflows_dir.join("ci.yml");
429
430        let content = r#"
431name: CI
432
433jobs:
434  fmt:
435    runs-on: ubuntu-latest
436    steps:
437      - run: cargo fmt --check
438
439  clippy:
440    runs-on: ubuntu-latest
441    steps:
442      - run: cargo clippy
443
444  test:
445    strategy:
446      matrix:
447        os: [ubuntu-latest, macos-latest]
448        rust: [stable, nightly]
449    runs-on: ${{ matrix.os }}
450    steps:
451      - run: cargo nextest run
452"#;
453        std::fs::write(&ci_file, content).unwrap();
454
455        let rule = CiWorkflowRule::new();
456        let result = rule.check(temp.path()).unwrap();
457        assert!(result.passed);
458    }
459
460    #[test]
461    fn test_workflow_with_llvm_cov() {
462        let temp = TempDir::new().unwrap();
463        let workflows_dir = create_workflow_dir(&temp);
464        let ci_file = workflows_dir.join("ci.yml");
465
466        let content = r#"
467name: CI
468
469jobs:
470  fmt:
471    steps:
472      - run: cargo fmt --check
473
474  clippy:
475    steps:
476      - run: cargo clippy
477
478  test:
479    steps:
480      - run: cargo llvm-cov --html
481"#;
482        std::fs::write(&ci_file, content).unwrap();
483
484        let rule = CiWorkflowRule::new();
485        let result = rule.check(temp.path()).unwrap();
486        assert!(result.passed);
487    }
488
489    #[test]
490    fn test_workflow_missing_stable_rust() {
491        let temp = TempDir::new().unwrap();
492        let workflows_dir = create_workflow_dir(&temp);
493        let ci_file = workflows_dir.join("ci.yml");
494
495        let content = r#"
496name: CI
497
498jobs:
499  fmt:
500    steps:
501      - run: cargo fmt --check
502
503  clippy:
504    steps:
505      - run: cargo clippy
506
507  test:
508    strategy:
509      matrix:
510        rust: [nightly, beta]
511    steps:
512      - run: cargo nextest run
513      - run: cargo llvm-cov
514"#;
515        std::fs::write(&ci_file, content).unwrap();
516
517        let rule = CiWorkflowRule::new();
518        let result = rule.check(temp.path()).unwrap();
519        assert!(result.passed);
520        // Should have suggestion for stable rust
521        assert!(result.suggestions.iter().any(|s| s.message.contains("stable")));
522    }
523
524    #[test]
525    fn test_workflow_data_debug() {
526        let data = WorkflowData {
527            jobs: vec!["test".to_string()],
528            matrix_os: vec!["ubuntu-latest".to_string()],
529            matrix_rust: vec!["stable".to_string()],
530            uses_nextest: true,
531            uses_llvm_cov: false,
532        };
533        let debug_str = format!("{:?}", data);
534        assert!(debug_str.contains("WorkflowData"));
535    }
536
537    #[test]
538    fn test_parse_workflow_invalid_yaml() {
539        let temp = TempDir::new().unwrap();
540        let file = temp.path().join("invalid.yml");
541        std::fs::write(&file, "invalid: yaml: content: [").unwrap();
542
543        let rule = CiWorkflowRule::new();
544        let result = rule.parse_workflow(&file);
545        assert!(result.is_err());
546    }
547
548    #[test]
549    fn test_parse_workflow_empty_yaml() {
550        let temp = TempDir::new().unwrap();
551        let file = temp.path().join("empty.yml");
552        std::fs::write(&file, "name: Empty").unwrap();
553
554        let rule = CiWorkflowRule::new();
555        let result = rule.parse_workflow(&file).unwrap();
556        assert!(result.jobs.is_empty());
557    }
558
559    #[test]
560    fn test_job_name_variations() {
561        let temp = TempDir::new().unwrap();
562        let workflows_dir = create_workflow_dir(&temp);
563        let ci_file = workflows_dir.join("ci.yml");
564
565        // Test with underscore variations
566        let content = r#"
567name: CI
568
569jobs:
570  rust_fmt:
571    steps:
572      - run: cargo fmt --check
573
574  rust_clippy:
575    steps:
576      - run: cargo clippy
577
578  unit_test:
579    steps:
580      - run: cargo nextest run
581      - run: cargo llvm-cov
582"#;
583        std::fs::write(&ci_file, content).unwrap();
584
585        let rule = CiWorkflowRule::new();
586        let result = rule.check(temp.path()).unwrap();
587        assert!(
588            result.passed,
589            "Should recognize _fmt, _clippy, _test variations: {:?}",
590            result.violations
591        );
592    }
593
594    #[test]
595    fn test_ci_workflow_alternative_filenames() {
596        let temp = TempDir::new().unwrap();
597        let workflows_dir = create_workflow_dir(&temp);
598
599        // Test ci.yaml (not ci.yml)
600        let content = r#"
601name: CI
602
603jobs:
604  fmt:
605    steps:
606      - run: cargo fmt
607
608  clippy:
609    steps:
610      - run: cargo clippy
611
612  test:
613    steps:
614      - run: cargo test
615"#;
616        std::fs::write(workflows_dir.join("ci.yaml"), content).unwrap();
617
618        let rule = CiWorkflowRule::new();
619        let result = rule.check(temp.path()).unwrap();
620        assert!(result.passed);
621    }
622
623    #[test]
624    fn test_workflow_toolchain_matrix() {
625        let temp = TempDir::new().unwrap();
626        let workflows_dir = create_workflow_dir(&temp);
627        let ci_file = workflows_dir.join("ci.yml");
628
629        let content = r#"
630name: CI
631
632jobs:
633  fmt:
634    steps:
635      - run: cargo fmt
636
637  clippy:
638    steps:
639      - run: cargo clippy
640
641  test:
642    strategy:
643      matrix:
644        toolchain: [stable, nightly]
645    steps:
646      - run: cargo nextest run
647      - run: cargo llvm-cov
648"#;
649        std::fs::write(&ci_file, content).unwrap();
650
651        let rule = CiWorkflowRule::new();
652        let result = rule.check(temp.path()).unwrap();
653        assert!(result.passed);
654    }
655}