1use std::io::Write;
9use std::path::PathBuf;
10
11use alint_core::{Error, FixContext, FixOutcome, Fixer, Result, Violation};
12
13use crate::case::CaseConvention;
14
15const UTF8_BOM: &[u8] = b"\xEF\xBB\xBF";
18
19#[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#[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#[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#[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#[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#[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 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#[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#[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#[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 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#[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 false,
555 )
556 }
557}
558
559#[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 true,
578 )
579 }
580}
581
582#[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
628fn 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#[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
745pub(crate) fn line_is_blank(body: &str) -> bool {
747 body.bytes().all(|b| b == b' ' || b == b'\t')
748}
749
750pub(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 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 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 #[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 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 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 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}