aimcal_core/
datetime.rs

1// SPDX-FileCopyrightText: 2025 Zexin Yuan <aim@yzx9.xyz>
2//
3// SPDX-License-Identifier: Apache-2.0
4
5use chrono::offset::LocalResult;
6use chrono::{DateTime, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeDelta, TimeZone};
7use chrono_tz::Tz;
8use icalendar::{CalendarDateTime, DatePerhapsTime};
9
10/// A date and time that may be in different formats, such as date only, floating time, or local time with timezone.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum LooseDateTime {
13    /// Date only without time.
14    DateOnly(NaiveDate),
15
16    /// Floating date and time without timezone.
17    Floating(NaiveDateTime),
18
19    /// Local date and time with timezone.
20    /// NOTE: This is always in the local timezone of the system running the code.
21    Local(DateTime<Local>),
22}
23
24impl LooseDateTime {
25    /// Returns the date part
26    pub fn date(&self) -> NaiveDate {
27        match self {
28            LooseDateTime::DateOnly(d) => *d,
29            LooseDateTime::Floating(dt) => dt.date(),
30            LooseDateTime::Local(dt) => dt.date_naive(),
31        }
32    }
33
34    /// Returns the time part, if available.
35    pub fn time(&self) -> Option<NaiveTime> {
36        match self {
37            LooseDateTime::DateOnly(_) => None,
38            LooseDateTime::Floating(dt) => Some(dt.time()),
39            LooseDateTime::Local(dt) => Some(dt.time()),
40        }
41    }
42
43    /// Converts to a datetime with default start time (00:00:00) if time is missing.
44    pub fn with_start_of_day(&self) -> NaiveDateTime {
45        NaiveDateTime::new(self.date(), self.time().unwrap_or_else(start_of_day_naive))
46    }
47
48    /// Converts to a datetime with default end time (23:59:59.999999999) if time is missing.
49    pub fn with_end_of_day(&self) -> NaiveDateTime {
50        NaiveDateTime::new(self.date(), self.time().unwrap_or_else(end_of_day_naive))
51    }
52
53    /// Determines the position of a given datetime relative to a start and optional end date.
54    pub fn position_in_range(
55        t: &NaiveDateTime,
56        start: &Option<LooseDateTime>,
57        end: &Option<LooseDateTime>,
58    ) -> RangePosition {
59        match (start, end) {
60            (Some(start), Some(end)) => {
61                let start_dt = start.with_start_of_day(); // 00:00
62                let end_dt = end.with_end_of_day(); // 23:59
63                if start_dt > end_dt {
64                    RangePosition::InvalidRange
65                } else if t > &end_dt {
66                    RangePosition::After
67                } else if t < &start_dt {
68                    RangePosition::Before
69                } else {
70                    RangePosition::InRange
71                }
72            }
73            (Some(start), None) => match t >= &start.with_start_of_day() {
74                true => RangePosition::InRange,
75                false => RangePosition::Before,
76            },
77            (None, Some(end)) => match t > &end.with_end_of_day() {
78                true => RangePosition::After,
79                false => RangePosition::InRange,
80            },
81            (None, None) => RangePosition::InvalidRange,
82        }
83    }
84
85    /// NOTE: Used for storing in the database, so it should be stable across different runs.
86    const DATEONLY_FORMAT: &str = "%Y-%m-%d";
87    const FLOATING_FORMAT: &str = "%Y-%m-%dT%H:%M:%S";
88    const LOCAL_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%z";
89
90    /// Converts to a string representation of date and time.
91    pub(crate) fn format_stable(&self) -> String {
92        match self {
93            LooseDateTime::DateOnly(d) => d.format(Self::DATEONLY_FORMAT).to_string(),
94            LooseDateTime::Floating(dt) => dt.format(Self::FLOATING_FORMAT).to_string(),
95            LooseDateTime::Local(dt) => dt.format(Self::LOCAL_FORMAT).to_string(),
96        }
97    }
98
99    pub(crate) fn parse_stable(s: &str) -> Option<Self> {
100        match s.len() {
101            // 2006-01-02
102            10 => NaiveDate::parse_from_str(s, Self::DATEONLY_FORMAT)
103                .map(Self::DateOnly)
104                .ok(),
105
106            // 2006-01-02T15:04:05
107            19 => NaiveDateTime::parse_from_str(s, Self::FLOATING_FORMAT)
108                .map(Self::Floating)
109                .ok(),
110
111            // 2006-01-02T15:04:05Z
112            20.. => DateTime::parse_from_str(s, Self::LOCAL_FORMAT)
113                .map(|a| Self::Local(a.with_timezone(&Local)))
114                .ok(),
115
116            _ => None,
117        }
118    }
119}
120
121impl From<DatePerhapsTime> for LooseDateTime {
122    #[tracing::instrument]
123    fn from(dt: DatePerhapsTime) -> Self {
124        match dt {
125            DatePerhapsTime::DateTime(dt) => match dt {
126                CalendarDateTime::Floating(dt) => dt.into(),
127                CalendarDateTime::Utc(dt) => dt.into(),
128                CalendarDateTime::WithTimezone { date_time, tzid } => match tzid.parse::<Tz>() {
129                    Ok(tz) => match tz.from_local_datetime(&date_time) {
130                        // Use the parsed timezone to interpret the datetime
131                        LocalResult::Single(dt_in_tz) => dt_in_tz.into(),
132                        LocalResult::Ambiguous(dt1, _) => {
133                            tracing::warn!(tzid, "ambiguous local time, picking earliest");
134                            dt1.into()
135                        }
136                        LocalResult::None => {
137                            tracing::warn!(tzid, "invalid local time, falling back to floating");
138                            date_time.into()
139                        }
140                    },
141                    Err(_) => {
142                        tracing::warn!(tzid, "unknown timezone, treating as floating");
143                        date_time.into()
144                    }
145                },
146            },
147            DatePerhapsTime::Date(d) => d.into(),
148        }
149    }
150}
151
152impl From<LooseDateTime> for DatePerhapsTime {
153    fn from(dt: LooseDateTime) -> Self {
154        match dt {
155            LooseDateTime::DateOnly(d) => d.into(),
156            LooseDateTime::Floating(dt) => CalendarDateTime::Floating(dt).into(),
157            LooseDateTime::Local(dt) => match iana_time_zone::get_timezone() {
158                Ok(tzid) => CalendarDateTime::WithTimezone {
159                    date_time: dt.naive_local(),
160                    tzid,
161                }
162                .into(),
163                Err(_) => {
164                    tracing::warn!("Failed to get timezone, using UTC");
165                    CalendarDateTime::Utc(dt.into()).into()
166                }
167            },
168        }
169    }
170}
171
172impl From<NaiveDate> for LooseDateTime {
173    fn from(d: NaiveDate) -> Self {
174        LooseDateTime::DateOnly(d)
175    }
176}
177
178impl From<NaiveDateTime> for LooseDateTime {
179    fn from(dt: NaiveDateTime) -> Self {
180        LooseDateTime::Floating(dt)
181    }
182}
183
184impl<Tz: TimeZone> From<DateTime<Tz>> for LooseDateTime {
185    fn from(dt: DateTime<Tz>) -> Self {
186        LooseDateTime::Local(dt.with_timezone(&Local))
187    }
188}
189
190/// The position of a date relative to a range defined by a start and optional end date.
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub enum RangePosition {
193    /// The date is before the start of the range.
194    Before,
195
196    /// The date is within the range.
197    InRange,
198
199    /// The date is after the start of the range.
200    After,
201
202    /// The range is invalid, e.g., start date is after end date.
203    InvalidRange,
204}
205
206/// Represents a date and time anchor that can be used to calculate relative dates and times.
207#[derive(Debug, Clone, Copy)]
208pub enum DateTimeAnchor {
209    /// A specific number of hours in the future or past.
210    InHours(i64),
211
212    /// A specific number of days in the future or past.
213    InDays(i64),
214}
215
216impl DateTimeAnchor {
217    /// Represents the current time.
218    pub fn now() -> Self {
219        DateTimeAnchor::InHours(0)
220    }
221
222    /// Represents the current date.
223    pub fn today() -> Self {
224        DateTimeAnchor::InDays(0)
225    }
226
227    /// Represents tomorrow, which is one day after today.
228    pub fn tomorrow() -> Self {
229        DateTimeAnchor::InDays(1)
230    }
231
232    /// Represents yesterday, which is one day before today.
233    pub fn yesterday() -> Self {
234        DateTimeAnchor::InDays(-1)
235    }
236
237    /// Parses the `When` enum based on the current time.
238    pub fn parse_as_start_of_day<Tz: TimeZone>(&self, now: &DateTime<Tz>) -> DateTime<Tz> {
239        match self {
240            DateTimeAnchor::InHours(n) => now.clone() + TimeDelta::hours(*n),
241            DateTimeAnchor::InDays(n) => start_of_day(now) + TimeDelta::days(*n),
242        }
243    }
244
245    /// Parses the `When` enum based on the current time.
246    pub fn parse_as_end_of_day<Tz: TimeZone>(&self, now: &DateTime<Tz>) -> DateTime<Tz> {
247        match self {
248            DateTimeAnchor::InHours(n) => now.clone() + TimeDelta::hours(*n),
249            DateTimeAnchor::InDays(n) => end_of_day(now) + TimeDelta::days(*n),
250        }
251    }
252}
253
254/// Returns the start of the day (00:00:00) for the given `DateTime` in the same timezone.
255fn start_of_day<Tz: TimeZone>(dt: &DateTime<Tz>) -> DateTime<Tz> {
256    let naive = NaiveDateTime::new(dt.date_naive(), start_of_day_naive());
257    from_local_datetime(&dt.timezone(), naive)
258}
259
260/// Returns the end of the day (23:59:59) for the given `DateTime` in the same timezone.
261fn end_of_day<Tz: TimeZone>(dt: &DateTime<Tz>) -> DateTime<Tz> {
262    let last_nano_sec = end_of_day_naive();
263    let naive = NaiveDateTime::new(dt.date_naive(), last_nano_sec);
264    from_local_datetime(&dt.timezone(), naive)
265}
266
267const fn start_of_day_naive() -> NaiveTime {
268    NaiveTime::from_hms_opt(0, 0, 0).expect("00:00:00 must exist in NaiveTime")
269}
270
271/// Using a leap second to represent the end of the day
272const fn end_of_day_naive() -> NaiveTime {
273    NaiveTime::from_hms_nano_opt(23, 59, 59, 1_999_999_999)
274        .expect("23:59:59:1_999_999_999 must exist in NaiveTime")
275}
276
277/// Convert the `NaiveDateTime` to the local timezone, handles local time ambiguities:
278/// - `Single(dt)` returns directly;
279/// - `Ambiguous(a, b)` takes the earlier one;
280/// - `None` (local time does not exist, e.g., due to DST transition): falls back to UTC
281///   combination and then converts.
282fn from_local_datetime<Tz: TimeZone>(tz: &Tz, naive: NaiveDateTime) -> DateTime<Tz> {
283    match tz.from_local_datetime(&naive) {
284        LocalResult::Single(x) => x,
285        LocalResult::Ambiguous(a, b) => {
286            // Choose the earlier one
287            if a <= b { a } else { b }
288        }
289        LocalResult::None => {
290            let utc = chrono::Utc.from_utc_datetime(&naive);
291            utc.with_timezone(tz)
292        }
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use chrono::Utc;
299
300    use super::*;
301
302    #[test]
303    fn test_date_and_time_methods() {
304        let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
305        let time = NaiveTime::from_hms_opt(12, 30, 45).unwrap();
306        let datetime = NaiveDateTime::new(date, time);
307        let local_dt = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 45).unwrap();
308
309        let d1 = LooseDateTime::DateOnly(date);
310        let d2 = LooseDateTime::Floating(datetime);
311        let d3 = LooseDateTime::Local(local_dt);
312
313        // Date
314        assert_eq!(d1.date(), date);
315        assert_eq!(d2.date(), date);
316        assert_eq!(d3.date(), date);
317
318        // Time
319        assert_eq!(d1.time(), None);
320        assert_eq!(d2.time(), Some(time));
321        assert_eq!(d3.time(), Some(time));
322    }
323
324    #[test]
325    fn test_with_start_of_day() {
326        let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
327        let time = NaiveTime::from_hms_opt(12, 30, 0).unwrap();
328        let datetime = NaiveDateTime::new(date, time);
329        let local_dt = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 0).unwrap();
330
331        let d1 = LooseDateTime::DateOnly(date);
332        let d2 = LooseDateTime::Floating(datetime);
333        let d3 = LooseDateTime::Local(local_dt);
334
335        assert_eq!(
336            d1.with_start_of_day(),
337            NaiveDateTime::new(date, NaiveTime::from_hms_opt(0, 0, 0).unwrap())
338        );
339        assert_eq!(d2.with_start_of_day(), datetime);
340        assert_eq!(d3.with_start_of_day(), datetime);
341    }
342
343    #[test]
344    fn test_with_end_of_day() {
345        let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
346        let time = NaiveTime::from_hms_opt(12, 30, 0).unwrap();
347        let datetime = NaiveDateTime::new(date, time);
348        let local_dt = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 0).unwrap();
349
350        let d1 = LooseDateTime::DateOnly(date);
351        let d2 = LooseDateTime::Floating(datetime);
352        let d3 = LooseDateTime::Local(local_dt);
353
354        assert_eq!(
355            d1.with_end_of_day(),
356            NaiveDateTime::new(
357                date,
358                NaiveTime::from_hms_nano_opt(23, 59, 59, 1_999_999_999).unwrap()
359            )
360        );
361        assert_eq!(d2.with_end_of_day(), datetime);
362        assert_eq!(d3.with_end_of_day(), datetime);
363    }
364
365    fn datetime(y: i32, m: u32, d: u32, h: u32, mm: u32, s: u32) -> Option<NaiveDateTime> {
366        NaiveDate::from_ymd_opt(y, m, d).and_then(|a| a.and_hms_opt(h, mm, s))
367    }
368
369    #[test]
370    fn test_position_in_range_date_date() {
371        let start = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
372        let end = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 3).unwrap());
373
374        let t_before = datetime(2023, 12, 31, 23, 59, 59).unwrap();
375        let t_in_s = datetime(2024, 1, 1, 12, 0, 0).unwrap();
376        let t_in_e = datetime(2024, 1, 3, 12, 0, 0).unwrap();
377        let t_after = datetime(2024, 1, 4, 0, 0, 0).unwrap();
378
379        assert_eq!(
380            LooseDateTime::position_in_range(&t_before, &Some(start), &Some(end)),
381            RangePosition::Before
382        );
383        assert_eq!(
384            LooseDateTime::position_in_range(&t_in_s, &Some(start), &Some(end)),
385            RangePosition::InRange
386        );
387        assert_eq!(
388            LooseDateTime::position_in_range(&t_in_e, &Some(start), &Some(end)),
389            RangePosition::InRange
390        );
391        assert_eq!(
392            LooseDateTime::position_in_range(&t_after, &Some(start), &Some(end)),
393            RangePosition::After
394        );
395    }
396
397    #[test]
398    fn test_position_in_range_date_floating() {
399        let start = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
400        let end = LooseDateTime::Floating(datetime(2024, 1, 3, 13, 0, 0).unwrap());
401
402        let t_before = datetime(2023, 12, 31, 23, 59, 59).unwrap();
403        let t_in_s = datetime(2024, 1, 1, 12, 0, 0).unwrap();
404        let t_in_e = datetime(2024, 1, 3, 12, 0, 0).unwrap();
405        let t_after = datetime(2024, 1, 3, 14, 0, 0).unwrap();
406
407        assert_eq!(
408            LooseDateTime::position_in_range(&t_before, &Some(start), &Some(end)),
409            RangePosition::Before
410        );
411        assert_eq!(
412            LooseDateTime::position_in_range(&t_in_s, &Some(start), &Some(end)),
413            RangePosition::InRange
414        );
415        assert_eq!(
416            LooseDateTime::position_in_range(&t_in_e, &Some(start), &Some(end)),
417            RangePosition::InRange
418        );
419        assert_eq!(
420            LooseDateTime::position_in_range(&t_after, &Some(start), &Some(end)),
421            RangePosition::After
422        );
423    }
424
425    #[test]
426    fn test_position_in_range_floating_date() {
427        let start = LooseDateTime::Floating(datetime(2024, 1, 1, 13, 0, 0).unwrap());
428        let end = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
429
430        let t_before = datetime(2024, 1, 1, 12, 0, 0).unwrap();
431        let t_in_s = datetime(2024, 1, 1, 14, 0, 0).unwrap();
432        let t_in_e = datetime(2024, 1, 1, 23, 59, 59).unwrap();
433        let t_after = datetime(2024, 1, 2, 0, 0, 0).unwrap();
434
435        assert_eq!(
436            LooseDateTime::position_in_range(&t_before, &Some(start), &Some(end)),
437            RangePosition::Before
438        );
439        assert_eq!(
440            LooseDateTime::position_in_range(&t_in_s, &Some(start), &Some(end)),
441            RangePosition::InRange
442        );
443        assert_eq!(
444            LooseDateTime::position_in_range(&t_in_e, &Some(start), &Some(end)),
445            RangePosition::InRange
446        );
447        assert_eq!(
448            LooseDateTime::position_in_range(&t_after, &Some(start), &Some(end)),
449            RangePosition::After
450        );
451    }
452
453    #[test]
454    fn test_position_in_range_without_start() {
455        let t1 = datetime(2023, 12, 31, 23, 59, 59).unwrap();
456        let t2 = datetime(2024, 1, 1, 20, 0, 0).unwrap();
457
458        for end in [
459            LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2023, 12, 31).unwrap()),
460            LooseDateTime::Floating(datetime(2023, 12, 31, 23, 59, 59).unwrap()),
461            LooseDateTime::Local(Local.with_ymd_and_hms(2023, 12, 31, 23, 59, 59).unwrap()),
462        ] {
463            assert_eq!(
464                LooseDateTime::position_in_range(&t1, &None, &Some(end)),
465                RangePosition::InRange,
466                "end = {end:?}"
467            );
468            assert_eq!(
469                LooseDateTime::position_in_range(&t2, &None, &Some(end)),
470                RangePosition::After,
471                "end = {end:?}"
472            );
473        }
474    }
475
476    #[test]
477    fn test_position_in_range_date_without_end() {
478        let t1 = datetime(2023, 12, 31, 23, 59, 59).unwrap();
479        let t2 = datetime(2024, 1, 1, 0, 0, 0).unwrap();
480
481        for start in [
482            LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
483            LooseDateTime::Floating(datetime(2024, 1, 1, 0, 0, 0).unwrap()),
484            LooseDateTime::Local(Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap()),
485        ] {
486            assert_eq!(
487                LooseDateTime::position_in_range(&t1, &Some(start), &None),
488                RangePosition::Before,
489                "start = {start:?}"
490            );
491            assert_eq!(
492                LooseDateTime::position_in_range(&t2, &Some(start), &None),
493                RangePosition::InRange,
494                "start = {start:?}"
495            );
496        }
497    }
498
499    #[test]
500    fn test_invalid_range() {
501        let start = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 5).unwrap());
502        let end = LooseDateTime::DateOnly(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
503
504        let t = datetime(2024, 1, 3, 12, 0, 0).unwrap();
505
506        assert_eq!(
507            LooseDateTime::position_in_range(&t, &Some(start), &Some(end)),
508            RangePosition::InvalidRange
509        );
510
511        assert_eq!(
512            LooseDateTime::position_in_range(&t, &None, &None),
513            RangePosition::InvalidRange
514        );
515    }
516
517    #[test]
518    fn test_format_and_parse_stable() {
519        let date = NaiveDate::from_ymd_opt(2024, 7, 18).unwrap();
520        let time = NaiveTime::from_hms_opt(12, 30, 45).unwrap();
521        let datetime = NaiveDateTime::new(date, time);
522        let local = Local.with_ymd_and_hms(2024, 7, 18, 12, 30, 45).unwrap();
523
524        let d1 = LooseDateTime::DateOnly(date);
525        let d2 = LooseDateTime::Floating(datetime);
526        let d3 = LooseDateTime::Local(local);
527
528        // Format
529        let f1 = d1.format_stable();
530        let f2 = d2.format_stable();
531        let f3 = d3.format_stable();
532
533        assert_eq!(f1, "2024-07-18");
534        assert_eq!(f2, "2024-07-18T12:30:45");
535        assert!(f3.starts_with("2024-07-18T12:30:45"));
536
537        // Parse
538        assert_eq!(LooseDateTime::parse_stable(&f1), Some(d1));
539        assert_eq!(LooseDateTime::parse_stable(&f2), Some(d2));
540        let parsed3 = LooseDateTime::parse_stable(&f3);
541        if let Some(LooseDateTime::Local(dt)) = parsed3 {
542            assert_eq!(dt.naive_local(), local.naive_local());
543        } else {
544            panic!("Failed to parse local datetime");
545        }
546    }
547
548    #[test]
549    fn test_when_now() {
550        let now = Utc.with_ymd_and_hms(2025, 1, 1, 15, 30, 45).unwrap();
551        assert_eq!(DateTimeAnchor::now().parse_as_start_of_day(&now), now);
552        assert_eq!(DateTimeAnchor::now().parse_as_end_of_day(&now), now);
553    }
554
555    #[test]
556    fn test_when_in_hours() {
557        let now = Utc.with_ymd_and_hms(2025, 1, 1, 15, 30, 45).unwrap();
558        let anchor = DateTimeAnchor::InHours(1);
559
560        let parsed = anchor.parse_as_start_of_day(&now);
561        let expected = Utc.with_ymd_and_hms(2025, 1, 1, 16, 30, 45).unwrap();
562        assert_eq!(parsed, expected);
563
564        let parsed = anchor.parse_as_end_of_day(&now);
565        assert_eq!(parsed, expected);
566    }
567
568    #[test]
569    fn test_when_in_days() {
570        let now = Utc.with_ymd_and_hms(2025, 1, 1, 15, 30, 45).unwrap();
571        let anchor = DateTimeAnchor::InDays(1);
572
573        let parsed = anchor.parse_as_start_of_day(&now);
574        let expected = Utc.with_ymd_and_hms(2025, 1, 2, 0, 0, 0).unwrap();
575        assert_eq!(parsed, expected);
576
577        let parsed = anchor.parse_as_end_of_day(&now);
578        assert!(parsed > Utc.with_ymd_and_hms(2025, 1, 2, 23, 59, 59).unwrap());
579        assert!(parsed < Utc.with_ymd_and_hms(2025, 1, 3, 0, 0, 0).unwrap());
580    }
581
582    #[test]
583    fn test_start_of_day() {
584        let now = Utc.with_ymd_and_hms(2025, 1, 1, 10, 30, 59).unwrap();
585        let parsed = start_of_day(&now);
586        let expected = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
587        assert_eq!(parsed, expected);
588    }
589
590    #[test]
591    fn test_end_of_day() {
592        let now = Utc.with_ymd_and_hms(2025, 1, 1, 10, 30, 0).unwrap();
593        let parsed = end_of_day(&now);
594        let last_sec = Utc.with_ymd_and_hms(2025, 1, 1, 23, 59, 59).unwrap();
595        let next_day = Utc.with_ymd_and_hms(2025, 1, 2, 0, 0, 0).unwrap();
596        assert!(parsed > last_sec);
597        assert!(parsed < next_day);
598    }
599
600    #[test]
601    fn test_from_local_datetime_dst_ambiguity_pick_earliest() {
602        let tz = chrono_tz::America::New_York; // DST
603        let now = NaiveDateTime::new(
604            NaiveDate::from_ymd_opt(2025, 11, 2).unwrap(),
605            NaiveTime::from_hms_opt(1, 30, 0).unwrap(),
606        );
607
608        let parsed = from_local_datetime(&tz, now).with_timezone(&Utc);
609        let expected = Utc.with_ymd_and_hms(2025, 11, 2, 5, 30, 0).unwrap();
610        assert_eq!(parsed, expected);
611    }
612}