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#[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
53fn names_an_offset(local: &str) -> bool {
55 local
56 .split_once(['T', 't', ' '])
57 .is_some_and(|(_, time)| time.contains(['+', '-', 'Z', 'z', '[']))
58}
59
60impl 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
122impl 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}