From 5e0e12b572edd4ab5972c1c5f2b966f803c9fd52 Mon Sep 17 00:00:00 2001 From: Alvaro Guimaraes Date: Sat, 28 Mar 2026 00:12:26 -0400 Subject: [PATCH 1/2] date: support MMDDhhmm[[CC]YY][.ss] positional argument for setting date --- src/uu/date/src/date.rs | 96 ++++++++++++++--- tests/by-util/test_date.rs | 205 +++++++++++++++++++++++++++++++++++++ 2 files changed, 286 insertions(+), 15 deletions(-) diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index e577d081c49..f3246505692 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore strtime ; (format) DATEFILE MMDDhhmm ; (vars) datetime datetimes getres AWST ACST AEST foobarbaz +// spell-checker:ignore strtime rsplit ; (format) DATEFILE MMDDhhmm ; (vars) datetime datetimes getres AWST ACST AEST foobarbaz mod format_modifiers; mod locale; @@ -280,6 +280,60 @@ fn parse_military_timezone_with_offset(s: &str) -> Option<(i32, DayDelta)> { Some((hours_from_midnight, day_delta)) } +/// Parse a positional argument with format `MMDDhhmm[[CC]YY][.ss]` +fn parse_positional_set_datetime(s: &str, utc: bool) -> Option { + let (base, ss) = if let Some((before, after)) = s.rsplit_once('.') { + if after.len() != 2 || !after.chars().all(|c| c.is_ascii_digit()) { + return None; + } + (before, Some(after)) + } else { + (s, None) + }; + + if !base.chars().all(|c| c.is_ascii_digit()) { + return None; + } + + let tz = if utc { + TimeZone::UTC + } else { + TimeZone::system() + }; + + let rearranged = match base.len() { + 8 => { + let year = Timestamp::now().to_zoned(tz.clone()).year(); + format!("{year}{base}") + } + 10 => { + let yy: u32 = base[8..10].parse().ok()?; + let century = if yy > 68 { 19 } else { 20 }; + format!("{century}{}{}", &base[8..], &base[..8]) + } + 12 => format!("{}{}", &base[8..], &base[..8]), + _ => return None, + }; + + let with_seconds = if let Some(seconds) = ss { + format!("{rearranged}.{seconds}") + } else { + rearranged + }; + + let parse_format = if ss.is_none() { + "%Y%m%d%H%M" + } else { + "%Y%m%d%H%M.%S" + }; + + let dt = strtime::parse(parse_format, &with_seconds) + .and_then(|parsed| parsed.to_datetime()) + .ok()?; + + tz.to_ambiguous_zoned(dt).unambiguous().ok() +} + #[uucore::main] #[allow(clippy::cognitive_complexity)] pub fn uumain(args: impl uucore::Args) -> UResult<()> { @@ -317,25 +371,34 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } } + let utc = matches.get_flag(OPT_UNIVERSAL); + + let mut positional_set_to: Option = None; + let format = if let Some(form) = matches.get_one::(OPT_FORMAT) { - if !form.starts_with('+') { - // 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(_)) { - return Err(USimpleError::new( - 1, - translate!("date-error-invalid-date", "date" => form), - )); - } - // If the user did provide an input date with the --date flag and the Format String is - // not starting with '+' GNU prints the missing '+' error message + if let Some(stripped) = form.strip_prefix('+') { + Format::Custom(stripped.to_string()) + } else if matches!(date_source, DateSource::Human(_)) { + // -d was given, positional must be +FORMAT return Err(USimpleError::new( 1, translate!("date-error-format-missing-plus", "arg" => form), )); + } else if matches.get_one::(OPT_SET).is_some() { + // -s flag is present: positional must be +FORMAT + return Err(USimpleError::new( + 1, + translate!("date-error-invalid-date", "date" => form), + )); + } else if let Some(zoned) = parse_positional_set_datetime(form, utc) { + positional_set_to = Some(zoned); + Format::Default + } else { + return Err(USimpleError::new( + 1, + translate!("date-error-invalid-date", "date" => form), + )); } - let form = form[1..].to_string(); - Format::Custom(form) } else if let Some(fmt) = matches .get_many::(OPT_ISO_8601) .map(|mut iter| iter.next().unwrap_or(&DATE.to_string()).as_str().into()) @@ -354,7 +417,6 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { Format::Default }; - let utc = matches.get_flag(OPT_UNIVERSAL); let debug_mode = matches.get_flag(OPT_DEBUG); // Get the current time, either in the local time zone or UTC. @@ -378,6 +440,10 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { Some(Ok(date)) => Some(date), }; + // positional_set_to and set_to are mutually exclusive: + // MMDDhhmm parsing is only attempted when -s is absent. + let set_to = positional_set_to.or(set_to); + let settings = Settings { utc, format, diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index db4637e6273..27c2fd3d671 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -2836,3 +2836,208 @@ fn test_korean_time_zone() { .succeeds() .stdout_is("2026-01-15 01:00:00 UTC\n"); } + +#[test] +fn test_positional_set_invalid_month() { + new_ucmd!() + .arg("13011200") + .fails_with_code(1) + .stderr_contains("invalid date"); +} + +#[test] +fn test_positional_set_invalid_day() { + new_ucmd!() + .arg("01321200") + .fails_with_code(1) + .stderr_contains("invalid date"); +} + +#[test] +fn test_positional_set_feb_30() { + new_ucmd!() + .arg("02301200") + .fails_with_code(1) + .stderr_contains("invalid date"); +} + +#[test] +fn test_positional_invalid_hour() { + new_ucmd!() + .arg("01012500") + .fails_with_code(1) + .stderr_contains("invalid date"); +} + +#[test] +fn test_positional_set_invalid_minute() { + new_ucmd!() + .arg("01010160") + .fails_with_code(1) + .stderr_contains("invalid date"); +} + +#[test] +fn test_positional_set_invalid_length() { + new_ucmd!() + .arg("010101600") + .fails_with_code(1) + .stderr_contains("invalid date"); +} + +#[test] +fn test_positional_set_non_digits() { + new_ucmd!() + .arg("010110a") + .fails_with_code(1) + .stderr_contains("invalid date"); +} + +#[test] +fn test_positional_set_wrong_seconds_suffix() { + new_ucmd!() + .arg("01011200.1") + .fails_with_code(1) + .stderr_contains("invalid date"); + new_ucmd!() + .arg("01012359.aa") + .fails_with_code(1) + .stderr_contains("invalid date"); + new_ucmd!() + .arg("01011200.123") + .fails_with_code(1) + .stderr_contains("invalid date"); +} + +#[test] +fn test_positional_set_with_extra_operand() { + new_ucmd!() + .args(&["01011200", "+%Y"]) + .fails_with_code(1) + .stderr_contains("extra operand"); +} + +#[test] +fn test_positional_set_with_d_flag_format_error() { + new_ucmd!() + .args(&["--date", "2025-01-01", "01012025"]) + .fails_with_code(1) + .stderr_contains("lacks a leading '+'"); +} + +#[test] +#[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] +fn test_positional_set_12_digits() { + if geteuid() == 0 || uucore::os::is_wsl_1() { + return; + } + let result = new_ucmd!().arg("010112002025").fails(); + result.no_stdout(); + assert!( + result.stderr_str().starts_with("date: cannot set date: "), + "Expected permission error, got: {}", + result.stderr_str() + ); +} + +#[test] +#[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] +fn test_positional_set_8_digits() { + if geteuid() == 0 || uucore::os::is_wsl_1() { + return; + } + let result = new_ucmd!().arg("01011200").fails(); + result.no_stdout(); + assert!( + result.stderr_str().starts_with("date: cannot set date: "), + "Expected permission error, got: {}", + result.stderr_str() + ); +} + +#[test] +#[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] +fn test_positional_set_10_digits() { + if geteuid() == 0 || uucore::os::is_wsl_1() { + return; + } + let result = new_ucmd!().arg("0101120025").fails(); + result.no_stdout(); + assert!( + result.stderr_str().starts_with("date: cannot set date: "), + "Expected permission error, got: {}", + result.stderr_str() + ); +} + +#[test] +#[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] +fn test_positional_set_12_digits_with_seconds() { + if geteuid() == 0 || uucore::os::is_wsl_1() { + return; + } + let result = new_ucmd!().arg("010112002025.40").fails(); + result.no_stdout(); + assert!( + result.stderr_str().starts_with("date: cannot set date: "), + "Expected permission error, got: {}", + result.stderr_str() + ); +} + +#[test] +#[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] +fn test_positional_set_12_digits_with_utc_flag() { + if geteuid() == 0 || uucore::os::is_wsl_1() { + return; + } + let result = new_ucmd!().args(&["-u", "010112002025"]).fails(); + result.no_stdout(); + assert!( + result.stderr_str().starts_with("date: cannot set date: "), + "Expected permission error, got: {}", + result.stderr_str() + ); +} + +#[test] +#[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] +fn test_positional_set_century_rule_68() { + if geteuid() == 0 || uucore::os::is_wsl_1() { + return; + } + let result = new_ucmd!().arg("0101120068").fails(); + result.no_stdout(); + assert!( + result.stderr_str().starts_with("date: cannot set date: "), + "Expected permission error, got: {}", + result.stderr_str() + ); +} + +#[test] +#[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] +fn test_positional_set_century_rule_69() { + if geteuid() == 0 || uucore::os::is_wsl_1() { + return; + } + let result = new_ucmd!().arg("0101120069").fails(); + result.no_stdout(); + assert!( + result.stderr_str().starts_with("date: cannot set date: "), + "Expected permission error, got: {}", + result.stderr_str() + ); +} + +#[test] +#[cfg(target_os = "macos")] +fn test_positional_set_macos_unavailable() { + let result = new_ucmd!().arg("010112002025").fails(); + result.no_stdout(); + assert!( + result + .stderr_str() + .starts_with("date: setting the date is not supported by macOS") + ); +} From db92a256c7780a727d7e14b866b5ca4ed42f6bb7 Mon Sep 17 00:00:00 2001 From: Alvaro Guimaraes Date: Sun, 29 Mar 2026 09:34:43 -0400 Subject: [PATCH 2/2] add unit tests --- src/uu/date/src/date.rs | 98 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index f3246505692..8d4ba374046 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -1233,4 +1233,102 @@ mod tests { assert_eq!(strip_parenthesized_comments("a(b(c)d"), "a"); // Nested unbalanced assert_eq!(strip_parenthesized_comments("a(b)c(d)e(f"), "ace"); // Multiple groups, last unmatched } + + #[test] + fn test_parse_positional_12_digits() { + let result = parse_positional_set_datetime("010112002025", true).unwrap(); + assert_eq!(result.year(), 2025); + assert_eq!(result.month(), 1); + assert_eq!(result.day(), 1); + assert_eq!(result.hour(), 12); + assert_eq!(result.minute(), 0); + assert_eq!(result.second(), 0); + } + + #[test] + fn test_parse_positional_8_digits() { + let result = parse_positional_set_datetime("01011200", true).unwrap(); + let current_year = Timestamp::now().to_zoned(TimeZone::UTC).year(); + assert_eq!(result.year(), current_year); + assert_eq!(result.month(), 1); + assert_eq!(result.day(), 1); + assert_eq!(result.hour(), 12); + assert_eq!(result.minute(), 0); + } + + #[test] + fn test_parse_positional_10_digits_century_rule() { + // 25 -> 2025 + let result = parse_positional_set_datetime("0101120025", true).unwrap(); + assert_eq!(result.year(), 2025); + + // 68 -> 2068 (boundary) + let result = parse_positional_set_datetime("0101120068", true).unwrap(); + assert_eq!(result.year(), 2068); + + // 69 -> 1969 (boundary) + let result = parse_positional_set_datetime("0101120069", true).unwrap(); + assert_eq!(result.year(), 1969); + + // 99 -> 1999 + let result = parse_positional_set_datetime("0101120099", true).unwrap(); + assert_eq!(result.year(), 1999); + } + + #[test] + fn test_parse_positional_with_seconds() { + // 12 digits + seconds + let result = parse_positional_set_datetime("010112002025.45", true).unwrap(); + assert_eq!(result.year(), 2025); + assert_eq!(result.hour(), 12); + assert_eq!(result.second(), 45); + + // 10 digits + seconds + let result = parse_positional_set_datetime("0101120025.30", true).unwrap(); + assert_eq!(result.year(), 2025); + assert_eq!(result.second(), 30); + + // 8 digits + seconds + let result = parse_positional_set_datetime("01011200.59", true).unwrap(); + assert_eq!(result.hour(), 12); + assert_eq!(result.second(), 59); + + // Seconds boundary: .00 + let result = parse_positional_set_datetime("010112002025.00", true).unwrap(); + assert_eq!(result.second(), 0); + } + + #[test] + fn test_parse_positional_local_timezone() { + let result = parse_positional_set_datetime("010112002025", false).unwrap(); + assert_eq!(result.year(), 2025); + assert_eq!(result.month(), 1); + assert_eq!(result.day(), 1); + assert_eq!(result.hour(), 12); + assert_eq!(result.minute(), 0); + } + + #[test] + fn test_parse_positional_invalid_inputs() { + // Invalid month + assert!(parse_positional_set_datetime("13011200", true).is_none()); + // Invalid day + assert!(parse_positional_set_datetime("01321200", true).is_none()); + // Invalid hour + assert!(parse_positional_set_datetime("01012500", true).is_none()); + // Invalid minute + assert!(parse_positional_set_datetime("01011261", true).is_none()); + // Feb 30 + assert!(parse_positional_set_datetime("02301200", true).is_none()); + // Wrong length + assert!(parse_positional_set_datetime("0101120", true).is_none()); + // Non-digits + assert!(parse_positional_set_datetime("0101120a", true).is_none()); + // Empty string + assert!(parse_positional_set_datetime("", true).is_none()); + // Bad seconds suffix + assert!(parse_positional_set_datetime("01011200.1", true).is_none()); + assert!(parse_positional_set_datetime("01011200.abc", true).is_none()); + assert!(parse_positional_set_datetime("01011200.123", true).is_none()); + } }