Skip to main content

ssh_cli/domain/
time.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! RFC 3339 timestamps as `DateTime<Utc>` newtypes (G-DOM-03).
3//!
4//! Wire format remains a canonical RFC 3339 string (TOML/JSON agent contract).
5//! Construction always uses [`Utc::now`] — never `Local::now`.
6#![forbid(unsafe_code)]
7
8use super::error::DomainError;
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Deserializer, Serialize, Serializer};
11use std::fmt;
12
13/// Instant stored as UTC, serialized as RFC 3339.
14///
15/// Used for `VpsRecord.added_at`, ACME `created_at`, and any audit timestamp.
16#[repr(transparent)]
17#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
18pub struct Rfc3339Utc(DateTime<Utc>);
19
20/// Alias: VPS registry inclusion time.
21pub type AddedAt = Rfc3339Utc;
22
23/// Alias: ACME / audit creation time.
24pub type CreatedAt = Rfc3339Utc;
25
26impl Rfc3339Utc {
27    /// Current UTC instant.
28    #[must_use]
29    pub fn now() -> Self {
30        Self(Utc::now())
31    }
32
33    /// Parses an external RFC 3339 string into UTC.
34    pub fn try_new(raw: impl AsRef<str>) -> Result<Self, DomainError> {
35        let s = raw.as_ref().trim();
36        if s.is_empty() {
37            return Err(DomainError::new("rfc3339", "timestamp must not be empty"));
38        }
39        if s.len() > 64 {
40            return Err(DomainError::new("rfc3339", "timestamp too long (max 64)"));
41        }
42        DateTime::parse_from_rfc3339(s)
43            .map(|d| Self(d.with_timezone(&Utc)))
44            .map_err(|e| DomainError::new("rfc3339", e.to_string()))
45    }
46
47    /// Wraps an already-UTC datetime (infallible).
48    #[must_use]
49    pub const fn from_utc(dt: DateTime<Utc>) -> Self {
50        Self(dt)
51    }
52
53    /// Borrows the inner UTC datetime.
54    #[must_use]
55    pub const fn as_datetime(&self) -> &DateTime<Utc> {
56        &self.0
57    }
58
59    /// Consumes into [`DateTime<Utc>`].
60    #[must_use]
61    pub const fn into_inner(self) -> DateTime<Utc> {
62        self.0
63    }
64
65    /// Canonical RFC 3339 string.
66    #[must_use]
67    pub fn to_rfc3339(&self) -> String {
68        self.0.to_rfc3339()
69    }
70}
71
72impl fmt::Display for Rfc3339Utc {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        f.write_str(&self.0.to_rfc3339())
75    }
76}
77
78impl Serialize for Rfc3339Utc {
79    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
80        s.serialize_str(&self.0.to_rfc3339())
81    }
82}
83
84impl<'de> Deserialize<'de> for Rfc3339Utc {
85    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
86        let s = String::deserialize(d)?;
87        Self::try_new(s).map_err(serde::de::Error::custom)
88    }
89}
90
91impl From<DateTime<Utc>> for Rfc3339Utc {
92    fn from(dt: DateTime<Utc>) -> Self {
93        Self(dt)
94    }
95}
96
97impl From<Rfc3339Utc> for DateTime<Utc> {
98    fn from(v: Rfc3339Utc) -> Self {
99        v.0
100    }
101}
102
103impl TryFrom<String> for Rfc3339Utc {
104    type Error = DomainError;
105
106    fn try_from(value: String) -> Result<Self, Self::Error> {
107        Self::try_new(value)
108    }
109}
110
111impl TryFrom<&str> for Rfc3339Utc {
112    type Error = DomainError;
113
114    fn try_from(value: &str) -> Result<Self, Self::Error> {
115        Self::try_new(value)
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn parses_rfc3339_and_normalizes_to_utc() {
125        let t = Rfc3339Utc::try_new("2014-11-28T21:00:09+09:00").unwrap();
126        assert_eq!(t.as_datetime().to_rfc3339(), "2014-11-28T12:00:09+00:00");
127    }
128
129    #[test]
130    fn rejects_garbage() {
131        assert!(Rfc3339Utc::try_new("not-a-date").is_err());
132        assert!(Rfc3339Utc::try_new("").is_err());
133        assert!(Rfc3339Utc::try_new("x".repeat(65)).is_err());
134    }
135
136    #[test]
137    fn now_is_utc() {
138        let n = Rfc3339Utc::now();
139        assert!(n.to_rfc3339().contains('T') || n.to_rfc3339().contains('+'));
140    }
141
142    #[test]
143    fn serde_roundtrip() {
144        let t = Rfc3339Utc::try_new("2024-01-02T03:04:05Z").unwrap();
145        let j = serde_json::to_string(&t).unwrap();
146        assert_eq!(j, "\"2024-01-02T03:04:05+00:00\"");
147        let back: Rfc3339Utc = serde_json::from_str(&j).unwrap();
148        assert_eq!(back, t);
149    }
150}