hgtime/
lib.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8//! # parsedate
9//!
10//! See [`HgTime`] and [`HgTime::parse`] for main features.
11
12use std::ops::Add;
13use std::ops::Range;
14use std::ops::RangeInclusive;
15use std::ops::Sub;
16use std::sync::atomic::AtomicI32;
17use std::sync::atomic::AtomicU64;
18use std::sync::atomic::Ordering;
19
20use chrono::prelude::*;
21use chrono::Duration;
22use chrono::LocalResult;
23
24#[cfg(feature = "serde")]
25mod serde_impl;
26
27/// A simple time structure that matches hg's time representation.
28///
29/// Internally it's unixtime (in GMT), and offset (GMT -1 = +3600).
30#[derive(Clone, Copy, Debug, Default, PartialEq)]
31pub struct HgTime {
32    pub unixtime: i64,
33    pub offset: i32,
34}
35
36const DEFAULT_FORMATS: [&str; 35] = [
37    // mercurial/util.py defaultdateformats
38    "%Y-%m-%dT%H:%M:%S", // the 'real' ISO8601
39    "%Y-%m-%dT%H:%M",    //   without seconds
40    "%Y-%m-%dT%H%M%S",   // another awful but legal variant without :
41    "%Y-%m-%dT%H%M",     //   without seconds
42    "%Y-%m-%d %H:%M:%S", // our common legal variant
43    "%Y-%m-%d %H:%M",    //   without seconds
44    "%Y-%m-%d %H%M%S",   // without :
45    "%Y-%m-%d %H%M",     //   without seconds
46    "%Y-%m-%d %I:%M:%S%p",
47    "%Y-%m-%d %H:%M",
48    "%Y-%m-%d %I:%M%p",
49    "%a %b %d %H:%M:%S %Y",
50    "%a %b %d %I:%M:%S%p %Y",
51    "%a, %d %b %Y %H:%M:%S", //  GNU coreutils "/bin/date --rfc-2822"
52    "%b %d %H:%M:%S %Y",
53    "%b %d %I:%M:%S%p %Y",
54    "%b %d %H:%M:%S",
55    "%b %d %I:%M:%S%p",
56    "%b %d %H:%M",
57    "%b %d %I:%M%p",
58    "%m-%d",
59    "%m/%d",
60    "%Y-%m-%d",
61    "%m/%d/%y",
62    "%m/%d/%Y",
63    "%b",
64    "%b %d",
65    "%b %Y",
66    "%b %d %Y",
67    "%I:%M%p",
68    "%H:%M",
69    "%H:%M:%S",
70    "%I:%M:%S%p",
71    "%Y",
72    "%Y-%m",
73];
74
75const INVALID_OFFSET: i32 = i32::MAX;
76static DEFAULT_OFFSET: AtomicI32 = AtomicI32::new(INVALID_OFFSET);
77static FORCED_NOW: AtomicU64 = AtomicU64::new(0); // test only
78
79/// Call `TimeZone` methods on either `Local` (system default) or
80/// TimeZone specified by `set_default_offset`.
81///
82/// This cannot be made as a regular function because:
83/// - TimeZone<Local> and TimeZone<FixedOffset> are different types.
84/// - TimeZone<T> cannot be made into a trait object.
85macro_rules! with_local_timezone {
86    (|$tz:ident| { $($expr: tt)* }) => {
87        {
88            let offset = DEFAULT_OFFSET.load(Ordering::Acquire);
89            match FixedOffset::west_opt(offset) {
90                Some($tz) => {
91                    $($expr)*
92                },
93                None => {
94                    let $tz = Local;
95                    $($expr)*
96                },
97            }
98        }
99    };
100}
101
102impl HgTime {
103    /// Supported Range. This is to be compatible with Python stdlib.
104    ///
105    /// The Python `datetime`  library can only express a limited range
106    /// of dates (0001-01-01 to 9999-12-31). Its strftime requires
107    /// year >= 1900.
108    pub const RANGE: RangeInclusive<HgTime> = Self::min_value()..=Self::max_value();
109
110    /// Return the current time with local timezone, or `None` if the timestamp
111    /// is outside [`HgTime::RANGE`].
112    ///
113    /// The local timezone can be affected by `set_default_offset`.
114    pub fn now() -> Option<Self> {
115        let forced_now = FORCED_NOW.load(Ordering::Acquire);
116        if forced_now == 0 {
117            Self::try_from(Local::now()).ok().and_then(|mut t: HgTime| {
118                let offset = DEFAULT_OFFSET.load(Ordering::Acquire);
119                if is_valid_offset(offset) {
120                    t.offset = offset;
121                }
122                t.bounded()
123            })
124        } else {
125            Some(Self::from_compact_u64(forced_now))
126        }
127    }
128
129    pub fn to_utc(self) -> DateTime<Utc> {
130        let naive = DateTime::from_timestamp(self.unixtime, 0).unwrap();
131        naive.to_utc()
132    }
133
134    /// Converts to `NaiveDateTime` with local timezone specified by `offset`.
135    fn to_naive(self) -> NaiveDateTime {
136        DateTime::from_timestamp(self.unixtime - self.offset as i64, 0)
137            .unwrap()
138            .naive_local()
139    }
140
141    /// Set as the faked "now". Useful for testing.
142    ///
143    /// This should only be used for testing.
144    pub fn set_as_now_for_testing(self) {
145        FORCED_NOW.store(self.to_lossy_compact_u64(), Ordering::SeqCst);
146    }
147
148    /// Remove faked "now". Reverts "set_as_now_for_testing" effect.
149    pub fn clear_now_for_testing() {
150        FORCED_NOW.store(0, Ordering::Release);
151    }
152
153    /// Parse a date string.
154    ///
155    /// Return `None` if it cannot be parsed.
156    ///
157    /// This function matches `mercurial.util.parsedate`, and can parse
158    /// some additional forms like `2 days ago`.
159    pub fn parse(date: &str) -> Option<Self> {
160        match date {
161            "now" => Self::now(),
162            "today" => Self::now().and_then(|now| {
163                Self::try_from(now.to_naive().date().and_hms_opt(0, 0, 0).unwrap()).ok()
164            }),
165            "yesterday" => Self::now().and_then(|now| {
166                Self::try_from(
167                    (now.to_naive().date() - Duration::days(1))
168                        .and_hms_opt(0, 0, 0)
169                        .unwrap(),
170                )
171                .ok()
172            }),
173            date if date.ends_with(" ago") => {
174                let duration_str = &date[..date.len() - 4];
175                duration_str
176                    .parse::<humantime::Duration>()
177                    .ok()
178                    .and_then(|duration| Self::now().and_then(|n| n - duration.as_secs()))
179            }
180            _ => Self::parse_absolute(date, &default_date_lower),
181        }
182    }
183
184    /// Parse a date string as a range.
185    ///
186    /// For example, `Apr 2000` covers range `Apr 1, 2000` to `Apr 30, 2000`.
187    /// Also support more explicit ranges:
188    /// - START to END
189    /// - > START
190    /// - < END
191    pub fn parse_range(date: &str) -> Option<Range<Self>> {
192        Self::parse_range_internal(date, true)
193    }
194
195    fn parse_range_internal(date: &str, support_to: bool) -> Option<Range<Self>> {
196        match date {
197            "now" => Self::now().and_then(|n| (n + 1).map(|m| n..m)),
198            "today" => Self::now().and_then(|now| {
199                let date = now.to_naive().date();
200                let start = Self::try_from(date.and_hms_opt(0, 0, 0).unwrap());
201                let end = Self::try_from(date.and_hms_opt(23, 59, 59).unwrap()).map(|t| t + 1);
202                if let (Ok(start), Ok(Some(end))) = (start, end) {
203                    Some(start..end)
204                } else {
205                    None
206                }
207            }),
208            "yesterday" => Self::now().and_then(|now| {
209                let date = now.to_naive().date() - Duration::days(1);
210                let start = Self::try_from(date.and_hms_opt(0, 0, 0).unwrap());
211                let end = Self::try_from(date.and_hms_opt(23, 59, 59).unwrap()).map(|t| t + 1);
212                if let (Ok(start), Ok(Some(end))) = (start, end) {
213                    Some(start..end)
214                } else {
215                    None
216                }
217            }),
218            date if date.starts_with('>') => {
219                Self::parse(&date[1..]).map(|start| start..Self::max_value())
220            }
221            date if date.starts_with("since ") => {
222                Self::parse(&date[6..]).map(|start| start..Self::max_value())
223            }
224            date if date.starts_with('<') => Self::parse(&date[1..])
225                .and_then(|end| end + 1)
226                .map(|end| Self::min_value()..end),
227            date if date.starts_with('-') => {
228                // This does not really make much sense. But is supported by hg
229                // (see 'hg help dates').
230                Self::parse_range(&format!("since {} days ago", &date[1..]))
231            }
232            date if date.starts_with("before ") => {
233                Self::parse(&date[7..]).map(|end| Self::min_value()..end)
234            }
235            date if support_to && date.contains(" to ") => {
236                let phrases: Vec<_> = date.split(" to ").collect();
237                if phrases.len() == 2 {
238                    if let (Some(start), Some(end)) = (
239                        Self::parse_range_internal(phrases[0], false),
240                        Self::parse_range_internal(phrases[1], false),
241                    ) {
242                        Some(start.start..end.end)
243                    } else {
244                        None
245                    }
246                } else {
247                    None
248                }
249            }
250            _ => {
251                let start = Self::parse_absolute(date, &default_date_lower);
252                let end = Self::parse_absolute(date, &|c| default_date_upper(c, "31"))
253                    .or_else(|| Self::parse_absolute(date, &|c| default_date_upper(c, "30")))
254                    .or_else(|| Self::parse_absolute(date, &|c| default_date_upper(c, "29")))
255                    .or_else(|| Self::parse_absolute(date, &|c| default_date_upper(c, "28")))
256                    .and_then(|end| end + 1);
257                if let (Some(start), Some(end)) = (start, end) {
258                    Some(start..end)
259                } else {
260                    None
261                }
262            }
263        }
264    }
265
266    /// Parse a date from a Sapling-internal format. Tolerates floating point timestamps for compatibility reasons.
267    ///
268    /// Return `None` if cannot be parsed, `Option<None>` if the parsed date is invalid
269    pub fn parse_hg_internal_format(date: &str) -> Option<Option<Self>> {
270        let parts: Vec<_> = date.split(' ').collect();
271        if parts.len() == 2 {
272            let unixtime = parts[0].parse::<i64>().ok();
273            let unixtime = if unixtime.is_none() {
274                parts[0].parse::<f64>().map(|x| x as i64).ok()
275            } else {
276                unixtime
277            };
278            if let Some(unixtime) = unixtime {
279                if let Ok(offset) = parts[1].parse() {
280                    if is_valid_offset(offset) {
281                        return Some(Self { unixtime, offset }.bounded());
282                    }
283                }
284            }
285        }
286        None
287    }
288
289    /// Parse date in an absolute form.
290    ///
291    /// Return None if it cannot be parsed.
292    ///
293    /// `default_date` takes a format char, for example, `H`, and returns a
294    /// default value of it.
295    fn parse_absolute(date: &str, default_date: &dyn Fn(char) -> &'static str) -> Option<Self> {
296        let date = date.trim();
297
298        if let Some(hg_internal) = Self::parse_hg_internal_format(date) {
299            return hg_internal;
300        }
301
302        // Normalize UTC timezone name to +0000. The parser does not know
303        // timezone names.
304        let date = if date.ends_with("GMT") || date.ends_with("UTC") {
305            format!("{} +0000", &date[..date.len() - 3])
306        } else {
307            date.to_string()
308        };
309        let mut now = None; // cached, lazily calculated "now"
310
311        // Try all formats!
312        for naive_format in DEFAULT_FORMATS.iter() {
313            // Fill out default fields.  See mercurial.util.strdate.
314            // This makes it possible to parse partial dates like "month/day",
315            // or "hour:minute", since the missing fields will be filled.
316            let mut default_format = String::new();
317            let mut date_with_defaults = date.clone();
318            let mut use_now = false;
319            for part in ["S", "M", "HI", "d", "mb", "Yy"] {
320                if part
321                    .chars()
322                    .any(|ch| naive_format.contains(&format!("%{}", ch)))
323                {
324                    // For example, if the user specified "d" (day), but
325                    // not other things, we should use 0 for "H:M:S", and
326                    // "now" for "Y-m" (year, month).
327                    use_now = true;
328                } else {
329                    let format_char = part.chars().next().unwrap();
330                    default_format += &format!(" @%{}", format_char);
331                    if use_now {
332                        // For example, if the user only specified "month/day",
333                        // then we should use the current "year", instead of
334                        // year 0.
335                        now = now.or_else(|| Self::now().map(|n| n.to_naive()));
336                        match now {
337                            Some(now) => {
338                                date_with_defaults +=
339                                    &format!(" @{}", now.format(&format!("%{}", format_char)))
340                            }
341                            None => return None,
342                        }
343                    } else {
344                        // For example, if the user only specified
345                        // "hour:minute", then we should use "second 0", instead
346                        // of the current second.
347                        date_with_defaults += " @";
348                        date_with_defaults += default_date(format_char);
349                    }
350                }
351            }
352
353            // Try parse with timezone.
354            // See https://docs.rs/chrono/0.4.9/chrono/format/strftime/index.html#specifiers
355            let format = format!("{}%#z{}", naive_format, default_format);
356            if let Ok(parsed) = DateTime::parse_from_str(&date_with_defaults, &format) {
357                if let Ok(parsed) = parsed.try_into() {
358                    return Some(parsed);
359                }
360            }
361
362            // Without timezone.
363            let format = format!("{}{}", naive_format, default_format);
364            if let Ok(parsed) = NaiveDateTime::parse_from_str(&date_with_defaults, &format) {
365                if let Ok(parsed) = parsed.try_into() {
366                    return Some(parsed);
367                }
368            }
369        }
370
371        None
372    }
373
374    /// See [`HgTime::RANGE`] for details.
375    pub const fn min_value() -> Self {
376        Self {
377            unixtime: -2208988800, // 1900-01-01 00:00:00
378            offset: 0,
379        }
380    }
381
382    /// See [`HgTime::RANGE`] for details.
383    pub const fn max_value() -> Self {
384        Self {
385            unixtime: 253402300799, // 9999-12-31 23:59:59
386            offset: 0,
387        }
388    }
389
390    /// Return `None` if timestamp is out of [`HgTime::RANGE`].
391    pub fn bounded(self) -> Option<Self> {
392        if self < Self::min_value() || self > Self::max_value() {
393            None
394        } else {
395            Some(self)
396        }
397    }
398}
399
400// Convert to compact u64.  Used by FORCED_NOW.
401// For testing purpose only (no overflow checking).
402impl HgTime {
403    fn to_lossy_compact_u64(self) -> u64 {
404        ((self.unixtime as u64) << 17) + (self.offset + 50401) as u64
405    }
406
407    fn from_compact_u64(value: u64) -> Self {
408        let unixtime = (value as i64) >> 17;
409        let offset = (((value & 0x1ffff) as i64) - 50401) as i32;
410        Self { unixtime, offset }
411    }
412}
413
414impl From<HgTime> for NaiveDateTime {
415    fn from(time: HgTime) -> Self {
416        time.to_naive()
417    }
418}
419
420impl From<HgTime> for DateTime<Utc> {
421    fn from(time: HgTime) -> Self {
422        time.to_utc()
423    }
424}
425
426impl Add<u64> for HgTime {
427    type Output = Option<Self>;
428
429    fn add(self, seconds: u64) -> Option<Self> {
430        seconds.try_into().ok().and_then(|seconds| {
431            self.unixtime.checked_add(seconds).and_then(|unixtime| {
432                Self {
433                    unixtime,
434                    offset: self.offset,
435                }
436                .bounded()
437            })
438        })
439    }
440}
441
442impl Sub<u64> for HgTime {
443    type Output = Option<Self>;
444
445    fn sub(self, seconds: u64) -> Option<Self> {
446        seconds.try_into().ok().and_then(|seconds| {
447            self.unixtime.checked_sub(seconds).and_then(|unixtime| {
448                Self {
449                    unixtime,
450                    offset: self.offset,
451                }
452                .bounded()
453            })
454        })
455    }
456}
457
458impl PartialOrd for HgTime {
459    fn partial_cmp(&self, other: &HgTime) -> Option<std::cmp::Ordering> {
460        self.unixtime.partial_cmp(&other.unixtime)
461    }
462}
463
464impl<Tz: TimeZone> TryFrom<DateTime<Tz>> for HgTime {
465    type Error = ();
466    fn try_from(time: DateTime<Tz>) -> Result<Self, ()> {
467        Self {
468            unixtime: time.timestamp(),
469            offset: time.offset().fix().utc_minus_local(),
470        }
471        .bounded()
472        .ok_or(())
473    }
474}
475
476impl<Tz: TimeZone> TryFrom<LocalResult<DateTime<Tz>>> for HgTime {
477    type Error = ();
478    fn try_from(time: LocalResult<DateTime<Tz>>) -> Result<Self, ()> {
479        match time {
480            LocalResult::Single(datetime) => HgTime::try_from(datetime),
481            _ => Err(()),
482        }
483    }
484}
485
486impl TryFrom<NaiveDateTime> for HgTime {
487    type Error = ();
488    fn try_from(time: NaiveDateTime) -> Result<Self, ()> {
489        with_local_timezone!(|tz| { tz.from_local_datetime(&time).try_into() })
490    }
491}
492
493/// Change default offset (timezone).
494pub fn set_default_offset(offset: i32) {
495    DEFAULT_OFFSET.store(offset, Ordering::SeqCst);
496}
497
498fn is_valid_offset(offset: i32) -> bool {
499    // UTC-12 to UTC+14.
500    (-50400..=43200).contains(&offset)
501}
502
503/// Lower bound for default values in dates.
504fn default_date_lower(format_char: char) -> &'static str {
505    match format_char {
506        'H' | 'M' | 'S' => "00",
507        'm' | 'd' => "1",
508        _ => unreachable!(),
509    }
510}
511
512/// Upper bound. Assume a month has `N::to_static_str()` days.
513fn default_date_upper(format_char: char, max_day: &'static str) -> &'static str {
514    match format_char {
515        'H' => "23",
516        'M' | 'S' => "59",
517        'm' => "12",
518        'd' => max_day,
519        _ => unreachable!(),
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    #[test]
528    fn test_naive_local_roundtrip() {
529        let now = Local::now().with_nanosecond(0).unwrap().naive_local();
530        let hgtime: HgTime = now.try_into().unwrap();
531        let now_again = hgtime.to_naive();
532        assert_eq!(now, now_again);
533    }
534
535    #[test]
536    fn test_parse_date() {
537        // Test cases are mostly from test-parse-date.t.
538        // Some variants were added.
539        set_default_offset(7200);
540
541        // t: parse date
542        // d: parse date, compare with now, within expected range
543        // The right side of assert_eq! is a string so it's autofix-able.
544
545        assert_eq!(t("2006-02-01 13:00:30"), t("2006-02-01 13:00:30-0200"));
546        assert_eq!(t("2006-02-01 13:00:30-0500"), "1138816830 18000");
547        assert_eq!(t("2006-02-01 13:00:30 +05:00"), "1138780830 -18000");
548        assert_eq!(t("2006-02-01 13:00:30Z"), "1138798830 0");
549        assert_eq!(t("2006-02-01 13:00:30 GMT"), "1138798830 0");
550        assert_eq!(t("2006-4-5 13:30"), "1144251000 7200");
551        assert_eq!(t("1150000000 14400"), "1150000000 14400");
552        assert_eq!(t("100000 1400000"), "fail");
553        assert_eq!(t("1000000000 -16200"), "1000000000 -16200");
554        assert_eq!(t("2006-02-01 1:00:30PM +0000"), "1138798830 0");
555
556        assert_eq!(d("1:00:30PM +0000", Duration::days(1)), "0");
557        assert_eq!(d("02/01", Duration::weeks(52)), "0");
558        assert_eq!(d("today", Duration::days(1)), "0");
559        assert_eq!(d("yesterday", Duration::days(2)), "0");
560
561        // ISO8601
562        assert_eq!(t("2016-07-27T12:10:21"), "1469628621 7200");
563        assert_eq!(t("2016-07-27T12:10:21Z"), "1469621421 0");
564        assert_eq!(t("2016-07-27T12:10:21+00:00"), "1469621421 0");
565        assert_eq!(t("2016-07-27T121021Z"), "1469621421 0");
566        assert_eq!(t("2016-07-27 12:10:21"), "1469628621 7200");
567        assert_eq!(t("2016-07-27 12:10:21Z"), "1469621421 0");
568        assert_eq!(t("2016-07-27 12:10:21+00:00"), "1469621421 0");
569        assert_eq!(t("2016-07-27 121021Z"), "1469621421 0");
570
571        // Months
572        assert_eq!(t("Jan 2018"), "1514772000 7200");
573        assert_eq!(t("Feb 2018"), "1517450400 7200");
574        assert_eq!(t("Mar 2018"), "1519869600 7200");
575        assert_eq!(t("Apr 2018"), "1522548000 7200");
576        assert_eq!(t("May 2018"), "1525140000 7200");
577        assert_eq!(t("Jun 2018"), "1527818400 7200");
578        assert_eq!(t("Jul 2018"), "1530410400 7200");
579        assert_eq!(t("Sep 2018"), "1535767200 7200");
580        assert_eq!(t("Oct 2018"), "1538359200 7200");
581        assert_eq!(t("Nov 2018"), "1541037600 7200");
582        assert_eq!(t("Dec 2018"), "1543629600 7200");
583        assert_eq!(t("Foo 2018"), "fail");
584
585        // Extra tests not in test-parse-date.t
586        assert_eq!(d("Jan", Duration::weeks(52)), "0");
587        assert_eq!(d("Jan 1", Duration::weeks(52)), "0"); // 1 is not considered as "year 1"
588        assert_eq!(d("4-26", Duration::weeks(52)), "0");
589        assert_eq!(d("4/26", Duration::weeks(52)), "0");
590        assert_eq!(t("4/26/2000"), "956714400 7200");
591        assert_eq!(t("Apr 26 2000"), "956714400 7200");
592        assert_eq!(t("2020"), "1577844000 7200"); // 2020 is considered as a "year"
593        assert_eq!(t("2020 GMT"), "1577836800 0");
594        assert_eq!(t("2020-12"), "1606788000 7200");
595        assert_eq!(t("2020-13"), "fail");
596        assert_eq!(t("1000"), "fail"); // year 1000 < HgTime::min_value()
597        assert_eq!(t("1"), "fail");
598        assert_eq!(t("0"), "fail");
599        assert_eq!(t("100000000000000000 1400"), "fail");
600
601        assert_eq!(t("Fri, 20 Sep 2019 12:15:13 -0700"), "1569006913 25200"); // date --rfc-2822
602        assert_eq!(t("Fri, 20 Sep 2019 12:15:13"), "1568988913 7200");
603    }
604
605    #[test]
606    fn test_parse_ago() {
607        set_default_offset(7200);
608        assert_eq!(d("10m ago", Duration::hours(1)), "0");
609        assert_eq!(d("10 min ago", Duration::hours(1)), "0");
610        assert_eq!(d("10 minutes ago", Duration::hours(1)), "0");
611        assert_eq!(d("10 hours ago", Duration::days(1)), "0");
612        assert_eq!(d("10 h ago", Duration::days(1)), "0");
613        assert_eq!(t("9999999 years ago"), "fail");
614    }
615
616    #[test]
617    fn test_parse_range() {
618        set_default_offset(7200);
619
620        assert_eq!(c("since 1 month ago", "now"), "contains");
621        assert_eq!(c("since 1 month ago", "2 months ago"), "does not contain");
622        assert_eq!(c("> 1 month ago", "2 months ago"), "does not contain");
623        assert_eq!(c("< 1 month ago", "2 months ago"), "contains");
624        assert_eq!(c("< 1 month ago", "now"), "does not contain");
625
626        assert_eq!(c("-3", "now"), "contains");
627        assert_eq!(c("-3", "2 days ago"), "contains");
628        assert_eq!(c("-3", "4 days ago"), "does not contain");
629
630        assert_eq!(c("2018", "2017-12-31 23:59:59"), "does not contain");
631        assert_eq!(c("2018", "2018-1-1"), "contains");
632        assert_eq!(c("2018", "2018-12-31 23:59:59"), "contains");
633        assert_eq!(c("2018", "2019-1-1"), "does not contain");
634
635        assert_eq!(c("2018-5-1 to 2018-6-2", "2018-4-30"), "does not contain");
636        assert_eq!(c("2018-5-1 to 2018-6-2", "2018-5-30"), "contains");
637        assert_eq!(c("2018-5-1 to 2018-6-2", "2018-6-30"), "does not contain");
638        assert_eq!(c("2018-5 to 2018-6", "2018-5-1 0:0:0"), "contains");
639        assert_eq!(c("2018-5 to 2018-6", "2018-6-30 23:59:59"), "contains");
640        assert_eq!(c("2018-5 to 2018-6 to 2018-7", "2018-6-30"), "fail");
641
642        // 0:0:0 yesterday to 23:59:59 today
643        // Usually it's 48 hours. However it might be affected by DST.
644        let range = HgTime::parse_range("yesterday to today").unwrap();
645        assert!(range.end.unixtime - range.start.unixtime >= (24 + 20) * 3600);
646    }
647
648    #[test]
649    fn test_parse_internal_format() {
650        assert_eq!(
651            HgTime::parse_hg_internal_format("123 456")
652                .unwrap()
653                .unwrap(),
654            HgTime {
655                unixtime: 123,
656                offset: 456
657            }
658        );
659        assert_eq!(
660            HgTime::parse_hg_internal_format("123.7 456")
661                .unwrap()
662                .unwrap(),
663            HgTime {
664                unixtime: 123,
665                offset: 456
666            }
667        );
668        assert_eq!(HgTime::parse_hg_internal_format("foobar"), None);
669        assert_eq!(
670            HgTime::parse_hg_internal_format("100000000000000000 1400"),
671            Some(None)
672        );
673    }
674
675    /// String representation of parse result.
676    fn t(date: &str) -> String {
677        match HgTime::parse(date) {
678            Some(time) => format!("{} {}", time.unixtime, time.offset),
679            None => "fail".to_string(),
680        }
681    }
682
683    /// String representation of (parse result - now) / seconds.
684    fn d(date: &str, duration: Duration) -> String {
685        match HgTime::parse(date) {
686            Some(time) => {
687                let value = (time.unixtime - HgTime::now().unwrap().unixtime).abs()
688                    / duration.num_seconds();
689                format!("{}", value)
690            }
691            None => "fail".to_string(),
692        }
693    }
694
695    /// String "contains" (if range contains date) or "does not contain"
696    /// or "fail" (if either range or date fails to parse).
697    fn c(range: &str, date: &str) -> &'static str {
698        if let (Some(range), Some(date)) = (HgTime::parse_range(range), HgTime::parse(date)) {
699            if range.contains(&date) {
700                "contains"
701            } else {
702                "does not contain"
703            }
704        } else {
705            "fail"
706        }
707    }
708}