Skip to main content

rapport_temporal/
time.rs

1//! Simple way to deal with the current time, as an `Instant`.
2
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use chrono::{DateTime, Local, TimeZone, Utc};
6use facet::Facet;
7use serde::{Deserialize, Serialize};
8
9use crate::date::Date;
10
11#[derive(Facet, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
12pub struct Instant {
13    /// The number of seconds since the unix epoch.
14    pub seconds: u64,
15    pub nanos: u32,
16}
17
18impl Instant {
19    #[must_use]
20    pub fn duration_until_midnight(self) -> Duration {
21        let secs = self.seconds.try_into().unwrap_or_default();
22        // Convert to local datetime
23        let local_datetime = Local
24            .timestamp_opt(secs, self.nanos)
25            .earliest()
26            .or_else(|| Local.timestamp_opt(secs, self.nanos).latest())
27            .unwrap_or_else(|| {
28                // Fallback to UTC if local timezone fails
29                Utc.timestamp_opt(secs, self.nanos)
30                    .earliest()
31                    .unwrap_or_default()
32                    .with_timezone(&Local)
33            });
34
35        // Get start of next day
36        let next_day = local_datetime.date_naive() + chrono::Duration::days(1);
37        let next_midnight_naive = next_day.and_hms_opt(0, 0, 0).unwrap_or_default();
38
39        // Convert back to local timezone
40        let next_midnight = Local
41            .from_local_datetime(&next_midnight_naive)
42            .earliest()
43            .or_else(|| Local.from_local_datetime(&next_midnight_naive).latest())
44            .unwrap_or_else(|| {
45                Utc.from_utc_datetime(&next_midnight_naive)
46                    .with_timezone(&Local)
47            });
48
49        let next_midnight_timestamp = next_midnight.timestamp();
50        let seconds_until_midnight = next_midnight_timestamp.saturating_sub(secs);
51
52        Duration::from_secs(seconds_until_midnight.try_into().unwrap_or_default())
53    }
54
55    #[must_use]
56    pub fn into_date(self) -> Date {
57        self.into()
58    }
59
60    #[must_use]
61    pub fn from_utc_datetime(value: DateTime<Utc>) -> Self {
62        let timestamp = value.timestamp();
63        let seconds = timestamp.max(0).try_into().unwrap_or_default();
64        let nanos = value.timestamp_subsec_nanos();
65
66        Self { seconds, nanos }
67    }
68
69    #[must_use]
70    pub fn from_timestamp(seconds: i64) -> Self {
71        let seconds = seconds.max(0).try_into().unwrap_or_default();
72        Self { seconds, nanos: 0 }
73    }
74
75    #[must_use]
76    pub fn into_utc_datetime(self) -> DateTime<Utc> {
77        let seconds = i64::try_from(self.seconds).unwrap_or(i64::MAX);
78        // Convert timestamp to UTC datetime
79        Utc.timestamp_opt(seconds, self.nanos)
80            .earliest()
81            .unwrap_or_default()
82    }
83
84    #[must_use]
85    pub fn subtract_minutes(self, value: u64) -> Self {
86        let seconds = self.seconds.saturating_sub(value);
87        Self {
88            seconds,
89            nanos: self.nanos,
90        }
91    }
92
93    #[must_use]
94    pub fn add_minutes(self, value: u64) -> Self {
95        let seconds = self.seconds.saturating_add(value.saturating_mul(60));
96        Self {
97            seconds,
98            nanos: self.nanos,
99        }
100    }
101}
102
103impl std::fmt::Display for Instant {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        self.into_utc_datetime().fmt(f)
106    }
107}
108
109impl From<Date> for Instant {
110    fn from(value: Date) -> Self {
111        // Convert Date to NaiveDate
112        let naive_date: chrono::NaiveDate = value.into();
113
114        // Try to create midnight, fallback to start of day if needed
115        let midnight = naive_date.and_hms_opt(0, 0, 0).unwrap_or_else(|| {
116            naive_date
117                .and_hms_opt(0, 0, 1)
118                .unwrap_or(naive_date.and_hms_opt(1, 0, 0).unwrap_or_default())
119        });
120
121        // Try local timezone first, with fallbacks for DST issues
122        let seconds = if let Some(local_dt) = Local.from_local_datetime(&midnight).earliest() {
123            local_dt.timestamp().max(0)
124        } else if let Some(local_dt) = Local.from_local_datetime(&midnight).latest() {
125            local_dt.timestamp().max(0)
126        } else {
127            // Fallback to UTC if local time is problematic
128            Utc.from_utc_datetime(&midnight).timestamp().max(0)
129        };
130        Instant {
131            seconds: seconds.max(0).try_into().unwrap_or_default(),
132            nanos: 0,
133        }
134    }
135}
136
137impl From<Instant> for Date {
138    fn from(value: Instant) -> Self {
139        let seconds = i64::try_from(value.seconds).unwrap_or(i64::MAX);
140        // Convert timestamp to local datetime (matching the forward conversion logic)
141        let local_datetime = Local
142            .timestamp_opt(seconds, value.nanos)
143            .earliest()
144            .or_else(|| Local.timestamp_opt(seconds, value.nanos).latest())
145            .unwrap_or_else(|| {
146                // Fallback to UTC if local timezone fails
147                Utc.timestamp_opt(seconds, value.nanos)
148                    .earliest()
149                    .unwrap_or_default()
150                    .with_timezone(&Local)
151            });
152        let naive_date = local_datetime.date_naive();
153
154        Date::from(naive_date)
155    }
156}
157
158impl From<SystemTime> for Instant {
159    fn from(value: SystemTime) -> Self {
160        let duration = value.duration_since(UNIX_EPOCH).unwrap_or_default();
161        let seconds = duration.as_secs();
162        Instant {
163            seconds,
164            nanos: duration.subsec_nanos(),
165        }
166    }
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, derive_more::Display)]
170#[display("{} seconds", self.as_secs())]
171pub struct Duration {
172    pub nanos: u64,
173}
174
175impl Duration {
176    #[must_use]
177    pub fn from_secs(secs: u64) -> Self {
178        Self {
179            nanos: secs * 1_000_000_000,
180        }
181    }
182
183    #[must_use]
184    pub fn as_secs(&self) -> u64 {
185        self.nanos / 1_000_000_000
186    }
187
188    #[must_use]
189    pub fn from_std(duration: std::time::Duration) -> Self {
190        Self {
191            nanos: (duration.as_secs() * 1_000_000_000)
192                .saturating_add(duration.subsec_nanos().into()),
193        }
194    }
195
196    #[must_use]
197    pub fn into_std(&self) -> std::time::Duration {
198        std::time::Duration::from_nanos(self.nanos)
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use chrono::NaiveDate;
205    use claims::{assert_ok, assert_some};
206    use pretty_assertions::assert_eq;
207    use rstest::rstest;
208
209    use super::*;
210
211    #[rstest]
212    #[case::summer_date(2025, 7, 24)]
213    #[case::new_years_day(2024, 1, 1)]
214    #[case::independence_day(2024, 7, 4)]
215    #[case::new_years_eve(2024, 12, 31)]
216    #[case::leap_year_feb_29(2024, 2, 29)]
217    #[case::regular_feb_28(2023, 2, 28)]
218    #[case::end_of_month_31(2024, 1, 31)]
219    #[case::end_of_month_30(2024, 4, 30)]
220    fn from_date_should_convert_to_local_midnight(
221        #[case] year: i32,
222        #[case] month: u32,
223        #[case] day: u32,
224    ) {
225        let naive_date = assert_some!(
226            NaiveDate::from_ymd_opt(year, month, day),
227            "precondition: date is constructed"
228        );
229        let date = crate::date::Date::from(naive_date);
230
231        // initial conversion
232        let instant = Instant::from(date);
233
234        let expected_datetime = assert_some!(naive_date.and_hms_opt(0, 0, 0));
235        let expected_seconds = assert_some!(
236            Local.from_local_datetime(&expected_datetime).earliest(),
237            "expecting local time to exist"
238        )
239        .timestamp();
240
241        assert_eq!(
242            instant.seconds,
243            expected_seconds.max(0).try_into().unwrap_or_default()
244        );
245        assert_eq!(instant.nanos, 0);
246
247        // convert back to date
248        let converted_date = Date::from(instant);
249        assert_eq!(
250            converted_date, date,
251            "expecting instant to convert back into date"
252        );
253    }
254
255    #[rstest]
256    #[case::spring_dst_transition(2024, 3, 10)]
257    #[case::fall_dst_transition(2024, 11, 3)]
258    fn from_date_should_handle_dst_transitions(
259        #[case] year: i32,
260        #[case] month: u32,
261        #[case] day: u32,
262    ) {
263        let naive_date = assert_some!(
264            NaiveDate::from_ymd_opt(year, month, day),
265            "precondition: DST transition date is constructed"
266        );
267        let date = crate::date::Date::from(naive_date);
268
269        // Should always produce a valid instant regardless of DST complications
270        let instant = Instant::from(date);
271
272        assert!(
273            instant.seconds > 0,
274            "Should produce valid timestamp for DST transition"
275        );
276        assert_eq!(instant.nanos, 0);
277    }
278
279    #[test]
280    fn now_should_convert_to_instant() {
281        let instant: Instant = SystemTime::now().into();
282        assert!(instant.seconds > 0, "expecting instant to have seconds");
283    }
284
285    #[rstest]
286    #[case::early_morning("2024-07-15 02:00:00", 22 * 3600)] // ~22 hours
287    #[case::afternoon("2024-07-15 14:30:00", 9 * 3600 + 30 * 60)] // ~9.5 hours
288    #[case::late_evening("2024-07-15 23:30:00", 30 * 60)] // ~30 minutes
289    #[case::very_close_to_midnight("2024-07-15 23:59:59", 1)] // ~1 second
290    #[case::exactly_midnight("2024-07-15 00:00:00", 24 * 3600)] // ~24 hours
291    fn duration_until_midnight_should_calculate_correctly(
292        #[case] datetime_str: &str,
293        #[case] expected_seconds: u64,
294    ) {
295        // Parse the datetime string to create an Instant
296        let naive_datetime = assert_ok!(
297            chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%d %H:%M:%S"),
298            "parsing test datetime"
299        );
300
301        let local_datetime = assert_some!(
302            Local.from_local_datetime(&naive_datetime).earliest(),
303            "converting to local time"
304        );
305
306        let instant = Instant {
307            seconds: local_datetime.timestamp().try_into().unwrap_or_default(),
308            nanos: 0,
309        };
310
311        let duration = instant.duration_until_midnight();
312
313        // Allow for small differences due to timezone/DST calculations
314        let diff = if duration.as_secs() > expected_seconds {
315            duration.as_secs() - expected_seconds
316        } else {
317            expected_seconds - duration.as_secs()
318        };
319
320        assert!(
321            diff <= 1,
322            "Expected ~{} seconds, got {} seconds (diff: {})",
323            expected_seconds,
324            duration.as_secs(),
325            diff
326        );
327    }
328
329    #[test]
330    fn duration_until_midnight_should_handle_nanoseconds() {
331        // Create an instant with nanoseconds
332        let instant = Instant {
333            seconds: 1_721_030_400, // Some timestamp
334            nanos: 500_000_000,     // 0.5 seconds
335        };
336
337        let duration = instant.duration_until_midnight();
338
339        // The duration should account for the nanoseconds
340        // (exact value depends on the date, but should be reasonable)
341        assert!(
342            duration.as_secs() < 24 * 3600,
343            "Duration should be less than 24 hours"
344        );
345        assert!(duration.as_secs() > 0, "Duration should be positive");
346    }
347
348    #[rstest]
349    #[case::seconds(4, 0, 5, 0)]
350    #[case::nanos(5, 2, 5, 3)]
351    #[case::nanos_larger_but_seconds_smaller(5, 10, 6, 0)]
352    fn instant_comparison(
353        #[case] earlier_seconds: u64,
354        #[case] earlier_nanos: u32,
355        #[case] later_seconds: u64,
356        #[case] later_nanos: u32,
357    ) {
358        let earlier = Instant {
359            seconds: earlier_seconds,
360            nanos: earlier_nanos,
361        };
362        let later = Instant {
363            seconds: later_seconds,
364            nanos: later_nanos,
365        };
366
367        assert!(earlier < later, "expecting earlier to be less than later");
368        assert!(
369            earlier <= later,
370            "expecting earlier to be less than or equal to later"
371        );
372        assert!(
373            later > earlier,
374            "expecting later to be greater than earlier"
375        );
376        assert!(
377            later >= earlier,
378            "expecting later to be greater than or equal to earlier"
379        );
380    }
381}