Skip to main content

automapper_validation/eval/
timezone.rs

1//! DST timezone helpers for German MESZ/MEZ validation.
2//!
3//! EU DST rule: MESZ (summer time) starts the last Sunday of March at 01:00 UTC,
4//! and ends the last Sunday of October at 01:00 UTC.
5
6use super::evaluator::ConditionResult;
7
8/// Returns `True` if the given CCYYMMDDHHMM value falls within German summer time (MESZ),
9/// `False` if it falls in winter time (MEZ), or `Unknown` if the input is invalid/too short.
10///
11/// Only the first 12 characters are used, so a 15-char value with timezone suffix also works.
12pub fn is_mesz_utc(dtm_value: &str) -> ConditionResult {
13    let s = dtm_value.trim();
14    if s.len() < 12 {
15        return ConditionResult::Unknown;
16    }
17    let s = &s[..12];
18
19    let year: u32 = match s[0..4].parse() {
20        Ok(v) => v,
21        Err(_) => return ConditionResult::Unknown,
22    };
23    let month: u32 = match s[4..6].parse() {
24        Ok(v) => v,
25        Err(_) => return ConditionResult::Unknown,
26    };
27    let day: u32 = match s[6..8].parse() {
28        Ok(v) => v,
29        Err(_) => return ConditionResult::Unknown,
30    };
31    let hour: u32 = match s[8..10].parse() {
32        Ok(v) => v,
33        Err(_) => return ConditionResult::Unknown,
34    };
35    let minute: u32 = match s[10..12].parse() {
36        Ok(v) => v,
37        Err(_) => return ConditionResult::Unknown,
38    };
39
40    // Basic validation
41    if !(1..=12).contains(&month) || !(1..=31).contains(&day) || hour > 23 || minute > 59 {
42        return ConditionResult::Unknown;
43    }
44
45    // Compute transition dates for this year
46    let march_last_sunday = last_sunday_of_month(year, 3);
47    let october_last_sunday = last_sunday_of_month(year, 10);
48
49    // MESZ transition: last Sunday of March at 01:00 UTC
50    // MEZ transition: last Sunday of October at 01:00 UTC
51    // MESZ is active when: (month, day, hour, minute) >= March transition AND < October transition
52
53    let dt = (month, day, hour, minute);
54    let mesz_start = (3u32, march_last_sunday, 1u32, 0u32);
55    let mesz_end = (10u32, october_last_sunday, 1u32, 0u32);
56
57    let in_mesz = dt >= mesz_start && dt < mesz_end;
58
59    ConditionResult::from(in_mesz)
60}
61
62/// Returns `True` if the given CCYYMMDDHHMM value falls within German winter time (MEZ).
63///
64/// This is the complement of [`is_mesz_utc`].
65pub fn is_mez_utc(dtm_value: &str) -> ConditionResult {
66    match is_mesz_utc(dtm_value) {
67        ConditionResult::True => ConditionResult::False,
68        ConditionResult::False => ConditionResult::True,
69        ConditionResult::Unknown => ConditionResult::Unknown,
70    }
71}
72
73/// Compute the day-of-month of the last Sunday of the given month and year.
74///
75/// Uses Tomohiko Sakamoto's algorithm for day-of-week calculation.
76fn last_sunday_of_month(year: u32, month: u32) -> u32 {
77    let days_in_month = match month {
78        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
79        4 | 6 | 9 | 11 => 30,
80        2 => {
81            if is_leap_year(year) {
82                29
83            } else {
84                28
85            }
86        }
87        _ => unreachable!("invalid month"),
88    };
89
90    // Find the day of the week for the last day of the month (0=Sunday..6=Saturday)
91    let dow = day_of_week(year, month, days_in_month);
92
93    // Subtract to get to Sunday
94    days_in_month - dow
95}
96
97/// Returns the day of the week for a given date (0=Sunday, 1=Monday, ..., 6=Saturday).
98///
99/// Uses Tomohiko Sakamoto's algorithm.
100fn day_of_week(mut year: u32, month: u32, day: u32) -> u32 {
101    const T: [u32; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
102    if month < 3 {
103        year -= 1;
104    }
105    (year + year / 4 - year / 100 + year / 400 + T[(month - 1) as usize] + day) % 7
106}
107
108fn is_leap_year(year: u32) -> bool {
109    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn test_known_mesz_date() {
118        // 2026-07-15 12:00 UTC — clearly summer time
119        assert_eq!(is_mesz_utc("202607151200"), ConditionResult::True);
120    }
121
122    #[test]
123    fn test_known_mez_date() {
124        // 2026-01-15 12:00 UTC — clearly winter time
125        assert_eq!(is_mesz_utc("202601151200"), ConditionResult::False);
126    }
127
128    #[test]
129    fn test_march_transition_2026() {
130        // Last Sunday of March 2026 = March 29
131        // Before 01:00 UTC → still MEZ
132        assert_eq!(is_mesz_utc("202603290059"), ConditionResult::False);
133        // At 01:00 UTC → MESZ starts
134        assert_eq!(is_mesz_utc("202603290100"), ConditionResult::True);
135    }
136
137    #[test]
138    fn test_october_transition_2026() {
139        // Last Sunday of October 2026 = October 25
140        // Before 01:00 UTC → still MESZ
141        assert_eq!(is_mesz_utc("202610250059"), ConditionResult::True);
142        // At 01:00 UTC → MEZ starts
143        assert_eq!(is_mesz_utc("202610250100"), ConditionResult::False);
144    }
145
146    #[test]
147    fn test_short_input_returns_unknown() {
148        assert_eq!(is_mesz_utc("2026"), ConditionResult::Unknown);
149        assert_eq!(is_mesz_utc(""), ConditionResult::Unknown);
150        assert_eq!(is_mesz_utc("20260715"), ConditionResult::Unknown);
151    }
152
153    #[test]
154    fn test_invalid_input_returns_unknown() {
155        assert_eq!(is_mesz_utc("abcdefghijkl"), ConditionResult::Unknown);
156        // Invalid month
157        assert_eq!(is_mesz_utc("202613151200"), ConditionResult::Unknown);
158        // Invalid day
159        assert_eq!(is_mesz_utc("202601321200"), ConditionResult::Unknown);
160    }
161
162    #[test]
163    fn test_is_mez_complements_is_mesz() {
164        // Summer → MESZ=True, MEZ=False
165        assert_eq!(is_mez_utc("202607151200"), ConditionResult::False);
166        // Winter → MESZ=False, MEZ=True
167        assert_eq!(is_mez_utc("202601151200"), ConditionResult::True);
168        // Invalid → both Unknown
169        assert_eq!(is_mez_utc("short"), ConditionResult::Unknown);
170    }
171
172    #[test]
173    fn test_value_with_timezone_suffix() {
174        // 15-char value with suffix — only first 12 used
175        assert_eq!(is_mesz_utc("202607151200UTC"), ConditionResult::True);
176        assert_eq!(is_mesz_utc("202601151200303"), ConditionResult::False);
177    }
178
179    #[test]
180    fn test_last_sunday_of_march_2026() {
181        // March 2026: March 1 is Sunday? Let's verify.
182        // March 31 is Tuesday (dow=2), so last Sunday = 31 - 2 = 29
183        assert_eq!(last_sunday_of_month(2026, 3), 29);
184    }
185
186    #[test]
187    fn test_last_sunday_of_october_2026() {
188        // October 31, 2026 is Saturday (dow=6), so last Sunday = 31 - 6 = 25
189        assert_eq!(last_sunday_of_month(2026, 10), 25);
190    }
191
192    #[test]
193    fn test_different_years() {
194        // 2025: last Sunday of March = March 30, last Sunday of October = October 26
195        assert_eq!(last_sunday_of_month(2025, 3), 30);
196        assert_eq!(last_sunday_of_month(2025, 10), 26);
197
198        // 2024: last Sunday of March = March 31, last Sunday of October = October 27
199        assert_eq!(last_sunday_of_month(2024, 3), 31);
200        assert_eq!(last_sunday_of_month(2024, 10), 27);
201    }
202}