Skip to main content

datetime/
lib.rs

1// datetime -- calendar time: timestamps, civil dates, RFC 3339, time zones, cron schedules
2//
3//   Timestamp { secs, nanos }          seconds since 1970-01-01 00:00 UTC
4//   Civil { year .. second, nanos }    a calendar date and wall time (no zone)
5//   days_from_civil / civil_from_days / days_in_month / is_leap / weekday (0 = Monday)
6//   rfc3339(ts) / rfc3339_offset(ts, offset) / parse_rfc3339(s)
7//   format_date_time(ts, &zone, en) / format_date / format_time / format_duration(secs, en)
8//   tz::Zone                           IANA zones with their current DST rule (zones.rs from
9//                                      tools/gen_tz.py), Windows zone keys, POSIX TZ strings;
10//                                      offset_at / to_local / from_local (Single, Ambiguous,
11//                                      Gap)
12//   cron::Cron                         5-field cron expressions: next_after in a zone (a time
13//                                      in a DST gap runs right after the gap, a repeated time
14//                                      runs once), next_n, describe (de / en)
15//
16// No clock here: plat::unix_time_ns gives "now"; datetime only computes.
17#![no_std]
18#![forbid(unsafe_code)]
19
20extern crate alloc;
21
22pub mod cron;
23pub mod tz;
24mod zones;
25
26use alloc::format;
27use alloc::string::String;
28
29pub use cron::{Cron, CronError};
30pub use tz::{LocalResult, Zone};
31
32/// a point in time: seconds since 1970-01-01 00:00:00 UTC and nanoseconds
33#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Default, Hash)]
34pub struct Timestamp {
35    pub secs: i64,
36    pub nanos: u32,
37}
38
39impl Timestamp {
40    pub const fn from_secs(secs: i64) -> Timestamp {
41        Timestamp { secs, nanos: 0 }
42    }
43    /// from nanoseconds since the epoch (plat::unix_time_ns)
44    pub const fn from_nanos(ns: i64) -> Timestamp {
45        Timestamp { secs: ns.div_euclid(1_000_000_000), nanos: ns.rem_euclid(1_000_000_000) as u32 }
46    }
47    pub const fn from_millis(ms: i64) -> Timestamp {
48        Timestamp { secs: ms.div_euclid(1000), nanos: (ms.rem_euclid(1000) * 1_000_000) as u32 }
49    }
50    pub const fn as_millis(&self) -> i64 {
51        self.secs.saturating_mul(1000).saturating_add((self.nanos / 1_000_000) as i64)
52    }
53    pub const fn add_secs(&self, s: i64) -> Timestamp {
54        Timestamp { secs: self.secs.saturating_add(s), nanos: self.nanos }
55    }
56}
57
58/// a calendar date and wall time without a zone
59#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Default, Hash)]
60pub struct Civil {
61    pub year: i32,
62    pub month: u8,
63    pub day: u8,
64    pub hour: u8,
65    pub minute: u8,
66    pub second: u8,
67    pub nanos: u32,
68}
69
70impl Civil {
71    pub const fn new(year: i32, month: u8, day: u8, hour: u8, minute: u8, second: u8) -> Civil {
72        Civil { year, month, day, hour, minute, second, nanos: 0 }
73    }
74    pub const fn date(year: i32, month: u8, day: u8) -> Civil {
75        Civil::new(year, month, day, 0, 0, 0)
76    }
77    /// the civil time of seconds since the epoch read as UTC
78    pub fn from_unix(secs: i64) -> Civil {
79        let days = secs.div_euclid(86_400);
80        let rem = secs.rem_euclid(86_400);
81        let (y, m, d) = civil_from_days(days);
82        Civil::new(y, m, d, (rem / 3600) as u8, (rem % 3600 / 60) as u8, (rem % 60) as u8)
83    }
84    /// seconds since the epoch of this wall time read as UTC
85    pub fn to_unix(&self) -> i64 {
86        days_from_civil(self.year, self.month, self.day) * 86_400
87            + self.hour as i64 * 3600
88            + self.minute as i64 * 60
89            + self.second as i64
90    }
91    /// 0 = Monday .. 6 = Sunday
92    pub fn weekday(&self) -> u8 {
93        weekday(self.year, self.month, self.day)
94    }
95    pub fn is_valid(&self) -> bool {
96        (1..=12).contains(&self.month)
97            && self.day >= 1
98            && self.day <= days_in_month(self.year, self.month)
99            && self.hour < 24
100            && self.minute < 60
101            && self.second < 61
102    }
103    /// the same wall time `days` days later
104    pub fn add_days(&self, days: i64) -> Civil {
105        let (y, m, d) = civil_from_days(days_from_civil(self.year, self.month, self.day) + days);
106        Civil { year: y, month: m, day: d, ..*self }
107    }
108}
109
110pub fn is_leap(y: i32) -> bool {
111    (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
112}
113
114/// days of month m (1..12)
115pub fn days_in_month(y: i32, m: u8) -> u8 {
116    match m {
117        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
118        4 | 6 | 9 | 11 => 30,
119        2 if is_leap(y) => 29,
120        2 => 28,
121        _ => 0,
122    }
123}
124
125/// days since 1970-01-01 of a date (proleptic Gregorian; Howard Hinnant's algorithm)
126pub fn days_from_civil(y: i32, m: u8, d: u8) -> i64 {
127    let y = if m <= 2 { y as i64 - 1 } else { y as i64 };
128    let era = y.div_euclid(400);
129    let yoe = y - era * 400;
130    let mp = (m as i64 + 9) % 12;
131    let doy = (153 * mp + 2) / 5 + d as i64 - 1;
132    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
133    era * 146_097 + doe - 719_468
134}
135
136/// (year, month, day) of days since 1970-01-01
137pub fn civil_from_days(z: i64) -> (i32, u8, u8) {
138    let z = z + 719_468;
139    let era = z.div_euclid(146_097);
140    let doe = z - era * 146_097;
141    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
142    let y = yoe + era * 400;
143    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
144    let mp = (5 * doy + 2) / 153;
145    let d = (doy - (153 * mp + 2) / 5 + 1) as u8;
146    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u8;
147    ((if m <= 2 { y + 1 } else { y }) as i32, m, d)
148}
149
150/// weekday of a date: 0 = Monday .. 6 = Sunday
151pub fn weekday(y: i32, m: u8, d: u8) -> u8 {
152    (days_from_civil(y, m, d) + 3).rem_euclid(7) as u8
153}
154
155fn two(s: &[u8], i: usize) -> Option<u32> {
156    let a = s.get(i)?.wrapping_sub(b'0');
157    let b = s.get(i + 1)?.wrapping_sub(b'0');
158    if a > 9 || b > 9 {
159        return None;
160    }
161    Some(a as u32 * 10 + b as u32)
162}
163
164/// "2026-10-12T19:58:00Z" (with ".123" milliseconds when the timestamp has them)
165pub fn rfc3339(ts: Timestamp) -> String {
166    rfc3339_offset(ts, 0)
167}
168
169/// the timestamp as local time with its UTC offset: "2026-10-12T21:58:00+02:00"
170pub fn rfc3339_offset(ts: Timestamp, offset_secs: i32) -> String {
171    // years 0000..9999 only: anything beyond is clamped
172    let lo = days_from_civil(0, 1, 1) * 86_400;
173    let hi = days_from_civil(9999, 12, 31) * 86_400 + 86_399;
174    let c = Civil::from_unix(ts.secs.saturating_add(offset_secs as i64).clamp(lo, hi));
175    let ms = ts.nanos / 1_000_000;
176    let frac = if ms != 0 { format!(".{:03}", ms) } else { String::new() };
177    let zone = if offset_secs == 0 {
178        String::from("Z")
179    } else {
180        let a = offset_secs.unsigned_abs();
181        format!("{}{:02}:{:02}", if offset_secs < 0 { '-' } else { '+' }, a / 3600, a % 3600 / 60)
182    };
183    format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{}{}", c.year, c.month, c.day, c.hour, c.minute, c.second, frac, zone)
184}
185
186/// parse `YYYY-MM-DDTHH:MM:SS[.fff…](Z|±HH:MM)` (also with a space or lower-case t / z)
187pub fn parse_rfc3339(s: &str) -> Option<Timestamp> {
188    let b = s.trim().as_bytes();
189    if b.len() < 20 {
190        return None;
191    }
192    let y = two(b, 0)? * 100 + two(b, 2)?;
193    if b[4] != b'-' || b[7] != b'-' || !matches!(b[10], b'T' | b't' | b' ') || b[13] != b':' || b[16] != b':' {
194        return None;
195    }
196    let c = Civil::new(y as i32, two(b, 5)? as u8, two(b, 8)? as u8, two(b, 11)? as u8, two(b, 14)? as u8, two(b, 17)? as u8);
197    if !c.is_valid() {
198        return None;
199    }
200    let mut i = 19;
201    let mut nanos = 0u32;
202    if b.get(i) == Some(&b'.') {
203        i += 1;
204        let mut digits = 0;
205        while let Some(&d) = b.get(i) {
206            if !d.is_ascii_digit() {
207                break;
208            }
209            if digits < 9 {
210                nanos = nanos * 10 + (d - b'0') as u32;
211                digits += 1;
212            }
213            i += 1;
214        }
215        while digits < 9 {
216            nanos *= 10;
217            digits += 1;
218        }
219    }
220    let off = match b.get(i)? {
221        b'Z' | b'z' => {
222            i += 1;
223            0
224        }
225        &sign @ (b'+' | b'-') => {
226            let h = two(b, i + 1)? as i64;
227            if b.get(i + 3) != Some(&b':') {
228                return None;
229            }
230            let m = two(b, i + 4)? as i64;
231            if h > 23 || m > 59 {
232                return None;
233            }
234            i += 6;
235            let o = h * 3600 + m * 60;
236            if sign == b'-' { -o } else { o }
237        }
238        _ => return None,
239    };
240    if i != b.len() {
241        return None;
242    }
243    Some(Timestamp { secs: c.to_unix() - off, nanos })
244}
245
246/// "12.10.2026 19:58" (de) / "2026-10-12 19:58" (en) in a zone
247pub fn format_date_time(ts: Timestamp, zone: &Zone, en: bool) -> String {
248    let c = zone.to_local(ts);
249    if en {
250        format!("{:04}-{:02}-{:02} {:02}:{:02}", c.year, c.month, c.day, c.hour, c.minute)
251    } else {
252        format!("{:02}.{:02}.{:04} {:02}:{:02}", c.day, c.month, c.year, c.hour, c.minute)
253    }
254}
255
256/// "12.10.2026" / "2026-10-12"
257pub fn format_date(ts: Timestamp, zone: &Zone, en: bool) -> String {
258    let c = zone.to_local(ts);
259    if en {
260        format!("{:04}-{:02}-{:02}", c.year, c.month, c.day)
261    } else {
262        format!("{:02}.{:02}.{:04}", c.day, c.month, c.year)
263    }
264}
265
266/// "19:58"
267pub fn format_time(ts: Timestamp, zone: &Zone) -> String {
268    let c = zone.to_local(ts);
269    format!("{:02}:{:02}", c.hour, c.minute)
270}
271
272/// a duration for people: "42 s", "3 min 05 s", "1 h 12 min", "2 d 3 h"
273pub fn format_duration(secs: i64, en: bool) -> String {
274    let s = secs.max(0);
275    let d = if en { "d" } else { "T" };
276    if s < 60 {
277        format!("{} s", s)
278    } else if s < 3600 {
279        format!("{} min {:02} s", s / 60, s % 60)
280    } else if s < 86_400 {
281        format!("{} h {:02} min", s / 3600, s % 3600 / 60)
282    } else {
283        format!("{} {} {} h", s / 86_400, d, s % 86_400 / 3600)
284    }
285}
286
287/// a relative time for people: "vor 5 min" / "5 min ago", "in 2 h" (now and then in seconds)
288pub fn format_relative(now: i64, then: i64, en: bool) -> String {
289    let d = then - now;
290    let a = d.unsigned_abs() as i64;
291    let v = if a < 60 {
292        return String::from(if en { "just now" } else { "gerade eben" });
293    } else if a < 3600 {
294        format!("{} min", a / 60)
295    } else if a < 86_400 {
296        format!("{} h", a / 3600)
297    } else {
298        let n = a / 86_400;
299        // German: dative after "vor" / "in" ("vor 2 Tagen", "in 1 Tag")
300        format!("{} {}", n, match (en, n == 1) {
301            (true, true) => "day",
302            (true, false) => "days",
303            (false, true) => "Tag",
304            (false, false) => "Tagen",
305        })
306    };
307    match (d < 0, en) {
308        (true, true) => format!("{} ago", v),
309        (true, false) => format!("vor {}", v),
310        (false, true) => format!("in {}", v),
311        (false, false) => format!("in {}", v),
312    }
313}