Skip to main content

alint_rules/
fixers.rs

1//! Shared [`Fixer`] implementations.
2//!
3//! Each fixer is a small, rule-agnostic helper: rule builders (e.g.
4//! `file_exists`, `file_absent`) decide whether the configured `fix:`
5//! op makes sense for their kind and, if so, construct one of the
6//! fixers here and attach it to the built rule.
7
8use std::io::Write;
9use std::path::PathBuf;
10
11use alint_core::{Error, FixContext, FixOutcome, Fixer, Result, Violation};
12
13use crate::case::CaseConvention;
14
15/// UTF-8 byte-order mark. Preserved across prepend operations so
16/// editors that rely on it don't break.
17const UTF8_BOM: &[u8] = b"\xEF\xBB\xBF";
18
19/// Creates a file with pre-declared content. Target path is set at
20/// rule-build time (either explicit `fix.file_create.path` or the
21/// rule's first literal `paths:` entry).
22#[derive(Debug)]
23pub struct FileCreateFixer {
24    path: PathBuf,
25    content: String,
26    create_parents: bool,
27}
28
29impl FileCreateFixer {
30    pub fn new(path: PathBuf, content: String, create_parents: bool) -> Self {
31        Self {
32            path,
33            content,
34            create_parents,
35        }
36    }
37}
38
39impl Fixer for FileCreateFixer {
40    fn describe(&self) -> String {
41        format!(
42            "create {} ({} byte{})",
43            self.path.display(),
44            self.content.len(),
45            if self.content.len() == 1 { "" } else { "s" }
46        )
47    }
48
49    fn apply(&self, _violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
50        let abs = ctx.root.join(&self.path);
51        if abs.exists() {
52            return Ok(FixOutcome::Skipped(format!(
53                "{} already exists",
54                self.path.display()
55            )));
56        }
57        if ctx.dry_run {
58            return Ok(FixOutcome::Applied(format!(
59                "would create {}",
60                self.path.display()
61            )));
62        }
63        if self.create_parents {
64            if let Some(parent) = abs.parent() {
65                std::fs::create_dir_all(parent).map_err(|source| Error::Io {
66                    path: parent.to_path_buf(),
67                    source,
68                })?;
69            }
70        }
71        std::fs::write(&abs, &self.content).map_err(|source| Error::Io {
72            path: abs.clone(),
73            source,
74        })?;
75        Ok(FixOutcome::Applied(format!(
76            "created {}",
77            self.path.display()
78        )))
79    }
80}
81
82/// Removes the file named by the violation's `path`. Used by
83/// `file_absent` to purge committed files that shouldn't be there.
84#[derive(Debug)]
85pub struct FileRemoveFixer;
86
87impl Fixer for FileRemoveFixer {
88    fn describe(&self) -> String {
89        "remove the violating file".to_string()
90    }
91
92    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
93        let Some(path) = &violation.path else {
94            return Ok(FixOutcome::Skipped(
95                "violation did not carry a path".to_string(),
96            ));
97        };
98        let abs = ctx.root.join(path);
99        if !abs.exists() {
100            return Ok(FixOutcome::Skipped(format!(
101                "{} does not exist",
102                path.display()
103            )));
104        }
105        if ctx.dry_run {
106            return Ok(FixOutcome::Applied(format!(
107                "would remove {}",
108                path.display()
109            )));
110        }
111        std::fs::remove_file(&abs).map_err(|source| Error::Io {
112            path: abs.clone(),
113            source,
114        })?;
115        Ok(FixOutcome::Applied(format!("removed {}", path.display())))
116    }
117}
118
119/// Prepends `content` to the start of each violating file. Paired with
120/// `file_header` to inject the required header comment/boilerplate.
121///
122/// If the file starts with a UTF-8 BOM, `content` is inserted *after*
123/// the BOM so editors that rely on it don't break.
124#[derive(Debug)]
125pub struct FilePrependFixer {
126    content: String,
127}
128
129impl FilePrependFixer {
130    pub fn new(content: String) -> Self {
131        Self { content }
132    }
133}
134
135impl Fixer for FilePrependFixer {
136    fn describe(&self) -> String {
137        format!(
138            "prepend {} byte{} to each violating file",
139            self.content.len(),
140            if self.content.len() == 1 { "" } else { "s" }
141        )
142    }
143
144    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
145        let Some(path) = &violation.path else {
146            return Ok(FixOutcome::Skipped(
147                "violation did not carry a path".to_string(),
148            ));
149        };
150        let abs = ctx.root.join(path);
151        if ctx.dry_run {
152            return Ok(FixOutcome::Applied(format!(
153                "would prepend {} byte(s) to {}",
154                self.content.len(),
155                path.display()
156            )));
157        }
158        let existing = std::fs::read(&abs).map_err(|source| Error::Io {
159            path: abs.clone(),
160            source,
161        })?;
162        let mut out = Vec::with_capacity(existing.len() + self.content.len());
163        if existing.starts_with(UTF8_BOM) {
164            out.extend_from_slice(UTF8_BOM);
165            out.extend_from_slice(self.content.as_bytes());
166            out.extend_from_slice(&existing[UTF8_BOM.len()..]);
167        } else {
168            out.extend_from_slice(self.content.as_bytes());
169            out.extend_from_slice(&existing);
170        }
171        std::fs::write(&abs, &out).map_err(|source| Error::Io {
172            path: abs.clone(),
173            source,
174        })?;
175        Ok(FixOutcome::Applied(format!("prepended {}", path.display())))
176    }
177}
178
179/// Appends `content` to the end of each violating file. Paired with
180/// `file_content_matches` when the required pattern is satisfied by
181/// the content appearing anywhere in the file.
182#[derive(Debug)]
183pub struct FileAppendFixer {
184    content: String,
185}
186
187impl FileAppendFixer {
188    pub fn new(content: String) -> Self {
189        Self { content }
190    }
191}
192
193impl Fixer for FileAppendFixer {
194    fn describe(&self) -> String {
195        format!(
196            "append {} byte{} to each violating file",
197            self.content.len(),
198            if self.content.len() == 1 { "" } else { "s" }
199        )
200    }
201
202    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
203        let Some(path) = &violation.path else {
204            return Ok(FixOutcome::Skipped(
205                "violation did not carry a path".to_string(),
206            ));
207        };
208        let abs = ctx.root.join(path);
209        if ctx.dry_run {
210            return Ok(FixOutcome::Applied(format!(
211                "would append {} byte(s) to {}",
212                self.content.len(),
213                path.display()
214            )));
215        }
216        let mut f = std::fs::OpenOptions::new()
217            .append(true)
218            .open(&abs)
219            .map_err(|source| Error::Io {
220                path: abs.clone(),
221                source,
222            })?;
223        f.write_all(self.content.as_bytes())
224            .map_err(|source| Error::Io {
225                path: abs.clone(),
226                source,
227            })?;
228        Ok(FixOutcome::Applied(format!(
229            "appended to {}",
230            path.display()
231        )))
232    }
233}
234
235/// Renames the violating file's stem to a target case convention,
236/// preserving the extension and keeping the file in the same parent
237/// directory. Paired with `filename_case`.
238///
239/// Skips with a clear reason when: the violation has no path, the
240/// target name equals the current name (already conforming), or a
241/// different file already occupies the target name (collision).
242#[derive(Debug)]
243pub struct FileRenameFixer {
244    case: CaseConvention,
245}
246
247impl FileRenameFixer {
248    pub fn new(case: CaseConvention) -> Self {
249        Self { case }
250    }
251}
252
253impl Fixer for FileRenameFixer {
254    fn describe(&self) -> String {
255        format!("rename stems to {}", self.case.display_name())
256    }
257
258    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
259        let Some(path) = &violation.path else {
260            return Ok(FixOutcome::Skipped(
261                "violation did not carry a path".to_string(),
262            ));
263        };
264        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
265            return Ok(FixOutcome::Skipped(format!(
266                "cannot decode filename stem for {}",
267                path.display()
268            )));
269        };
270        let new_stem = self.case.convert(stem);
271        if new_stem == stem {
272            return Ok(FixOutcome::Skipped(format!(
273                "{} already matches target case",
274                path.display()
275            )));
276        }
277        if new_stem.is_empty() {
278            return Ok(FixOutcome::Skipped(format!(
279                "case conversion produced an empty stem for {}",
280                path.display()
281            )));
282        }
283
284        let mut new_basename = new_stem;
285        if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
286            new_basename.push('.');
287            new_basename.push_str(ext);
288        }
289        let new_path: PathBuf = match path.parent() {
290            Some(p) if !p.as_os_str().is_empty() => p.join(&new_basename),
291            _ => PathBuf::from(&new_basename),
292        };
293
294        let abs_from = ctx.root.join(path);
295        let abs_to = ctx.root.join(&new_path);
296        if abs_to.exists() {
297            return Ok(FixOutcome::Skipped(format!(
298                "target {} already exists",
299                new_path.display()
300            )));
301        }
302        if ctx.dry_run {
303            return Ok(FixOutcome::Applied(format!(
304                "would rename {} → {}",
305                path.display(),
306                new_path.display()
307            )));
308        }
309        std::fs::rename(&abs_from, &abs_to).map_err(|source| Error::Io {
310            path: abs_from,
311            source,
312        })?;
313        Ok(FixOutcome::Applied(format!(
314            "renamed {} → {}",
315            path.display(),
316            new_path.display()
317        )))
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use tempfile::TempDir;
325
326    fn make_ctx(tmp: &TempDir, dry_run: bool) -> FixContext<'_> {
327        FixContext {
328            root: tmp.path(),
329            dry_run,
330        }
331    }
332
333    #[test]
334    fn file_create_writes_content_when_missing() {
335        let tmp = TempDir::new().unwrap();
336        let fixer = FileCreateFixer::new(PathBuf::from("LICENSE"), "Apache-2.0\n".into(), true);
337        let outcome = fixer
338            .apply(&Violation::new("missing LICENSE"), &make_ctx(&tmp, false))
339            .unwrap();
340        assert!(matches!(outcome, FixOutcome::Applied(_)));
341        let written = std::fs::read_to_string(tmp.path().join("LICENSE")).unwrap();
342        assert_eq!(written, "Apache-2.0\n");
343    }
344
345    #[test]
346    fn file_create_creates_intermediate_directories() {
347        let tmp = TempDir::new().unwrap();
348        let fixer = FileCreateFixer::new(PathBuf::from("a/b/c/config.yaml"), "k: v\n".into(), true);
349        fixer
350            .apply(&Violation::new("missing"), &make_ctx(&tmp, false))
351            .unwrap();
352        assert!(tmp.path().join("a/b/c/config.yaml").exists());
353    }
354
355    #[test]
356    fn file_create_skips_when_target_exists() {
357        let tmp = TempDir::new().unwrap();
358        std::fs::write(tmp.path().join("README.md"), "existing\n").unwrap();
359        let fixer = FileCreateFixer::new(PathBuf::from("README.md"), "NEW\n".into(), true);
360        let outcome = fixer
361            .apply(&Violation::new("x"), &make_ctx(&tmp, false))
362            .unwrap();
363        match outcome {
364            FixOutcome::Skipped(reason) => assert!(reason.contains("already exists")),
365            FixOutcome::Applied(_) => panic!("expected Skipped"),
366        }
367        assert_eq!(
368            std::fs::read_to_string(tmp.path().join("README.md")).unwrap(),
369            "existing\n",
370            "pre-existing content must not be overwritten"
371        );
372    }
373
374    #[test]
375    fn file_create_dry_run_does_not_touch_disk() {
376        let tmp = TempDir::new().unwrap();
377        let fixer = FileCreateFixer::new(PathBuf::from("x.txt"), "body".into(), true);
378        let outcome = fixer
379            .apply(&Violation::new("x"), &make_ctx(&tmp, true))
380            .unwrap();
381        match outcome {
382            FixOutcome::Applied(s) => assert!(s.starts_with("would create")),
383            FixOutcome::Skipped(_) => panic!("expected Applied"),
384        }
385        assert!(!tmp.path().join("x.txt").exists());
386    }
387
388    #[test]
389    fn file_remove_deletes_violating_path() {
390        let tmp = TempDir::new().unwrap();
391        let target = tmp.path().join("debug.log");
392        std::fs::write(&target, "noise").unwrap();
393        let outcome = FileRemoveFixer
394            .apply(
395                &Violation::new("forbidden").with_path("debug.log"),
396                &make_ctx(&tmp, false),
397            )
398            .unwrap();
399        assert!(matches!(outcome, FixOutcome::Applied(_)));
400        assert!(!target.exists());
401    }
402
403    #[test]
404    fn file_remove_skips_when_violation_has_no_path() {
405        let tmp = TempDir::new().unwrap();
406        let outcome = FileRemoveFixer
407            .apply(&Violation::new("no path"), &make_ctx(&tmp, false))
408            .unwrap();
409        match outcome {
410            FixOutcome::Skipped(reason) => assert!(reason.contains("path")),
411            FixOutcome::Applied(_) => panic!("expected Skipped"),
412        }
413    }
414
415    #[test]
416    fn file_remove_dry_run_keeps_the_file() {
417        let tmp = TempDir::new().unwrap();
418        let target = tmp.path().join("victim.bak");
419        std::fs::write(&target, "bytes").unwrap();
420        let outcome = FileRemoveFixer
421            .apply(
422                &Violation::new("forbidden").with_path("victim.bak"),
423                &make_ctx(&tmp, true),
424            )
425            .unwrap();
426        match outcome {
427            FixOutcome::Applied(s) => assert!(s.starts_with("would remove")),
428            FixOutcome::Skipped(_) => panic!("expected Applied"),
429        }
430        assert!(target.exists());
431    }
432
433    #[test]
434    fn file_prepend_inserts_at_start() {
435        let tmp = TempDir::new().unwrap();
436        std::fs::write(tmp.path().join("a.rs"), "fn main() {}\n").unwrap();
437        let fixer = FilePrependFixer::new("// Copyright 2026\n".into());
438        fixer
439            .apply(
440                &Violation::new("missing header").with_path("a.rs"),
441                &make_ctx(&tmp, false),
442            )
443            .unwrap();
444        assert_eq!(
445            std::fs::read_to_string(tmp.path().join("a.rs")).unwrap(),
446            "// Copyright 2026\nfn main() {}\n"
447        );
448    }
449
450    #[test]
451    fn file_prepend_preserves_utf8_bom() {
452        let tmp = TempDir::new().unwrap();
453        // BOM + "hello\n"
454        let mut bytes = b"\xEF\xBB\xBF".to_vec();
455        bytes.extend_from_slice(b"hello\n");
456        std::fs::write(tmp.path().join("x.txt"), &bytes).unwrap();
457        let fixer = FilePrependFixer::new("HEAD\n".into());
458        fixer
459            .apply(
460                &Violation::new("m").with_path("x.txt"),
461                &make_ctx(&tmp, false),
462            )
463            .unwrap();
464        let got = std::fs::read(tmp.path().join("x.txt")).unwrap();
465        assert_eq!(&got[..3], b"\xEF\xBB\xBF");
466        assert_eq!(&got[3..], b"HEAD\nhello\n");
467    }
468
469    #[test]
470    fn file_prepend_dry_run_does_not_touch_disk() {
471        let tmp = TempDir::new().unwrap();
472        std::fs::write(tmp.path().join("a.rs"), "original\n").unwrap();
473        FilePrependFixer::new("HEAD\n".into())
474            .apply(
475                &Violation::new("m").with_path("a.rs"),
476                &make_ctx(&tmp, true),
477            )
478            .unwrap();
479        assert_eq!(
480            std::fs::read_to_string(tmp.path().join("a.rs")).unwrap(),
481            "original\n"
482        );
483    }
484
485    #[test]
486    fn file_prepend_skips_when_violation_has_no_path() {
487        let tmp = TempDir::new().unwrap();
488        let outcome = FilePrependFixer::new("h".into())
489            .apply(&Violation::new("m"), &make_ctx(&tmp, false))
490            .unwrap();
491        assert!(matches!(outcome, FixOutcome::Skipped(_)));
492    }
493
494    #[test]
495    fn file_append_writes_at_end() {
496        let tmp = TempDir::new().unwrap();
497        std::fs::write(tmp.path().join("notes.md"), "# Notes\n").unwrap();
498        let fixer = FileAppendFixer::new("\n## Section\n".into());
499        fixer
500            .apply(
501                &Violation::new("missing section").with_path("notes.md"),
502                &make_ctx(&tmp, false),
503            )
504            .unwrap();
505        assert_eq!(
506            std::fs::read_to_string(tmp.path().join("notes.md")).unwrap(),
507            "# Notes\n\n## Section\n"
508        );
509    }
510
511    #[test]
512    fn file_append_dry_run_leaves_file_unchanged() {
513        let tmp = TempDir::new().unwrap();
514        std::fs::write(tmp.path().join("x.txt"), "orig\n").unwrap();
515        FileAppendFixer::new("extra\n".into())
516            .apply(
517                &Violation::new("m").with_path("x.txt"),
518                &make_ctx(&tmp, true),
519            )
520            .unwrap();
521        assert_eq!(
522            std::fs::read_to_string(tmp.path().join("x.txt")).unwrap(),
523            "orig\n"
524        );
525    }
526
527    #[test]
528    fn file_append_skips_when_violation_has_no_path() {
529        let tmp = TempDir::new().unwrap();
530        let outcome = FileAppendFixer::new("x".into())
531            .apply(&Violation::new("m"), &make_ctx(&tmp, false))
532            .unwrap();
533        assert!(matches!(outcome, FixOutcome::Skipped(_)));
534    }
535
536    #[test]
537    fn file_rename_converts_stem_preserving_extension() {
538        let tmp = TempDir::new().unwrap();
539        std::fs::write(tmp.path().join("FooBar.rs"), "fn main() {}\n").unwrap();
540        FileRenameFixer::new(CaseConvention::Snake)
541            .apply(
542                &Violation::new("case").with_path("FooBar.rs"),
543                &make_ctx(&tmp, false),
544            )
545            .unwrap();
546        assert!(tmp.path().join("foo_bar.rs").exists());
547        assert!(!tmp.path().join("FooBar.rs").exists());
548    }
549
550    #[test]
551    fn file_rename_keeps_file_in_same_directory() {
552        let tmp = TempDir::new().unwrap();
553        std::fs::create_dir(tmp.path().join("src")).unwrap();
554        std::fs::write(tmp.path().join("src/MyModule.rs"), "").unwrap();
555        FileRenameFixer::new(CaseConvention::Snake)
556            .apply(
557                &Violation::new("case").with_path("src/MyModule.rs"),
558                &make_ctx(&tmp, false),
559            )
560            .unwrap();
561        assert!(tmp.path().join("src/my_module.rs").exists());
562    }
563
564    #[test]
565    fn file_rename_skips_when_already_in_target_case() {
566        let tmp = TempDir::new().unwrap();
567        std::fs::write(tmp.path().join("foo_bar.rs"), "").unwrap();
568        let outcome = FileRenameFixer::new(CaseConvention::Snake)
569            .apply(
570                &Violation::new("case").with_path("foo_bar.rs"),
571                &make_ctx(&tmp, false),
572            )
573            .unwrap();
574        match outcome {
575            FixOutcome::Skipped(reason) => assert!(reason.contains("already")),
576            FixOutcome::Applied(_) => panic!("expected Skipped"),
577        }
578    }
579
580    #[test]
581    fn file_rename_skips_on_target_collision() {
582        let tmp = TempDir::new().unwrap();
583        std::fs::write(tmp.path().join("FooBar.rs"), "A").unwrap();
584        std::fs::write(tmp.path().join("foo_bar.rs"), "B").unwrap();
585        let outcome = FileRenameFixer::new(CaseConvention::Snake)
586            .apply(
587                &Violation::new("case").with_path("FooBar.rs"),
588                &make_ctx(&tmp, false),
589            )
590            .unwrap();
591        match outcome {
592            FixOutcome::Skipped(reason) => assert!(reason.contains("already exists")),
593            FixOutcome::Applied(_) => panic!("expected Skipped"),
594        }
595        // Neither file should have been touched.
596        assert_eq!(
597            std::fs::read_to_string(tmp.path().join("FooBar.rs")).unwrap(),
598            "A"
599        );
600        assert_eq!(
601            std::fs::read_to_string(tmp.path().join("foo_bar.rs")).unwrap(),
602            "B"
603        );
604    }
605
606    #[test]
607    fn file_rename_dry_run_does_not_touch_disk() {
608        let tmp = TempDir::new().unwrap();
609        std::fs::write(tmp.path().join("FooBar.rs"), "").unwrap();
610        FileRenameFixer::new(CaseConvention::Snake)
611            .apply(
612                &Violation::new("case").with_path("FooBar.rs"),
613                &make_ctx(&tmp, true),
614            )
615            .unwrap();
616        assert!(tmp.path().join("FooBar.rs").exists());
617        assert!(!tmp.path().join("foo_bar.rs").exists());
618    }
619}