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}