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}