1use cac_core::violation::{FixProposal, Violation};
2use regex::Regex;
3use std::path::{Path, PathBuf};
4use thiserror::Error;
5
6#[derive(Debug, Error)]
7pub enum FixError {
8 #[error("io error: {0}")]
9 Io(#[from] std::io::Error),
10 #[error("no auto-fix available for rule {0}")]
11 NotFixable(String),
12}
13
14pub struct Fixer {
15 root: PathBuf,
16 dry_run: bool,
17}
18
19impl Fixer {
20 pub fn new(root: impl Into<PathBuf>, dry_run: bool) -> Self {
21 Self {
22 root: root.into(),
23 dry_run,
24 }
25 }
26
27 pub fn propose(&self, violations: &[Violation]) -> Vec<FixProposal> {
28 violations
29 .iter()
30 .filter(|v| v.auto_fixable)
31 .filter_map(|v| self.propose_for(v).ok())
32 .collect()
33 }
34
35 pub fn apply(&self, proposals: &[FixProposal]) -> Result<usize, FixError> {
36 let mut applied = 0usize;
37 for proposal in proposals {
38 if self.apply_one(proposal)? {
39 applied += 1;
40 }
41 }
42 Ok(applied)
43 }
44
45 fn propose_for(&self, violation: &Violation) -> Result<FixProposal, FixError> {
46 match violation.rule_id.as_str() {
47 id if id.starts_with("secret-") => self.propose_secret_fix(violation),
48 id if id.starts_with("gdpr-") => self.propose_gdpr_fix(violation),
49 id if id.starts_with("soc2-") => self.propose_soc2_fix(violation),
50 _ => Err(FixError::NotFixable(violation.rule_id.clone())),
51 }
52 }
53
54 fn propose_secret_fix(&self, violation: &Violation) -> Result<FixProposal, FixError> {
55 let re = Regex::new(r#"(?i)(api[_-]?key|secret|password|token)\s*[:=]\s*['"]?[^'"\s]+['"]?"#)
56 .unwrap();
57 let fixed = re.replace(
58 &violation.snippet,
59 "${1}=std::env::var(\"${1}\").expect(\"${1} must be set\")",
60 );
61 Ok(FixProposal {
62 violation_id: format!("{}:{}", violation.file_path, violation.line),
63 file_path: violation.file_path.clone(),
64 original_snippet: violation.snippet.clone(),
65 fixed_snippet: fixed.into_owned(),
66 description: "Replace hardcoded secret with environment variable lookup".into(),
67 })
68 }
69
70 fn propose_gdpr_fix(&self, violation: &Violation) -> Result<FixProposal, FixError> {
71 let annotation = "/// @gdpr personal-data — requires lawful basis and retention policy\n";
72 Ok(FixProposal {
73 violation_id: format!("{}:{}", violation.file_path, violation.line),
74 file_path: violation.file_path.clone(),
75 original_snippet: violation.snippet.clone(),
76 fixed_snippet: format!("{annotation}{}", violation.snippet),
77 description: "Add GDPR data-classification annotation above PII field".into(),
78 })
79 }
80
81 fn propose_soc2_fix(&self, violation: &Violation) -> Result<FixProposal, FixError> {
82 Ok(FixProposal {
83 violation_id: format!("{}:{}", violation.file_path, violation.line),
84 file_path: violation.file_path.clone(),
85 original_snippet: violation.snippet.clone(),
86 fixed_snippet: format!(
87 "audit_log::record(\"sensitive_operation\", &{{ \"file\": \"{}\", \"line\": {} }});",
88 violation.file_path, violation.line
89 ),
90 description: "Insert SOC2 audit trail call for sensitive operation".into(),
91 })
92 }
93
94 fn apply_one(&self, proposal: &FixProposal) -> Result<bool, FixError> {
95 let path = self.root.join(&proposal.file_path);
96 if !path.exists() {
97 return Ok(false);
98 }
99 let content = std::fs::read_to_string(&path)?;
100 if !content.contains(&proposal.original_snippet) {
101 return Ok(false);
102 }
103 let updated = content.replace(
104 &proposal.original_snippet,
105 &proposal.fixed_snippet,
106 );
107 if self.dry_run {
108 return Ok(true);
109 }
110 std::fs::write(path, updated)?;
111 Ok(true)
112 }
113}
114
115pub fn group_by_file(violations: &[Violation]) -> Vec<(String, Vec<&Violation>)> {
116 let mut files: Vec<String> = violations
117 .iter()
118 .map(|v| v.file_path.clone())
119 .collect();
120 files.sort();
121 files.dedup();
122 files
123 .into_iter()
124 .map(|f| {
125 let items: Vec<_> = violations.iter().filter(|v| v.file_path == f).collect();
126 (f, items)
127 })
128 .collect()
129}
130
131pub fn root_path(root: &Path) -> PathBuf {
132 root.to_path_buf()
133}