refactor: introduce a builder and simplify the date time composition

This commit is contained in:
yuankunzhang
2025-06-29 15:15:05 +00:00
parent 4d9afba2d4
commit 706ab45937
3 changed files with 372 additions and 326 deletions
+303
View File
@@ -0,0 +1,303 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
use chrono::{DateTime, Datelike, FixedOffset, NaiveDate, TimeZone, Timelike};
use super::{date, relative, time, weekday};
/// The builder is used to construct a DateTime object from various components.
/// The parser creates a `DateTimeBuilder` object with the parsed components,
/// but without the baseline date and time. So you normally need to set the base
/// date and time using the `set_base()` method before calling `build()`, or
/// leave it unset to use the current date and time as the base.
#[derive(Debug, Default)]
pub struct DateTimeBuilder {
base: Option<DateTime<FixedOffset>>,
timestamp: Option<i32>,
date: Option<date::Date>,
time: Option<time::Time>,
weekday: Option<weekday::Weekday>,
timezone: Option<time::Offset>,
relative: Vec<relative::Relative>,
}
impl DateTimeBuilder {
pub(super) fn new() -> Self {
Self::default()
}
/// Sets the base date and time for the builder. If not set, the current
/// date and time will be used.
pub(super) fn set_base(mut self, base: DateTime<FixedOffset>) -> Self {
self.base = Some(base);
self
}
pub(super) fn set_timestamp(mut self, ts: i32) -> Result<Self, &'static str> {
if self.timestamp.is_some() {
Err("timestamp cannot appear more than once")
} else {
self.timestamp = Some(ts);
Ok(self)
}
}
pub(super) fn set_year(mut self, year: u32) -> Result<Self, &'static str> {
if let Some(date) = self.date.as_mut() {
if date.year.is_some() {
Err("year cannot appear more than once")
} else {
date.year = Some(year);
Ok(self)
}
} else {
self.date = Some(date::Date {
day: 1,
month: 1,
year: Some(year),
});
Ok(self)
}
}
pub(super) fn set_date(mut self, date: date::Date) -> Result<Self, &'static str> {
if self.date.is_some() || self.timestamp.is_some() {
Err("date cannot appear more than once")
} else {
self.date = Some(date);
Ok(self)
}
}
pub(super) fn set_time(mut self, time: time::Time) -> Result<Self, &'static str> {
if self.time.is_some() || self.timestamp.is_some() {
Err("time cannot appear more than once")
} else if self.timezone.is_some() && time.offset.is_some() {
Err("time offset and timezone are mutually exclusive")
} else {
self.time = Some(time);
Ok(self)
}
}
pub(super) fn set_weekday(mut self, weekday: weekday::Weekday) -> Result<Self, &'static str> {
if self.weekday.is_some() {
Err("weekday cannot appear more than once")
} else {
self.weekday = Some(weekday);
Ok(self)
}
}
pub(super) fn set_timezone(mut self, timezone: time::Offset) -> Result<Self, &'static str> {
if self.timezone.is_some() {
Err("timezone cannot appear more than once")
} else if self.time.as_ref().and_then(|t| t.offset.as_ref()).is_some() {
Err("time offset and timezone are mutually exclusive")
} else {
self.timezone = Some(timezone);
Ok(self)
}
}
pub(super) fn push_relative(mut self, relative: relative::Relative) -> Self {
self.relative.push(relative);
self
}
pub(super) fn build(self) -> Option<DateTime<FixedOffset>> {
let base = self.base.unwrap_or_else(|| chrono::Local::now().into());
let mut dt = new_date(
base.year(),
base.month(),
base.day(),
0,
0,
0,
0,
*base.offset(),
)?;
if let Some(ts) = self.timestamp {
dt = chrono::Utc
.timestamp_opt(ts.into(), 0)
.unwrap()
.with_timezone(&dt.timezone());
}
if let Some(date::Date { year, month, day }) = self.date {
dt = new_date(
year.map(|x| x as i32).unwrap_or(dt.year()),
month,
day,
dt.hour(),
dt.minute(),
dt.second(),
dt.nanosecond(),
*dt.offset(),
)?;
}
if let Some(time::Time {
hour,
minute,
second,
ref offset,
}) = self.time
{
let offset = offset
.clone()
.and_then(|o| chrono::FixedOffset::try_from(o).ok())
.unwrap_or(*dt.offset());
dt = new_date(
dt.year(),
dt.month(),
dt.day(),
hour,
minute,
second as u32,
(second.fract() * 10f64.powi(9)).round() as u32,
offset,
)?;
}
if let Some(weekday::Weekday { offset, day }) = self.weekday {
if self.time.is_none() {
dt = new_date(dt.year(), dt.month(), dt.day(), 0, 0, 0, 0, *dt.offset())?;
}
let mut offset = offset;
let day = day.into();
// If the current day is not the target day, we need to adjust
// the x value to ensure we find the correct day.
//
// Consider this:
// Assuming today is Monday, next Friday is actually THIS Friday;
// but next Monday is indeed NEXT Monday.
if dt.weekday() != day && offset > 0 {
offset -= 1;
}
// Calculate the delta to the target day.
//
// Assuming today is Thursday, here are some examples:
//
// Example 1: last Thursday (x = -1, day = Thursday)
// delta = (3 - 3) % 7 + (-1) * 7 = -7
//
// Example 2: last Monday (x = -1, day = Monday)
// delta = (0 - 3) % 7 + (-1) * 7 = -3
//
// Example 3: next Monday (x = 1, day = Monday)
// delta = (0 - 3) % 7 + (0) * 7 = 4
// (Note that we have adjusted the x value above)
//
// Example 4: next Thursday (x = 1, day = Thursday)
// delta = (3 - 3) % 7 + (1) * 7 = 7
let delta = (day.num_days_from_monday() as i32
- dt.weekday().num_days_from_monday() as i32)
.rem_euclid(7)
+ offset.checked_mul(7)?;
dt = if delta < 0 {
dt.checked_sub_days(chrono::Days::new((-delta) as u64))?
} else {
dt.checked_add_days(chrono::Days::new(delta as u64))?
}
}
for rel in self.relative {
if self.timestamp.is_none()
&& self.date.is_none()
&& self.time.is_none()
&& self.weekday.is_none()
{
dt = base;
}
match rel {
relative::Relative::Years(x) => {
dt = dt.with_year(dt.year() + x)?;
}
relative::Relative::Months(x) => {
// *NOTE* This is done in this way to conform to
// GNU behavior.
let days = last_day_of_month(dt.year(), dt.month());
if x >= 0 {
dt += dt
.date_naive()
.checked_add_days(chrono::Days::new((days * x as u32) as u64))?
.signed_duration_since(dt.date_naive());
} else {
dt += dt
.date_naive()
.checked_sub_days(chrono::Days::new((days * -x as u32) as u64))?
.signed_duration_since(dt.date_naive());
}
}
relative::Relative::Days(x) => dt += chrono::Duration::days(x.into()),
relative::Relative::Hours(x) => dt += chrono::Duration::hours(x.into()),
relative::Relative::Minutes(x) => {
dt += chrono::Duration::try_minutes(x.into())?;
}
// Seconds are special because they can be given as a float
relative::Relative::Seconds(x) => {
dt += chrono::Duration::try_seconds(x as i64)?;
}
}
}
if let Some(offset) = self.timezone {
dt = with_timezone_restore(offset, dt)?;
}
Some(dt)
}
}
#[allow(clippy::too_many_arguments)]
fn new_date(
year: i32,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: u32,
nano: u32,
offset: FixedOffset,
) -> Option<DateTime<FixedOffset>> {
let newdate = NaiveDate::from_ymd_opt(year, month, day)
.and_then(|naive| naive.and_hms_nano_opt(hour, minute, second, nano))?;
Some(DateTime::<FixedOffset>::from_local(newdate, offset))
}
/// Restores year, month, day, etc after applying the timezone
/// returns None if timezone overflows the date
fn with_timezone_restore(
offset: time::Offset,
at: DateTime<FixedOffset>,
) -> Option<DateTime<FixedOffset>> {
let offset: FixedOffset = chrono::FixedOffset::try_from(offset).ok()?;
let copy = at;
let x = at
.with_timezone(&offset)
.with_day(copy.day())?
.with_month(copy.month())?
.with_year(copy.year())?
.with_hour(copy.hour())?
.with_minute(copy.minute())?
.with_second(copy.second())?
.with_nanosecond(copy.nanosecond())?;
Some(x)
}
fn last_day_of_month(year: i32, month: u32) -> u32 {
NaiveDate::from_ymd_opt(year, month + 1, 1)
.unwrap_or(NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap())
.pred_opt()
.unwrap()
.day()
}
+68 -325
View File
@@ -20,24 +20,29 @@
//! We put all of those in separate modules:
//! - [`date`]
//! - [`time`]
//! - [`time_zone`]
//! - [`timezone`]
//! - [`combined`]
//! - [`weekday`]
//! - [`relative`]
//! - [`number]
#![allow(deprecated)]
// date and time items
mod combined;
mod date;
mod epoch;
mod ordinal;
mod primitive;
mod relative;
mod time;
mod timezone;
mod weekday;
use chrono::{DateTime, Datelike, FixedOffset, NaiveDate, TimeZone, Timelike};
// utility modules
mod builder;
mod ordinal;
mod primitive;
use builder::DateTimeBuilder;
use chrono::{DateTime, FixedOffset};
use primitive::space;
use winnow::{
combinator::{alt, trace},
@@ -60,7 +65,9 @@ pub enum Item {
TimeZone(time::Offset),
}
// Parse an item
/// Parse an item.
/// TODO: timestamp values are exclusive with other items. See
/// https://github.com/uutils/parse_datetime/issues/156
pub fn parse_one(input: &mut &str) -> ModalResult<Item> {
trace(
"parse_one",
@@ -86,77 +93,47 @@ fn expect_error(input: &mut &str, reason: &'static str) -> ErrMode<ContextError>
)
}
pub fn parse(input: &mut &str) -> ModalResult<Vec<Item>> {
let mut items = Vec::new();
let mut date_seen = false;
let mut time_seen = false;
let mut year_seen = false;
let mut tz_seen = false;
pub fn parse(input: &mut &str) -> ModalResult<DateTimeBuilder> {
let mut builder = DateTimeBuilder::new();
loop {
match parse_one.parse_next(input) {
Ok(item) => {
match item {
Item::DateTime(ref dt) => {
if date_seen || time_seen {
return Err(expect_error(
input,
"date or time cannot appear more than once",
));
}
date_seen = true;
time_seen = true;
if dt.date.year.is_some() {
year_seen = true;
}
}
Item::Date(ref d) => {
if date_seen {
return Err(expect_error(input, "date cannot appear more than once"));
}
date_seen = true;
if d.year.is_some() {
year_seen = true;
}
}
Item::Time(ref t) => {
if time_seen {
return Err(expect_error(input, "time cannot appear more than once"));
}
if t.offset.is_some() {
if tz_seen {
return Err(expect_error(
input,
"timezone cannot appear more than once",
));
}
tz_seen = true;
}
time_seen = true;
}
Item::Year(_) => {
if year_seen {
return Err(expect_error(input, "year cannot appear more than once"));
}
year_seen = true;
}
Item::TimeZone(_) => {
if tz_seen {
return Err(expect_error(
input,
"timezone cannot appear more than once",
));
}
tz_seen = true;
}
_ => {}
Ok(item) => match item {
Item::Timestamp(ts) => {
builder = builder
.set_timestamp(ts)
.map_err(|e| expect_error(input, e))?;
}
items.push(item);
}
Item::Year(year) => {
builder = builder.set_year(year).map_err(|e| expect_error(input, e))?;
}
Item::DateTime(dt) => {
builder = builder
.set_date(dt.date)
.map_err(|e| expect_error(input, e))?
.set_time(dt.time)
.map_err(|e| expect_error(input, e))?;
}
Item::Date(d) => {
builder = builder.set_date(d).map_err(|e| expect_error(input, e))?;
}
Item::Time(t) => {
builder = builder.set_time(t).map_err(|e| expect_error(input, e))?;
}
Item::Weekday(weekday) => {
builder = builder
.set_weekday(weekday)
.map_err(|e| expect_error(input, e))?;
}
Item::TimeZone(tz) => {
builder = builder
.set_timezone(tz)
.map_err(|e| expect_error(input, e))?;
}
Item::Relative(rel) => {
builder = builder.push_relative(rel);
}
},
Err(ErrMode::Backtrack(_)) => break,
Err(e) => return Err(e),
}
@@ -167,256 +144,34 @@ pub fn parse(input: &mut &str) -> ModalResult<Vec<Item>> {
return Err(expect_error(input, "unexpected input"));
}
Ok(items)
}
#[allow(clippy::too_many_arguments)]
fn new_date(
year: i32,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: u32,
nano: u32,
offset: FixedOffset,
) -> Option<DateTime<FixedOffset>> {
let newdate = NaiveDate::from_ymd_opt(year, month, day)
.and_then(|naive| naive.and_hms_nano_opt(hour, minute, second, nano))?;
Some(DateTime::<FixedOffset>::from_local(newdate, offset))
}
/// Restores year, month, day, etc after applying the timezone
/// returns None if timezone overflows the date
fn with_timezone_restore(
offset: time::Offset,
at: DateTime<FixedOffset>,
) -> Option<DateTime<FixedOffset>> {
let offset: FixedOffset = chrono::FixedOffset::try_from(offset).ok()?;
let copy = at;
let x = at
.with_timezone(&offset)
.with_day(copy.day())?
.with_month(copy.month())?
.with_year(copy.year())?
.with_hour(copy.hour())?
.with_minute(copy.minute())?
.with_second(copy.second())?
.with_nanosecond(copy.nanosecond())?;
Some(x)
}
fn last_day_of_month(year: i32, month: u32) -> u32 {
NaiveDate::from_ymd_opt(year, month + 1, 1)
.unwrap_or(NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap())
.pred_opt()
.unwrap()
.day()
}
fn at_date_inner(date: Vec<Item>, at: DateTime<FixedOffset>) -> Option<DateTime<FixedOffset>> {
let mut d = at
.with_hour(0)
.unwrap()
.with_minute(0)
.unwrap()
.with_second(0)
.unwrap()
.with_nanosecond(0)
.unwrap();
// This flag is used by relative items to determine which date/time to use.
// If any date/time item is set, it will use that; otherwise, it will use
// the `at` value.
let date_time_set = date.iter().any(|item| {
matches!(
item,
Item::Timestamp(_)
| Item::Date(_)
| Item::DateTime(_)
| Item::Year(_)
| Item::Time(_)
| Item::Weekday(_)
)
});
for item in date {
match item {
Item::Timestamp(ts) => {
d = chrono::Utc
.timestamp_opt(ts.into(), 0)
.unwrap()
.with_timezone(&d.timezone())
}
Item::Date(date::Date { day, month, year }) => {
d = new_date(
year.map(|x| x as i32).unwrap_or(d.year()),
month,
day,
d.hour(),
d.minute(),
d.second(),
d.nanosecond(),
*d.offset(),
)?;
}
Item::DateTime(combined::DateTime {
date: date::Date { day, month, year },
time:
time::Time {
hour,
minute,
second,
offset,
},
..
}) => {
let offset = offset
.and_then(|o| chrono::FixedOffset::try_from(o).ok())
.unwrap_or(*d.offset());
d = new_date(
year.map(|x| x as i32).unwrap_or(d.year()),
month,
day,
hour,
minute,
second as u32,
(second.fract() * 10f64.powi(9)).round() as u32,
offset,
)?;
}
Item::Year(year) => d = d.with_year(year as i32).unwrap_or(d),
Item::Time(time::Time {
hour,
minute,
second,
offset,
}) => {
let offset = offset
.and_then(|o| chrono::FixedOffset::try_from(o).ok())
.unwrap_or(*d.offset());
d = new_date(
d.year(),
d.month(),
d.day(),
hour,
minute,
second as u32,
(second.fract() * 10f64.powi(9)).round() as u32,
offset,
)?;
}
Item::Weekday(weekday::Weekday { offset: x, day }) => {
let mut x = x;
let day = day.into();
// If the current day is not the target day, we need to adjust
// the x value to ensure we find the correct day.
//
// Consider this:
// Assuming today is Monday, next Friday is actually THIS Friday;
// but next Monday is indeed NEXT Monday.
if d.weekday() != day && x > 0 {
x -= 1;
}
// Calculate the delta to the target day.
//
// Assuming today is Thursday, here are some examples:
//
// Example 1: last Thursday (x = -1, day = Thursday)
// delta = (3 - 3) % 7 + (-1) * 7 = -7
//
// Example 2: last Monday (x = -1, day = Monday)
// delta = (0 - 3) % 7 + (-1) * 7 = -3
//
// Example 3: next Monday (x = 1, day = Monday)
// delta = (0 - 3) % 7 + (0) * 7 = 4
// (Note that we have adjusted the x value above)
//
// Example 4: next Thursday (x = 1, day = Thursday)
// delta = (3 - 3) % 7 + (1) * 7 = 7
let delta = (day.num_days_from_monday() as i32
- d.weekday().num_days_from_monday() as i32)
.rem_euclid(7)
+ x.checked_mul(7)?;
d = if delta < 0 {
d.checked_sub_days(chrono::Days::new((-delta) as u64))?
} else {
d.checked_add_days(chrono::Days::new(delta as u64))?
}
}
Item::Relative(rel) => {
// If date and/or time is set, use the set value; otherwise, use
// the reference value.
if !date_time_set {
d = at;
}
match rel {
relative::Relative::Years(x) => {
d = d.with_year(d.year() + x)?;
}
relative::Relative::Months(x) => {
// *NOTE* This is done in this way to conform to
// GNU behavior.
let days = last_day_of_month(d.year(), d.month());
if x >= 0 {
d += d
.date_naive()
.checked_add_days(chrono::Days::new((days * x as u32) as u64))?
.signed_duration_since(d.date_naive());
} else {
d += d
.date_naive()
.checked_sub_days(chrono::Days::new((days * -x as u32) as u64))?
.signed_duration_since(d.date_naive());
}
}
relative::Relative::Days(x) => d += chrono::Duration::days(x.into()),
relative::Relative::Hours(x) => d += chrono::Duration::hours(x.into()),
relative::Relative::Minutes(x) => {
d += chrono::Duration::try_minutes(x.into())?;
}
// Seconds are special because they can be given as a float
relative::Relative::Seconds(x) => {
d += chrono::Duration::try_seconds(x as i64)?;
}
}
}
Item::TimeZone(offset) => {
d = with_timezone_restore(offset, d)?;
}
}
}
Some(d)
Ok(builder)
}
pub(crate) fn at_date(
date: Vec<Item>,
at: DateTime<FixedOffset>,
builder: DateTimeBuilder,
base: DateTime<FixedOffset>,
) -> Result<DateTime<FixedOffset>, ParseDateTimeError> {
at_date_inner(date, at).ok_or(ParseDateTimeError::InvalidInput)
builder
.set_base(base)
.build()
.ok_or(ParseDateTimeError::InvalidInput)
}
pub(crate) fn at_local(date: Vec<Item>) -> Result<DateTime<FixedOffset>, ParseDateTimeError> {
at_date(date, chrono::Local::now().into())
pub(crate) fn at_local(
builder: DateTimeBuilder,
) -> Result<DateTime<FixedOffset>, ParseDateTimeError> {
builder.build().ok_or(ParseDateTimeError::InvalidInput)
}
#[cfg(test)]
mod tests {
use super::{at_date, date::Date, parse, time::Time, Item};
use super::{at_date, parse, DateTimeBuilder};
use chrono::{
DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Timelike, Utc,
};
fn at_utc(date: Vec<Item>) -> DateTime<FixedOffset> {
at_date(date, Utc::now().fixed_offset()).unwrap()
fn at_utc(builder: DateTimeBuilder) -> DateTime<FixedOffset> {
at_date(builder, Utc::now().fixed_offset()).unwrap()
}
fn test_eq_fmt(fmt: &str, input: &str) -> String {
@@ -432,20 +187,8 @@ mod tests {
#[test]
fn date_and_time() {
assert_eq!(
parse(&mut " 10:10 2022-12-12 "),
Ok(vec![
Item::Time(Time {
hour: 10,
minute: 10,
second: 0.0,
offset: None,
}),
Item::Date(Date {
day: 12,
month: 12,
year: Some(2022)
})
])
"2022-12-12",
test_eq_fmt("%Y-%m-%d", " 10:10 2022-12-12 ")
);
// format, expected output, input
@@ -504,7 +247,7 @@ mod tests {
assert!(result
.unwrap_err()
.to_string()
.contains("date or time cannot appear more than once"));
.contains("date cannot appear more than once"));
let result = parse(&mut "2025-05-19 2024-05-20");
assert!(result.is_err());
@@ -539,7 +282,7 @@ mod tests {
assert!(result
.unwrap_err()
.to_string()
.contains("timezone cannot appear more than once"));
.contains("time offset and timezone are mutually exclusive"));
let result = parse(&mut "2025-05-19 abcdef");
assert!(result.is_err());
+1 -1
View File
@@ -29,7 +29,7 @@ use winnow::{
use super::{ordinal::ordinal, primitive::s};
#[derive(PartialEq, Eq, Debug)]
#[derive(PartialEq, Eq, Debug, Copy, Clone)]
pub(crate) enum Day {
Monday,
Tuesday,