Skip to main content

aiproof_cli/
fix.rs

1/// Apply safe autofixes idempotently.
2use aiproof_config::Config;
3use aiproof_core::rule::Ctx;
4use std::path::PathBuf;
5
6pub struct FixOutcome {
7    pub fixes_applied: usize,
8    pub files_changed: usize,
9}
10
11pub fn run_fix(
12    paths: &[PathBuf],
13    config: &Config,
14    unsafe_fixes: bool,
15) -> anyhow::Result<FixOutcome> {
16    let files = crate::discovery::discover(paths, config)?;
17    let rules = crate::run::filtered_rules(config);
18    let ctx = Ctx {
19        target_models: config.target_models.as_slice(),
20        max_tokens_budget: config.max_tokens_budget,
21    };
22
23    let mut fixes_applied = 0;
24    let mut files_changed = 0;
25
26    for f in files {
27        if std::fs::metadata(&f.path).is_ok_and(|m| m.len() > 10 * 1024 * 1024) {
28            continue;
29        }
30        let Ok(mut source) = std::fs::read_to_string(&f.path) else {
31            continue;
32        };
33        let original = source.clone();
34
35        // Iterate until no more fixes. Cap at 10 iterations.
36        for _ in 0..10 {
37            let docs = match aiproof_parse::parse_file(&f.path, &source) {
38                Ok(d) => d,
39                Err(_) => break,
40            };
41            let mut edits_for_pass = Vec::new();
42            for doc in &docs {
43                for rule in &rules {
44                    for diag in rule.check(doc, &ctx) {
45                        if let Some(fix) = rule.autofix(&diag, doc) {
46                            if !unsafe_fixes && !fix.safe {
47                                continue;
48                            }
49                            edits_for_pass.extend(fix.edits);
50                        }
51                    }
52                }
53            }
54            if edits_for_pass.is_empty() {
55                break;
56            }
57
58            // Apply edits in reverse-byte-position order to avoid shifting offsets.
59            // Skip any edit that overlaps with a previously-applied (later-positioned) one
60            // OR that lands on a non-char boundary in the (possibly UTF-8) source.
61            edits_for_pass.sort_by_key(|e| std::cmp::Reverse(e.span.byte_range.start));
62            let mut next_safe_end = source.len();
63            for e in &edits_for_pass {
64                let range = e.span.byte_range.clone();
65                if range.end > source.len() || range.start > range.end {
66                    continue;
67                }
68                // Reject overlap: the previous (higher-start) edit covered this region.
69                if range.end > next_safe_end {
70                    continue;
71                }
72                // Reject if either endpoint is not on a UTF-8 char boundary.
73                if !source.is_char_boundary(range.start) || !source.is_char_boundary(range.end) {
74                    continue;
75                }
76                source.replace_range(range.clone(), &e.replacement);
77                next_safe_end = range.start;
78                fixes_applied += 1;
79            }
80        }
81
82        if source != original {
83            // Atomic rewrite: write to a tempfile in the same dir, then rename.
84            let tmp = f.path.with_extension("aiproof-tmp");
85            std::fs::write(&tmp, &source)?;
86            std::fs::rename(&tmp, &f.path)?;
87            files_changed += 1;
88        }
89    }
90
91    Ok(FixOutcome {
92        fixes_applied,
93        files_changed,
94    })
95}