use std::io::Write;
use std::path::PathBuf;
use alint_core::{ContentSourceSpec, Error, FixContext, FixOutcome, Fixer, Result, Violation};
use crate::case::CaseConvention;
const UTF8_BOM: &[u8] = b"\xEF\xBB\xBF";
#[derive(Debug)]
pub struct FileCreateFixer {
path: PathBuf,
source: ContentSourceSpec,
create_parents: bool,
}
impl FileCreateFixer {
pub fn new(path: PathBuf, source: ContentSourceSpec, create_parents: bool) -> Self {
Self {
path,
source,
create_parents,
}
}
}
impl Fixer for FileCreateFixer {
fn describe(&self) -> String {
match &self.source {
ContentSourceSpec::Inline(s) => format!(
"create {} ({} byte{})",
self.path.display(),
s.len(),
if s.len() == 1 { "" } else { "s" }
),
ContentSourceSpec::File(rel) => format!(
"create {} (content from {})",
self.path.display(),
rel.display()
),
}
}
fn apply(&self, _violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
let abs = ctx.root.join(&self.path);
if abs.exists() {
return Ok(FixOutcome::Skipped(format!(
"{} already exists",
self.path.display()
)));
}
let content = match resolve_source_bytes(&self.source, ctx.root) {
Ok(bytes) => bytes,
Err(skip_msg) => return Ok(FixOutcome::Skipped(skip_msg)),
};
if ctx.dry_run {
return Ok(FixOutcome::Applied(format!(
"would create {}",
self.path.display()
)));
}
if self.create_parents
&& let Some(parent) = abs.parent()
{
std::fs::create_dir_all(parent).map_err(|source| Error::Io {
path: parent.to_path_buf(),
source,
})?;
}
std::fs::write(&abs, &content).map_err(|source| Error::Io {
path: abs.clone(),
source,
})?;
Ok(FixOutcome::Applied(format!(
"created {}",
self.path.display()
)))
}
}
fn resolve_source_bytes(
source: &ContentSourceSpec,
ctx_root: &std::path::Path,
) -> std::result::Result<Vec<u8>, String> {
match source {
ContentSourceSpec::Inline(s) => Ok(s.as_bytes().to_vec()),
ContentSourceSpec::File(rel) => {
let abs = ctx_root.join(rel);
std::fs::read(&abs)
.map_err(|e| format!("content_from `{}` could not be read: {e}", rel.display()))
}
}
}
#[derive(Debug)]
pub struct FileRemoveFixer;
impl Fixer for FileRemoveFixer {
fn describe(&self) -> String {
"remove the violating file".to_string()
}
fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
let Some(path) = &violation.path else {
return Ok(FixOutcome::Skipped(
"violation did not carry a path".to_string(),
));
};
let abs = ctx.root.join(path);
if !abs.exists() {
return Ok(FixOutcome::Skipped(format!(
"{} does not exist",
path.display()
)));
}
if ctx.dry_run {
return Ok(FixOutcome::Applied(format!(
"would remove {}",
path.display()
)));
}
std::fs::remove_file(&abs).map_err(|source| Error::Io {
path: abs.clone(),
source,
})?;
Ok(FixOutcome::Applied(format!("removed {}", path.display())))
}
}
#[derive(Debug)]
pub struct FilePrependFixer {
source: ContentSourceSpec,
}
impl FilePrependFixer {
pub fn new(source: ContentSourceSpec) -> Self {
Self { source }
}
}
impl Fixer for FilePrependFixer {
fn describe(&self) -> String {
match &self.source {
ContentSourceSpec::Inline(s) => format!(
"prepend {} byte{} to each violating file",
s.len(),
if s.len() == 1 { "" } else { "s" }
),
ContentSourceSpec::File(rel) => {
format!(
"prepend content from {} to each violating file",
rel.display()
)
}
}
}
fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
let Some(path) = &violation.path else {
return Ok(FixOutcome::Skipped(
"violation did not carry a path".to_string(),
));
};
let abs = ctx.root.join(path);
let prepend = match resolve_source_bytes(&self.source, ctx.root) {
Ok(b) => b,
Err(skip_msg) => return Ok(FixOutcome::Skipped(skip_msg)),
};
if ctx.dry_run {
return Ok(FixOutcome::Applied(format!(
"would prepend {} byte(s) to {}",
prepend.len(),
path.display()
)));
}
let existing = match alint_core::read_for_fix(&abs, path, ctx)? {
alint_core::ReadForFix::Bytes(b) => b,
alint_core::ReadForFix::Skipped(outcome) => return Ok(outcome),
};
let mut out = Vec::with_capacity(existing.len() + prepend.len());
if existing.starts_with(UTF8_BOM) {
out.extend_from_slice(UTF8_BOM);
out.extend_from_slice(&prepend);
out.extend_from_slice(&existing[UTF8_BOM.len()..]);
} else {
out.extend_from_slice(&prepend);
out.extend_from_slice(&existing);
}
std::fs::write(&abs, &out).map_err(|source| Error::Io {
path: abs.clone(),
source,
})?;
Ok(FixOutcome::Applied(format!("prepended {}", path.display())))
}
}
#[derive(Debug)]
pub struct FileAppendFixer {
source: ContentSourceSpec,
}
impl FileAppendFixer {
pub fn new(source: ContentSourceSpec) -> Self {
Self { source }
}
}
impl Fixer for FileAppendFixer {
fn describe(&self) -> String {
match &self.source {
ContentSourceSpec::Inline(s) => format!(
"append {} byte{} to each violating file",
s.len(),
if s.len() == 1 { "" } else { "s" }
),
ContentSourceSpec::File(rel) => {
format!(
"append content from {} to each violating file",
rel.display()
)
}
}
}
fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
let Some(path) = &violation.path else {
return Ok(FixOutcome::Skipped(
"violation did not carry a path".to_string(),
));
};
let abs = ctx.root.join(path);
let payload = match resolve_source_bytes(&self.source, ctx.root) {
Ok(b) => b,
Err(skip_msg) => return Ok(FixOutcome::Skipped(skip_msg)),
};
if ctx.dry_run {
return Ok(FixOutcome::Applied(format!(
"would append {} byte(s) to {}",
payload.len(),
path.display()
)));
}
if let Some(skip) = alint_core::check_fix_size(&abs, path, ctx)? {
return Ok(skip);
}
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&abs)
.map_err(|source| Error::Io {
path: abs.clone(),
source,
})?;
f.write_all(&payload).map_err(|source| Error::Io {
path: abs.clone(),
source,
})?;
Ok(FixOutcome::Applied(format!(
"appended to {}",
path.display()
)))
}
}
#[derive(Debug)]
pub struct FileRenameFixer {
case: CaseConvention,
}
impl FileRenameFixer {
pub fn new(case: CaseConvention) -> Self {
Self { case }
}
}
impl Fixer for FileRenameFixer {
fn describe(&self) -> String {
format!("rename stems to {}", self.case.display_name())
}
fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
let Some(path) = &violation.path else {
return Ok(FixOutcome::Skipped(
"violation did not carry a path".to_string(),
));
};
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
return Ok(FixOutcome::Skipped(format!(
"cannot decode filename stem for {}",
path.display()
)));
};
let new_stem = self.case.convert(stem);
if new_stem == stem {
return Ok(FixOutcome::Skipped(format!(
"{} already matches target case",
path.display()
)));
}
if new_stem.is_empty() {
return Ok(FixOutcome::Skipped(format!(
"case conversion produced an empty stem for {}",
path.display()
)));
}
let mut new_basename = new_stem;
if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
new_basename.push('.');
new_basename.push_str(ext);
}
let new_path: PathBuf = match path.parent() {
Some(p) if !p.as_os_str().is_empty() => p.join(&new_basename),
_ => PathBuf::from(&new_basename),
};
let abs_from = ctx.root.join(path);
let abs_to = ctx.root.join(&new_path);
if abs_to.exists() {
return Ok(FixOutcome::Skipped(format!(
"target {} already exists",
new_path.display()
)));
}
if ctx.dry_run {
return Ok(FixOutcome::Applied(format!(
"would rename {} → {}",
path.display(),
new_path.display()
)));
}
std::fs::rename(&abs_from, &abs_to).map_err(|source| Error::Io {
path: abs_from,
source,
})?;
Ok(FixOutcome::Applied(format!(
"renamed {} → {}",
path.display(),
new_path.display()
)))
}
}
#[derive(Debug)]
pub struct FileTrimTrailingWhitespaceFixer;
impl Fixer for FileTrimTrailingWhitespaceFixer {
fn describe(&self) -> String {
"strip trailing whitespace on every line".to_string()
}
fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
let Some(path) = &violation.path else {
return Ok(FixOutcome::Skipped(
"violation did not carry a path".to_string(),
));
};
let abs = ctx.root.join(path);
if ctx.dry_run {
return Ok(FixOutcome::Applied(format!(
"would trim trailing whitespace in {}",
path.display()
)));
}
let existing = match alint_core::read_for_fix(&abs, path, ctx)? {
alint_core::ReadForFix::Bytes(b) => b,
alint_core::ReadForFix::Skipped(outcome) => return Ok(outcome),
};
let Ok(text) = std::str::from_utf8(&existing) else {
return Ok(FixOutcome::Skipped(format!(
"{} is not UTF-8; cannot trim",
path.display()
)));
};
let trimmed = strip_trailing_whitespace(text);
if trimmed.as_bytes() == existing {
return Ok(FixOutcome::Skipped(format!(
"{} already clean",
path.display()
)));
}
std::fs::write(&abs, trimmed.as_bytes()).map_err(|source| Error::Io {
path: abs.clone(),
source,
})?;
Ok(FixOutcome::Applied(format!(
"trimmed trailing whitespace in {}",
path.display()
)))
}
}
fn strip_trailing_whitespace(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut first = true;
for line in text.split('\n') {
if !first {
out.push('\n');
}
first = false;
let (body, cr) = match line.strip_suffix('\r') {
Some(stripped) => (stripped, "\r"),
None => (line, ""),
};
out.push_str(body.trim_end_matches([' ', '\t']));
out.push_str(cr);
}
out
}
#[derive(Debug)]
pub struct FileAppendFinalNewlineFixer;
impl Fixer for FileAppendFinalNewlineFixer {
fn describe(&self) -> String {
"append final newline when missing".to_string()
}
fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
let Some(path) = &violation.path else {
return Ok(FixOutcome::Skipped(
"violation did not carry a path".to_string(),
));
};
let abs = ctx.root.join(path);
if ctx.dry_run {
return Ok(FixOutcome::Applied(format!(
"would append final newline to {}",
path.display()
)));
}
if let Some(skip) = alint_core::check_fix_size(&abs, path, ctx)? {
return Ok(skip);
}
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&abs)
.map_err(|source| Error::Io {
path: abs.clone(),
source,
})?;
f.write_all(b"\n").map_err(|source| Error::Io {
path: abs.clone(),
source,
})?;
Ok(FixOutcome::Applied(format!(
"appended final newline to {}",
path.display()
)))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LineEndingTarget {
Lf,
Crlf,
}
impl LineEndingTarget {
pub fn name(self) -> &'static str {
match self {
Self::Lf => "lf",
Self::Crlf => "crlf",
}
}
fn bytes(self) -> &'static [u8] {
match self {
Self::Lf => b"\n",
Self::Crlf => b"\r\n",
}
}
}
#[derive(Debug)]
pub struct FileNormalizeLineEndingsFixer {
target: LineEndingTarget,
}
impl FileNormalizeLineEndingsFixer {
pub fn new(target: LineEndingTarget) -> Self {
Self { target }
}
}
impl Fixer for FileNormalizeLineEndingsFixer {
fn describe(&self) -> String {
format!("normalize line endings to {}", self.target.name())
}
fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
let Some(path) = &violation.path else {
return Ok(FixOutcome::Skipped(
"violation did not carry a path".to_string(),
));
};
let abs = ctx.root.join(path);
if ctx.dry_run {
return Ok(FixOutcome::Applied(format!(
"would normalize line endings in {} to {}",
path.display(),
self.target.name()
)));
}
let existing = match alint_core::read_for_fix(&abs, path, ctx)? {
alint_core::ReadForFix::Bytes(b) => b,
alint_core::ReadForFix::Skipped(outcome) => return Ok(outcome),
};
let normalized = normalize_line_endings(&existing, self.target);
if normalized == existing {
return Ok(FixOutcome::Skipped(format!(
"{} already {}",
path.display(),
self.target.name()
)));
}
std::fs::write(&abs, &normalized).map_err(|source| Error::Io {
path: abs.clone(),
source,
})?;
Ok(FixOutcome::Applied(format!(
"normalized {} to {}",
path.display(),
self.target.name()
)))
}
}
fn normalize_line_endings(bytes: &[u8], target: LineEndingTarget) -> Vec<u8> {
let target_bytes = target.bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\n' {
if out.last().copied() == Some(b'\r') {
out.pop();
}
out.extend_from_slice(target_bytes);
} else {
out.push(bytes[i]);
}
i += 1;
}
out
}
#[derive(Debug)]
pub struct FileStripBidiFixer;
impl Fixer for FileStripBidiFixer {
fn describe(&self) -> String {
"strip Unicode bidi control characters".to_string()
}
fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
apply_char_filter(
"bidi",
"stripped bidi controls from",
violation,
ctx,
crate::no_bidi_controls::is_bidi_control,
false,
)
}
}
#[derive(Debug)]
pub struct FileStripZeroWidthFixer;
impl Fixer for FileStripZeroWidthFixer {
fn describe(&self) -> String {
"strip zero-width characters (U+200B/C/D, body-internal U+FEFF)".to_string()
}
fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
apply_char_filter(
"zero-width",
"stripped zero-width chars from",
violation,
ctx,
|c| matches!(c, '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}'),
true,
)
}
}
#[derive(Debug)]
pub struct FileStripBomFixer;
impl Fixer for FileStripBomFixer {
fn describe(&self) -> String {
"strip leading BOM".to_string()
}
fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
let Some(path) = &violation.path else {
return Ok(FixOutcome::Skipped(
"violation did not carry a path".to_string(),
));
};
let abs = ctx.root.join(path);
if ctx.dry_run {
return Ok(FixOutcome::Applied(format!(
"would strip BOM from {}",
path.display()
)));
}
let existing = match alint_core::read_for_fix(&abs, path, ctx)? {
alint_core::ReadForFix::Bytes(b) => b,
alint_core::ReadForFix::Skipped(outcome) => return Ok(outcome),
};
let Some(bom) = crate::no_bom::detect_bom(&existing) else {
return Ok(FixOutcome::Skipped(format!(
"{} has no BOM",
path.display()
)));
};
let stripped = &existing[bom.byte_len()..];
std::fs::write(&abs, stripped).map_err(|source| Error::Io {
path: abs.clone(),
source,
})?;
Ok(FixOutcome::Applied(format!(
"stripped {} BOM from {}",
bom.name(),
path.display()
)))
}
}
fn apply_char_filter(
label: &str,
verb: &str,
violation: &Violation,
ctx: &FixContext<'_>,
predicate: impl Fn(char) -> bool,
preserve_leading_feff: bool,
) -> Result<FixOutcome> {
let Some(path) = &violation.path else {
return Ok(FixOutcome::Skipped(
"violation did not carry a path".to_string(),
));
};
let abs = ctx.root.join(path);
if ctx.dry_run {
return Ok(FixOutcome::Applied(format!(
"would strip {label} chars from {}",
path.display()
)));
}
let existing = match alint_core::read_for_fix(&abs, path, ctx)? {
alint_core::ReadForFix::Bytes(b) => b,
alint_core::ReadForFix::Skipped(outcome) => return Ok(outcome),
};
let Ok(text) = std::str::from_utf8(&existing) else {
return Ok(FixOutcome::Skipped(format!(
"{} is not UTF-8; cannot filter {label} chars",
path.display()
)));
};
let mut out = String::with_capacity(text.len());
let mut first_char = true;
for c in text.chars() {
let keep_because_leading_bom = preserve_leading_feff && first_char && c == '\u{FEFF}';
if keep_because_leading_bom || !predicate(c) {
out.push(c);
}
first_char = false;
}
if out.as_bytes() == existing {
return Ok(FixOutcome::Skipped(format!(
"{} has no {label} chars to strip",
path.display()
)));
}
std::fs::write(&abs, out.as_bytes()).map_err(|source| Error::Io {
path: abs.clone(),
source,
})?;
Ok(FixOutcome::Applied(format!("{verb} {}", path.display())))
}
#[derive(Debug)]
pub struct FileCollapseBlankLinesFixer {
max: u32,
}
impl FileCollapseBlankLinesFixer {
pub fn new(max: u32) -> Self {
Self { max }
}
}
impl Fixer for FileCollapseBlankLinesFixer {
fn describe(&self) -> String {
format!("collapse runs of blank lines to at most {}", self.max)
}
fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
let Some(path) = &violation.path else {
return Ok(FixOutcome::Skipped(
"violation did not carry a path".to_string(),
));
};
let abs = ctx.root.join(path);
if ctx.dry_run {
return Ok(FixOutcome::Applied(format!(
"would collapse blank lines in {} to at most {}",
path.display(),
self.max,
)));
}
let existing = match alint_core::read_for_fix(&abs, path, ctx)? {
alint_core::ReadForFix::Bytes(b) => b,
alint_core::ReadForFix::Skipped(outcome) => return Ok(outcome),
};
let Ok(text) = std::str::from_utf8(&existing) else {
return Ok(FixOutcome::Skipped(format!(
"{} is not UTF-8; cannot collapse",
path.display()
)));
};
let collapsed = collapse_blank_lines(text, self.max);
if collapsed.as_bytes() == existing {
return Ok(FixOutcome::Skipped(format!(
"{} already clean",
path.display()
)));
}
std::fs::write(&abs, collapsed.as_bytes()).map_err(|source| Error::Io {
path: abs.clone(),
source,
})?;
Ok(FixOutcome::Applied(format!(
"collapsed blank-line runs in {} to at most {}",
path.display(),
self.max,
)))
}
}
pub(crate) fn line_is_blank(body: &str) -> bool {
body.bytes().all(|b| b == b' ' || b == b'\t')
}
pub(crate) fn collapse_blank_lines(text: &str, max: u32) -> String {
let mut out = String::with_capacity(text.len());
let mut blank_run: u32 = 0;
let mut remaining = text;
loop {
let (body, ending, rest) = match remaining.find('\n') {
Some(i) => {
let before = &remaining[..i];
let (body, cr) = match before.strip_suffix('\r') {
Some(s) => (s, "\r\n"),
None => (before, "\n"),
};
(body, cr, &remaining[i + 1..])
}
None => (remaining, "", ""),
};
let blank = line_is_blank(body);
if blank {
blank_run += 1;
if blank_run > max {
if ending.is_empty() {
break;
}
remaining = rest;
continue;
}
} else {
blank_run = 0;
}
out.push_str(body);
out.push_str(ending);
if ending.is_empty() {
break;
}
remaining = rest;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_ctx(tmp: &TempDir, dry_run: bool) -> FixContext<'_> {
FixContext {
root: tmp.path(),
dry_run,
fix_size_limit: None,
}
}
#[test]
fn file_create_writes_content_when_missing() {
let tmp = TempDir::new().unwrap();
let fixer = FileCreateFixer::new(PathBuf::from("LICENSE"), "Apache-2.0\n".into(), true);
let outcome = fixer
.apply(&Violation::new("missing LICENSE"), &make_ctx(&tmp, false))
.unwrap();
assert!(matches!(outcome, FixOutcome::Applied(_)));
let written = std::fs::read_to_string(tmp.path().join("LICENSE")).unwrap();
assert_eq!(written, "Apache-2.0\n");
}
#[test]
fn file_create_reads_content_from_relative_path() {
let tmp = TempDir::new().unwrap();
let template_dir = tmp.path().join(".alint/templates");
std::fs::create_dir_all(&template_dir).unwrap();
std::fs::write(
template_dir.join("LICENSE-MIT.txt"),
"MIT License\n\nCopyright (c) 2026 demo\n",
)
.unwrap();
let fixer = FileCreateFixer::new(
PathBuf::from("LICENSE"),
ContentSourceSpec::File(PathBuf::from(".alint/templates/LICENSE-MIT.txt")),
true,
);
let outcome = fixer
.apply(&Violation::new("missing LICENSE"), &make_ctx(&tmp, false))
.unwrap();
assert!(matches!(outcome, FixOutcome::Applied(_)));
let written = std::fs::read_to_string(tmp.path().join("LICENSE")).unwrap();
assert!(written.starts_with("MIT License"));
assert!(written.contains("Copyright (c) 2026"));
}
#[test]
fn file_create_skips_when_content_from_missing() {
let tmp = TempDir::new().unwrap();
let fixer = FileCreateFixer::new(
PathBuf::from("LICENSE"),
ContentSourceSpec::File(PathBuf::from("does/not/exist.txt")),
true,
);
let outcome = fixer
.apply(&Violation::new("missing"), &make_ctx(&tmp, false))
.unwrap();
let FixOutcome::Skipped(msg) = &outcome else {
panic!("expected Skipped, got {outcome:?}")
};
assert!(msg.contains("could not be read"));
assert!(!tmp.path().join("LICENSE").exists());
}
#[test]
fn file_prepend_with_content_from_reads_at_apply() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("hdr.txt"),
"// SPDX-License-Identifier: MIT\n",
)
.unwrap();
std::fs::write(tmp.path().join("a.rs"), "fn main() {}\n").unwrap();
let fixer = FilePrependFixer::new(ContentSourceSpec::File(PathBuf::from("hdr.txt")));
let outcome = fixer
.apply(
&Violation::new("missing header").with_path(PathBuf::from("a.rs")),
&make_ctx(&tmp, false),
)
.unwrap();
assert!(matches!(outcome, FixOutcome::Applied(_)));
let updated = std::fs::read_to_string(tmp.path().join("a.rs")).unwrap();
assert!(updated.starts_with("// SPDX-License-Identifier: MIT\n"));
assert!(updated.contains("fn main() {}"));
}
#[test]
fn file_create_creates_intermediate_directories() {
let tmp = TempDir::new().unwrap();
let fixer = FileCreateFixer::new(PathBuf::from("a/b/c/config.yaml"), "k: v\n".into(), true);
fixer
.apply(&Violation::new("missing"), &make_ctx(&tmp, false))
.unwrap();
assert!(tmp.path().join("a/b/c/config.yaml").exists());
}
#[test]
fn file_create_skips_when_target_exists() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("README.md"), "existing\n").unwrap();
let fixer = FileCreateFixer::new(PathBuf::from("README.md"), "NEW\n".into(), true);
let outcome = fixer
.apply(&Violation::new("x"), &make_ctx(&tmp, false))
.unwrap();
match outcome {
FixOutcome::Skipped(reason) => assert!(reason.contains("already exists")),
FixOutcome::Applied(_) => panic!("expected Skipped"),
}
assert_eq!(
std::fs::read_to_string(tmp.path().join("README.md")).unwrap(),
"existing\n",
"pre-existing content must not be overwritten"
);
}
#[test]
fn file_create_dry_run_does_not_touch_disk() {
let tmp = TempDir::new().unwrap();
let fixer = FileCreateFixer::new(PathBuf::from("x.txt"), "body".into(), true);
let outcome = fixer
.apply(&Violation::new("x"), &make_ctx(&tmp, true))
.unwrap();
match outcome {
FixOutcome::Applied(s) => assert!(s.starts_with("would create")),
FixOutcome::Skipped(_) => panic!("expected Applied"),
}
assert!(!tmp.path().join("x.txt").exists());
}
#[test]
fn file_remove_deletes_violating_path() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("debug.log");
std::fs::write(&target, "noise").unwrap();
let outcome = FileRemoveFixer
.apply(
&Violation::new("forbidden").with_path(std::path::Path::new("debug.log")),
&make_ctx(&tmp, false),
)
.unwrap();
assert!(matches!(outcome, FixOutcome::Applied(_)));
assert!(!target.exists());
}
#[test]
fn file_remove_skips_when_violation_has_no_path() {
let tmp = TempDir::new().unwrap();
let outcome = FileRemoveFixer
.apply(&Violation::new("no path"), &make_ctx(&tmp, false))
.unwrap();
match outcome {
FixOutcome::Skipped(reason) => assert!(reason.contains("path")),
FixOutcome::Applied(_) => panic!("expected Skipped"),
}
}
#[test]
fn file_remove_dry_run_keeps_the_file() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("victim.bak");
std::fs::write(&target, "bytes").unwrap();
let outcome = FileRemoveFixer
.apply(
&Violation::new("forbidden").with_path(std::path::Path::new("victim.bak")),
&make_ctx(&tmp, true),
)
.unwrap();
match outcome {
FixOutcome::Applied(s) => assert!(s.starts_with("would remove")),
FixOutcome::Skipped(_) => panic!("expected Applied"),
}
assert!(target.exists());
}
#[test]
fn file_prepend_inserts_at_start() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("a.rs"), "fn main() {}\n").unwrap();
let fixer = FilePrependFixer::new("// Copyright 2026\n".into());
fixer
.apply(
&Violation::new("missing header").with_path(std::path::Path::new("a.rs")),
&make_ctx(&tmp, false),
)
.unwrap();
assert_eq!(
std::fs::read_to_string(tmp.path().join("a.rs")).unwrap(),
"// Copyright 2026\nfn main() {}\n"
);
}
#[test]
fn file_prepend_preserves_utf8_bom() {
let tmp = TempDir::new().unwrap();
let mut bytes = b"\xEF\xBB\xBF".to_vec();
bytes.extend_from_slice(b"hello\n");
std::fs::write(tmp.path().join("x.txt"), &bytes).unwrap();
let fixer = FilePrependFixer::new("HEAD\n".into());
fixer
.apply(
&Violation::new("m").with_path(std::path::Path::new("x.txt")),
&make_ctx(&tmp, false),
)
.unwrap();
let got = std::fs::read(tmp.path().join("x.txt")).unwrap();
assert_eq!(&got[..3], b"\xEF\xBB\xBF");
assert_eq!(&got[3..], b"HEAD\nhello\n");
}
#[test]
fn file_prepend_dry_run_does_not_touch_disk() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("a.rs"), "original\n").unwrap();
FilePrependFixer::new("HEAD\n".into())
.apply(
&Violation::new("m").with_path(std::path::Path::new("a.rs")),
&make_ctx(&tmp, true),
)
.unwrap();
assert_eq!(
std::fs::read_to_string(tmp.path().join("a.rs")).unwrap(),
"original\n"
);
}
#[test]
fn file_prepend_skips_when_violation_has_no_path() {
let tmp = TempDir::new().unwrap();
let outcome = FilePrependFixer::new("h".into())
.apply(&Violation::new("m"), &make_ctx(&tmp, false))
.unwrap();
assert!(matches!(outcome, FixOutcome::Skipped(_)));
}
#[test]
fn file_append_writes_at_end() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("notes.md"), "# Notes\n").unwrap();
let fixer = FileAppendFixer::new("\n## Section\n".into());
fixer
.apply(
&Violation::new("missing section").with_path(std::path::Path::new("notes.md")),
&make_ctx(&tmp, false),
)
.unwrap();
assert_eq!(
std::fs::read_to_string(tmp.path().join("notes.md")).unwrap(),
"# Notes\n\n## Section\n"
);
}
#[test]
fn file_append_dry_run_leaves_file_unchanged() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("x.txt"), "orig\n").unwrap();
FileAppendFixer::new("extra\n".into())
.apply(
&Violation::new("m").with_path(std::path::Path::new("x.txt")),
&make_ctx(&tmp, true),
)
.unwrap();
assert_eq!(
std::fs::read_to_string(tmp.path().join("x.txt")).unwrap(),
"orig\n"
);
}
#[test]
fn file_append_skips_when_violation_has_no_path() {
let tmp = TempDir::new().unwrap();
let outcome = FileAppendFixer::new("x".into())
.apply(&Violation::new("m"), &make_ctx(&tmp, false))
.unwrap();
assert!(matches!(outcome, FixOutcome::Skipped(_)));
}
#[test]
fn file_rename_converts_stem_preserving_extension() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("FooBar.rs"), "fn main() {}\n").unwrap();
FileRenameFixer::new(CaseConvention::Snake)
.apply(
&Violation::new("case").with_path(std::path::Path::new("FooBar.rs")),
&make_ctx(&tmp, false),
)
.unwrap();
assert!(tmp.path().join("foo_bar.rs").exists());
assert!(!tmp.path().join("FooBar.rs").exists());
}
#[test]
fn file_rename_keeps_file_in_same_directory() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir(tmp.path().join("src")).unwrap();
std::fs::write(tmp.path().join("src/MyModule.rs"), "").unwrap();
FileRenameFixer::new(CaseConvention::Snake)
.apply(
&Violation::new("case").with_path(std::path::Path::new("src/MyModule.rs")),
&make_ctx(&tmp, false),
)
.unwrap();
assert!(tmp.path().join("src/my_module.rs").exists());
}
#[test]
fn file_rename_skips_when_already_in_target_case() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("foo_bar.rs"), "").unwrap();
let outcome = FileRenameFixer::new(CaseConvention::Snake)
.apply(
&Violation::new("case").with_path(std::path::Path::new("foo_bar.rs")),
&make_ctx(&tmp, false),
)
.unwrap();
match outcome {
FixOutcome::Skipped(reason) => assert!(reason.contains("already")),
FixOutcome::Applied(_) => panic!("expected Skipped"),
}
}
#[test]
fn file_rename_skips_on_target_collision() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("FooBar.rs"), "A").unwrap();
std::fs::write(tmp.path().join("foo_bar.rs"), "B").unwrap();
let outcome = FileRenameFixer::new(CaseConvention::Snake)
.apply(
&Violation::new("case").with_path(std::path::Path::new("FooBar.rs")),
&make_ctx(&tmp, false),
)
.unwrap();
match outcome {
FixOutcome::Skipped(reason) => assert!(reason.contains("already exists")),
FixOutcome::Applied(_) => panic!("expected Skipped"),
}
assert_eq!(
std::fs::read_to_string(tmp.path().join("FooBar.rs")).unwrap(),
"A"
);
assert_eq!(
std::fs::read_to_string(tmp.path().join("foo_bar.rs")).unwrap(),
"B"
);
}
#[test]
fn file_rename_dry_run_does_not_touch_disk() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("FooBar.rs"), "").unwrap();
FileRenameFixer::new(CaseConvention::Snake)
.apply(
&Violation::new("case").with_path(std::path::Path::new("FooBar.rs")),
&make_ctx(&tmp, true),
)
.unwrap();
assert!(tmp.path().join("FooBar.rs").exists());
assert!(!tmp.path().join("foo_bar.rs").exists());
}
#[test]
fn strip_trailing_whitespace_preserves_lf_and_crlf() {
assert_eq!(strip_trailing_whitespace("a \nb\t\n"), "a\nb\n");
assert_eq!(strip_trailing_whitespace("a \r\nb\t\r\n"), "a\r\nb\r\n");
}
#[test]
fn file_trim_trailing_whitespace_rewrites_in_place() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("x.rs"), "let _ = 1; \n").unwrap();
let outcome = FileTrimTrailingWhitespaceFixer
.apply(
&Violation::new("ws").with_path(std::path::Path::new("x.rs")),
&make_ctx(&tmp, false),
)
.unwrap();
assert!(matches!(outcome, FixOutcome::Applied(_)));
assert_eq!(
std::fs::read_to_string(tmp.path().join("x.rs")).unwrap(),
"let _ = 1;\n"
);
}
#[test]
fn file_trim_trailing_whitespace_honors_size_limit() {
let tmp = TempDir::new().unwrap();
let big = "x \n".repeat(2_000);
std::fs::write(tmp.path().join("big.txt"), &big).unwrap();
let ctx = FixContext {
root: tmp.path(),
dry_run: false,
fix_size_limit: Some(100),
};
let outcome = FileTrimTrailingWhitespaceFixer
.apply(
&Violation::new("ws").with_path(std::path::Path::new("big.txt")),
&ctx,
)
.unwrap();
match outcome {
FixOutcome::Skipped(reason) => {
assert!(reason.contains("fix_size_limit"), "{reason}");
}
FixOutcome::Applied(_) => panic!("expected Skipped on oversized file"),
}
assert_eq!(
std::fs::read_to_string(tmp.path().join("big.txt")).unwrap(),
big
);
}
#[test]
fn file_append_final_newline_adds_missing_newline() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("x.txt"), "hello").unwrap();
FileAppendFinalNewlineFixer
.apply(
&Violation::new("eof").with_path(std::path::Path::new("x.txt")),
&make_ctx(&tmp, false),
)
.unwrap();
assert_eq!(
std::fs::read_to_string(tmp.path().join("x.txt")).unwrap(),
"hello\n"
);
}
#[test]
fn normalize_line_endings_lf_target() {
let mixed = b"a\r\nb\nc\r\nd".to_vec();
let out = normalize_line_endings(&mixed, LineEndingTarget::Lf);
assert_eq!(out, b"a\nb\nc\nd");
}
#[test]
fn normalize_line_endings_crlf_target() {
let mixed = b"a\r\nb\nc\r\nd".to_vec();
let out = normalize_line_endings(&mixed, LineEndingTarget::Crlf);
assert_eq!(out, b"a\r\nb\r\nc\r\nd");
}
#[test]
fn file_normalize_line_endings_rewrites_to_lf() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("a.md"), "one\r\ntwo\r\n").unwrap();
FileNormalizeLineEndingsFixer::new(LineEndingTarget::Lf)
.apply(
&Violation::new("le").with_path(std::path::Path::new("a.md")),
&make_ctx(&tmp, false),
)
.unwrap();
assert_eq!(
std::fs::read_to_string(tmp.path().join("a.md")).unwrap(),
"one\ntwo\n"
);
}
#[test]
fn collapse_blank_lines_keeps_up_to_max() {
assert_eq!(collapse_blank_lines("a\n\n\nb\n", 1), "a\n\nb\n");
assert_eq!(collapse_blank_lines("a\n\n\n\nb\n", 2), "a\n\n\nb\n");
assert_eq!(collapse_blank_lines("a\nb\n", 1), "a\nb\n");
}
#[test]
fn collapse_blank_lines_preserves_trailing_newline() {
assert_eq!(collapse_blank_lines("a\n\n", 1), "a\n\n");
}
#[test]
fn collapse_blank_lines_max_zero_drops_all_blanks() {
assert_eq!(collapse_blank_lines("a\n\n\nb\n", 0), "a\nb\n");
assert_eq!(collapse_blank_lines("\n", 0), "");
assert_eq!(collapse_blank_lines("a\n\n", 0), "a\n");
}
#[test]
fn collapse_blank_lines_preserves_crlf() {
assert_eq!(
collapse_blank_lines("a\r\n\r\n\r\n\r\nb\r\n", 1),
"a\r\n\r\nb\r\n"
);
}
#[test]
fn collapse_blank_lines_treats_whitespace_only_as_blank() {
assert_eq!(collapse_blank_lines("a\n \n\t\n\nb\n", 1), "a\n \nb\n");
}
#[test]
fn collapse_blank_lines_no_op_on_empty_file() {
assert_eq!(collapse_blank_lines("", 2), "");
}
}