From 20de775004aa3a563817409545466e3b050865af Mon Sep 17 00:00:00 2001 From: xmakro Date: Thu, 8 Oct 2026 23:52:20 -0700 Subject: [PATCH] fix(date): read weekday names in -d instead of returning now `date -d 'next monday'` read `monday` as an unknown unit with a zero offset and printed the current time with exit 0. Weekday names now resolve as in GNU, at midnight in the sandbox `TZ`: a bare day may be today, `next` skips today, `last` is the latest one before today. `today` is accepted as `now`, and any other unknown word after `next`/`last` is an invalid date. --- crates/bashkit/src/builtins/date.rs | 81 ++++++++++++++++++- .../tests/spec_cases/bash/date.test.sh | 12 +++ 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/crates/bashkit/src/builtins/date.rs b/crates/bashkit/src/builtins/date.rs index 35aca11a1..a4fa4d574 100644 --- a/crates/bashkit/src/builtins/date.rs +++ b/crates/bashkit/src/builtins/date.rs @@ -11,7 +11,9 @@ use async_trait::async_trait; use super::arg_parser::OptArg; use chrono::format::{Item, StrftimeItems}; -use chrono::{DateTime, Duration, LocalResult, NaiveDate, NaiveDateTime, TimeZone, Utc}; +use chrono::{ + DateTime, Datelike, Duration, LocalResult, NaiveDate, NaiveDateTime, TimeZone, Utc, Weekday, +}; #[cfg(feature = "tzdata")] use chrono_tz::Tz; @@ -147,6 +149,14 @@ impl SandboxTimezone { Self::Iana(tz) => dt.with_timezone(&tz).format(format).to_string(), } } + + fn date_naive(self, dt: &DateTime) -> NaiveDate { + match self { + Self::Utc => dt.date_naive(), + #[cfg(feature = "tzdata")] + Self::Iana(tz) => dt.with_timezone(&tz).date_naive(), + } + } } impl Date { @@ -294,7 +304,7 @@ fn parse_base_date( // Special words match lower.as_str() { - "now" => return Ok(now), + "now" | "today" => return Ok(now), "yesterday" => return Ok(now - Duration::days(1)), "tomorrow" => return Ok(now + Duration::days(1)), _ => {} @@ -347,13 +357,39 @@ fn parse_base_date( return Ok(dt.with_timezone(&Utc)); } + // Day of week: "monday", "next fri", "last tuesday", at midnight. As in + // GNU, a bare day may be today, `next` skips today, `last` is the latest + // one before today. + let (which, day) = lower + .split_once(' ') + .map_or(("this", lower.as_str()), |(which, day)| { + (which, day.trim_start()) + }); + if let Ok(day) = day.parse::() { + let today = timezone.date_naive(&now); + let ahead = i64::from(day.days_since(today.weekday())); + let days = match which { + "this" => ahead, + "next" if ahead == 0 => 7, + "next" => ahead, + "last" => ahead - 7, + _ => return Err(format!("date: invalid date '{}'", s)), + }; + let midnight = today + .checked_add_signed(Duration::days(days)) + .and_then(|d| d.and_hms_opt(0, 0, 0)) + .ok_or_else(|| format!("date out of range: '{}'", s))?; + return timezone.local_to_utc(midnight, s); + } + Err(format!("date: invalid date '{}'", s)) } /// Parse a date string like GNU date's -d flag. /// /// Supports simple expressions: -/// "now", "yesterday", "tomorrow", "N days ago", "+N days", +/// "now", "today", "yesterday", "tomorrow", "N days ago", "+N days", +/// "monday", "next friday", "last tuesday", /// "N weeks ago", "N months ago", "N years ago", "N hours ago", /// "@EPOCH", "YYYY-MM-DD", "YYYY-MM-DD HH:MM:SS" /// @@ -447,7 +483,8 @@ fn unit_duration(unit: &str, n: i64) -> Option { "week" => Duration::try_weeks(n), "month" => n.checked_mul(30).and_then(Duration::try_days), // Approximate "year" => n.checked_mul(365).and_then(Duration::try_days), // Approximate - _ => Some(Duration::zero()), + // Not a unit (`next monday`): an invalid date, not a zero offset. + _ => None, } } @@ -1006,6 +1043,42 @@ mod tests { assert_eq!(date.len(), 10); } + /// From Thursday 2026-10-08 12:00 UTC; expected days match GNU date. + #[test] + fn test_date_d_weekdays() { + let now = DateTime::from_timestamp(1_791_460_800, 0).unwrap(); + for (input, expected) in [ + ("thursday", "2026-10-08"), + ("next thursday", "2026-10-15"), + ("last thursday", "2026-10-01"), + ("monday", "2026-10-12"), + ("next mon", "2026-10-12"), + ("last Fri", "2026-10-02"), + ] { + let dt = parse_date_string(input, now, SandboxTimezone::Utc).unwrap(); + assert_eq!( + dt.format("%F %T").to_string(), + format!("{expected} 00:00:00"), + "{input}" + ); + } + assert_eq!( + parse_date_string("today", now, SandboxTimezone::Utc), + Ok(now) + ); + } + + /// `next ` used to read an unknown word as a zero offset and + /// print the current time. + #[tokio::test] + async fn test_date_d_unknown_word_is_invalid() { + for input in ["next blursday", "last blah"] { + let result = run_date(&["-d", input]).await; + assert_eq!(result.exit_code, 1, "{input}"); + assert!(result.stderr.contains("invalid date"), "{input}"); + } + } + #[tokio::test] async fn test_date_long_date_flag() { let result = run_date(&["--date=yesterday", "+%Y-%m-%d"]).await; diff --git a/crates/bashkit/tests/spec_cases/bash/date.test.sh b/crates/bashkit/tests/spec_cases/bash/date.test.sh index 8c79dea58..c0ecbac31 100644 --- a/crates/bashkit/tests/spec_cases/bash/date.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/date.test.sh @@ -171,6 +171,18 @@ date -d 'tomorrow' +%Y-%m-%d | grep -qE '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' && echo " valid ### end +### date_next_weekday +date -d 'next monday' '+%u %T' +### expect +1 00:00:00 +### end + +### date_unknown_word_is_invalid +date -d 'next blursday' 2>/dev/null || echo invalid +### expect +invalid +### end + ### date_set_time ### skip: date -s (set time) not implemented and requires privileges date -s '2024-01-01 12:00:00' 2>/dev/null || echo "skip"