use std::{fmt, str::FromStr};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct PartialDate {
year: i32,
month: Option<u8>,
day: Option<u8>,
}
impl PartialDate {
pub const fn from_year(year: i32) -> Self {
Self {
year,
month: None,
day: None,
}
}
pub const fn year_month(year: i32, month: u8) -> Option<Self> {
if 1 <= month && month <= 12 {
Some(Self {
year,
month: Some(month),
day: None,
})
} else {
None
}
}
pub const fn ymd(year: i32, month: u8, day: u8) -> Option<Self> {
if 1 <= month && month <= 12 && 1 <= day && day <= 31 {
Some(Self {
year,
month: Some(month),
day: Some(day),
})
} else {
None
}
}
pub const fn new(year: i32, month: Option<u8>, day: Option<u8>) -> Option<Self> {
match (month, day) {
(None, None) => Some(Self::from_year(year)),
(Some(m), None) => Self::year_month(year, m),
(Some(m), Some(d)) => Self::ymd(year, m, d),
(None, Some(_)) => None,
}
}
pub const fn year(&self) -> i32 {
self.year
}
pub const fn month(&self) -> Option<u8> {
self.month
}
pub const fn day(&self) -> Option<u8> {
self.day
}
pub const fn is_complete(&self) -> bool {
self.day.is_some()
}
pub fn to_naive_date(&self) -> Option<chrono::NaiveDate> {
let month = self.month?;
let day = self.day?;
chrono::NaiveDate::from_ymd_opt(self.year, month as u32, day as u32)
}
}
impl From<chrono::NaiveDate> for PartialDate {
fn from(date: chrono::NaiveDate) -> Self {
use chrono::Datelike;
Self {
year: date.year(),
month: Some(date.month() as u8),
day: Some(date.day() as u8),
}
}
}
impl fmt::Display for PartialDate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match (self.month, self.day) {
(Some(m), Some(d)) => write!(f, "{:04}-{m:02}-{d:02}", self.year),
(Some(m), None) => write!(f, "{:04}-{m:02}", self.year),
_ => write!(f, "{:04}", self.year),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseDateError {
input: String,
}
impl fmt::Display for ParseDateError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"invalid partial date {:?} (expected YYYY, YYYY-MM, or YYYY-MM-DD)",
self.input
)
}
}
impl std::error::Error for ParseDateError {}
impl FromStr for PartialDate {
type Err = ParseDateError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let err = || ParseDateError {
input: s.to_string(),
};
let mut parts = s.split('-');
let year = parts.next().ok_or_else(err)?.parse().map_err(|_| err())?;
let month = parts
.next()
.map(|m| m.parse::<u8>())
.transpose()
.map_err(|_| err())?;
let day = parts
.next()
.map(|d| d.parse::<u8>())
.transpose()
.map_err(|_| err())?;
if parts.next().is_some() {
return Err(err());
}
PartialDate::new(year, month, day).ok_or_else(err)
}
}
impl Serialize for PartialDate {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for PartialDate {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
s.parse().map_err(de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constructors_enforce_invariants() {
assert_eq!(PartialDate::from_year(2022).to_string(), "2022");
assert!(PartialDate::year_month(2022, 4).is_some());
assert!(PartialDate::year_month(2022, 0).is_none());
assert!(PartialDate::year_month(2022, 13).is_none());
assert!(PartialDate::ymd(2022, 4, 1).is_some());
assert!(PartialDate::ymd(2022, 4, 0).is_none());
assert!(PartialDate::ymd(2022, 4, 32).is_none());
assert!(PartialDate::new(2022, None, Some(1)).is_none());
}
#[test]
fn display_matches_precision() {
assert_eq!(
PartialDate::ymd(2022, 4, 1).unwrap().to_string(),
"2022-04-01"
);
assert_eq!(
PartialDate::year_month(2022, 4).unwrap().to_string(),
"2022-04"
);
assert_eq!(PartialDate::from_year(2022).to_string(), "2022");
}
#[test]
fn parse_round_trips_all_precisions() {
for s in ["2022", "2022-04", "2022-04-01"] {
let parsed: PartialDate = s.parse().unwrap();
assert_eq!(parsed.to_string(), s, "round-trip failed for {s:?}");
}
}
#[test]
fn parse_rejects_garbage() {
for s in [
"",
"abc",
"2022-13",
"2022-00-00",
"2022-04-99",
"2022-04-01-01",
"-2022",
] {
assert!(s.parse::<PartialDate>().is_err(), "should reject {s:?}");
}
}
#[test]
fn json_round_trips_losslessly() {
for s in ["2022", "2022-04", "2022-04-01"] {
let original: PartialDate = s.parse().unwrap();
let json = serde_json::to_string(&original).unwrap();
assert_eq!(json, format!("\"{s}\""));
let back: PartialDate = serde_json::from_str(&json).unwrap();
assert_eq!(back, original);
}
}
#[test]
fn chrono_interop() {
let full = PartialDate::ymd(2022, 4, 1).unwrap();
let naive = full.to_naive_date().unwrap();
assert_eq!(PartialDate::from(naive), full);
assert!(
PartialDate::year_month(2022, 4)
.unwrap()
.to_naive_date()
.is_none()
);
assert!(
PartialDate::ymd(2022, 2, 30)
.unwrap()
.to_naive_date()
.is_none()
);
}
}