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                    let scale = 10u32.pow(9 - frac.len() as u32);
150                    let mut t = Self::from_hms_nano(hour, min, sec, n * scale)?;
151                    if let Some(off) = offset {
152                        t = t.with_offset(off);
153                    }
154                    Some(t)
155                };
156            };
157            padded.parse().ok()?
158        } else {
159            0
160        };
161
162        let mut t = Self::from_hms_nano(hour, min, sec, nano)?;
163        if let Some(off) = offset {
164            t = t.with_offset(off);
165        }
166        Some(t)
167    }
168
169    /// Returns the current local time (UTC).
170    #[must_use]
171    pub fn now() -> Self {
172        let ts = super::Timestamp::now();
173        ts.to_time()
174    }
175
176    /// Adds a duration's time component to this time.
177    ///
178    /// Only the nanosecond component of the duration is used (months and days
179    /// are not meaningful for time-of-day). The result wraps around at midnight.
180    #[must_use]
181    pub fn add_duration(self, dur: &super::Duration) -> Self {
182        let total = self.nanos as i64 + dur.nanos();
183        let wrapped = total.rem_euclid(NANOS_PER_DAY as i64) as u64;
184        Self {
185            nanos: wrapped,
186            offset: self.offset,
187        }
188    }
189
190    /// Returns UTC-normalized nanoseconds (for comparison).
191    fn utc_nanos(&self) -> u64 {
192        match self.offset {
193            Some(off) => {
194                let adjusted = self.nanos as i64 - off as i64 * NANOS_PER_SECOND as i64;
195                adjusted.rem_euclid(NANOS_PER_DAY as i64) as u64
196            }
197            None => self.nanos,
198        }
199    }
200}
201
202impl Default for Time {
203    fn default() -> Self {
204        Self {
205            nanos: 0,
206            offset: None,
207        }
208    }
209}
210
211impl Ord for Time {
212    fn cmp(&self, other: &Self) -> Ordering {
213        // Compare by UTC-normalized value when both have offsets,
214        // or by raw nanos when neither has an offset.
215        // Mixed offset/no-offset compares raw nanos as fallback.
216        match (self.offset, other.offset) {
217            (Some(_), Some(_)) => self.utc_nanos().cmp(&other.utc_nanos()),
218            _ => self.nanos.cmp(&other.nanos),
219        }
220    }
221}
222
223impl PartialOrd for Time {
224    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
225        Some(self.cmp(other))
226    }
227}
228
229/// Parses an optional timezone offset suffix from a time string.
230/// Returns (time_part, offset_in_seconds).
231fn parse_offset_suffix(s: &str) -> (&str, Option<i32>) {
232    if let Some(rest) = s.strip_suffix('Z') {
233        return (rest, Some(0));
234    }
235    // Look for +HH:MM or -HH:MM at the end
236    if s.len() >= 6 {
237        let sign_pos = s.len() - 6;
238        let candidate = &s[sign_pos..];
239        if (candidate.starts_with('+') || candidate.starts_with('-'))
240            && candidate.as_bytes()[3] == b':'
241        {
242            let sign: i32 = if candidate.starts_with('+') { 1 } else { -1 };
243            if let (Ok(h), Ok(m)) = (
244                candidate[1..3].parse::<i32>(),
245                candidate[4..6].parse::<i32>(),
246            ) {
247                return (&s[..sign_pos], Some(sign * (h * 3600 + m * 60)));
248            }
249        }
250    }
251    (s, None)
252}
253
254impl fmt::Debug for Time {
255    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
256        write!(f, "Time({})", self)
257    }
258}
259
260impl fmt::Display for Time {
261    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
262        let h = self.hour();
263        let m = self.minute();
264        let s = self.second();
265        let ns = self.nanosecond();
266
267        if ns > 0 {
268            // Trim trailing zeros from fractional part
269            let frac = format!("{:09}", ns);
270            let trimmed = frac.trim_end_matches('0');
271            write!(f, "{h:02}:{m:02}:{s:02}.{trimmed}")?;
272        } else {
273            write!(f, "{h:02}:{m:02}:{s:02}")?;
274        }
275
276        match self.offset {
277            Some(0) => write!(f, "Z"),
278            Some(off) => {
279                let sign = if off >= 0 { '+' } else { '-' };
280                let abs = off.unsigned_abs();
281                let oh = abs / 3600;
282                let om = (abs % 3600) / 60;
283                write!(f, "{sign}{oh:02}:{om:02}")
284            }
285            None => Ok(()),
286        }
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn test_basic() {
296        let t = Time::from_hms(14, 30, 45).unwrap();
297        assert_eq!(t.hour(), 14);
298        assert_eq!(t.minute(), 30);
299        assert_eq!(t.second(), 45);
300        assert_eq!(t.nanosecond(), 0);
301    }
302
303    #[test]
304    fn test_with_nanos() {
305        let t = Time::from_hms_nano(0, 0, 0, 123_456_789).unwrap();
306        assert_eq!(t.nanosecond(), 123_456_789);
307        assert_eq!(t.to_string(), "00:00:00.123456789");
308    }
309
310    #[test]
311    fn test_validation() {
312        assert!(Time::from_hms(24, 0, 0).is_none());
313        assert!(Time::from_hms(0, 60, 0).is_none());
314        assert!(Time::from_hms(0, 0, 60).is_none());
315        assert!(Time::from_hms_nano(0, 0, 0, 1_000_000_000).is_none());
316    }
317
318    #[test]
319    fn test_parse_basic() {
320        let t = Time::parse("14:30:00").unwrap();
321        assert_eq!(t.hour(), 14);
322        assert_eq!(t.minute(), 30);
323        assert_eq!(t.second(), 0);
324        assert!(t.offset_seconds().is_none());
325    }
326
327    #[test]
328    fn test_parse_with_offset() {
329        let t = Time::parse("14:30:00+02:00").unwrap();
330        assert_eq!(t.hour(), 14);
331        assert_eq!(t.offset_seconds(), Some(7200));
332
333        let t = Time::parse("14:30:00Z").unwrap();
334        assert_eq!(t.offset_seconds(), Some(0));
335
336        let t = Time::parse("14:30:00-05:30").unwrap();
337        assert_eq!(t.offset_seconds(), Some(-19800));
338    }
339
340    #[test]
341    fn test_parse_fractional() {
342        let t = Time::parse("14:30:00.5").unwrap();
343        assert_eq!(t.nanosecond(), 500_000_000);
344
345        let t = Time::parse("14:30:00.123").unwrap();
346        assert_eq!(t.nanosecond(), 123_000_000);
347    }
348
349    #[test]
350    fn test_display() {
351        assert_eq!(Time::from_hms(9, 5, 3).unwrap().to_string(), "09:05:03");
352        assert_eq!(
353            Time::from_hms(14, 30, 0)
354                .unwrap()
355                .with_offset(0)
356                .to_string(),
357            "14:30:00Z"
358        );
359        assert_eq!(
360            Time::from_hms(14, 30, 0)
361                .unwrap()
362                .with_offset(5 * 3600 + 30 * 60)
363                .to_string(),
364            "14:30:00+05:30"
365        );
366    }
367
368    #[test]
369    fn test_ordering() {
370        let t1 = Time::from_hms(10, 0, 0).unwrap();
371        let t2 = Time::from_hms(14, 0, 0).unwrap();
372        assert!(t1 < t2);
373    }
374
375    #[test]
376    fn test_ordering_with_offsets() {
377        // 14:00 UTC+2 = 12:00 UTC
378        // 13:00 UTC+0 = 13:00 UTC
379        // So the UTC+2 time is earlier in UTC
380        let t1 = Time::from_hms(14, 0, 0).unwrap().with_offset(7200);
381        let t2 = Time::from_hms(13, 0, 0).unwrap().with_offset(0);
382        assert!(t1 < t2);
383    }
384
385    #[test]
386    fn test_default() {
387        let t = Time::default();
388        assert_eq!(t.hour(), 0);
389        assert_eq!(t.minute(), 0);
390        assert_eq!(t.second(), 0);
391    }
392}