From f50eda0c2b6f7a8afb4161ba7dbf9795127f68b8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 07:26:31 +0000 Subject: [PATCH 1/6] Initial plan From 9d8acd1b6b0919d55226d7111ffa41e34581d02e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 07:34:40 +0000 Subject: [PATCH 2/6] Fix date command %Z format to show timezone abbreviations instead of offsets Co-authored-by: jgarzik <494411+jgarzik@users.noreply.github.com> --- Cargo.lock | 1 + datetime/Cargo.toml | 1 + datetime/date.rs | 57 +++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fd9bd81aa..b2e88aafa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1661,6 +1661,7 @@ name = "posixutils-datetime" version = "0.7.0" dependencies = [ "chrono", + "chrono-tz", "clap", "gettext-rs", "libc", diff --git a/datetime/Cargo.toml b/datetime/Cargo.toml index f2e50809f..8d1d62c43 100644 --- a/datetime/Cargo.toml +++ b/datetime/Cargo.toml @@ -11,6 +11,7 @@ rust-version.workspace = true clap.workspace = true gettext-rs.workspace = true chrono.workspace = true +chrono-tz.workspace = true libc.workspace = true [dev-dependencies] diff --git a/datetime/date.rs b/datetime/date.rs index 5ea597de9..52c361c4d 100644 --- a/datetime/date.rs +++ b/datetime/date.rs @@ -12,11 +12,57 @@ // use chrono::{DateTime, Datelike, Local, LocalResult, TimeZone, Utc}; +use chrono_tz::Tz; use clap::Parser; use gettextrs::{bind_textdomain_codeset, gettext, setlocale, textdomain, LocaleCategory}; +use std::env; const DEF_TIMESTR: &str = "%a %b %e %H:%M:%S %Z %Y"; +/// Get the timezone abbreviation for the given datetime +fn get_timezone_abbreviation(dt: &DateTime) -> String { + // Try to get TZ environment variable + if let Ok(tz_str) = env::var("TZ") { + // Handle special cases for UTC + if tz_str == "UTC" || tz_str == "UTC0" || tz_str.is_empty() { + return "UTC".to_string(); + } + + // Try to parse it as a chrono-tz timezone + if let Ok(tz) = tz_str.parse::() { + // Convert the local datetime to the specified timezone + if let Some(dt_tz) = tz.from_local_datetime(&dt.naive_local()).single() { + return dt_tz.format("%Z").to_string(); + } + } + } + + // Fallback: if TZ is not set or invalid, try to detect system timezone + // For now, check if offset is zero, then it's UTC + let offset = dt.offset().local_minus_utc(); + if offset == 0 { + return "UTC".to_string(); + } + + // Otherwise, use the offset format as fallback + dt.format("%:z").to_string() +} + +/// Format a datetime string, replacing %Z with proper timezone abbreviation +fn format_with_timezone(formatstr: &str, dt: &DateTime) -> String { + if formatstr.contains("%Z") { + let tz_abbr = get_timezone_abbreviation(dt); + let formatted = dt.format(formatstr).to_string(); + // Replace the offset (like +01:00) with the timezone abbreviation + // chrono uses %Z for offset, so we need to replace it + // The offset format from chrono with %Z is like "+01:00" or "+00:00" + let offset_pattern = dt.format("%Z").to_string(); + formatted.replace(&offset_pattern, &tz_abbr) + } else { + dt.format(formatstr).to_string() + } +} + #[derive(Parser)] #[command(version, about = gettext("date - write the date and time"))] struct Args { @@ -40,12 +86,19 @@ struct Args { fn show_time_local(formatstr: &str) -> String { let now = chrono::Local::now(); - now.format(formatstr).to_string() + format_with_timezone(formatstr, &now) } fn show_time_utc(formatstr: &str) -> String { let now = chrono::Utc::now(); - now.format(formatstr).to_string() + // For UTC, %Z should always be "UTC" + if formatstr.contains("%Z") { + let formatted = now.format(formatstr).to_string(); + let offset_pattern = now.format("%Z").to_string(); + formatted.replace(&offset_pattern, "UTC") + } else { + now.format(formatstr).to_string() + } } fn show_time(utc: bool, formatstr: &str) { From b5e0192a55da3898aa5062a17f978e722903e95e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 07:37:37 +0000 Subject: [PATCH 3/6] Improve %Z handling: fix DST transitions and escaped %% support Co-authored-by: jgarzik <494411+jgarzik@users.noreply.github.com> --- datetime/date.rs | 84 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 67 insertions(+), 17 deletions(-) diff --git a/datetime/date.rs b/datetime/date.rs index 52c361c4d..9e02da070 100644 --- a/datetime/date.rs +++ b/datetime/date.rs @@ -31,7 +31,10 @@ fn get_timezone_abbreviation(dt: &DateTime) -> String { // Try to parse it as a chrono-tz timezone if let Ok(tz) = tz_str.parse::() { // Convert the local datetime to the specified timezone - if let Some(dt_tz) = tz.from_local_datetime(&dt.naive_local()).single() { + // Use earliest() to handle DST transitions consistently + let dt_tz = tz.from_local_datetime(&dt.naive_local()).earliest() + .or_else(|| tz.from_local_datetime(&dt.naive_local()).latest()); + if let Some(dt_tz) = dt_tz { return dt_tz.format("%Z").to_string(); } } @@ -49,15 +52,69 @@ fn get_timezone_abbreviation(dt: &DateTime) -> String { } /// Format a datetime string, replacing %Z with proper timezone abbreviation -fn format_with_timezone(formatstr: &str, dt: &DateTime) -> String { +fn format_with_timezone_local(formatstr: &str, dt: &DateTime) -> String { if formatstr.contains("%Z") { let tz_abbr = get_timezone_abbreviation(dt); - let formatted = dt.format(formatstr).to_string(); - // Replace the offset (like +01:00) with the timezone abbreviation - // chrono uses %Z for offset, so we need to replace it - // The offset format from chrono with %Z is like "+01:00" or "+00:00" - let offset_pattern = dt.format("%Z").to_string(); - formatted.replace(&offset_pattern, &tz_abbr) + // Process the format string character by character to handle %Z properly + let mut result = String::new(); + let mut chars = formatstr.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '%' { + if let Some(&next_ch) = chars.peek() { + if next_ch == '%' { + // %% should become % in the output - let chrono handle this + result.push('%'); + result.push('%'); + chars.next(); // consume the second % + continue; + } else if next_ch == 'Z' { + // Replace %Z with the timezone abbreviation + result.push_str(&tz_abbr); + chars.next(); // consume 'Z' + continue; + } + } + } + result.push(ch); + } + + // Format the modified format string + dt.format(&result).to_string() + } else { + dt.format(formatstr).to_string() + } +} + +/// Format a datetime string for UTC, replacing %Z with "UTC" +fn format_with_timezone_utc(formatstr: &str, dt: &DateTime) -> String { + if formatstr.contains("%Z") { + // Process the format string character by character to handle %Z properly + let mut result = String::new(); + let mut chars = formatstr.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '%' { + if let Some(&next_ch) = chars.peek() { + if next_ch == '%' { + // %% should become % in the output - let chrono handle this + result.push('%'); + result.push('%'); + chars.next(); // consume the second % + continue; + } else if next_ch == 'Z' { + // Replace %Z with "UTC" + result.push_str("UTC"); + chars.next(); // consume 'Z' + continue; + } + } + } + result.push(ch); + } + + // Format the modified format string + dt.format(&result).to_string() } else { dt.format(formatstr).to_string() } @@ -86,19 +143,12 @@ struct Args { fn show_time_local(formatstr: &str) -> String { let now = chrono::Local::now(); - format_with_timezone(formatstr, &now) + format_with_timezone_local(formatstr, &now) } fn show_time_utc(formatstr: &str) -> String { let now = chrono::Utc::now(); - // For UTC, %Z should always be "UTC" - if formatstr.contains("%Z") { - let formatted = now.format(formatstr).to_string(); - let offset_pattern = now.format("%Z").to_string(); - formatted.replace(&offset_pattern, "UTC") - } else { - now.format(formatstr).to_string() - } + format_with_timezone_utc(formatstr, &now) } fn show_time(utc: bool, formatstr: &str) { From 089762faf261c29ace6a854d9fdac3e9027ec20b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 07:38:45 +0000 Subject: [PATCH 4/6] Refactor: eliminate code duplication and improve efficiency Co-authored-by: jgarzik <494411+jgarzik@users.noreply.github.com> --- datetime/date.rs | 91 +++++++++++++++++++----------------------------- 1 file changed, 36 insertions(+), 55 deletions(-) diff --git a/datetime/date.rs b/datetime/date.rs index 9e02da070..ab7b06dd5 100644 --- a/datetime/date.rs +++ b/datetime/date.rs @@ -31,9 +31,9 @@ fn get_timezone_abbreviation(dt: &DateTime) -> String { // Try to parse it as a chrono-tz timezone if let Ok(tz) = tz_str.parse::() { // Convert the local datetime to the specified timezone - // Use earliest() to handle DST transitions consistently - let dt_tz = tz.from_local_datetime(&dt.naive_local()).earliest() - .or_else(|| tz.from_local_datetime(&dt.naive_local()).latest()); + // Use earliest() to handle DST transitions consistently, with latest() as fallback + let local_result = tz.from_local_datetime(&dt.naive_local()); + let dt_tz = local_result.earliest().or_else(|| local_result.latest()); if let Some(dt_tz) = dt_tz { return dt_tz.format("%Z").to_string(); } @@ -51,36 +51,41 @@ fn get_timezone_abbreviation(dt: &DateTime) -> String { dt.format("%:z").to_string() } +/// Parse a format string and replace %Z with the provided timezone abbreviation +/// This function handles escaped %% sequences properly +fn parse_format_string_with_tz(formatstr: &str, tz_abbr: &str) -> String { + let mut result = String::new(); + let mut chars = formatstr.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '%' { + if let Some(&next_ch) = chars.peek() { + if next_ch == '%' { + // Preserve %% so chrono can later convert it to a single % + result.push('%'); + result.push('%'); + chars.next(); // consume the second % + continue; + } else if next_ch == 'Z' { + // Replace %Z with the timezone abbreviation + result.push_str(tz_abbr); + chars.next(); // consume 'Z' + continue; + } + } + } + result.push(ch); + } + + result +} + /// Format a datetime string, replacing %Z with proper timezone abbreviation fn format_with_timezone_local(formatstr: &str, dt: &DateTime) -> String { if formatstr.contains("%Z") { let tz_abbr = get_timezone_abbreviation(dt); - // Process the format string character by character to handle %Z properly - let mut result = String::new(); - let mut chars = formatstr.chars().peekable(); - - while let Some(ch) = chars.next() { - if ch == '%' { - if let Some(&next_ch) = chars.peek() { - if next_ch == '%' { - // %% should become % in the output - let chrono handle this - result.push('%'); - result.push('%'); - chars.next(); // consume the second % - continue; - } else if next_ch == 'Z' { - // Replace %Z with the timezone abbreviation - result.push_str(&tz_abbr); - chars.next(); // consume 'Z' - continue; - } - } - } - result.push(ch); - } - - // Format the modified format string - dt.format(&result).to_string() + let modified_format = parse_format_string_with_tz(formatstr, &tz_abbr); + dt.format(&modified_format).to_string() } else { dt.format(formatstr).to_string() } @@ -89,32 +94,8 @@ fn format_with_timezone_local(formatstr: &str, dt: &DateTime) -> String { /// Format a datetime string for UTC, replacing %Z with "UTC" fn format_with_timezone_utc(formatstr: &str, dt: &DateTime) -> String { if formatstr.contains("%Z") { - // Process the format string character by character to handle %Z properly - let mut result = String::new(); - let mut chars = formatstr.chars().peekable(); - - while let Some(ch) = chars.next() { - if ch == '%' { - if let Some(&next_ch) = chars.peek() { - if next_ch == '%' { - // %% should become % in the output - let chrono handle this - result.push('%'); - result.push('%'); - chars.next(); // consume the second % - continue; - } else if next_ch == 'Z' { - // Replace %Z with "UTC" - result.push_str("UTC"); - chars.next(); // consume 'Z' - continue; - } - } - } - result.push(ch); - } - - // Format the modified format string - dt.format(&result).to_string() + let modified_format = parse_format_string_with_tz(formatstr, "UTC"); + dt.format(&modified_format).to_string() } else { dt.format(formatstr).to_string() } From 462c6985def50695272bd99fe464f17f0614eac6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 07:39:59 +0000 Subject: [PATCH 5/6] Fix POSIX compliance: handle empty TZ and escaped %%Z correctly Co-authored-by: jgarzik <494411+jgarzik@users.noreply.github.com> --- datetime/date.rs | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/datetime/date.rs b/datetime/date.rs index ab7b06dd5..64971a524 100644 --- a/datetime/date.rs +++ b/datetime/date.rs @@ -24,7 +24,7 @@ fn get_timezone_abbreviation(dt: &DateTime) -> String { // Try to get TZ environment variable if let Ok(tz_str) = env::var("TZ") { // Handle special cases for UTC - if tz_str == "UTC" || tz_str == "UTC0" || tz_str.is_empty() { + if tz_str == "UTC" || tz_str == "UTC0" { return "UTC".to_string(); } @@ -80,9 +80,27 @@ fn parse_format_string_with_tz(formatstr: &str, tz_abbr: &str) -> String { result } +/// Check if a format string contains an unescaped %Z +fn contains_unescaped_tz(formatstr: &str) -> bool { + let mut chars = formatstr.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '%' { + if let Some(&next_ch) = chars.peek() { + if next_ch == 'Z' { + return true; + } else if next_ch == '%' { + // Skip escaped %% + chars.next(); + } + } + } + } + false +} + /// Format a datetime string, replacing %Z with proper timezone abbreviation fn format_with_timezone_local(formatstr: &str, dt: &DateTime) -> String { - if formatstr.contains("%Z") { + if contains_unescaped_tz(formatstr) { let tz_abbr = get_timezone_abbreviation(dt); let modified_format = parse_format_string_with_tz(formatstr, &tz_abbr); dt.format(&modified_format).to_string() @@ -93,7 +111,7 @@ fn format_with_timezone_local(formatstr: &str, dt: &DateTime) -> String { /// Format a datetime string for UTC, replacing %Z with "UTC" fn format_with_timezone_utc(formatstr: &str, dt: &DateTime) -> String { - if formatstr.contains("%Z") { + if contains_unescaped_tz(formatstr) { let modified_format = parse_format_string_with_tz(formatstr, "UTC"); dt.format(&modified_format).to_string() } else { From 7e6ab2d764e3de996dea9e9755dfc0aa809221e5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 13:40:28 +0000 Subject: [PATCH 6/6] Run cargo fmt to fix trailing whitespace Co-authored-by: jgarzik <494411+jgarzik@users.noreply.github.com> --- datetime/date.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/datetime/date.rs b/datetime/date.rs index 64971a524..d517fb8bd 100644 --- a/datetime/date.rs +++ b/datetime/date.rs @@ -27,7 +27,7 @@ fn get_timezone_abbreviation(dt: &DateTime) -> String { if tz_str == "UTC" || tz_str == "UTC0" { return "UTC".to_string(); } - + // Try to parse it as a chrono-tz timezone if let Ok(tz) = tz_str.parse::() { // Convert the local datetime to the specified timezone @@ -39,14 +39,14 @@ fn get_timezone_abbreviation(dt: &DateTime) -> String { } } } - + // Fallback: if TZ is not set or invalid, try to detect system timezone // For now, check if offset is zero, then it's UTC let offset = dt.offset().local_minus_utc(); if offset == 0 { return "UTC".to_string(); } - + // Otherwise, use the offset format as fallback dt.format("%:z").to_string() } @@ -56,7 +56,7 @@ fn get_timezone_abbreviation(dt: &DateTime) -> String { fn parse_format_string_with_tz(formatstr: &str, tz_abbr: &str) -> String { let mut result = String::new(); let mut chars = formatstr.chars().peekable(); - + while let Some(ch) = chars.next() { if ch == '%' { if let Some(&next_ch) = chars.peek() { @@ -76,7 +76,7 @@ fn parse_format_string_with_tz(formatstr: &str, tz_abbr: &str) -> String { } result.push(ch); } - + result }