From 5b083975c7485304d7b41153da098f7be63f8d8f Mon Sep 17 00:00:00 2001 From: binhvq Date: Wed, 11 Nov 2026 16:02:46 +0700 Subject: [PATCH 1/3] diff: fold only ASCII letters for --ignore-file-name-case in the POSIX locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File names were always lowercased as Unicode text, so names such as É and é matched in the C locale, where GNU diff keeps them apart. Follow LC_CTYPE and fold only the ASCII letters in the POSIX locale. --- src/utils.rs | 8 +++--- tests/by-util/test_diff.rs | 52 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/utils.rs b/src/utils.rs index 29ab93df..d73c2ef1 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -163,11 +163,13 @@ pub fn quote_shell_argument(arg: &[u8]) -> Cow<'_, [u8]> { Cow::Owned(quoted) } -/// Returns `name` in lowercase, to match file names ignoring case. +/// Returns `name` in lowercase, to match file names ignoring case. On Unix, +/// the POSIX locale only has the ASCII letters. pub fn fold_file_name_case(name: &OsStr) -> Vec { + let ascii_only = cfg!(unix) && is_posix_locale("LC_CTYPE"); match name.to_str() { - Some(name) => name.to_lowercase().into_bytes(), - None => name.as_encoded_bytes().to_ascii_lowercase(), + Some(name) if !ascii_only => name.to_lowercase().into_bytes(), + _ => name.as_encoded_bytes().to_ascii_lowercase(), } } diff --git a/tests/by-util/test_diff.rs b/tests/by-util/test_diff.rs index 10fc0a10..74239868 100644 --- a/tests/by-util/test_diff.rs +++ b/tests/by-util/test_diff.rs @@ -748,6 +748,58 @@ fn compare_directories_special_entries() -> Result<(), Box Result<(), Box> { + let tmp_dir = tempdir()?; + let dir = tmp_dir.path(); + for (side, accented, plain) in [("one", "É", "Plain"), ("two", "é", "plain")] { + std::fs::create_dir(dir.join(side))?; + std::fs::write(dir.join(side).join(accented), "a\n")?; + std::fs::write(dir.join(side).join(plain), "a\n")?; + } + let diff = |locale: &[(&str, &str)], args: &[&str]| { + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.current_dir(dir) + .arg("diff") + .arg("--ignore-file-name-case"); + // LC_ALL overrides LC_CTYPE, which overrides LANG; empty values are unset. + for name in ["LC_ALL", "LC_CTYPE", "LANG"] { + cmd.env_remove(name); + } + cmd.envs(locale.iter().copied()).args(args); + cmd.assert() + }; + + for locale in [ + &[("LC_ALL", "C")][..], + &[("LC_ALL", "POSIX"), ("LC_CTYPE", "en_US.UTF-8")], + &[("LC_ALL", ""), ("LC_CTYPE", "C"), ("LANG", "en_US.UTF-8")], + &[("LANG", "C")], + &[], + ] { + diff(locale, &["-r", "one", "two"]) + .code(predicate::eq(1)) + .stdout("Only in one: É\nOnly in two: é\n"); + diff(locale, &["one/É", "two"]) + .code(predicate::eq(2)) + .stderr(predicate::str::contains("No such file or directory")); + } + for locale in [ + &[("LC_ALL", "C.UTF-8")][..], + &[("LC_CTYPE", "en_US.UTF-8"), ("LANG", "C")], + &[("LANG", "fr_FR.UTF-8")], + ] { + diff(locale, &["-r", "one", "two"]) + .code(predicate::eq(0)) + .stdout(""); + diff(locale, &["one/É", "two"]).code(predicate::eq(0)); + } + Ok(()) +} + #[test] fn directory_name_matching() -> Result<(), Box> { let tmp_dir = tempdir()?; From 8adb4c34493a6e207231e78a0ffbe624818a6389 Mon Sep 17 00:00:00 2001 From: binhvq Date: Wed, 11 Nov 2026 17:03:35 +0700 Subject: [PATCH 2/3] test: skip locale case folding test on case-insensitive file systems --- tests/by-util/test_diff.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/by-util/test_diff.rs b/tests/by-util/test_diff.rs index 74239868..44a726a8 100644 --- a/tests/by-util/test_diff.rs +++ b/tests/by-util/test_diff.rs @@ -760,6 +760,11 @@ fn ignore_file_name_case_is_ascii_only_in_posix_locale() -> Result<(), Box Date: Thu, 12 Nov 2026 04:04:20 +0700 Subject: [PATCH 3/3] tests: port the POSIX locale case test to uutests --- tests/by-util/test_diff.rs | 50 +++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 28 deletions(-) diff --git a/tests/by-util/test_diff.rs b/tests/by-util/test_diff.rs index 21cee47c..95851241 100644 --- a/tests/by-util/test_diff.rs +++ b/tests/by-util/test_diff.rs @@ -578,57 +578,51 @@ fn recursive_loop_on_one_side_only() { // does not make É and é the same name there. #[cfg(unix)] #[test] -fn ignore_file_name_case_is_ascii_only_in_posix_locale() -> Result<(), Box> { - let tmp_dir = tempdir()?; - let dir = tmp_dir.path(); +fn ignore_file_name_case_is_ascii_only_in_posix_locale() { + let (at, ts) = at_and_ts!(); for (side, accented, plain) in [("one", "É", "Plain"), ("two", "é", "plain")] { - std::fs::create_dir(dir.join(side))?; - std::fs::write(dir.join(side).join(accented), "a\n")?; - std::fs::write(dir.join(side).join(plain), "a\n")?; + at.mkdir(side); + at.write(&format!("{side}/{accented}"), "a\n"); + at.write(&format!("{side}/{plain}"), "a\n"); } - if dir.join("two/É").exists() { + if at.file_exists("two/É") { // The file system is not case-sensitive, so it finds é under // either name whatever the locale. - return Ok(()); + return; } + // LC_ALL overrides LC_CTYPE, which overrides LANG; an empty value counts + // as unset, which replaces the LC_ALL=C that ucmd sets. let diff = |locale: &[(&str, &str)], args: &[&str]| { - let mut cmd = cargo_bin_cmd!("diffutils"); - cmd.current_dir(dir) - .arg("diff") - .arg("--ignore-file-name-case"); - // LC_ALL overrides LC_CTYPE, which overrides LANG; empty values are unset. - for name in ["LC_ALL", "LC_CTYPE", "LANG"] { - cmd.env_remove(name); - } - cmd.envs(locale.iter().copied()).args(args); - cmd.assert() + ts.ucmd() + .env("LC_ALL", "") + .envs(locale.iter().copied()) + .arg("--ignore-file-name-case") + .args(args) + .run() }; for locale in [ &[("LC_ALL", "C")][..], &[("LC_ALL", "POSIX"), ("LC_CTYPE", "en_US.UTF-8")], - &[("LC_ALL", ""), ("LC_CTYPE", "C"), ("LANG", "en_US.UTF-8")], + &[("LC_CTYPE", "C"), ("LANG", "en_US.UTF-8")], &[("LANG", "C")], &[], ] { diff(locale, &["-r", "one", "two"]) - .code(predicate::eq(1)) - .stdout("Only in one: É\nOnly in two: é\n"); + .code_is(1) + .stdout_only("Only in one: É\nOnly in two: é\n"); diff(locale, &["one/É", "two"]) - .code(predicate::eq(2)) - .stderr(predicate::str::contains("No such file or directory")); + .code_is(2) + .stderr_contains("No such file or directory"); } for locale in [ &[("LC_ALL", "C.UTF-8")][..], &[("LC_CTYPE", "en_US.UTF-8"), ("LANG", "C")], &[("LANG", "fr_FR.UTF-8")], ] { - diff(locale, &["-r", "one", "two"]) - .code(predicate::eq(0)) - .stdout(""); - diff(locale, &["one/É", "two"]).code(predicate::eq(0)); + diff(locale, &["-r", "one", "two"]).code_is(0).no_output(); + diff(locale, &["one/É", "two"]).code_is(0); } - Ok(()) } #[test]