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        let text = String::deserialize(deserializer)?;
117        Self::parse(&text).map_err(Error::into_deserialize_error)
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::{Cast, RowReader, SqlValue};
125
126    fn instant(text: &str) -> Instant {
127        Instant::parse(text).unwrap()
128    }
129
130    #[test]
131    fn reads_any_offset_and_writes_utc() {
132        assert_eq!(
133            instant("2026-10-11T12:30:00+03:00").to_string(),
134            "2026-10-11T09:30:00Z"
135        );
136        assert_eq!(
137            instant("2026-10-11T09:30:00Z"),
138            instant("2026-10-11T12:30:00+03:00")
139        );
140        assert!(instant("2026-10-11T09:30:00Z") < instant("2026-10-11T09:30:01Z"));
141    }
142
143    #[test]
144    fn reads_a_date_time_control_s_minute_or_second_in_the_display_zone() {
145        let istanbul = DisplayZone::named("Europe/Istanbul").unwrap();
146        let read = |text| Instant::parse_in(text, &istanbul).unwrap();
147
148        assert_eq!(read("2026-10-11T12:30"), instant("2026-10-11T09:30:00Z"));
149        assert_eq!(read("2026-10-11T12:30:15"), instant("2026-10-11T09:30:15Z"));
150        assert_eq!(
151            read("2026-10-11T12:30:15.25"),
152            instant("2026-10-11T09:30:15.25Z")
153        );
154        assert_eq!(
155            read("2026-10-11T12:30:00Z"),
156            instant("2026-10-11T12:30:00Z")
157        );
158        assert_eq!(instant("2026-10-11T09:30"), instant("2026-10-11T09:30:00Z"));
159    }
160
161    #[test]
162    fn reads_a_wall_clock_skipped_by_a_forward_shift_where_the_shift_ends() {
163        let berlin = DisplayZone::named("Europe/Berlin").unwrap();
164
165        let read = Instant::parse_in("2026-03-29T02:30", &berlin).unwrap();
166
167        assert_eq!(read, instant("2026-03-29T01:00:00Z"));
168    }
169
170    #[test]
171    fn refuses_a_repeated_wall_clock_naming_both_offsets() {
172        let berlin = DisplayZone::named("Europe/Berlin").unwrap();
173
174        let error = Instant::parse_in("2026-10-25T02:30", &berlin).unwrap_err();
175
176        let Error::Invalid(errors) = &error else {
177            panic!("{error}");
178        };
179        let message = &errors[0].message;
180        assert!(
181            message.contains("+02:00") && message.contains("+01:00"),
182            "{message}"
183        );
184        assert!(message.contains("2026-10-25T02:30:00+02:00"), "{message}");
185        assert_eq!(
186            Instant::parse_in("2026-10-25T02:30:00+01:00", &berlin).unwrap(),
187            instant("2026-10-25T01:30:00Z")
188        );
189    }
190
191    #[test]
192    fn never_reads_a_short_offset_as_a_wall_clock() {
193        assert_ne!(
194            Instant::parse("2026-10-11T09:30+03").ok(),
195            Some(instant("2026-10-11T09:30:00Z"))
196        );
197    }
198
199    #[test]
200    fn refuses_what_is_neither_an_offset_time_nor_a_date_time_control_s_value() {
201        for text in ["2026-10-11 09:30:00", "2026-10-11", "yesterday", ""] {
202            assert!(Instant::parse(text).is_err(), "{text:?}");
203        }
204    }
205
206    #[test]
207    fn round_trips_through_a_timestamp_column_and_json() {
208        let moment = instant("2026-10-11T09:30:00.5Z");
209        let mut values = Vec::new();
210        moment.encode(&mut values);
211
212        assert_eq!(values, vec![SqlValue::Timestamp(moment.timestamp())]);
213        assert_eq!(
214            Instant::decode(&mut RowReader::new(&values)).unwrap(),
215            moment
216        );
217        let json = serde_json::to_value(moment).unwrap();
218        assert_eq!(json, serde_json::json!("2026-10-11T09:30:00.5Z"));
219        assert_eq!(serde_json::from_value::<Instant>(json).unwrap(), moment);
220        assert!(serde_json::from_value::<Instant>(serde_json::json!(0)).is_err());
221    }
222
223    #[test]
224    fn keeps_the_microseconds_a_database_stores() {
225        assert_eq!(
226            instant("2026-10-11T09:30:00.123456789Z"),
227            instant("2026-10-11T09:30:00.123456Z")
228        );
229        let before_1970 = Timestamp::new(-5, -123_456_789).unwrap();
230        assert_eq!(
231            Instant::from(before_1970).timestamp(),
232            Timestamp::new(-5, -123_456_000).unwrap()
233        );
234    }
235
236    #[test]
237    fn moves_by_a_duration() {
238        let moment = instant("2026-10-11T09:30:00Z");
239
240        assert_eq!(
241            moment.checked_add(Duration::from_secs(60)).unwrap(),
242            instant("2026-10-11T09:31:00Z")
243        );
244        assert_eq!(
245            moment.checked_sub(Duration::from_secs(60)).unwrap(),
246            instant("2026-10-11T09:29:00Z")
247        );
248    }
249}