Properly handle timezone offsets; Implement conversion to/from Unix

timestamps (i64);
This commit is contained in:
Nathan Fisher 2023-06-11 22:48:02 -04:00
parent 03ac43b821
commit 8c6e15a0c3
5 changed files with 160 additions and 451 deletions

View file

@ -16,7 +16,10 @@ pub struct Parser {
impl Parser { impl Parser {
pub fn new(id: &str) -> Self { 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<Message, super::Error> { pub fn parse(mut self, content: &str) -> Result<Message, super::Error> {

View file

@ -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));
}
}

View file

@ -1,7 +1,6 @@
//! A container representing a moment in ISO-8601 format Time //! A container representing a moment in ISO-8601 format Time
use std::{fmt, str::FromStr}; use std::{fmt, str::FromStr};
pub mod epoch;
mod error; mod error;
mod parser; mod parser;
pub use {error::Error, parser::Parser}; pub use {error::Error, parser::Parser};
@ -9,6 +8,32 @@ pub use {error::Error, parser::Parser};
#[cfg(feature = "serde")] #[cfg(feature = "serde")]
use serde::{Deserialize, Serialize}; 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)] #[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))] #[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
pub enum Sign { pub enum Sign {
@ -113,10 +138,9 @@ impl fmt::Display for DateTime {
impl PartialOrd for DateTime { impl PartialOrd for DateTime {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
let a = self.normalize()?; let a: i64 = (*self).into();
let b = other.normalize()?; let b: i64 = (*other).into();
(a.year, a.month, a.day, a.hour, a.minute, a.second) a.partial_cmp(&b)
.partial_cmp(&(b.year, b.month, b.day, b.hour, b.minute, b.second))
} }
} }
@ -128,38 +152,90 @@ impl FromStr for DateTime {
} }
} }
impl DateTime { impl From<i64> for DateTime {
pub fn normalize(&self) -> Option<Self> { fn from(ts: i64) -> Self {
if self.tz == Some(TimeZone::UTC) { // Only allow using this function for positive timestamp values,
Some(*self) // ie no dates before 1970-01-01
} else if let Some(TimeZone::Offset(tz)) = self.tz { assert!(ts.is_positive());
let hour = if let Some(hour) = self.hour {
match tz.sign { let mut year = 1970;
Sign::Positive => Some(hour + tz.hours), let mut days = days_since_epoch(ts);
Sign::Negative => Some(hour - tz.hours), loop {
} let days_in_year = if is_leap(year) { 366 } else { 365 };
if days > days_in_year {
days -= days_in_year;
year += 1;
} else { } else {
None break;
}; }
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
} }
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<DateTime> 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");
}
} }

View file

@ -1,5 +1,5 @@
//! Implements a parser for ISO-8601 format Time //! 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)] #[derive(Debug, PartialEq)]
enum Format { enum Format {
@ -53,20 +53,9 @@ impl<'a> Parser<'a> {
} }
} }
#[allow(clippy::missing_panics_doc)]
fn validate_day(&self, day: u8) -> Result<(), Error> { fn validate_day(&self, day: u8) -> Result<(), Error> {
let max = match self.month { let max = days_in_month(self.month.unwrap(), self.year.unwrap() as i64) as u8;
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),
};
if day > max { if day > max {
Err(Error::InvalidDay) Err(Error::InvalidDay)
} else { } else {
@ -267,12 +256,12 @@ impl<'a> Parser<'a> {
let sign = match self.buffer.chars().next() { let sign = match self.buffer.chars().next() {
Some('+') => Sign::Positive, Some('+') => Sign::Positive,
Some('-') => Sign::Negative, Some('-') => Sign::Negative,
None => return Ok(()),
_ => return Err(Error::InvalidOffset), _ => return Err(Error::InvalidOffset),
}; };
let Some(n) = self.buffer.get(1..3) else { let Some(n) = self.buffer.get(1..3) else {
return Err(Error::InvalidOffset); return Err(Error::InvalidOffset);
}; };
println!("Hours: {n}");
let hours: u8 = n.parse()?; let hours: u8 = n.parse()?;
let minutes: Option<u8> = if let Some(n) = self.buffer.get(3..5) { let minutes: Option<u8> = if let Some(n) = self.buffer.get(3..5) {
Some(n.parse()?) Some(n.parse()?)
@ -510,4 +499,10 @@ mod tests {
})) }))
); );
} }
#[test]
fn parse_no_tz() {
let dt: Result<DateTime, Error> = Parser::new("2023-06-11T22:41:31").parse();
assert!(dt.unwrap().tz.is_none());
}
} }

View file

@ -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<u32>,
month: Option<u8>,
day: Option<u8>,
hour: Option<u8>,
minute: Option<u8>,
second: Option<u8>,
tz: Option<TimeZone>,
}
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<DateTime, Error> {
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,
})
}
}