use chrono::NaiveDate;
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DateError(String);
impl fmt::Display for DateError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "invalid date: {}", self.0)
}
}
impl std::error::Error for DateError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Date(NaiveDate);
impl Date {
pub fn from_ymd(y: i32, m: u32, d: u32) -> Result<Self, DateError> {
NaiveDate::from_ymd_opt(y, m, d)
.map(Self)
.ok_or_else(|| DateError(format!("{y:04}-{m:02}-{d:02} is not a valid date")))
}
pub fn from_yyyymmdd(v: u32) -> Result<Self, DateError> {
let y = (v / 10000) as i32;
let m = (v / 100) % 100;
let d = v % 100;
Self::from_ymd(y, m, d)
}
pub fn today_et() -> Self {
use chrono::Utc;
use chrono_tz::America::New_York;
Self(Utc::now().with_timezone(&New_York).date_naive())
}
pub fn today_utc() -> Self {
use chrono::Utc;
Self(Utc::now().date_naive())
}
#[inline]
pub fn inner(self) -> NaiveDate {
self.0
}
}
impl From<NaiveDate> for Date {
#[inline]
fn from(d: NaiveDate) -> Self {
Self(d)
}
}
mod private {
pub trait Sealed {}
}
pub trait IntoDate: private::Sealed {
fn into_date(self) -> Result<Date, DateError>;
}
impl private::Sealed for Date {}
impl IntoDate for Date {
fn into_date(self) -> Result<Date, DateError> {
Ok(self)
}
}
impl private::Sealed for &str {}
impl IntoDate for &str {
fn into_date(self) -> Result<Date, DateError> {
self.parse()
}
}
impl private::Sealed for String {}
impl IntoDate for String {
fn into_date(self) -> Result<Date, DateError> {
self.parse()
}
}
impl private::Sealed for u32 {}
impl IntoDate for u32 {
fn into_date(self) -> Result<Date, DateError> {
Date::from_yyyymmdd(self)
}
}
impl private::Sealed for (i32, u32, u32) {}
impl IntoDate for (i32, u32, u32) {
fn into_date(self) -> Result<Date, DateError> {
Date::from_ymd(self.0, self.1, self.2)
}
}
impl private::Sealed for (u32, u32, u32) {}
impl IntoDate for (u32, u32, u32) {
fn into_date(self) -> Result<Date, DateError> {
Date::from_ymd(self.0 as i32, self.1, self.2)
}
}
impl private::Sealed for NaiveDate {}
impl IntoDate for NaiveDate {
fn into_date(self) -> Result<Date, DateError> {
Ok(Date(self))
}
}
impl FromStr for Date {
type Err = DateError;
fn from_str(s: &str) -> Result<Self, DateError> {
let s = s.trim();
if s.len() == 10 && s.as_bytes()[4] == b'-' && s.as_bytes()[7] == b'-' {
return NaiveDate::parse_from_str(s, "%Y-%m-%d")
.map(Self)
.map_err(|_| DateError(format!("'{s}' is not a valid YYYY-MM-DD date")));
}
if s.len() == 10 && s.as_bytes()[4] == b'/' && s.as_bytes()[7] == b'/' {
return NaiveDate::parse_from_str(s, "%Y/%m/%d")
.map(Self)
.map_err(|_| DateError(format!("'{s}' is not a valid YYYY/MM/DD date")));
}
if s.len() == 8 && s.bytes().all(|b| b.is_ascii_digit()) {
let v: u32 = s
.parse()
.map_err(|_| DateError(format!("'{s}' cannot be parsed as YYYYMMDD")))?;
return Self::from_yyyymmdd(v);
}
Err(DateError(format!(
"'{s}' is not a recognised date format — expected YYYY-MM-DD, YYYY/MM/DD, or YYYYMMDD"
)))
}
}
impl fmt::Display for Date {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0.format("%Y-%m-%d"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Datelike;
#[test]
fn parse_iso() {
let d: Date = "2020-03-20".parse().unwrap();
assert_eq!(d.inner(), NaiveDate::from_ymd_opt(2020, 3, 20).unwrap());
}
#[test]
fn parse_slashed() {
let d: Date = "2020/03/20".parse().unwrap();
assert_eq!(d.inner(), NaiveDate::from_ymd_opt(2020, 3, 20).unwrap());
}
#[test]
fn parse_compact() {
let d: Date = "20200320".parse().unwrap();
assert_eq!(d.inner(), NaiveDate::from_ymd_opt(2020, 3, 20).unwrap());
}
#[test]
fn parse_invalid_returns_err() {
assert!("2020-99-99".parse::<Date>().is_err());
assert!("not-a-date".parse::<Date>().is_err());
assert!("".parse::<Date>().is_err());
}
#[test]
fn from_yyyymmdd_valid() {
let d = Date::from_yyyymmdd(20200320).unwrap();
assert_eq!(d.to_string(), "2020-03-20");
}
#[test]
fn from_yyyymmdd_invalid() {
assert!(Date::from_yyyymmdd(20209999).is_err());
}
#[test]
fn into_date_u32() {
let d = 20200320u32.into_date().unwrap();
assert_eq!(d.to_string(), "2020-03-20");
}
#[test]
fn into_date_i32_tuple() {
let d = (2020i32, 3u32, 20u32).into_date().unwrap();
assert_eq!(d.to_string(), "2020-03-20");
}
#[test]
fn into_date_u32_tuple() {
let d = (2020u32, 3u32, 20u32).into_date().unwrap();
assert_eq!(d.to_string(), "2020-03-20");
}
#[test]
fn from_naive_date() {
let nd = NaiveDate::from_ymd_opt(2020, 3, 20).unwrap();
let d = Date::from(nd);
assert_eq!(d.inner(), nd);
}
#[test]
fn display_is_iso() {
let d = Date::from_ymd(2020, 3, 20).unwrap();
assert_eq!(d.to_string(), "2020-03-20");
}
#[test]
fn from_ymd_valid() {
let d = Date::from_ymd(2020, 3, 20).unwrap();
assert_eq!(d.inner().month(), 3);
}
#[test]
fn from_ymd_invalid() {
assert!(Date::from_ymd(2020, 13, 1).is_err());
}
#[test]
fn today_utc_is_plausible() {
let d = Date::today_utc();
assert!(d.inner().year() >= 2024);
}
#[test]
fn today_et_is_plausible() {
let d = Date::today_et();
assert!(d.inner().year() >= 2024);
}
#[test]
fn ordering() {
let d1 = Date::from_ymd(2020, 1, 1).unwrap();
let d2 = Date::from_ymd(2020, 12, 31).unwrap();
assert!(d1 < d2);
}
}