Skip to main content

devcap_core/
period.rs

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    /// Build a range from two explicit dates (both inclusive).
105    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    /// Build a range from a start date to now.
118    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    /// Override the upper bound of an existing range.
126    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}