1use std::collections::BTreeMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use anyhow::{Context, Result};
11
12use crate::rules::{TextEdit, Violation};
13
14#[derive(Debug, Clone)]
16pub struct FileFixResult {
17 pub path: PathBuf,
19 pub applied: usize,
21 pub conflict: Option<FixConflict>,
23}
24
25#[derive(Debug, Clone)]
27pub struct FixConflict {
28 pub first: std::ops::Range<usize>,
30 pub second: std::ops::Range<usize>,
32}
33
34pub fn apply_all(violations: &[Violation]) -> Result<Vec<FileFixResult>> {
40 let mut by_file: BTreeMap<PathBuf, Vec<TextEdit>> = BTreeMap::new();
41 for v in violations {
42 if v.fixes.is_empty() {
43 continue;
44 }
45 by_file
46 .entry(v.file.clone())
47 .or_default()
48 .extend(v.fixes.iter().cloned());
49 }
50
51 let mut results = Vec::with_capacity(by_file.len());
52 for (path, edits) in by_file {
53 results.push(apply_to_file(&path, edits)?);
54 }
55 Ok(results)
56}
57
58fn apply_to_file(path: &Path, mut edits: Vec<TextEdit>) -> Result<FileFixResult> {
59 edits.sort_by_key(|e| std::cmp::Reverse(e.range.start));
61
62 for pair in edits.windows(2) {
64 let higher = &pair[0];
65 let lower = &pair[1];
66 if lower.range.end > higher.range.start {
67 return Ok(FileFixResult {
68 path: path.to_path_buf(),
69 applied: 0,
70 conflict: Some(FixConflict {
71 first: higher.range.clone(),
72 second: lower.range.clone(),
73 }),
74 });
75 }
76 }
77
78 let mut raw = fs::read_to_string(path)
79 .with_context(|| format!("failed to read {} for fixing", path.display()))?;
80 let applied = edits.len();
81 for edit in edits {
82 if edit.range.end > raw.len() {
83 anyhow::bail!(
84 "fix range {:?} out of bounds ({} bytes) in {}",
85 edit.range,
86 raw.len(),
87 path.display()
88 );
89 }
90 raw.replace_range(edit.range, &edit.replacement);
91 }
92 fs::write(path, raw)
93 .with_context(|| format!("failed to write fixed content to {}", path.display()))?;
94
95 Ok(FileFixResult {
96 path: path.to_path_buf(),
97 applied,
98 conflict: None,
99 })
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105 use crate::rules::{RuleId, Severity};
106
107 fn tmp_file(name: &str, body: &str) -> PathBuf {
108 let mut p = std::env::temp_dir();
109 p.push(format!("ailint-fix-{}-{name}", std::process::id()));
110 std::fs::write(&p, body).unwrap();
111 p
112 }
113
114 fn violation_with(path: PathBuf, edits: Vec<TextEdit>) -> Violation {
115 let mut v = Violation::new(RuleId::new(999, "test"), Severity::Warning, path, "test");
116 v.fixes = edits;
117 v
118 }
119
120 #[test]
121 fn applies_single_edit() {
122 let path = tmp_file("single.md", "hello world");
123 let v = violation_with(
124 path.clone(),
125 vec![TextEdit {
126 range: 6..11,
127 replacement: "there".into(),
128 }],
129 );
130 let results = apply_all(&[v]).unwrap();
131 assert_eq!(results.len(), 1);
132 assert_eq!(results[0].applied, 1);
133 assert!(results[0].conflict.is_none());
134 assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello there");
135 let _ = std::fs::remove_file(&path);
136 }
137
138 #[test]
139 fn applies_two_non_overlapping_in_reverse_order() {
140 let path = tmp_file("two.md", "AAA BBB CCC");
141 let v = violation_with(
144 path.clone(),
145 vec![
146 TextEdit {
147 range: 0..3,
148 replacement: "aaaa".into(),
149 },
150 TextEdit {
151 range: 8..11,
152 replacement: "cc".into(),
153 },
154 ],
155 );
156 let results = apply_all(&[v]).unwrap();
157 assert_eq!(results[0].applied, 2);
158 assert_eq!(std::fs::read_to_string(&path).unwrap(), "aaaa BBB cc");
159 let _ = std::fs::remove_file(&path);
160 }
161
162 #[test]
163 fn refuses_overlapping_edits() {
164 let path = tmp_file("overlap.md", "hello world");
165 let v = violation_with(
166 path.clone(),
167 vec![
168 TextEdit {
169 range: 0..5,
170 replacement: "HI".into(),
171 },
172 TextEdit {
173 range: 3..8,
174 replacement: "XX".into(),
175 },
176 ],
177 );
178 let results = apply_all(&[v]).unwrap();
179 assert_eq!(results[0].applied, 0);
180 assert!(results[0].conflict.is_some());
181 assert_eq!(
182 std::fs::read_to_string(&path).unwrap(),
183 "hello world",
184 "content must be untouched when edits conflict"
185 );
186 let _ = std::fs::remove_file(&path);
187 }
188}