1use std::io::Write;
9use std::path::PathBuf;
10
11use alint_core::{ContentSourceSpec, Error, FixContext, FixOutcome, Fixer, Result, Violation};
12
13use crate::case::CaseConvention;
14
15const UTF8_BOM: &[u8] = b"\xEF\xBB\xBF";
18
19#[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
94fn 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#[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#[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#[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#[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#[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 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#[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#[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#[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 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#[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 false,
611 )
612 }
613}
614
615#[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 true,
634 )
635 }
636}
637
638#[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
684fn 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#[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
801pub(crate) fn line_is_blank(body: &str) -> bool {
803 body.bytes().all(|b| b == b' ' || b == b'\t')
804}
805
806pub(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 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 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 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 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 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 #[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 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 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 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}