aimcal_core/datetime/
util.rs

1// SPDX-FileCopyrightText: 2025 Zexin Yuan <aim@yzx9.xyz>
2//
3// SPDX-License-Identifier: Apache-2.0
4
5use chrono::{DateTime, NaiveDateTime, NaiveTime, TimeZone, Utc, offset::LocalResult};
6
7/// NOTE: Used for storing in the database, so it should be stable across different runs.
8pub const STABLE_FORMAT_DATEONLY: &str = "%Y-%m-%d";
9pub const STABLE_FORMAT_FLOATING: &str = "%Y-%m-%dT%H:%M:%S";
10pub const STABLE_FORMAT_LOCAL: &str = "%Y-%m-%dT%H:%M:%S%z";
11
12/// The position of a date relative to a range defined by a start and optional end date.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum RangePosition {
15    /// The date is before the start of the range.
16    Before,
17
18    /// The date is within the range.
19    InRange,
20
21    /// The date is after the start of the range.
22    After,
23
24    /// The range is invalid, e.g., start date is after end date.
25    InvalidRange,
26}
27
28pub const fn start_of_day_naive() -> NaiveTime {
29    NaiveTime::from_hms_opt(0, 0, 0).expect("00:00:00 must exist in NaiveTime")
30}
31
32/// Using a leap second to represent the end of the day
33pub const fn end_of_day_naive() -> NaiveTime {
34    NaiveTime::from_hms_nano_opt(23, 59, 59, 1_999_999_999)
35        .expect("23:59:59:1_999_999_999 must exist in NaiveTime")
36}
37
38/// The start of the day (00:00:00) for the given `DateTime` in the same timezone.
39pub fn start_of_day<Tz: TimeZone>(dt: &DateTime<Tz>) -> DateTime<Tz> {
40    let naive = NaiveDateTime::new(dt.date_naive(), start_of_day_naive());
41    from_local_datetime(&dt.timezone(), naive)
42}
43
44/// The end of the day (23:59:59) for the given `DateTime` in the same timezone.
45pub fn end_of_day<Tz: TimeZone>(dt: &DateTime<Tz>) -> DateTime<Tz> {
46    let last_nano_sec = end_of_day_naive();
47    let naive = NaiveDateTime::new(dt.date_naive(), last_nano_sec);
48    from_local_datetime(&dt.timezone(), naive)
49}
50
51/// Convert the `NaiveDateTime` to the local timezone, handles local time ambiguities:
52/// - `Single(dt)` returns directly;
53/// - `Ambiguous(a, b)` takes the earlier one;
54/// - `None` (local time does not exist, e.g., due to DST transition): falls back to UTC
55///   combination and then converts.
56pub fn from_local_datetime<Tz: TimeZone>(tz: &Tz, naive: NaiveDateTime) -> DateTime<Tz> {
57    match tz.from_local_datetime(&naive) {
58        LocalResult::Single(x) => x,
59        LocalResult::Ambiguous(a, b) => {
60            // Choose the earlier one
61            if a <= b { a } else { b }
62        }
63        LocalResult::None => Utc.from_utc_datetime(&naive).with_timezone(tz),
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use chrono::{TimeZone, Utc};
70
71    use super::*;
72
73    #[test]
74    fn test_start_of_day_naive() {
75        let time = start_of_day_naive();
76        assert!(time <= NaiveTime::from_hms_opt(0, 0, 0).unwrap());
77    }
78
79    #[test]
80    fn test_end_of_day_naive() {
81        let time = end_of_day_naive();
82        assert!(time >= NaiveTime::from_hms_opt(23, 59, 59).unwrap());
83    }
84
85    #[test]
86    fn test_start_of_day() {
87        let dt = Utc.with_ymd_and_hms(2025, 1, 15, 14, 30, 45).unwrap();
88        let start = start_of_day(&dt);
89        assert_eq!(start.date_naive(), dt.date_naive());
90        assert!(start <= Utc.with_ymd_and_hms(2025, 1, 15, 14, 30, 45).unwrap());
91    }
92
93    #[test]
94    fn test_end_of_day() {
95        let dt = Utc.with_ymd_and_hms(2025, 1, 15, 14, 30, 45).unwrap();
96        let end = end_of_day(&dt);
97        assert_eq!(end.date_naive(), dt.date_naive());
98        assert!(end >= Utc.with_ymd_and_hms(2025, 1, 15, 14, 30, 45).unwrap());
99    }
100
101    #[test]
102    fn test_from_local_datetime_single() {
103        let tz = Utc;
104        // Use the newer DateTime::from_timestamp instead of deprecated NaiveDateTime::from_timestamp_opt
105        let dt = DateTime::from_timestamp(1609459200, 0).unwrap(); // 2021-01-01 00:00:00 UTC
106        let naive = dt.naive_utc();
107        let result = from_local_datetime(&tz, naive);
108        assert_eq!(result.timestamp(), 1609459200);
109    }
110
111    #[test]
112    fn test_start_end_of_day_constants() {
113        // Test that the constants are what we expect
114        let start = start_of_day_naive();
115        let end = end_of_day_naive();
116
117        assert_eq!(start, NaiveTime::from_hms_opt(0, 0, 0).unwrap());
118        assert_eq!(
119            end,
120            NaiveTime::from_hms_nano_opt(23, 59, 59, 1_999_999_999).unwrap()
121        );
122    }
123}