Skip to main content

bear_rs/
dates.rs

1use anyhow::{Result, anyhow, bail};
2use chrono::{Datelike, Duration, Local, LocalResult, NaiveDate, TimeZone};
3
4const APPLE_EPOCH_OFFSET_SECONDS: i64 = 978_307_200;
5
6pub fn parse_bear_date_filter(input: &str) -> Result<i64> {
7    let now = Local::now();
8    let today = now.date_naive();
9
10    let date = match input {
11        "today" => today,
12        "yesterday" => today - Duration::days(1),
13        "last-week" => today - Duration::days(7),
14        "last-month" => shift_months(today, -1),
15        "last-year" => shift_years(today, -1),
16        other => NaiveDate::parse_from_str(other, "%Y-%m-%d")
17            .map_err(|_| anyhow!("invalid date filter: {other}"))?,
18    };
19
20    let datetime = date
21        .and_hms_opt(0, 0, 0)
22        .ok_or_else(|| anyhow!("invalid date value"))?;
23    let local = match Local.from_local_datetime(&datetime) {
24        LocalResult::Single(value) => value,
25        LocalResult::Ambiguous(earliest, _) => earliest,
26        LocalResult::None => bail!("could not resolve local date for {input}"),
27    };
28    Ok(local.timestamp() - APPLE_EPOCH_OFFSET_SECONDS)
29}
30
31fn shift_months(date: NaiveDate, delta: i32) -> NaiveDate {
32    let total_months = date.year() * 12 + date.month0() as i32 + delta;
33    let year = total_months.div_euclid(12);
34    let month0 = total_months.rem_euclid(12) as u32;
35    let last_day = last_day_of_month(year, month0 + 1);
36    let day = date.day().min(last_day);
37    NaiveDate::from_ymd_opt(year, month0 + 1, day).expect("valid shifted month date")
38}
39
40fn shift_years(date: NaiveDate, delta: i32) -> NaiveDate {
41    let year = date.year() + delta;
42    let last_day = last_day_of_month(year, date.month());
43    let day = date.day().min(last_day);
44    NaiveDate::from_ymd_opt(year, date.month(), day).expect("valid shifted year date")
45}
46
47fn last_day_of_month(year: i32, month: u32) -> u32 {
48    for day in (28..=31).rev() {
49        if NaiveDate::from_ymd_opt(year, month, day).is_some() {
50            return day;
51        }
52    }
53    28
54}
55
56#[cfg(test)]
57mod tests {
58    use chrono::{Datelike, Duration, Local, TimeZone};
59
60    use super::parse_bear_date_filter;
61
62    const APPLE_EPOCH_OFFSET_SECONDS: i64 = 978_307_200;
63
64    #[test]
65    fn parses_absolute_date() {
66        let parsed = parse_bear_date_filter("2026-04-01").expect("date should parse");
67        let expected = Local
68            .with_ymd_and_hms(2026, 4, 1, 0, 0, 0)
69            .earliest()
70            .expect("valid local date")
71            .timestamp()
72            - APPLE_EPOCH_OFFSET_SECONDS;
73        assert_eq!(parsed, expected);
74    }
75
76    #[test]
77    fn parses_relative_dates() {
78        let now = Local::now();
79        let today = now.date_naive();
80        let expected_yesterday = Local
81            .from_local_datetime(
82                &(today - Duration::days(1))
83                    .and_hms_opt(0, 0, 0)
84                    .expect("valid midnight"),
85            )
86            .earliest()
87            .expect("valid local datetime")
88            .timestamp()
89            - APPLE_EPOCH_OFFSET_SECONDS;
90
91        let parsed = parse_bear_date_filter("yesterday").expect("yesterday should parse");
92        assert_eq!(parsed, expected_yesterday);
93    }
94
95    #[test]
96    fn parses_last_month_with_clamped_day() {
97        let now = Local::now().date_naive();
98        if now.month() == 3 && now.day() == 31 {
99            let parsed = parse_bear_date_filter("last-month").expect("last-month should parse");
100            let expected = Local
101                .with_ymd_and_hms(now.year(), 2, 28, 0, 0, 0)
102                .earliest()
103                .expect("valid local date")
104                .timestamp()
105                - APPLE_EPOCH_OFFSET_SECONDS;
106            assert_eq!(parsed, expected);
107        }
108    }
109}