Skip to main content

grafeo_common/types/
time.rs

1//! Time of day type with optional UTC offset.
2
3use serde::{Deserialize, Serialize};
4use std::cmp::Ordering;
5use std::fmt;
6
7/// Maximum nanoseconds in a day (exclusive).
8const NANOS_PER_DAY: u64 = 86_400_000_000_000;
9const NANOS_PER_HOUR: u64 = 3_600_000_000_000;
10const NANOS_PER_MINUTE: u64 = 60_000_000_000;
11const NANOS_PER_SECOND: u64 = 1_000_000_000;
12
13/// A time of day with optional UTC offset.
14///
15/// Stored as nanoseconds since midnight plus an optional UTC offset in seconds.
16/// Without an offset, this is a "local time."
17///
18/// # Examples
19///
20/// ```
21/// use grafeo_common::types::Time;
22///
23/// let t = Time::from_hms(14, 30, 0).unwrap();
24/// assert_eq!(t.hour(), 14);
25/// assert_eq!(t.minute(), 30);
26/// assert_eq!(t.to_string(), "14:30:00");
27///
28/// let tz = t.with_offset(3600); // +01:00
29/// assert_eq!(tz.to_string(), "14:30:00+01:00");
30/// ```
31#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
32pub struct Time {
33    /// Nanoseconds since midnight (0..86_400_000_000_000).
34    nanos: u64,
35    /// UTC offset in seconds, or None for local time.
36    offset: Option<i32>,
37}
38
39impl Time {
40    /// Creates a time from hours (0-23), minutes (0-59), and seconds (0-59).
41    #[must_use]
42    pub fn from_hms(hour: u32, min: u32, sec: u32) -> Option<Self> {
43        Self::from_hms_nano(hour, min, sec, 0)
44    }
45
46    /// Creates a time from hours, minutes, seconds, and nanoseconds.
47    #[must_use]
48    pub fn from_hms_nano(hour: u32, min: u32, sec: u32, nano: u32) -> Option<Self> {
49        if hour >= 24 || min >= 60 || sec >= 60 || nano >= 1_000_000_000 {
50            return None;
51        }
52        let nanos = hour as u64 * NANOS_PER_HOUR
53            + min as u64 * NANOS_PER_MINUTE
54            + sec as u64 * NANOS_PER_SECOND
55            + nano as u64;
56        Some(Self {
57            nanos,
58            offset: None,
59        })
60    }
61
62    /// Creates a time from nanoseconds since midnight.
63    #[must_use]
64    pub fn from_nanos(nanos: u64) -> Option<Self> {
65        if nanos >= NANOS_PER_DAY {
66            return None;
67        }
68        Some(Self {
69            nanos,
70            offset: None,
71        })
72    }
73
74    /// Returns a new Time with the given UTC offset in seconds.
75    #[must_use]
76    pub fn with_offset(self, offset_secs: i32) -> Self {
77        Self {
78            nanos: self.nanos,
79            offset: Some(offset_secs),
80        }
81    }
82
83    /// Returns the hour component (0-23).
84    #[must_use]
85    pub fn hour(&self) -> u32 {
86        (self.nanos / NANOS_PER_HOUR) as u32
87    }
88
89    /// Returns the minute component (0-59).
90    #[must_use]
91    pub fn minute(&self) -> u32 {
92        ((self.nanos % NANOS_PER_HOUR) / NANOS_PER_MINUTE) as u32
93    }
94
95    /// Returns the second component (0-59).
96    #[must_use]
97    pub fn second(&self) -> u32 {
98        ((self.nanos % NANOS_PER_MINUTE) / NANOS_PER_SECOND) as u32
99    }
100
101    /// Returns the nanosecond component (0-999_999_999).
102    #[must_use]
103    pub fn nanosecond(&self) -> u32 {
104        (self.nanos % NANOS_PER_SECOND) as u32
105    }
106
107    /// Returns the total nanoseconds since midnight.
108    #[must_use]
109    pub fn as_nanos(&self) -> u64 {
110        self.nanos
111    }
112
113    /// Returns the UTC offset in seconds, if present.
114    #[must_use]
115    pub fn offset_seconds(&self) -> Option<i32> {
116        self.offset
117    }
118
119    /// Parses a time from ISO 8601 format `HH:MM:SS[.nnn][+HH:MM|Z]`.
120    #[must_use]
121    pub fn parse(s: &str) -> Option<Self> {
122        // Split off timezone suffix
123        let (time_part, offset) = parse_offset_suffix(s);
124
125        let parts: Vec<&str> = time_part.splitn(2, '.').collect();
126        let hms: Vec<&str> = parts[0].splitn(3, ':').collect();
127        if hms.len() < 2 {
128            return None;
129        }
130
131        let hour: u32 = hms[0].parse().ok()?;
132        let min: u32 = hms[1].parse().ok()?;
133        let sec: u32 = if hms.len() == 3 {
134            hms[2].parse().ok()?
135        } else {
136            0
137        };
138
139        // Parse fractional seconds
140        let nano: u32 = if parts.len() == 2 {
141            let frac = parts[1];
142            // Pad or truncate to 9 digits
143            let padded = if frac.len() >= 9 {
144                &frac[..9]
145            } else {
146                // We need to pad, but can't modify the slice, so parse differently
147                return {
148                    let n: u32 = frac.parse().ok()?;
149                    // reason: frac.len() is at most 8 here (< 9 branch), so 9 - len fits u32
150                    #[allow(clippy::cast_possible_truncation)]
151                    let scale = 10u32.pow(9 - frac.len() as u32);
152                    let mut t = Self::from_hms_nano(hour, min, sec, n * scale)?;
153                    if let Some(off) = offset {
154                        t = t.with_offset(off);
155                    }
156                    Some(t)
157                };
158            };
159            padded.parse().ok()?
160        } else {
161            0
162        };
163
164        let mut t = Self::from_hms_nano(hour, min, sec, nano)?;
165        if let Some(off) = offset {
166            t = t.with_offset(off);
167        }
168        Some(t)
169    }
170
171    /// Returns the current local time (UTC).
172    #[must_use]
173    pub fn now() -> Self {
174        let ts = super::Timestamp::now();
175        ts.to_time()
176    }
177
178    /// Adds a duration's time component to this time.
179    ///
180    /// Only the nanosecond component of the duration is used (months and days
181    /// are not meaningful for time-of-day). The result wraps around at midnight.
182    #[must_use]
183    pub fn add_duration(self, dur: &super::Duration) -> Self {
184        // reason: nanos < NANOS_PER_DAY (86.4e12), well within i64 range
185        #[allow(clippy::cast_possible_wrap)]
186        let total = self.nanos as i64 + dur.nanos();
187        // reason: NANOS_PER_DAY (86.4e12) is well within i64::MAX
188        #[allow(clippy::cast_possible_wrap)]
189        let wrapped = total.rem_euclid(NANOS_PER_DAY as i64) as u64;
190        Self {
191            nanos: wrapped,
192            offset: self.offset,
193        }
194    }
195
196    /// Truncates this time to the given unit, preserving the offset.
197    ///
198    /// - `"hour"`: zeros minutes, seconds, and nanoseconds
199    /// - `"minute"`: zeros seconds and nanoseconds
200    /// - `"second"`: zeros nanoseconds
201    #[must_use]
202    pub fn truncate(&self, unit: &str) -> Option<Self> {
203        let truncated_nanos = match unit {
204            "hour" => (self.nanos / NANOS_PER_HOUR) * NANOS_PER_HOUR,
205            "minute" => (self.nanos / NANOS_PER_MINUTE) * NANOS_PER_MINUTE,
206            "second" => (self.nanos / NANOS_PER_SECOND) * NANOS_PER_SECOND,
207            _ => return None,
208        };
209        Some(Self {
210            nanos: truncated_nanos,
211            offset: self.offset,
212        })
213    }
214
215    /// Returns UTC-normalized nanoseconds (for comparison).
216    fn utc_nanos(&self) -> u64 {
217        match self.offset {
218            Some(off) => {
219                // reason: nanos < NANOS_PER_DAY and constants are well within i64 range
220                #[allow(clippy::cast_possible_wrap)]
221                let adjusted = self.nanos as i64 - off as i64 * NANOS_PER_SECOND as i64;
222                // reason: rem_euclid result is non-negative and < NANOS_PER_DAY, fits u64
223                #[allow(clippy::cast_possible_wrap)]
224                let result = adjusted.rem_euclid(NANOS_PER_DAY as i64) as u64;
225                result
226            }
227            None => self.nanos,
228        }
229    }
230}
231
232impl Default for Time {
233    fn default() -> Self {
234        Self {
235            nanos: 0,
236            offset: None,
237        }
238    }
239}
240
241impl Ord for Time {
242    fn cmp(&self, other: &Self) -> Ordering {
243        // Compare by UTC-normalized value when both have offsets,
244        // or by raw nanos when neither has an offset.
245        // Mixed offset/no-offset compares raw nanos as fallback.
246        match (self.offset, other.offset) {
247            (Some(_), Some(_)) => self.utc_nanos().cmp(&other.utc_nanos()),
248            _ => self.nanos.cmp(&other.nanos),
249        }
250    }
251}
252
253impl PartialOrd for Time {
254    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
255        Some(self.cmp(other))
256    }
257}
258
259/// Parses an optional timezone offset suffix from a time string.
260/// Returns (time_part, offset_in_seconds).
261fn parse_offset_suffix(s: &str) -> (&str, Option<i32>) {
262    if let Some(rest) = s.strip_suffix('Z') {
263        return (rest, Some(0));
264    }
265    // Look for +HH:MM or -HH:MM at the end
266    if s.len() >= 6 {
267        let sign_pos = s.len() - 6;
268        let candidate = &s[sign_pos..];
269        if (candidate.starts_with('+') || candidate.starts_with('-'))
270            && candidate.as_bytes()[3] == b':'
271        {
272            let sign: i32 = if candidate.starts_with('+') { 1 } else { -1 };
273            if let (Ok(h), Ok(m)) = (
274                candidate[1..3].parse::<i32>(),
275                candidate[4..6].parse::<i32>(),
276            ) {
277                return (&s[..sign_pos], Some(sign * (h * 3600 + m * 60)));
278            }
279        }
280    }
281    (s, None)
282}
283
284impl fmt::Debug for Time {
285    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
286        write!(f, "Time({})", self)
287    }
288}
289
290impl fmt::Display for Time {
291    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
292        let h = self.hour();
293        let m = self.minute();
294        let s = self.second();
295        let ns = self.nanosecond();
296
297        if ns > 0 {
298            // Trim trailing zeros from fractional part
299            let frac = format!("{:09}", ns);
300            let trimmed = frac.trim_end_matches('0');
301            write!(f, "{h:02}:{m:02}:{s:02}.{trimmed}")?;
302        } else {
303            write!(f, "{h:02}:{m:02}:{s:02}")?;
304        }
305
306        match self.offset {
307            Some(0) => write!(f, "Z"),
308            Some(off) => {
309                let sign = if off >= 0 { '+' } else { '-' };
310                let abs = off.unsigned_abs();
311                let oh = abs / 3600;
312                let om = (abs % 3600) / 60;
313                write!(f, "{sign}{oh:02}:{om:02}")
314            }
315            None => Ok(()),
316        }
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn test_basic() {
326        let t = Time::from_hms(14, 30, 45).unwrap();
327        assert_eq!(t.hour(), 14);
328        assert_eq!(t.minute(), 30);
329        assert_eq!(t.second(), 45);
330        assert_eq!(t.nanosecond(), 0);
331    }
332
333    #[test]
334    fn test_with_nanos() {
335        let t = Time::from_hms_nano(0, 0, 0, 123_456_789).unwrap();
336        assert_eq!(t.nanosecond(), 123_456_789);
337        assert_eq!(t.to_string(), "00:00:00.123456789");
338    }
339
340    #[test]
341    fn test_validation() {
342        assert!(Time::from_hms(24, 0, 0).is_none());
343        assert!(Time::from_hms(0, 60, 0).is_none());
344        assert!(Time::from_hms(0, 0, 60).is_none());
345        assert!(Time::from_hms_nano(0, 0, 0, 1_000_000_000).is_none());
346    }
347
348    #[test]
349    fn test_parse_basic() {
350        let t = Time::parse("14:30:00").unwrap();
351        assert_eq!(t.hour(), 14);
352        assert_eq!(t.minute(), 30);
353        assert_eq!(t.second(), 0);
354        assert!(t.offset_seconds().is_none());
355    }
356
357    #[test]
358    fn test_parse_with_offset() {
359        let t = Time::parse("14:30:00+02:00").unwrap();
360        assert_eq!(t.hour(), 14);
361        assert_eq!(t.offset_seconds(), Some(7200));
362
363        let t = Time::parse("14:30:00Z").unwrap();
364        assert_eq!(t.offset_seconds(), Some(0));
365
366        let t = Time::parse("14:30:00-05:30").unwrap();
367        assert_eq!(t.offset_seconds(), Some(-19800));
368    }
369
370    #[test]
371    fn test_parse_fractional() {
372        let t = Time::parse("14:30:00.5").unwrap();
373        assert_eq!(t.nanosecond(), 500_000_000);
374
375        let t = Time::parse("14:30:00.123").unwrap();
376        assert_eq!(t.nanosecond(), 123_000_000);
377    }
378
379    #[test]
380    fn test_display() {
381        assert_eq!(Time::from_hms(9, 5, 3).unwrap().to_string(), "09:05:03");
382        assert_eq!(
383            Time::from_hms(14, 30, 0)
384                .unwrap()
385                .with_offset(0)
386                .to_string(),
387            "14:30:00Z"
388        );
389        assert_eq!(
390            Time::from_hms(14, 30, 0)
391                .unwrap()
392                .with_offset(5 * 3600 + 30 * 60)
393                .to_string(),
394            "14:30:00+05:30"
395        );
396    }
397
398    #[test]
399    fn test_ordering() {
400        let t1 = Time::from_hms(10, 0, 0).unwrap();
401        let t2 = Time::from_hms(14, 0, 0).unwrap();
402        assert!(t1 < t2);
403    }
404
405    #[test]
406    fn test_ordering_with_offsets() {
407        // 14:00 UTC+2 = 12:00 UTC
408        // 13:00 UTC+0 = 13:00 UTC
409        // So the UTC+2 time is earlier in UTC
410        let t1 = Time::from_hms(14, 0, 0).unwrap().with_offset(7200);
411        let t2 = Time::from_hms(13, 0, 0).unwrap().with_offset(0);
412        assert!(t1 < t2);
413    }
414
415    #[test]
416    fn test_truncate() {
417        let t = Time::from_hms_nano(14, 30, 45, 123_456_789).unwrap();
418
419        let hour = t.truncate("hour").unwrap();
420        assert_eq!(hour.hour(), 14);
421        assert_eq!(hour.minute(), 0);
422        assert_eq!(hour.second(), 0);
423        assert_eq!(hour.nanosecond(), 0);
424
425        let minute = t.truncate("minute").unwrap();
426        assert_eq!(minute.hour(), 14);
427        assert_eq!(minute.minute(), 30);
428        assert_eq!(minute.second(), 0);
429
430        let second = t.truncate("second").unwrap();
431        assert_eq!(second.hour(), 14);
432        assert_eq!(second.minute(), 30);
433        assert_eq!(second.second(), 45);
434        assert_eq!(second.nanosecond(), 0);
435
436        assert!(t.truncate("day").is_none());
437    }
438
439    #[test]
440    fn test_truncate_preserves_offset() {
441        let t = Time::from_hms_nano(14, 30, 45, 0)
442            .unwrap()
443            .with_offset(19800); // +05:30
444        let truncated = t.truncate("hour").unwrap();
445        assert_eq!(truncated.offset_seconds(), Some(19800));
446        assert_eq!(truncated.hour(), 14);
447        assert_eq!(truncated.minute(), 0);
448    }
449
450    #[test]
451    fn test_default() {
452        let t = Time::default();
453        assert_eq!(t.hour(), 0);
454        assert_eq!(t.minute(), 0);
455        assert_eq!(t.second(), 0);
456    }
457
458    #[test]
459    fn test_add_duration_wraps_at_midnight() {
460        use crate::types::Duration;
461        // 23:00:00 + 2 hours should wrap to 01:00:00
462        let t = Time::from_hms(23, 0, 0).unwrap();
463        let dur = Duration::from_nanos(2 * 3_600_000_000_000); // 2 hours
464        let result = t.add_duration(&dur);
465        assert_eq!(result.hour(), 1);
466        assert_eq!(result.minute(), 0);
467    }
468
469    #[test]
470    fn test_add_duration_within_day() {
471        use crate::types::Duration;
472        let t = Time::from_hms(10, 30, 0).unwrap();
473        let dur = Duration::from_nanos(90 * 60 * 1_000_000_000); // 90 minutes
474        let result = t.add_duration(&dur);
475        assert_eq!(result.hour(), 12);
476        assert_eq!(result.minute(), 0);
477    }
478}