use crate::domain::model::temporal::duration::Duration;
use chrono::NaiveDate;
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct IsoDate(NaiveDate);
impl serde::Serialize for IsoDate {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.to_string())
}
}
impl IsoDate {
pub fn new(s: &str) -> anyhow::Result<Self> {
if s.len() != 10 {
anyhow::bail!("invalid date '{s}': expected YYYY-MM-DD");
}
NaiveDate::parse_from_str(s, "%Y-%m-%d")
.map(IsoDate)
.map_err(|_| anyhow::anyhow!("invalid date '{s}': expected YYYY-MM-DD"))
}
pub fn as_naive_date(&self) -> NaiveDate {
self.0
}
pub fn as_str(&self) -> String {
self.to_string()
}
pub fn duration_until(&self, other: &IsoDate) -> Duration {
Duration::from_days((other.0 - self.0).num_days())
}
pub fn minus_weeks(&self, weeks: u32) -> IsoDate {
IsoDate(self.0 - chrono::Duration::weeks(weeks as i64))
}
pub fn minus_months(&self, months: u32) -> IsoDate {
IsoDate(self.0 - chrono::Months::new(months))
}
pub fn iso_week_label(&self) -> String {
use chrono::Datelike as _;
let w = self.0.iso_week();
format!("{:04}-W{:02}", w.year(), w.week())
}
pub fn year_month_label(&self) -> String {
self.0.format("%Y-%m").to_string()
}
}
impl fmt::Display for IsoDate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0.format("%Y-%m-%d"))
}
}
impl FromStr for IsoDate {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
IsoDate::new(s)
}
}
#[cfg(test)]
pub mod strategy {
use super::*;
use proptest::prelude::*;
pub fn iso_date() -> impl Strategy<Value = IsoDate> {
proptest::string::string_regex("20[0-9]{2}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])")
.unwrap()
.prop_filter_map("valid calendar date", |s| IsoDate::new(&s).ok())
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn new_accepts_valid_date() {
assert!(IsoDate::new("2026-03-11").is_ok());
}
#[test]
fn new_rejects_wrong_separator() {
assert!(IsoDate::new("2026/03/11").is_err());
}
#[test]
fn new_rejects_non_numeric_year() {
assert!(IsoDate::new("YYYY-03-11").is_err());
}
#[test]
fn new_rejects_too_short() {
assert!(IsoDate::new("26-03-11").is_err());
}
#[test]
fn new_rejects_empty_string() {
assert!(IsoDate::new("").is_err());
}
#[test]
fn new_rejects_trailing_chars() {
assert!(IsoDate::new("2026-03-11T00:00:00Z").is_err());
}
#[test]
fn display_roundtrips() {
let d = IsoDate::new("2026-03-11").unwrap();
assert_eq!(d.to_string(), "2026-03-11");
}
#[test]
fn as_str_returns_formatted_date() {
let d = IsoDate::new("2026-01-01").unwrap();
assert_eq!(d.as_str(), "2026-01-01");
}
#[test]
fn as_naive_date_roundtrips() {
let d = IsoDate::new("2026-03-11").unwrap();
assert_eq!(
d.as_naive_date(),
NaiveDate::from_ymd_opt(2026, 3, 11).unwrap()
);
}
#[test]
fn from_str_accepts_valid_date() {
let d: IsoDate = "2026-06-15".parse().unwrap();
assert_eq!(d.to_string(), "2026-06-15");
}
#[test]
fn from_str_rejects_invalid_date() {
assert!("not-a-date".parse::<IsoDate>().is_err());
}
#[test]
fn equality_holds_for_same_date() {
let a = IsoDate::new("2026-03-11").unwrap();
let b = IsoDate::new("2026-03-11").unwrap();
assert_eq!(a, b);
}
#[test]
fn duration_until_positive_when_other_is_later() {
let a = IsoDate::new("2026-01-01").unwrap();
let b = IsoDate::new("2026-01-15").unwrap();
assert_eq!(a.duration_until(&b), Duration::from_days(14));
}
#[test]
fn duration_until_negative_when_other_is_earlier() {
let a = IsoDate::new("2026-01-15").unwrap();
let b = IsoDate::new("2026-01-01").unwrap();
assert_eq!(a.duration_until(&b), Duration::from_days(-14));
}
#[test]
fn duration_until_zero_for_same_date() {
let a = IsoDate::new("2026-03-11").unwrap();
assert_eq!(a.duration_until(&a), Duration::default());
}
#[test]
fn minus_weeks_subtracts_calendar_weeks() {
let d = IsoDate::new("2026-03-12").unwrap();
assert_eq!(d.minus_weeks(2), IsoDate::new("2026-02-26").unwrap());
}
#[test]
fn minus_months_subtracts_calendar_months() {
let d = IsoDate::new("2026-03-12").unwrap();
assert_eq!(d.minus_months(5), IsoDate::new("2025-10-12").unwrap());
}
#[test]
fn minus_months_clamps_to_end_of_target_month() {
let d = IsoDate::new("2026-03-31").unwrap();
assert_eq!(d.minus_months(1), IsoDate::new("2026-02-28").unwrap());
}
#[test]
fn iso_week_label_formats_year_and_week() {
let d = IsoDate::new("2026-03-12").unwrap();
assert_eq!(d.iso_week_label(), "2026-W11");
}
#[test]
fn year_month_label_pads_month() {
let d = IsoDate::new("2026-03-12").unwrap();
assert_eq!(d.year_month_label(), "2026-03");
}
#[test]
fn ordering_matches_chronological_order() {
let earlier = IsoDate::new("2025-12-31").unwrap();
let later = IsoDate::new("2026-01-01").unwrap();
assert!(earlier < later);
}
#[test]
fn is_copy() {
let a = IsoDate::new("2026-03-11").unwrap();
let b = a; assert_eq!(a, b);
}
proptest! {
#[test]
fn prop_strategy_always_produces_valid_dates(d in strategy::iso_date()) {
prop_assert!(IsoDate::new(&d.to_string()).is_ok());
}
#[test]
fn prop_display_roundtrips(d in strategy::iso_date()) {
let s = d.to_string();
let parsed: IsoDate = s.parse().unwrap();
prop_assert_eq!(d, parsed);
}
}
}