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 = match alint_core::read_for_fix(&abs, path, ctx)? {
159            alint_core::ReadForFix::Bytes(b) => b,
160            alint_core::ReadForFix::Skipped(outcome) => return Ok(outcome),
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        if let Some(skip) = alint_core::check_fix_size(&abs, path, ctx)? {
217            return Ok(skip);
218        }
219        let mut f = std::fs::OpenOptions::new()
220            .append(true)
221            .open(&abs)
222            .map_err(|source| Error::Io {
223                path: abs.clone(),
224                source,
225            })?;
226        f.write_all(self.content.as_bytes())
227            .map_err(|source| Error::Io {
228                path: abs.clone(),
229                source,
230            })?;
231        Ok(FixOutcome::Applied(format!(
232            "appended to {}",
233            path.display()
234        )))
235    }
236}
237
238/// Renames the violating file's stem to a target case convention,
239/// preserving the extension and keeping the file in the same parent
240/// directory. Paired with `filename_case`.
241///
242/// Skips with a clear reason when: the violation has no path, the
243/// target name equals the current name (already conforming), or a
244/// different file already occupies the target name (collision).
245#[derive(Debug)]
246pub struct FileRenameFixer {
247    case: CaseConvention,
248}
249
250impl FileRenameFixer {
251    pub fn new(case: CaseConvention) -> Self {
252        Self { case }
253    }
254}
255
256impl Fixer for FileRenameFixer {
257    fn describe(&self) -> String {
258        format!("rename stems to {}", self.case.display_name())
259    }
260
261    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
262        let Some(path) = &violation.path else {
263            return Ok(FixOutcome::Skipped(
264                "violation did not carry a path".to_string(),
265            ));
266        };
267        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
268            return Ok(FixOutcome::Skipped(format!(
269                "cannot decode filename stem for {}",
270                path.display()
271            )));
272        };
273        let new_stem = self.case.convert(stem);
274        if new_stem == stem {
275            return Ok(FixOutcome::Skipped(format!(
276                "{} already matches target case",
277                path.display()
278            )));
279        }
280        if new_stem.is_empty() {
281            return Ok(FixOutcome::Skipped(format!(
282                "case conversion produced an empty stem for {}",
283                path.display()
284            )));
285        }
286
287        let mut new_basename = new_stem;
288        if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
289            new_basename.push('.');
290            new_basename.push_str(ext);
291        }
292        let new_path: PathBuf = match path.parent() {
293            Some(p) if !p.as_os_str().is_empty() => p.join(&new_basename),
294            _ => PathBuf::from(&new_basename),
295        };
296
297        let abs_from = ctx.root.join(path);
298        let abs_to = ctx.root.join(&new_path);
299        if abs_to.exists() {
300            return Ok(FixOutcome::Skipped(format!(
301                "target {} already exists",
302                new_path.display()
303            )));
304        }
305        if ctx.dry_run {
306            return Ok(FixOutcome::Applied(format!(
307                "would rename {} → {}",
308                path.display(),
309                new_path.display()
310            )));
311        }
312        std::fs::rename(&abs_from, &abs_to).map_err(|source| Error::Io {
313            path: abs_from,
314            source,
315        })?;
316        Ok(FixOutcome::Applied(format!(
317            "renamed {} → {}",
318            path.display(),
319            new_path.display()
320        )))
321    }
322}
323
324/// Strips trailing space/tab on every line of each violating
325/// file. Preserves original line endings (LF stays LF, CRLF
326/// stays CRLF).
327#[derive(Debug)]
328pub struct FileTrimTrailingWhitespaceFixer;
329
330impl Fixer for FileTrimTrailingWhitespaceFixer {
331    fn describe(&self) -> String {
332        "strip trailing whitespace on every line".to_string()
333    }
334
335    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
336        let Some(path) = &violation.path else {
337            return Ok(FixOutcome::Skipped(
338                "violation did not carry a path".to_string(),
339            ));
340        };
341        let abs = ctx.root.join(path);
342        if ctx.dry_run {
343            return Ok(FixOutcome::Applied(format!(
344                "would trim trailing whitespace in {}",
345                path.display()
346            )));
347        }
348        let existing = match alint_core::read_for_fix(&abs, path, ctx)? {
349            alint_core::ReadForFix::Bytes(b) => b,
350            alint_core::ReadForFix::Skipped(outcome) => return Ok(outcome),
351        };
352        let Ok(text) = std::str::from_utf8(&existing) else {
353            return Ok(FixOutcome::Skipped(format!(
354                "{} is not UTF-8; cannot trim",
355                path.display()
356            )));
357        };
358        let trimmed = strip_trailing_whitespace(text);
359        if trimmed.as_bytes() == existing {
360            return Ok(FixOutcome::Skipped(format!(
361                "{} already clean",
362                path.display()
363            )));
364        }
365        std::fs::write(&abs, trimmed.as_bytes()).map_err(|source| Error::Io {
366            path: abs.clone(),
367            source,
368        })?;
369        Ok(FixOutcome::Applied(format!(
370            "trimmed trailing whitespace in {}",
371            path.display()
372        )))
373    }
374}
375
376fn strip_trailing_whitespace(text: &str) -> String {
377    let mut out = String::with_capacity(text.len());
378    let mut first = true;
379    for line in text.split('\n') {
380        if !first {
381            out.push('\n');
382        }
383        first = false;
384        // Preserve CR before the (upcoming) LF so CRLF endings survive.
385        let (body, cr) = match line.strip_suffix('\r') {
386            Some(stripped) => (stripped, "\r"),
387            None => (line, ""),
388        };
389        out.push_str(body.trim_end_matches([' ', '\t']));
390        out.push_str(cr);
391    }
392    out
393}
394
395/// Appends a single `\n` byte when a file has content but
396/// doesn't end with one.
397#[derive(Debug)]
398pub struct FileAppendFinalNewlineFixer;
399
400impl Fixer for FileAppendFinalNewlineFixer {
401    fn describe(&self) -> String {
402        "append final newline when missing".to_string()
403    }
404
405    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
406        let Some(path) = &violation.path else {
407            return Ok(FixOutcome::Skipped(
408                "violation did not carry a path".to_string(),
409            ));
410        };
411        let abs = ctx.root.join(path);
412        if ctx.dry_run {
413            return Ok(FixOutcome::Applied(format!(
414                "would append final newline to {}",
415                path.display()
416            )));
417        }
418        if let Some(skip) = alint_core::check_fix_size(&abs, path, ctx)? {
419            return Ok(skip);
420        }
421        let mut f = std::fs::OpenOptions::new()
422            .append(true)
423            .open(&abs)
424            .map_err(|source| Error::Io {
425                path: abs.clone(),
426                source,
427            })?;
428        f.write_all(b"\n").map_err(|source| Error::Io {
429            path: abs.clone(),
430            source,
431        })?;
432        Ok(FixOutcome::Applied(format!(
433            "appended final newline to {}",
434            path.display()
435        )))
436    }
437}
438
439/// Which line ending [`FileNormalizeLineEndingsFixer`] rewrites to.
440#[derive(Debug, Clone, Copy, PartialEq, Eq)]
441pub enum LineEndingTarget {
442    Lf,
443    Crlf,
444}
445
446impl LineEndingTarget {
447    pub fn name(self) -> &'static str {
448        match self {
449            Self::Lf => "lf",
450            Self::Crlf => "crlf",
451        }
452    }
453
454    fn bytes(self) -> &'static [u8] {
455        match self {
456            Self::Lf => b"\n",
457            Self::Crlf => b"\r\n",
458        }
459    }
460}
461
462/// Rewrites every line ending in a file to the target (`lf` or `crlf`).
463#[derive(Debug)]
464pub struct FileNormalizeLineEndingsFixer {
465    target: LineEndingTarget,
466}
467
468impl FileNormalizeLineEndingsFixer {
469    pub fn new(target: LineEndingTarget) -> Self {
470        Self { target }
471    }
472}
473
474impl Fixer for FileNormalizeLineEndingsFixer {
475    fn describe(&self) -> String {
476        format!("normalize line endings to {}", self.target.name())
477    }
478
479    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
480        let Some(path) = &violation.path else {
481            return Ok(FixOutcome::Skipped(
482                "violation did not carry a path".to_string(),
483            ));
484        };
485        let abs = ctx.root.join(path);
486        if ctx.dry_run {
487            return Ok(FixOutcome::Applied(format!(
488                "would normalize line endings in {} to {}",
489                path.display(),
490                self.target.name()
491            )));
492        }
493        let existing = match alint_core::read_for_fix(&abs, path, ctx)? {
494            alint_core::ReadForFix::Bytes(b) => b,
495            alint_core::ReadForFix::Skipped(outcome) => return Ok(outcome),
496        };
497        let normalized = normalize_line_endings(&existing, self.target);
498        if normalized == existing {
499            return Ok(FixOutcome::Skipped(format!(
500                "{} already {}",
501                path.display(),
502                self.target.name()
503            )));
504        }
505        std::fs::write(&abs, &normalized).map_err(|source| Error::Io {
506            path: abs.clone(),
507            source,
508        })?;
509        Ok(FixOutcome::Applied(format!(
510            "normalized {} to {}",
511            path.display(),
512            self.target.name()
513        )))
514    }
515}
516
517fn normalize_line_endings(bytes: &[u8], target: LineEndingTarget) -> Vec<u8> {
518    let target_bytes = target.bytes();
519    let mut out = Vec::with_capacity(bytes.len());
520    let mut i = 0;
521    while i < bytes.len() {
522        if bytes[i] == b'\n' {
523            // Drop a preceding CR so `\r\n` collapses to `\n` before
524            // we emit the target.
525            if out.last().copied() == Some(b'\r') {
526                out.pop();
527            }
528            out.extend_from_slice(target_bytes);
529        } else {
530            out.push(bytes[i]);
531        }
532        i += 1;
533    }
534    out
535}
536
537/// Strips Unicode bidi control characters (the Trojan Source
538/// codepoints U+202A–202E, U+2066–2069) from the file's content.
539#[derive(Debug)]
540pub struct FileStripBidiFixer;
541
542impl Fixer for FileStripBidiFixer {
543    fn describe(&self) -> String {
544        "strip Unicode bidi control characters".to_string()
545    }
546
547    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
548        apply_char_filter(
549            "bidi",
550            "stripped bidi controls from",
551            violation,
552            ctx,
553            crate::no_bidi_controls::is_bidi_control,
554            /* preserve_leading_feff = */ false,
555        )
556    }
557}
558
559/// Strips zero-width characters (U+200B / U+200C / U+200D, plus
560/// body-internal U+FEFF — a leading BOM is preserved so
561/// `no_bom` can own that concern).
562#[derive(Debug)]
563pub struct FileStripZeroWidthFixer;
564
565impl Fixer for FileStripZeroWidthFixer {
566    fn describe(&self) -> String {
567        "strip zero-width characters (U+200B/C/D, body-internal U+FEFF)".to_string()
568    }
569
570    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
571        apply_char_filter(
572            "zero-width",
573            "stripped zero-width chars from",
574            violation,
575            ctx,
576            |c| matches!(c, '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}'),
577            /* preserve_leading_feff = */ true,
578        )
579    }
580}
581
582/// Strips a leading BOM (UTF-8 / UTF-16 / UTF-32 LE & BE) from
583/// the violating file.
584#[derive(Debug)]
585pub struct FileStripBomFixer;
586
587impl Fixer for FileStripBomFixer {
588    fn describe(&self) -> String {
589        "strip leading BOM".to_string()
590    }
591
592    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
593        let Some(path) = &violation.path else {
594            return Ok(FixOutcome::Skipped(
595                "violation did not carry a path".to_string(),
596            ));
597        };
598        let abs = ctx.root.join(path);
599        if ctx.dry_run {
600            return Ok(FixOutcome::Applied(format!(
601                "would strip BOM from {}",
602                path.display()
603            )));
604        }
605        let existing = match alint_core::read_for_fix(&abs, path, ctx)? {
606            alint_core::ReadForFix::Bytes(b) => b,
607            alint_core::ReadForFix::Skipped(outcome) => return Ok(outcome),
608        };
609        let Some(bom) = crate::no_bom::detect_bom(&existing) else {
610            return Ok(FixOutcome::Skipped(format!(
611                "{} has no BOM",
612                path.display()
613            )));
614        };
615        let stripped = &existing[bom.byte_len()..];
616        std::fs::write(&abs, stripped).map_err(|source| Error::Io {
617            path: abs.clone(),
618            source,
619        })?;
620        Ok(FixOutcome::Applied(format!(
621            "stripped {} BOM from {}",
622            bom.name(),
623            path.display()
624        )))
625    }
626}
627
628/// Shared read-modify-write helper for "remove every char that
629/// matches `predicate`" fix ops.
630fn apply_char_filter(
631    label: &str,
632    verb: &str,
633    violation: &Violation,
634    ctx: &FixContext<'_>,
635    predicate: impl Fn(char) -> bool,
636    preserve_leading_feff: bool,
637) -> Result<FixOutcome> {
638    let Some(path) = &violation.path else {
639        return Ok(FixOutcome::Skipped(
640            "violation did not carry a path".to_string(),
641        ));
642    };
643    let abs = ctx.root.join(path);
644    if ctx.dry_run {
645        return Ok(FixOutcome::Applied(format!(
646            "would strip {label} chars from {}",
647            path.display()
648        )));
649    }
650    let existing = match alint_core::read_for_fix(&abs, path, ctx)? {
651        alint_core::ReadForFix::Bytes(b) => b,
652        alint_core::ReadForFix::Skipped(outcome) => return Ok(outcome),
653    };
654    let Ok(text) = std::str::from_utf8(&existing) else {
655        return Ok(FixOutcome::Skipped(format!(
656            "{} is not UTF-8; cannot filter {label} chars",
657            path.display()
658        )));
659    };
660    let mut out = String::with_capacity(text.len());
661    let mut first_char = true;
662    for c in text.chars() {
663        let keep_because_leading_bom = preserve_leading_feff && first_char && c == '\u{FEFF}';
664        if keep_because_leading_bom || !predicate(c) {
665            out.push(c);
666        }
667        first_char = false;
668    }
669    if out.as_bytes() == existing {
670        return Ok(FixOutcome::Skipped(format!(
671            "{} has no {label} chars to strip",
672            path.display()
673        )));
674    }
675    std::fs::write(&abs, out.as_bytes()).map_err(|source| Error::Io {
676        path: abs.clone(),
677        source,
678    })?;
679    Ok(FixOutcome::Applied(format!("{verb} {}", path.display())))
680}
681
682/// Collapses runs of blank lines longer than `max` down to exactly
683/// `max` blank lines. A blank line is one whose content between
684/// line endings is empty or only spaces/tabs. Preserves the file's
685/// line endings (LF vs. CRLF) by operating on byte-level newlines.
686#[derive(Debug)]
687pub struct FileCollapseBlankLinesFixer {
688    max: u32,
689}
690
691impl FileCollapseBlankLinesFixer {
692    pub fn new(max: u32) -> Self {
693        Self { max }
694    }
695}
696
697impl Fixer for FileCollapseBlankLinesFixer {
698    fn describe(&self) -> String {
699        format!("collapse runs of blank lines to at most {}", self.max)
700    }
701
702    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
703        let Some(path) = &violation.path else {
704            return Ok(FixOutcome::Skipped(
705                "violation did not carry a path".to_string(),
706            ));
707        };
708        let abs = ctx.root.join(path);
709        if ctx.dry_run {
710            return Ok(FixOutcome::Applied(format!(
711                "would collapse blank lines in {} to at most {}",
712                path.display(),
713                self.max,
714            )));
715        }
716        let existing = match alint_core::read_for_fix(&abs, path, ctx)? {
717            alint_core::ReadForFix::Bytes(b) => b,
718            alint_core::ReadForFix::Skipped(outcome) => return Ok(outcome),
719        };
720        let Ok(text) = std::str::from_utf8(&existing) else {
721            return Ok(FixOutcome::Skipped(format!(
722                "{} is not UTF-8; cannot collapse",
723                path.display()
724            )));
725        };
726        let collapsed = collapse_blank_lines(text, self.max);
727        if collapsed.as_bytes() == existing {
728            return Ok(FixOutcome::Skipped(format!(
729                "{} already clean",
730                path.display()
731            )));
732        }
733        std::fs::write(&abs, collapsed.as_bytes()).map_err(|source| Error::Io {
734            path: abs.clone(),
735            source,
736        })?;
737        Ok(FixOutcome::Applied(format!(
738            "collapsed blank-line runs in {} to at most {}",
739            path.display(),
740            self.max,
741        )))
742    }
743}
744
745/// A "blank" line has content consisting only of spaces or tabs.
746pub(crate) fn line_is_blank(body: &str) -> bool {
747    body.bytes().all(|b| b == b' ' || b == b'\t')
748}
749
750/// Walk the file in (body, ending) pairs so the final slot after the
751/// last newline doesn't get double-counted as an extra blank line.
752/// Preserves CRLF vs LF verbatim.
753pub(crate) fn collapse_blank_lines(text: &str, max: u32) -> String {
754    let mut out = String::with_capacity(text.len());
755    let mut blank_run: u32 = 0;
756    let mut remaining = text;
757    loop {
758        let (body, ending, rest) = match remaining.find('\n') {
759            Some(i) => {
760                let before = &remaining[..i];
761                let (body, cr) = match before.strip_suffix('\r') {
762                    Some(s) => (s, "\r\n"),
763                    None => (before, "\n"),
764                };
765                (body, cr, &remaining[i + 1..])
766            }
767            None => (remaining, "", ""),
768        };
769        let blank = line_is_blank(body);
770        if blank {
771            blank_run += 1;
772            if blank_run > max {
773                if ending.is_empty() {
774                    break;
775                }
776                remaining = rest;
777                continue;
778            }
779        } else {
780            blank_run = 0;
781        }
782        out.push_str(body);
783        out.push_str(ending);
784        if ending.is_empty() {
785            break;
786        }
787        remaining = rest;
788    }
789    out
790}
791
792#[cfg(test)]
793mod tests {
794    use super::*;
795    use tempfile::TempDir;
796
797    fn make_ctx(tmp: &TempDir, dry_run: bool) -> FixContext<'_> {
798        FixContext {
799            root: tmp.path(),
800            dry_run,
801            fix_size_limit: None,
802        }
803    }
804
805    #[test]
806    fn file_create_writes_content_when_missing() {
807        let tmp = TempDir::new().unwrap();
808        let fixer = FileCreateFixer::new(PathBuf::from("LICENSE"), "Apache-2.0\n".into(), true);
809        let outcome = fixer
810            .apply(&Violation::new("missing LICENSE"), &make_ctx(&tmp, false))
811            .unwrap();
812        assert!(matches!(outcome, FixOutcome::Applied(_)));
813        let written = std::fs::read_to_string(tmp.path().join("LICENSE")).unwrap();
814        assert_eq!(written, "Apache-2.0\n");
815    }
816
817    #[test]
818    fn file_create_creates_intermediate_directories() {
819        let tmp = TempDir::new().unwrap();
820        let fixer = FileCreateFixer::new(PathBuf::from("a/b/c/config.yaml"), "k: v\n".into(), true);
821        fixer
822            .apply(&Violation::new("missing"), &make_ctx(&tmp, false))
823            .unwrap();
824        assert!(tmp.path().join("a/b/c/config.yaml").exists());
825    }
826
827    #[test]
828    fn file_create_skips_when_target_exists() {
829        let tmp = TempDir::new().unwrap();
830        std::fs::write(tmp.path().join("README.md"), "existing\n").unwrap();
831        let fixer = FileCreateFixer::new(PathBuf::from("README.md"), "NEW\n".into(), true);
832        let outcome = fixer
833            .apply(&Violation::new("x"), &make_ctx(&tmp, false))
834            .unwrap();
835        match outcome {
836            FixOutcome::Skipped(reason) => assert!(reason.contains("already exists")),
837            FixOutcome::Applied(_) => panic!("expected Skipped"),
838        }
839        assert_eq!(
840            std::fs::read_to_string(tmp.path().join("README.md")).unwrap(),
841            "existing\n",
842            "pre-existing content must not be overwritten"
843        );
844    }
845
846    #[test]
847    fn file_create_dry_run_does_not_touch_disk() {
848        let tmp = TempDir::new().unwrap();
849        let fixer = FileCreateFixer::new(PathBuf::from("x.txt"), "body".into(), true);
850        let outcome = fixer
851            .apply(&Violation::new("x"), &make_ctx(&tmp, true))
852            .unwrap();
853        match outcome {
854            FixOutcome::Applied(s) => assert!(s.starts_with("would create")),
855            FixOutcome::Skipped(_) => panic!("expected Applied"),
856        }
857        assert!(!tmp.path().join("x.txt").exists());
858    }
859
860    #[test]
861    fn file_remove_deletes_violating_path() {
862        let tmp = TempDir::new().unwrap();
863        let target = tmp.path().join("debug.log");
864        std::fs::write(&target, "noise").unwrap();
865        let outcome = FileRemoveFixer
866            .apply(
867                &Violation::new("forbidden").with_path("debug.log"),
868                &make_ctx(&tmp, false),
869            )
870            .unwrap();
871        assert!(matches!(outcome, FixOutcome::Applied(_)));
872        assert!(!target.exists());
873    }
874
875    #[test]
876    fn file_remove_skips_when_violation_has_no_path() {
877        let tmp = TempDir::new().unwrap();
878        let outcome = FileRemoveFixer
879            .apply(&Violation::new("no path"), &make_ctx(&tmp, false))
880            .unwrap();
881        match outcome {
882            FixOutcome::Skipped(reason) => assert!(reason.contains("path")),
883            FixOutcome::Applied(_) => panic!("expected Skipped"),
884        }
885    }
886
887    #[test]
888    fn file_remove_dry_run_keeps_the_file() {
889        let tmp = TempDir::new().unwrap();
890        let target = tmp.path().join("victim.bak");
891        std::fs::write(&target, "bytes").unwrap();
892        let outcome = FileRemoveFixer
893            .apply(
894                &Violation::new("forbidden").with_path("victim.bak"),
895                &make_ctx(&tmp, true),
896            )
897            .unwrap();
898        match outcome {
899            FixOutcome::Applied(s) => assert!(s.starts_with("would remove")),
900            FixOutcome::Skipped(_) => panic!("expected Applied"),
901        }
902        assert!(target.exists());
903    }
904
905    #[test]
906    fn file_prepend_inserts_at_start() {
907        let tmp = TempDir::new().unwrap();
908        std::fs::write(tmp.path().join("a.rs"), "fn main() {}\n").unwrap();
909        let fixer = FilePrependFixer::new("// Copyright 2026\n".into());
910        fixer
911            .apply(
912                &Violation::new("missing header").with_path("a.rs"),
913                &make_ctx(&tmp, false),
914            )
915            .unwrap();
916        assert_eq!(
917            std::fs::read_to_string(tmp.path().join("a.rs")).unwrap(),
918            "// Copyright 2026\nfn main() {}\n"
919        );
920    }
921
922    #[test]
923    fn file_prepend_preserves_utf8_bom() {
924        let tmp = TempDir::new().unwrap();
925        // BOM + "hello\n"
926        let mut bytes = b"\xEF\xBB\xBF".to_vec();
927        bytes.extend_from_slice(b"hello\n");
928        std::fs::write(tmp.path().join("x.txt"), &bytes).unwrap();
929        let fixer = FilePrependFixer::new("HEAD\n".into());
930        fixer
931            .apply(
932                &Violation::new("m").with_path("x.txt"),
933                &make_ctx(&tmp, false),
934            )
935            .unwrap();
936        let got = std::fs::read(tmp.path().join("x.txt")).unwrap();
937        assert_eq!(&got[..3], b"\xEF\xBB\xBF");
938        assert_eq!(&got[3..], b"HEAD\nhello\n");
939    }
940
941    #[test]
942    fn file_prepend_dry_run_does_not_touch_disk() {
943        let tmp = TempDir::new().unwrap();
944        std::fs::write(tmp.path().join("a.rs"), "original\n").unwrap();
945        FilePrependFixer::new("HEAD\n".into())
946            .apply(
947                &Violation::new("m").with_path("a.rs"),
948                &make_ctx(&tmp, true),
949            )
950            .unwrap();
951        assert_eq!(
952            std::fs::read_to_string(tmp.path().join("a.rs")).unwrap(),
953            "original\n"
954        );
955    }
956
957    #[test]
958    fn file_prepend_skips_when_violation_has_no_path() {
959        let tmp = TempDir::new().unwrap();
960        let outcome = FilePrependFixer::new("h".into())
961            .apply(&Violation::new("m"), &make_ctx(&tmp, false))
962            .unwrap();
963        assert!(matches!(outcome, FixOutcome::Skipped(_)));
964    }
965
966    #[test]
967    fn file_append_writes_at_end() {
968        let tmp = TempDir::new().unwrap();
969        std::fs::write(tmp.path().join("notes.md"), "# Notes\n").unwrap();
970        let fixer = FileAppendFixer::new("\n## Section\n".into());
971        fixer
972            .apply(
973                &Violation::new("missing section").with_path("notes.md"),
974                &make_ctx(&tmp, false),
975            )
976            .unwrap();
977        assert_eq!(
978            std::fs::read_to_string(tmp.path().join("notes.md")).unwrap(),
979            "# Notes\n\n## Section\n"
980        );
981    }
982
983    #[test]
984    fn file_append_dry_run_leaves_file_unchanged() {
985        let tmp = TempDir::new().unwrap();
986        std::fs::write(tmp.path().join("x.txt"), "orig\n").unwrap();
987        FileAppendFixer::new("extra\n".into())
988            .apply(
989                &Violation::new("m").with_path("x.txt"),
990                &make_ctx(&tmp, true),
991            )
992            .unwrap();
993        assert_eq!(
994            std::fs::read_to_string(tmp.path().join("x.txt")).unwrap(),
995            "orig\n"
996        );
997    }
998
999    #[test]
1000    fn file_append_skips_when_violation_has_no_path() {
1001        let tmp = TempDir::new().unwrap();
1002        let outcome = FileAppendFixer::new("x".into())
1003            .apply(&Violation::new("m"), &make_ctx(&tmp, false))
1004            .unwrap();
1005        assert!(matches!(outcome, FixOutcome::Skipped(_)));
1006    }
1007
1008    #[test]
1009    fn file_rename_converts_stem_preserving_extension() {
1010        let tmp = TempDir::new().unwrap();
1011        std::fs::write(tmp.path().join("FooBar.rs"), "fn main() {}\n").unwrap();
1012        FileRenameFixer::new(CaseConvention::Snake)
1013            .apply(
1014                &Violation::new("case").with_path("FooBar.rs"),
1015                &make_ctx(&tmp, false),
1016            )
1017            .unwrap();
1018        assert!(tmp.path().join("foo_bar.rs").exists());
1019        assert!(!tmp.path().join("FooBar.rs").exists());
1020    }
1021
1022    #[test]
1023    fn file_rename_keeps_file_in_same_directory() {
1024        let tmp = TempDir::new().unwrap();
1025        std::fs::create_dir(tmp.path().join("src")).unwrap();
1026        std::fs::write(tmp.path().join("src/MyModule.rs"), "").unwrap();
1027        FileRenameFixer::new(CaseConvention::Snake)
1028            .apply(
1029                &Violation::new("case").with_path("src/MyModule.rs"),
1030                &make_ctx(&tmp, false),
1031            )
1032            .unwrap();
1033        assert!(tmp.path().join("src/my_module.rs").exists());
1034    }
1035
1036    #[test]
1037    fn file_rename_skips_when_already_in_target_case() {
1038        let tmp = TempDir::new().unwrap();
1039        std::fs::write(tmp.path().join("foo_bar.rs"), "").unwrap();
1040        let outcome = FileRenameFixer::new(CaseConvention::Snake)
1041            .apply(
1042                &Violation::new("case").with_path("foo_bar.rs"),
1043                &make_ctx(&tmp, false),
1044            )
1045            .unwrap();
1046        match outcome {
1047            FixOutcome::Skipped(reason) => assert!(reason.contains("already")),
1048            FixOutcome::Applied(_) => panic!("expected Skipped"),
1049        }
1050    }
1051
1052    #[test]
1053    fn file_rename_skips_on_target_collision() {
1054        let tmp = TempDir::new().unwrap();
1055        std::fs::write(tmp.path().join("FooBar.rs"), "A").unwrap();
1056        std::fs::write(tmp.path().join("foo_bar.rs"), "B").unwrap();
1057        let outcome = FileRenameFixer::new(CaseConvention::Snake)
1058            .apply(
1059                &Violation::new("case").with_path("FooBar.rs"),
1060                &make_ctx(&tmp, false),
1061            )
1062            .unwrap();
1063        match outcome {
1064            FixOutcome::Skipped(reason) => assert!(reason.contains("already exists")),
1065            FixOutcome::Applied(_) => panic!("expected Skipped"),
1066        }
1067        // Neither file should have been touched.
1068        assert_eq!(
1069            std::fs::read_to_string(tmp.path().join("FooBar.rs")).unwrap(),
1070            "A"
1071        );
1072        assert_eq!(
1073            std::fs::read_to_string(tmp.path().join("foo_bar.rs")).unwrap(),
1074            "B"
1075        );
1076    }
1077
1078    #[test]
1079    fn file_rename_dry_run_does_not_touch_disk() {
1080        let tmp = TempDir::new().unwrap();
1081        std::fs::write(tmp.path().join("FooBar.rs"), "").unwrap();
1082        FileRenameFixer::new(CaseConvention::Snake)
1083            .apply(
1084                &Violation::new("case").with_path("FooBar.rs"),
1085                &make_ctx(&tmp, true),
1086            )
1087            .unwrap();
1088        assert!(tmp.path().join("FooBar.rs").exists());
1089        assert!(!tmp.path().join("foo_bar.rs").exists());
1090    }
1091
1092    // ── text-hygiene fixers ────────────────────────────────────
1093
1094    #[test]
1095    fn strip_trailing_whitespace_preserves_lf_and_crlf() {
1096        assert_eq!(strip_trailing_whitespace("a  \nb\t\n"), "a\nb\n");
1097        assert_eq!(strip_trailing_whitespace("a  \r\nb\t\r\n"), "a\r\nb\r\n");
1098    }
1099
1100    #[test]
1101    fn file_trim_trailing_whitespace_rewrites_in_place() {
1102        let tmp = TempDir::new().unwrap();
1103        std::fs::write(tmp.path().join("x.rs"), "let _ = 1;   \n").unwrap();
1104        let outcome = FileTrimTrailingWhitespaceFixer
1105            .apply(
1106                &Violation::new("ws").with_path("x.rs"),
1107                &make_ctx(&tmp, false),
1108            )
1109            .unwrap();
1110        assert!(matches!(outcome, FixOutcome::Applied(_)));
1111        assert_eq!(
1112            std::fs::read_to_string(tmp.path().join("x.rs")).unwrap(),
1113            "let _ = 1;\n"
1114        );
1115    }
1116
1117    #[test]
1118    fn file_trim_trailing_whitespace_honors_size_limit() {
1119        let tmp = TempDir::new().unwrap();
1120        let big = "x   \n".repeat(2_000);
1121        std::fs::write(tmp.path().join("big.txt"), &big).unwrap();
1122        let ctx = FixContext {
1123            root: tmp.path(),
1124            dry_run: false,
1125            fix_size_limit: Some(100),
1126        };
1127        let outcome = FileTrimTrailingWhitespaceFixer
1128            .apply(&Violation::new("ws").with_path("big.txt"), &ctx)
1129            .unwrap();
1130        match outcome {
1131            FixOutcome::Skipped(reason) => {
1132                assert!(reason.contains("fix_size_limit"), "{reason}");
1133            }
1134            FixOutcome::Applied(_) => panic!("expected Skipped on oversized file"),
1135        }
1136        // Disk unchanged.
1137        assert_eq!(
1138            std::fs::read_to_string(tmp.path().join("big.txt")).unwrap(),
1139            big
1140        );
1141    }
1142
1143    #[test]
1144    fn file_append_final_newline_adds_missing_newline() {
1145        let tmp = TempDir::new().unwrap();
1146        std::fs::write(tmp.path().join("x.txt"), "hello").unwrap();
1147        FileAppendFinalNewlineFixer
1148            .apply(
1149                &Violation::new("eof").with_path("x.txt"),
1150                &make_ctx(&tmp, false),
1151            )
1152            .unwrap();
1153        assert_eq!(
1154            std::fs::read_to_string(tmp.path().join("x.txt")).unwrap(),
1155            "hello\n"
1156        );
1157    }
1158
1159    #[test]
1160    fn normalize_line_endings_lf_target() {
1161        let mixed = b"a\r\nb\nc\r\nd".to_vec();
1162        let out = normalize_line_endings(&mixed, LineEndingTarget::Lf);
1163        assert_eq!(out, b"a\nb\nc\nd");
1164    }
1165
1166    #[test]
1167    fn normalize_line_endings_crlf_target() {
1168        let mixed = b"a\r\nb\nc\r\nd".to_vec();
1169        let out = normalize_line_endings(&mixed, LineEndingTarget::Crlf);
1170        assert_eq!(out, b"a\r\nb\r\nc\r\nd");
1171    }
1172
1173    #[test]
1174    fn file_normalize_line_endings_rewrites_to_lf() {
1175        let tmp = TempDir::new().unwrap();
1176        std::fs::write(tmp.path().join("a.md"), "one\r\ntwo\r\n").unwrap();
1177        FileNormalizeLineEndingsFixer::new(LineEndingTarget::Lf)
1178            .apply(
1179                &Violation::new("le").with_path("a.md"),
1180                &make_ctx(&tmp, false),
1181            )
1182            .unwrap();
1183        assert_eq!(
1184            std::fs::read_to_string(tmp.path().join("a.md")).unwrap(),
1185            "one\ntwo\n"
1186        );
1187    }
1188
1189    #[test]
1190    fn collapse_blank_lines_keeps_up_to_max() {
1191        assert_eq!(collapse_blank_lines("a\n\n\nb\n", 1), "a\n\nb\n");
1192        assert_eq!(collapse_blank_lines("a\n\n\n\nb\n", 2), "a\n\n\nb\n");
1193        assert_eq!(collapse_blank_lines("a\nb\n", 1), "a\nb\n");
1194    }
1195
1196    #[test]
1197    fn collapse_blank_lines_preserves_trailing_newline() {
1198        // One existing blank line, max=1 → file must still end with "\n\n"
1199        // (i.e. the blank line plus the EOF newline).
1200        assert_eq!(collapse_blank_lines("a\n\n", 1), "a\n\n");
1201    }
1202
1203    #[test]
1204    fn collapse_blank_lines_max_zero_drops_all_blanks() {
1205        assert_eq!(collapse_blank_lines("a\n\n\nb\n", 0), "a\nb\n");
1206        assert_eq!(collapse_blank_lines("\n", 0), "");
1207        assert_eq!(collapse_blank_lines("a\n\n", 0), "a\n");
1208    }
1209
1210    #[test]
1211    fn collapse_blank_lines_preserves_crlf() {
1212        assert_eq!(
1213            collapse_blank_lines("a\r\n\r\n\r\n\r\nb\r\n", 1),
1214            "a\r\n\r\nb\r\n"
1215        );
1216    }
1217
1218    #[test]
1219    fn collapse_blank_lines_treats_whitespace_only_as_blank() {
1220        // Lines with only spaces/tabs count as blank, and dropped
1221        // copies disappear entirely (their whitespace goes too).
1222        assert_eq!(collapse_blank_lines("a\n  \n\t\n\nb\n", 1), "a\n  \nb\n");
1223    }
1224
1225    #[test]
1226    fn collapse_blank_lines_no_op_on_empty_file() {
1227        assert_eq!(collapse_blank_lines("", 2), "");
1228    }
1229}