commit 40470e17528bc59e6c2dfa0c4e71010b5539948b Author: Nathan Fisher Date: Mon Jan 29 10:58:27 2024 -0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..aacb506 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,65 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "epoch" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "proc-macro2" +version = "1.0.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.196" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.196" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..574cb44 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "epoch" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +serde = { version = "1.0", features = ["derive"] } diff --git a/src/datetime.rs b/src/datetime.rs new file mode 100644 index 0000000..fb02339 --- /dev/null +++ b/src/datetime.rs @@ -0,0 +1,155 @@ +use { + crate::{ + month::Month, weekday::Weekday, year::Year, zone::TimeZone, SECONDS_PER_DAY, + SECONDS_PER_HOUR, SECONDS_PER_MINUTE, + }, + serde::{Deserialize, Serialize}, + std::{cmp, fmt}, +}; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct DateTime { + pub year: Year, + pub month: Month, + pub day: u8, + pub hour: u8, + pub minute: u8, + pub second: u8, + pub zone: TimeZone, +} + +impl DateTime { + /// Retrieves the year + pub fn year(&self) -> i32 { + self.year.get() + } + + /// Gets the Unix timestamp corresponding to this `DateTime` + pub fn timestamp(&self) -> i64 { + let mut seconds: i64 = 0; + let mut year = Year::new(1970); + if self.year() < 1970 { + while year.get() > self.year() { + year = year.previous(); + seconds -= year.seconds(); + } + } else if self.year() > 1970 { + while year.get() < self.year() { + seconds += year.seconds(); + year = year.next(); + } + } + let mut month = Month::Janurary; + while month < self.month { + seconds += month.seconds(self.year); + month = month.next().unwrap(); + } + // The days begin numbering with 1, so on the 5th we have had four full + // days plus some remainder. So we use self.days - 1 for our calculations + seconds += i64::from(self.day - 1) * SECONDS_PER_DAY; + seconds += i64::from(self.hour) * SECONDS_PER_HOUR; + seconds += i64::from(self.second); + seconds += self.zone.as_seconds(); + seconds + } + + /// Converts a Unix timestamp to a `DateTime` struct + pub fn from_timestamp(ts: i64) -> Self { + if ts < 0 { + let mut seconds: i64 = 0; + let mut year = Year::new(-1); + while seconds > -year.seconds() { + seconds -= year.seconds(); + year = year.previous(); + } + let mut month = Some(Month::December); + while month.is_some() { + let m = month.unwrap(); + if seconds > m.seconds(year) { + break; + } + seconds -= m.seconds(year); + month = m.previous(); + } + let month = month.unwrap(); + let mut day = month.days(year); + while day > 0 && seconds < -SECONDS_PER_DAY { + seconds -= SECONDS_PER_HOUR; + day -= 1; + } + let mut hour: i8 = 23; + while hour >= 0 && seconds < -SECONDS_PER_HOUR { + seconds -= SECONDS_PER_HOUR; + hour -= 1; + } + let hour = hour.try_into().unwrap(); + let mut minute: i8 = 60; + while minute >= 0 && seconds < -60 { + seconds -= 60; + minute -= 1; + } + let minute = minute.try_into().unwrap(); + let second: u8 = (seconds + 60).try_into().unwrap(); + Self { + year, + month, + day, + hour, + minute, + second, + zone: TimeZone::Utc, + } + } else if ts > 0 { + let mut seconds: i64 = ts; + let mut year = Year::new(1970); + while year.seconds() < seconds { + seconds -= year.seconds(); + year = year.next(); + } + let mut month = Month::Janurary; + while month.seconds(year) < seconds { + seconds -= month.seconds(year); + month = month.next().unwrap(); + } + let day = seconds / SECONDS_PER_DAY + 1; + seconds %= SECONDS_PER_DAY; + let hour = seconds / SECONDS_PER_HOUR; + seconds %= SECONDS_PER_HOUR; + let minute = seconds / SECONDS_PER_MINUTE; + seconds %= SECONDS_PER_MINUTE; + Self { + year, + month, + day: day.try_into().unwrap(), + hour: hour.try_into().unwrap(), + minute: minute.try_into().unwrap(), + second: seconds.try_into().unwrap(), + zone: TimeZone::Utc, + } + } else { + Self { + year: Year::new(1970), + month: Month::Janurary, + day: 1, + hour: 0, + minute: 0, + second: 0, + zone: TimeZone::Utc, + } + } + } +} + +impl Ord for DateTime { + fn cmp(&self, other: &Self) -> cmp::Ordering { + let a = self.timestamp(); + let b = other.timestamp(); + a.cmp(&b) + } +} + +impl PartialOrd for DateTime { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..6892eb8 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,14 @@ +#[warn(clippy::all, clippy::pedantic)] +#[allow(clippy::must_use_candidate)] +#[allow(clippy::comparison_chain)] +pub(crate) mod datetime; +pub(crate) mod month; +pub(crate) mod weekday; +pub(crate) mod year; +pub(crate) mod zone; + +pub mod prelude; + +pub static SECONDS_PER_MINUTE: i64 = 60; +pub static SECONDS_PER_HOUR: i64 = 60 * 60; +pub static SECONDS_PER_DAY: i64 = 60 * 60 * 24; diff --git a/src/month.rs b/src/month.rs new file mode 100644 index 0000000..ba1b96d --- /dev/null +++ b/src/month.rs @@ -0,0 +1,175 @@ +use std::str::FromStr; + +use { + crate::{year::Year, SECONDS_PER_DAY}, + serde::{Deserialize, Serialize}, + std::{cmp, error, fmt}, +}; + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[repr(u8)] +pub enum Month { + Janurary = 1, + February = 2, + March = 3, + April = 4, + May = 5, + June = 6, + July = 7, + August = 8, + September = 9, + October = 10, + November = 11, + December = 12, +} + +#[derive(Debug)] +pub enum Error { + ParseMonth, + Range, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{self:?}") + } +} + +impl error::Error for Error {} + +impl Month { + pub fn days(&self, year: Year) -> u8 { + match *self as u8 { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 2 => match year { + Year::Normal(_) => 28, + Year::Leap(_) => 29, + }, + _ => 30, + } + } + + pub fn seconds(&self, year: Year) -> i64 { + self.days(year) as i64 * SECONDS_PER_DAY + } + + pub fn next(&self) -> Option { + let n = *self as u8 + 1; + n.try_into().ok() + } + + pub fn previous(&self) -> Option { + let n = *self as u8 - 1; + n.try_into().ok() + } +} + +impl TryFrom for Month { + type Error = Error; + + fn try_from(value: u8) -> Result { + match value { + 1 => Ok(Self::Janurary), + 2 => Ok(Self::February), + 3 => Ok(Self::March), + 4 => Ok(Self::April), + 5 => Ok(Self::May), + 6 => Ok(Self::June), + 7 => Ok(Self::July), + 8 => Ok(Self::August), + 9 => Ok(Self::September), + 10 => Ok(Self::October), + 11 => Ok(Self::November), + 12 => Ok(Self::December), + _ => Err(Error::Range), + } + } +} + +impl Ord for Month { + fn cmp(&self, other: &Self) -> cmp::Ordering { + (*self as u8).cmp(&(*other as u8)) + } +} + +impl PartialOrd for Month { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl fmt::Display for Month { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + Self::Janurary => "January", + Self::February => "February", + Self::March => "March", + Self::April => "April", + Self::May => "May", + Self::June => "June", + Self::July => "Jujly", + Self::August => "August", + Self::September => "September", + Self::October => "October", + Self::November => "November", + Self::December => "December", + } + ) + } +} + +impl FromStr for Month { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "Jan" | "jan" | "January" | "january" => Ok(Self::Janurary), + "Feb" | "feb" | "February" | "february" => Ok(Self::February), + "March" | "march" | "Mar" | "mar" => Ok(Self::March), + "April" | "april" | "Apr" | "apr" => Ok(Self::April), + "May" | "may" => Ok(Self::May), + "June" | "june" | "Jun" | "jun" => Ok(Self::June), + "July" | "july" | "Jul" | "jul" => Ok(Self::July), + "August" | "august" | "Aug" | "aug" => Ok(Self::August), + "September" | "september" | "Sep" | "sep" => Ok(Self::September), + "October" | "october" | "Oct" | "oct" => Ok(Self::October), + "November" | "november" | "Nov" | "nov" => Ok(Self::November), + "December" | "december" | "Dec" | "dec" => Ok(Self::December), + _ => Err(Error::ParseMonth), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn next() { + let mut month = Month::Janurary; + month = month.next().unwrap(); + assert_eq!(month, Month::February); + month = month.next().unwrap(); + assert_eq!(month, Month::March); + month = Month::December; + assert!(month.next().is_none()) + } + + #[test] + fn previous() { + let mut month = Month::March.previous().unwrap(); + assert_eq!(month, Month::February); + month = month.previous().unwrap(); + assert_eq!(month, Month::Janurary); + assert!(month.previous().is_none()); + } + + #[test] + fn compare() { + assert!(Month::Janurary < Month::October); + assert!(Month::June > Month::March); + } +} diff --git a/src/prelude.rs b/src/prelude.rs new file mode 100644 index 0000000..f5e7495 --- /dev/null +++ b/src/prelude.rs @@ -0,0 +1,7 @@ +pub use crate::{ + datetime::DateTime, + month::{Error as MonthError, Month}, + weekday::Weekday, + year::Year, + zone::{Sign, TimeZone}, +}; diff --git a/src/weekday.rs b/src/weekday.rs new file mode 100644 index 0000000..d6dd97f --- /dev/null +++ b/src/weekday.rs @@ -0,0 +1,13 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[repr(u8)] +pub enum Weekday { + Thursday, + Friday, + Saturday, + Sunday, + Monday, + Tuesday, + Wednesday, +} diff --git a/src/year.rs b/src/year.rs new file mode 100644 index 0000000..5d26505 --- /dev/null +++ b/src/year.rs @@ -0,0 +1,73 @@ +use { + crate::SECONDS_PER_DAY, + serde::{Deserialize, Serialize}, + std::{cmp, fmt, num::ParseIntError, str::FromStr}, +}; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum Year { + Normal(i32), + Leap(i32), +} + +impl Year { + pub fn new(year: i32) -> Self { + if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) { + Self::Leap(year) + } else { + Self::Normal(year) + } + } + + pub fn days(&self) -> u16 { + match self { + Self::Normal(_) => 365, + Self::Leap(_) => 366, + } + } + + pub fn seconds(&self) -> i64 { + self.days() as i64 * SECONDS_PER_DAY + } + + pub fn get(&self) -> i32 { + match self { + Self::Normal(y) | Self::Leap(y) => *y, + } + } + + pub fn next(&self) -> Self { + Self::new(self.get() + 1) + } + + pub fn previous(&self) -> Self { + Self::new(self.get() - 1) + } +} + +impl Ord for Year { + fn cmp(&self, other: &Self) -> cmp::Ordering { + self.get().cmp(&other.get()) + } +} + +impl PartialOrd for Year { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl fmt::Display for Year { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:>4}", self.get()) + } +} + +impl FromStr for Year { + type Err = ParseIntError; + + fn from_str(s: &str) -> Result { + let year: i32 = s.parse()?; + Ok(Self::new(year)) + } +} diff --git a/src/zone.rs b/src/zone.rs new file mode 100644 index 0000000..e2d45e3 --- /dev/null +++ b/src/zone.rs @@ -0,0 +1,159 @@ +use { + crate::SECONDS_PER_HOUR, + serde::{Deserialize, Serialize}, + std::{error, fmt, str::FromStr}, +}; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum Sign { + Positive, + Negative, +} + +impl fmt::Display for Sign { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + Self::Positive => "+", + Self::Negative => "-", + } + ) + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum TimeZone { + Offset { sign: Sign, hours: u8, minutes: u8 }, + Utc, +} + +impl TimeZone { + pub fn new(hours: Option, minutes: Option) -> Result { + match (hours, minutes) { + (Some(h), Some(m)) => { + if (!(-12..=12).contains(&h) || m > 59) || ((h == -12 || h == 12) && m > 0) { + return Err(TZError::Range); + } + let (sign, hours) = if h > 0 { + (Sign::Positive, u8::try_from(h).unwrap()) + } else { + (Sign::Negative, u8::try_from(-h).unwrap()) + }; + Ok(Self::Offset { + sign, + hours, + minutes: m, + }) + } + (Some(h), None) => { + if !(-12..=12).contains(&h) { + return Err(TZError::Range); + } + let (sign, hours) = if h > 0 { + (Sign::Positive, u8::try_from(h).unwrap()) + } else { + (Sign::Negative, u8::try_from(-h).unwrap()) + }; + Ok(Self::Offset { + sign, + hours, + minutes: 0, + }) + } + (None, Some(m)) => { + if m > 59 { + Err(TZError::Range) + } else { + Ok(Self::Offset { + sign: Sign::Positive, + hours: 0, + minutes: m, + }) + } + } + _ => Ok(Self::Utc), + } + } + + pub fn as_seconds(&self) -> i64 { + match self { + Self::Utc => 0, + Self::Offset { + sign, + hours, + minutes, + } => { + let base = i64::from(*hours) * SECONDS_PER_HOUR + i64::from(*minutes) * 60; + if let Sign::Negative = sign { + -base + } else { + base + } + } + } + } +} + +impl fmt::Display for TimeZone { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Utc => write!(f, "Z"), + Self::Offset { + sign, + hours, + minutes, + } => { + write!(f, "{sign}{hours}:{minutes}") + } + } + } +} + +impl FromStr for TimeZone { + type Err = TZError; + + fn from_str(s: &str) -> Result { + if s == "z" || s == "Z" { + return Ok(Self::Utc); + } + let sign = match s.get(0..1) { + Some("+") => Sign::Positive, + Some("-") => Sign::Negative, + _ => return Err(TZError::ParseTZError), + }; + if let Some(s) = s.get(1..) { + let Some((h, m)) = s.split_once(':') else { + return Err(TZError::ParseTZError); + }; + let hours = h.parse::().map_err(|_| TZError::ParseTZError)?; + let minutes = m.parse::().map_err(|_| TZError::ParseTZError)?; + if hours > 12 || minutes > 59 || (hours == 12 && minutes > 0) { + Err(TZError::Range) + } else { + Ok(Self::Offset { + sign, + hours, + minutes, + }) + } + } else { + Err(TZError::ParseTZError) + } + } +} + +#[derive(Debug)] +pub enum TZError { + Range, + ParseTZError, +} + +impl fmt::Display for TZError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{self:?}") + } +} + +impl error::Error for TZError {}