From a1519e1b74f204dde51986127214f942bd4091cb Mon Sep 17 00:00:00 2001 From: Nicolas Boichat Date: Mon, 28 Jul 2025 16:35:32 +0800 Subject: [PATCH 01/10] ls: Add support for --time-style=posix-* From GNU manual, if LC_TIME=POSIX, then the "locale" timestamp is used. Else, `posix-` is stripped and the format is parsed as usual. Also, just move the whole text to the locale file, instead of trying to generate it manually. --- src/uu/ls/locales/en-US.ftl | 7 ++++++- src/uu/ls/locales/fr-FR.ftl | 7 ++++++- src/uu/ls/src/ls.rs | 35 +++++++++++++++++++--------------- tests/by-util/test_ls.rs | 38 +++++++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 17 deletions(-) diff --git a/src/uu/ls/locales/en-US.ftl b/src/uu/ls/locales/en-US.ftl index 308e9d6d727..43660330538 100644 --- a/src/uu/ls/locales/en-US.ftl +++ b/src/uu/ls/locales/en-US.ftl @@ -16,7 +16,12 @@ ls-error-invalid-block-size = invalid --block-size argument {$size} ls-error-dired-and-zero-incompatible = --dired and --zero are incompatible ls-error-not-listing-already-listed = {$path}: not listing already-listed directory ls-error-invalid-time-style = invalid --time-style argument {$style} - Possible values are: {$values} + Possible values are: + - [posix-]full-iso + - [posix-]long-iso + - [posix-]iso + - [posix-]locale + - +FORMAT (e.g., +%H:%M) for a 'date'-style format For more information try --help diff --git a/src/uu/ls/locales/fr-FR.ftl b/src/uu/ls/locales/fr-FR.ftl index 9aa81bd282e..e5016594685 100644 --- a/src/uu/ls/locales/fr-FR.ftl +++ b/src/uu/ls/locales/fr-FR.ftl @@ -16,7 +16,12 @@ ls-error-invalid-block-size = argument --block-size invalide {$size} ls-error-dired-and-zero-incompatible = --dired et --zero sont incompatibles ls-error-not-listing-already-listed = {$path} : ne liste pas un répertoire déjà listé ls-error-invalid-time-style = argument --time-style invalide {$style} - Les valeurs possibles sont : {$values} + Les valeurs possibles sont : + - [posix-]full-iso + - [posix-]long-iso + - [posix-]iso + - [posix-]locale + - +FORMAT (e.g., +%H:%M) pour un format de type 'date' Pour plus d'informations, essayez --help diff --git a/src/uu/ls/src/ls.rs b/src/uu/ls/src/ls.rs index 31108229bd1..41240365da4 100644 --- a/src/uu/ls/src/ls.rs +++ b/src/uu/ls/src/ls.rs @@ -203,8 +203,8 @@ enum LsError { #[error("{}", translate!("ls-error-not-listing-already-listed", "path" => .0.to_string_lossy()))] AlreadyListedError(PathBuf), - #[error("{}", translate!("ls-error-invalid-time-style", "style" => .0.quote(), "values" => format!("{:?}", .1)))] - TimeStyleParseError(String, Vec), + #[error("{}", translate!("ls-error-invalid-time-style", "style" => .0.quote()))] + TimeStyleParseError(String), } impl UError for LsError { @@ -217,7 +217,7 @@ impl UError for LsError { Self::BlockSizeParseError(_) => 2, Self::DiredAndZeroAreIncompatible => 2, Self::AlreadyListedError(_) => 2, - Self::TimeStyleParseError(_, _) => 2, + Self::TimeStyleParseError(_) => 2, } } } @@ -260,13 +260,6 @@ fn parse_time_style(options: &clap::ArgMatches) -> Result<(String, Option)) -> Result<(String, Option), LsError> { @@ -282,14 +275,26 @@ fn parse_time_style(options: &clap::ArgMatches) -> Result<(String, Option ok(*formats), None => match field.chars().next().unwrap() { '+' => Ok((field[1..].to_string(), None)), - _ => Err(LsError::TimeStyleParseError( - String::from(field), - possible_time_styles.collect(), - )), + _ => Err(LsError::TimeStyleParseError(String::from(field))), }, } } diff --git a/tests/by-util/test_ls.rs b/tests/by-util/test_ls.rs index 66227d06a59..f0c1986c9fb 100644 --- a/tests/by-util/test_ls.rs +++ b/tests/by-util/test_ls.rs @@ -1971,6 +1971,44 @@ fn test_ls_styles() { .succeeds() .stdout_matches(&re_locale); + //posix-full-iso + scene + .ucmd() + .arg("-l") + .arg("--time-style=posix-full-iso") + .succeeds() + .stdout_matches(&re_full); + //posix-long-iso + scene + .ucmd() + .arg("-l") + .arg("--time-style=posix-long-iso") + .succeeds() + .stdout_matches(&re_long); + //posix-iso + scene + .ucmd() + .arg("-l") + .arg("--time-style=posix-iso") + .succeeds() + .stdout_matches(&re_iso); + + //posix-* with LC_TIME/LC_ALL=POSIX is equivalent to locale + scene + .ucmd() + .env("LC_TIME", "POSIX") + .arg("-l") + .arg("--time-style=posix-full-iso") + .succeeds() + .stdout_matches(&re_locale); + scene + .ucmd() + .env("LC_ALL", "POSIX") + .arg("-l") + .arg("--time-style=posix-iso") + .succeeds() + .stdout_matches(&re_locale); + //+FORMAT scene .ucmd() From e5f2f79ea1e62de418be1b4017669fc35d28a733 Mon Sep 17 00:00:00 2001 From: Nicolas Boichat Date: Mon, 28 Jul 2025 17:56:58 +0800 Subject: [PATCH 02/10] ls: Allow reading time-style from TIME_STYLE environment variable According to GNU manual. --- src/uu/ls/src/ls.rs | 8 ++++++-- tests/by-util/test_ls.rs | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/uu/ls/src/ls.rs b/src/uu/ls/src/ls.rs index 41240365da4..341e8bac071 100644 --- a/src/uu/ls/src/ls.rs +++ b/src/uu/ls/src/ls.rs @@ -266,7 +266,11 @@ fn parse_time_style(options: &clap::ArgMatches) -> Result<(String, Option(options::TIME_STYLE) { + if let Some(field) = options + .get_one::(options::TIME_STYLE) + .map(|s| s.to_owned()) + .or_else(|| std::env::var("TIME_STYLE").ok()) + { //If both FULL_TIME and TIME_STYLE are present //The one added last is dominant if options.get_flag(options::FULL_TIME) @@ -287,7 +291,7 @@ fn parse_time_style(options: &clap::ArgMatches) -> Result<(String, Option Date: Mon, 28 Jul 2025 18:41:20 +0800 Subject: [PATCH 03/10] ls: Add support for newline separated format for recent/older Documented in GNU manual. Also improve test_ls to test for both recent and older files. --- src/uu/ls/src/ls.rs | 11 ++++- tests/by-util/test_ls.rs | 99 +++++++++++++++++++++++++++++----------- 2 files changed, 82 insertions(+), 28 deletions(-) diff --git a/src/uu/ls/src/ls.rs b/src/uu/ls/src/ls.rs index 341e8bac071..dc8b580fac1 100644 --- a/src/uu/ls/src/ls.rs +++ b/src/uu/ls/src/ls.rs @@ -297,7 +297,16 @@ fn parse_time_style(options: &clap::ArgMatches) -> Result<(String, Option ok(*formats), None => match field.chars().next().unwrap() { - '+' => Ok((field[1..].to_string(), None)), + '+' => { + // recent/older formats are (optionally) separated by a newline + let mut it = field[1..].split('\n'); + let recent = it.next().unwrap_or_default(); + let older = it.next(); + match it.next() { + None => ok((recent, older)), + Some(_) => Err(LsError::TimeStyleParseError(String::from(field))), + } + } _ => Err(LsError::TimeStyleParseError(String::from(field))), }, } diff --git a/tests/by-util/test_ls.rs b/tests/by-util/test_ls.rs index bfb9e36d766..64201307096 100644 --- a/tests/by-util/test_ls.rs +++ b/tests/by-util/test_ls.rs @@ -19,11 +19,11 @@ use std::collections::HashMap; use std::ffi::OsStr; #[cfg(target_os = "linux")] use std::os::unix::ffi::OsStrExt; -use std::path::Path; #[cfg(not(windows))] use std::path::PathBuf; use std::thread::sleep; use std::time::Duration; +use std::{path::Path, time::SystemTime}; use uutests::new_ucmd; #[cfg(unix)] use uutests::unwrap_or_return; @@ -1923,24 +1923,38 @@ fn test_ls_order_birthtime() { } #[test] -fn test_ls_styles() { +fn test_ls_time_styles() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; + // Create a recent and old (<6 months) file, as format can be different. at.touch("test"); + let f3 = at.make_file("test-old"); + f3.set_modified(SystemTime::now() - Duration::from_secs(3600 * 24 * 365)) + .unwrap(); - let re_full = Regex::new( + let re_full_recent = Regex::new( r"[a-z-]* \d* [\w.]* [\w.]* \d* \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d* (\+|\-)\d{4} test\n", ) .unwrap(); - let re_long = + let re_long_recent = Regex::new(r"[a-z-]* \d* [\w.]* [\w.]* \d* \d{4}-\d{2}-\d{2} \d{2}:\d{2} test\n").unwrap(); - let re_iso = + let re_iso_recent = Regex::new(r"[a-z-]* \d* [\w.]* [\w.]* \d* \d{2}-\d{2} \d{2}:\d{2} test\n").unwrap(); - let re_locale = + let re_locale_recent = Regex::new(r"[a-z-]* \d* [\w.]* [\w.]* \d* [A-Z][a-z]{2} ( |\d)\d \d{2}:\d{2} test\n") .unwrap(); - let re_custom_format = - Regex::new(r"[a-z-]* \d* [\w.]* [\w.]* \d* \d{4}__\d{2} test\n").unwrap(); + let re_full_old = Regex::new( + r"[a-z-]* \d* [\w.]* [\w.]* \d* \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d* (\+|\-)\d{4} test-old\n", + ) + .unwrap(); + let re_long_old = + Regex::new(r"[a-z-]* \d* [\w.]* [\w.]* \d* \d{4}-\d{2}-\d{2} \d{2}:\d{2} test-old\n") + .unwrap(); + let re_iso_old = + Regex::new(r"[a-z-]* \d* [\w.]* [\w.]* \d* \d{4}-\d{2}-\d{2} test-old\n").unwrap(); + let re_locale_old = + Regex::new(r"[a-z-]* \d* [\w.]* [\w.]* \d* [A-Z][a-z]{2} ( |\d)\d \d{4} test-old\n") + .unwrap(); //full-iso scene @@ -1948,28 +1962,32 @@ fn test_ls_styles() { .arg("-l") .arg("--time-style=full-iso") .succeeds() - .stdout_matches(&re_full); + .stdout_matches(&re_full_recent) + .stdout_matches(&re_full_old); //long-iso scene .ucmd() .arg("-l") .arg("--time-style=long-iso") .succeeds() - .stdout_matches(&re_long); + .stdout_matches(&re_long_recent) + .stdout_matches(&re_long_old); //iso scene .ucmd() .arg("-l") .arg("--time-style=iso") .succeeds() - .stdout_matches(&re_iso); + .stdout_matches(&re_iso_recent) + .stdout_matches(&re_iso_old); //locale scene .ucmd() .arg("-l") .arg("--time-style=locale") .succeeds() - .stdout_matches(&re_locale); + .stdout_matches(&re_locale_recent) + .stdout_matches(&re_locale_old); //posix-full-iso scene @@ -1977,21 +1995,24 @@ fn test_ls_styles() { .arg("-l") .arg("--time-style=posix-full-iso") .succeeds() - .stdout_matches(&re_full); + .stdout_matches(&re_full_recent) + .stdout_matches(&re_full_old); //posix-long-iso scene .ucmd() .arg("-l") .arg("--time-style=posix-long-iso") .succeeds() - .stdout_matches(&re_long); + .stdout_matches(&re_long_recent) + .stdout_matches(&re_long_old); //posix-iso scene .ucmd() .arg("-l") .arg("--time-style=posix-iso") .succeeds() - .stdout_matches(&re_iso); + .stdout_matches(&re_iso_recent) + .stdout_matches(&re_iso_old); //posix-* with LC_TIME/LC_ALL=POSIX is equivalent to locale scene @@ -2000,22 +2021,40 @@ fn test_ls_styles() { .arg("-l") .arg("--time-style=posix-full-iso") .succeeds() - .stdout_matches(&re_locale); + .stdout_matches(&re_locale_recent) + .stdout_matches(&re_locale_old); scene .ucmd() .env("LC_ALL", "POSIX") .arg("-l") .arg("--time-style=posix-iso") .succeeds() - .stdout_matches(&re_locale); + .stdout_matches(&re_locale_recent) + .stdout_matches(&re_locale_old); //+FORMAT + let re_custom_format_recent = + Regex::new(r"[a-z-]* \d* [\w.]* [\w.]* \d* \d{4}__\d{2} test\n").unwrap(); + let re_custom_format_old = + Regex::new(r"[a-z-]* \d* [\w.]* [\w.]* \d* \d{4}__\d{2} test-old\n").unwrap(); scene .ucmd() .arg("-l") .arg("--time-style=+%Y__%M") .succeeds() - .stdout_matches(&re_custom_format); + .stdout_matches(&re_custom_format_recent) + .stdout_matches(&re_custom_format_old); + + //+FORMAT_RECENT\nFORMAT_OLD + let re_custom_format_old = + Regex::new(r"[a-z-]* \d* [\w.]* [\w.]* \d* \d{4}--\d{2} test-old\n").unwrap(); + scene + .ucmd() + .arg("-l") + .arg("--time-style=+%Y__%M\n%Y--%M") + .succeeds() + .stdout_matches(&re_custom_format_recent) + .stdout_matches(&re_custom_format_old); // Also fails due to not having full clap support for time_styles scene @@ -2024,6 +2063,13 @@ fn test_ls_styles() { .arg("--time-style=invalid") .fails_with_code(2); + // Cannot have 2 new lines in custom format + scene + .ucmd() + .arg("-l") + .arg("--time-style=+%Y__%M\n%Y--%M\n") + .fails_with_code(2); + //Overwrite options tests scene .ucmd() @@ -2031,19 +2077,19 @@ fn test_ls_styles() { .arg("--time-style=long-iso") .arg("--time-style=iso") .succeeds() - .stdout_matches(&re_iso); + .stdout_matches(&re_iso_recent); scene .ucmd() .arg("--time-style=iso") .arg("--full-time") .succeeds() - .stdout_matches(&re_full); + .stdout_matches(&re_full_recent); scene .ucmd() .arg("--full-time") .arg("--time-style=iso") .succeeds() - .stdout_matches(&re_iso); + .stdout_matches(&re_iso_recent); scene .ucmd() @@ -2051,7 +2097,7 @@ fn test_ls_styles() { .arg("--time-style=iso") .arg("--full-time") .succeeds() - .stdout_matches(&re_full); + .stdout_matches(&re_full_recent); scene .ucmd() @@ -2059,15 +2105,14 @@ fn test_ls_styles() { .arg("-x") .arg("-l") .succeeds() - .stdout_matches(&re_full); + .stdout_matches(&re_full_recent); - at.touch("test2"); scene .ucmd() .arg("--full-time") .arg("-x") .succeeds() - .stdout_is("test test2\n"); + .stdout_is("test test-old\n"); // Time style can also be setup from environment scene @@ -2075,7 +2120,7 @@ fn test_ls_styles() { .env("TIME_STYLE", "full-iso") .arg("-l") .succeeds() - .stdout_matches(&re_full); + .stdout_matches(&re_full_recent); // ... but option takes precedence scene @@ -2084,7 +2129,7 @@ fn test_ls_styles() { .arg("-l") .arg("--time-style=long-iso") .succeeds() - .stdout_matches(&re_long); + .stdout_matches(&re_long_recent); } #[test] From fc08fa1d621694274a3f2ce891e86fab0c9dbb0c Mon Sep 17 00:00:00 2001 From: Nicolas Boichat Date: Mon, 28 Jul 2025 21:11:33 +0800 Subject: [PATCH 04/10] ls: Print dates in the future as "old" ones ls has different time format for recent (< 6 months) and older files. Files in the future (even by just a second), are considered old, which makes sense as we probably want the date to be printed in that case. --- src/uu/ls/src/ls.rs | 10 ++++-- tests/by-util/test_ls.rs | 78 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/src/uu/ls/src/ls.rs b/src/uu/ls/src/ls.rs index dc8b580fac1..e36a4849432 100644 --- a/src/uu/ls/src/ls.rs +++ b/src/uu/ls/src/ls.rs @@ -7,6 +7,7 @@ use std::collections::HashMap; use std::iter; +use std::ops::RangeInclusive; #[cfg(unix)] use std::os::unix::fs::{FileTypeExt, MetadataExt}; #[cfg(windows)] @@ -1959,7 +1960,7 @@ struct ListState<'a> { uid_cache: HashMap, #[cfg(unix)] gid_cache: HashMap, - recent_time_threshold: SystemTime, + recent_time_range: RangeInclusive, } #[allow(clippy::cognitive_complexity)] @@ -1976,8 +1977,11 @@ pub fn list(locs: Vec<&Path>, config: &Config) -> UResult<()> { uid_cache: HashMap::new(), #[cfg(unix)] gid_cache: HashMap::new(), + // Time range for which to use the "recent" format. Anything from 0.5 year in the past to now + // (files with modification time in the future use "old" format). // According to GNU a Gregorian year has 365.2425 * 24 * 60 * 60 == 31556952 seconds on the average. - recent_time_threshold: SystemTime::now() - Duration::new(31_556_952 / 2, 0), + recent_time_range: (SystemTime::now() - Duration::new(31_556_952 / 2, 0)) + ..=SystemTime::now(), }; for loc in locs { @@ -2961,7 +2965,7 @@ fn display_date( // Use "recent" format if the given date is considered recent (i.e., in the last 6 months), // or if no "older" format is available. let fmt = match &config.time_format_older { - Some(time_format_older) if time <= state.recent_time_threshold => time_format_older, + Some(time_format_older) if !state.recent_time_range.contains(&time) => time_format_older, _ => &config.time_format_recent, }; diff --git a/tests/by-util/test_ls.rs b/tests/by-util/test_ls.rs index 64201307096..734f70b1fc8 100644 --- a/tests/by-util/test_ls.rs +++ b/tests/by-util/test_ls.rs @@ -2132,6 +2132,84 @@ fn test_ls_time_styles() { .stdout_matches(&re_long_recent); } +#[test] +fn test_ls_time_recent_future() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + let f = at.make_file("test"); + + let re_iso_recent = + Regex::new(r"[a-z-]* \d* [\w.]* [\w.]* \d* \d{2}-\d{2} \d{2}:\d{2} test\n").unwrap(); + let re_iso_old = + Regex::new(r"[a-z-]* \d* [\w.]* [\w.]* \d* \d{4}-\d{2}-\d{2} test\n").unwrap(); + + // `test` has just been created, so it's recent + scene + .ucmd() + .arg("-l") + .arg("--time-style=iso") + .succeeds() + .stdout_matches(&re_iso_recent); + + // 100 days ago is still recent (<0.5 years) + f.set_modified(SystemTime::now() - Duration::from_secs(3600 * 24 * 100)) + .unwrap(); + scene + .ucmd() + .arg("-l") + .arg("--time-style=iso") + .succeeds() + .stdout_matches(&re_iso_recent); + + // 200 days ago is not recent + f.set_modified(SystemTime::now() - Duration::from_secs(3600 * 24 * 200)) + .unwrap(); + scene + .ucmd() + .arg("-l") + .arg("--time-style=iso") + .succeeds() + .stdout_matches(&re_iso_old); + + // A timestamp in the future (even just a minute), is not considered "recent" + f.set_modified(SystemTime::now() + Duration::from_secs(60)) + .unwrap(); + scene + .ucmd() + .arg("-l") + .arg("--time-style=iso") + .succeeds() + .stdout_matches(&re_iso_old); + + // Also test that we can set a format that varies for recent of older files. + //+FORMAT_RECENT\nFORMAT_OLD + f.set_modified(SystemTime::now()).unwrap(); + scene + .ucmd() + .arg("-l") + .arg("--time-style=+RECENT\nOLD") + .succeeds() + .stdout_contains("RECENT"); + + // Old file + f.set_modified(SystemTime::now() - Duration::from_secs(3600 * 24 * 200)) + .unwrap(); + scene + .ucmd() + .arg("-l") + .arg("--time-style=+RECENT\nOLD") + .succeeds() + .stdout_contains("OLD"); + + // RECENT format is still used if no "OLD" one provided. + scene + .ucmd() + .arg("-l") + .arg("--time-style=+RECENT") + .succeeds() + .stdout_contains("RECENT"); +} + #[test] fn test_ls_order_time() { let scene = TestScenario::new(util_name!()); From 1272bfc2227868037117823d78ec7d8390c10717 Mon Sep 17 00:00:00 2001 From: Nicolas Boichat Date: Mon, 28 Jul 2025 22:27:01 +0800 Subject: [PATCH 05/10] du: Add support for +FORMAT time-style Also add to error text, which... GNU coreutils doesn't do for some reason. --- src/uu/du/locales/en-US.ftl | 1 + src/uu/du/locales/fr-FR.ftl | 1 + src/uu/du/src/du.rs | 5 +++- tests/by-util/test_du.rs | 50 +++++++++++++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/uu/du/locales/en-US.ftl b/src/uu/du/locales/en-US.ftl index c9bd3bf52dc..b503d8d539f 100644 --- a/src/uu/du/locales/en-US.ftl +++ b/src/uu/du/locales/en-US.ftl @@ -52,6 +52,7 @@ du-error-invalid-time-style = invalid argument { $style } for 'time style' - 'full-iso' - 'long-iso' - 'iso' + - +FORMAT (e.g., +%H:%M) for a 'date'-style format Try '{ $help }' for more information. du-error-invalid-time-arg = 'birth' and 'creation' arguments for --time are not supported on this platform. du-error-invalid-glob = Invalid exclude syntax: { $error } diff --git a/src/uu/du/locales/fr-FR.ftl b/src/uu/du/locales/fr-FR.ftl index fd242706221..e89385213aa 100644 --- a/src/uu/du/locales/fr-FR.ftl +++ b/src/uu/du/locales/fr-FR.ftl @@ -52,6 +52,7 @@ du-error-invalid-time-style = argument invalide { $style } pour 'style de temps' - 'full-iso' - 'long-iso' - 'iso' + - +FORMAT (e.g., +%H:%M) pour un format de type 'date' Essayez '{ $help }' pour plus d'informations. du-error-invalid-time-arg = les arguments 'birth' et 'creation' pour --time ne sont pas supportés sur cette plateforme. du-error-invalid-glob = Syntaxe d'exclusion invalide : { $error } diff --git a/src/uu/du/src/du.rs b/src/uu/du/src/du.rs index a6ddc8a6a0d..7f4ac1a161f 100644 --- a/src/uu/du/src/du.rs +++ b/src/uu/du/src/du.rs @@ -761,7 +761,10 @@ fn parse_time_style(s: Option<&str>) -> UResult<&str> { "full-iso" => Ok("%Y-%m-%d %H:%M:%S.%f %z"), "long-iso" => Ok("%Y-%m-%d %H:%M"), "iso" => Ok("%Y-%m-%d"), - _ => Err(DuError::InvalidTimeStyleArg(s.into()).into()), + _ => match s.chars().next().unwrap() { + '+' => Ok(&s[1..]), + _ => Err(DuError::InvalidTimeStyleArg(s.into()).into()), + }, }, None => Ok("%Y-%m-%d %H:%M"), } diff --git a/tests/by-util/test_du.rs b/tests/by-util/test_du.rs index 40352e88d90..e7bf6dee582 100644 --- a/tests/by-util/test_du.rs +++ b/tests/by-util/test_du.rs @@ -626,6 +626,56 @@ fn test_du_time() { .succeeds(); result.stdout_only("0\t2016-06-16 00:00\tdate_test\n"); + // long-iso (same as default) + let result = ts + .ucmd() + .env("TZ", "UTC") + .arg("--time") + .arg("--time-style=long-iso") + .arg("date_test") + .succeeds(); + result.stdout_only("0\t2016-06-16 00:00\tdate_test\n"); + + // full-iso + let result = ts + .ucmd() + .env("TZ", "UTC") + .arg("--time") + .arg("--time-style=full-iso") + .arg("date_test") + .succeeds(); + result.stdout_only("0\t2016-06-16 00:00:00.000000000 +0000\tdate_test\n"); + + // iso + let result = ts + .ucmd() + .env("TZ", "UTC") + .arg("--time") + .arg("--time-style=iso") + .arg("date_test") + .succeeds(); + result.stdout_only("0\t2016-06-16\tdate_test\n"); + + // custom +FORMAT + let result = ts + .ucmd() + .env("TZ", "UTC") + .arg("--time") + .arg("--time-style=+%Y__%H") + .arg("date_test") + .succeeds(); + result.stdout_only("0\t2016__00\tdate_test\n"); + + // ls has special handling for new line in format, du doesn't. + let result = ts + .ucmd() + .env("TZ", "UTC") + .arg("--time") + .arg("--time-style=+%Y_\n_%H") + .arg("date_test") + .succeeds(); + result.stdout_only("0\t2016_\n_00\tdate_test\n"); + for argument in ["--time=atime", "--time=atim", "--time=a"] { let result = ts .ucmd() From b99615c1e372ca3cd776ea8315ea2f905b4f73f6 Mon Sep 17 00:00:00 2001 From: Nicolas Boichat Date: Mon, 28 Jul 2025 18:40:06 +0800 Subject: [PATCH 06/10] du/ls: Move common time formats to constants in uucore/time Along the way: - Fix the full-iso format, that was supposed to use %N, not %f (%f padded with chrono, but not with jiff, and %N is more correct and what GNU says in their manual) - The Hashmap thing in parse_time_style was too smart, to a point that it became too unflexible (it would have been even worse when we added locale support). I was hoping the share more of the code, but that seems difficult. --- src/uu/du/src/du.rs | 12 +++++----- src/uu/ls/src/ls.rs | 35 ++++++++++++++--------------- src/uucore/src/lib/features/time.rs | 6 +++++ 3 files changed, 29 insertions(+), 24 deletions(-) diff --git a/src/uu/du/src/du.rs b/src/uu/du/src/du.rs index 7f4ac1a161f..bbf90a711fc 100644 --- a/src/uu/du/src/du.rs +++ b/src/uu/du/src/du.rs @@ -28,7 +28,7 @@ use uucore::translate; use uucore::parser::parse_glob; use uucore::parser::parse_size::{ParseSizeError, parse_size_u64}; use uucore::parser::shortcut_value_parser::ShortcutValueParser; -use uucore::time::{FormatSystemTimeFallback, format_system_time}; +use uucore::time::{FormatSystemTimeFallback, format, format_system_time}; use uucore::{format_usage, show, show_error, show_warning}; #[cfg(windows)] use windows_sys::Win32::Foundation::HANDLE; @@ -668,7 +668,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let time_format = if time.is_some() { parse_time_style(matches.get_one::("time-style").map(|s| s.as_str()))?.to_string() } else { - "%Y-%m-%d %H:%M".to_string() + format::LONG_ISO.to_string() }; let stat_printer = StatPrinter { @@ -758,15 +758,15 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { fn parse_time_style(s: Option<&str>) -> UResult<&str> { match s { Some(s) => match s { - "full-iso" => Ok("%Y-%m-%d %H:%M:%S.%f %z"), - "long-iso" => Ok("%Y-%m-%d %H:%M"), - "iso" => Ok("%Y-%m-%d"), + "full-iso" => Ok(format::FULL_ISO), + "long-iso" => Ok(format::LONG_ISO), + "iso" => Ok(format::ISO), _ => match s.chars().next().unwrap() { '+' => Ok(&s[1..]), _ => Err(DuError::InvalidTimeStyleArg(s.into()).into()), }, }, - None => Ok("%Y-%m-%d %H:%M"), + None => Ok(format::LONG_ISO), } } diff --git a/src/uu/ls/src/ls.rs b/src/uu/ls/src/ls.rs index e36a4849432..20f6ba66d33 100644 --- a/src/uu/ls/src/ls.rs +++ b/src/uu/ls/src/ls.rs @@ -60,7 +60,7 @@ use uucore::line_ending::LineEnding; use uucore::translate; use uucore::quoting_style::{QuotingStyle, locale_aware_escape_dir_name, locale_aware_escape_name}; -use uucore::time::{FormatSystemTimeFallback, format_system_time}; +use uucore::time::{FormatSystemTimeFallback, format, format_system_time}; use uucore::{ display::Quotable, error::{UError, UResult, set_exit_code}, @@ -251,16 +251,8 @@ enum Files { } fn parse_time_style(options: &clap::ArgMatches) -> Result<(String, Option), LsError> { - const TIME_STYLES: [(&str, (&str, Option<&str>)); 4] = [ - ("full-iso", ("%Y-%m-%d %H:%M:%S.%f %z", None)), - ("long-iso", ("%Y-%m-%d %H:%M", None)), - ("iso", ("%m-%d %H:%M", Some("%Y-%m-%d "))), - // TODO: Using correct locale string is not implemented. - ("locale", ("%b %e %H:%M", Some("%b %e %Y"))), - ]; - // A map from a time-style parameter to a length-2 tuple of formats: - // the first one is used for recent dates, the second one for older ones (optional). - let time_styles = HashMap::from(TIME_STYLES); + // TODO: Using correct locale string is not implemented. + const LOCALE_FORMAT: (&str, Option<&str>) = ("%b %e %H:%M", Some("%b %e %Y")); // Convert time_styles references to owned String/option. fn ok((recent, older): (&str, Option<&str>)) -> Result<(String, Option), LsError> { @@ -278,7 +270,7 @@ fn parse_time_style(options: &clap::ArgMatches) -> Result<(String, Option options.indices_of(options::TIME_STYLE).unwrap().next_back() { - ok(time_styles["full-iso"]) + ok((format::FULL_ISO, None)) } else { let field = if let Some(field) = field.strip_prefix("posix-") { // See GNU documentation, set format to "locale" if LC_TIME="POSIX", @@ -288,16 +280,23 @@ fn parse_time_style(options: &clap::ArgMatches) -> Result<(String, Option ok(*formats), - None => match field.chars().next().unwrap() { + match field { + "full-iso" => ok((format::FULL_ISO, None)), + "long-iso" => ok((format::LONG_ISO, None)), + // ISO older format needs extra padding. + "iso" => Ok(( + "%m-%d %H:%M".to_string(), + Some(format::ISO.to_string() + " "), + )), + "locale" => ok(LOCALE_FORMAT), + _ => match field.chars().next().unwrap() { '+' => { // recent/older formats are (optionally) separated by a newline let mut it = field[1..].split('\n'); @@ -313,9 +312,9 @@ fn parse_time_style(options: &clap::ArgMatches) -> Result<(String, Option (i64, u32) { } } +pub mod format { + pub static FULL_ISO: &str = "%Y-%m-%d %H:%M:%S.%N %z"; + pub static LONG_ISO: &str = "%Y-%m-%d %H:%M"; + pub static ISO: &str = "%Y-%m-%d"; +} + /// Sets how `format_system_time` behaves if the time cannot be converted. pub enum FormatSystemTimeFallback { Integer, // Just print seconds since epoch (`ls`) From 2d63020cd95d22f95c30fc52f25fc14234fbe15c Mon Sep 17 00:00:00 2001 From: Nicolas Boichat Date: Mon, 28 Jul 2025 22:57:39 +0800 Subject: [PATCH 07/10] du: Add support for reading time-style from environment Similar as what ls does, but du has an extra compatibility layer described in the GNU manual (and that upstream dev helped me understand: https://debbugs.gnu.org/cgi/bugreport.cgi?bug=79113 ). --- src/uu/du/src/du.rs | 40 ++++++++++++++++++++++++------- tests/by-util/test_du.rs | 52 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/src/uu/du/src/du.rs b/src/uu/du/src/du.rs index bbf90a711fc..f39d257de56 100644 --- a/src/uu/du/src/du.rs +++ b/src/uu/du/src/du.rs @@ -666,7 +666,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { }; let time_format = if time.is_some() { - parse_time_style(matches.get_one::("time-style").map(|s| s.as_str()))?.to_string() + parse_time_style(matches.get_one::("time-style"))? } else { format::LONG_ISO.to_string() }; @@ -755,18 +755,40 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { Ok(()) } -fn parse_time_style(s: Option<&str>) -> UResult<&str> { +// Parse --time-style argument, falling back to environment variable if necessary. +fn parse_time_style(s: Option<&String>) -> UResult { + let s = match s { + Some(s) => Some(s.into()), + None => { + match env::var("TIME_STYLE") { + // Per GNU manual, strip `posix-` if present, ignore anything after a newline if + // the string starts with +, and ignore "locale". + Ok(s) => { + let s = s.strip_prefix("posix-").unwrap_or(s.as_str()); + let s = match s.chars().next().unwrap() { + '+' => s.split('\n').next().unwrap(), + _ => s, + }; + match s { + "locale" => None, + _ => Some(s.to_string()), + } + } + Err(_) => None, + } + } + }; match s { - Some(s) => match s { - "full-iso" => Ok(format::FULL_ISO), - "long-iso" => Ok(format::LONG_ISO), - "iso" => Ok(format::ISO), + Some(s) => match s.as_ref() { + "full-iso" => Ok(format::FULL_ISO.to_string()), + "long-iso" => Ok(format::LONG_ISO.to_string()), + "iso" => Ok(format::ISO.to_string()), _ => match s.chars().next().unwrap() { - '+' => Ok(&s[1..]), - _ => Err(DuError::InvalidTimeStyleArg(s.into()).into()), + '+' => Ok(s[1..].to_string()), + _ => Err(DuError::InvalidTimeStyleArg(s).into()), }, }, - None => Ok(format::LONG_ISO), + None => Ok(format::LONG_ISO.to_string()), } } diff --git a/tests/by-util/test_du.rs b/tests/by-util/test_du.rs index e7bf6dee582..6c4191a2089 100644 --- a/tests/by-util/test_du.rs +++ b/tests/by-util/test_du.rs @@ -591,6 +591,7 @@ fn test_du_h_precision() { } } +#[allow(clippy::too_many_lines)] #[cfg(feature = "touch")] #[test] fn test_du_time() { @@ -676,6 +677,57 @@ fn test_du_time() { .succeeds(); result.stdout_only("0\t2016_\n_00\tdate_test\n"); + // Time style can also be setup from environment + let result = ts + .ucmd() + .env("TZ", "UTC") + .env("TIME_STYLE", "full-iso") + .arg("--time") + .arg("date_test") + .succeeds(); + result.stdout_only("0\t2016-06-16 00:00:00.000000000 +0000\tdate_test\n"); + + // For compatibility reason, we also allow posix- prefix. + let result = ts + .ucmd() + .env("TZ", "UTC") + .env("TIME_STYLE", "posix-full-iso") + .arg("--time") + .arg("date_test") + .succeeds(); + result.stdout_only("0\t2016-06-16 00:00:00.000000000 +0000\tdate_test\n"); + + // ... and we strip content after a new line + let result = ts + .ucmd() + .env("TZ", "UTC") + .env("TIME_STYLE", "+XXX\nYYY") + .arg("--time") + .arg("date_test") + .succeeds(); + result.stdout_only("0\tXXX\tdate_test\n"); + + // ... and we ignore "locale", fall back to full-iso. + let result = ts + .ucmd() + .env("TZ", "UTC") + .env("TIME_STYLE", "locale") + .arg("--time") + .arg("date_test") + .succeeds(); + result.stdout_only("0\t2016-06-16 00:00\tdate_test\n"); + + // Command line option takes precedence + let result = ts + .ucmd() + .env("TZ", "UTC") + .env("TIME_STYLE", "full-iso") + .arg("--time") + .arg("--time-style=iso") + .arg("date_test") + .succeeds(); + result.stdout_only("0\t2016-06-16\tdate_test\n"); + for argument in ["--time=atime", "--time=atim", "--time=a"] { let result = ts .ucmd() From 4ba8d3e0a9680fff81006f911d1422235157e386 Mon Sep 17 00:00:00 2001 From: Nicolas Boichat Date: Wed, 30 Jul 2025 17:58:39 +0800 Subject: [PATCH 08/10] ls: Fix Windows build --- src/uu/ls/src/ls.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/uu/ls/src/ls.rs b/src/uu/ls/src/ls.rs index 20f6ba66d33..8df0cf655a0 100644 --- a/src/uu/ls/src/ls.rs +++ b/src/uu/ls/src/ls.rs @@ -5,6 +5,7 @@ // spell-checker:ignore (ToDO) somegroup nlink tabsize dired subdired dtype colorterm stringly nohash strtime +#[cfg(unix)] use std::collections::HashMap; use std::iter; use std::ops::RangeInclusive; From 4cb57fb7acf8b88bf4c6502a3967ebdf38def0f7 Mon Sep 17 00:00:00 2001 From: Nicolas Boichat Date: Wed, 30 Jul 2025 17:59:52 +0800 Subject: [PATCH 09/10] ls: cleanup imports They were a bit jumbled with no particular logic, at least that I could see. --- src/uu/ls/src/ls.rs | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/src/uu/ls/src/ls.rs b/src/uu/ls/src/ls.rs index 8df0cf655a0..272d563090d 100644 --- a/src/uu/ls/src/ls.rs +++ b/src/uu/ls/src/ls.rs @@ -7,23 +7,24 @@ #[cfg(unix)] use std::collections::HashMap; -use std::iter; -use std::ops::RangeInclusive; #[cfg(unix)] use std::os::unix::fs::{FileTypeExt, MetadataExt}; #[cfg(windows)] use std::os::windows::fs::MetadataExt; -use std::{cell::LazyCell, cell::OnceCell, num::IntErrorKind}; use std::{ + cell::{LazyCell, OnceCell}, cmp::Reverse, + collections::HashSet, ffi::{OsStr, OsString}, fmt::Write as FmtWrite, fs::{self, DirEntry, FileType, Metadata, ReadDir}, - io::{BufWriter, ErrorKind, Stdout, Write, stdout}, + io::{BufWriter, ErrorKind, IsTerminal, Stdout, Write, stdout}, + iter, + num::IntErrorKind, + ops::RangeInclusive, path::{Path, PathBuf}, time::{Duration, SystemTime, UNIX_EPOCH}, }; -use std::{collections::HashSet, io::IsTerminal}; use ansi_width::ansi_width; use clap::{ @@ -34,12 +35,9 @@ use glob::{MatchOptions, Pattern}; use lscolors::LsColors; use term_grid::{DEFAULT_SEPARATOR_SIZE, Direction, Filling, Grid, GridOptions, SPACES_IN_TAB}; use thiserror::Error; + #[cfg(unix)] use uucore::entries; -use uucore::error::USimpleError; -use uucore::format::human::{SizeFormat, human_readable}; -use uucore::fs::FileInformation; -use uucore::fsext::{MetadataTimeField, metadata_get_time}; #[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] use uucore::fsxattr::has_acl; #[cfg(unix)] @@ -57,22 +55,25 @@ use uucore::libc::{S_IXGRP, S_IXOTH, S_IXUSR}; target_os = "solaris" ))] use uucore::libc::{dev_t, major, minor}; -use uucore::line_ending::LineEnding; -use uucore::translate; - -use uucore::quoting_style::{QuotingStyle, locale_aware_escape_dir_name, locale_aware_escape_name}; -use uucore::time::{FormatSystemTimeFallback, format, format_system_time}; use uucore::{ display::Quotable, - error::{UError, UResult, set_exit_code}, + error::{UError, UResult, USimpleError, set_exit_code}, + format::human::{SizeFormat, human_readable}, format_usage, + fs::FileInformation, fs::display_permissions, + fsext::{MetadataTimeField, metadata_get_time}, + line_ending::LineEnding, os_str_as_bytes_lossy, + parser::parse_glob, parser::parse_size::parse_size_u64, parser::shortcut_value_parser::ShortcutValueParser, + quoting_style::{QuotingStyle, locale_aware_escape_dir_name, locale_aware_escape_name}, + show, show_error, show_warning, + time::{FormatSystemTimeFallback, format, format_system_time}, + translate, version_cmp::version_cmp, }; -use uucore::{parser::parse_glob, show, show_error, show_warning}; mod dired; use dired::{DiredOutput, is_dired_arg_present}; From b13bf37f7b4df2900a7d77be82cfab582efcd1bb Mon Sep 17 00:00:00 2001 From: Nicolas Boichat Date: Wed, 30 Jul 2025 19:45:29 +0800 Subject: [PATCH 10/10] util/build-gnu.sh: Change string matching for tests/ls/time-style-diag.sh We now are closer to what GNU prints, and handle the [posix-] cases that they do. --- util/build-gnu.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/util/build-gnu.sh b/util/build-gnu.sh index f6270160031..e9a54998654 100755 --- a/util/build-gnu.sh +++ b/util/build-gnu.sh @@ -329,8 +329,10 @@ sed -i -e "s|44 45|48 49|" tests/ls/stat-failed.sh # small difference in the error message # Use GNU sed for /c command -"${SED}" -i -e "/ls: invalid argument 'XX' for 'time style'/,/Try 'ls --help' for more information\./c\ -ls: invalid --time-style argument 'XX'\nPossible values are: [\"full-iso\", \"long-iso\", \"iso\", \"locale\", \"+FORMAT (e.g., +%H:%M) for a 'date'-style format\"]\n\nFor more information try --help" tests/ls/time-style-diag.sh +"${SED}" -i -e "s/ls: invalid argument 'XX' for 'time style'/ls: invalid --time-style argument 'XX'/" \ + -e "s/Valid arguments are:/Possible values are:/" \ + -e "s/Try 'ls --help' for more information./\nFor more information try --help/" \ + tests/ls/time-style-diag.sh # disable two kind of tests: # "hostid BEFORE --help" doesn't fail for GNU. we fail. we are probably doing better