Skip to main content

pamoja_gateway/
time.rs

1//! The two timestamp formats the packet forwarder protocol prescribes.
2//!
3//! An `rxpk` object times a packet's reception in the ISO 8601 compact form with microsecond
4//! precision, `2013-03-31T16:21:17.528002Z`, and a `stat` object times the gateway's own
5//! clock in the expanded form `2014-01-12 08:59:28 GMT`. Both are UTC, and the protocol
6//! carries both as strings, so these turn a count from the Unix epoch into one and read one
7//! back. The calendar arithmetic is Howard Hinnant's `days_from_civil` and `civil_from_days`,
8//! which are exact for every year the protocol can carry.
9//!
10//! # Examples
11//!
12//! ```
13//! use pamoja_gateway::time;
14//!
15//! // The reception time of the protocol's own rxpk example.
16//! assert_eq!(time::compact(1_364_746_877_528_002), "2013-03-31T16:21:17.528002Z");
17//! assert_eq!(time::from_compact("2013-03-31T16:21:17.528002Z"), Some(1_364_746_877_528_002));
18//!
19//! // The gateway clock of its stat example.
20//! assert_eq!(time::expanded(1_389_517_168), "2014-01-12 08:59:28 GMT");
21//! ```
22
23/// Seconds in a day, which no leap second reaches this arithmetic.
24const DAY: u64 = 86_400;
25
26/// Microseconds in a second.
27const MICROS: u64 = 1_000_000;
28
29/// Formats a reception time as an `rxpk` carries it: ISO 8601, compact, to the microsecond.
30///
31/// # Arguments
32///
33/// * `micros_since_epoch` - the time in microseconds since 1970-01-01 UTC.
34///
35/// # Returns
36///
37/// The timestamp, such as `2013-03-31T16:21:17.528002Z`.
38pub fn compact(micros_since_epoch: u64) -> String {
39    let seconds = micros_since_epoch / MICROS;
40    let fraction = micros_since_epoch % MICROS;
41    let (year, month, day, hour, minute, second) = civil(seconds);
42    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{fraction:06}Z")
43}
44
45/// Formats a gateway's clock as a `stat` carries it: the expanded form, to the second.
46///
47/// # Arguments
48///
49/// * `seconds_since_epoch` - the time in seconds since 1970-01-01 UTC.
50///
51/// # Returns
52///
53/// The timestamp, such as `2014-01-12 08:59:28 GMT`.
54pub fn expanded(seconds_since_epoch: u64) -> String {
55    let (year, month, day, hour, minute, second) = civil(seconds_since_epoch);
56    format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02} GMT")
57}
58
59/// Reads an expanded timestamp back, as a `stat` object carries one.
60///
61/// # Arguments
62///
63/// * `text` - the timestamp, such as `2014-01-12 08:59:28 GMT`.
64///
65/// # Returns
66///
67/// The time in seconds since the epoch, or `None` when the text is not that shape or names
68/// no date on the calendar.
69pub fn from_expanded(text: &str) -> Option<u64> {
70    let body = text.strip_suffix(" GMT")?;
71    let (date, clock) = body.split_once(' ')?;
72    from_compact(&format!("{date}T{clock}Z")).map(|micros| micros / MICROS)
73}
74
75/// Reads a compact timestamp back.
76///
77/// The fraction may be absent or one to six digits, since a gateway that times to the
78/// millisecond writes three.
79///
80/// # Arguments
81///
82/// * `text` - the timestamp, such as `2013-03-31T16:21:17.528002Z`.
83///
84/// # Returns
85///
86/// The time in microseconds since the epoch, or `None` when the text is not that shape or
87/// names no date on the calendar.
88pub fn from_compact(text: &str) -> Option<u64> {
89    let body = text.strip_suffix('Z')?;
90    let (date, rest) = body.split_once('T')?;
91    let (clock, fraction) = match rest.split_once('.') {
92        Some((clock, digits)) => (clock, micros_of(digits)?),
93        None => (rest, 0),
94    };
95
96    let mut parts = date.split('-');
97    let year: i64 = parts.next()?.parse().ok()?;
98    let month: u32 = parts.next()?.parse().ok()?;
99    let day: u32 = parts.next()?.parse().ok()?;
100    if parts.next().is_some() || !(1..=12).contains(&month) || !(1..=31).contains(&day) {
101        return None;
102    }
103
104    let mut clock = clock.split(':');
105    let hour: u64 = clock.next()?.parse().ok()?;
106    let minute: u64 = clock.next()?.parse().ok()?;
107    let second: u64 = clock.next()?.parse().ok()?;
108    if clock.next().is_some() || hour > 23 || minute > 59 || second > 60 {
109        return None;
110    }
111
112    let days = days_from_civil(year, month, day);
113    if days < 0 {
114        return None;
115    }
116    let seconds = u64::try_from(days).ok()? * DAY + hour * 3_600 + minute * 60 + second;
117    Some(seconds * MICROS + fraction)
118}
119
120/// Reads a fraction of a second, which the protocol writes with up to six digits.
121fn micros_of(digits: &str) -> Option<u64> {
122    if digits.is_empty() || digits.len() > 6 || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
123        return None;
124    }
125    let value: u64 = digits.parse().ok()?;
126    Some(value * 10u64.pow(6 - digits.len() as u32))
127}
128
129/// Splits a count of seconds into its civil date and time of day.
130fn civil(seconds_since_epoch: u64) -> (i64, u32, u32, u64, u64, u64) {
131    let days = (seconds_since_epoch / DAY) as i64;
132    let rest = seconds_since_epoch % DAY;
133    let (year, month, day) = civil_from_days(days);
134    (year, month, day, rest / 3_600, (rest / 60) % 60, rest % 60)
135}
136
137/// Returns the days from 1970-01-01 to a civil date, by Howard Hinnant's `days_from_civil`.
138fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
139    let year = year - i64::from(month <= 2);
140    let era = year.div_euclid(400);
141    let year_of_era = year - era * 400;
142    let day_of_year =
143        (153 * (i64::from(month) + if month > 2 { -3 } else { 9 }) + 2) / 5 + i64::from(day) - 1;
144    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
145    era * 146_097 + day_of_era - 719_468
146}
147
148/// Returns the civil date a day count names, by Howard Hinnant's `civil_from_days`.
149fn civil_from_days(days: i64) -> (i64, u32, u32) {
150    let days = days + 719_468;
151    let era = days.div_euclid(146_097);
152    let day_of_era = days - era * 146_097;
153    let year_of_era =
154        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
155    let year = year_of_era + era * 400;
156    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
157    let shifted = (5 * day_of_year + 2) / 153;
158    let day = (day_of_year - (153 * shifted + 2) / 5 + 1) as u32;
159    let month = (shifted + if shifted < 10 { 3 } else { -9 }) as u32;
160    (year + i64::from(month <= 2), month, day)
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn the_protocols_own_examples_format_and_parse() {
169        // The rxpk example of section 4, and the stat example beside it.
170        assert_eq!(
171            compact(1_364_746_877_528_002),
172            "2013-03-31T16:21:17.528002Z"
173        );
174        assert_eq!(
175            from_compact("2013-03-31T16:21:17.528002Z"),
176            Some(1_364_746_877_528_002)
177        );
178        assert_eq!(expanded(1_389_517_168), "2014-01-12 08:59:28 GMT");
179        assert_eq!(
180            from_expanded("2014-01-12 08:59:28 GMT"),
181            Some(1_389_517_168)
182        );
183        assert_eq!(from_expanded("2014-01-12T08:59:28Z"), None);
184    }
185
186    #[test]
187    fn the_calendar_holds_at_its_edges() {
188        assert_eq!(compact(0), "1970-01-01T00:00:00.000000Z");
189        // A leap day in a year divisible by 400, and the last microsecond of a leap year.
190        assert_eq!(compact(951_825_600_000_000), "2000-02-29T12:00:00.000000Z");
191        assert_eq!(
192            compact(1_735_689_599_999_999),
193            "2024-12-31T23:59:59.999999Z"
194        );
195        // 2100 is divisible by 100 but not by 400, so February has 28 days.
196        assert_eq!(
197            compact(4_107_542_400_000_000),
198            "2100-03-01T00:00:00.000000Z"
199        );
200    }
201
202    #[test]
203    fn a_shorter_fraction_is_read_as_written() {
204        assert_eq!(
205            from_compact("2013-03-31T16:21:17.528Z"),
206            Some(1_364_746_877_528_000)
207        );
208        assert_eq!(
209            from_compact("2013-03-31T16:21:17Z"),
210            Some(1_364_746_877_000_000)
211        );
212    }
213
214    #[test]
215    fn every_timestamp_survives_a_round_trip() {
216        let mut micros = 0u64;
217        while micros < 4_200_000_000_000_000 {
218            let text = compact(micros);
219            assert_eq!(from_compact(&text), Some(micros), "{text}");
220            micros += 97_777_777_777;
221        }
222    }
223
224    #[test]
225    fn what_is_not_a_timestamp_is_refused() {
226        for text in [
227            "2013-03-31T16:21:17.528002",
228            "2013-03-31 16:21:17.528002Z",
229            "2013-13-31T16:21:17.528002Z",
230            "2013-03-31T24:21:17.528002Z",
231            "2013-03-31T16:21:17.5280021Z",
232            "1969-12-31T23:59:59.000000Z",
233            "not a time",
234        ] {
235            assert_eq!(from_compact(text), None, "{text}");
236        }
237    }
238}