408 lines
11 KiB
Rust
408 lines
11 KiB
Rust
//! A container representing a moment in ISO-8601 format Time
|
|
use std::{fmt, str::FromStr};
|
|
|
|
mod error;
|
|
mod parser;
|
|
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)
|
|
}
|
|
|
|
/// Returns the number of days in a given month. If the
|
|
/// given month is February, then we also need to know
|
|
/// whether it is a leap year or not, hence this function
|
|
/// takes the year as an argument as well.
|
|
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 {
|
|
Positive,
|
|
Negative,
|
|
}
|
|
|
|
impl fmt::Display for Sign {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::Positive => write!(f, "+"),
|
|
Self::Negative => write!(f, "-"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TryFrom<char> for Sign {
|
|
type Error = Error;
|
|
|
|
fn try_from(value: char) -> Result<Self, Self::Error> {
|
|
match value {
|
|
'+' => Ok(Self::Positive),
|
|
'-' => Ok(Self::Negative),
|
|
_ => Err(Error::InvalidOffset),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
|
|
pub struct Offset {
|
|
pub sign: Sign,
|
|
pub hours: u8,
|
|
pub minutes: Option<u8>,
|
|
}
|
|
|
|
impl fmt::Display for Offset {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
if let Some(m) = self.minutes {
|
|
write!(f, "{}{:02}:{m:02}", self.sign, self.hours)
|
|
} else {
|
|
write!(f, "{}{:02}", self.sign, self.hours)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
|
|
pub enum TimeZone {
|
|
UTC,
|
|
Offset(Offset),
|
|
}
|
|
|
|
impl fmt::Display for TimeZone {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::UTC => write!(f, "Z"),
|
|
Self::Offset(o) => write!(f, "{o}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
|
|
pub struct DateTime {
|
|
pub year: u32,
|
|
pub month: u8,
|
|
pub day: u8,
|
|
pub hour: Option<u8>,
|
|
pub minute: Option<u8>,
|
|
pub second: Option<u8>,
|
|
pub tz: Option<TimeZone>,
|
|
}
|
|
|
|
impl fmt::Display for DateTime {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
write!(f, "{:04}", self.year)?;
|
|
'blk: {
|
|
for field in &[self.month, self.day] {
|
|
write!(f, "-{field:02}")?;
|
|
}
|
|
if let Some(h) = self.hour {
|
|
write!(f, "T{h:02}")?;
|
|
} else {
|
|
break 'blk;
|
|
}
|
|
for field in &[self.minute, self.second] {
|
|
if let Some(field) = field {
|
|
write!(f, ":{field:02}")?;
|
|
} else {
|
|
break 'blk;
|
|
}
|
|
}
|
|
}
|
|
if let Some(ref tz) = self.tz {
|
|
write!(f, "{tz}")
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
impl PartialOrd for DateTime {
|
|
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
|
let a: i64 = (*self).into();
|
|
let b: i64 = (*other).into();
|
|
a.partial_cmp(&b)
|
|
}
|
|
}
|
|
|
|
impl FromStr for DateTime {
|
|
type Err = Error;
|
|
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
Parser::new(s).parse()
|
|
}
|
|
}
|
|
|
|
impl From<i64> 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 {
|
|
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;
|
|
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)) 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
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn fmt_full() {
|
|
let dt = DateTime {
|
|
year: 2023,
|
|
month: 5,
|
|
day: 7,
|
|
hour: Some(9),
|
|
minute: Some(3),
|
|
second: Some(8),
|
|
tz: Some(TimeZone::Offset(Offset {
|
|
sign: Sign::Positive,
|
|
hours: 5,
|
|
minutes: Some(15),
|
|
})),
|
|
};
|
|
assert_eq!(dt.to_string(), "2023-05-07T09:03:08+05:15");
|
|
}
|
|
|
|
#[test]
|
|
fn fmt_partial_offset() {
|
|
let dt = DateTime {
|
|
year: 2023,
|
|
month: 5,
|
|
day: 7,
|
|
hour: Some(9),
|
|
minute: Some(3),
|
|
second: Some(8),
|
|
tz: Some(TimeZone::Offset(Offset {
|
|
sign: Sign::Negative,
|
|
hours: 5,
|
|
minutes: None,
|
|
})),
|
|
};
|
|
assert_eq!(dt.to_string(), "2023-05-07T09:03:08-05");
|
|
}
|
|
|
|
#[test]
|
|
fn fmt_utc() {
|
|
let dt = DateTime {
|
|
year: 2023,
|
|
month: 5,
|
|
day: 7,
|
|
hour: Some(9),
|
|
minute: Some(3),
|
|
second: Some(8),
|
|
tz: Some(TimeZone::UTC),
|
|
};
|
|
assert_eq!(dt.to_string(), "2023-05-07T09:03:08Z");
|
|
}
|
|
|
|
#[test]
|
|
fn fmt_no_tz() {
|
|
let dt = DateTime {
|
|
year: 2023,
|
|
month: 5,
|
|
day: 7,
|
|
hour: Some(9),
|
|
minute: Some(3),
|
|
second: Some(8),
|
|
tz: None,
|
|
};
|
|
assert_eq!(dt.to_string(), "2023-05-07T09:03:08");
|
|
}
|
|
|
|
#[test]
|
|
fn fmt_no_seconds() {
|
|
let dt = DateTime {
|
|
year: 2023,
|
|
month: 5,
|
|
day: 7,
|
|
hour: Some(9),
|
|
minute: Some(3),
|
|
second: None,
|
|
tz: Some(TimeZone::UTC),
|
|
};
|
|
assert_eq!(dt.to_string(), "2023-05-07T09:03Z");
|
|
}
|
|
|
|
#[test]
|
|
fn fmt_no_minutes() {
|
|
let dt = DateTime {
|
|
year: 2023,
|
|
month: 5,
|
|
day: 7,
|
|
hour: Some(9),
|
|
minute: None,
|
|
second: Some(50),
|
|
tz: Some(TimeZone::UTC),
|
|
};
|
|
assert_eq!(dt.to_string(), "2023-05-07T09Z");
|
|
}
|
|
|
|
#[test]
|
|
fn fmt_no_time() {
|
|
let dt = DateTime {
|
|
year: 2023,
|
|
month: 5,
|
|
day: 7,
|
|
hour: None,
|
|
minute: None,
|
|
second: Some(50),
|
|
tz: Some(TimeZone::UTC),
|
|
};
|
|
assert_eq!(dt.to_string(), "2023-05-07Z");
|
|
}
|
|
|
|
#[test]
|
|
fn ordering() {
|
|
let mut dates = vec![
|
|
"2023-06-10T10:01:52Z",
|
|
"2023-06-10T08:01:52+05",
|
|
"2023-06-10T11:30:30-05:15",
|
|
];
|
|
dates.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
|
assert_eq!(
|
|
dates,
|
|
vec![
|
|
"2023-06-10T08:01:52+05",
|
|
"2023-06-10T10:01:52Z",
|
|
"2023-06-10T11:30:30-05:15",
|
|
]
|
|
)
|
|
}
|
|
|
|
#[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(5));
|
|
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");
|
|
}
|
|
}
|