From 8c6e15a0c35deaafc3632c04584a72a3b5a63801 Mon Sep 17 00:00:00 2001 From: Nathan Fisher Date: Sun, 11 Jun 2023 22:48:02 -0400 Subject: [PATCH] Properly handle timezone offsets; Implement conversion to/from Unix timestamps (i64); --- src/message/parser.rs | 5 +- src/time/epoch.rs | 114 ---------------- src/time/mod.rs | 181 +++++++++++++++++++++----- src/time/parser.rs | 25 ++-- src/time/parser.rs.bak | 286 ----------------------------------------- 5 files changed, 160 insertions(+), 451 deletions(-) delete mode 100644 src/time/epoch.rs delete mode 100644 src/time/parser.rs.bak diff --git a/src/message/parser.rs b/src/message/parser.rs index 727184f..e808f60 100644 --- a/src/message/parser.rs +++ b/src/message/parser.rs @@ -16,7 +16,10 @@ pub struct Parser { impl Parser { pub fn new(id: &str) -> Self { - Self { id: id.to_string(), ..Default::default() } + Self { + id: id.to_string(), + ..Default::default() + } } pub fn parse(mut self, content: &str) -> Result { diff --git a/src/time/epoch.rs b/src/time/epoch.rs deleted file mode 100644 index a1ed394..0000000 --- a/src/time/epoch.rs +++ /dev/null @@ -1,114 +0,0 @@ -use super::{DateTime, TimeZone}; - -pub const SECS_PER_DAY: i64 = 86400; - -fn days_since_epoch(ts: i64) -> i64 { - ts / SECS_PER_DAY -} - -fn is_leap(year: i64) -> bool { - year % 4 == 0 && (year % 100 != 0 || year % 16 == 0) -} - -fn year_from_ts_with_remainder(ts: i64) -> (i64, i64) { - let mut year = 1970; - let mut days = days_since_epoch(ts); - loop { - let days_in_year = if is_leap(year) { - 366 - } else { - 365 - }; - if days > days_in_year { - days -= days_in_year; - year += 1; - } else { - break; - } - } - (year, days) -} - -fn days_in_month(month: u8, year: i64) -> i64 { - assert!(month < 13); - match month { - 1 | 3 | 5 | 7 | 10 | 12 => 31, - 2 => { - if is_leap(year) { - 29 - } else { - 28 - } - }, - 4 | 6 | 9 | 11 => 30, - _ => unreachable!(), - } -} - -fn month_and_days_from_ordinal(days: i64, year: i64) -> (u8, i64) { - assert!( - if is_leap(year) { - days < 366 - } else { - days < 365 - } - ); - let mut days = days; - let mut month = 1; - while days_in_month(month, year) <= days { - days -= days_in_month(month, year); - month += 1; - } - (month, days) -} - -pub(super) fn date_time_from_ts(ts: i64) -> DateTime { - let (year, days) = year_from_ts_with_remainder(ts); - let (month, days) = month_and_days_from_ordinal(days, year); - let seconds = ts % SECS_PER_DAY; - let minutes = seconds / 60; - let second = seconds % 60; - let hour = minutes / 60; - let minute = minutes % 60; - DateTime { - year: year as u32, - month: month.try_into().unwrap(), - day: (days + 1).try_into().unwrap(), - hour: Some((hour + 1).try_into().unwrap()), - minute: Some((minute).try_into().unwrap()), - second: Some(second.try_into().unwrap()), - tz: Some(TimeZone::UTC), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn get_year() { - let ts = 1686459092; - let (y, r) = year_from_ts_with_remainder(ts); - assert_eq!(y, 2023); - assert_eq!(r, 161); - } - - #[test] - fn get_month_and_days() { - let ts = 1686459092; - let (year, days) = year_from_ts_with_remainder(ts); - let (month, days) = month_and_days_from_ordinal(days, year); - assert_eq!(month, 6); - assert_eq!(days, 10); - } - - #[test] - fn get_datetime() { - let ts = 1686462068; - let dt = date_time_from_ts(ts); - assert_eq!(dt.day, 11); - assert_eq!(dt.hour, Some(6)); - assert_eq!(dt.minute, Some(41)); - assert_eq!(dt.second, Some(8)); - } -} diff --git a/src/time/mod.rs b/src/time/mod.rs index 5a7db17..9b4225d 100644 --- a/src/time/mod.rs +++ b/src/time/mod.rs @@ -1,7 +1,6 @@ //! A container representing a moment in ISO-8601 format Time use std::{fmt, str::FromStr}; -pub mod epoch; mod error; mod parser; pub use {error::Error, parser::Parser}; @@ -9,6 +8,32 @@ pub use {error::Error, parser::Parser}; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; +const SECS_PER_DAY: i64 = 86400; + +fn days_since_epoch(ts: i64) -> i64 { + ts / SECS_PER_DAY +} + +fn is_leap(year: i64) -> bool { + year % 4 == 0 && (year % 100 != 0 || year % 16 == 0) +} + +pub fn days_in_month(month: u8, year: i64) -> i64 { + assert!(month < 13); + match month { + 1 | 3 | 5 | 7 | 10 | 12 => 31, + 2 => { + if is_leap(year) { + 29 + } else { + 28 + } + } + 4 | 6 | 9 | 11 => 30, + _ => unreachable!(), + } +} + #[derive(Clone, Copy, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(Deserialize, Serialize))] pub enum Sign { @@ -113,10 +138,9 @@ impl fmt::Display for DateTime { impl PartialOrd for DateTime { fn partial_cmp(&self, other: &Self) -> Option { - let a = self.normalize()?; - let b = other.normalize()?; - (a.year, a.month, a.day, a.hour, a.minute, a.second) - .partial_cmp(&(b.year, b.month, b.day, b.hour, b.minute, b.second)) + let a: i64 = (*self).into(); + let b: i64 = (*other).into(); + a.partial_cmp(&b) } } @@ -128,38 +152,90 @@ impl FromStr for DateTime { } } -impl DateTime { - pub fn normalize(&self) -> Option { - if self.tz == Some(TimeZone::UTC) { - Some(*self) - } else if let Some(TimeZone::Offset(tz)) = self.tz { - let hour = if let Some(hour) = self.hour { - match tz.sign { - Sign::Positive => Some(hour + tz.hours), - Sign::Negative => Some(hour - tz.hours), - } +impl From for DateTime { + fn from(ts: i64) -> Self { + // Only allow using this function for positive timestamp values, + // ie no dates before 1970-01-01 + assert!(ts.is_positive()); + + let mut year = 1970; + let mut days = days_since_epoch(ts); + loop { + let days_in_year = if is_leap(year) { 366 } else { 365 }; + if days > days_in_year { + days -= days_in_year; + year += 1; } else { - None - }; - let minute = 'blk: { - if let Some(minutes) = self.minute { - if let Some(o) = tz.minutes { - match tz.sign { - Sign::Positive => break 'blk Some(minutes + o), - Sign::Negative => break 'blk Some(minutes - o), - } - } - } - None - }; - Some(DateTime { - hour, - minute, - ..*self - }) - } else { - None + break; + } } + let mut month = 1; + while days_in_month(month, year) <= days { + days -= days_in_month(month, year); + month += 1; + } + let seconds = ts % SECS_PER_DAY; + let minutes = seconds / 60; + let second = seconds % 60; + let hour = minutes / 60 + 1; + let minute = minutes % 60; + DateTime { + year: year as u32, + month: month.try_into().unwrap(), + day: (days + 1).try_into().unwrap(), + hour: Some((hour).try_into().unwrap()), + minute: Some((minute).try_into().unwrap()), + second: Some(second.try_into().unwrap()), + tz: Some(TimeZone::UTC), + } + } +} + +impl From for i64 { + fn from(dt: DateTime) -> Self { + let mut seconds = dt.second.unwrap_or(0).into(); + seconds += dt.minute.unwrap_or(0) as i64 * 60; + seconds += (dt.hour.unwrap_or(1) - 1) as i64 * 3600; + seconds += SECS_PER_DAY * (dt.day as i64 - 1); + let mut m = 1; + while m < dt.month { + seconds += SECS_PER_DAY * days_in_month(m, dt.year.into()); + m += 1; + } + let mut y = 1970; + while y < dt.year { + if is_leap(y.into()) { + seconds += SECS_PER_DAY * 366; + } else { + seconds += SECS_PER_DAY * 365; + } + y += 1; + } + if let Some(TimeZone::Offset(offset)) = dt.tz { + let mut offset_seconds = offset.hours as i64 * 3600; + if let Some(offset_minutes) = offset.minutes { + offset_seconds += offset_minutes as i64 * 60; + } + match offset.sign { + Sign::Positive => seconds += offset_seconds, + Sign::Negative => seconds -= offset_seconds, + } + } + seconds + } +} + +impl DateTime { + /// Expresses this `DateTime` as an equivalent with the timezone + /// set to UTC. If the timezone is already UTC, it will be returned + /// unchanged. If there is no timezone set, it is assumed to be UTC + /// and set accordingly. If the timezone is offset from UTC, the + /// offset is applied and the result returned. + pub fn normalize(&mut self) -> &Self { + if let Some(TimeZone::Offset(_)) = self.tz { + *self = i64::from(*self).into(); + } + self } } @@ -290,4 +366,39 @@ mod test { ] ) } + + #[test] + fn convert_timestamp() { + let ts = 1686462068; + let dt: DateTime = ts.into(); + assert_eq!(dt.year, 2023); + assert_eq!(dt.month, 6); + assert_eq!(dt.day, 11); + assert_eq!(dt.hour, Some(6)); + assert_eq!(dt.minute, Some(41)); + assert_eq!(dt.second, Some(8)); + assert_eq!(ts, dt.into()); + } + + #[test] + fn compare_offset() { + let a: DateTime = "2023-06-11T02:07:16-05".parse().unwrap(); + let b: DateTime = "2023-06-11T02:07:16Z".parse().unwrap(); + assert!(b > a); + } + + #[test] + fn normalize() { + let mut dt: DateTime = "2023-06-11T22:29:48+05".parse().unwrap(); + assert_eq!( + dt.tz.unwrap(), + TimeZone::Offset(Offset { + sign: Sign::Positive, + hours: 5, + minutes: None + }) + ); + assert_eq!(dt.normalize().tz, Some(TimeZone::UTC)); + assert_eq!(dt.to_string(), "2023-06-12T03:29:48Z"); + } } diff --git a/src/time/parser.rs b/src/time/parser.rs index 7ac4bf2..d5dea23 100644 --- a/src/time/parser.rs +++ b/src/time/parser.rs @@ -1,5 +1,5 @@ //! Implements a parser for ISO-8601 format Time -use super::{DateTime, Error, Offset, Sign, TimeZone}; +use super::{days_in_month, DateTime, Error, Offset, Sign, TimeZone}; #[derive(Debug, PartialEq)] enum Format { @@ -53,20 +53,9 @@ impl<'a> Parser<'a> { } } + #[allow(clippy::missing_panics_doc)] fn validate_day(&self, day: u8) -> Result<(), Error> { - let max = match self.month { - Some(1 | 3 | 5 | 7 | 10 | 12) => 31, - Some(2) => { - let year = self.year.unwrap(); - if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) { - 29 - } else { - 28 - } - } - Some(4 | 6 | 9 | 11) => 30, - _ => return Err(Error::InvalidMonth), - }; + let max = days_in_month(self.month.unwrap(), self.year.unwrap() as i64) as u8; if day > max { Err(Error::InvalidDay) } else { @@ -267,12 +256,12 @@ impl<'a> Parser<'a> { let sign = match self.buffer.chars().next() { Some('+') => Sign::Positive, Some('-') => Sign::Negative, + None => return Ok(()), _ => return Err(Error::InvalidOffset), }; let Some(n) = self.buffer.get(1..3) else { return Err(Error::InvalidOffset); }; - println!("Hours: {n}"); let hours: u8 = n.parse()?; let minutes: Option = if let Some(n) = self.buffer.get(3..5) { Some(n.parse()?) @@ -510,4 +499,10 @@ mod tests { })) ); } + + #[test] + fn parse_no_tz() { + let dt: Result = Parser::new("2023-06-11T22:41:31").parse(); + assert!(dt.unwrap().tz.is_none()); + } } diff --git a/src/time/parser.rs.bak b/src/time/parser.rs.bak deleted file mode 100644 index 37055c8..0000000 --- a/src/time/parser.rs.bak +++ /dev/null @@ -1,286 +0,0 @@ -//! Implements a parser for ISO-8601 format Time -use super::{DateTime, Error, TimeZone}; - -#[derive(Debug, PartialEq)] -enum Format { - Basic, - Extended, -} - -#[derive(Debug, PartialEq)] -enum Mode { - Year, - Month, - Day, - Hour, - Minute, - Second, - TimeZone, - Finish, -} - -#[derive(Debug)] -pub struct Parser<'a> { - text: &'a str, - format: Format, - mode: Mode, - year: Option, - month: Option, - day: Option, - hour: Option, - minute: Option, - second: Option, - tz: Option, -} - -impl<'a> Parser<'a> { - pub fn new(text: &'a str) -> Self { - Self { - text, - format: Format::Extended, - mode: Mode::Year, - year: None, - month: None, - day: None, - hour: None, - minute: None, - second: None, - tz: None, - } - } - - fn parse_year(&mut self) -> Result<(), Error> { - assert_eq!(self.mode, Mode::Year); - let year = self.text.get(..3).ok_or(Error::Truncated)?.parse()?; - self.year = Some(year); - if let Some(c) = self.text.chars().nth(5) { - match c { - '-' => self.format = Format::Extended, - _ => self.format = Format::Basic, - } - } - self.mode = Mode::Month; - Ok(()) - } - - fn parse_month(&mut self) -> Result<(), Error> { - assert_eq!(self.mode, Mode::Month); - let month = match self.format { - Format::Basic => self.text.get(4..6), - Format::Extended => self.text.get(5..7), - }; - if let Some(m) = month { - let month = match m.parse() { - Ok(m) => m, - Err(_) => { - self.mode = Mode::TimeZone; - return Ok(()); - } - }; - if month > 12 { - return Err(Error::InvalidMonth); - } - self.month = Some(month); - self.mode = Mode::Day; - } else { - self.mode = Mode::TimeZone; - } - Ok(()) - } - - fn parse_day(&mut self) -> Result<(), Error> { - assert_eq!(self.mode, Mode::Day); - let day = match self.format { - Format::Basic => self.text.get(6..8), - Format::Extended => self.text.get(8..10), - }; - if let Some(d) = day { - let day = match d.parse() { - Ok(d) => d, - Err(_) => { - self.mode = Mode::TimeZone; - return Ok(()); - } - }; - let max = match self.month { - Some(1|3|5|7|10|12) => 31, - Some(2) => { - if self.year.unwrap() % 4 == 0 { - 29 - } else { - 28 - } - }, - Some(4|6|9|11) => 30, - _ => return Err(Error::InvalidMonth), - }; - if day > max { - return Err(Error::InvalidDay); - } - self.day = Some(day); - let tidx = match self.format { - Format::Basic => 8, - Format::Extended => 10, - }; - if let Some(c) = self.text.chars().nth(tidx) { - match c { - 'T' => self.mode = Mode::Hour, - 'Z' | '-' | '+' => self.mode = Mode::TimeZone, - _ => return Err(Error::InvalidHour), - } - } else { - self.mode = Mode::Finish; - } - } else { - self.mode = Mode::TimeZone; - } - Ok(()) - } - - fn parse_hour(&mut self) -> Result<(), Error> { - assert_eq!(self.mode, Mode::Hour); - let hour = match self.format { - Format::Basic => self.text.get(9..11), - Format::Extended => self.text.get(11..13), - }; - if let Some(h) = hour { - let hour = match h.parse() { - Ok(h) => h, - Err(_) => { - self.mode = Mode::TimeZone; - return Ok(()); - } - }; - if hour > 24 { - return Err(Error::InvalidHour); - } - self.hour = Some(hour); - self.mode = Mode::Minute; - } else { - self.mode = Mode::TimeZone; - } - Ok(()) - } - - fn parse_minute(&mut self) -> Result<(), Error> { - assert_eq!(self.mode, Mode::Minute); - let minute = match self.format { - Format::Basic => self.text.get(11..13), - Format::Extended => self.text.get(14..16), - }; - if let Some(m) = minute { - let minute = match m.parse() { - Ok(m) => m, - Err(_) => { - self.mode = Mode::TimeZone; - return Ok(()); - } - }; - if minute > 60 { - return Err(Error::InvalidMinute); - } - self.minute = Some(minute); - self.mode = Mode::Second; - } else { - self.mode = Mode::TimeZone; - } - Ok(()) - } - - fn parse_second(&mut self) -> Result<(), Error> { - assert_eq!(self.mode, Mode::Second); - let second = match self.format { - Format::Basic => self.text.get(13..15), - Format::Extended => self.text.get(17..19), - }; - if let Some(s) = second { - let second = match s.parse() { - Ok(s) => s, - Err(_) => { - self.mode = Mode::TimeZone; - return Ok(()); - } - }; - if second > 60 { - return Err(Error::InvalidSecond); - } - self.second = Some(second); - } - self.mode = Mode::TimeZone; - Ok(()) - } - - fn parse_timezone(&mut self) -> Result<(), Error> { - assert_eq!(self.mode, Mode::TimeZone); - let idx = if self.second.is_some() { - match self.format { - Format::Basic => 17, - Format::Extended => 21, - } - } else if self.minute.is_some() { - match self.format { - Format::Basic => 15, - Format::Extended => 19, - } - } else if self.hour.is_some() { - match self.format { - Format::Basic => 13, - Format::Extended => 16, - } - } else if self.day.is_some() { - match self.format { - Format::Basic => 9, - Format::Extended => 11, - } - } else if self.month.is_some() { - match self.format { - Format::Basic => 6, - Format::Extended => 8, - } - } else { - 4 - }; - match self.text.chars().nth(idx) { - Some('Z') => { - if self.text.len() > idx + 2 { - return Err(Error::TrailingGarbage); - } else { - self.tz = Some(TimeZone::UTC); - return Ok(()); - } - }, - Some('-') => todo!(), - Some('+') => todo!(), - None => self.mode = Mode::Finish, - _ => return Err(Error::InvalidTimezone), - } - todo!() - } - - pub fn parse(mut self) -> Result { - loop { - match self.mode { - Mode::Year => self.parse_year()?, - Mode::Month => self.parse_month()?, - Mode::Day => self.parse_day()?, - Mode::Hour => self.parse_hour()?, - Mode::Minute => self.parse_minute()?, - Mode::Second => self.parse_second()?, - Mode::TimeZone => { - self.parse_timezone()?; - break; - } - Mode::Finish => break, - } - } - Ok(DateTime { - year: self.year.unwrap(), - month: self.month, - day: self.day, - hour: self.hour, - minute: self.minute, - second: self.second, - tz: self.tz, - }) - } -}