Skip to main content

rustrails_support/
time_zone.rs

1use chrono::{DateTime, LocalResult, NaiveDateTime, Offset, TimeZone as _, Utc};
2use chrono_tz::Tz;
3
4/// Errors returned by timezone lookup and parsing operations.
5#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
6pub enum TimeZoneError {
7    /// The requested timezone or alias was not recognized.
8    #[error("unknown timezone: {0}")]
9    Unknown(String),
10    /// The requested local time could not be resolved to a single instant.
11    #[error("ambiguous time")]
12    Ambiguous,
13}
14
15/// A timezone wrapper with a Rails-like API.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct TimeZone {
18    tz: Tz,
19    name: String,
20}
21
22impl TimeZone {
23    /// Creates a timezone from an IANA identifier or supported Rails alias.
24    pub fn new(name: &str) -> Result<Self, TimeZoneError> {
25        find_timezone(name)
26    }
27
28    /// Finds a timezone from an IANA identifier or supported Rails alias.
29    pub fn find(name: &str) -> Result<Self, TimeZoneError> {
30        find_timezone(name)
31    }
32
33    /// Returns the display name used to construct this timezone.
34    #[must_use]
35    pub fn name(&self) -> &str {
36        &self.name
37    }
38
39    /// Returns the current time in this timezone.
40    #[must_use]
41    pub fn now(&self) -> DateTime<Tz> {
42        let now = crate::testing::frozen_now().unwrap_or_else(Utc::now);
43        self.convert(now)
44    }
45
46    /// Returns the current UTC offset for this timezone in seconds.
47    #[must_use]
48    pub fn utc_offset(&self) -> i32 {
49        self.now().offset().fix().local_minus_utc()
50    }
51
52    /// Converts a UTC timestamp into this timezone.
53    #[must_use]
54    pub fn convert(&self, time: DateTime<Utc>) -> DateTime<Tz> {
55        time.with_timezone(&self.tz)
56    }
57
58    /// Parses a local time string in this timezone using the provided format string.
59    pub fn parse(&self, s: &str, fmt: &str) -> Result<DateTime<Tz>, TimeZoneError> {
60        let naive = NaiveDateTime::parse_from_str(s, fmt)
61            .map_err(|_| TimeZoneError::Unknown(s.to_owned()))?;
62
63        match self.tz.from_local_datetime(&naive) {
64            LocalResult::Single(datetime) => Ok(datetime),
65            LocalResult::Ambiguous(_, _) | LocalResult::None => Err(TimeZoneError::Ambiguous),
66        }
67    }
68}
69
70/// Finds a timezone from a supported Rails alias or an IANA timezone name.
71pub fn find_timezone(name: &str) -> Result<TimeZone, TimeZoneError> {
72    let canonical = match name {
73        "Eastern Time (US & Canada)" => "America/New_York",
74        "Central Time (US & Canada)" => "America/Chicago",
75        "Mountain Time (US & Canada)" => "America/Denver",
76        "Pacific Time (US & Canada)" => "America/Los_Angeles",
77        "UTC" => "Etc/UTC",
78        "London" => "Europe/London",
79        "Tokyo" => "Asia/Tokyo",
80        "Beijing" => "Asia/Shanghai",
81        other => other,
82    };
83
84    let tz = canonical
85        .parse::<Tz>()
86        .map_err(|_| TimeZoneError::Unknown(name.to_owned()))?;
87
88    Ok(TimeZone {
89        tz,
90        name: name.to_owned(),
91    })
92}
93
94/// Returns all known IANA timezone names bundled by `chrono-tz`.
95#[must_use]
96pub fn all_timezones() -> Vec<&'static str> {
97    chrono_tz::TZ_VARIANTS.iter().map(|tz| tz.name()).collect()
98}
99
100#[cfg(test)]
101mod tests {
102    use super::{TimeZone, TimeZoneError, all_timezones, find_timezone};
103    use crate::testing::{TESTING_TIME_LOCK, freeze_time};
104    use chrono::{TimeZone as _, Timelike, Utc};
105    use chrono_tz::Tz;
106
107    #[test]
108    fn time_zone_accepts_iana_names() {
109        let zone = TimeZone::new("America/New_York").unwrap();
110
111        assert_eq!(zone.name(), "America/New_York");
112        assert_eq!(
113            zone.convert(Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap())
114                .timezone(),
115            Tz::America__New_York
116        );
117    }
118
119    #[test]
120    fn time_zone_finds_rails_aliases() {
121        let zone = find_timezone("Eastern Time (US & Canada)").unwrap();
122        let converted = zone.convert(Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap());
123
124        assert_eq!(zone.name(), "Eastern Time (US & Canada)");
125        assert_eq!(converted.timezone(), Tz::America__New_York);
126    }
127
128    #[test]
129    fn time_zone_supports_multiple_aliases() {
130        let samples = [
131            ("Central Time (US & Canada)", Tz::America__Chicago),
132            ("Mountain Time (US & Canada)", Tz::America__Denver),
133            ("Pacific Time (US & Canada)", Tz::America__Los_Angeles),
134            ("London", Tz::Europe__London),
135            ("Tokyo", Tz::Asia__Tokyo),
136            ("Beijing", Tz::Asia__Shanghai),
137            ("UTC", Tz::Etc__UTC),
138        ];
139
140        for (name, expected) in samples {
141            let zone = find_timezone(name).unwrap();
142            let converted = zone.convert(Utc.with_ymd_and_hms(2024, 6, 1, 12, 0, 0).unwrap());
143            assert_eq!(converted.timezone(), expected, "alias {name}");
144        }
145    }
146
147    #[test]
148    fn time_zone_convert_translates_utc_to_local_time() {
149        let zone = TimeZone::find("America/Los_Angeles").unwrap();
150        let utc = Utc.with_ymd_and_hms(2024, 1, 15, 12, 0, 0).unwrap();
151
152        let converted = zone.convert(utc);
153
154        assert_eq!(converted.hour(), 4);
155        assert_eq!(converted.timezone(), Tz::America__Los_Angeles);
156    }
157
158    #[test]
159    fn time_zone_now_uses_current_zone_and_respects_freeze_time() {
160        let _lock = TESTING_TIME_LOCK.lock().unwrap();
161        let frozen = Utc.with_ymd_and_hms(2024, 3, 1, 15, 30, 0).unwrap();
162        let _guard = freeze_time(frozen);
163        let zone = TimeZone::find("Tokyo").unwrap();
164
165        let now = zone.now();
166
167        assert_eq!(now.timezone(), Tz::Asia__Tokyo);
168        assert_eq!(now.hour(), 0);
169    }
170
171    #[test]
172    fn time_zone_utc_offset_returns_seconds() {
173        let _lock = TESTING_TIME_LOCK.lock().unwrap();
174        let frozen = Utc.with_ymd_and_hms(2024, 1, 15, 12, 0, 0).unwrap();
175        let _guard = freeze_time(frozen);
176        let zone = TimeZone::find("America/New_York").unwrap();
177
178        assert_eq!(zone.utc_offset(), -5 * 60 * 60);
179    }
180
181    #[test]
182    fn time_zone_parse_reads_local_time() {
183        let zone = TimeZone::find("America/New_York").unwrap();
184
185        let parsed = zone
186            .parse("2024-01-15 08:45:30", "%Y-%m-%d %H:%M:%S")
187            .unwrap();
188
189        assert_eq!(parsed.timezone(), Tz::America__New_York);
190        assert_eq!(parsed.hour(), 8);
191        assert_eq!(parsed.minute(), 45);
192        assert_eq!(parsed.second(), 30);
193    }
194
195    #[test]
196    fn time_zone_parse_rejects_ambiguous_time() {
197        let zone = TimeZone::find("America/New_York").unwrap();
198
199        let error = zone
200            .parse("2024-11-03 01:30:00", "%Y-%m-%d %H:%M:%S")
201            .unwrap_err();
202
203        assert_eq!(error, TimeZoneError::Ambiguous);
204    }
205
206    #[test]
207    fn time_zone_unknown_name_returns_error() {
208        let error = TimeZone::find("Mars/Base").unwrap_err();
209
210        assert_eq!(error, TimeZoneError::Unknown(String::from("Mars/Base")));
211    }
212
213    #[test]
214    fn time_zone_parse_invalid_input_returns_error() {
215        let zone = TimeZone::find("UTC").unwrap();
216        let error = zone.parse("not-a-time", "%Y-%m-%d %H:%M:%S").unwrap_err();
217
218        assert_eq!(error, TimeZoneError::Unknown(String::from("not-a-time")));
219    }
220
221    #[test]
222    fn all_timezones_contains_common_iana_names() {
223        let zones = all_timezones();
224
225        assert!(zones.contains(&"America/New_York"));
226        assert!(zones.contains(&"Europe/London"));
227        assert!(zones.contains(&"Asia/Tokyo"));
228    }
229
230    #[test]
231    fn time_zone_major_aliases_resolve_to_expected_iana_regions() {
232        let samples = [
233            ("London", "Europe/London"),
234            ("Tokyo", "Asia/Tokyo"),
235            ("Beijing", "Asia/Shanghai"),
236            ("UTC", "Etc/UTC"),
237        ];
238
239        for (name, expected) in samples {
240            let zone = TimeZone::find(name).unwrap();
241            let converted = zone.convert(Utc.with_ymd_and_hms(2024, 1, 15, 12, 0, 0).unwrap());
242
243            assert_eq!(zone.name(), name);
244            assert_eq!(converted.timezone().name(), expected, "lookup {name}");
245        }
246    }
247
248    #[test]
249    fn time_zone_utc_offset_changes_with_dst_for_named_zones() {
250        let _lock = TESTING_TIME_LOCK.lock().unwrap();
251        let eastern = TimeZone::find("Eastern Time (US & Canada)").unwrap();
252        let london = TimeZone::find("London").unwrap();
253
254        {
255            let _guard = freeze_time(Utc.with_ymd_and_hms(2024, 1, 15, 12, 0, 0).unwrap());
256            assert_eq!(eastern.utc_offset(), -5 * 60 * 60);
257            assert_eq!(london.utc_offset(), 0);
258        }
259
260        {
261            let _guard = freeze_time(Utc.with_ymd_and_hms(2024, 7, 15, 12, 0, 0).unwrap());
262            assert_eq!(eastern.utc_offset(), -4 * 60 * 60);
263            assert_eq!(london.utc_offset(), 60 * 60);
264        }
265    }
266
267    #[test]
268    fn time_zone_now_reflects_dst_shift_across_date_boundaries() {
269        let _lock = TESTING_TIME_LOCK.lock().unwrap();
270        let zone = TimeZone::find("America/Los_Angeles").unwrap();
271
272        {
273            let _guard = freeze_time(Utc.with_ymd_and_hms(2024, 1, 1, 4, 30, 0).unwrap());
274            assert_eq!(
275                zone.now().format("%Y-%m-%d %H:%M %:z").to_string(),
276                "2023-12-31 20:30 -08:00"
277            );
278        }
279
280        {
281            let _guard = freeze_time(Utc.with_ymd_and_hms(2024, 7, 1, 4, 30, 0).unwrap());
282            assert_eq!(
283                zone.now().format("%Y-%m-%d %H:%M %:z").to_string(),
284                "2024-06-30 21:30 -07:00"
285            );
286        }
287    }
288
289    #[test]
290    fn time_zone_now_enforces_spring_dst_rules() {
291        let _lock = TESTING_TIME_LOCK.lock().unwrap();
292        let zone = TimeZone::find("Eastern Time (US & Canada)").unwrap();
293        let _guard = freeze_time(Utc.with_ymd_and_hms(2024, 3, 10, 7, 0, 0).unwrap());
294
295        assert_eq!(
296            zone.now().format("%Y-%m-%d %H:%M:%S %:z").to_string(),
297            "2024-03-10 03:00:00 -04:00"
298        );
299    }
300
301    #[test]
302    fn time_zone_now_distinguishes_repeated_fall_hour_by_offset() {
303        let _lock = TESTING_TIME_LOCK.lock().unwrap();
304        let zone = TimeZone::find("Eastern Time (US & Canada)").unwrap();
305
306        {
307            let _guard = freeze_time(Utc.with_ymd_and_hms(2024, 11, 3, 5, 30, 0).unwrap());
308            assert_eq!(
309                zone.now().format("%Y-%m-%d %H:%M:%S %:z").to_string(),
310                "2024-11-03 01:30:00 -04:00"
311            );
312        }
313
314        {
315            let _guard = freeze_time(Utc.with_ymd_and_hms(2024, 11, 3, 6, 30, 0).unwrap());
316            assert_eq!(
317                zone.now().format("%Y-%m-%d %H:%M:%S %:z").to_string(),
318                "2024-11-03 01:30:00 -05:00"
319            );
320        }
321    }
322
323    #[test]
324    fn time_zone_alias_parse_matches_iana_in_winter_and_summer() {
325        let alias = TimeZone::find("Eastern Time (US & Canada)").unwrap();
326        let iana = TimeZone::find("America/New_York").unwrap();
327
328        for input in ["2024-01-15 08:45:30", "2024-07-15 08:45:30"] {
329            assert_eq!(
330                alias
331                    .parse(input, "%Y-%m-%d %H:%M:%S")
332                    .unwrap()
333                    .with_timezone(&Utc),
334                iana.parse(input, "%Y-%m-%d %H:%M:%S")
335                    .unwrap()
336                    .with_timezone(&Utc),
337                "input {input}"
338            );
339        }
340    }
341
342    #[test]
343    fn time_zone_convert_preserves_fractional_seconds_across_dst_offsets() {
344        let zone = TimeZone::find("Eastern Time (US & Canada)").unwrap();
345        let winter = Utc
346            .with_ymd_and_hms(2000, 1, 1, 0, 0, 0)
347            .unwrap()
348            .with_nanosecond(123_456_000)
349            .unwrap();
350        let summer = Utc
351            .with_ymd_and_hms(2000, 7, 1, 0, 0, 0)
352            .unwrap()
353            .with_nanosecond(123_456_000)
354            .unwrap();
355
356        let converted_winter = zone.convert(winter);
357        let converted_summer = zone.convert(summer);
358
359        assert_eq!(converted_winter.nanosecond(), 123_456_000);
360        assert_eq!(
361            converted_winter.format("%Y-%m-%d %H:%M:%S %:z").to_string(),
362            "1999-12-31 19:00:00 -05:00"
363        );
364        assert_eq!(converted_summer.nanosecond(), 123_456_000);
365        assert_eq!(
366            converted_summer.format("%Y-%m-%d %H:%M:%S %:z").to_string(),
367            "2000-06-30 20:00:00 -04:00"
368        );
369    }
370
371    #[test]
372    fn time_zone_parse_rejects_nonexistent_spring_forward_time() {
373        let zone = TimeZone::find("America/New_York").unwrap();
374
375        let error = zone
376            .parse("2024-03-10 02:30:00", "%Y-%m-%d %H:%M:%S")
377            .unwrap_err();
378
379        assert_eq!(error, TimeZoneError::Ambiguous);
380    }
381
382    #[test]
383    fn time_zone_parse_formats_offsets_around_dst_boundaries() {
384        let zone = TimeZone::find("America/New_York").unwrap();
385
386        let before_spring = zone
387            .parse("2024-03-10 01:59:59", "%Y-%m-%d %H:%M:%S")
388            .unwrap();
389        let after_spring = zone
390            .parse("2024-03-10 03:00:00", "%Y-%m-%d %H:%M:%S")
391            .unwrap();
392        let after_fall = zone
393            .parse("2024-11-03 02:30:00", "%Y-%m-%d %H:%M:%S")
394            .unwrap();
395
396        assert_eq!(
397            before_spring.format("%Y-%m-%d %H:%M:%S %:z").to_string(),
398            "2024-03-10 01:59:59 -05:00"
399        );
400        assert_eq!(
401            after_spring.format("%Y-%m-%d %H:%M:%S %:z").to_string(),
402            "2024-03-10 03:00:00 -04:00"
403        );
404        assert_eq!(
405            after_fall.format("%Y-%m-%d %H:%M:%S %:z").to_string(),
406            "2024-11-03 02:30:00 -05:00"
407        );
408    }
409
410    #[test]
411    fn time_zone_convert_handles_half_and_quarter_hour_offsets() {
412        let utc = Utc.with_ymd_and_hms(2024, 1, 1, 20, 30, 0).unwrap();
413        let adelaide = TimeZone::find("Australia/Adelaide").unwrap();
414        let kathmandu = TimeZone::find("Asia/Kathmandu").unwrap();
415
416        assert_eq!(
417            adelaide
418                .convert(utc)
419                .format("%Y-%m-%d %H:%M %:z")
420                .to_string(),
421            "2024-01-02 07:00 +10:30"
422        );
423        assert_eq!(
424            kathmandu
425                .convert(utc)
426                .format("%Y-%m-%d %H:%M %:z")
427                .to_string(),
428            "2024-01-02 02:15 +05:45"
429        );
430    }
431
432    #[test]
433    fn time_zone_new_accepts_aliases() {
434        let zone = TimeZone::new("Pacific Time (US & Canada)").unwrap();
435
436        assert_eq!(zone.name(), "Pacific Time (US & Canada)");
437    }
438
439    #[test]
440    fn find_timezone_preserves_display_name_for_iana_identifiers() {
441        let zone = find_timezone("Asia/Tokyo").unwrap();
442
443        assert_eq!(zone.name(), "Asia/Tokyo");
444    }
445
446    #[test]
447    fn time_zone_convert_tokyo_crosses_date_boundary() {
448        let zone = TimeZone::find("Asia/Tokyo").unwrap();
449        let utc = Utc.with_ymd_and_hms(2024, 1, 1, 18, 30, 0).unwrap();
450
451        assert_eq!(
452            zone.convert(utc).format("%Y-%m-%d %H:%M %:z").to_string(),
453            "2024-01-02 03:30 +09:00"
454        );
455    }
456
457    #[test]
458    fn time_zone_convert_utc_is_identity() {
459        let zone = TimeZone::find("UTC").unwrap();
460        let utc = Utc.with_ymd_and_hms(2024, 1, 1, 18, 30, 0).unwrap();
461
462        assert_eq!(
463            zone.convert(utc).format("%Y-%m-%d %H:%M %:z").to_string(),
464            "2024-01-01 18:30 +00:00"
465        );
466    }
467
468    #[test]
469    fn time_zone_now_in_utc_matches_frozen_timestamp() {
470        let _lock = TESTING_TIME_LOCK.lock().unwrap();
471        let frozen = Utc.with_ymd_and_hms(2024, 5, 1, 12, 34, 56).unwrap();
472        let _guard = freeze_time(frozen);
473        let zone = TimeZone::find("UTC").unwrap();
474
475        assert_eq!(
476            zone.now().format("%Y-%m-%d %H:%M:%S %:z").to_string(),
477            "2024-05-01 12:34:56 +00:00"
478        );
479    }
480
481    #[test]
482    fn time_zone_utc_offset_for_utc_is_zero() {
483        let _lock = TESTING_TIME_LOCK.lock().unwrap();
484        let frozen = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
485        let _guard = freeze_time(frozen);
486        let zone = TimeZone::find("UTC").unwrap();
487
488        assert_eq!(zone.utc_offset(), 0);
489    }
490
491    #[test]
492    fn time_zone_utc_offset_for_tokyo_is_nine_hours() {
493        let _lock = TESTING_TIME_LOCK.lock().unwrap();
494        let frozen = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
495        let _guard = freeze_time(frozen);
496        let zone = TimeZone::find("Asia/Tokyo").unwrap();
497
498        assert_eq!(zone.utc_offset(), 9 * 60 * 60);
499    }
500
501    #[test]
502    fn time_zone_utc_offset_for_india_is_half_hour() {
503        let _lock = TESTING_TIME_LOCK.lock().unwrap();
504        let frozen = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
505        let _guard = freeze_time(frozen);
506        let zone = TimeZone::find("Asia/Kolkata").unwrap();
507
508        assert_eq!(zone.utc_offset(), 19_800);
509    }
510
511    #[test]
512    fn time_zone_utc_offset_for_kathmandu_is_quarter_hour() {
513        let _lock = TESTING_TIME_LOCK.lock().unwrap();
514        let frozen = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
515        let _guard = freeze_time(frozen);
516        let zone = TimeZone::find("Asia/Kathmandu").unwrap();
517
518        assert_eq!(zone.utc_offset(), 20_700);
519    }
520
521    #[test]
522    fn time_zone_parse_in_utc_yields_exact_timestamp() {
523        let zone = TimeZone::find("UTC").unwrap();
524        let parsed = zone
525            .parse("2024-01-15 08:45:30", "%Y-%m-%d %H:%M:%S")
526            .unwrap();
527
528        assert_eq!(
529            parsed.with_timezone(&Utc),
530            Utc.with_ymd_and_hms(2024, 1, 15, 8, 45, 30).unwrap()
531        );
532    }
533
534    #[test]
535    fn time_zone_parse_in_tokyo_maps_to_expected_utc_instant() {
536        let zone = TimeZone::find("Asia/Tokyo").unwrap();
537        let parsed = zone
538            .parse("2024-01-15 08:45:30", "%Y-%m-%d %H:%M:%S")
539            .unwrap();
540
541        assert_eq!(
542            parsed.with_timezone(&Utc),
543            Utc.with_ymd_and_hms(2024, 1, 14, 23, 45, 30).unwrap()
544        );
545    }
546
547    #[test]
548    fn time_zone_parse_in_london_has_winter_offset() {
549        let zone = TimeZone::find("Europe/London").unwrap();
550        let parsed = zone
551            .parse("2024-01-15 08:45:30", "%Y-%m-%d %H:%M:%S")
552            .unwrap();
553
554        assert_eq!(
555            parsed.format("%Y-%m-%d %H:%M:%S %:z").to_string(),
556            "2024-01-15 08:45:30 +00:00"
557        );
558    }
559
560    #[test]
561    fn time_zone_parse_in_london_has_summer_offset() {
562        let zone = TimeZone::find("Europe/London").unwrap();
563        let parsed = zone
564            .parse("2024-07-15 08:45:30", "%Y-%m-%d %H:%M:%S")
565            .unwrap();
566
567        assert_eq!(
568            parsed.format("%Y-%m-%d %H:%M:%S %:z").to_string(),
569            "2024-07-15 08:45:30 +01:00"
570        );
571    }
572
573    #[test]
574    fn time_zone_parse_in_london_rejects_ambiguous_fall_time() {
575        let zone = TimeZone::find("Europe/London").unwrap();
576        let error = zone
577            .parse("2024-10-27 01:30:00", "%Y-%m-%d %H:%M:%S")
578            .unwrap_err();
579
580        assert_eq!(error, TimeZoneError::Ambiguous);
581    }
582
583    #[test]
584    fn time_zone_parse_in_london_rejects_nonexistent_spring_time() {
585        let zone = TimeZone::find("Europe/London").unwrap();
586        let error = zone
587            .parse("2024-03-31 01:30:00", "%Y-%m-%d %H:%M:%S")
588            .unwrap_err();
589
590        assert_eq!(error, TimeZoneError::Ambiguous);
591    }
592
593    #[test]
594    fn all_timezones_contains_half_and_quarter_hour_regions() {
595        let zones = all_timezones();
596
597        assert!(zones.contains(&"Asia/Kolkata"));
598        assert!(zones.contains(&"Asia/Kathmandu"));
599        assert!(zones.contains(&"Australia/Adelaide"));
600    }
601
602    #[test]
603    fn all_timezones_contains_major_us_regions() {
604        let zones = all_timezones();
605
606        assert!(zones.contains(&"America/Chicago"));
607        assert!(zones.contains(&"America/Los_Angeles"));
608    }
609
610    #[test]
611    fn time_zone_unknown_alias_reports_original_input() {
612        let error = find_timezone("Eastern").unwrap_err();
613
614        assert_eq!(error, TimeZoneError::Unknown(String::from("Eastern")));
615    }
616
617    #[test]
618    fn time_zone_alias_and_iana_lookup_match_same_instant() {
619        let utc = Utc.with_ymd_and_hms(2024, 2, 1, 12, 0, 0).unwrap();
620        let alias = TimeZone::find("Eastern Time (US & Canada)").unwrap();
621        let iana = TimeZone::find("America/New_York").unwrap();
622
623        assert_eq!(
624            alias.convert(utc).with_timezone(&Utc),
625            iana.convert(utc).with_timezone(&Utc)
626        );
627        assert_eq!(
628            alias.convert(utc).format("%Y-%m-%d %H:%M %:z").to_string(),
629            "2024-02-01 07:00 -05:00"
630        );
631    }
632
633    #[test]
634    fn time_zone_convert_kolkata_crosses_midnight() {
635        let zone = TimeZone::find("Asia/Kolkata").unwrap();
636        let utc = Utc.with_ymd_and_hms(2024, 1, 1, 20, 30, 0).unwrap();
637
638        assert_eq!(
639            zone.convert(utc).format("%Y-%m-%d %H:%M %:z").to_string(),
640            "2024-01-02 02:00 +05:30"
641        );
642    }
643
644    #[test]
645    fn time_zone_convert_kathmandu_crosses_midnight() {
646        let zone = TimeZone::find("Asia/Kathmandu").unwrap();
647        let utc = Utc.with_ymd_and_hms(2024, 1, 1, 20, 30, 0).unwrap();
648
649        assert_eq!(
650            zone.convert(utc).format("%Y-%m-%d %H:%M %:z").to_string(),
651            "2024-01-02 02:15 +05:45"
652        );
653    }
654
655    #[test]
656    fn time_zone_now_in_half_hour_zone_reflects_offset() {
657        let _lock = TESTING_TIME_LOCK.lock().unwrap();
658        let frozen = Utc.with_ymd_and_hms(2024, 1, 1, 20, 30, 0).unwrap();
659        let _guard = freeze_time(frozen);
660        let zone = TimeZone::find("Asia/Kolkata").unwrap();
661
662        assert_eq!(
663            zone.now().format("%Y-%m-%d %H:%M %:z").to_string(),
664            "2024-01-02 02:00 +05:30"
665        );
666    }
667
668    #[test]
669    fn time_zone_alias_name_is_preserved() {
670        let zone = TimeZone::find("London").unwrap();
671
672        assert_eq!(zone.name(), "London");
673    }
674
675    #[test]
676    fn time_zone_parse_supports_alternative_format_strings() {
677        let zone = TimeZone::find("UTC").unwrap();
678        let parsed = zone.parse("15/01/2024 08:45", "%d/%m/%Y %H:%M").unwrap();
679
680        assert_eq!(
681            parsed.format("%Y-%m-%d %H:%M %:z").to_string(),
682            "2024-01-15 08:45 +00:00"
683        );
684    }
685
686    #[test]
687    fn time_zone_parse_los_angeles_offsets_differ_by_season() {
688        let zone = TimeZone::find("America/Los_Angeles").unwrap();
689        let winter = zone
690            .parse("2024-01-15 08:45:30", "%Y-%m-%d %H:%M:%S")
691            .unwrap();
692        let summer = zone
693            .parse("2024-07-15 08:45:30", "%Y-%m-%d %H:%M:%S")
694            .unwrap();
695
696        assert_eq!(
697            winter.format("%Y-%m-%d %H:%M:%S %:z").to_string(),
698            "2024-01-15 08:45:30 -08:00"
699        );
700        assert_eq!(
701            summer.format("%Y-%m-%d %H:%M:%S %:z").to_string(),
702            "2024-07-15 08:45:30 -07:00"
703        );
704    }
705}