Skip to main content

sofie_core/
instant.rs

1use std::convert::Infallible;
2use std::fmt;
3use std::time::Duration;
4
5use jiff::Timestamp;
6use jiff::civil::DateTime;
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8
9use crate::value::Value;
10use crate::{DisplayZone, Error, FieldError, Ordered, Result};
11
12/// Stored and written in UTC whatever offset it was read with.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
14pub struct Instant {
15    timestamp: Timestamp,
16}
17
18impl Instant {
19    /// RFC 3339 with an offset, or a `datetime-local` value without one, read in the display zone.
20    pub fn parse(text: &str) -> Result<Self> {
21        Self::parse_in(text, DisplayZone::current())
22    }
23
24    pub fn parse_in(text: &str, zone: &DisplayZone) -> Result<Self> {
25        let text = text.trim();
26        if Self::is_wall_clock(text)
27            && let Ok(wall_clock) = text.parse::<DateTime>()
28        {
29            return zone.instant_at(wall_clock);
30        }
31        text.parse::<Timestamp>().map(Self::from).map_err(|_| {
32            Error::Invalid(vec![FieldError::new(
33                "instant",
34                "validation.instant.invalid",
35                "must be a time with an offset, such as 2026-10-11T09:30:00Z",
36            )])
37        })
38    }
39
40    /// A `datetime-local` value: minutes, or seconds and a fraction when the control's `step` allows.
41    #[must_use]
42    pub fn is_wall_clock(text: &str) -> bool {
43        let (clock, fraction) = text.split_once('.').unwrap_or((text, ""));
44        let fraction_fits = clock.len() == text.len()
45            || (clock.len() == 19
46                && !fraction.is_empty()
47                && fraction.bytes().all(|byte| byte.is_ascii_digit()));
48        fraction_fits
49            && matches!(
50                (clock.len(), clock.as_bytes().get(10)),
51                (16 | 19, Some(b'T'))
52            )
53            && clock
54                .bytes()
55                .skip(11)
56                .all(|byte| byte.is_ascii_digit() || byte == b':')
57    }
58
59    #[must_use]
60    pub fn timestamp(self) -> Timestamp {
61        self.timestamp
62    }
63
64    pub fn checked_add(self, duration: Duration) -> Result<Self> {
65        self.timestamp
66            .checked_add(duration)
67            .map(Self::from)
68            .map_err(|error| Error::internal("adding to an instant", error))
69    }
70
71    pub fn checked_sub(self, duration: Duration) -> Result<Self> {
72        self.timestamp
73            .checked_sub(duration)
74            .map(Self::from)
75            .map_err(|error| Error::internal("subtracting from an instant", error))
76    }
77}
78
79impl From<Timestamp> for Instant {
80    fn from(timestamp: Timestamp) -> Self {
81        let timestamp = Timestamp::from_microsecond(timestamp.as_microsecond()).expect(
82            "a timestamp truncated to the microseconds both databases store stays in range",
83        );
84        Self { timestamp }
85    }
86}
87
88impl fmt::Display for Instant {
89    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
90        fmt::Display::fmt(&self.timestamp, formatter)
91    }
92}
93
94impl Value for Instant {
95    type Inner = Timestamp;
96
97    fn inner(&self) -> &Timestamp {
98        &self.timestamp
99    }
100
101    fn try_from_inner(timestamp: Timestamp) -> Result<Self, impl fmt::Display> {
102        Ok::<_, Infallible>(Self::from(timestamp))
103    }
104}
105
106impl Ordered for Instant {}
107
108impl Serialize for Instant {
109    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
110        serializer.collect_str(self)
111    }
112}
113
114impl<'de> Deserialize<'de> for Instant {
115    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
116        crate::deserialize_parsed(deserializer, "an RFC 3339 instant", Self::parse)
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::{Cast, RowReader, SqlValue};
124
125    fn instant(text: &str) -> Instant {
126        Instant::parse(text).unwrap()
127    }
128
129    #[test]
130    fn reads_any_offset_and_writes_utc() {
131        assert_eq!(
132            instant("2026-10-11T12:30:00+03:00").to_string(),
133            "2026-10-11T09:30:00Z"
134        );
135        assert_eq!(
136            instant("2026-10-11T09:30:00Z"),
137            instant("2026-10-11T12:30:00+03:00")
138        );
139        assert!(instant("2026-10-11T09:30:00Z") < instant("2026-10-11T09:30:01Z"));
140    }
141
142    #[test]
143    fn reads_a_date_time_control_s_minute_or_second_in_the_display_zone() {
144        let istanbul = DisplayZone::named("Europe/Istanbul").unwrap();
145        let read = |text| Instant::parse_in(text, &istanbul).unwrap();
146
147        assert_eq!(read("2026-10-11T12:30"), instant("2026-10-11T09:30:00Z"));
148        assert_eq!(read("2026-10-11T12:30:15"), instant("2026-10-11T09:30:15Z"));
149        assert_eq!(
150            read("2026-10-11T12:30:15.25"),
151            instant("2026-10-11T09:30:15.25Z")
152        );
153        assert_eq!(
154            read("2026-10-11T12:30:00Z"),
155            instant("2026-10-11T12:30:00Z")
156        );
157        assert_eq!(instant("2026-10-11T09:30"), instant("2026-10-11T09:30:00Z"));
158    }
159
160    #[test]
161    fn reads_a_wall_clock_skipped_by_a_forward_shift_where_the_shift_ends() {
162        let berlin = DisplayZone::named("Europe/Berlin").unwrap();
163
164        let read = Instant::parse_in("2026-03-29T02:30", &berlin).unwrap();
165
166        assert_eq!(read, instant("2026-03-29T01:00:00Z"));
167    }
168
169    #[test]
170    fn refuses_a_repeated_wall_clock_naming_both_offsets() {
171        let berlin = DisplayZone::named("Europe/Berlin").unwrap();
172
173        let error = Instant::parse_in("2026-10-25T02:30", &berlin).unwrap_err();
174
175        let Error::Invalid(errors) = &error else {
176            panic!("{error}");
177        };
178        let message = &errors[0].message;
179        assert!(
180            message.contains("+02:00") && message.contains("+01:00"),
181            "{message}"
182        );
183        assert!(message.contains("2026-10-25T02:30:00+02:00"), "{message}");
184        assert_eq!(
185            Instant::parse_in("2026-10-25T02:30:00+01:00", &berlin).unwrap(),
186            instant("2026-10-25T01:30:00Z")
187        );
188    }
189
190    #[test]
191    fn never_reads_a_short_offset_as_a_wall_clock() {
192        assert_ne!(
193            Instant::parse("2026-10-11T09:30+03").ok(),
194            Some(instant("2026-10-11T09:30:00Z"))
195        );
196    }
197
198    #[test]
199    fn refuses_what_is_neither_an_offset_time_nor_a_date_time_control_s_value() {
200        for text in ["2026-10-11 09:30:00", "2026-10-11", "yesterday", ""] {
201            assert!(Instant::parse(text).is_err(), "{text:?}");
202        }
203    }
204
205    #[test]
206    fn round_trips_through_a_timestamp_column_and_json() {
207        let moment = instant("2026-10-11T09:30:00.5Z");
208        let mut values = Vec::new();
209        moment.encode(&mut values);
210
211        assert_eq!(values, vec![SqlValue::Timestamp(moment.timestamp())]);
212        assert_eq!(
213            Instant::decode(&mut RowReader::new(&values)).unwrap(),
214            moment
215        );
216        let json = serde_json::to_value(moment).unwrap();
217        assert_eq!(json, serde_json::json!("2026-10-11T09:30:00.5Z"));
218        assert_eq!(serde_json::from_value::<Instant>(json).unwrap(), moment);
219        assert!(serde_json::from_value::<Instant>(serde_json::json!(0)).is_err());
220    }
221
222    #[test]
223    fn keeps_the_microseconds_a_database_stores() {
224        assert_eq!(
225            instant("2026-10-11T09:30:00.123456789Z"),
226            instant("2026-10-11T09:30:00.123456Z")
227        );
228        let before_1970 = Timestamp::new(-5, -123_456_789).unwrap();
229        assert_eq!(
230            Instant::from(before_1970).timestamp(),
231            Timestamp::new(-5, -123_456_000).unwrap()
232        );
233    }
234
235    #[test]
236    fn moves_by_a_duration() {
237        let moment = instant("2026-10-11T09:30:00Z");
238
239        assert_eq!(
240            moment.checked_add(Duration::from_secs(60)).unwrap(),
241            instant("2026-10-11T09:31:00Z")
242        );
243        assert_eq!(
244            moment.checked_sub(Duration::from_secs(60)).unwrap(),
245            instant("2026-10-11T09:29:00Z")
246        );
247    }
248}