From 6d29c8abe7d202d329fad7e59e284f6ee79c15b1 Mon Sep 17 00:00:00 2001 From: Nicolas Boichat Date: Mon, 24 Feb 2025 19:37:53 +0100 Subject: [PATCH] Add quarter (%q) date string specifier GNU date supports %q as a date string specifier. This adds support for that in chrono. This is needed by uutils/coreutils for compability. --- src/format/formatting.rs | 2 ++ src/format/mod.rs | 2 ++ src/format/parse.rs | 14 +++++++--- src/format/parsed.rs | 53 ++++++++++++++++++++++++++++++++++++- src/format/strftime.rs | 3 +++ src/naive/date/tests.rs | 4 ++- src/naive/datetime/tests.rs | 2 +- src/traits.rs | 8 ++++++ tests/dateutils.rs | 2 +- 9 files changed, 83 insertions(+), 7 deletions(-) diff --git a/src/format/formatting.rs b/src/format/formatting.rs index 1b7df6db..e27b7640 100644 --- a/src/format/formatting.rs +++ b/src/format/formatting.rs @@ -185,6 +185,7 @@ impl<'a, I: Iterator + Clone, B: Borrow>> DelayedFormat { (IsoYearMod100, Some(d), _) => { write_two(w, d.iso_week().year().rem_euclid(100) as u8, pad) } + (Quarter, Some(d), _) => write_one(w, d.quarter() as u8), (Month, Some(d), _) => write_two(w, d.month() as u8, pad), (Day, Some(d), _) => write_two(w, d.day() as u8, pad), (WeekFromSun, Some(d), _) => write_two(w, d.weeks_from(Weekday::Sun) as u8, pad), @@ -657,6 +658,7 @@ mod tests { let d = NaiveDate::from_ymd_opt(2012, 3, 4).unwrap(); assert_eq!(d.format("%Y,%C,%y,%G,%g").to_string(), "2012,20,12,2012,12"); assert_eq!(d.format("%m,%b,%h,%B").to_string(), "03,Mar,Mar,March"); + assert_eq!(d.format("%q").to_string(), "1"); assert_eq!(d.format("%d,%e").to_string(), "04, 4"); assert_eq!(d.format("%U,%W,%V").to_string(), "10,09,09"); assert_eq!(d.format("%a,%A,%w,%u").to_string(), "Sun,Sunday,0,7"); diff --git a/src/format/mod.rs b/src/format/mod.rs index fb608cc8..241be7a1 100644 --- a/src/format/mod.rs +++ b/src/format/mod.rs @@ -115,6 +115,8 @@ pub enum Numeric { IsoYearDiv100, /// Year in the ISO week date, modulo 100 (FW=PW=2). Cannot be negative. IsoYearMod100, + /// Quarter (FW=PW=1). + Quarter, /// Month (FW=PW=2). Month, /// Day of the month (FW=PW=2). diff --git a/src/format/parse.rs b/src/format/parse.rs index 91a58654..40b5b052 100644 --- a/src/format/parse.rs +++ b/src/format/parse.rs @@ -343,6 +343,7 @@ where IsoYear => (4, true, Parsed::set_isoyear), IsoYearDiv100 => (2, false, Parsed::set_isoyear_div_100), IsoYearMod100 => (2, false, Parsed::set_isoyear_mod_100), + Quarter => (1, false, Parsed::set_quarter), Month => (2, false, Parsed::set_month), Day => (2, false, Parsed::set_day), WeekFromSun => (2, false, Parsed::set_week_from_sun), @@ -819,9 +820,16 @@ mod tests { parsed!(year_div_100: 12, year_mod_100: 34, isoyear_div_100: 56, isoyear_mod_100: 78), ); check( - "1 2 3 4 5", - &[num(Month), num(Day), num(WeekFromSun), num(NumDaysFromSun), num(IsoWeek)], - parsed!(month: 1, day: 2, week_from_sun: 3, weekday: Weekday::Thu, isoweek: 5), + "1 1 2 3 4 5", + &[ + num(Quarter), + num(Month), + num(Day), + num(WeekFromSun), + num(NumDaysFromSun), + num(IsoWeek), + ], + parsed!(quarter: 1, month: 1, day: 2, week_from_sun: 3, weekday: Weekday::Thu, isoweek: 5), ); check( "6 7 89 01", diff --git a/src/format/parsed.rs b/src/format/parsed.rs index c9fdd679..83975cb4 100644 --- a/src/format/parsed.rs +++ b/src/format/parsed.rs @@ -140,6 +140,8 @@ pub struct Parsed { #[doc(hidden)] pub isoyear_mod_100: Option, #[doc(hidden)] + pub quarter: Option, + #[doc(hidden)] pub month: Option, #[doc(hidden)] pub week_from_sun: Option, @@ -304,6 +306,23 @@ impl Parsed { set_if_consistent(&mut self.isoyear_mod_100, value as i32) } + /// Set the [`quarter`](Parsed::quarter) field to the given value. + /// + /// Quarter 1 starts in January. + /// + /// # Errors + /// + /// Returns `OUT_OF_RANGE` if `value` is not in the range 1-4. + /// + /// Returns `IMPOSSIBLE` if this field was already set to a different value. + #[inline] + pub fn set_quarter(&mut self, value: i64) -> ParseResult<()> { + if !(1..=4).contains(&value) { + return Err(OUT_OF_RANGE); + } + set_if_consistent(&mut self.quarter, value as u32) + } + /// Set the [`month`](Parsed::month) field to the given value. /// /// # Errors @@ -698,7 +717,15 @@ impl Parsed { (_, _, _) => return Err(NOT_ENOUGH), }; - if verified { Ok(parsed_date) } else { Err(IMPOSSIBLE) } + if !verified { + return Err(IMPOSSIBLE); + } else if let Some(parsed) = self.quarter { + if parsed != parsed_date.quarter() { + return Err(IMPOSSIBLE); + } + } + + Ok(parsed_date) } /// Returns a parsed naive time out of given fields. @@ -1013,6 +1040,14 @@ impl Parsed { self.isoyear_mod_100 } + /// Get the `quarter` field if set. + /// + /// See also [`set_quarter()`](Parsed::set_quarter). + #[inline] + pub fn quarter(&self) -> Option { + self.quarter + } + /// Get the `month` field if set. /// /// See also [`set_month()`](Parsed::set_month). @@ -1267,6 +1302,11 @@ mod tests { assert!(Parsed::new().set_isoyear_mod_100(99).is_ok()); assert_eq!(Parsed::new().set_isoyear_mod_100(100), Err(OUT_OF_RANGE)); + assert_eq!(Parsed::new().set_quarter(0), Err(OUT_OF_RANGE)); + assert!(Parsed::new().set_quarter(1).is_ok()); + assert!(Parsed::new().set_quarter(4).is_ok()); + assert_eq!(Parsed::new().set_quarter(5), Err(OUT_OF_RANGE)); + assert_eq!(Parsed::new().set_month(0), Err(OUT_OF_RANGE)); assert!(Parsed::new().set_month(1).is_ok()); assert!(Parsed::new().set_month(12).is_ok()); @@ -1425,6 +1465,17 @@ mod tests { assert_eq!(parse!(year: -1, year_div_100: 0, month: 1, day: 1), Err(IMPOSSIBLE)); assert_eq!(parse!(year: -1, year_mod_100: 99, month: 1, day: 1), Err(IMPOSSIBLE)); + // quarters + assert_eq!(parse!(year: 2000, quarter: 1), Err(NOT_ENOUGH)); + assert_eq!(parse!(year: 2000, quarter: 1, month: 1, day: 1), ymd(2000, 1, 1)); + assert_eq!(parse!(year: 2000, quarter: 2, month: 4, day: 1), ymd(2000, 4, 1)); + assert_eq!(parse!(year: 2000, quarter: 3, month: 7, day: 1), ymd(2000, 7, 1)); + assert_eq!(parse!(year: 2000, quarter: 4, month: 10, day: 1), ymd(2000, 10, 1)); + + // quarter: conflicting inputs + assert_eq!(parse!(year: 2000, quarter: 2, month: 3, day: 31), Err(IMPOSSIBLE)); + assert_eq!(parse!(year: 2000, quarter: 4, month: 3, day: 31), Err(IMPOSSIBLE)); + // weekdates assert_eq!(parse!(year: 2000, week_from_mon: 0), Err(NOT_ENOUGH)); assert_eq!(parse!(year: 2000, week_from_sun: 0), Err(NOT_ENOUGH)); diff --git a/src/format/strftime.rs b/src/format/strftime.rs index e2bd4f5b..f0478b43 100644 --- a/src/format/strftime.rs +++ b/src/format/strftime.rs @@ -15,6 +15,7 @@ The following specifiers are available both to formatting and parsing. | `%C` | `20` | The proleptic Gregorian year divided by 100, zero-padded to 2 digits. [^1] | | `%y` | `01` | The proleptic Gregorian year modulo 100, zero-padded to 2 digits. [^1] | | | | | +| `%q` | `1` | Quarter of year (1-4) | | `%m` | `07` | Month number (01--12), zero-padded to 2 digits. | | `%b` | `Jul` | Abbreviated month name. Always 3 letters. | | `%B` | `July` | Full month name. Also accepts corresponding abbreviation in parsing. | @@ -538,6 +539,7 @@ impl<'a> StrftimeItems<'a> { 'm' => num0(Month), 'n' => Space("\n"), 'p' => fixed(Fixed::UpperAmPm), + 'q' => num(Quarter), #[cfg(not(feature = "unstable-locales"))] 'r' => queue_from_slice!(T_FMT_AMPM), #[cfg(feature = "unstable-locales")] @@ -866,6 +868,7 @@ mod tests { assert_eq!(dt.format("%Y").to_string(), "2001"); assert_eq!(dt.format("%C").to_string(), "20"); assert_eq!(dt.format("%y").to_string(), "01"); + assert_eq!(dt.format("%q").to_string(), "3"); assert_eq!(dt.format("%m").to_string(), "07"); assert_eq!(dt.format("%b").to_string(), "Jul"); assert_eq!(dt.format("%B").to_string(), "July"); diff --git a/src/naive/date/tests.rs b/src/naive/date/tests.rs index 5c944d59..35c9da24 100644 --- a/src/naive/date/tests.rs +++ b/src/naive/date/tests.rs @@ -666,7 +666,7 @@ fn test_date_parse_from_str() { Ok(ymd(2014, 5, 7)) ); // ignore time and offset assert_eq!( - NaiveDate::parse_from_str("2015-W06-1=2015-033", "%G-W%V-%u = %Y-%j"), + NaiveDate::parse_from_str("2015-W06-1=2015-033 Q1", "%G-W%V-%u = %Y-%j Q%q"), Ok(ymd(2015, 2, 2)) ); assert_eq!(NaiveDate::parse_from_str("Fri, 09 Aug 13", "%a, %d %b %y"), Ok(ymd(2013, 8, 9))); @@ -674,6 +674,8 @@ fn test_date_parse_from_str() { assert!(NaiveDate::parse_from_str("2014-57", "%Y-%m-%d").is_err()); assert!(NaiveDate::parse_from_str("2014", "%Y").is_err()); // insufficient + assert!(NaiveDate::parse_from_str("2014-5-7 Q3", "%Y-%m-%d Q%q").is_err()); // mismatched quarter + assert_eq!( NaiveDate::parse_from_str("2020-01-0", "%Y-%W-%w").ok(), NaiveDate::from_ymd_opt(2020, 1, 12), diff --git a/src/naive/datetime/tests.rs b/src/naive/datetime/tests.rs index 33eb57bd..75a168a7 100644 --- a/src/naive/datetime/tests.rs +++ b/src/naive/datetime/tests.rs @@ -203,7 +203,7 @@ fn test_datetime_parse_from_str() { NaiveDateTime::parse_from_str("Sat, 09 Aug 2013 23:54:35 GMT", "%a, %d %b %Y %H:%M:%S GMT") .is_err() ); - assert!(NaiveDateTime::parse_from_str("2014-5-7 12:3456", "%Y-%m-%d %H:%M:%S").is_err()); + assert!(NaiveDateTime::parse_from_str("2014-5-7 Q2 12:3456", "%Y-%m-%d Q%q %H:%M:%S").is_err()); assert!(NaiveDateTime::parse_from_str("12:34:56", "%H:%M:%S").is_err()); // insufficient assert_eq!( NaiveDateTime::parse_from_str("1441497364", "%s"), diff --git a/src/traits.rs b/src/traits.rs index 0771b5c1..21ed88e9 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -40,6 +40,14 @@ pub trait Datelike: Sized { if year < 1 { (false, (1 - year) as u32) } else { (true, year as u32) } } + /// Returns the quarter number starting from 1. + /// + /// The return value ranges from 1 to 4. + #[inline] + fn quarter(&self) -> u32 { + (self.month() - 1).div_euclid(3) + 1 + } + /// Returns the month number starting from 1. /// /// The return value ranges from 1 to 12. diff --git a/tests/dateutils.rs b/tests/dateutils.rs index a0a445aa..849abc72 100644 --- a/tests/dateutils.rs +++ b/tests/dateutils.rs @@ -110,7 +110,7 @@ fn try_verify_against_date_command() { #[cfg(target_os = "linux")] fn verify_against_date_command_format_local(path: &'static str, dt: NaiveDateTime) { let required_format = - "d%d D%D F%F H%H I%I j%j k%k l%l m%m M%M S%S T%T u%u U%U w%w W%W X%X y%y Y%Y z%:z"; + "d%d D%D F%F H%H I%I j%j k%k l%l m%m M%M q%q S%S T%T u%u U%U w%w W%W X%X y%y Y%Y z%:z"; // a%a - depends from localization // A%A - depends from localization // b%b - depends from localization