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