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 crate::domain::model::temporal::iso_date::IsoDate;
use std::fmt;
use std::str::FromStr;

/// A UTC timestamp in `YYYY-MM-DDTHH:MM:SSZ` format.
///
/// The invariant is enforced at construction time: `Timestamp::new` (and
/// `FromStr`) reject any string that does not match the format exactly.
/// This makes it impossible for an invalid timestamp to exist inside an
/// `Event` once the object is constructed.
///
/// Sub-second precision and non-UTC timezones are out of scope.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Timestamp(String);

impl Timestamp {
    /// Construct a `Timestamp` from a string slice.
    ///
    /// Returns an error if `s` does not match `YYYY-MM-DDTHH:MM:SSZ`
    /// exactly (20 bytes, fixed separators, all-digit fields).
    pub fn new(s: &str) -> anyhow::Result<Self> {
        if is_valid_timestamp(s) {
            Ok(Timestamp(s.to_string()))
        } else {
            anyhow::bail!("invalid timestamp '{s}': expected YYYY-MM-DDTHH:MM:SSZ")
        }
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Render this UTC timestamp in the user's local timezone as
    /// `YYYY-MM-DD HH:MM:SS ±HH:MM`. Infallible thanks to the
    /// construction invariant.
    pub fn format_local(&self) -> String {
        use chrono::{DateTime, Local, Utc};
        let utc_dt: DateTime<Utc> = self
            .0
            .parse()
            .expect("Timestamp invariant guarantees RFC 3339");
        utc_dt
            .with_timezone(&Local)
            .format("%Y-%m-%d %H:%M:%S %z")
            .to_string()
    }

    /// Signed duration from `earlier` to `self`. Positive when `self` is
    /// later. Panics only if either string fails to parse as RFC 3339,
    /// which the construction invariant rules out.
    pub fn duration_since(&self, earlier: &Timestamp) -> Duration {
        use chrono::{DateTime, Utc};
        let a: DateTime<Utc> = self
            .0
            .parse()
            .expect("Timestamp invariant guarantees RFC 3339");
        let b: DateTime<Utc> = earlier
            .0
            .parse()
            .expect("Timestamp invariant guarantees RFC 3339");
        Duration::from_seconds((a - b).num_seconds())
    }

    /// Seconds since the Unix epoch (1970-01-01T00:00:00Z). Infallible
    /// thanks to the construction invariant — any valid Timestamp is
    /// also a valid RFC 3339 instant.
    pub fn unix_seconds(&self) -> i64 {
        use chrono::{DateTime, Utc};
        let dt: DateTime<Utc> = self
            .0
            .parse()
            .expect("Timestamp invariant guarantees RFC 3339");
        dt.timestamp()
    }

    /// Extract the date part as an `IsoDate`.
    ///
    /// The first 10 characters of a valid `Timestamp` are always `YYYY-MM-DD`,
    /// so this conversion is infallible.
    pub fn to_iso_date(&self) -> IsoDate {
        // Safety: a valid Timestamp always starts with a valid YYYY-MM-DD date.
        IsoDate::new(&self.0[..10]).expect("Timestamp always contains a valid YYYY-MM-DD prefix")
    }
}

impl fmt::Display for Timestamp {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

impl FromStr for Timestamp {
    type Err = anyhow::Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Timestamp::new(s)
    }
}

// serde support — stored as a plain string in YAML frontmatter
impl serde::Serialize for Timestamp {
    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        s.serialize_str(&self.0)
    }
}

impl<'de> serde::Deserialize<'de> for Timestamp {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        let raw = String::deserialize(d)?;
        Timestamp::new(&raw).map_err(serde::de::Error::custom)
    }
}

/// Return `true` iff `s` matches `YYYY-MM-DDTHH:MM:SSZ` exactly (20 bytes).
fn is_valid_timestamp(s: &str) -> bool {
    let b = s.as_bytes();
    if b.len() != 20 {
        return false;
    }
    if b[4] != b'-'
        || b[7] != b'-'
        || b[10] != b'T'
        || b[13] != b':'
        || b[16] != b':'
        || b[19] != b'Z'
    {
        return false;
    }
    b[..4].iter().all(|c| c.is_ascii_digit())
        && b[5..7].iter().all(|c| c.is_ascii_digit())
        && b[8..10].iter().all(|c| c.is_ascii_digit())
        && b[11..13].iter().all(|c| c.is_ascii_digit())
        && b[14..16].iter().all(|c| c.is_ascii_digit())
        && b[17..19].iter().all(|c| c.is_ascii_digit())
}

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

#[cfg(test)]
pub mod strategy {
    use super::*;
    use proptest::prelude::*;

