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::{ContentSourceSpec, 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). Content is either inline
22/// or read at apply time from a path-relative-to-root.
23#[derive(Debug)]
24pub struct FileCreateFixer {
25    path: PathBuf,
26    source: ContentSourceSpec,
27    create_parents: bool,
28}
29
30impl FileCreateFixer {
31    pub fn new(path: PathBuf, source: ContentSourceSpec, create_parents: bool) -> Self {
32        Self {
33            path,
34            source,
35            create_parents,
36        }
37    }
38}
39
40impl Fixer for FileCreateFixer {
41    fn describe(&self) -> String {
42        match &self.source {
43            ContentSourceSpec::Inline(s) => format!(
44                "create {} ({} byte{})",
45                self.path.display(),
46                s.len(),
47                if s.len() == 1 { "" } else { "s" }
48            ),
49            ContentSourceSpec::File(rel) => format!(
50                "create {} (content from {})",
51                self.path.display(),
52                rel.display()
53            ),
54        }
55    }
56
57    fn apply(&self, _violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
58        let abs = ctx.root.join(&self.path);
59        if abs.exists() {
60            return Ok(FixOutcome::Skipped(format!(
61                "{} already exists",
62                self.path.display()
63            )));
64        }
65        let content = match resolve_source_bytes(&self.source, ctx.root) {
66            Ok(bytes) => bytes,
67            Err(skip_msg) => return Ok(FixOutcome::Skipped(skip_msg)),
68        };
69        if ctx.dry_run {
70            return Ok(FixOutcome::Applied(format!(
71                "would create {}",
72                self.path.display()
73            )));
74        }
75        if self.create_parents
76            && let Some(parent) = abs.parent()
77        {
78            std::fs::create_dir_all(parent).map_err(|source| Error::Io {
79                path: parent.to_path_buf(),
80                source,
81            })?;
82        }
83        std::fs::write(&abs, &content).map_err(|source| Error::Io {
84            path: abs.clone(),
85            source,
86        })?;
87        Ok(FixOutcome::Applied(format!(
88            "created {}",
89            self.path.display()
90        )))
91    }
92}
93
94/// Read a `ContentSourceSpec` to bytes. Returns the raw payload
95/// for inline content; for file-sourced content, reads the file
96/// at apply time, resolving its path relative to `ctx_root`. A
97/// missing or unreadable source produces a `Skipped`-friendly
98/// `Err(String)` so the caller can degrade gracefully rather
99/// than abort the whole fix run.
100fn resolve_source_bytes(
101    source: &ContentSourceSpec,
102    ctx_root: &std::path::Path,
103) -> std::result::Result<Vec<u8>, String> {
104    match source {
105        ContentSourceSpec::Inline(s) => Ok(s.as_bytes().to_vec()),
106        ContentSourceSpec::File(rel) => {
107            let abs = ctx_root.join(rel);
108            std::fs::read(&abs)
109                .map_err(|e| format!("content_from `{}` could not be read: {e}", rel.display()))
110        }
111    }
112}
113
114/// Removes the file named by the violation's `path`. Used by
115/// `file_absent` to purge committed files that shouldn't be there.
116#[derive(Debug)]
117pub struct FileRemoveFixer;
118
119impl Fixer for FileRemoveFixer {
120    fn describe(&self) -> String {
121        "remove the violating file".to_string()
122    }
123
124    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
125        let Some(path) = &violation.path else {
126            return Ok(FixOutcome::Skipped(
127                "violation did not carry a path".to_string(),
128            ));
129        };
130        let abs = ctx.root.join(path);
131        if !abs.exists() {
132            return Ok(FixOutcome::Skipped(format!(
133                "{} does not exist",
134                path.display()
135            )));
136        }
137        if ctx.dry_run {
138            return Ok(FixOutcome::Applied(format!(
139                "would remove {}",
140                path.display()
141            )));
142        }
143        std::fs::remove_file(&abs).map_err(|source| Error::Io {
144            path: abs.clone(),
145            source,
146        })?;
147        Ok(FixOutcome::Applied(format!("removed {}", path.display())))
148    }
149}
150
151/// Prepends `source` content to the start of each violating
152/// file. Paired with `file_header` to inject a required header
153/// comment / boilerplate.
154///
155/// If the file starts with a UTF-8 BOM, the prepended bytes go
156/// *after* the BOM so editors that rely on it don't break.
157#[derive(Debug)]
158pub struct FilePrependFixer {
159    source: ContentSourceSpec,
160}
161
162impl FilePrependFixer {
163    pub fn new(source: ContentSourceSpec) -> Self {
164        Self { source }
165    }
166}
167
168impl Fixer for FilePrependFixer {
169    fn describe(&self) -> String {
170        match &self.source {
171            ContentSourceSpec::Inline(s) => format!(
172                "prepend {} byte{} to each violating file",
173                s.len(),
174                if s.len() == 1 { "" } else { "s" }
175            ),
176            ContentSourceSpec::File(rel) => {
177                format!(
178                    "prepend content from {} to each violating file",
179                    rel.display()
180                )
181            }
182        }
183    }
184
185    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
186        let Some(path) = &violation.path else {
187            return Ok(FixOutcome::Skipped(
188                "violation did not carry a path".to_string(),
189            ));
190        };
191        let abs = ctx.root.join(path);
192        let prepend = match resolve_source_bytes(&self.source, ctx.root) {
193            Ok(b) => b,
194            Err(skip_msg) => return Ok(FixOutcome::Skipped(skip_msg)),
195        };
196        if ctx.dry_run {
197            return Ok(FixOutcome::Applied(format!(
198                "would prepend {} byte(s) to {}",
199                prepend.len(),
200                path.display()
201            )));
202        }
203        let existing = match alint_core::read_for_fix(&abs, path, ctx)? {
204            alint_core::ReadForFix::Bytes(b) => b,
205            alint_core::ReadForFix::Skipped(outcome) => return Ok(outcome),
206        };
207        let mut out = Vec::with_capacity(existing.len() + prepend.len());
208        if existing.starts_with(UTF8_BOM) {
209            out.extend_from_slice(UTF8_BOM);
210            out.extend_from_slice(&prepend);
211            out.extend_from_slice(&existing[UTF8_BOM.len()..]);
212        } else {
213            out.extend_from_slice(&prepend);
214            out.extend_from_slice(&existing);
215        }
216        std::fs::write(&abs, &out).map_err(|source| Error::Io {
217            path: abs.clone(),
218            source,
219        })?;
220        Ok(FixOutcome::Applied(format!("prepended {}", path.display())))
221    }
222}
223
224/// Appends `source` content to the end of each violating file.
225/// Paired with `file_content_matches` / `file_footer` when the
226/// required content is satisfied by the appended bytes.
227#[derive(Debug)]
228pub struct FileAppendFixer {
229    source: ContentSourceSpec,
230}
231
232impl FileAppendFixer {
233    pub fn new(source: ContentSourceSpec) -> Self {
234        Self { source }
235    }
236}
237
238impl Fixer for FileAppendFixer {
239    fn describe(&self) -> String {
240        match &self.source {
241            ContentSourceSpec::Inline(s) => format!(
242                "append {} byte{} to each violating file",
243                s.len(),
244                if s.len() == 1 { "" } else { "s" }
245            ),
246            ContentSourceSpec::File(rel) => {
247                format!(
248                    "append content from {} to each violating file",
249                    rel.display()
250                )
251            }
252        }
253    }
254
255    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
256        let Some(path) = &violation.path else {
257            return Ok(FixOutcome::Skipped(
258                "violation did not carry a path".to_string(),
259            ));
260        };
261        let abs = ctx.root.join(path);
262        let payload = match resolve_source_bytes(&self.source, ctx.root) {
263            Ok(b) => b,
264            Err(skip_msg) => return Ok(FixOutcome::Skipped(skip_msg)),
265        };
266        if ctx.dry_run {
267            return Ok(FixOutcome::Applied(format!(
268                "would append {} byte(s) to {}",
269                payload.len(),
270                path.display()
271            )));
272        }
273        if let Some(skip) = alint_core::check_fix_size(&abs, path, ctx)? {
274            return Ok(skip);
275        }
276        let mut f = std::fs::OpenOptions::new()
277            .append(true)
278            .open(&abs)
279            .map_err(|source| Error::Io {
280                path: abs.clone(),
281                source,
282            })?;
283        f.write_all(&payload).map_err(|source| Error::Io {
284            path: abs.clone(),
285            source,
286        })?;
287        Ok(FixOutcome::Applied(format!(
288            "appended to {}",
289            path.display()
290        )))
291    }
292}
293
294/// Renames the violating file's stem to a target case convention,
295/// preserving the extension and keeping the file in the same parent
296/// directory. Paired with `filename_case`.
297///
298/// Skips with a clear reason when: the violation has no path, the
299/// target name equals the current name (already conforming), or a
300/// different file already occupies the target name (collision).
301#[derive(Debug)]
302pub struct FileRenameFixer {
303    case: CaseConvention,
304}
305
306impl FileRenameFixer {
307    pub fn new(case: CaseConvention) -> Self {
308        Self { case }
309    }
310}
311
312impl Fixer for FileRenameFixer {
313    fn describe(&self) -> String {
314        format!("rename stems to {}", self.case.display_name())
315    }
316
317    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
318        let Some(path) = &violation.path else {
319            return Ok(FixOutcome::Skipped(
320                "violation did not carry a path".to_string(),
321            ));
322        };
323        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
324            return Ok(FixOutcome::Skipped(format!(
325                "cannot decode filename stem for {}",
326                path.display()
327            )));
328        };
329        let new_stem = self.case.convert(stem);
330        if new_stem == stem {
331            return Ok(FixOutcome::Skipped(format!(
332                "{} already matches target case",
333                path.display()
334            )));
335        }
336        if new_stem.is_empty() {
337            return Ok(FixOutcome::Skipped(format!(
338                "case conversion produced an empty stem for {}",
339                path.display()
340            )));
341        }
342
343        let mut new_basename = new_stem;
344        if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
345            new_basename.push('.');
346            new_basename.push_str(ext);
347        }
348        let new_path: PathBuf = match path.parent() {
349            Some(p) if !p.as_os_str().is_empty() => p.join(&new_basename),
350            _ => PathBuf::from(&new_basename),
351        };
352
353        let abs_from = ctx.root.join(path);
354        let abs_to = ctx.root.join(&new_path);
355        if abs_to.exists() {
356            return Ok(FixOutcome::Skipped(format!(
357                "target {} already exists",
358                new_path.display()
359            )));
360        }
361        if ctx.dry_run {
362            return Ok(FixOutcome::Applied(format!(
363                "would rename {} → {}",
364                path.display(),
365                new_path.display()
366            )));
367        }
368        std::fs::rename(&abs_from, &abs_to).map_err(|source| Error::Io {
369            path: abs_from,
370            source,
371        })?;
372        Ok(FixOutcome::Applied(format!(
373            "renamed {} → {}",
374            path.display(),
375            new_path.display()
376        )))
377    }
378}
379
380/// Strips trailing space/tab on every line of each violating
381/// file. Preserves original line endings (LF stays LF, CRLF
382/// stays CRLF).
383#[derive(Debug)]
384pub struct FileTrimTrailingWhitespaceFixer;
385
386impl Fixer for FileTrimTrailingWhitespaceFixer {
387    fn describe(&self) -> String {
388        "strip trailing whitespace on every line".to_string()
389    }
390
391    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
392        let Some(path) = &violation.path else {
393            return Ok(FixOutcome::Skipped(
394                "violation did not carry a path".to_string(),
395            ));
396        };
397        let abs = ctx.root.join(path);
398        if ctx.dry_run {
399            return Ok(FixOutcome::Applied(format!(
400                "would trim trailing whitespace in {}",
401                path.display()
402            )));
403        }
404        let existing = match alint_core::read_for_fix(&abs, path, ctx)? {
405            alint_core::ReadForFix::Bytes(b) => b,
406            alint_core::ReadForFix::Skipped(outcome) => return Ok(outcome),
407        };
408        let Ok(text) = std::str::from_utf8(&existing) else {
409            return Ok(FixOutcome::Skipped(format!(
410                "{} is not UTF-8; cannot trim",
411                path.display()
412            )));
413        };
414        let trimmed = strip_trailing_whitespace(text);
415        if trimmed.as_bytes() == existing {
416            return Ok(FixOutcome::Skipped(format!(
417                "{} already clean",
418                path.display()
419            )));
420        }
421        std::fs::write(&abs, trimmed.as_bytes()).map_err(|source| Error::Io {
422            path: abs.clone(),
423            source,
424        })?;
425        Ok(FixOutcome::Applied(format!(
426            "trimmed trailing whitespace in {}",
427            path.display()
428        )))
429    }
430}
431
432fn strip_trailing_whitespace(text: &str) -> String {
433    let mut out = String::with_capacity(text.len());
434    let mut first = true;
435    for line in text.split('\n') {
436        if !first {
437            out.push('\n');
438        }
439        first = false;
440        // Preserve CR before the (upcoming) LF so CRLF endings survive.
441        let (body, cr) = match line.strip_suffix('\r') {
442            Some(stripped) => (stripped, "\r"),
443            None => (line, ""),
444        };
445        out.push_str(body.trim_end_matches([' ', '\t']));
446        out.push_str(cr);
447    }
448    out
449}
450
451/// Appends a single `\n` byte when a file has content but
452/// doesn't end with one.
453#[derive(Debug)]
454pub struct FileAppendFinalNewlineFixer;
455
456impl Fixer for FileAppendFinalNewlineFixer {
457    fn describe(&self) -> String {
458        "append final newline when missing".to_string()
459    }
460
461    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
462        let Some(path) = &violation.path else {
463            return Ok(FixOutcome::Skipped(
464                "violation did not carry a path".to_string(),
465            ));
466        };
467        let abs = ctx.root.join(path);
468        if ctx.dry_run {
469            return Ok(FixOutcome::Applied(format!(
470                "would append final newline to {}",
471                path.display()
472            )));
473        }
474        if let Some(skip) = alint_core::check_fix_size(&abs, path, ctx)? {
475            return Ok(skip);
476        }
477        let mut f = std::fs::OpenOptions::new()
478            .append(true)
479            .open(&abs)
480            .map_err(|source| Error::Io {
481                path: abs.clone(),
482                source,
483            })?;
484        f.write_all(b"\n").map_err(|source| Error::Io {
485            path: abs.clone(),
486            source,
487        })?;
488        Ok(FixOutcome::Applied(format!(
489            "appended final newline to {}",
490            path.display()
491        )))
492    }
493}
494
495/// Which line ending [`FileNormalizeLineEndingsFixer`] rewrites to.
496#[derive(Debug, Clone, Copy, PartialEq, Eq)]
497pub enum LineEndingTarget {
498    Lf,
499    Crlf,
500}
501
502impl LineEndingTarget {
503    pub fn name(self) -> &'static str {
504        match self {
505            Self::Lf => "lf",
506            Self::Crlf => "crlf",
507        }
508    }
509
510    fn bytes(self) -> &'static [u8] {
511        match self {
512            Self::Lf => b"\n",
513            Self::Crlf => b"\r\n",
514        }
515    }
516}
517
518/// Rewrites every line ending in a file to the target (`lf` or `crlf`).
519#[derive(Debug)]
520pub struct FileNormalizeLineEndingsFixer {
521    target: LineEndingTarget,
522}
523
524impl FileNormalizeLineEndingsFixer {
525    pub fn new(target: LineEndingTarget) -> Self {
526        Self { target }
527    }
528}
529
530impl Fixer for FileNormalizeLineEndingsFixer {
531    fn describe(&self) -> String {
532        format!("normalize line endings to {}", self.target.name())
533    }
534
535    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
536        let Some(path) = &violation.path else {
537            return Ok(FixOutcome::Skipped(
538                "violation did not carry a path".to_string(),
539            ));
540        };
541        let abs = ctx.root.join(path);
542        if ctx.dry_run {
543            return Ok(FixOutcome::Applied(format!(
544                "would normalize line endings in {} to {}",
545                path.display(),
546                self.target.name()
547            )));
548        }
549        let existing = match alint_core::read_for_fix(&abs, path, ctx)? {
550            alint_core::ReadForFix::Bytes(b) => b,
551            alint_core::ReadForFix::Skipped(outcome) => return Ok(outcome),
552        };
553        let normalized = normalize_line_endings(&existing, self.target);
554        if normalized == existing {
555            return Ok(FixOutcome::Skipped(format!(
556                "{} already {}",
557                path.display(),
558                self.target.name()
559            )));
560        }
561        std::fs::write(&abs, &normalized).map_err(|source| Error::Io {
562            path: abs.clone(),
563            source,
564        })?;
565        Ok(FixOutcome::Applied(format!(
566            "normalized {} to {}",
567            path.display(),
568            self.target.name()
569        )))
570    }
571}
572
573fn normalize_line_endings(bytes: &[u8], target: LineEndingTarget) -> Vec<u8> {
574    let target_bytes = target.bytes();
575    let mut out = Vec::with_capacity(bytes.len());
576    let mut i = 0;
577    while i < bytes.len() {
578        if bytes[i] == b'\n' {
579            // Drop a preceding CR so `\r\n` collapses to `\n` before
580            // we emit the target.
581            if out.last().copied() == Some(b'\r') {
582                out.pop();
583            }
584            out.extend_from_slice(target_bytes);
585        } else {
586            out.push(bytes[i]);
587        }
588        i += 1;
589    }
590    out
591}
592
593/// Strips Unicode bidi control characters (the Trojan Source
594/// codepoints U+202A–202E, U+2066–2069) from the file's content.
595#[derive(Debug)]
596pub struct FileStripBidiFixer;
597
598impl Fixer for FileStripBidiFixer {
599    fn describe(&self) -> String {
600        "strip Unicode bidi control characters".to_string()
601    }
602
603    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
604        apply_char_filter(
605            "bidi",
606            "stripped bidi controls from",
607            violation,
608            ctx,
609            crate::no_bidi_controls::is_bidi_control,
610            /* preserve_leading_feff = */ false,
611        )
612    }
613}
614
615/// Strips zero-width characters (U+200B / U+200C / U+200D, plus
616/// body-internal U+FEFF — a leading BOM is preserved so
617/// `no_bom` can own that concern).
618#[derive(Debug)]
619pub struct FileStripZeroWidthFixer;
620
621impl Fixer for FileStripZeroWidthFixer {
622    fn describe(&self) -> String {
623        "strip zero-width characters (U+200B/C/D, body-internal U+FEFF)".to_string()
624    }
625
626    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
627        apply_char_filter(
628            "zero-width",
629            "stripped zero-width chars from",
630            violation,
631            ctx,
632            |c| matches!(c, '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}'),
633            /* preserve_leading_feff = */ true,
634        )
635    }
636}
637
638/// Strips a leading BOM (UTF-8 / UTF-16 / UTF-32 LE & BE) from
639/// the violating file.
640#[derive(Debug)]
641pub struct FileStripBomFixer;
642
643impl Fixer for FileStripBomFixer {
644    fn describe(&self) -> String {
645        "strip leading BOM".to_string()
646    }
647
648    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
649        let Some(path) = &violation.path else {
650            return Ok(FixOutcome::Skipped(
651                "violation did not carry a path".to_string(),
652            ));
653        };
654        let abs = ctx.root.join(path);
655        if ctx.dry_run {
656            return Ok(FixOutcome::Applied(format!(
657                "would strip BOM from {}",
658                path.display()
659            )));
660        }
661        let existing = match alint_core::read_for_fix(&abs, path, ctx)? {
662            alint_core::ReadForFix::Bytes(b) => b,
663            alint_core::ReadForFix::Skipped(outcome) => return Ok(outcome),
664        };
665        let Some(bom) = crate::no_bom::detect_bom(&existing) else {
666            return Ok(FixOutcome::Skipped(format!(
667                "{} has no BOM",
668                path.display()
669            )));
670        };
671        let stripped = &existing[bom.byte_len()..];
672        std::fs::write(&abs, stripped).map_err(|source| Error::Io {
673            path: abs.clone(),
674            source,
675        })?;
676        Ok(FixOutcome::Applied(format!(
677            "stripped {} BOM from {}",
678            bom.name(),
679            path.display()
680        )))
681    }
682}
683
684/// Shared read-modify-write helper for "remove every char that
685/// matches `predicate`" fix ops.
686fn apply_char_filter(
687    label: &str,
688    verb: &str,
689    violation: &Violation,
690    ctx: &FixContext<'_>,
691    predicate: impl Fn(char) -> bool,
692    preserve_leading_feff: bool,
693) -> Result<FixOutcome> {
694    let Some(path) = &violation.path else {
695        return Ok(FixOutcome::Skipped(
696            "violation did not carry a path".to_string(),
697        ));
698    };
699    let abs = ctx.root.join(path);
700    if ctx.dry_run {
701        return Ok(FixOutcome::Applied(format!(
702            "would strip {label} chars from {}",
703            path.display()
704        )));
705    }
706    let existing = match alint_core::read_for_fix(&abs, path, ctx)? {
707        alint_core::ReadForFix::Bytes(b) => b,
708        alint_core::ReadForFix::Skipped(outcome) => return Ok(outcome),
709    };
710    let Ok(text) = std::str::from_utf8(&existing) else {
711        return Ok(FixOutcome::Skipped(format!(
712            "{} is not UTF-8; cannot filter {label} chars",
713            path.display()
714        )));
715    };
716    let mut out = String::with_capacity(text.len());
717    let mut first_char = true;
718    for c in text.chars() {
719        let keep_because_leading_bom = preserve_leading_feff && first_char && c == '\u{FEFF}';
720        if keep_because_leading_bom || !predicate(c) {
721            out.push(c);
722        }
723        first_char = false;
724    }
725    if out.as_bytes() == existing {
726        return Ok(FixOutcome::Skipped(format!(
727            "{} has no {label} chars to strip",
728            path.display()
729        )));
730    }
731    std::fs::write(&abs, out.as_bytes()).map_err(|source| Error::Io {
732        path: abs.clone(),
733        source,
734    })?;
735    Ok(FixOutcome::Applied(format!("{verb} {}", path.display())))
736}
737
738/// Collapses runs of blank lines longer than `max` down to exactly
739/// `max` blank lines. A blank line is one whose content between
740/// line endings is empty or only spaces/tabs. Preserves the file's
741/// line endings (LF vs. CRLF) by operating on byte-level newlines.
742#[derive(Debug)]
743pub struct FileCollapseBlankLinesFixer {
744    max: u32,
745}
746
747impl FileCollapseBlankLinesFixer {
748    pub fn new(max: u32) -> Self {
749        Self { max }
750    }
751}
752
753impl Fixer for FileCollapseBlankLinesFixer {
754    fn describe(&self) -> String {
755        format!("collapse runs of blank lines to at most {}", self.max)
756    }
757
758    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
759        let Some(path) = &violation.path else {
760            return Ok(FixOutcome::Skipped(
761                "violation did not carry a path".to_string(),
762            ));
763        };
764        let abs = ctx.root.join(path);
765        if ctx.dry_run {
766            return Ok(FixOutcome::Applied(format!(
767                "would collapse blank lines in {} to at most {}",
768                path.display(),
769                self.max,
770            )));
771        }
772        let existing = match alint_core::read_for_fix(&abs, path, ctx)? {
773            alint_core::ReadForFix::Bytes(b) => b,
774            alint_core::ReadForFix::Skipped(outcome) => return Ok(outcome),
775        };
776        let Ok(text) = std::str::from_utf8(&existing) else {
777            return Ok(FixOutcome::Skipped(format!(
778                "{} is not UTF-8; cannot collapse",
779                path.display()
780            )));
781        };
782        let collapsed = collapse_blank_lines(text, self.max);
783        if collapsed.as_bytes() == existing {
784            return Ok(FixOutcome::Skipped(format!(
785                "{} already clean",
786                path.display()
787            )));
788        }
789        std::fs::write(&abs, collapsed.as_bytes()).map_err(|source| Error::Io {
790            path: abs.clone(),
791            source,
792        })?;
793        Ok(FixOutcome::Applied(format!(
794            "collapsed blank-line runs in {} to at most {}",
795            path.display(),
796            self.max,
797        )))
798    }
799}
800
801/// A "blank" line has content consisting only of spaces or tabs.
802pub(crate) fn line_is_blank(body: &str) -> bool {
803    body.bytes().all(|b| b == b' ' || b == b'\t')
804}
805
806/// Walk the file in (body, ending) pairs so the final slot after the
807/// last newline doesn't get double-counted as an extra blank line.
808/// Preserves CRLF vs LF verbatim.
809pub(crate) fn collapse_blank_lines(text: &str, max: u32) -> String {
810    let mut out = String::with_capacity(text.len());
811    let mut blank_run: u32 = 0;
812    let mut remaining = text;
813    loop {
814        let (body, ending, rest) = match remaining.find('\n') {
815            Some(i) => {
816                let before = &remaining[..i];
817                let (body, cr) = match before.strip_suffix('\r') {
818                    Some(s) => (s, "\r\n"),
819                    None => (before, "\n"),
820                };
821                (body, cr, &remaining[i + 1..])
822            }
823            None => (remaining, "", ""),
824        };
825        let blank = line_is_blank(body);
826        if blank {
827            blank_run += 1;
828            if blank_run > max {
829                if ending.is_empty() {
830                    break;
831                }
832                remaining = rest;
833                continue;
834            }
835        } else {
836            blank_run = 0;
837        }
838        out.push_str(body);
839        out.push_str(ending);
840        if ending.is_empty() {
841            break;
842        }
843        remaining = rest;
844    }
845    out
846}
847
848#[cfg(test)]
849mod tests {
850    use super::*;
851    use tempfile::TempDir;
852
853    fn make_ctx(tmp: &TempDir, dry_run: bool) -> FixContext<'_> {
854        FixContext {
855            root: tmp.path(),
856            dry_run,
857            fix_size_limit: None,
858        }
859    }
860
861    #[test]
862    fn file_create_writes_content_when_missing() {
863        let tmp = TempDir::new().unwrap();
864        let fixer = FileCreateFixer::new(PathBuf::from("LICENSE"), "Apache-2.0\n".into(), true);
865        let outcome = fixer
866            .apply(&Violation::new("missing LICENSE"), &make_ctx(&tmp, false))
867            .unwrap();
868        assert!(matches!(outcome, FixOutcome::Applied(_)));
869        let written = std::fs::read_to_string(tmp.path().join("LICENSE")).unwrap();
870        assert_eq!(written, "Apache-2.0\n");
871    }
872
873    #[test]
874    fn file_create_reads_content_from_relative_path() {
875        // `content_from` relative to ctx.root: stage a template
876        // file in the tempdir, point the fixer at it via a
877        // relative path, and verify the apply step reads from
878        // disk at apply time.
879        let tmp = TempDir::new().unwrap();
880        let template_dir = tmp.path().join(".alint/templates");
881        std::fs::create_dir_all(&template_dir).unwrap();
882        std::fs::write(
883            template_dir.join("LICENSE-MIT.txt"),
884            "MIT License\n\nCopyright (c) 2026 demo\n",
885        )
886        .unwrap();
887        let fixer = FileCreateFixer::new(
888            PathBuf::from("LICENSE"),
889            ContentSourceSpec::File(PathBuf::from(".alint/templates/LICENSE-MIT.txt")),
890            true,
891        );
892        let outcome = fixer
893            .apply(&Violation::new("missing LICENSE"), &make_ctx(&tmp, false))
894            .unwrap();
895        assert!(matches!(outcome, FixOutcome::Applied(_)));
896        let written = std::fs::read_to_string(tmp.path().join("LICENSE")).unwrap();
897        assert!(written.starts_with("MIT License"));
898        assert!(written.contains("Copyright (c) 2026"));
899    }
900
901    #[test]
902    fn file_create_skips_when_content_from_missing() {
903        // Missing source file produces a `Skipped` outcome
904        // rather than aborting the whole fix run — same posture
905        // as the rest of the fixer module.
906        let tmp = TempDir::new().unwrap();
907        let fixer = FileCreateFixer::new(
908            PathBuf::from("LICENSE"),
909            ContentSourceSpec::File(PathBuf::from("does/not/exist.txt")),
910            true,
911        );
912        let outcome = fixer
913            .apply(&Violation::new("missing"), &make_ctx(&tmp, false))
914            .unwrap();
915        let FixOutcome::Skipped(msg) = &outcome else {
916            panic!("expected Skipped, got {outcome:?}")
917        };
918        assert!(msg.contains("could not be read"));
919        // The target file should NOT have been created since
920        // we skipped before the write.
921        assert!(!tmp.path().join("LICENSE").exists());
922    }
923
924    #[test]
925    fn file_prepend_with_content_from_reads_at_apply() {
926        let tmp = TempDir::new().unwrap();
927        std::fs::write(
928            tmp.path().join("hdr.txt"),
929            "// SPDX-License-Identifier: MIT\n",
930        )
931        .unwrap();
932        std::fs::write(tmp.path().join("a.rs"), "fn main() {}\n").unwrap();
933        let fixer = FilePrependFixer::new(ContentSourceSpec::File(PathBuf::from("hdr.txt")));
934        let outcome = fixer
935            .apply(
936                &Violation::new("missing header").with_path(PathBuf::from("a.rs")),
937                &make_ctx(&tmp, false),
938            )
939            .unwrap();
940        assert!(matches!(outcome, FixOutcome::Applied(_)));
941        let updated = std::fs::read_to_string(tmp.path().join("a.rs")).unwrap();
942        assert!(updated.starts_with("// SPDX-License-Identifier: MIT\n"));
943        assert!(updated.contains("fn main() {}"));
944    }
945
946    #[test]
947    fn file_create_creates_intermediate_directories() {
948        let tmp = TempDir::new().unwrap();
949        let fixer = FileCreateFixer::new(PathBuf::from("a/b/c/config.yaml"), "k: v\n".into(), true);
950        fixer
951            .apply(&Violation::new("missing"), &make_ctx(&tmp, false))
952            .unwrap();
953        assert!(tmp.path().join("a/b/c/config.yaml").exists());
954    }
955
956    #[test]
957    fn file_create_skips_when_target_exists() {
958        let tmp = TempDir::new().unwrap();
959        std::fs::write(tmp.path().join("README.md"), "existing\n").unwrap();
960        let fixer = FileCreateFixer::new(PathBuf::from("README.md"), "NEW\n".into(), true);
961        let outcome = fixer
962            .apply(&Violation::new("x"), &make_ctx(&tmp, false))
963            .unwrap();
964        match outcome {
965            FixOutcome::Skipped(reason) => assert!(reason.contains("already exists")),
966            FixOutcome::Applied(_) => panic!("expected Skipped"),
967        }
968        assert_eq!(
969            std::fs::read_to_string(tmp.path().join("README.md")).unwrap(),
970            "existing\n",
971            "pre-existing content must not be overwritten"
972        );
973    }
974
975    #[test]
976    fn file_create_dry_run_does_not_touch_disk() {
977        let tmp = TempDir::new().unwrap();
978        let fixer = FileCreateFixer::new(PathBuf::from("x.txt"), "body".into(), true);
979        let outcome = fixer
980            .apply(&Violation::new("x"), &make_ctx(&tmp, true))
981            .unwrap();
982        match outcome {
983            FixOutcome::Applied(s) => assert!(s.starts_with("would create")),
984            FixOutcome::Skipped(_) => panic!("expected Applied"),
985        }
986        assert!(!tmp.path().join("x.txt").exists());
987    }
988
989    #[test]
990    fn file_remove_deletes_violating_path() {
991        let tmp = TempDir::new().unwrap();
992        let target = tmp.path().join("debug.log");
993        std::fs::write(&target, "noise").unwrap();
994        let outcome = FileRemoveFixer
995            .apply(
996                &Violation::new("forbidden").with_path("debug.log"),
997                &make_ctx(&tmp, false),
998            )
999            .unwrap();
1000        assert!(matches!(outcome, FixOutcome::Applied(_)));
1001        assert!(!target.exists());
1002    }
1003
1004    #[test]
1005    fn file_remove_skips_when_violation_has_no_path() {
1006        let tmp = TempDir::new().unwrap();
1007        let outcome = FileRemoveFixer
1008            .apply(&Violation::new("no path"), &make_ctx(&tmp, false))
1009            .unwrap();
1010        match outcome {
1011            FixOutcome::Skipped(reason) => assert!(reason.contains("path")),
1012            FixOutcome::Applied(_) => panic!("expected Skipped"),
1013        }
1014    }
1015
1016    #[test]
1017    fn file_remove_dry_run_keeps_the_file() {
1018        let tmp = TempDir::new().unwrap();
1019        let target = tmp.path().join("victim.bak");
1020        std::fs::write(&target, "bytes").unwrap();
1021        let outcome = FileRemoveFixer
1022            .apply(
1023                &Violation::new("forbidden").with_path("victim.bak"),
1024                &make_ctx(&tmp, true),
1025            )
1026            .unwrap();
1027        match outcome {
1028            FixOutcome::Applied(s) => assert!(s.starts_with("would remove")),
1029            FixOutcome::Skipped(_) => panic!("expected Applied"),
1030        }
1031        assert!(target.exists());
1032    }
1033
1034    #[test]
1035    fn file_prepend_inserts_at_start() {
1036        let tmp = TempDir::new().unwrap();
1037        std::fs::write(tmp.path().join("a.rs"), "fn main() {}\n").unwrap();
1038        let fixer = FilePrependFixer::new("// Copyright 2026\n".into());
1039        fixer
1040            .apply(
1041                &Violation::new("missing header").with_path("a.rs"),
1042                &make_ctx(&tmp, false),
1043            )
1044            .unwrap();
1045        assert_eq!(
1046            std::fs::read_to_string(tmp.path().join("a.rs")).unwrap(),
1047            "// Copyright 2026\nfn main() {}\n"
1048        );
1049    }
1050
1051    #[test]
1052    fn file_prepend_preserves_utf8_bom() {
1053        let tmp = TempDir::new().unwrap();
1054        // BOM + "hello\n"
1055        let mut bytes = b"\xEF\xBB\xBF".to_vec();
1056        bytes.extend_from_slice(b"hello\n");
1057        std::fs::write(tmp.path().join("x.txt"), &bytes).unwrap();
1058        let fixer = FilePrependFixer::new("HEAD\n".into());
1059        fixer
1060            .apply(
1061                &Violation::new("m").with_path("x.txt"),
1062                &make_ctx(&tmp, false),
1063            )
1064            .unwrap();
1065        let got = std::fs::read(tmp.path().join("x.txt")).unwrap();
1066        assert_eq!(&got[..3], b"\xEF\xBB\xBF");
1067        assert_eq!(&got[3..], b"HEAD\nhello\n");
1068    }
1069
1070    #[test]
1071    fn file_prepend_dry_run_does_not_touch_disk() {
1072        let tmp = TempDir::new().unwrap();
1073        std::fs::write(tmp.path().join("a.rs"), "original\n").unwrap();
1074        FilePrependFixer::new("HEAD\n".into())
1075            .apply(
1076                &Violation::new("m").with_path("a.rs"),
1077                &make_ctx(&tmp, true),
1078            )
1079            .unwrap();
1080        assert_eq!(
1081            std::fs::read_to_string(tmp.path().join("a.rs")).unwrap(),
1082            "original\n"
1083        );
1084    }
1085
1086    #[test]
1087    fn file_prepend_skips_when_violation_has_no_path() {
1088        let tmp = TempDir::new().unwrap();
1089        let outcome = FilePrependFixer::new("h".into())
1090            .apply(&Violation::new("m"), &make_ctx(&tmp, false))
1091            .unwrap();
1092        assert!(matches!(outcome, FixOutcome::Skipped(_)));
1093    }
1094
1095    #[test]
1096    fn file_append_writes_at_end() {
1097        let tmp = TempDir::new().unwrap();
1098        std::fs::write(tmp.path().join("notes.md"), "# Notes\n").unwrap();
1099        let fixer = FileAppendFixer::new("\n## Section\n".into());
1100        fixer
1101            .apply(
1102                &Violation::new("missing section").with_path("notes.md"),
1103                &make_ctx(&tmp, false),
1104            )
1105            .unwrap();
1106        assert_eq!(
1107            std::fs::read_to_string(tmp.path().join("notes.md")).unwrap(),
1108            "# Notes\n\n## Section\n"
1109        );
1110    }
1111
1112    #[test]
1113    fn file_append_dry_run_leaves_file_unchanged() {
1114        let tmp = TempDir::new().unwrap();
1115        std::fs::write(tmp.path().join("x.txt"), "orig\n").unwrap();
1116        FileAppendFixer::new("extra\n".into())
1117            .apply(
1118                &Violation::new("m").with_path("x.txt"),
1119                &make_ctx(&tmp, true),
1120            )
1121            .unwrap();
1122        assert_eq!(
1123            std::fs::read_to_string(tmp.path().join("x.txt")).unwrap(),
1124            "orig\n"
1125        );
1126    }
1127
1128    #[test]
1129    fn file_append_skips_when_violation_has_no_path() {
1130        let tmp = TempDir::new().unwrap();
1131        let outcome = FileAppendFixer::new("x".into())
1132            .apply(&Violation::new("m"), &make_ctx(&tmp, false))
1133            .unwrap();
1134        assert!(matches!(outcome, FixOutcome::Skipped(_)));
1135    }
1136
1137    #[test]
1138    fn file_rename_converts_stem_preserving_extension() {
1139        let tmp = TempDir::new().unwrap();
1140        std::fs::write(tmp.path().join("FooBar.rs"), "fn main() {}\n").unwrap();
1141        FileRenameFixer::new(CaseConvention::Snake)
1142            .apply(
1143                &Violation::new("case").with_path("FooBar.rs"),
1144                &make_ctx(&tmp, false),
1145            )
1146            .unwrap();
1147        assert!(tmp.path().join("foo_bar.rs").exists());
1148        assert!(!tmp.path().join("FooBar.rs").exists());
1149    }
1150
1151    #[test]
1152    fn file_rename_keeps_file_in_same_directory() {
1153        let tmp = TempDir::new().unwrap();
1154        std::fs::create_dir(tmp.path().join("src")).unwrap();
1155        std::fs::write(tmp.path().join("src/MyModule.rs"), "").unwrap();
1156        FileRenameFixer::new(CaseConvention::Snake)
1157            .apply(
1158                &Violation::new("case").with_path("src/MyModule.rs"),
1159                &make_ctx(&tmp, false),
1160            )
1161            .unwrap();
1162        assert!(tmp.path().join("src/my_module.rs").exists());
1163    }
1164
1165    #[test]
1166    fn file_rename_skips_when_already_in_target_case() {
1167        let tmp = TempDir::new().unwrap();
1168        std::fs::write(tmp.path().join("foo_bar.rs"), "").unwrap();
1169        let outcome = FileRenameFixer::new(CaseConvention::Snake)
1170            .apply(
1171                &Violation::new("case").with_path("foo_bar.rs"),
1172                &make_ctx(&tmp, false),
1173            )
1174            .unwrap();
1175        match outcome {
1176            FixOutcome::Skipped(reason) => assert!(reason.contains("already")),
1177            FixOutcome::Applied(_) => panic!("expected Skipped"),
1178        }
1179    }
1180
1181    #[test]
1182    fn file_rename_skips_on_target_collision() {
1183        let tmp = TempDir::new().unwrap();
1184        std::fs::write(tmp.path().join("FooBar.rs"), "A").unwrap();
1185        std::fs::write(tmp.path().join("foo_bar.rs"), "B").unwrap();
1186        let outcome = FileRenameFixer::new(CaseConvention::Snake)
1187            .apply(
1188                &Violation::new("case").with_path("FooBar.rs"),
1189                &make_ctx(&tmp, false),
1190            )
1191            .unwrap();
1192        match outcome {
1193            FixOutcome::Skipped(reason) => assert!(reason.contains("already exists")),
1194            FixOutcome::Applied(_) => panic!("expected Skipped"),
1195        }
1196        // Neither file should have been touched.
1197        assert_eq!(
1198            std::fs::read_to_string(tmp.path().join("FooBar.rs")).unwrap(),
1199            "A"
1200        );
1201        assert_eq!(
1202            std::fs::read_to_string(tmp.path().join("foo_bar.rs")).unwrap(),
1203            "B"
1204        );
1205    }
1206
1207    #[test]
1208    fn file_rename_dry_run_does_not_touch_disk() {
1209        let tmp = TempDir::new().unwrap();
1210        std::fs::write(tmp.path().join("FooBar.rs"), "").unwrap();
1211        FileRenameFixer::new(CaseConvention::Snake)
1212            .apply(
1213                &Violation::new("case").with_path("FooBar.rs"),
1214                &make_ctx(&tmp, true),
1215            )
1216            .unwrap();
1217        assert!(tmp.path().join("FooBar.rs").exists());
1218        assert!(!tmp.path().join("foo_bar.rs").exists());
1219    }
1220
1221    // ── text-hygiene fixers ────────────────────────────────────
1222
1223    #[test]
1224    fn strip_trailing_whitespace_preserves_lf_and_crlf() {
1225        assert_eq!(strip_trailing_whitespace("a  \nb\t\n"), "a\nb\n");
1226        assert_eq!(strip_trailing_whitespace("a  \r\nb\t\r\n"), "a\r\nb\r\n");
1227    }
1228
1229    #[test]
1230    fn file_trim_trailing_whitespace_rewrites_in_place() {
1231        let tmp = TempDir::new().unwrap();
1232        std::fs::write(tmp.path().join("x.rs"), "let _ = 1;   \n").unwrap();
1233        let outcome = FileTrimTrailingWhitespaceFixer
1234            .apply(
1235                &Violation::new("ws").with_path("x.rs"),
1236                &make_ctx(&tmp, false),
1237            )
1238            .unwrap();
1239        assert!(matches!(outcome, FixOutcome::Applied(_)));
1240        assert_eq!(
1241            std::fs::read_to_string(tmp.path().join("x.rs")).unwrap(),
1242            "let _ = 1;\n"
1243        );
1244    }
1245
1246    #[test]
1247    fn file_trim_trailing_whitespace_honors_size_limit() {
1248        let tmp = TempDir::new().unwrap();
1249        let big = "x   \n".repeat(2_000);
1250        std::fs::write(tmp.path().join("big.txt"), &big).unwrap();
1251        let ctx = FixContext {
1252            root: tmp.path(),
1253            dry_run: false,
1254            fix_size_limit: Some(100),
1255        };
1256        let outcome = FileTrimTrailingWhitespaceFixer
1257            .apply(&Violation::new("ws").with_path("big.txt"), &ctx)
1258            .unwrap();
1259        match outcome {
1260            FixOutcome::Skipped(reason) => {
1261                assert!(reason.contains("fix_size_limit"), "{reason}");
1262            }
1263            FixOutcome::Applied(_) => panic!("expected Skipped on oversized file"),
1264        }
1265        // Disk unchanged.
1266        assert_eq!(
1267            std::fs::read_to_string(tmp.path().join("big.txt")).unwrap(),
1268            big
1269        );
1270    }
1271
1272    #[test]
1273    fn file_append_final_newline_adds_missing_newline() {
1274        let tmp = TempDir::new().unwrap();
1275        std::fs::write(tmp.path().join("x.txt"), "hello").unwrap();
1276        FileAppendFinalNewlineFixer
1277            .apply(
1278                &Violation::new("eof").with_path("x.txt"),
1279                &make_ctx(&tmp, false),
1280            )
1281            .unwrap();
1282        assert_eq!(
1283            std::fs::read_to_string(tmp.path().join("x.txt")).unwrap(),
1284            "hello\n"
1285        );
1286    }
1287
1288    #[test]
1289    fn normalize_line_endings_lf_target() {
1290        let mixed = b"a\r\nb\nc\r\nd".to_vec();
1291        let out = normalize_line_endings(&mixed, LineEndingTarget::Lf);
1292        assert_eq!(out, b"a\nb\nc\nd");
1293    }
1294
1295    #[test]
1296    fn normalize_line_endings_crlf_target() {
1297        let mixed = b"a\r\nb\nc\r\nd".to_vec();
1298        let out = normalize_line_endings(&mixed, LineEndingTarget::Crlf);
1299        assert_eq!(out, b"a\r\nb\r\nc\r\nd");
1300    }
1301
1302    #[test]
1303    fn file_normalize_line_endings_rewrites_to_lf() {
1304        let tmp = TempDir::new().unwrap();
1305        std::fs::write(tmp.path().join("a.md"), "one\r\ntwo\r\n").unwrap();
1306        FileNormalizeLineEndingsFixer::new(LineEndingTarget::Lf)
1307            .apply(
1308                &Violation::new("le").with_path("a.md"),
1309                &make_ctx(&tmp, false),
1310            )
1311            .unwrap();
1312        assert_eq!(
1313            std::fs::read_to_string(tmp.path().join("a.md")).unwrap(),
1314            "one\ntwo\n"
1315        );
1316    }
1317
1318    #[test]
1319    fn collapse_blank_lines_keeps_up_to_max() {
1320        assert_eq!(collapse_blank_lines("a\n\n\nb\n", 1), "a\n\nb\n");
1321        assert_eq!(collapse_blank_lines("a\n\n\n\nb\n", 2), "a\n\n\nb\n");
1322        assert_eq!(collapse_blank_lines("a\nb\n", 1), "a\nb\n");
1323    }
1324
1325    #[test]
1326    fn collapse_blank_lines_preserves_trailing_newline() {
1327        // One existing blank line, max=1 → file must still end with "\n\n"
1328        // (i.e. the blank line plus the EOF newline).
1329        assert_eq!(collapse_blank_lines("a\n\n", 1), "a\n\n");
1330    }
1331
1332    #[test]
1333    fn collapse_blank_lines_max_zero_drops_all_blanks() {
1334        assert_eq!(collapse_blank_lines("a\n\n\nb\n", 0), "a\nb\n");
1335        assert_eq!(collapse_blank_lines("\n", 0), "");
1336        assert_eq!(collapse_blank_lines("a\n\n", 0), "a\n");
1337    }
1338
1339    #[test]
1340    fn collapse_blank_lines_preserves_crlf() {
1341        assert_eq!(
1342            collapse_blank_lines("a\r\n\r\n\r\n\r\nb\r\n", 1),
1343            "a\r\n\r\nb\r\n"
1344        );
1345    }
1346
1347    #[test]
1348    fn collapse_blank_lines_treats_whitespace_only_as_blank() {
1349        // Lines with only spaces/tabs count as blank, and dropped
1350        // copies disappear entirely (their whitespace goes too).
1351        assert_eq!(collapse_blank_lines("a\n  \n\t\n\nb\n", 1), "a\n  \nb\n");
1352    }
1353
1354    #[test]
1355    fn collapse_blank_lines_no_op_on_empty_file() {
1356        assert_eq!(collapse_blank_lines("", 2), "");
1357    }
1358}