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