use crate::domain::model::temporal::duration::Duration;
use crate::domain::model::temporal::iso_date::IsoDate;
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Timestamp(String);
impl Timestamp {
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
}
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()
}
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())
}
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()
}
pub fn to_iso_date(&self) -> IsoDate {
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)
}
}
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)
}
}
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())
}
#[cfg(test)]
pub mod strategy {
use super::*;
use proptest::prelude::*;
pub fn timestamp() -> impl Strategy<Value = Timestamp> {
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::*;
#[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();
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);
}
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);
}
}
}