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 dst_transitions_between(a_utc_min: i64, b_utc_min: i64) -> i32 {
41 let (lo, hi) = if a_utc_min <= b_utc_min {
42 (a_utc_min, b_utc_min)
43 } else {
44 (b_utc_min, a_utc_min)
45 };
46 if lo == hi {
47 return 0;
48 }
49 let lo_year = year_of_epoch_min(lo);
50 let hi_year = year_of_epoch_min(hi);
51 let mut delta: i32 = 0;
52 for y in lo_year..=hi_year {
53 let spring_forward = dst_instant_min(y, 3);
54 if lo <= spring_forward && spring_forward < hi {
55 delta += 60;
56 }
57 let fall_back = dst_instant_min(y, 10);
58 if lo <= fall_back && fall_back < hi {
59 delta -= 60;
60 }
61 }
62 delta
63}
64
65fn dst_instant_min(year: i32, month: u32) -> i64 {
66 let date = last_sunday_of_month(year, month);
67 date.and_hms_opt(1, 0, 0).unwrap().and_utc().timestamp() / 60
68}
69
70fn year_of_epoch_min(epoch_min: i64) -> i32 {
71 chrono::DateTime::from_timestamp(epoch_min * 60, 0)
72 .map(|dt| dt.date_naive().year())
73 .unwrap_or(1970)
74}
75
76pub fn is_mesz_utc(dtm_value: &str) -> ConditionResult {
81 let s = dtm_value.trim();
82 if s.len() < 8 {
83 return ConditionResult::Unknown;
84 }
85
86 let year: i32 = match s[0..4].parse() {
87 Ok(v) => v,
88 Err(_) => return ConditionResult::Unknown,
89 };
90 let month: u32 = match s[4..6].parse() {
91 Ok(v) => v,
92 Err(_) => return ConditionResult::Unknown,
93 };
94 let day: u32 = match s[6..8].parse() {
95 Ok(v) => v,
96 Err(_) => return ConditionResult::Unknown,
97 };
98 let hour: u32 = if s.len() >= 10 {
99 match s[8..10].parse() {
100 Ok(v) => v,
101 Err(_) => return ConditionResult::Unknown,
102 }
103 } else {
104 0
105 };
106 let minute: u32 = if s.len() >= 12 {
107 match s[10..12].parse() {
108 Ok(v) => v,
109 Err(_) => return ConditionResult::Unknown,
110 }
111 } else {
112 0
113 };
114
115 let Some(dt) =
116 NaiveDate::from_ymd_opt(year, month, day).and_then(|d| d.and_hms_opt(hour, minute, 0))
117 else {
118 return ConditionResult::Unknown;
119 };
120
121 let mesz_start = last_sunday_of_month(year, 3).and_hms_opt(1, 0, 0).unwrap();
122 let mesz_end = last_sunday_of_month(year, 10).and_hms_opt(1, 0, 0).unwrap();
123
124 ConditionResult::from(dt >= mesz_start && dt < mesz_end)
125}
126
127pub fn is_mez_utc(dtm_value: &str) -> ConditionResult {
131 match is_mesz_utc(dtm_value) {
132 ConditionResult::True => ConditionResult::False,
133 ConditionResult::False => ConditionResult::True,
134 ConditionResult::Unknown => ConditionResult::Unknown,
135 }
136}
137
138fn last_sunday_of_month(year: i32, month: u32) -> NaiveDate {
140 let last_day = NaiveDate::from_ymd_opt(year, month + 1, 1)
142 .unwrap_or_else(|| NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap())
143 .pred_opt()
144 .unwrap();
145 let days_since_sunday = last_day.weekday().num_days_from_sunday();
146 last_day - chrono::Duration::days(days_since_sunday as i64)
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
154 fn test_known_mesz_date() {
155 assert_eq!(is_mesz_utc("202607151200"), ConditionResult::True);
156 }
157
158 #[test]
159 fn test_known_mez_date() {
160 assert_eq!(is_mesz_utc("202601151200"), ConditionResult::False);
161 }
162
163 #[test]
164 fn test_march_transition_2026() {
165 assert_eq!(is_mesz_utc("202603290059"), ConditionResult::False);
167 assert_eq!(is_mesz_utc("202603290100"), ConditionResult::True);
168 }
169
170 #[test]
171 fn test_october_transition_2026() {
172 assert_eq!(is_mesz_utc("202610250059"), ConditionResult::True);
174 assert_eq!(is_mesz_utc("202610250100"), ConditionResult::False);
175 }
176
177 #[test]
178 fn test_short_input() {
179 assert_eq!(is_mesz_utc("2026"), ConditionResult::Unknown);
180 assert_eq!(is_mesz_utc(""), ConditionResult::Unknown);
181 assert_eq!(is_mesz_utc("20260715"), ConditionResult::True);
182 assert_eq!(is_mesz_utc("20260115"), ConditionResult::False);
183 }
184
185 #[test]
186 fn test_invalid_input_returns_unknown() {
187 assert_eq!(is_mesz_utc("abcdefghijkl"), ConditionResult::Unknown);
188 assert_eq!(is_mesz_utc("202613151200"), ConditionResult::Unknown);
189 assert_eq!(is_mesz_utc("202601321200"), ConditionResult::Unknown);
190 }
191
192 #[test]
193 fn test_is_mez_complements_is_mesz() {
194 assert_eq!(is_mez_utc("202607151200"), ConditionResult::False);
195 assert_eq!(is_mez_utc("202601151200"), ConditionResult::True);
196 assert_eq!(is_mez_utc("short"), ConditionResult::Unknown);
197 }
198
199 #[test]
200 fn test_value_with_timezone_suffix() {
201 assert_eq!(is_mesz_utc("202607151200UTC"), ConditionResult::True);
202 assert_eq!(is_mesz_utc("202601151200303"), ConditionResult::False);
203 }
204
205 #[test]
206 fn test_last_sunday_of_march_2026() {
207 assert_eq!(
208 last_sunday_of_month(2026, 3),
209 NaiveDate::from_ymd_opt(2026, 3, 29).unwrap()
210 );
211 }
212
213 #[test]
214 fn test_last_sunday_of_october_2026() {
215 assert_eq!(
216 last_sunday_of_month(2026, 10),
217 NaiveDate::from_ymd_opt(2026, 10, 25).unwrap()
218 );
219 }
220
221 #[test]
222 fn test_parse_dtm303_valid() {
223 let m = parse_dtm303("202601151200").unwrap();
225 let expected = NaiveDate::from_ymd_opt(2026, 1, 15)
227 .unwrap()
228 .and_hms_opt(12, 0, 0)
229 .unwrap()
230 .and_utc()
231 .timestamp()
232 / 60;
233 assert_eq!(m, expected);
234 }
235
236 #[test]
237 fn test_parse_dtm303_ignores_tz_suffix() {
238 let a = parse_dtm303("202603290100").unwrap();
239 let b = parse_dtm303("202603290100+00").unwrap();
240 let c = parse_dtm303("202603290100UTC").unwrap();
241 assert_eq!(a, b);
242 assert_eq!(a, c);
243 }
244
245 #[test]
246 fn test_parse_dtm303_rejects_short() {
247 assert!(parse_dtm303("20260115").is_none());
248 assert!(parse_dtm303("").is_none());
249 }
250
251 #[test]
252 fn test_parse_dtm303_rejects_invalid() {
253 assert!(parse_dtm303("2026ZZ151200").is_none());
254 assert!(parse_dtm303("202613151200").is_none());
255 }
256
257 #[test]
258 fn test_dst_no_transition_same_day() {
259 let a = parse_dtm303("202601150800").unwrap();
260 let b = parse_dtm303("202601151200").unwrap();
261 assert_eq!(dst_transitions_between(a, b), 0);
262 }
263
264 #[test]
265 fn test_dst_spring_forward_2026() {
266 let before = parse_dtm303("202603290030").unwrap();
268 let after = parse_dtm303("202603290130").unwrap();
269 assert_eq!(dst_transitions_between(before, after), 60);
270 assert_eq!(dst_transitions_between(after, before), 60);
272 }
273
274 #[test]
275 fn test_dst_fall_back_2026() {
276 let before = parse_dtm303("202610250030").unwrap();
278 let after = parse_dtm303("202610250130").unwrap();
279 assert_eq!(dst_transitions_between(before, after), -60);
280 assert_eq!(dst_transitions_between(after, before), -60);
281 }
282
283 #[test]
284 fn test_dst_transition_at_exact_boundary() {
285 let at_boundary = parse_dtm303("202603290100").unwrap();
287 assert_eq!(dst_transitions_between(at_boundary, at_boundary), 0);
288 let after = parse_dtm303("202603290200").unwrap();
290 assert_eq!(dst_transitions_between(at_boundary, after), 60);
291 }
292
293 #[test]
294 fn test_dst_both_transitions_cancel() {
295 let winter_a = parse_dtm303("202601010000").unwrap();
297 let winter_b = parse_dtm303("202612310000").unwrap();
298 assert_eq!(dst_transitions_between(winter_a, winter_b), 0);
299 }
300
301 #[test]
302 fn test_different_years() {
303 assert_eq!(
304 last_sunday_of_month(2025, 3),
305 NaiveDate::from_ymd_opt(2025, 3, 30).unwrap()
306 );
307 assert_eq!(
308 last_sunday_of_month(2025, 10),
309 NaiveDate::from_ymd_opt(2025, 10, 26).unwrap()
310 );
311 assert_eq!(
312 last_sunday_of_month(2024, 3),
313 NaiveDate::from_ymd_opt(2024, 3, 31).unwrap()
314 );
315 assert_eq!(
316 last_sunday_of_month(2024, 10),
317 NaiveDate::from_ymd_opt(2024, 10, 27).unwrap()
318 );
319 }
320}