cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
use crate::domain::model::temporal::duration::Duration;
use chrono::NaiveDate;
use std::fmt;
use std::str::FromStr;

/// A calendar date stored as a [`NaiveDate`].
///
/// The invariant is enforced at construction time: `IsoDate::new` (and `FromStr`)
/// reject any string that does not match `YYYY-MM-DD`. This makes it impossible
/// for an invalid date to exist inside a `DecisionRecord` or `Issue` once the
/// object is constructed.
///
/// Display and serialisation always produce `YYYY-MM-DD` format.
#[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 {
    /// Construct an `IsoDate` from a `YYYY-MM-DD` string slice.
    ///
    /// Returns an error if `s` is not a valid ISO 8601 date.
    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"))
    }

    /// Return the underlying [`NaiveDate`].
    pub fn as_naive_date(&self) -> NaiveDate {
        self.0
    }

    /// Return the date as a `YYYY-MM-DD` string.
    ///
    /// Allocates a new `String` on each call. Prefer `Display` or `Serialize`
    /// for formatting contexts.
    pub fn as_str(&self) -> String {
        self.to_string()
    }

    /// Signed duration from `self` to `other`. Positive when `other`
    /// is after `self`, negative when before.
    pub fn duration_until(&self, other: &IsoDate) -> Duration {
        Duration::from_days((other.0 - self.0).num_days())
    }

    /// Date `weeks` weeks before this one.
    pub fn minus_weeks(&self, weeks: u32) -> IsoDate {
        IsoDate(self.0 - chrono::Duration::weeks(weeks as i64))
    }

    /// Date `months` calendar months before this one. If the source
    /// day-of-month does not exist in the target month, clamps to the
    /// last day of that month (delegated to `chrono::Months`).
    pub fn minus_months(&self, months: u32) -> IsoDate {
        IsoDate(self.0 - chrono::Months::new(months))
    }

    /// ISO week label, e.g. `"2026-W11"`.
    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())
    }

    /// Year-month label, e.g. `"2026-03"`.
    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)
    }
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[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; // copy, not move
        assert_eq!(a, b);
    }

    // ── Property-based tests ──────────────────────────────────────────────────

    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);
        }
    }
}