1use crate::comply::rule::{
6 FixResult, RuleCategory, RuleResult, RuleViolation, StackComplianceRule, Suggestion,
7 ViolationLevel,
8};
9use std::path::Path;
10
11fn 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#[derive(Debug)]
24pub struct CiWorkflowRule {
25 workflow_files: Vec<String>,
27 required_jobs: Vec<String>,
29}
30
31impl Default for CiWorkflowRule {
32 fn default() -> Self {
33 Self::new()
34 }
35}
36
37impl CiWorkflowRule {
38 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 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 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 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 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 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 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 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 }
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 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 assert!(result.violations.len() >= 2);
339 }
340
341 #[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 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 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 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}