chrono_systemd_time/
lib.rs

1//! The library parses timestamps following the [systemd.time] specifications into [chrono] types.
2//!
3//! [systemd.time]: https://www.freedesktop.org/software/systemd/man/systemd.time.html
4//! [chrono]: https://docs.rs/chrono/
5//!
6//! ## Timestamp Format
7//!
8//! The supported timestamp formats are any defined by the systemd.time specifications, with a few exceptions:
9//! * time units **must** accompany all time span values.
10//! * time zone suffixes are **not** supported.
11//! * weekday prefixes are **not** supported.
12//!
13//! The format of a timestamp may be either a time, a time span, or a combination of a time +/- a time span.
14//! * When only a time is given, the parsed time is returned.
15//! * When only a time span is given, the time span is added or subtracted from the current time (now).
16//! * When a combination of a time and a time span is given, the time span is added or subtracted from the parsed time.
17//!
18//! Examples of parsing valid timestamps, assuming now is 2018-06-21 01:02:03:
19//! ```rust,ignore
20//!     parse_timestamp_tz("2018-08-20 09:11:12.123", Utc) == "2018-08-20T09:11:12.000123Z"
21//!     parse_timestamp_tz("2018-08-20 09:11:12", Utc) == "2018-08-20T09:11:12Z"
22//!     parse_timestamp_tz("18-08-20 09:11:12 +2m", Utc) == "2018-08-20T09:13:12Z"
23//!     parse_timestamp_tz("2018-08-20 + 1h2m3s", Utc) == "2018-08-20T01:02:03Z"
24//!     parse_timestamp_tz("18-08-20 - 1h 2m 3s", Utc) == "2018-08-19T22:57:57Z"
25//!     parse_timestamp_tz("09:11:12 -1day", Utc) == "2018-06-20T09:11:12Z"
26//!     parse_timestamp_tz("09:11:12.123", Utc) == "2018-06-21T09:11:12.000123Z"
27//!     parse_timestamp_tz("11:12", Utc) == "2018-06-21T11:12:00Z"
28//!     parse_timestamp_tz("now", Utc) == "2018-06-21T01:02:03.203918151Z"
29//!     parse_timestamp_tz("today", Utc) == "2018-06-21T00:00:00Z"
30//!     parse_timestamp_tz("yesterday -2days", Utc) == "2018-06-18T00:00:00Z"
31//!     parse_timestamp_tz("tomorrow +1week", Utc) == "2018-06-29T00:00:00Z"
32//!
33//!     parse_timestamp_tz("epoch +1529578800s", Utc) == "2018-06-21T11:00:00Z"
34//!     parse_timestamp_tz("@1529578800s", Utc) == "2018-06-21T11:00:00Z"
35//!     parse_timestamp_tz("now +4h50m", Utc) == "2018-06-21T05:52:03.203918151Z"
36//!     parse_timestamp_tz("4h50m left", Utc) == "2018-06-21T05:52:03.203918151Z"
37//!     parse_timestamp_tz("+4h50m", Utc) == "2018-06-21T05:52:03.203918151Z"
38//!     parse_timestamp_tz("now -3s", Utc) == "2018-06-21T01:02:00.203918151Z"
39//!     parse_timestamp_tz("3s ago", Utc) == "2018-06-21T01:02:00.203918151Z"
40//!     parse_timestamp_tz("-3s", Utc) == "2018-06-21T01:02:00.203918151Z"
41//! ```
42//!
43//! #### Time
44//! The syntax of a time consists of a set of keywords and strftime formats:
45//! * `"now"`, `"epoch"`
46//! * `"today"`, `"yesterday"`, `"tomorrow"`
47//! * `"%y-%m-%d %H:%M:%S"`, `"%Y-%m-%d %H:%M:%S"`
48//! * `"%y-%m-%d %H:%M"`, `"%Y-%m-%d %H:%M"`
49//! * `"%y-%m-%d"`, `"%Y-%m-%d"`
50//! * `"%H:%M:%S"`
51//! * `"%H:%M"`
52//!
53//! Strftime timestamps with a seconds component may also include a microsecond component, separated by a `'.'`.
54//! * When the date is omitted, today is assumed.
55//! * When the time is omitted, 00:00:00 is assumed.
56//!
57//! Examples of valid times (assuming now is 2018-06-21 01:02:03):
58//! ```rust,ignore
59//!     "2018-08-20 09:11:12.123" == "2018-08-20T09:11:12.000123"
60//!         "2018-08-20 09:11:12" == "2018-08-20T09:11:12"
61//!           "18-08-20 09:11:12" == "2018-08-20T09:11:12"
62//!                  "2018-08-20" == "2018-08-20T00:00:00"
63//!                    "18-08-20" == "2018-08-20T00:00:00"
64//!                    "09:11:12" == "2018-06-21T09:11:12"
65//!                "09:11:12.123" == "2018-06-21T09:11:12.000123"
66//!                       "11:12" == "2018-06-21T11:12:00"
67//!                         "now" == "2018-06-21T01:02:03.203918151"
68//!                       "epoch" == "1970-01-01T00:00:00"
69//!                       "today" == "2018-06-21T00:00:00"
70//!                   "yesterday" == "2018-06-20T00:00:00"
71//!                    "tomorrow" == "2018-06-22T00:00:00"
72//! ```
73//!
74//! #### Time span
75//! A time span is made up of a combination of time units, with the following time units understood:
76//! * `"usec"`, `"us"`, `"µs"`
77//! * `"msec"`, `"ms"`
78//! * `"seconds"`, `"second"`, `"sec"`, `"s"`
79//! * `"minutes"`, `"minute"`, `"min"`, `"m"`
80//! * `"hours"`, `"hour"`, `"hr"`, `"h"`
81//! * `"days"`, `"day"`, `"d"`
82//! * `"weeks"`, `"week"`, `"w"`
83//! * `"months"`, `"month"`, `"M"` (defined as 30.44 days)
84//! * `"years"`, `"year"`, `"y"` (defined as 365.25 days)
85//!
86//! All components of a time span are added to together.
87//!
88//! Examples of valid time spans:
89//! ```rust,ignore
90//!           "3hours" == Duration::hours(3)
91//!            "2d 5h" == Duration::days(2) + Duration::hours(5)
92//!     "1y 10 months" == Duration::years(1) + Duration::months(10)
93//!           "30m22s" == Duration::minutes(30) + Duration::seconds(22)
94//!        "10m 2s 5m" == Duration::minutes(15) + Duration::seconds(2)
95//!         "10d 2 5m" == Duration::days(10) + Duration::minutes(25)
96//! ```
97
98#[cfg(test)]
99mod tests;
100
101mod error;
102mod local_datetime;
103
104pub use self::{error::Error, local_datetime::LocalDateTime};
105
106use std::borrow::Borrow;
107use std::collections::HashMap;
108use std::str;
109use std::sync::LazyLock;
110
111use chrono::offset::Utc;
112use chrono::{Days, Duration};
113use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeZone};
114
115/*
116 * Chrono stores its DateTimes and Durations in i64s, so use that here.
117 * Ideally we would use a larger primitive type (and unsigned).
118 */
119
120const USEC_PER_USEC: i64 = 1;
121const USEC_PER_MSEC: i64 = 1_000 * USEC_PER_USEC;
122const USEC_PER_SEC: i64 = 1_000 * USEC_PER_MSEC;
123const USEC_PER_MINUTE: i64 = 60 * USEC_PER_SEC;
124const USEC_PER_HOUR: i64 = 60 * USEC_PER_MINUTE;
125const USEC_PER_DAY: i64 = 24 * USEC_PER_HOUR;
126const USEC_PER_WEEK: i64 = 7 * USEC_PER_DAY;
127const USEC_PER_MONTH: i64 = 2_629_800 * USEC_PER_SEC;
128const USEC_PER_YEAR: i64 = 31_557_600 * USEC_PER_SEC;
129
130#[rustfmt::skip]
131static USEC_MULTIPLIER: LazyLock<HashMap<&'static str, i64>> = LazyLock::new(|| {
132    HashMap::from_iter([
133        ("us", USEC_PER_USEC),
134        ("usec", USEC_PER_USEC),
135        ("µs", USEC_PER_USEC),
136
137        ("ms", USEC_PER_MSEC),
138        ("msec", USEC_PER_MSEC),
139
140        ("s", USEC_PER_SEC),
141        ("sec", USEC_PER_SEC),
142        ("second", USEC_PER_SEC),
143        ("seconds", USEC_PER_SEC),
144
145        ("m", USEC_PER_MINUTE),
146        ("min", USEC_PER_MINUTE),
147        ("minute", USEC_PER_MINUTE),
148        ("minutes", USEC_PER_MINUTE),
149
150        ("h", USEC_PER_HOUR),
151        ("hour", USEC_PER_HOUR),
152        ("hours", USEC_PER_HOUR),
153        ("hr", USEC_PER_HOUR),
154
155        ("d", USEC_PER_DAY),
156        ("day", USEC_PER_DAY),
157        ("days", USEC_PER_DAY),
158
159        ("M", USEC_PER_MONTH),
160        ("month", USEC_PER_MONTH),
161        ("months", USEC_PER_MONTH),
162
163        ("w", USEC_PER_WEEK),
164        ("week", USEC_PER_WEEK),
165        ("weeks", USEC_PER_WEEK),
166
167        ("y", USEC_PER_YEAR),
168        ("year", USEC_PER_YEAR),
169        ("years", USEC_PER_YEAR),
170    ])
171});
172
173/// Parse a timestamp returning a `DateTime` with the specified timezone.
174///
175/// # Examples
176/// ```rust
177/// # use chrono_systemd_time::parse_timestamp_tz;
178/// use chrono::{DateTime, Duration, Local, TimeZone, Utc};
179///
180/// fn parse_timestamp_tz_aux<Tz: TimeZone>(timestamp: &str, timezone: Tz) -> DateTime<Tz> {
181///     parse_timestamp_tz(timestamp, timezone)
182///         .unwrap()
183///         .single()
184///         .unwrap()
185/// }
186///
187/// assert_eq!(parse_timestamp_tz_aux("today + 2h", Utc),
188///             parse_timestamp_tz_aux("today", Utc) + Duration::hours(2));
189/// assert_eq!(parse_timestamp_tz_aux("yesterday", Local),
190///             parse_timestamp_tz_aux("today - 1d", Local));
191/// assert_eq!(parse_timestamp_tz_aux("2018-06-21", Utc),
192///             parse_timestamp_tz_aux("18-06-21 1:00 - 1h", Utc));
193/// ```
194pub fn parse_timestamp_tz<S, T, Tz>(timestamp: S, timezone: T) -> Result<LocalDateTime<Tz>, Error>
195where
196    S: AsRef<str>,
197    T: Borrow<Tz>,
198    Tz: TimeZone,
199{
200    let tz = timezone.borrow();
201    let ts = timestamp.as_ref();
202    let ts_nw = ts
203        .chars()
204        .filter(|&c| !c.is_whitespace())
205        .collect::<String>();
206
207    if ts_nw.is_empty() {
208        return Err(Error::Format("Timestamp cannot be empty".to_owned()));
209    }
210
211    /*
212     * A timestamp is composed of two parts: a time and an offset relative to that time.
213     *
214     * In the general case, the time is separated from the offset by either a '+' or '-'
215     * character which denotes how the offset is relative to that time.
216     *
217     * There are a few special cases which are not handled by the general case.
218     * These are detected, and handled, before applying the general case algorithm.
219     */
220
221    // Special Case 1 - a suffix of " left" or " ago", or a prefix of '+' or '-':
222    //  - the time is now.
223    //  - the offset consists of the remaining characters added to or subtracted from the current time, respectively.
224    if ts.starts_with('+') {
225        let now = Utc::now().with_timezone(tz);
226        let offset = parse_offset(&ts_nw[1..])?;
227        return Ok(LocalDateTime::Single(now + offset));
228    }
229    if ts.ends_with(" left") {
230        let now = Utc::now().with_timezone(tz);
231        let offset = parse_offset(&ts_nw[..(ts_nw.len() - 4)])?;
232        return Ok(LocalDateTime::Single(now + offset));
233    }
234
235    if ts.starts_with('-') {
236        let now = Utc::now().with_timezone(tz);
237        let offset = parse_offset(&ts_nw[1..])?;
238        return Ok(LocalDateTime::Single(now - offset));
239    }
240    if ts.ends_with(" ago") {
241        let now = Utc::now().with_timezone(tz);
242        let offset = parse_offset(&ts_nw[..(ts_nw.len() - 3)])?;
243        return Ok(LocalDateTime::Single(now - offset));
244    }
245
246    // Special Case 2 - a prefix of '@':
247    //  - the time is the unix epoch.
248    //  - the offset consists of the remaining characters added to the epoch time.
249    if ts.starts_with('@') {
250        let epoch = tz.timestamp_opt(0, 0).unwrap();
251        let offset = parse_offset(&ts_nw[1..])?;
252        return Ok(LocalDateTime::Single(epoch + offset));
253    }
254
255    // General Case - the time is separated from the offset by either a '+' or '-'.
256    // Note: need to find " +" and " -" here because strftime date formats may contain the '-' character,
257    //       but with no leading whitespaces.
258    match (ts.find(" +"), ts.find(" -")) {
259        (Some(_), Some(_)) => Err(Error::Format(
260            "Timestamp cannot contain both a `+` and `-`".to_owned(),
261        )),
262        (Some(p), None) => {
263            let p_nw = ts_nw.find('+').unwrap();
264            let time = parse_time(&ts[..p], tz)?;
265            let offset = parse_offset(&ts_nw[(p_nw + 1)..])?;
266            Ok(time + offset)
267        }
268        (None, Some(m)) => {
269            let m_nw = ts_nw.rfind('-').unwrap();
270            let time = parse_time(&ts[..m], tz)?;
271            let offset = parse_offset(&ts_nw[(m_nw + 1)..])?;
272            Ok(time - offset)
273        }
274        (None, None) => {
275            let time = parse_time(ts, tz)?;
276            Ok(time)
277        }
278    }
279}
280
281/// Parse a point-in-time into a `DateTime` with the given timezone.
282///
283/// * `ts` - a str of a time with whitespace intact.
284/// * `tz` - the time zone to use.
285fn parse_time<Tz: TimeZone>(ts: &str, tz: &Tz) -> Result<LocalDateTime<Tz>, Error> {
286    let dt = match ts {
287        "now" => LocalDateTime::Single(Utc::now().with_timezone(tz)),
288        "epoch" => LocalDateTime::Single(tz.timestamp_opt(0, 0).unwrap()),
289        "today" => LocalDateTime::from_date(naive_today(tz), tz)?,
290        "yesterday" => LocalDateTime::from_date(naive_today(tz) - Days::new(1), tz)?,
291        "tomorrow" => LocalDateTime::from_date(naive_today(tz) + Days::new(1), tz)?,
292        ts => match ts.find('.') {
293            // an optional '.' separates the seconds and microseconds components
294            Some(p) => {
295                let ts_t = &ts[..p];
296                let ndt = NaiveDateTime::parse_from_str(ts_t, "%y-%m-%d %H:%M:%S")
297                    .or_else(|_| NaiveDateTime::parse_from_str(ts_t, "%Y-%m-%d %H:%M:%S"))
298                    .or_else(|_| {
299                        NaiveTime::parse_from_str(ts_t, "%H:%M:%S")
300                            .map(|nt| naive_today(tz).and_time(nt))
301                    })
302                    .map_err(|_| {
303                        Error::Format(format!("Cannot parse `{ts_t}` before '.' into a time"))
304                    })?;
305
306                let ts_u = &ts[(p + 1)..];
307                let usecs: i64 = ts_u.parse().map_err(|e| {
308                    Error::Number(format!(
309                        "Cannot parse `{ts_u}` after '.' into a number: {e}"
310                    ))
311                })?;
312
313                let ndt = ndt + Duration::microseconds(usecs);
314                LocalDateTime::from_datetime(ndt, tz)?
315            }
316            None => NaiveDateTime::parse_from_str(ts, "%y-%m-%d %H:%M:%S")
317                .or_else(|_| NaiveDateTime::parse_from_str(ts, "%Y-%m-%d %H:%M:%S"))
318                .or_else(|_| NaiveDateTime::parse_from_str(ts, "%y-%m-%d %H:%M"))
319                .or_else(|_| NaiveDateTime::parse_from_str(ts, "%Y-%m-%d %H:%M"))
320                .or_else(|_| {
321                    NaiveDate::parse_from_str(ts, "%y-%m-%d")
322                        .map(|nd| nd.and_hms_opt(0, 0, 0).unwrap())
323                })
324                .or_else(|_| {
325                    NaiveDate::parse_from_str(ts, "%Y-%m-%d")
326                        .map(|nd| nd.and_hms_opt(0, 0, 0).unwrap())
327                })
328                .or_else(|_| {
329                    NaiveTime::parse_from_str(ts, "%H:%M:%S").map(|nt| naive_today(tz).and_time(nt))
330                })
331                .or_else(|_| {
332                    NaiveTime::parse_from_str(ts, "%H:%M").map(|nt| naive_today(tz).and_time(nt))
333                })
334                .map_err(|_| Error::Format(format!("Cannot parse `{ts}` into a time")))
335                .and_then(|ndt| LocalDateTime::from_datetime(ndt, tz))?,
336        },
337    };
338    Ok(dt)
339}
340
341/// Parse and combine all time spans into a single duration.
342///
343/// * `ts_nw` - a str of time spans with whitespace removed.
344fn parse_offset(mut ts_nw: &str) -> Result<Duration, Error> {
345    let mut total_usecs: i64 = 0;
346    loop {
347        if ts_nw.is_empty() {
348            return Ok(Duration::microseconds(total_usecs));
349        }
350
351        /*
352         * Time spans have the format: "<number><multipler>"
353         */
354
355        // look for digit characters to make up the `number`
356        // followed by alphabetic characters to make up the `multiplier`
357        let (digits, ts_tail) = partition_predicate(ts_nw, |c| c.is_ascii_digit());
358        let (letters, ts_tail) = partition_predicate(ts_tail, char::is_alphabetic);
359        ts_nw = ts_tail;
360
361        // parse the `number` and `multipler` strings into i64
362        let number: i64 = digits
363            .parse()
364            .map_err(|e| Error::Number(format!("Cannot parse `{digits}` into a number: {e}")))?;
365        let Some(&multiplier) = USEC_MULTIPLIER.get(letters) else {
366            return Err(Error::TimeUnit(letters.to_owned()));
367        };
368
369        let Some(usecs) = number
370            .checked_mul(multiplier)
371            .and_then(|usec| usec.checked_add(total_usecs))
372        else {
373            return Err(Error::Number(format!(
374                "Offset microseconds overflowed: total_usecs `{total_usecs}` number `{number}` multiplier `{multiplier}`"
375            )));
376        };
377        // increment the total microsecond offset returning a failure on an overflow
378        total_usecs = usecs;
379    }
380}
381
382fn naive_today<Tz: TimeZone>(tz: &Tz) -> NaiveDate {
383    Utc::now().with_timezone(tz).date_naive()
384}
385
386/// Partition a str by a given predicate.
387/// Returned is a tuple where:
388/// - the first element contains the sub-slice of sequential characters that tested true.
389/// - the second element contains the remaining characters of the original str.
390fn partition_predicate<P>(ts: &str, predicate: P) -> (&str, &str)
391where
392    P: Fn(char) -> bool,
393{
394    ts.find(|c: char| !predicate(c))
395        .map(|p| ts.split_at(p))
396        .unwrap_or((ts, ""))
397}