use crate::cli::global::GlobalFlags;
use crate::diff::{render_diffs_colored, render_diffs_plain};
use crate::exit;
use crate::plan::Operation;
use crate::tx::engine::{ExecuteOptions, ExecutionResult, execute_operations};
use crate::write::{apply_policy, policy_from_flags};
use clap::Args;
use serde::Serialize;
use std::path::Path;
#[derive(Debug, Args)]
#[command(after_help = "\
EXAMPLES:
patchloom tidy src/ --ensure-final-newline
patchloom tidy . --trim-trailing-whitespace --apply
patchloom tidy . --normalize-eol lf --check")]
pub struct TidyArgs {
#[command(subcommand)]
pub action: TidyAction,
#[command(flatten)]
pub write: crate::cli::global::WriteFlags,
}
#[derive(Debug, clap::Subcommand)]
pub enum TidyAction {
Check { paths: Vec<String> },
Fix {
paths: Vec<String>,
#[arg(long)]
dedent: Option<String>,
#[arg(long)]
indent: Option<String>,
#[arg(long)]
lines: Option<String>,
},
}
#[derive(Debug, Clone, Serialize)]
pub struct TidyIssue {
pub path: String,
pub issue: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub line: Option<usize>,
}
fn check_file(
path: &Path,
quiet: bool,
eol_target: Option<crate::write::EolMode>,
check_trailing_ws: bool,
) -> Vec<TidyIssue> {
let Some(text) = crate::files::read_text_file_logged(path, "tidy", quiet) else {
return Vec::new();
};
let data = text.as_bytes();
let path_str = path.to_string_lossy().into_owned();
let mut issues = Vec::new();
if !data.is_empty() && !data.ends_with(b"\n") {
issues.push(TidyIssue {
path: path_str.clone(),
issue: "missing final newline",
line: None,
});
}
let has_crlf = memchr::memmem::find(data, b"\r\n").is_some();
let has_bare_lf = memchr::memchr_iter(b'\n', data).any(|i| i == 0 || data[i - 1] != b'\r');
if has_crlf && has_bare_lf {
issues.push(TidyIssue {
path: path_str.clone(),
issue: "mixed line endings",
line: None,
});
}
if let Some(target) = eol_target {
let has_cr_only =
memchr::memchr_iter(b'\r', data).any(|i| i + 1 >= data.len() || data[i + 1] != b'\n');
match target {
crate::write::EolMode::Lf => {
if has_crlf || has_cr_only {
issues.push(TidyIssue {
path: path_str.clone(),
issue: "line endings need normalization to LF",
line: None,
});
}
}
crate::write::EolMode::Crlf => {
if has_bare_lf || has_cr_only {
issues.push(TidyIssue {
path: path_str.clone(),
issue: "line endings need normalization to CRLF",
line: None,
});
}
}
crate::write::EolMode::Cr => {
if has_crlf || has_bare_lf {
issues.push(TidyIssue {
path: path_str.clone(),
issue: "line endings need normalization to CR",
line: None,
});
}
}
crate::write::EolMode::Keep => {}
}
}
if !check_trailing_ws {
return issues;
}
for (line_idx, raw_line) in data.split(|&b| b == b'\n').enumerate() {
let content = raw_line.strip_suffix(b"\r").unwrap_or(raw_line);
if content.is_empty() {
continue;
}
if matches!(content.last(), Some(b' ' | b'\t')) {
issues.push(TidyIssue {
path: path_str.clone(),
issue: "trailing whitespace",
line: Some(line_idx + 1), });
}
}
issues
}
#[cfg(feature = "cli")]
fn editorconfig_eol(path: &Path) -> Option<crate::write::EolMode> {
let props = ec4rs::properties_of(path).ok()?;
let val = props.get::<ec4rs::property::EndOfLine>().ok()?;
Some(match val {
ec4rs::property::EndOfLine::Lf => crate::write::EolMode::Lf,
ec4rs::property::EndOfLine::CrLf => crate::write::EolMode::Crlf,
ec4rs::property::EndOfLine::Cr => crate::write::EolMode::Cr,
})
}
#[cfg(not(feature = "cli"))]
fn editorconfig_eol(_path: &Path) -> Option<crate::write::EolMode> {
None
}
#[cfg(feature = "cli")]
fn editorconfig_trim_ws(path: &Path) -> Option<bool> {
let props = ec4rs::properties_of(path).ok()?;
match props.get::<ec4rs::property::TrimTrailingWs>() {
Ok(ec4rs::property::TrimTrailingWs::Value(v)) => Some(v),
_ => None,
}
}
#[cfg(not(feature = "cli"))]
fn editorconfig_trim_ws(_path: &Path) -> Option<bool> {
None
}
fn collect_issues(paths: &[String], global: &GlobalFlags) -> anyhow::Result<Vec<TidyIssue>> {
let cwd = global.resolve_cwd()?;
let glob_matcher = crate::build_glob_matcher_from_global(global)?;
let file_paths = crate::collect_file_paths_opts(paths, global, true, Some(&cwd))?;
let glob_roots = crate::collect_glob_roots_from_global(paths, global, Some(&cwd))?;
let quiet = global.quiet;
let eol_target = global.normalize_eol;
let respect_ec = global.respect_editorconfig;
let file_issues: Vec<Vec<TidyIssue>> =
crate::par_process_files(&file_paths, glob_matcher.as_ref(), &glob_roots, |path| {
let file_eol_target =
eol_target.or_else(|| respect_ec.then(|| editorconfig_eol(path)).flatten());
let check_trailing_ws = if respect_ec {
match editorconfig_trim_ws(path) {
Some(false) => false,
Some(true) => true,
None => true, }
} else {
true
};
let issues = check_file(path, quiet, file_eol_target, check_trailing_ws);
if issues.is_empty() {
None
} else {
Some(issues)
}
});
Ok(file_issues.into_iter().flatten().collect())
}
#[derive(Debug, Serialize)]
struct TidyCheckOutput {
ok: bool,
issue_count: usize,
issues: Vec<TidyIssue>,
}
#[derive(Debug, Clone, Serialize)]
struct TidyFixFileResult {
path: String,
}
#[derive(Debug, Serialize)]
struct TidyFixOutput {
ok: bool,
files_changed: usize,
files: Vec<TidyFixFileResult>,
#[serde(skip_serializing_if = "Option::is_none")]
diff: Option<String>,
}
fn render_issues(issues: &[TidyIssue], global: &GlobalFlags) {
if global.json {
let output = TidyCheckOutput {
ok: issues.is_empty(),
issue_count: issues.len(),
issues: issues.to_vec(),
};
let _ = global.emit_json(&output);
} else if let Ok(true) = global.emit_json_items(issues) {
} else {
for issue in issues {
if let Some(line) = issue.line {
println!("{}:{}: {}", issue.path, line, issue.issue);
} else {
println!("{}: {}", issue.path, issue.issue);
}
}
}
}
pub fn run(args: TidyArgs, global: &GlobalFlags) -> anyhow::Result<u8> {
crate::verbose!("tidy: action={:?}", std::mem::discriminant(&args.action));
match args.action {
TidyAction::Check { paths } => {
crate::verbose!("tidy: checking {} path(s)", paths.len());
let issues = collect_issues(&paths, global)?;
if !global.quiet || global.json || global.jsonl {
render_issues(&issues, global);
}
if issues.is_empty() {
Ok(exit::SUCCESS)
} else {
Ok(exit::CHANGES_DETECTED)
}
}
TidyAction::Fix {
paths,
dedent,
indent,
lines,
} => {
crate::verbose!("tidy: fixing {} path(s)", paths.len());
if dedent.is_some() && indent.is_some() {
anyhow::bail!("--dedent and --indent cannot both be set");
}
let cwd = global.resolve_cwd()?;
let glob_matcher = crate::build_glob_matcher_from_global(global)?;
let fix_file_paths = crate::collect_file_paths_opts(&paths, global, true, Some(&cwd))?;
let glob_roots = crate::collect_glob_roots_from_global(&paths, global, Some(&cwd))?;
let line_range = lines
.as_deref()
.map(crate::ops::read::parse_line_range)
.transpose()?;
let quiet = global.quiet;
let dedent_ref = dedent.as_deref();
let indent_ref = indent.as_deref();
let dirty_rel_paths: Vec<String> = crate::par_process_files(
&fix_file_paths,
glob_matcher.as_ref(),
&glob_roots,
|file_path| {
let original =
match crate::files::read_text_file_logged(file_path, "tidy", quiet) {
Some(text) => text,
None => return None,
};
let policy = policy_from_flags(global, Some(file_path));
let mut fixed = apply_policy(&original, &policy).into_owned();
if let Some(spec) = dedent_ref {
fixed = crate::write::dedent_content(&fixed, spec, line_range);
}
if let Some(spec) = indent_ref {
fixed = crate::write::indent_content(&fixed, spec, line_range);
}
if fixed == *original {
return None;
}
let rel_path = file_path
.strip_prefix(&cwd)
.unwrap_or(file_path)
.to_string_lossy()
.to_string();
Some(rel_path)
},
);
crate::verbose!("tidy: {} file(s) need fixing", dirty_rel_paths.len());
if dirty_rel_paths.is_empty() {
emit_tidy_fix_output(global, &[], None)?;
return Ok(exit::SUCCESS);
}
let eol_str = global.normalize_eol.map(eol_mode_to_str);
let collapse = if global.collapse_blanks {
Some(true)
} else {
None
};
let ops: Vec<Operation> = dirty_rel_paths
.iter()
.map(|rel_path| Operation::TidyFix {
path: rel_path.clone(),
ensure_final_newline: Some(global.ensure_final_newline),
trim_trailing_whitespace: Some(global.trim_trailing_whitespace),
normalize_eol: eol_str.map(String::from),
collapse_blanks: collapse,
dedent: dedent.clone(),
indent: indent.clone(),
lines: lines.clone(),
})
.collect();
let options = ExecuteOptions {
cwd: &cwd,
global,
guard: None,
};
let result = execute_operations(ops, options)?;
tidy_fix_output(global, result, &dirty_rel_paths, &cwd)
}
}
}
fn eol_mode_to_str(mode: crate::cli::global::EolMode) -> &'static str {
match mode {
crate::cli::global::EolMode::Lf => "lf",
crate::cli::global::EolMode::Crlf => "crlf",
crate::cli::global::EolMode::Cr => "cr",
crate::cli::global::EolMode::Keep => "keep",
}
}
fn tidy_fix_output(
global: &GlobalFlags,
result: ExecutionResult,
dirty_rel_paths: &[String],
cwd: &Path,
) -> anyhow::Result<u8> {
let fix_files: Vec<TidyFixFileResult> = dirty_rel_paths
.iter()
.map(|p| TidyFixFileResult { path: p.clone() })
.collect();
if global.check {
emit_tidy_fix_output(global, &fix_files, None)?;
if !global.quiet && !global.json && !global.jsonl {
for p in dirty_rel_paths {
println!("{p}");
}
}
return Ok(exit::CHANGES_DETECTED);
}
if global.apply {
let diffs = result.build_diffs();
result.commit()?;
crate::write::run_format_command(global, cwd)?;
let diff_text = if global.diff {
Some(render_diffs_plain(&diffs))
} else {
None
};
emit_tidy_fix_output(global, &fix_files, diff_text)?;
return Ok(exit::SUCCESS);
}
let diffs = result.build_diffs();
let diff_text = if !diffs.is_empty() {
Some(render_diffs_plain(&diffs))
} else {
None
};
emit_tidy_fix_output(global, &fix_files, diff_text)?;
if !global.json && !global.jsonl && !global.quiet && !diffs.is_empty() {
print!("{}", render_diffs_colored(&diffs, global.should_color()));
}
if global.show_status() {
eprintln!("{} file(s) changed", dirty_rel_paths.len());
}
if global.should_apply() {
result.commit()?;
crate::write::run_format_command(global, cwd)?;
return Ok(exit::SUCCESS);
}
Ok(exit::CHANGES_DETECTED)
}
fn emit_tidy_fix_output(
global: &GlobalFlags,
fix_files: &[TidyFixFileResult],
diff_text: Option<String>,
) -> anyhow::Result<()> {
if global.json {
let output = TidyFixOutput {
ok: true,
files_changed: fix_files.len(),
files: fix_files.to_vec(),
diff: diff_text,
};
let _ = global.emit_json(&output);
} else if global.jsonl {
let _ = global.emit_json_items(fix_files);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::global::GlobalFlags;
use tempfile::TempDir;
#[test]
fn detects_missing_final_newline() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("no_newline.txt");
std::fs::write(&file, b"hello").unwrap();
let issues = check_file(&file, true, None, true);
assert!(
issues.iter().any(|i| i.issue == "missing final newline"),
"expected missing final newline issue, got: {issues:?}"
);
}
#[test]
fn empty_file_no_missing_newline() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("empty.txt");
std::fs::write(&file, b"").unwrap();
let issues = check_file(&file, true, None, true);
assert!(
!issues.iter().any(|i| i.issue == "missing final newline"),
"empty file should not be flagged for missing final newline: {issues:?}"
);
}
#[test]
fn detects_mixed_line_endings() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("mixed.txt");
std::fs::write(&file, b"line1\r\nline2\nline3\n").unwrap();
let issues = check_file(&file, true, None, true);
assert!(
issues.iter().any(|i| i.issue == "mixed line endings"),
"expected mixed line endings issue, got: {issues:?}"
);
}
#[test]
fn detects_trailing_whitespace() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("trailing.txt");
std::fs::write(&file, b"hello \nworld\n").unwrap();
let issues = check_file(&file, true, None, true);
let trailing: Vec<_> = issues
.iter()
.filter(|i| i.issue == "trailing whitespace")
.collect();
assert_eq!(trailing.len(), 1, "expected 1 trailing whitespace issue");
assert_eq!(trailing[0].line, Some(1));
}
#[test]
fn clean_file_produces_no_issues_and_exit_zero() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("clean.txt");
std::fs::write(&file, b"hello\nworld\n").unwrap();
let issues = check_file(&file, true, None, true);
assert!(issues.is_empty(), "expected no issues for clean file");
let global = GlobalFlags::test_with_cwd(tmp.path());
let args = TidyArgs {
action: TidyAction::Check {
paths: vec![".".to_string()],
},
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
}
#[test]
fn multiple_issues_in_one_file() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("multi.txt");
std::fs::write(&file, b"hello \r\nworld\nfoo").unwrap();
let issues = check_file(&file, true, None, true);
let issue_types: Vec<&str> = issues.iter().map(|i| i.issue).collect();
assert!(
issue_types.contains(&"missing final newline"),
"missing final newline not found in {issue_types:?}"
);
assert!(
issue_types.contains(&"mixed line endings"),
"mixed line endings not found in {issue_types:?}"
);
assert!(
issue_types.contains(&"trailing whitespace"),
"trailing whitespace not found in {issue_types:?}"
);
}
#[test]
fn binary_files_are_skipped() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("binary.bin");
let mut data = b"hello \nworld".to_vec();
data.insert(3, 0x00);
std::fs::write(&file, &data).unwrap();
let issues = check_file(&file, true, None, true);
assert!(
issues.is_empty(),
"expected no issues for binary file, got: {issues:?}"
);
}
#[test]
fn large_binary_files_are_skipped_via_header_probe() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("large.bin");
let mut data = vec![b'a'; 10_000];
data[4096] = 0;
std::fs::write(&file, &data).unwrap();
let issues = check_file(&file, true, None, true);
assert!(
issues.is_empty(),
"expected no issues for large binary file, got: {issues:?}"
);
}
#[test]
fn glob_filtering_works() {
let tmp = TempDir::new().unwrap();
let rs_file = tmp.path().join("clean.rs");
std::fs::write(&rs_file, b"fn main() {}\n").unwrap();
let txt_file = tmp.path().join("dirty.txt");
std::fs::write(&txt_file, b"no newline").unwrap();
let mut global = GlobalFlags::test_with_cwd(tmp.path());
global.glob = vec!["*.rs".to_string()];
let issues = collect_issues(&[".".to_string()], &global).unwrap();
assert!(
issues.is_empty(),
"expected no issues when filtering to *.rs, got: {issues:?}"
);
global.glob = vec!["*.txt".to_string()];
let issues = collect_issues(&[".".to_string()], &global).unwrap();
assert!(
!issues.is_empty(),
"expected issues when filtering to *.txt"
);
assert!(issues.iter().any(|i| i.issue == "missing final newline"));
}
#[test]
fn check_returns_changes_detected_for_dirty_file() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("no_newline.txt");
std::fs::write(&file, b"hello").unwrap();
let global = GlobalFlags::test_with_cwd(tmp.path());
let args = TidyArgs {
action: TidyAction::Check {
paths: vec![".".to_string()],
},
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(code, exit::CHANGES_DETECTED);
}
#[test]
fn fix_adds_missing_newline() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("no_nl.txt");
std::fs::write(&file, b"hello").unwrap();
let mut global = GlobalFlags::test_with_cwd(tmp.path());
global.ensure_final_newline = true;
global.apply = true;
let args = TidyArgs {
action: TidyAction::Fix {
paths: vec![".".to_string()],
dedent: None,
indent: None,
lines: None,
},
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
let content = std::fs::read(&file).unwrap();
assert!(
content.ends_with(b"\n"),
"expected file to end with newline after fix"
);
}
#[test]
fn fix_normalizes_eol() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("crlf.txt");
std::fs::write(&file, b"line1\r\nline2\r\n").unwrap();
let mut global = GlobalFlags::test_with_cwd(tmp.path());
global.normalize_eol = Some(crate::cli::global::EolMode::Lf);
global.apply = true;
let args = TidyArgs {
action: TidyAction::Fix {
paths: vec![".".to_string()],
dedent: None,
indent: None,
lines: None,
},
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
let content = std::fs::read(&file).unwrap();
assert!(
!content.windows(2).any(|w| w == b"\r\n"),
"expected no CRLF sequences after fix"
);
}
#[test]
fn fix_trims_trailing_whitespace() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("trailing.txt");
std::fs::write(&file, b"hello \nworld\t\n").unwrap();
let mut global = GlobalFlags::test_with_cwd(tmp.path());
global.trim_trailing_whitespace = true;
global.apply = true;
let args = TidyArgs {
action: TidyAction::Fix {
paths: vec![".".to_string()],
dedent: None,
indent: None,
lines: None,
},
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(content, "hello\nworld\n");
}
#[test]
fn fix_is_idempotent_on_clean_file() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("clean.txt");
std::fs::write(&file, b"hello\nworld\n").unwrap();
let mut global = GlobalFlags::test_with_cwd(tmp.path());
global.ensure_final_newline = true;
global.trim_trailing_whitespace = true;
global.normalize_eol = Some(crate::cli::global::EolMode::Lf);
global.apply = true;
let args = TidyArgs {
action: TidyAction::Fix {
paths: vec![".".to_string()],
dedent: None,
indent: None,
lines: None,
},
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(content, "hello\nworld\n");
}
#[test]
fn fix_dry_run_returns_changes_detected_when_dirty() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("no_nl.txt");
std::fs::write(&file, b"hello").unwrap();
let mut global = GlobalFlags::test_with_cwd(tmp.path());
global.ensure_final_newline = true;
let args = TidyArgs {
action: TidyAction::Fix {
paths: vec![".".to_string()],
dedent: None,
indent: None,
lines: None,
},
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(code, exit::CHANGES_DETECTED);
let content = std::fs::read(&file).unwrap();
assert_eq!(content, b"hello");
}
#[test]
fn fix_dry_run_returns_success_when_clean() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("clean.txt");
std::fs::write(&file, b"hello\n").unwrap();
let mut global = GlobalFlags::test_with_cwd(tmp.path());
global.ensure_final_newline = true;
let args = TidyArgs {
action: TidyAction::Fix {
paths: vec![".".to_string()],
dedent: None,
indent: None,
lines: None,
},
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
}
#[test]
fn fix_apply_creates_backup_session() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("no_nl.txt");
std::fs::write(&file, b"hello").unwrap();
let mut global = GlobalFlags::test_with_cwd(tmp.path());
global.ensure_final_newline = true;
global.apply = true;
let args = TidyArgs {
action: TidyAction::Fix {
paths: vec![".".to_string()],
dedent: None,
indent: None,
lines: None,
},
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
let backup_dir = tmp.path().join(".patchloom").join("backups");
assert!(
backup_dir.exists(),
"backup directory should exist after tidy fix --apply"
);
let sessions: Vec<_> = std::fs::read_dir(&backup_dir)
.unwrap()
.filter_map(|e| e.ok())
.collect();
assert!(
!sessions.is_empty(),
"at least one backup session should be created"
);
}
#[test]
fn eol_mode_conversion_round_trips() {
use crate::cli::global::EolMode;
assert_eq!(eol_mode_to_str(EolMode::Lf), "lf");
assert_eq!(eol_mode_to_str(EolMode::Crlf), "crlf");
assert_eq!(eol_mode_to_str(EolMode::Cr), "cr");
assert_eq!(eol_mode_to_str(EolMode::Keep), "keep");
}
#[test]
fn check_quiet_still_emits_json() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("no_nl.txt");
std::fs::write(&file, b"hello").unwrap();
let mut global = GlobalFlags::test_with_cwd(tmp.path());
global.quiet = true;
global.json = true;
let issues = collect_issues(&[".".to_string()], &global).unwrap();
assert!(!issues.is_empty(), "should detect issues even when quiet");
let args = TidyArgs {
action: TidyAction::Check {
paths: vec![".".to_string()],
},
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(
code,
exit::CHANGES_DETECTED,
"exit code must reflect issues even with --quiet"
);
}
#[test]
fn collapse_blanks_flag_forwarded_to_tidy_fix() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("blank.txt");
std::fs::write(&file, "a\n\n\n\nb\n").unwrap();
let mut global = GlobalFlags::test_with_cwd(tmp.path());
global.apply = true;
global.collapse_blanks = true;
let args = TidyArgs {
action: TidyAction::Fix {
paths: vec![".".to_string()],
dedent: None,
indent: None,
lines: None,
},
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(content, "a\n\nb\n");
}
#[test]
fn check_detects_crlf_when_eol_target_is_lf() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("crlf.txt");
std::fs::write(&file, b"line1\r\nline2\r\n").unwrap();
let issues = check_file(&file, true, None, true);
assert!(
!issues.iter().any(|i| i.issue.contains("normalization")),
"no normalization issue without eol target: {issues:?}"
);
let issues = check_file(&file, true, Some(crate::write::EolMode::Lf), true);
assert!(
issues
.iter()
.any(|i| i.issue.contains("normalization to LF")),
"expected LF normalization issue, got: {issues:?}"
);
let mut global = GlobalFlags::test_with_cwd(tmp.path());
global.normalize_eol = Some(crate::write::EolMode::Lf);
let args = TidyArgs {
action: TidyAction::Check {
paths: vec![".".to_string()],
},
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(
code,
exit::CHANGES_DETECTED,
"tidy check --normalize-eol lf must return CHANGES_DETECTED for CRLF file"
);
}
#[test]
fn check_detects_lf_when_eol_target_is_crlf() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("lf.txt");
std::fs::write(&file, b"line1\nline2\n").unwrap();
let issues = check_file(&file, true, Some(crate::write::EolMode::Crlf), true);
assert!(
issues
.iter()
.any(|i| i.issue.contains("normalization to CRLF")),
"expected CRLF normalization issue, got: {issues:?}"
);
}
#[test]
fn check_no_eol_issue_when_already_matching() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("lf.txt");
std::fs::write(&file, b"line1\nline2\n").unwrap();
let issues = check_file(&file, true, Some(crate::write::EolMode::Lf), true);
assert!(
!issues.iter().any(|i| i.issue.contains("normalization")),
"LF file with LF target should not be flagged: {issues:?}"
);
}
#[test]
fn check_respect_editorconfig_detects_eol_mismatch() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join(".editorconfig"),
"root = true\n\n[*.txt]\nend_of_line = crlf\n",
)
.unwrap();
let file = tmp.path().join("test.txt");
std::fs::write(&file, b"line1\nline2\n").unwrap();
let mut global = GlobalFlags::test_with_cwd(tmp.path());
global.respect_editorconfig = true;
let args = TidyArgs {
action: TidyAction::Check {
paths: vec![".".to_string()],
},
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(
code,
exit::CHANGES_DETECTED,
"tidy check --respect-editorconfig must detect LF when editorconfig says CRLF"
);
}
#[test]
fn check_respect_editorconfig_suppresses_trailing_ws() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join(".editorconfig"),
"root = true\n\n[*.md]\ntrim_trailing_whitespace = false\n",
)
.unwrap();
let file = tmp.path().join("readme.md");
std::fs::write(&file, "Hello \nWorld\n").unwrap();
let mut global = GlobalFlags::test_with_cwd(tmp.path());
global.respect_editorconfig = true;
let issues = collect_issues(&[".".to_string()], &global).unwrap();
assert!(
!issues.iter().any(|i| i.issue == "trailing whitespace"),
"trailing whitespace should not be reported when editorconfig says \
trim_trailing_whitespace = false, got: {issues:?}"
);
let args = TidyArgs {
action: TidyAction::Check {
paths: vec![".".to_string()],
},
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(
code,
exit::SUCCESS,
"tidy check --respect-editorconfig must not flag trailing ws \
when editorconfig says trim_trailing_whitespace = false"
);
}
#[test]
fn check_respect_editorconfig_flags_trailing_ws_when_true() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join(".editorconfig"),
"root = true\n\n[*.rs]\ntrim_trailing_whitespace = true\n",
)
.unwrap();
let file = tmp.path().join("main.rs");
std::fs::write(&file, "fn main() \n").unwrap();
let mut global = GlobalFlags::test_with_cwd(tmp.path());
global.respect_editorconfig = true;
let issues = collect_issues(&[".".to_string()], &global).unwrap();
assert!(
issues.iter().any(|i| i.issue == "trailing whitespace"),
"trailing whitespace should still be flagged when editorconfig says \
trim_trailing_whitespace = true, got: {issues:?}"
);
}
#[test]
fn check_file_skips_trailing_ws_when_disabled() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("test.txt");
std::fs::write(&file, "hello \nworld\n").unwrap();
let issues = check_file(&file, true, None, true);
assert!(
issues.iter().any(|i| i.issue == "trailing whitespace"),
"should find trailing ws with check_trailing_ws=true"
);
let issues = check_file(&file, true, None, false);
assert!(
!issues.iter().any(|i| i.issue == "trailing whitespace"),
"should not find trailing ws with check_trailing_ws=false"
);
}
}