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