alint_rules/fixers/
file_ops.rs1use std::path::PathBuf;
2
3use alint_core::{Error, FixContext, FixOutcome, Fixer, Result, Violation};
4
5use crate::case::CaseConvention;
6
7#[derive(Debug)]
10pub struct FileRemoveFixer;
11
12impl Fixer for FileRemoveFixer {
13 fn describe(&self) -> String {
14 "remove the violating file".to_string()
15 }
16
17 fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
18 let Some(path) = &violation.path else {
19 return Ok(FixOutcome::Skipped(
20 "violation did not carry a path".to_string(),
21 ));
22 };
23 let abs = ctx.root.join(path);
24 if !abs.exists() {
25 return Ok(FixOutcome::Skipped(format!(
26 "{} does not exist",
27 path.display()
28 )));
29 }
30 if ctx.dry_run {
31 return Ok(FixOutcome::Applied(format!(
32 "would remove {}",
33 path.display()
34 )));
35 }
36 std::fs::remove_file(&abs).map_err(|source| Error::Io {
37 path: abs.clone(),
38 source,
39 })?;
40 Ok(FixOutcome::Applied(format!("removed {}", path.display())))
41 }
42}
43
44#[derive(Debug)]
52pub struct FileRenameFixer {
53 case: CaseConvention,
54}
55
56impl FileRenameFixer {
57 pub fn new(case: CaseConvention) -> Self {
58 Self { case }
59 }
60}
61
62impl Fixer for FileRenameFixer {
63 fn describe(&self) -> String {
64 format!("rename stems to {}", self.case.display_name())
65 }
66
67 fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
68 let Some(path) = &violation.path else {
69 return Ok(FixOutcome::Skipped(
70 "violation did not carry a path".to_string(),
71 ));
72 };
73 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
74 return Ok(FixOutcome::Skipped(format!(
75 "cannot decode filename stem for {}",
76 path.display()
77 )));
78 };
79 let new_stem = self.case.convert(stem);
80 if new_stem == stem {
81 return Ok(FixOutcome::Skipped(format!(
82 "{} already matches target case",
83 path.display()
84 )));
85 }
86 if new_stem.is_empty() {
87 return Ok(FixOutcome::Skipped(format!(
88 "case conversion produced an empty stem for {}",
89 path.display()
90 )));
91 }
92
93 let mut new_basename = new_stem;
94 if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
95 new_basename.push('.');
96 new_basename.push_str(ext);
97 }
98 let new_path: PathBuf = match path.parent() {
99 Some(p) if !p.as_os_str().is_empty() => p.join(&new_basename),
100 _ => PathBuf::from(&new_basename),
101 };
102
103 let abs_from = ctx.root.join(path);
104 let abs_to = ctx.root.join(&new_path);
105 if abs_to.exists() {
106 return Ok(FixOutcome::Skipped(format!(
107 "target {} already exists",
108 new_path.display()
109 )));
110 }
111 if ctx.dry_run {
112 return Ok(FixOutcome::Applied(format!(
113 "would rename {} → {}",
114 path.display(),
115 new_path.display()
116 )));
117 }
118 std::fs::rename(&abs_from, &abs_to).map_err(|source| Error::Io {
119 path: abs_from,
120 source,
121 })?;
122 Ok(FixOutcome::Applied(format!(
123 "renamed {} → {}",
124 path.display(),
125 new_path.display()
126 )))
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use tempfile::TempDir;
134
135 fn make_ctx(tmp: &TempDir, dry_run: bool) -> FixContext<'_> {
136 FixContext {
137 root: tmp.path(),
138 dry_run,
139 fix_size_limit: None,
140 }
141 }
142
143 #[test]
144 fn file_remove_deletes_violating_path() {
145 let tmp = TempDir::new().unwrap();
146 let target = tmp.path().join("debug.log");
147 std::fs::write(&target, "noise").unwrap();
148 let outcome = FileRemoveFixer
149 .apply(
150 &Violation::new("forbidden").with_path(std::path::Path::new("debug.log")),
151 &make_ctx(&tmp, false),
152 )
153 .unwrap();
154 assert!(matches!(outcome, FixOutcome::Applied(_)));
155 assert!(!target.exists());
156 }
157
158 #[test]
159 fn file_remove_skips_when_violation_has_no_path() {
160 let tmp = TempDir::new().unwrap();
161 let outcome = FileRemoveFixer
162 .apply(&Violation::new("no path"), &make_ctx(&tmp, false))
163 .unwrap();
164 match outcome {
165 FixOutcome::Skipped(reason) => assert!(reason.contains("path")),
166 FixOutcome::Applied(_) => panic!("expected Skipped"),
167 }
168 }
169
170 #[test]
171 fn file_remove_dry_run_keeps_the_file() {
172 let tmp = TempDir::new().unwrap();
173 let target = tmp.path().join("victim.bak");
174 std::fs::write(&target, "bytes").unwrap();
175 let outcome = FileRemoveFixer
176 .apply(
177 &Violation::new("forbidden").with_path(std::path::Path::new("victim.bak")),
178 &make_ctx(&tmp, true),
179 )
180 .unwrap();
181 match outcome {
182 FixOutcome::Applied(s) => assert!(s.starts_with("would remove")),
183 FixOutcome::Skipped(_) => panic!("expected Applied"),
184 }
185 assert!(target.exists());
186 }
187
188 #[test]
189 fn file_rename_converts_stem_preserving_extension() {
190 let tmp = TempDir::new().unwrap();
191 std::fs::write(tmp.path().join("FooBar.rs"), "fn main() {}\n").unwrap();
192 FileRenameFixer::new(CaseConvention::Snake)
193 .apply(
194 &Violation::new("case").with_path(std::path::Path::new("FooBar.rs")),
195 &make_ctx(&tmp, false),
196 )
197 .unwrap();
198 assert!(tmp.path().join("foo_bar.rs").exists());
199 assert!(!tmp.path().join("FooBar.rs").exists());
200 }
201
202 #[test]
203 fn file_rename_keeps_file_in_same_directory() {
204 let tmp = TempDir::new().unwrap();
205 std::fs::create_dir(tmp.path().join("src")).unwrap();
206 std::fs::write(tmp.path().join("src/MyModule.rs"), "").unwrap();
207 FileRenameFixer::new(CaseConvention::Snake)
208 .apply(
209 &Violation::new("case").with_path(std::path::Path::new("src/MyModule.rs")),
210 &make_ctx(&tmp, false),
211 )
212 .unwrap();
213 assert!(tmp.path().join("src/my_module.rs").exists());
214 }
215
216 #[test]
217 fn file_rename_skips_when_already_in_target_case() {
218 let tmp = TempDir::new().unwrap();
219 std::fs::write(tmp.path().join("foo_bar.rs"), "").unwrap();
220 let outcome = FileRenameFixer::new(CaseConvention::Snake)
221 .apply(
222 &Violation::new("case").with_path(std::path::Path::new("foo_bar.rs")),
223 &make_ctx(&tmp, false),
224 )
225 .unwrap();
226 match outcome {
227 FixOutcome::Skipped(reason) => assert!(reason.contains("already")),
228 FixOutcome::Applied(_) => panic!("expected Skipped"),
229 }
230 }
231
232 #[test]
233 fn file_rename_skips_on_target_collision() {
234 let tmp = TempDir::new().unwrap();
235 std::fs::write(tmp.path().join("FooBar.rs"), "A").unwrap();
236 std::fs::write(tmp.path().join("foo_bar.rs"), "B").unwrap();
237 let outcome = FileRenameFixer::new(CaseConvention::Snake)
238 .apply(
239 &Violation::new("case").with_path(std::path::Path::new("FooBar.rs")),
240 &make_ctx(&tmp, false),
241 )
242 .unwrap();
243 match outcome {
244 FixOutcome::Skipped(reason) => assert!(reason.contains("already exists")),
245 FixOutcome::Applied(_) => panic!("expected Skipped"),
246 }
247 assert_eq!(
249 std::fs::read_to_string(tmp.path().join("FooBar.rs")).unwrap(),
250 "A"
251 );
252 assert_eq!(
253 std::fs::read_to_string(tmp.path().join("foo_bar.rs")).unwrap(),
254 "B"
255 );
256 }
257
258 #[test]
259 fn file_rename_dry_run_does_not_touch_disk() {
260 let tmp = TempDir::new().unwrap();
261 std::fs::write(tmp.path().join("FooBar.rs"), "").unwrap();
262 FileRenameFixer::new(CaseConvention::Snake)
263 .apply(
264 &Violation::new("case").with_path(std::path::Path::new("FooBar.rs")),
265 &make_ctx(&tmp, true),
266 )
267 .unwrap();
268 assert!(tmp.path().join("FooBar.rs").exists());
269 assert!(!tmp.path().join("foo_bar.rs").exists());
270 }
271}