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
81/// Unordered: the wall clock alone would put Tokyo 09:30 after New York 08:00.
82impl Cast for LocalDateTime {
83    const COLUMNS: &'static [ColumnSpec] = &[
84        ColumnSpec::new("", SqlType::DateTime).unordered(),
85        ColumnSpec::new("_zone", SqlType::Text).unordered(),
86    ];
87
88    fn encode(&self, out: &mut Vec<SqlValue>) {
89        out.push(SqlValue::DateTime(self.local));
90        self.zone.encode(out);
91    }
92
93    fn decode(row: &mut dyn ColumnReader) -> Result<Self> {
94        let local = row.date_time()?;
95        Ok(Self::new(local, TimeZone::decode(row)?))
96    }
97}
98
99#[derive(Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101struct LocalDateTimeFields {
102    local: String,
103    zone: TimeZone,
104}
105
106impl Serialize for LocalDateTime {
107    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
108        let fields = LocalDateTimeFields {
109            local: self.local.to_string(),
110            zone: self.zone.clone(),
111        };
112        fields.serialize(serializer)
113    }
114}
115
116impl<'de> Deserialize<'de> for LocalDateTime {
117    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
118        let fields = LocalDateTimeFields::deserialize(deserializer)?;
119        Self::parse(&fields.local, fields.zone).map_err(Error::into_deserialize_error)
120    }
121}
122
123/// Searched by its wall-clock time: an instant may not exist for it.
124impl Present for LocalDateTime {
125    const INPUT: Input = Input::new(Control::Parts(&[
126        Part {
127            name: "local",
128            attributes: &[("type", "datetime-local")],
129            options: &[],
130        },
131        Part {
132            name: "zone",
133            attributes: &[("type", "text")],
134            options: &[],
135        },
136    ]));
137    const SEARCH: crate::Search = crate::Search::Time;
138
139    fn csv(&self) -> String {
140        format!("{} {}", self.local, self.zone)
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::testing::{round_trip, suffixes};
148
149    fn zone(name: &str) -> TimeZone {
150        TimeZone::parse(name).unwrap()
151    }
152
153    #[test]
154    fn no_column_sorts_a_list_since_zones_differ_row_to_row() {
155        assert!(LocalDateTime::COLUMNS.iter().all(|column| !column.orders));
156    }
157
158    fn meeting(local: &str, name: &str) -> LocalDateTime {
159        LocalDateTime::parse(local, zone(name)).unwrap()
160    }
161
162    #[test]
163    fn works_out_its_instant_in_its_zone() {
164        let at = meeting("2026-11-01T09:30", "Europe/Istanbul");
165
166        assert_eq!(at.instant().unwrap().to_string(), "2026-11-01T06:30:00Z");
167        assert_eq!(at.local().to_string(), "2026-11-01T09:30:00");
168        assert_eq!(at.zone().name(), "Europe/Istanbul");
169    }
170
171    #[test]
172    fn places_a_skipped_time_where_the_shift_ends_and_a_repeated_one_first() {
173        let skipped = meeting("2026-03-08T02:30", "America/New_York");
174        let repeated = meeting("2026-11-01T01:30", "America/New_York");
175
176        assert_eq!(
177            skipped.instant().unwrap().to_string(),
178            "2026-03-08T07:00:00Z"
179        );
180        assert_eq!(
181            repeated.instant().unwrap().to_string(),
182            "2026-11-01T05:30:00Z"
183        );
184    }
185
186    #[test]
187    fn refuses_a_time_with_an_offset_or_none_at_all() {
188        for local in [
189            "2026-11-01T09:30:00Z",
190            "2026-11-01T09:30+03:00",
191            "2026-11-01 09:30-05:00",
192            "tomorrow",
193            "",
194        ] {
195            assert!(
196                LocalDateTime::parse(local, zone("UTC")).is_err(),
197                "{local:?}"
198            );
199        }
200    }
201
202    #[test]
203    fn round_trips_through_a_time_and_a_zone_column() {
204        let at = meeting("2026-11-01T09:30", "Europe/Istanbul");
205        assert_eq!(
206            round_trip(&at),
207            vec![
208                SqlValue::DateTime(civil::date(2026, 11, 1).at(9, 30, 0, 0)),
209                SqlValue::Text("Europe/Istanbul".into()),
210            ]
211        );
212        assert_eq!(suffixes::<LocalDateTime>(), ["", "_zone"]);
213    }
214
215    #[test]
216    fn round_trips_through_json() {
217        let at = meeting("2026-11-01T09:30", "Europe/Istanbul");
218        let json = serde_json::to_value(&at).unwrap();
219
220        assert_eq!(
221            json,
222            serde_json::json!({"local": "2026-11-01T09:30:00", "zone": "Europe/Istanbul"})
223        );
224        assert_eq!(serde_json::from_value::<LocalDateTime>(json).unwrap(), at);
225        let unknown_zone = serde_json::json!({"local": "2026-11-01T09:30", "zone": "Mars/Base"});
226        assert!(serde_json::from_value::<LocalDateTime>(unknown_zone).is_err());
227    }
228
229    #[test]
230    fn builds_from_a_civil_time() {
231        let local = civil::date(2026, 11, 1).at(9, 30, 0, 0);
232
233        assert_eq!(
234            LocalDateTime::new(local, zone("Europe/Istanbul")),
235            meeting("2026-11-01T09:30", "Europe/Istanbul")
236        );
237    }
238}