Skip to main content

sofie_values/time/
local_date_time.rs

1use jiff::civil;
2use serde::{Deserialize, Deserializer, Serialize, Serializer};
3use sofie_core::show::Fields;
4use sofie_core::{Cast, ColumnReader, ColumnSpec, Error, Result, Show, Shown, SqlType, SqlValue};
5
6use crate::{Control, Input, Instant, Part, Present, TimeZone};
7
8/// The instant is worked out when needed, so the time survives a change to the zone's rules.
9#[derive(Clone, Debug, PartialEq, Eq, Hash)]
10pub struct LocalDateTime {
11    local: civil::DateTime,
12    zone: TimeZone,
13}
14
15impl LocalDateTime {
16    #[must_use]
17    pub fn new(local: civil::DateTime, zone: TimeZone) -> Self {
18        Self { local, zone }
19    }
20
21    pub fn parse(local: &str, zone: TimeZone) -> Result<Self> {
22        Ok(Self::new(parse_local(local)?, zone))
23    }
24
25    #[must_use]
26    pub fn local(&self) -> civil::DateTime {
27        self.local
28    }
29
30    #[must_use]
31    pub fn zone(&self) -> &TimeZone {
32        &self.zone
33    }
34
35    pub fn instant(&self) -> Result<Instant> {
36        self.zone.instant_of(self.local)
37    }
38}
39
40fn parse_local(local: &str) -> Result<civil::DateTime> {
41    let refusal = || {
42        Error::invalid(
43            "local",
44            "must be a date and time without an offset, such as 2026-11-01T09:30",
45        )
46    };
47    if names_an_offset(local) {
48        return Err(refusal());
49    }
50    local.parse().map_err(|_| refusal())
51}
52
53/// The parser would ignore an offset, silently changing what the text means.
54fn names_an_offset(local: &str) -> bool {
55    local
56        .split_once(['T', 't', ' '])
57        .is_some_and(|(_, time)| time.contains(['+', '-', 'Z', 'z', '[']))
58}
59
60/// ISO 8601 text, which sorts as it reads: the value model has no zone-less time type.
61impl Show for LocalDateTime {
62    fn show(&self) -> Shown<'_> {
63        Shown::Record(self)
64    }
65}
66
67impl Fields for LocalDateTime {
68    fn name(&self, index: usize) -> Option<&str> {
69        ["local", "zone"].get(index).copied()
70    }
71
72    fn get(&self, name: &str) -> Option<Shown<'_>> {
73        match name {
74            "local" => Some(Shown::Formatted(&self.local)),
75            "zone" => Some(self.zone.show()),
76            _ => None,
77        }
78    }
79}
80
81impl Cast for LocalDateTime {
82    const COLUMNS: &'static [ColumnSpec] = &[
83        ColumnSpec::new("", SqlType::DateTime),
84        ColumnSpec::new("_zone", SqlType::Text).unordered(),
85    ];
86
87    fn encode(&self, out: &mut Vec<SqlValue>) {
88        out.push(SqlValue::DateTime(self.local));
89        self.zone.encode(out);
90    }
91
92    fn decode(row: &mut dyn ColumnReader) -> Result<Self> {
93        let local = row.date_time()?;
94        Ok(Self::new(local, TimeZone::decode(row)?))
95    }
96}
97
98#[derive(Serialize, Deserialize)]
99#[serde(deny_unknown_fields)]
100struct LocalDateTimeFields {
101    local: String,
102    zone: TimeZone,
103}
104
105impl Serialize for LocalDateTime {
106    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
107        let fields = LocalDateTimeFields {
108            local: self.local.to_string(),
109            zone: self.zone.clone(),
110        };
111        fields.serialize(serializer)
112    }
113}
114
115impl<'de> Deserialize<'de> for LocalDateTime {
116    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
117        let fields = LocalDateTimeFields::deserialize(deserializer)?;
118        Self::parse(&fields.local, fields.zone).map_err(Error::into_deserialize_error)
119    }
120}
121
122/// Sorted by its wall-clock time: an instant may not exist for it.
123impl Present for LocalDateTime {
124    const INPUT: Input = Input::new(Control::Parts(&[
125        Part {
126            name: "local",
127            attributes: &[("type", "datetime-local")],
128            options: &[],
129        },
130        Part {
131            name: "zone",
132            attributes: &[("type", "text")],
133            options: &[],
134        },
135    ]));
136    const SEARCH: crate::Search = crate::Search::Time;
137
138    fn csv(&self) -> String {
139        format!("{} {}", self.local, self.zone)
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::testing::{round_trip, suffixes};
147
148    fn zone(name: &str) -> TimeZone {
149        TimeZone::parse(name).unwrap()
150    }
151
152    fn meeting(local: &str, name: &str) -> LocalDateTime {
153        LocalDateTime::parse(local, zone(name)).unwrap()
154    }
155
156    #[test]
157    fn works_out_its_instant_in_its_zone() {
158        let at = meeting("2026-11-01T09:30", "Europe/Istanbul");
159
160        assert_eq!(at.instant().unwrap().to_string(), "2026-11-01T06:30:00Z");
161        assert_eq!(at.local().to_string(), "2026-11-01T09:30:00");
162        assert_eq!(at.zone().name(), "Europe/Istanbul");
163    }
164
165    #[test]
166    fn places_a_skipped_time_where_the_shift_ends_and_a_repeated_one_first() {
167        let skipped = meeting("2026-03-08T02:30", "America/New_York");
168        let repeated = meeting("2026-11-01T01:30", "America/New_York");
169
170        assert_eq!(
171            skipped.instant().unwrap().to_string(),
172            "2026-03-08T07:00:00Z"
173        );
174        assert_eq!(
175            repeated.instant().unwrap().to_string(),
176            "2026-11-01T05:30:00Z"
177        );
178    }
179
180    #[test]
181    fn refuses_a_time_with_an_offset_or_none_at_all() {
182        for local in [
183            "2026-11-01T09:30:00Z",
184            "2026-11-01T09:30+03:00",
185            "2026-11-01 09:30-05:00",
186            "tomorrow",
187            "",
188        ] {
189            assert!(
190                LocalDateTime::parse(local, zone("UTC")).is_err(),
191                "{local:?}"
192            );
193        }
194    }
195
196    #[test]
197    fn round_trips_through_a_time_and_a_zone_column() {
198        let at = meeting("2026-11-01T09:30", "Europe/Istanbul");
199        assert_eq!(
200            round_trip(&at),
201            vec![
202                SqlValue::DateTime(civil::date(2026, 11, 1).at(9, 30, 0, 0)),
203                SqlValue::Text("Europe/Istanbul".into()),
204            ]
205        );
206        assert_eq!(suffixes::<LocalDateTime>(), ["", "_zone"]);
207    }
208
209    #[test]
210    fn round_trips_through_json() {
211        let at = meeting("2026-11-01T09:30", "Europe/Istanbul");
212        let json = serde_json::to_value(&at).unwrap();
213
214        assert_eq!(
215            json,
216            serde_json::json!({"local": "2026-11-01T09:30:00", "zone": "Europe/Istanbul"})
217        );
218        assert_eq!(serde_json::from_value::<LocalDateTime>(json).unwrap(), at);
219        let unknown_zone = serde_json::json!({"local": "2026-11-01T09:30", "zone": "Mars/Base"});
220        assert!(serde_json::from_value::<LocalDateTime>(unknown_zone).is_err());
221    }
222
223    #[test]
224    fn builds_from_a_civil_time() {
225        let local = civil::date(2026, 11, 1).at(9, 30, 0, 0);
226
227        assert_eq!(
228            LocalDateTime::new(local, zone("Europe/Istanbul")),
229            meeting("2026-11-01T09:30", "Europe/Istanbul")
230        );
231    }
232}