automapper_validation/eval/
timezone.rs1use chrono::{Datelike, NaiveDate};
7
8use super::evaluator::ConditionResult;
9
10pub fn parse_dtm303(value: &str) -> Option<i64> {
16 let s = value.trim();
17 if s.len() < 12 {
18 return None;
19 }
20 let year: i32 = s.get(0..4).and_then(|v| v.parse().ok())?;
21 let month: u32 = s.get(4..6).and_then(|v| v.parse().ok())?;
22 let day: u32 = s.get(6..8).and_then(|v| v.parse().ok())?;
23 let hour: u32 = s.get(8..10).and_then(|v| v.parse().ok())?;
24 let minute: u32 = s.get(10..12).and_then(|v| v.parse().ok())?;
25 let dt = NaiveDate::from_ymd_opt(year, month, day)?.and_hms_opt(hour, minute, 0)?;
26 Some(dt.and_utc().timestamp() / 60)
27}
28
29pub fn dtm_start_utc_min(value: &str, format: &str) -> Option<i64> {
35 match format {
36 "303" => parse_dtm303(value),
37 "102" => {
38 let s = value.trim();
39 if s.len() != 8 {
40 return None;
41 }
42 let utc_midnight = parse_dtm303(&format!("{s}0000"))?;
43 let mesz_midnight = utc_midnight - 120;
46 let mesz = chrono::DateTime::from_timestamp(mesz_midnight * 60, 0)?
47 .format("%Y%m%d%H%M")
48 .to_string();
49 match is_mesz_utc(&mesz) {
50 ConditionResult::True => Some(mesz_midnight),
51 ConditionResult::False => Some(utc_midnight - 60),
52 ConditionResult::Unknown => None,
53 }
54 }
55 _ => None,
56 }
57}
58
59pub fn dtm_after(value: &str, format: &str, reference_303: &str) -> ConditionResult {
67 if !matches!(format, "303" | "102") {
68 return ConditionResult::Unknown;
69 }
70 let Some(reference) = parse_dtm303(reference_303) else {
71 return ConditionResult::Unknown;
72 };
73 match dtm_start_utc_min(value, format) {
74 Some(v) => ConditionResult::from(v > reference),
75 None => ConditionResult::False,
76 }
77}
78
79pub fn dst_transitions_between(a_utc_min: i64, b_utc_min: i64) -> i32 {
91 let (lo, hi) = if a_utc_min <= b_utc_min {
92 (a_utc_min, b_utc_min)
93 } else {
94 (b_utc_min, a_utc_min)
95 };
96 if lo == hi {
97 return 0;
98 }
99 let lo_year = year_of_epoch_min(lo);
100 let hi_year = year_of_epoch_min(hi);
101 let mut delta: i32 = 0;
102 for y in lo_year..=hi_year {
103 let spring_forward = dst_instant_min(y, 3);
104 if lo <= spring_forward && spring_forward < hi {
105 delta += 60;
106 }
107 let fall_back = dst_instant_min(y, 10);
108 if lo <= fall_back && fall_back < hi {
109 delta -= 60;
110 }
111 }
112 delta
113}
114
115fn dst_instant_min(year: i32, month: u32) -> i64 {
116 let date = last_sunday_of_month(year, month);
117 date.and_hms_opt(1, 0, 0).unwrap().and_utc().timestamp() / 60
118}
119
120fn year_of_epoch_min(epoch_min: i64) -> i32 {
121 chrono::DateTime::from_timestamp(epoch_min * 60, 0)
122 .map(|dt| dt.date_naive().year())
123 .unwrap_or(1970)
124}
125
126pub fn is_mesz_utc(dtm_value: &str) -> ConditionResult {
131 let s = dtm_value.trim();
132 if s.len() < 8 {
133 return ConditionResult::Unknown;
134 }
135
136 let year: i32 = match s[0..4].parse() {
137 Ok(v) => v,
138 Err(_) => return ConditionResult::Unknown,
139 };
140 let month: u32 = match s[4..6].parse() {
141 Ok(v) => v,
142 Err(_) => return ConditionResult::Unknown,
143 };
144 let day: u32 = match s[6..8].parse() {
145 Ok(v) => v,
146 Err(_) => return ConditionResult::Unknown,
147 };
148 let hour: u32 = if s.len() >= 10 {
149 match s[8..10].parse() {
150 Ok(v) => v,
151 Err(_) => return ConditionResult::Unknown,
152 }
153 } else {
154 0
155 };
156 let minute: u32 = if s.len() >= 12 {
157 match s[10..12].parse() {
158 Ok(v) => v,
159 Err(_) => return ConditionResult::Unknown,
160 }
161 } else {
162 0
163 };
164
165 let Some(dt) =
166 NaiveDate::from_ymd_opt(year, month, day).and_then(|d| d.and_hms_opt(hour, minute, 0))
167 else {
168 return ConditionResult::Unknown;
169 };
170
171 let mesz_start = last_sunday_of_month(year, 3).and_hms_opt(1, 0, 0).unwrap();
172 let mesz_end = last_sunday_of_month(year, 10).and_hms_opt(1, 0, 0).unwrap();
173
174 ConditionResult::from(dt >= mesz_start && dt < mesz_end)
175}
176
177pub fn is_mez_utc(dtm_value: &str) -> ConditionResult {
181 match is_mesz_utc(dtm_value) {
182 ConditionResult::True => ConditionResult::False,
183 ConditionResult::False => ConditionResult::True,
184 ConditionResult::Unknown => ConditionResult::Unknown,
185 }
186}
187
188fn last_sunday_of_month(year: i32, month: u32) -> NaiveDate {
190 let last_day = NaiveDate::from_ymd_opt(year, month + 1, 1)
192 .unwrap_or_else(|| NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap())
193 .pred_opt()
194 .unwrap();
195 let days_since_sunday = last_day.weekday().num_days_from_sunday();
196 last_day - chrono::Duration::days(days_since_sunday as i64)
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 #[test]
204 fn test_known_mesz_date() {
205 assert_eq!(is_mesz_utc("202607151200"), ConditionResult::True);
206 }
207
208 #[test]
209 fn test_known_mez_date() {
210 assert_eq!(is_mesz_utc("202601151200"), ConditionResult::False);
211 }
212
213 #[test]
214 fn test_march_transition_2026() {
215 assert_eq!(is_mesz_utc("202603290059"), ConditionResult::False);
217 assert_eq!(is_mesz_utc("202603290100"), ConditionResult::True);
218 }
219
220 #[test]
221 fn test_october_transition_2026() {
222 assert_eq!(is_mesz_utc("202610250059"), ConditionResult::True);
224 assert_eq!(is_mesz_utc("202610250100"), ConditionResult::False);
225 }
226
227 #[test]
228 fn test_short_input() {
229 assert_eq!(is_mesz_utc("2026"), ConditionResult::Unknown);
230 assert_eq!(is_mesz_utc(""), ConditionResult::Unknown);
231 assert_eq!(is_mesz_utc("20260715"), ConditionResult::True);
232 assert_eq!(is_mesz_utc("20260115"), ConditionResult::False);
233 }
234
235 #[test]
236 fn test_invalid_input_returns_unknown() {
237 assert_eq!(is_mesz_utc("abcdefghijkl"), ConditionResult::Unknown);
238 assert_eq!(is_mesz_utc("202613151200"), ConditionResult::Unknown);
239 assert_eq!(is_mesz_utc("202601321200"), ConditionResult::Unknown);
240 }
241
242 #[test]
243 fn test_is_mez_complements_is_mesz() {
244 assert_eq!(is_mez_utc("202607151200"), ConditionResult::False);
245 assert_eq!(is_mez_utc("202601151200"), ConditionResult::True);
246 assert_eq!(is_mez_utc("short"), ConditionResult::Unknown);
247 }
248
249 #[test]
250 fn test_value_with_timezone_suffix() {
251 assert_eq!(is_mesz_utc("202607151200UTC"), ConditionResult::True);
252 assert_eq!(is_mesz_utc("202601151200303"), ConditionResult::False);
253 }
254
255 #[test]
256 fn test_last_sunday_of_march_2026() {
257 assert_eq!(
258 last_sunday_of_month(2026, 3),
259 NaiveDate::from_ymd_opt(2026, 3, 29).unwrap()
260 );
261 }
262
263 #[test]
264 fn test_last_sunday_of_october_2026() {
265 assert_eq!(
266 last_sunday_of_month(2026, 10),
267 NaiveDate::from_ymd_opt(2026, 10, 25).unwrap()
268 );
269 }
270
271 #[test]
272 fn test_parse_dtm303_valid() {
273 let m = parse_dtm303("202601151200").unwrap();
275 let expected = NaiveDate::from_ymd_opt(2026, 1, 15)
277 .unwrap()
278 .and_hms_opt(12, 0, 0)
279 .unwrap()
280 .and_utc()
281 .timestamp()
282 / 60;
283 assert_eq!(m, expected);
284 }
285
286 #[test]
287 fn test_parse_dtm303_ignores_tz_suffix() {
288 let a = parse_dtm303("202603290100").unwrap();
289 let b = parse_dtm303("202603290100+00").unwrap();
290 let c = parse_dtm303("202603290100UTC").unwrap();
291 assert_eq!(a, b);
292 assert_eq!(a, c);
293 }
294
295 #[test]
296 fn test_parse_dtm303_rejects_short() {
297 assert!(parse_dtm303("20260115").is_none());
298 assert!(parse_dtm303("").is_none());
299 }
300
301 #[test]
302 fn test_parse_dtm303_rejects_invalid() {
303 assert!(parse_dtm303("2026ZZ151200").is_none());
304 assert!(parse_dtm303("202613151200").is_none());
305 }
306
307 #[test]
308 fn test_dst_no_transition_same_day() {
309 let a = parse_dtm303("202601150800").unwrap();
310 let b = parse_dtm303("202601151200").unwrap();
311 assert_eq!(dst_transitions_between(a, b), 0);
312 }
313
314 #[test]
315 fn test_dst_spring_forward_2026() {
316 let before = parse_dtm303("202603290030").unwrap();
318 let after = parse_dtm303("202603290130").unwrap();
319 assert_eq!(dst_transitions_between(before, after), 60);
320 assert_eq!(dst_transitions_between(after, before), 60);
322 }
323
324 #[test]
325 fn test_dst_fall_back_2026() {
326 let before = parse_dtm303("202610250030").unwrap();
328 let after = parse_dtm303("202610250130").unwrap();
329 assert_eq!(dst_transitions_between(before, after), -60);
330 assert_eq!(dst_transitions_between(after, before), -60);
331 }
332
333 #[test]
334 fn test_dst_transition_at_exact_boundary() {
335 let at_boundary = parse_dtm303("202603290100").unwrap();
337 assert_eq!(dst_transitions_between(at_boundary, at_boundary), 0);
338 let after = parse_dtm303("202603290200").unwrap();
340 assert_eq!(dst_transitions_between(at_boundary, after), 60);
341 }
342
343 #[test]
344 fn test_dst_both_transitions_cancel() {
345 let winter_a = parse_dtm303("202601010000").unwrap();
347 let winter_b = parse_dtm303("202612310000").unwrap();
348 assert_eq!(dst_transitions_between(winter_a, winter_b), 0);
349 }
350
351 #[test]
352 fn test_different_years() {
353 assert_eq!(
354 last_sunday_of_month(2025, 3),
355 NaiveDate::from_ymd_opt(2025, 3, 30).unwrap()
356 );
357 assert_eq!(
358 last_sunday_of_month(2025, 10),
359 NaiveDate::from_ymd_opt(2025, 10, 26).unwrap()
360 );
361 assert_eq!(
362 last_sunday_of_month(2024, 3),
363 NaiveDate::from_ymd_opt(2024, 3, 31).unwrap()
364 );
365 assert_eq!(
366 last_sunday_of_month(2024, 10),
367 NaiveDate::from_ymd_opt(2024, 10, 27).unwrap()
368 );
369 }
370}