1use chrono::{DateTime, Datelike, Duration, Local, NaiveDate, NaiveTime};
2use std::str::FromStr;
3
4#[derive(Debug, Clone)]
5pub enum Period {
6 Today,
7 Yesterday,
8 Hours(u32),
9 Days(u32),
10 Week,
11}
12
13#[derive(Debug)]
14pub struct TimeRange {
15 pub since: DateTime<Local>,
16 pub until: Option<DateTime<Local>>,
17}
18
19impl FromStr for Period {
20 type Err = String;
21
22 fn from_str(s: &str) -> Result<Self, Self::Err> {
23 match s {
24 "today" => Ok(Period::Today),
25 "yesterday" => Ok(Period::Yesterday),
26 "week" => Ok(Period::Week),
27 other => {
28 if let Some(h) = other.strip_suffix('h') {
29 h.parse::<u32>()
30 .map(Period::Hours)
31 .map_err(|_| format!("Invalid hours: {other}"))
32 } else if let Some(d) = other.strip_suffix('d') {
33 d.parse::<u32>()
34 .map(Period::Days)
35 .map_err(|_| format!("Invalid days: {other}"))
36 } else {
37 Err(format!(
38 "Unknown period: {other}. Use: today, yesterday, 24h, 3d, 7d, week"
39 ))
40 }
41 }
42 }
43 }
44}
45
46impl Period {
47 pub fn to_time_range(&self) -> TimeRange {
48 let now = Local::now();
49 let start_of_today = now
50 .date_naive()
51 .and_time(NaiveTime::MIN)
52 .and_local_timezone(Local)
53 .single()
54 .unwrap_or(now);
55
56 match self {
57 Period::Today => TimeRange {
58 since: start_of_today,
59 until: None,
60 },
61 Period::Yesterday => {
62 let yesterday_start = start_of_today - Duration::days(1);
63 TimeRange {
64 since: yesterday_start,
65 until: Some(start_of_today),
66 }
67 }
68 Period::Hours(h) => TimeRange {
69 since: now - Duration::hours(i64::from(*h)),
70 until: None,
71 },
72 Period::Days(d) => TimeRange {
73 since: now - Duration::days(i64::from(*d)),
74 until: None,
75 },
76 Period::Week => {
77 let days_since_monday = now.weekday().num_days_from_monday() as i64;
78 let monday = start_of_today - Duration::days(days_since_monday);
79 TimeRange {
80 since: monday,
81 until: None,
82 }
83 }
84 }
85 }
86}
87
88fn end_of_day(date: NaiveDate) -> Result<DateTime<Local>, String> {
89 let time = NaiveTime::from_hms_opt(23, 59, 59).unwrap_or(NaiveTime::MIN);
90 date.and_time(time)
91 .and_local_timezone(Local)
92 .single()
93 .ok_or_else(|| format!("Cannot convert {date} to local time"))
94}
95
96fn start_of_day(date: NaiveDate) -> Result<DateTime<Local>, String> {
97 date.and_time(NaiveTime::MIN)
98 .and_local_timezone(Local)
99 .single()
100 .ok_or_else(|| format!("Cannot convert {date} to local time"))
101}
102
103impl TimeRange {
104 pub fn from_dates(since: NaiveDate, until: NaiveDate) -> Result<Self, String> {
106 if since > until {
107 return Err(format!(
108 "--since ({since}) must be on or before --until ({until})"
109 ));
110 }
111 Ok(TimeRange {
112 since: start_of_day(since)?,
113 until: Some(end_of_day(until)?),
114 })
115 }
116
117 pub fn from_since_date(since: NaiveDate) -> Result<Self, String> {
119 Ok(TimeRange {
120 since: start_of_day(since)?,
121 until: None,
122 })
123 }
124
125 pub fn with_until_date(self, until: NaiveDate) -> Result<Self, String> {
127 let until_dt = end_of_day(until)?;
128 if self.since >= until_dt {
129 return Err(format!("--since must be before --until ({until})"));
130 }
131 Ok(TimeRange {
132 since: self.since,
133 until: Some(until_dt),
134 })
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use chrono::{Timelike, Weekday};
142
143 #[test]
144 fn parse_today() {
145 assert!(matches!(Period::from_str("today"), Ok(Period::Today)));
146 }
147
148 #[test]
149 fn parse_yesterday() {
150 assert!(matches!(
151 Period::from_str("yesterday"),
152 Ok(Period::Yesterday)
153 ));
154 }
155
156 #[test]
157 fn parse_week() {
158 assert!(matches!(Period::from_str("week"), Ok(Period::Week)));
159 }
160
161 #[test]
162 fn parse_hours() {
163 let period = Period::from_str("24h");
164 assert!(matches!(period, Ok(Period::Hours(24))));
165 }
166
167 #[test]
168 fn parse_days() {
169 let period = Period::from_str("7d");
170 assert!(matches!(period, Ok(Period::Days(7))));
171 }
172
173 #[test]
174 fn parse_custom_days() {
175 let period = Period::from_str("14d");
176 assert!(matches!(period, Ok(Period::Days(14))));
177 }
178
179 #[test]
180 fn parse_invalid_returns_error() {
181 assert!(Period::from_str("invalid").is_err());
182 }
183
184 #[test]
185 fn parse_invalid_number_returns_error() {
186 assert!(Period::from_str("abch").is_err());
187 }
188
189 #[test]
190 fn today_range_starts_at_midnight() {
191 let range = Period::Today.to_time_range();
192 assert_eq!(range.since.time().hour(), 0);
193 assert_eq!(range.since.time().minute(), 0);
194 assert!(range.until.is_none());
195 }
196
197 #[test]
198 fn yesterday_range_has_both_bounds() {
199 let range = Period::Yesterday.to_time_range();
200 assert!(range.until.is_some());
201 let until = range.until.as_ref().unwrap_or(&range.since);
202 assert!(range.since < *until);
203 }
204
205 #[test]
206 fn hours_range_is_in_past() {
207 let range = Period::Hours(24).to_time_range();
208 assert!(range.since < Local::now());
209 assert!(range.until.is_none());
210 }
211
212 #[test]
213 fn week_range_starts_on_monday() {
214 let range = Period::Week.to_time_range();
215 assert_eq!(range.since.weekday(), Weekday::Mon);
216 }
217
218 #[test]
219 fn from_dates_valid_range() {
220 let since = NaiveDate::from_ymd_opt(2026, 3, 1).expect("valid date");
221 let until = NaiveDate::from_ymd_opt(2026, 3, 10).expect("valid date");
222 let range = TimeRange::from_dates(since, until).expect("valid range");
223 assert_eq!(range.since.date_naive(), since);
224 assert_eq!(range.since.time().hour(), 0);
225 let until_dt = range.until.expect("should have until");
226 assert_eq!(until_dt.date_naive(), until);
227 assert_eq!(until_dt.time().hour(), 23);
228 assert_eq!(until_dt.time().minute(), 59);
229 }
230
231 #[test]
232 fn from_dates_same_day_is_valid() {
233 let date = NaiveDate::from_ymd_opt(2026, 3, 5).expect("valid date");
234 let range = TimeRange::from_dates(date, date).expect("same-day range");
235 assert_eq!(range.since.date_naive(), date);
236 let until_dt = range.until.expect("should have until");
237 assert_eq!(until_dt.date_naive(), date);
238 assert!(range.since < until_dt);
239 }
240
241 #[test]
242 fn from_dates_inverted_errors() {
243 let since = NaiveDate::from_ymd_opt(2026, 3, 10).expect("valid date");
244 let until = NaiveDate::from_ymd_opt(2026, 3, 1).expect("valid date");
245 let err = TimeRange::from_dates(since, until).unwrap_err();
246 assert!(err.contains("must be on or before"));
247 }
248
249 #[test]
250 fn from_since_date_open_ended() {
251 let since = NaiveDate::from_ymd_opt(2026, 3, 1).expect("valid date");
252 let range = TimeRange::from_since_date(since).expect("valid range");
253 assert_eq!(range.since.date_naive(), since);
254 assert!(range.until.is_none());
255 }
256
257 #[test]
258 fn with_until_date_overrides_end() {
259 let range = Period::Week.to_time_range();
260 let until = NaiveDate::from_ymd_opt(2030, 12, 31).expect("valid date");
261 let capped = range.with_until_date(until).expect("valid range");
262 let until_dt = capped.until.expect("should have until");
263 assert_eq!(until_dt.date_naive(), until);
264 }
265
266 #[test]
267 fn with_until_date_before_since_errors() {
268 let range = Period::Today.to_time_range();
269 let until = NaiveDate::from_ymd_opt(2020, 1, 1).expect("valid date");
270 assert!(range.with_until_date(until).is_err());
271 }
272}