diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index 9edf669584..1465ef49d9 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -4,8 +4,9 @@ // file that was distributed with this source code. // spell-checker:ignore strtime ; (format) DATEFILE MMDDhhmm ; (vars) datetime datetimes getres AWST ACST AEST foobarbaz unparseable -// spell-checker:ignore ohos OHOS tzdata tzdb tzif zoneinfo euctw nonrelative wednes +// spell-checker:ignore ohos OHOS tzdata tzdb tzif zoneinfo euctw nonrelative wednes Ihours +mod date_names; mod format_modifiers; mod locale; @@ -88,6 +89,64 @@ const OPT_REFERENCE: &str = "reference"; const OPT_UNIVERSAL: &str = "universal"; const OPT_UNIVERSAL_2: &str = "utc"; +// GNU treats `-I` followed by a space as a flag and the next word as a format operand. Clap would +// consume that word as the optional value, so translate attached `-I` forms, including short +// option groups such as `-uIhours`, to `--iso-8601=` and let Clap handle bare `-I`. A preceding +// value-taking short option owns the rest of the group, so stop before its attached value. +fn normalize_iso_8601_args(args: impl uucore::Args) -> impl Iterator { + args.scan(false, |options_ended, arg| { + if *options_ended { + return Some(vec![arg]); + } + + let Some(arg_str) = arg.to_str() else { + return Some(vec![arg]); + }; + + if arg_str == "--" { + *options_ended = true; + return Some(vec![arg]); + } + + let Some(iso_index) = arg_str + .strip_prefix('-') + .filter(|arg| !arg.starts_with('-')) + .and_then(|arg| { + let bytes = arg.as_bytes(); + let mut index = 0; + while let Some(&byte) = bytes.get(index) { + match byte { + b'I' => return Some(index), + b'd' | b'f' | b'r' | b's' => return None, + _ => index += 1, + } + } + None + }) + else { + return Some(vec![arg]); + }; + + let prefix = &arg_str[..=iso_index]; + let value = &arg_str[iso_index + 2..]; + let mut normalized = Vec::with_capacity(2); + if prefix.len() > 1 { + normalized.push(OsString::from(prefix)); + } + if value.is_empty() && prefix.len() == 1 { + normalized.push(arg); + } else if value.is_empty() { + normalized.push(OsString::from("-I")); + } else { + let value = value.strip_prefix('=').unwrap_or(value); + normalized.push(OsString::from(format!("--{OPT_ISO_8601}={value}"))); + } + + Some(normalized) + }) + .flatten() +} + #[derive(Debug, thiserror::Error)] enum DateError { #[error("{}", translate!("date-error-write", "error" => strip_errno(.0)))] @@ -327,7 +386,8 @@ fn parse_military_timezone_with_offset(s: &str) -> Option<(i32, DayDelta)> { #[uucore::main] #[allow(clippy::cognitive_complexity)] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; + let matches = + uucore::clap_localization::handle_clap_result(uu_app(), normalize_iso_8601_args(args))?; let date_source = if let Some(date_os) = matches .get_many::(OPT_DATE) @@ -367,7 +427,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let arg = operand_for_error(fmt); // if an optional Format String was found but the user has not provided an input date // GNU prints an invalid date Error - if !matches!(date_source, DateSource::Human(_)) { + if matches!(date_source, DateSource::Now) { return Err(Box::new(DateError::InvalidDate { date: arg })); } // If the user did provide an input date with the --date flag and the Format String is @@ -672,8 +732,9 @@ pub fn uu_app() -> Command { DATE, HOURS, MINUTES, SECONDS, NS, ])) .num_args(0..=1) + .require_equals(true) .default_missing_value(OPT_DATE) - .action(ArgAction::Append) + .overrides_with(OPT_ISO_8601) .help(translate!("date-help-iso-8601")), ) .arg( @@ -724,6 +785,7 @@ pub fn uu_app() -> Command { .long(OPT_SET) .value_name("STRING") .allow_hyphen_values(true) + .conflicts_with_all([OPT_DATE, OPT_FILE, OPT_REFERENCE, OPT_RESOLUTION]) .help({ #[cfg(not(target_os = "redox"))] { @@ -1655,9 +1717,7 @@ fn rewrite_european_date_token(token: &str) -> Option { _ => return None, }; - let day_number = day.parse::().ok()?; - let month_number = month.parse::().ok()?; - if !(1..=31).contains(&day_number) || !(1..=12).contains(&month_number) { + if !is_calendar_day(day) || !is_calendar_month(month) { return None; } @@ -1716,43 +1776,6 @@ fn normalize_european_dates(input: &str) -> Option { time_rewritten.or(Some(rewritten)) } -fn is_weekday_name(token: &str) -> bool { - // GNU documents these forms in its "Day of week items" section: - // https://www.gnu.org/software/coreutils/manual/html_node/Day-of-week-items.html - // Full names, three-letter abbreviations with an optional period, and the - // special `tues`, `wednes`, `thur`, and `thurs` forms are accepted. Single- - // letter forms are intentionally excluded because they are ambiguous with - // other syntax. - matches!( - token.to_ascii_lowercase().as_str(), - "monday" - | "mon" - | "mon." - | "tuesday" - | "tue" - | "tue." - | "tues" - | "wednesday" - | "wed" - | "wed." - | "wednes" - | "thursday" - | "thu" - | "thu." - | "thur" - | "thurs" - | "friday" - | "fri" - | "fri." - | "saturday" - | "sat" - | "sat." - | "sunday" - | "sun" - | "sun." - ) -} - fn looks_like_explicit_calendar_date(input: &str) -> bool { let Some(token) = input.split_whitespace().next() else { return false; @@ -1769,26 +1792,42 @@ fn looks_like_explicit_calendar_date(input: &str) -> bool { || (token.chars().all(|c| c.is_ascii_digit()) && token.len() >= 6) } -fn is_weekday_prefix(token: &str) -> bool { - token.parse::().is_ok() - || matches!( - token.to_ascii_lowercase().as_str(), - "first" - | "second" - | "third" - | "fourth" - | "fifth" - | "sixth" - | "seventh" - | "eighth" - | "ninth" - | "tenth" - | "eleventh" - | "twelfth" - | "last" - | "next" - | "this" - ) +fn is_calendar_number(token: &str, range: std::ops::RangeInclusive) -> bool { + let token = token.strip_suffix(',').unwrap_or(token); + token + .parse::() + .is_ok_and(|value| range.contains(&value)) +} + +fn is_calendar_day(token: &str) -> bool { + is_calendar_number(token, 1..=31) +} + +fn is_calendar_month(token: &str) -> bool { + is_calendar_number(token, 1..=12) +} + +fn is_calendar_year(token: &str) -> bool { + let token = token.strip_suffix(',').unwrap_or(token); + (2..=4).contains(&token.len()) && token.chars().all(|c| c.is_ascii_digit()) +} + +fn named_date_end(tokens: &[&str], date_index: usize) -> Option { + // Keep the day, month, and optional year together so a following number is + // not mistaken for an ordinal weekday prefix. + let first = tokens.get(date_index)?; + let second = tokens.get(date_index + 1)?; + + let month_day = date_names::is_month_name(first) && is_calendar_day(second); + let day_month = is_calendar_day(first) && date_names::is_month_name(second); + if !month_day && !day_month { + return None; + } + + let has_year = tokens + .get(date_index + 2) + .is_some_and(|year| is_calendar_year(year)); + Some(date_index + if has_year { 2 } else { 1 }) } /// Drop weekday items when an explicit calendar date is present. @@ -1813,23 +1852,29 @@ fn strip_weekday_for_explicit_date(input: &str) -> Option { .iter() .map(|&(start, end)| &input[start..end]) .collect(); - let date_index = tokens.iter().position(|token| { - !token.starts_with("TZ=\"") + let (date_index, named_date_end) = tokens.iter().enumerate().find_map(|(index, token)| { + let numeric_date = !token.starts_with("TZ=\"") && !token.ends_with(',') - && looks_like_explicit_calendar_date(token) + && looks_like_explicit_calendar_date(token); + if numeric_date { + Some((index, None)) + } else { + named_date_end(&tokens, index).map(|end| (index, Some(end))) + } })?; let weekday_index = tokens.iter().enumerate().find_map(|(index, token)| { let weekday = token.strip_suffix(',').unwrap_or(token); - (index != date_index && is_weekday_name(weekday)).then_some(index) + (index != date_index && date_names::is_weekday_name(weekday)).then_some(index) })?; let mut removed = vec![false; tokens.len()]; removed[weekday_index] = true; - if weekday_index > 0 - && weekday_index - 1 != date_index - && is_weekday_prefix(tokens[weekday_index - 1]) + if let Some(prefix_index) = weekday_index.checked_sub(1) + && prefix_index != date_index + && !named_date_end.is_some_and(|end| (date_index + 1..=end).contains(&prefix_index)) + && date_names::is_weekday_prefix(tokens[prefix_index]) { - removed[weekday_index - 1] = true; + removed[prefix_index] = true; } let mut result = String::with_capacity(input.len()); @@ -1842,7 +1887,7 @@ fn strip_weekday_for_explicit_date(input: &str) -> Option { cursor = end; } result.push_str(&input[cursor..]); - Some(result) + Some(result.trim().to_owned()) } /// Normalize offsets whose minute component exceeds 59 into total minutes. @@ -2433,6 +2478,16 @@ mod tests { assert_eq!(strip_parenthesized_comments("a(b)c(d)e(f"), "ace"); // Multiple groups, last unmatched } + #[test] + fn test_strip_weekday_from_day_month_dates() { + for input in ["Monday 17 August 2026", "Wednesday 17 August 2026"] { + assert_eq!( + strip_weekday_for_explicit_date(input).as_deref(), + Some("17 August 2026") + ); + } + } + /// Run one date through the shared writer used by the display loop and /// the `-s` echo, returning what it produced. fn write_date_with_settings(date: Zoned, settings: Settings) -> String { diff --git a/src/uu/date/src/date_names.rs b/src/uu/date/src/date_names.rs new file mode 100644 index 0000000000..06896e139f --- /dev/null +++ b/src/uu/date/src/date_names.rs @@ -0,0 +1,100 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. +// spell-checker:ignore wednes + +//! GNU-compatible date-name matching for input strings. + +/// GNU documents these forms in its "Calendar date items" section: +/// +/// Full names, three-letter abbreviations, and the special `Sept` form are +/// accepted. +pub(super) fn is_month_name(token: &str) -> bool { + matches!( + token.to_ascii_lowercase().as_str(), + "january" + | "jan" + | "february" + | "feb" + | "march" + | "mar" + | "april" + | "apr" + | "may" + | "june" + | "jun" + | "july" + | "jul" + | "august" + | "aug" + | "september" + | "sep" + | "sept" + | "october" + | "oct" + | "november" + | "nov" + | "december" + | "dec" + ) +} + +/// GNU documents these forms in its "Day of week items" section: +/// +/// Full names, three-letter abbreviations with an optional period, and the +/// special `tues`, `wednes`, `thur`, and `thurs` forms are accepted. Single- +/// letter forms are intentionally excluded because they are ambiguous with +/// other syntax. +pub(super) fn is_weekday_name(token: &str) -> bool { + matches!( + token.to_ascii_lowercase().as_str(), + "monday" + | "mon" + | "mon." + | "tuesday" + | "tue" + | "tue." + | "tues" + | "wednesday" + | "wed" + | "wed." + | "wednes" + | "thursday" + | "thu" + | "thu." + | "thur" + | "thurs" + | "friday" + | "fri" + | "fri." + | "saturday" + | "sat" + | "sat." + | "sunday" + | "sun" + | "sun." + ) +} + +pub(super) fn is_weekday_prefix(token: &str) -> bool { + token.parse::().is_ok() + || matches!( + token.to_ascii_lowercase().as_str(), + "first" + | "second" + | "third" + | "fourth" + | "fifth" + | "sixth" + | "seventh" + | "eighth" + | "ninth" + | "tenth" + | "eleventh" + | "twelfth" + | "last" + | "next" + | "this" + ) +} diff --git a/src/uu/date/src/format_modifiers.rs b/src/uu/date/src/format_modifiers.rs index 93cae9db33..67c32cd543 100644 --- a/src/uu/date/src/format_modifiers.rs +++ b/src/uu/date/src/format_modifiers.rs @@ -276,12 +276,10 @@ fn format_with_modifiers( /// Composite strftime conversions are expanded atomically by GNU date. In /// particular, `-` and `_` do not alter the padding of the fields inside an /// expansion such as `%D` or `%T`; an explicit width applies to the complete -/// expansion instead. +/// expansion instead. `%F` is intentionally handled as a regular numeric field +/// so its modifiers apply to the year component, as GNU specifies. fn is_composite_specifier(specifier: &str) -> bool { - matches!( - specifier, - "c" | "D" | "F" | "R" | "r" | "T" | "x" | "X" | "v" - ) + matches!(specifier, "c" | "D" | "R" | "r" | "T" | "x" | "X" | "v") } /// Apply flags and an optional width to the complete composite value rather @@ -599,6 +597,8 @@ pub fn apply_modifiers(value: &str, parsed: &ParsedSpec<'_>) -> Result) -> Result 0 && result.len() > default_w) { + // `%F` applies the sign to its year only when the width can expand the + // complete composite; narrower widths leave the existing date intact. + let width_allows_sign = if specifier == "F" { + width.is_some_and(|width| width > unmodified_width) + } else { + width.is_some() + }; + if width_allows_sign || (default_w > 0 && result.len() > default_w) { result.insert(0, '+'); } } diff --git a/src/uu/date/src/locale.rs b/src/uu/date/src/locale.rs index 1b3e3a9e29..6e1b6f1a05 100644 --- a/src/uu/date/src/locale.rs +++ b/src/uu/date/src/locale.rs @@ -14,8 +14,13 @@ const POSIX_DEFAULT_FORMAT: &[u8] = b"%a %b %e %X %Z %Y"; /// The specifiers that expand to a locale format, and their C locale formats, /// used without `nl_langinfo` or when the locale leaves one empty. -const LOCALE_FORMAT_SPECIFIERS: [&str; 3] = ["x", "X", "r"]; -const POSIX_LOCALE_FORMATS: [&str; 3] = ["%m/%d/%y", "%H:%M:%S", "%I:%M:%S %p"]; +const LOCALE_FORMAT_SPECIFIERS: [&str; 4] = ["x", "X", "r", "c"]; +const POSIX_LOCALE_FORMATS: [&str; 4] = [ + "%m/%d/%y", + "%H:%M:%S", + "%I:%M:%S %p", + "%a %b %e %H:%M:%S %Y", +]; const POSIX_AMPM_MARKERS: [&str; 2] = ["AM", "PM"]; // `_DATE_FMT` is the only langinfo item that spells the full date line the way @@ -40,8 +45,8 @@ macro_rules! cfg_langinfo { }; } -// `nl_langinfo`, with the POSIX items `D_FMT`, `T_FMT` and `T_FMT_AMPM` behind -// `%x`, `%X` and `%r`, exists on every unix but Android, Cygwin and Redox; +// `nl_langinfo`, with the POSIX items `D_FMT`, `T_FMT`, `T_FMT_AMPM` and `D_T_FMT` behind +// `%x`, `%X`, `%r` and `%c`, exists on every unix but Android, Cygwin and Redox; // `cfg_nl_langinfo!(else ...)` gates the items for the other platforms. macro_rules! cfg_nl_langinfo { (else $($item:item)*) => { @@ -83,9 +88,9 @@ cfg_nl_langinfo! { #[cfg(test)] static LOCALE_MUTEX: Mutex<()> = Mutex::new(()); - /// The locale's `D_FMT`, `T_FMT` and `T_FMT_AMPM`, read once. - static LOCALE_FORMATS: LazyLock<[&'static str; 3]> = LazyLock::new(|| { - let items = [libc::D_FMT, libc::T_FMT, libc::T_FMT_AMPM]; + /// The locale's `D_FMT`, `T_FMT`, `T_FMT_AMPM` and `D_T_FMT`, read once. + static LOCALE_FORMATS: LazyLock<[&'static str; 4]> = LazyLock::new(|| { + let items = [libc::D_FMT, libc::T_FMT, libc::T_FMT_AMPM, libc::D_T_FMT]; let mut formats = POSIX_LOCALE_FORMATS; for (format, item) in formats.iter_mut().zip(items) { if let Some(value) = langinfo(item) @@ -131,11 +136,11 @@ cfg_nl_langinfo! { } cfg_nl_langinfo! { else - static LOCALE_FORMATS: [&str; 3] = POSIX_LOCALE_FORMATS; + static LOCALE_FORMATS: [&str; 4] = POSIX_LOCALE_FORMATS; static LOCALE_AMPM_MARKERS: [&str; 2] = POSIX_AMPM_MARKERS; } -/// Returns the locale's format for `%x`, `%X` or `%r`, given the specifier +/// Returns the locale's format for `%x`, `%X`, `%r` or `%c`, given the specifier /// without `%`, and `None` for any other specifier. pub fn get_locale_format(specifier: &str) -> Option<&'static str> { let index = LOCALE_FORMAT_SPECIFIERS diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index abdbe6c206..5c313947fe 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -3,8 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore AEDT AEST EEST NZDT NZST Kolkata Iseconds févr février janv janvier mercredi samedi sommes juin décembre Januar Juni Dezember enero junio diciembre gennaio giugno dicembre junho dezembro lundi dimanche Montag Sonntag Samstag sábado febr MEST MESZ KST uueuu ueuu vasárnap június január distros Wednes -// spell-checker:ignore uppercases xffx +// spell-checker:ignore AEDT AEST EEST NZDT NZST Kolkata Iseconds févr février janv janvier mercredi samedi sommes juin décembre Januar Juni Dezember enero junio diciembre gennaio giugno dicembre junho dezembro lundi dimanche Montag Sonntag Samstag sábado febr MEST MESZ KST uueuu ueuu vasárnap június január distros Wednes Ihours uppercases xffx use std::cmp::Ordering; @@ -355,6 +354,8 @@ fn test_date_rfc_8601_hour() { .succeeds() .stdout_matches(&re); } + + new_ucmd!().arg("-uIhours").succeeds().stdout_matches(&re); } #[test] @@ -473,6 +474,51 @@ fn test_date_rejects_multiple_output_formats() { .stderr_is("date: multiple output formats specified\n"); } +#[test] +fn test_date_rejects_iso_format_with_positional_format() { + new_ucmd!() + .env("LANG", "C") + .env("LC_ALL", "C") + .args(&["-r", ".", "-I", "+hours"]) + .fails_with_code(1) + .no_stdout() + .stderr_is("date: multiple output formats specified\n"); + + new_ucmd!() + .env("LANG", "C") + .env("LC_ALL", "C") + .args(&["-r", ".", "-I", "hours"]) + .fails_with_code(1) + .no_stdout() + .stderr_contains("lacks a leading '+'"); +} + +#[test] +fn test_date_rejects_setting_with_date_sources() { + // Setting the clock cannot be combined with another date source. + for args in [ + ["-d", "@now", "-s", ""], + ["-r", ".", "-s", ""], + ["-f", ".", "-s", ""], + ] { + new_ucmd!() + .env("LANG", "C") + .env("LC_ALL", "C") + .args(&args) + .fails_with_code(1) + .no_stdout() + .stderr_contains("cannot be used with"); + } + + new_ucmd!() + .env("LANG", "C") + .env("LC_ALL", "C") + .args(&["-s", "a", "--resolution"]) + .fails_with_code(1) + .no_stdout() + .stderr_contains("cannot be used with"); +} + #[test] fn test_date_missing_format_plus_precedes_format_conflict() { new_ucmd!() @@ -918,6 +964,22 @@ fn test_date_for_file_mtime() { .stdout_only("1234\n"); } +#[test] +fn test_date_reference_attached_value_containing_iso_flag() { + use std::time::{Duration, UNIX_EPOCH}; + + let (at, mut ucmd) = at_and_ucmd!(); + let reference_file = "Ifile"; + let f = at.make_file(reference_file); + f.set_modified(UNIX_EPOCH + Duration::from_secs(1234)) + .unwrap(); + + ucmd.arg("-rIfile") + .arg("+%s") + .succeeds() + .stdout_only("1234\n"); +} + #[test] #[cfg(target_os = "linux")] #[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] @@ -1142,6 +1204,34 @@ fn test_weekday_items_are_ignored_with_explicit_dates() { } } +#[test] +fn test_weekday_items_are_ignored_with_named_dates() { + for (input, format, expected) in [ + ("WEDNESDAY August 17", "+%m-%d", "08-17\n"), + ("August 17 Monday", "+%m-%d", "08-17\n"), + ("Monday 17 August 2026", "+%F", "2026-08-17\n"), + ("Wednesday 17 August 2026", "+%F", "2026-08-17\n"), + ( + "Next Friday Sep 25 12:00:00 AM EDT 2026", + "+%F %T %Z", + "2026-09-25 04:00:00 UTC\n", + ), + ( + "Next Sunday Sep 25 12:00:00 AM EDT 2026", + "+%F %T %Z", + "2026-09-25 04:00:00 UTC\n", + ), + ] { + new_ucmd!() + .env("LANG", "C") + .env("LC_ALL", "C") + .env("TZ", "UTC") + .args(&["-u", "-d", input, format]) + .succeeds() + .stdout_is(expected); + } +} + #[test] fn test_invalid_date_string() { new_ucmd!() @@ -2324,7 +2414,7 @@ fn test_date_french_full_sentence() { } } -/// `%x`, `%X` and `%r` use the locale's `D_FMT`, `T_FMT` and `T_FMT_AMPM`. +/// `%x`, `%X`, `%r` and `%c` use the locale's `D_FMT`, `T_FMT`, `T_FMT_AMPM` and `D_T_FMT`. /// The Linux values are GNU date's, the macOS ones follow its locale data. #[test] #[cfg(any(target_vendor = "apple", all(target_os = "linux", target_env = "gnu")))] @@ -2365,6 +2455,27 @@ fn test_date_format_locale_date_and_time() { } } +#[test] +#[cfg(all(target_os = "linux", target_env = "gnu"))] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: timezone/locale database not visible" +)] +fn test_date_format_locale_c() { + if !is_locale_available("fr_FR.UTF-8") { + return; + } + + new_ucmd!() + .env("LC_ALL", "fr_FR.UTF-8") + .env("TZ", "UTC") + .args(&["-u", "-d", "@0", "+%c"]) + .succeeds() + .stdout_contains("jeu.") + .stdout_contains("janv") + .stdout_contains("1970 00:00:00"); +} + /// GNU pads `%x`, `%X` and `%r` as a whole and keeps the padding of the /// fields inside: `%-x` is still `01/19/97`. #[test] @@ -2617,6 +2728,48 @@ fn test_date_strftime_flag_on_composite() { } } +#[test] +fn test_date_strftime_flags_on_iso_date_year() { + // Unlike other composites, GNU applies these modifiers to %Y inside %F. + for (format, expected) in [ + ("+%-F", "1-01-01\n"), + ("+%_F", "1-01-01\n"), + ("+%6F", "1-01-01\n"), + ("+%_6F", "1-01-01\n"), + ("+%12F", "000001-01-01\n"), + ("+%_12F", " 1-01-01\n"), + ] { + new_ucmd!() + .env("LC_ALL", "C") + .env("TZ", "UTC") + .arg("-d") + .arg("0001-01-01") + .arg(format) + .succeeds() + .stdout_is(expected); + } +} + +#[test] +fn test_date_strftime_plus_flag_on_iso_date_width() { + for (date, format, expected) in [ + ("0001-01-01", "+%+8F", "01-01-01\n"), + ("0001-01-01", "+%+11F", "+0001-01-01\n"), + ("2024-06-15", "+%+6F", "2024-06-15\n"), + ("2024-06-15", "+%+11F", "+2024-06-15\n"), + ("2024-06-15", "+%+12F", "+02024-06-15\n"), + ] { + new_ucmd!() + .env("LC_ALL", "C") + .env("TZ", "UTC") + .arg("-d") + .arg(date) + .arg(format) + .succeeds() + .stdout_is(expected); + } +} + #[test] fn test_date_strftime_timezone_flag_precedence() { for (format, expected) in [