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