Skip to main content

alint_rules/fixers/
file_ops.rs

1use std::path::PathBuf;
2
3use alint_core::{Error, FixContext, FixOutcome, Fixer, Result, Violation};
4
5use crate::case::CaseConvention;
6
7/// Removes the file named by the violation's `path`. Used by
8/// `file_absent` to purge committed files that shouldn't be there.
9#[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/// Renames the violating file's stem to a target case convention,
45/// preserving the extension and keeping the file in the same parent
46/// directory. Paired with `filename_case`.
47///
48/// Skips with a clear reason when: the violation has no path, the
49/// target name equals the current name (already conforming), or a
50/// different file already occupies the target name (collision).
51#[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        // Neither file should have been touched.
248        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}