    /// Generate valid `Timestamp` values.
    pub fn timestamp() -> impl Strategy<Value = Timestamp> {
        // Days 01-28 are valid in every month — avoids invalid dates like Feb 30.
        proptest::string::string_regex(
            "20[0-9]{2}-(0[1-9]|1[0-2])-(0[1-9]|1[0-9]|2[0-8])T[0-2][0-9]:[0-5][0-9]:[0-5][0-9]Z",
        )
        .unwrap()
        .prop_map(|s| Timestamp::new(&s).expect("regex guarantees valid Timestamp"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;

    // ── Unit tests ────────────────────────────────────────────────────────────

    #[test]
    fn new_accepts_valid_timestamp() {
        assert!(Timestamp::new("2026-03-11T14:30:00Z").is_ok());
    }

    #[test]
    fn unix_seconds_matches_known_epoch() {
        let ts = Timestamp::new("1970-01-01T00:00:00Z").unwrap();
        assert_eq!(ts.unix_seconds(), 0);
    }

    #[test]
    fn format_local_contains_date_part() {
        let ts = Timestamp::new("2026-03-12T14:30:00Z").unwrap();
        // The date part is present regardless of the local offset.
        assert!(ts.format_local().contains("2026-03-"));
    }

    #[test]
    fn new_accepts_midnight_utc() {
        assert!(Timestamp::new("2026-03-11T00:00:00Z").is_ok());
    }

    #[test]
    fn new_rejects_date_only() {
        assert!(Timestamp::new("2026-03-11").is_err());
    }

    #[test]
    fn new_rejects_missing_time_part() {
        assert!(Timestamp::new("2026-03-11T").is_err());
    }

    #[test]
    fn new_rejects_wrong_separator_t() {
        assert!(Timestamp::new("2026-03-11 14:30:00Z").is_err());
    }

    #[test]
    fn new_rejects_missing_z() {
        assert!(Timestamp::new("2026-03-11T14:30:00").is_err());
    }

    #[test]
    fn new_rejects_non_utc_offset() {
        assert!(Timestamp::new("2026-03-11T14:30:00+02:00").is_err());
    }

    #[test]
    fn new_rejects_empty_string() {
        assert!(Timestamp::new("").is_err());
    }

    #[test]
    fn new_rejects_trailing_chars() {
        assert!(Timestamp::new("2026-03-11T14:30:00Z ").is_err());
    }

    #[test]
    fn display_roundtrips() {
        let t = Timestamp::new("2026-03-11T14:30:00Z").unwrap();
        assert_eq!(t.to_string(), "2026-03-11T14:30:00Z");
    }

    #[test]
    fn as_str_returns_inner() {
        let t = Timestamp::new("2026-01-01T00:00:00Z").unwrap();
        assert_eq!(t.as_str(), "2026-01-01T00:00:00Z");
    }

    #[test]
    fn from_str_accepts_valid_timestamp() {
        let t: Timestamp = "2026-06-15T12:00:00Z".parse().unwrap();
        assert_eq!(t.as_str(), "2026-06-15T12:00:00Z");
    }

    #[test]
    fn from_str_rejects_invalid() {
        assert!("not-a-timestamp".parse::<Timestamp>().is_err());
        assert!("2026-03-11".parse::<Timestamp>().is_err());
    }

    #[test]
    fn equality_holds_for_same_timestamp() {
        let a = Timestamp::new("2026-03-11T00:00:00Z").unwrap();
        let b = Timestamp::new("2026-03-11T00:00:00Z").unwrap();
        assert_eq!(a, b);
    }

    #[test]
    fn to_iso_date_extracts_date_part() {
        let t = Timestamp::new("2026-03-11T14:30:00Z").unwrap();
        let d = t.to_iso_date();
        assert_eq!(d.as_str(), "2026-03-11");
    }

    #[test]
    fn to_iso_date_midnight_utc() {
        let t = Timestamp::new("2026-01-01T00:00:00Z").unwrap();
        assert_eq!(t.to_iso_date().as_str(), "2026-01-01");
    }

    #[test]
    fn ordering_is_chronological() {
        let earlier = Timestamp::new("2026-03-11T00:00:00Z").unwrap();
        let later = Timestamp::new("2026-03-11T10:00:00Z").unwrap();
        assert!(earlier < later);
    }

    #[test]
    fn serde_roundtrip() {
        let t = Timestamp::new("2026-03-11T14:30:00Z").unwrap();
        let yaml = serde_yaml::to_string(&t).unwrap();
        assert_eq!(yaml.trim(), "2026-03-11T14:30:00Z");
        let back: Timestamp = serde_yaml::from_str(&yaml).unwrap();
        assert_eq!(back, t);
    }

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

    proptest! {
        #[test]
        fn prop_to_iso_date_matches_prefix(t in strategy::timestamp()) {
            let d = t.to_iso_date();
            prop_assert_eq!(d.to_string(), &t.as_str()[..10]);
        }

        #[test]
        fn prop_strategy_always_produces_valid_timestamps(t in strategy::timestamp()) {
            prop_assert!(Timestamp::new(t.as_str()).is_ok());
        }

        #[test]
        fn prop_display_roundtrips(t in strategy::timestamp()) {
            let s = t.to_string();
            let parsed: Timestamp = s.parse().unwrap();
            prop_assert_eq!(t, parsed);
        }

        #[test]
        fn prop_length_is_always_20(t in strategy::timestamp()) {
            prop_assert_eq!(t.as_str().len(), 20);
        }
    }
}