diff --git a/src/uu/date/src/format_modifiers.rs b/src/uu/date/src/format_modifiers.rs index c6a3d01c8..00e9df778 100644 --- a/src/uu/date/src/format_modifiers.rs +++ b/src/uu/date/src/format_modifiers.rs @@ -146,7 +146,8 @@ fn format_with_modifiers( if !flags.is_empty() || !width_str.is_empty() { // Apply modifiers to the formatted value let width: usize = width_str.parse().unwrap_or(0); - let modified = apply_modifiers(&formatted, flags, width, spec); + let explicit_width = !width_str.is_empty(); + let modified = apply_modifiers(&formatted, flags, width, spec, explicit_width); result.push_str(&modified); } else { // No modifiers, use formatted value as-is @@ -174,6 +175,59 @@ fn is_text_specifier(specifier: &str) -> bool { ) } +/// Returns true if the specifier defaults to space padding. +/// This includes text specifiers and numeric specifiers like %e and %k +/// that use blank-padding by default in GNU date. +fn is_space_padded_specifier(specifier: &str) -> bool { + matches!( + specifier.chars().last(), + Some('A' | 'a' | 'B' | 'b' | 'h' | 'Z' | 'p' | 'P' | 'e' | 'k' | 'l') + ) +} + +/// Returns the default width for a specifier. +/// This is used when a flag like `_` is used without an explicit width. +fn get_default_width(specifier: &str) -> usize { + match specifier.chars().last() { + // Day of month: 2 digits (01-31) + Some('d') | Some('e') => 2, + // Month: 2 digits (01-12) + Some('m') => 2, + // Hour: 2 digits (00-23) + Some('H') | Some('k') => 2, + // Hour (12-hour): 2 digits (01-12) + Some('I') | Some('l') => 2, + // Minute: 2 digits (00-59) + Some('M') => 2, + // Second: 2 digits (00-60) + Some('S') => 2, + // Year (2-digit): 2 digits + Some('y') => 2, + // Day of year: 3 digits (001-366) + Some('j') => 3, + // Week number: 2 digits (00-53) + Some('U') | Some('W') | Some('V') => 2, + // Day of week: 1 digit (0-6 or 1-7) + Some('w') | Some('u') => 1, + // Century: 2 digits (00-99) + Some('C') => 2, + // Full year: 4 digits + Some('Y') | Some('G') => 4, + // ISO week year (2-digit): 2 digits + Some('g') => 2, + // Epoch seconds: typically 10 digits (but variable) + Some('s') => 0, + // Nanoseconds: 9 digits + Some('N') => 9, + // Quarter: 1 digit + Some('q') => 1, + // Timezone offset: varies + Some('z') => 0, + // Text specifiers have no default width + _ => 0, + } +} + /// Strip default padding (leading zeros or leading spaces) from a value, /// preserving at least one character. fn strip_default_padding(value: &str) -> String { @@ -203,11 +257,23 @@ fn strip_default_padding(value: &str) -> String { /// which determines the default padding character (space for text, zero for numeric). /// Flags are processed in order so that when conflicting flags appear, /// the last one takes precedence (e.g., `_+` means `+` wins for padding). -fn apply_modifiers(value: &str, flags: &str, width: usize, specifier: &str) -> String { +/// +/// The `explicit_width` parameter indicates whether a width was explicitly +/// specified in the format string (true) or if width is 0 (false). +fn apply_modifiers( + value: &str, + flags: &str, + width: usize, + specifier: &str, + explicit_width: bool, +) -> String { let mut result = value.to_string(); // Determine default pad character based on specifier type - let default_pad = if is_text_specifier(specifier) { + // Determine default pad character based on specifier type. + // Text specifiers (month names, etc.) and numeric specifiers like %e, %k, %l + // default to space padding; other numeric specifiers default to zero padding. + let default_pad = if is_space_padded_specifier(specifier) { ' ' } else { '0' @@ -219,6 +285,7 @@ fn apply_modifiers(value: &str, flags: &str, width: usize, specifier: &str) -> S let mut uppercase = false; let mut swap_case = false; let mut force_sign = false; + let mut underscore_flag = false; for flag in flags.chars() { match flag { @@ -228,6 +295,7 @@ fn apply_modifiers(value: &str, flags: &str, width: usize, specifier: &str) -> S '_' => { no_pad = false; pad_char = ' '; + underscore_flag = true; } '0' => { no_pad = false; @@ -271,30 +339,48 @@ fn apply_modifiers(value: &str, flags: &str, width: usize, specifier: &str) -> S return strip_default_padding(&result); } + // Handle padding flag without explicit width: use default width + // This applies when _ or 0 flag overrides the default padding character + // and no explicit width is specified (e.g., %_m, %0e) + let effective_width = if !explicit_width && (underscore_flag || pad_char != default_pad) { + get_default_width(specifier) + } else { + width + }; + // Handle width smaller than result: strip default padding to fit - if width > 0 && width < result.len() { + if effective_width > 0 && effective_width < result.len() { return strip_default_padding(&result); } - // Strip leading zeros when switching to space padding on numeric fields - if pad_char == ' ' - && !is_text_specifier(specifier) - && result.starts_with('0') - && result.len() >= 2 - { - result = strip_default_padding(&result); + // Strip default padding when switching pad characters on numeric fields + if !is_text_specifier(specifier) && result.len() >= 2 { + if pad_char == ' ' && result.starts_with('0') { + // Switching to space padding: strip leading zeros + result = strip_default_padding(&result); + } else if pad_char == '0' && result.starts_with(' ') { + // Switching to zero padding: strip leading spaces + result = strip_default_padding(&result); + } } // Apply force sign for numeric values + // GNU behavior: + only adds sign if: + // 1. An explicit width is provided, OR + // 2. The value exceeds the default width for that specifier (e.g., year > 4 digits) if force_sign && !result.starts_with('+') && !result.starts_with('-') { if result.chars().next().is_some_and(|c| c.is_ascii_digit()) { - result.insert(0, '+'); + let default_w = get_default_width(specifier); + // Add sign only if explicit width provided OR result exceeds default width + if explicit_width || (default_w > 0 && result.len() > default_w) { + result.insert(0, '+'); + } } } // Apply width padding - if width > result.len() { - let padding = width - result.len(); + if effective_width > result.len() { + let padding = effective_width - result.len(); let has_sign = result.starts_with('+') || result.starts_with('-'); if pad_char == '0' && has_sign { @@ -488,82 +574,140 @@ mod tests { #[test] fn test_apply_modifiers_basic() { // No modifiers (numeric specifier) - assert_eq!(apply_modifiers("1999", "", 0, "Y"), "1999"); + assert_eq!(apply_modifiers("1999", "", 0, "Y", false), "1999"); // Zero padding - assert_eq!(apply_modifiers("1999", "0", 10, "Y"), "0000001999"); + assert_eq!(apply_modifiers("1999", "0", 10, "Y", true), "0000001999"); // Space padding (strips leading zeros) - assert_eq!(apply_modifiers("06", "_", 5, "m"), " 6"); + assert_eq!(apply_modifiers("06", "_", 5, "m", true), " 6"); // No-pad (strips leading zeros, width ignored) - assert_eq!(apply_modifiers("01", "-", 5, "d"), "1"); + assert_eq!(apply_modifiers("01", "-", 5, "d", true), "1"); // Uppercase - assert_eq!(apply_modifiers("june", "^", 0, "B"), "JUNE"); + assert_eq!(apply_modifiers("june", "^", 0, "B", false), "JUNE"); // Swap case: all uppercase → lowercase - assert_eq!(apply_modifiers("UTC", "#", 0, "Z"), "utc"); + assert_eq!(apply_modifiers("UTC", "#", 0, "Z", false), "utc"); // Swap case: mixed case → uppercase - assert_eq!(apply_modifiers("June", "#", 0, "B"), "JUNE"); + assert_eq!(apply_modifiers("June", "#", 0, "B", false), "JUNE"); } #[test] fn test_apply_modifiers_signs() { - // Force sign - assert_eq!(apply_modifiers("1970", "+", 6, "Y"), "+01970"); + // Force sign with explicit width + assert_eq!(apply_modifiers("1970", "+", 6, "Y", true), "+01970"); + // Force sign without explicit width: should NOT add sign for 4-digit year + assert_eq!(apply_modifiers("1999", "+", 0, "Y", false), "1999"); + // Force sign without explicit width: SHOULD add sign for year > 4 digits + assert_eq!(apply_modifiers("12345", "+", 0, "Y", false), "+12345"); // Negative with zero padding: sign first, then zeros - assert_eq!(apply_modifiers("-22", "0", 5, "s"), "-0022"); + assert_eq!(apply_modifiers("-22", "0", 5, "s", true), "-0022"); // Negative with space padding: spaces first, then sign - assert_eq!(apply_modifiers("-22", "_", 5, "s"), " -22"); + assert_eq!(apply_modifiers("-22", "_", 5, "s", true), " -22"); // Force sign (_+): + is last, overrides _ → zero pad with sign - assert_eq!(apply_modifiers("5", "_+", 5, "s"), "+0005"); + assert_eq!(apply_modifiers("5", "_+", 5, "s", true), "+0005"); // No-pad + uppercase: no padding applied - assert_eq!(apply_modifiers("june", "-^", 10, "B"), "JUNE"); + assert_eq!(apply_modifiers("june", "-^", 10, "B", true), "JUNE"); } #[test] fn test_case_flag_precedence() { // Test that ^ (uppercase) overrides # (swap case) - assert_eq!(apply_modifiers("June", "^#", 0, "B"), "JUNE"); - assert_eq!(apply_modifiers("June", "#^", 0, "B"), "JUNE"); + assert_eq!(apply_modifiers("June", "^#", 0, "B", false), "JUNE"); + assert_eq!(apply_modifiers("June", "#^", 0, "B", false), "JUNE"); // Test # alone (swap case) - assert_eq!(apply_modifiers("June", "#", 0, "B"), "JUNE"); - assert_eq!(apply_modifiers("JUNE", "#", 0, "B"), "june"); + assert_eq!(apply_modifiers("June", "#", 0, "B", false), "JUNE"); + assert_eq!(apply_modifiers("JUNE", "#", 0, "B", false), "june"); } #[test] fn test_apply_modifiers_text_specifiers() { // Text specifiers default to space padding - assert_eq!(apply_modifiers("June", "", 10, "B"), " June"); - assert_eq!(apply_modifiers("Mon", "", 10, "a"), " Mon"); + assert_eq!(apply_modifiers("June", "", 10, "B", true), " June"); + assert_eq!(apply_modifiers("Mon", "", 10, "a", true), " Mon"); // Numeric specifiers default to zero padding - assert_eq!(apply_modifiers("6", "", 10, "m"), "0000000006"); + assert_eq!(apply_modifiers("6", "", 10, "m", true), "0000000006"); } #[test] fn test_apply_modifiers_width_smaller_than_result() { // Width smaller than result strips default padding - assert_eq!(apply_modifiers("01", "", 1, "d"), "1"); - assert_eq!(apply_modifiers("06", "", 1, "m"), "6"); + assert_eq!(apply_modifiers("01", "", 1, "d", true), "1"); + assert_eq!(apply_modifiers("06", "", 1, "m", true), "6"); } #[test] fn test_apply_modifiers_parametrized() { let test_cases = vec![ - ("1", "0", 3, "Y", "001"), - ("1", "_", 3, "d", " 1"), - ("1", "-", 3, "d", "1"), // no-pad: width ignored - ("abc", "^", 5, "B", " ABC"), // text specifier: space pad - ("5", "+", 4, "s", "+005"), - ("5", "_+", 4, "s", "+005"), // + is last: zero pad with sign - ("-3", "0", 5, "s", "-0003"), - ("05", "_", 3, "d", " 5"), - ("09", "-", 4, "d", "9"), // no-pad: width ignored - ("1970", "_+", 6, "Y", "+01970"), // + is last: zero pad with sign + ("1", "0", 3, "Y", true, "001"), + ("1", "_", 3, "d", true, " 1"), + ("1", "-", 3, "d", true, "1"), // no-pad: width ignored + ("abc", "^", 5, "B", true, " ABC"), // text specifier: space pad + ("5", "+", 4, "s", true, "+005"), + ("5", "_+", 4, "s", true, "+005"), // + is last: zero pad with sign + ("-3", "0", 5, "s", true, "-0003"), + ("05", "_", 3, "d", true, " 5"), + ("09", "-", 4, "d", true, "9"), // no-pad: width ignored + ("1970", "_+", 6, "Y", true, "+01970"), // + is last: zero pad with sign ]; - for (value, flags, width, spec, expected) in test_cases { + for (value, flags, width, spec, explicit_width, expected) in test_cases { assert_eq!( - apply_modifiers(value, flags, width, spec), + apply_modifiers(value, flags, width, spec, explicit_width), expected, - "value='{value}', flags='{flags}', width={width}, spec='{spec}'", + "value='{value}', flags='{flags}', width={width}, spec='{spec}', explicit_width={explicit_width}", ); } } + + #[test] + fn test_underscore_flag_without_width() { + // %_m should pad month to default width 2 with spaces + assert_eq!(apply_modifiers("6", "_", 0, "m", false), " 6"); + // %_d should pad day to default width 2 with spaces + assert_eq!(apply_modifiers("1", "_", 0, "d", false), " 1"); + // %_H should pad hour to default width 2 with spaces + assert_eq!(apply_modifiers("5", "_", 0, "H", false), " 5"); + // %_Y should pad year to default width 4 with spaces + assert_eq!(apply_modifiers("1999", "_", 0, "Y", false), "1999"); // already at default width + } + + #[test] + fn test_plus_flag_without_width() { + // %+Y without width should NOT add sign for 4-digit year + assert_eq!(apply_modifiers("1999", "+", 0, "Y", false), "1999"); + // %+Y without width SHOULD add sign for year > 4 digits + assert_eq!(apply_modifiers("12345", "+", 0, "Y", false), "+12345"); + // %+Y with explicit width should add sign + assert_eq!(apply_modifiers("1999", "+", 6, "Y", true), "+01999"); + } + + #[test] + fn test_zero_flag_on_space_padded_specifiers() { + // GNU date: %0e should override space-padding with zero-padding + // Verified: `date -d "2024-06-05" "+%0e"` → "05" + let date = make_test_date(1999, 6, 5, 5); + let config = get_config(); + + // %0e: day-of-month (normally space-padded) with 0 flag → zero-padded + let result = format_with_modifiers(&date, "%0e", &config).unwrap(); + assert_eq!(result, "05", "GNU: %0e should produce '05', not ' 5'"); + + // %0k: hour (normally space-padded) with 0 flag → zero-padded + let result = format_with_modifiers(&date, "%0k", &config).unwrap(); + assert_eq!(result, "05", "GNU: %0k should produce '05', not ' 5'"); + } + + #[test] + fn test_underscore_century_default_width() { + // GNU date: %C default width is 2, not 4 + // Verified: `date -d "2024-06-15" "+%_C"` → "20" (no extra padding) + let date = make_test_date(1999, 6, 1, 0); + let config = get_config(); + + // %_C: century with underscore flag, no explicit width + // Default width for %C should be 2 (century is 00-99) + let result = format_with_modifiers(&date, "%_C", &config).unwrap(); + assert_eq!( + result, "19", + "GNU: %_C should produce '19', not ' 19' (default width is 2, not 4)" + ); + } } diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index 760e214cc..be7a4cd4a 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -2414,6 +2414,126 @@ fn test_date_format_modifier_percent_escape() { .stdout_is("%Y=0000001999\n"); } +// Tests for format modifier edge cases (flags without explicit width) +#[test] +fn test_date_format_modifier_edge_cases() { + // Test cases: (date, format, expected_output, description) + let cases = vec![ + // Underscore flag without explicit width (uses default width) + ("1999-06-01", "%_d", " 1", "%_d pads day to default width 2"), + ( + "1999-06-15", + "%_m", + " 6", + "%_m pads month to default width 2", + ), + ( + "1999-06-01 05:00:00", + "%_H", + " 5", + "%_H pads hour to default width 2", + ), + ( + "1999-06-01", + "%_Y", + "1999", + "%_Y year already at default width 4", + ), + ( + "1999-06-01", + "%_C", + "19", + "%_C century uses default width 2", + ), + ("2024-06-01", "%_C", "20", "%_C century for year 2024"), + ( + "1999-01-01", + "%_j", + " 1", + "%_j pads day-of-year to default width 3", + ), + ("1999-04-10", "%_j", "100", "%_j day 100 already at width 3"), + // Zero flag on space-padded specifiers (overrides default padding) + ( + "1999-06-05", + "%0e", + "05", + "%0e overrides space-padding with zero", + ), + ( + "1999-06-01 05:00:00", + "%0k", + "05", + "%0k overrides space-padding with zero", + ), + ( + "1999-06-01 05:00:00", + "%0l", + "05", + "%0l overrides space-padding with zero", + ), + // Zero flag without explicit width (uses default width) + ( + "1999-06-01", + "%0d", + "01", + "%0d day with zero padding (default width 2)", + ), + ( + "1999-06-15", + "%0m", + "06", + "%0m month with zero padding (default width 2)", + ), + ( + "1999-01-01", + "%0j", + "001", + "%0j day-of-year with zero padding (default width 3)", + ), + // Space-padded specifiers default behavior (no modifier) + ("1999-06-05", "%e", " 5", "%e defaults to space padding"), + ( + "1999-06-01 05:00:00", + "%k", + " 5", + "%k defaults to space padding", + ), + ( + "1999-06-01 05:00:00", + "%l", + " 5", + "%l defaults to space padding", + ), + // Plus flag without explicit width + ( + "1999-06-01", + "%+Y", + "1999", + "%+Y no sign for 4-digit year without width", + ), + ( + "1999-06-01", + "%+6Y", + "+01999", + "%+6Y with explicit width adds sign", + ), + ]; + + for (date, format, expected, description) in cases { + let result = new_ucmd!() + .env("TZ", "UTC") + .args(&["-d", date, &format!("+{format}")]) + .succeeds(); + // stdout includes newline, expected is without newline + assert_eq!( + result.stdout_str(), + format!("{expected}\n"), + "{description}" + ); + } +} + // Tests for --debug flag #[test] fn test_date_debug_basic() {