evergreen/
date.rs

1//! Date handling utilities
2
3use crate::result::EgResult;
4use chrono::{DateTime, Datelike, Duration, FixedOffset, Local, NaiveDate, TimeZone};
5use chrono_tz::Tz;
6use regex::{Captures, Regex};
7use std::time::SystemTime;
8
9const INTERVAL_PART_REGEX: &str = r#"\s*([\+-]?)\s*(\d+)\s*(\w+)\s*"#;
10const INTERVAL_HMS_REGEX: &str = r#"(\d{2,}):(\d{2}):(\d{2})"#;
11
12/// Shortcut -- one fewer import for most mods.
13pub type EgDate = DateTime<FixedOffset>;
14
15/// Turn an interval string into a number of seconds.
16///
17/// Supports a subset of the language, which is typically enough
18/// for our use cases.  For better parsing, if needed, we could use
19/// (e.g.) <https://crates.io/crates/parse_duration>
20///
21/// ```
22/// use evergreen::date;
23///
24/// let seconds = date::interval_to_seconds("02:20:05").expect("Parse OK");
25/// assert_eq!(seconds, 8405);
26///
27/// let seconds = date::interval_to_seconds("1 min 2 seconds").expect("Parse OK");
28/// assert_eq!(seconds, 62);
29/// ```
30pub fn interval_to_seconds(interval: &str) -> EgResult<i64> {
31    let hms_reg = Regex::new(INTERVAL_HMS_REGEX).unwrap();
32    let part_reg = Regex::new(INTERVAL_PART_REGEX).unwrap();
33
34    let mut interval = interval.to_lowercase();
35    interval = interval.replace("and", ",");
36    interval = interval.replace(',', " ");
37
38    // Format hh:mm:ss
39    let interval = hms_reg.replace(&interval, |caps: &Captures| {
40        // caps[0] is the full source string
41        format!("{} h {} min {} s", &caps[1], &caps[2], &caps[3])
42    });
43
44    let mut amount = 0;
45    for (_, [sign, count, itype]) in part_reg.captures_iter(&interval).map(|c| c.extract()) {
46        let count = match count.parse::<i64>() {
47            Ok(c) => c,
48            Err(e) => {
49                log::warn!("Invalid interval number: {count} {e} from {interval}");
50                continue;
51            }
52        };
53
54        let change = if itype.starts_with('s') {
55            count
56        } else if itype.starts_with("min") {
57            count * 60
58        } else if itype.starts_with('h') {
59            count * 60 * 60
60        } else if itype.starts_with('d') {
61            count * 60 * 60 * 24
62        } else if itype.starts_with('w') {
63            count * 60 * 60 * 24 * 7
64        } else if itype.starts_with("mon") {
65            (count * 60 * 60 * 24 * 365) / 12
66        } else if itype.starts_with('y') {
67            count * 60 * 60 * 24 * 365
68        } else {
69            0
70        };
71
72        if sign == "-" {
73            amount -= change;
74        } else {
75            amount += change;
76        }
77    }
78
79    Ok(amount)
80}
81
82/// Current date/time with a fixed offset matching the local time zone.
83pub fn now_local() -> EgDate {
84    now()
85}
86
87/// Current date/time with a fixed offset matching the local time zone.
88pub fn now() -> EgDate {
89    to_local_timezone_fixed(Local::now().into())
90}
91
92/// Parse an ISO date string and return a date which retains its original
93/// time zone.
94///
95/// If the datetime string is in the Local timezone, for example, the
96/// DateTime value produced will also be in the local timezone.
97///
98/// ```
99/// use evergreen::date;
100/// use chrono::{DateTime, FixedOffset, Local};
101///
102/// let dt = date::parse_datetime("2023-07-11T12:00:00-0200");
103/// assert!(dt.is_ok());
104///
105/// let dt2 = date::parse_datetime("2023-07-11T11:00:00-0300");
106/// assert!(dt2.is_ok());
107///
108/// assert_eq!(dt.unwrap(), dt2.unwrap());
109///
110/// let dt = date::parse_datetime("2023-07-11");
111/// assert!(dt.is_ok());
112///
113/// let dt = date::parse_datetime("2023-07-11 HOWDY");
114/// assert!(dt.is_err());
115///
116/// let res = evergreen::date::parse_datetime("2023-02-03T12:23:19-0400");
117/// assert!(res.is_ok());
118///
119/// let d = res.unwrap().to_rfc3339();
120/// assert_eq!(d, "2023-02-03T12:23:19-04:00");
121///
122/// let res = evergreen::date::parse_datetime("2023-02-03T123");
123/// assert!(res.is_err());
124/// ```
125pub fn parse_datetime(dt: &str) -> EgResult<EgDate> {
126    if dt.len() > 10 {
127        // Assume its a full date + time
128        return match dt.parse::<EgDate>() {
129            Ok(d) => Ok(d),
130            Err(e) => return Err(format!("Could not parse datetime string: {e} {dt}").into()),
131        };
132    }
133
134    if dt.len() < 10 {
135        return Err(format!("Invalid date string: {dt}").into());
136    }
137
138    // Assumes it's just a YYYY-MM-DD
139    let date = match dt.parse::<NaiveDate>() {
140        Ok(d) => d,
141        Err(e) => return Err(format!("Could not parse date string: {e} {dt}").into()),
142    };
143
144    // If we only have a date, use the local timezone.
145    let local_date = match Local
146        .with_ymd_and_hms(date.year(), date.month(), date.day(), 0, 0, 0)
147        .earliest()
148    {
149        Some(d) => d,
150        None => return Err(format!("Could not parse date string: {dt}").into()),
151    };
152
153    Ok(local_date.into())
154}
155
156/// Turn a DateTime into the kind of date string we like in these parts.
157/// ```
158/// use evergreen::date;
159/// use chrono::{DateTime, FixedOffset, Local};
160/// let dt: date::EgDate = "2023-07-11T12:00:00-0700".parse().unwrap();
161/// assert_eq!(date::to_iso(&dt), "2023-07-11T12:00:00-0700");
162/// ```
163pub fn to_iso(dt: &EgDate) -> String {
164    dt.format("%FT%T%z").to_string()
165}
166
167/// Same as to_iso but includes milliseconds
168/// e.g. 2023-09-08T10:59:01.687-0400
169pub fn to_iso_millis(dt: &EgDate) -> String {
170    dt.format("%FT%T%.3f%z").to_string()
171}
172
173/// Translate a DateTime into the Local timezone while leaving the
174/// DateTime as a FixedOffset DateTime.
175/// ```
176/// use evergreen::date;
177/// use chrono::{DateTime, FixedOffset, Local};
178/// let dt: date::EgDate = "2023-07-11T12:00:00-0200".parse().unwrap();
179/// let dt2: date::EgDate = date::to_local_timezone_fixed(dt);
180///
181/// // This test is faulty when the source date is on the other side
182/// // of a time change boundary.
183/// // assert_eq!(dt2.offset(), Local::now().offset());
184///
185/// // String output will vary by locale, but the dates will be equivalent.
186/// assert_eq!(dt, dt2);
187/// ```
188pub fn to_local_timezone_fixed(dt: EgDate) -> DateTime<FixedOffset> {
189    let local: DateTime<Local> = dt.into();
190
191    // Translate back to a fixed time zone using our newly
192    // acquired local time zone as the offset.
193    local.with_timezone(local.offset())
194}
195
196/// Apply a timezone to a DateTime value.
197///
198/// This does not change the date/time, only the lense through which
199/// the datetime is interpreted (string representation, hour, day of week, etc.).
200///
201/// To apply a timezone to a Local or Utc value, just:
202/// set_timezone(local_date.into(), "America/New_York");
203///
204/// ```
205/// use evergreen::date;
206/// use chrono::{DateTime, FixedOffset};
207/// let dt: date::EgDate = "2023-07-11T12:00:00-0400".parse().unwrap();
208/// let dt = date::set_timezone(dt, "GMT").unwrap();
209/// assert_eq!(date::to_iso(&dt), "2023-07-11T16:00:00+0000");
210/// ```
211pub fn set_timezone(dt: EgDate, timezone: &str) -> EgResult<EgDate> {
212    if timezone == "local" {
213        return Ok(to_local_timezone_fixed(dt));
214    }
215
216    // Parse the time zone string.
217    let tz: Tz = timezone
218        .parse()
219        .map_err(|e| format!("Cannot parse timezone: {timezone} {e}"))?;
220
221    let modified = dt.with_timezone(&tz);
222
223    let fixed: EgDate = match modified.format("%FT%T%z").to_string().parse() {
224        Ok(f) => f,
225        Err(e) => Err(format!("Cannot reconstruct date: {modified:?} : {e}"))?,
226    };
227
228    Ok(fixed)
229}
230
231/// Set the hour/minute/seconds on a DateTime, retaining the original date and timezone.
232///
233/// (There's gotta be a better way...)
234///
235/// ```
236/// use evergreen::date;
237/// use chrono::{DateTime, FixedOffset};
238/// let dt: date::EgDate = "2023-07-11T01:25:18-0400".parse().unwrap();
239/// let dt = date::set_hms(&dt, 23, 59, 59).unwrap();
240/// assert_eq!(date::to_iso(&dt), "2023-07-11T23:59:59-0400");
241/// ```
242pub fn set_hms(date: &EgDate, hours: u32, minutes: u32, seconds: u32) -> EgResult<EgDate> {
243    let offset = FixedOffset::from_offset(date.offset());
244
245    let datetime = match date.date_naive().and_hms_opt(hours, minutes, seconds) {
246        Some(dt) => dt,
247        None => Err(format!("Could not set time to {hours}:{minutes}:{seconds}"))?,
248    };
249
250    // and_local_timezone() can return multiples in cases where it's ambiguous.
251    let new_date: EgDate = match datetime.and_local_timezone(offset).single() {
252        Some(d) => d,
253        None => Err(format!("Error setting timezone for datetime {datetime:?}"))?,
254    };
255
256    Ok(new_date)
257}
258
259/// Add an interval (string) to a date.
260///
261/// ```
262/// use evergreen as eg;
263/// use eg::date;
264/// let dt = date::add_interval(
265///     date::parse_datetime("2023-08-18T23:59:59-0400").unwrap(),
266///     "1 day 1 hour 5 minutes 1 second"
267/// ).unwrap();
268/// assert_eq!("2023-08-20T01:05:00-0400", &date::to_iso(&dt));
269/// ```
270pub fn add_interval(date: EgDate, interval: &str) -> EgResult<EgDate> {
271    let seconds = interval_to_seconds(interval)?;
272    let duration = Duration::try_seconds(seconds)
273        .ok_or_else(|| format!("Invalid duration seconds: {seconds}"))?;
274
275    Ok(date + duration)
276}
277
278pub fn subtract_interval(date: EgDate, interval: &str) -> EgResult<EgDate> {
279    let seconds = interval_to_seconds(interval)?;
280    let duration = Duration::try_seconds(seconds)
281        .ok_or_else(|| format!("Invalid duration seconds: {seconds}"))?;
282
283    Ok(date - duration)
284}
285
286/// Epoch seconds with fractional milliseconds.
287pub fn epoch_secs() -> f64 {
288    if let Ok(dur) = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) {
289        let ms = dur.as_millis();
290        ms as f64 / 1000.0
291    } else {
292        0.0
293    }
294}
295
296/// Epoch seconds as a string with 3 decimal places of milliseconds.
297pub fn epoch_secs_str() -> String {
298    format!("{:0<3}", epoch_secs())
299}