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    /// Truncates this time to the given unit, preserving the offset.
191    ///
192    /// - `"hour"`: zeros minutes, seconds, and nanoseconds
193    /// - `"minute"`: zeros seconds and nanoseconds
194    /// - `"second"`: zeros nanoseconds
195    #[must_use]
196    pub fn truncate(&self, unit: &str) -> Option<Self> {
197        let truncated_nanos = match unit {
198            "hour" => (self.nanos / NANOS_PER_HOUR) * NANOS_PER_HOUR,
199            "minute" => (self.nanos / NANOS_PER_MINUTE) * NANOS_PER_MINUTE,
200            "second" => (self.nanos / NANOS_PER_SECOND) * NANOS_PER_SECOND,
201            _ => return None,
202        };
203        Some(Self {
204            nanos: truncated_nanos,
205            offset: self.offset,
206        })
207    }
208
209    /// Returns UTC-normalized nanoseconds (for comparison).
210    fn utc_nanos(&self) -> u64 {
211        match self.offset {
212            Some(off) => {
213                let adjusted = self.nanos as i64 - off as i64 * NANOS_PER_SECOND as i64;
214                adjusted.rem_euclid(NANOS_PER_DAY as i64) as u64
215            }
216            None => self.nanos,
217        }
218    }
219}
220
221impl Default for Time {
222    fn default() -> Self {
223        Self {
224            nanos: 0,
225            offset: None,
226        }
227    }
228}
229
230impl Ord for Time {
231    fn cmp(&self, other: &Self) -> Ordering {
232        // Compare by UTC-normalized value when both have offsets,
233        // or by raw nanos when neither has an offset.
234        // Mixed offset/no-offset compares raw nanos as fallback.
235        match (self.offset, other.offset) {
236            (Some(_), Some(_)) => self.utc_nanos().cmp(&other.utc_nanos()),
237            _ => self.nanos.cmp(&other.nanos),
238        }
239    }
240}
241
242impl PartialOrd for Time {
243    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
244        Some(self.cmp(other))
245    }
246}
247
248/// Parses an optional timezone offset suffix from a time string.
249/// Returns (time_part, offset_in_seconds).
250fn parse_offset_suffix(s: &str) -> (&str, Option<i32>) {
251    if let Some(rest) = s.strip_suffix('Z') {
252        return (rest, Some(0));
253    }
254    // Look for +HH:MM or -HH:MM at the end
255    if s.len() >= 6 {
256        let sign_pos = s.len() - 6;
257        let candidate = &s[sign_pos..];
258        if (candidate.starts_with('+') || candidate.starts_with('-'))
259            && candidate.as_bytes()[3] == b':'
260        {
261            let sign: i32 = if candidate.starts_with('+') { 1 } else { -1 };
262            if let (Ok(h), Ok(m)) = (
263                candidate[1..3].parse::<i32>(),
264                candidate[4..6].parse::<i32>(),
265            ) {
266                return (&s[..sign_pos], Some(sign * (h * 3600 + m * 60)));
267            }
268        }
269    }
270    (s, None)
271}
272
273impl fmt::Debug for Time {
274    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
275        write!(f, "Time({})", self)
276    }
277}
278
279impl fmt::Display for Time {
280    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
281        let h = self.hour();
282        let m = self.minute();
283        let s = self.second();
284        let ns = self.nanosecond();
285
286        if ns > 0 {
287            // Trim trailing zeros from fractional part
288            let frac = format!("{:09}", ns);
289            let trimmed = frac.trim_end_matches('0');
290            write!(f, "{h:02}:{m:02}:{s:02}.{trimmed}")?;
291        } else {
292            write!(f, "{h:02}:{m:02}:{s:02}")?;
293        }
294
295        match self.offset {
296            Some(0) => write!(f, "Z"),
297            Some(off) => {
298                let sign = if off >= 0 { '+' } else { '-' };
299                let abs = off.unsigned_abs();
300                let oh = abs / 3600;
301                let om = (abs % 3600) / 60;
302                write!(f, "{sign}{oh:02}:{om:02}")
303            }
304            None => Ok(()),
305        }
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn test_basic() {
315        let t = Time::from_hms(14, 30, 45).unwrap();
316        assert_eq!(t.hour(), 14);
317        assert_eq!(t.minute(), 30);
318        assert_eq!(t.second(), 45);
319        assert_eq!(t.nanosecond(), 0);
320    }
321
322    #[test]
323    fn test_with_nanos() {
324        let t = Time::from_hms_nano(0, 0, 0, 123_456_789).unwrap();
325        assert_eq!(t.nanosecond(), 123_456_789);
326        assert_eq!(t.to_string(), "00:00:00.123456789");
327    }
328
329    #[test]
330    fn test_validation() {
331        assert!(Time::from_hms(24, 0, 0).is_none());
332        assert!(Time::from_hms(0, 60, 0).is_none());
333        assert!(Time::from_hms(0, 0, 60).is_none());
334        assert!(Time::from_hms_nano(0, 0, 0, 1_000_000_000).is_none());
335    }
336
337    #[test]
338    fn test_parse_basic() {
339        let t = Time::parse("14:30:00").unwrap();
340        assert_eq!(t.hour(), 14);
341        assert_eq!(t.minute(), 30);
342        assert_eq!(t.second(), 0);
343        assert!(t.offset_seconds().is_none());
344    }
345
346    #[test]
347    fn test_parse_with_offset() {
348        let t = Time::parse("14:30:00+02:00").unwrap();
349        assert_eq!(t.hour(), 14);
350        assert_eq!(t.offset_seconds(), Some(7200));
351
352        let t = Time::parse("14:30:00Z").unwrap();
353        assert_eq!(t.offset_seconds(), Some(0));
354
355        let t = Time::parse("14:30:00-05:30").unwrap();
356        assert_eq!(t.offset_seconds(), Some(-19800));
357    }
358
359    #[test]
360    fn test_parse_fractional() {
361        let t = Time::parse("14:30:00.5").unwrap();
362        assert_eq!(t.nanosecond(), 500_000_000);
363
364        let t = Time::parse("14:30:00.123").unwrap();
365        assert_eq!(t.nanosecond(), 123_000_000);
366    }
367
368    #[test]
369    fn test_display() {
370        assert_eq!(Time::from_hms(9, 5, 3).unwrap().to_string(), "09:05:03");
371        assert_eq!(
372            Time::from_hms(14, 30, 0)
373                .unwrap()
374                .with_offset(0)
375                .to_string(),
376            "14:30:00Z"
377        );
378        assert_eq!(
379            Time::from_hms(14, 30, 0)
380                .unwrap()
381                .with_offset(5 * 3600 + 30 * 60)
382                .to_string(),
383            "14:30:00+05:30"
384        );
385    }
386
387    #[test]
388    fn test_ordering() {
389        let t1 = Time::from_hms(10, 0, 0).unwrap();
390        let t2 = Time::from_hms(14, 0, 0).unwrap();
391        assert!(t1 < t2);
392    }
393
394    #[test]
395    fn test_ordering_with_offsets() {
396        // 14:00 UTC+2 = 12:00 UTC
397        // 13:00 UTC+0 = 13:00 UTC
398        // So the UTC+2 time is earlier in UTC
399        let t1 = Time::from_hms(14, 0, 0).unwrap().with_offset(7200);
400        let t2 = Time::from_hms(13, 0, 0).unwrap().with_offset(0);
401        assert!(t1 < t2);
402    }
403
404    #[test]
405    fn test_truncate() {
406        let t = Time::from_hms_nano(14, 30, 45, 123_456_789).unwrap();
407
408        let hour = t.truncate("hour").unwrap();
409        assert_eq!(hour.hour(), 14);
410        assert_eq!(hour.minute(), 0);
411        assert_eq!(hour.second(), 0);
412        assert_eq!(hour.nanosecond(), 0);
413
414        let minute = t.truncate("minute").unwrap();
415        assert_eq!(minute.hour(), 14);
416        assert_eq!(minute.minute(), 30);
417        assert_eq!(minute.second(), 0);
418
419        let second = t.truncate("second").unwrap();
420        assert_eq!(second.hour(), 14);
421        assert_eq!(second.minute(), 30);
422        assert_eq!(second.second(), 45);
423        assert_eq!(second.nanosecond(), 0);
424
425        assert!(t.truncate("day").is_none());
426    }
427
428    #[test]
429    fn test_truncate_preserves_offset() {
430        let t = Time::from_hms_nano(14, 30, 45, 0)
431            .unwrap()
432            .with_offset(19800); // +05:30
433        let truncated = t.truncate("hour").unwrap();
434        assert_eq!(truncated.offset_seconds(), Some(19800));
435        assert_eq!(truncated.hour(), 14);
436        assert_eq!(truncated.minute(), 0);
437    }
438
439    #[test]
440    fn test_default() {
441        let t = Time::default();
442        assert_eq!(t.hour(), 0);
443        assert_eq!(t.minute(), 0);
444        assert_eq!(t.second(), 0);
445    }
446}