Skip to main content

sandogasa_cli/
date.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2
3//! Shared calendar date range parsing for CLI tools.
4//!
5//! Two forms are supported on the command line side:
6//!
7//! - `--since YYYY-MM-DD [--until YYYY-MM-DD]` — explicit,
8//!   inclusive range. `until` defaults to today when omitted.
9//! - `--period <token>` — a `YYYY`, `YYYYQ1..Q4`, or
10//!   `YYYYH1..H2` shortcut that expands to the matching
11//!   calendar range.
12//!
13//! Tools wire these up as two option groups and pass the raw
14//! values into [`resolve_date_range`]. See
15//! [`parse_period`] for the period token grammar.
16//!
17//! ```
18//! use chrono::NaiveDate;
19//! use sandogasa_cli::date::{parse_period, resolve_date_range};
20//!
21//! let (start, end) = parse_period("2026Q1").unwrap();
22//! assert_eq!(start, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap());
23//! assert_eq!(end,   NaiveDate::from_ymd_opt(2026, 3, 31).unwrap());
24//!
25//! let (s, e) = resolve_date_range(None, None, Some("2026H2")).unwrap();
26//! assert_eq!(s, NaiveDate::from_ymd_opt(2026, 7, 1).unwrap());
27//! assert_eq!(e, NaiveDate::from_ymd_opt(2026, 12, 31).unwrap());
28//! ```
29
30use chrono::NaiveDate;
31
32/// Resolve a `(--since, --until, --period)` triple into an
33/// inclusive date range.
34///
35/// Precedence: `period` wins when supplied. Otherwise
36/// `since` + `until` are used (with `until` defaulting to
37/// today's local date when absent). When all three are `None`,
38/// the range is unbounded (`NaiveDate::MIN..=NaiveDate::MAX`).
39///
40/// Errors when `since` is strictly after `until`.
41pub fn resolve_date_range(
42    since: Option<NaiveDate>,
43    until: Option<NaiveDate>,
44    period: Option<&str>,
45) -> Result<(NaiveDate, NaiveDate), String> {
46    if let Some(p) = period {
47        return parse_period(p);
48    }
49    let Some(since) = since else {
50        return Ok((NaiveDate::MIN, NaiveDate::MAX));
51    };
52    let until = until.unwrap_or_else(|| chrono::Local::now().date_naive());
53    if since > until {
54        return Err(format!("--since ({since}) is after --until ({until})"));
55    }
56    Ok((since, until))
57}
58
59/// Parse a calendar-period shortcut into an inclusive
60/// `(start, end)` range.
61///
62/// Accepted forms (case-insensitive on the suffix):
63///
64/// - `YYYY` — the full calendar year.
65/// - `YYYYQ1` / `Q2` / `Q3` / `Q4` — that calendar quarter.
66/// - `YYYYH1` / `H2` — the first or second half of the year.
67pub fn parse_period(period: &str) -> Result<(NaiveDate, NaiveDate), String> {
68    let period = period.trim();
69    if period.len() < 4 {
70        return Err(format!(
71            "invalid period: {period} (expected e.g. 2026, 2026Q1, or 2026H1)"
72        ));
73    }
74    let (year_str, kind) = period.split_at(4);
75    let year: i32 = year_str
76        .parse()
77        .map_err(|_| format!("invalid year in period: {period}"))?;
78    let (start_month, end_month) = match kind.to_uppercase().as_str() {
79        "" => (1, 12),
80        "Q1" => (1, 3),
81        "Q2" => (4, 6),
82        "Q3" => (7, 9),
83        "Q4" => (10, 12),
84        "H1" => (1, 6),
85        "H2" => (7, 12),
86        _ => {
87            return Err(format!(
88                "invalid period: {period} (expected Q1-Q4 or H1-H2)"
89            ));
90        }
91    };
92    let start = NaiveDate::from_ymd_opt(year, start_month, 1).unwrap();
93    // Last day of end_month: the day before the first of the
94    // following month.
95    let end = NaiveDate::from_ymd_opt(year + i32::from(end_month == 12), end_month % 12 + 1, 1)
96        .unwrap()
97        .pred_opt()
98        .unwrap();
99    Ok((start, end))
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn parse_period_bare_year() {
108        let (s, e) = parse_period("2026").unwrap();
109        assert_eq!(s, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap());
110        assert_eq!(e, NaiveDate::from_ymd_opt(2026, 12, 31).unwrap());
111    }
112
113    #[test]
114    fn parse_period_quarters_and_halves() {
115        let (s, e) = parse_period("2026Q2").unwrap();
116        assert_eq!(s, NaiveDate::from_ymd_opt(2026, 4, 1).unwrap());
117        assert_eq!(e, NaiveDate::from_ymd_opt(2026, 6, 30).unwrap());
118        let (s, e) = parse_period("2026H1").unwrap();
119        assert_eq!(s, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap());
120        assert_eq!(e, NaiveDate::from_ymd_opt(2026, 6, 30).unwrap());
121        let (s, e) = parse_period("2026h2").unwrap();
122        assert_eq!(s, NaiveDate::from_ymd_opt(2026, 7, 1).unwrap());
123        assert_eq!(e, NaiveDate::from_ymd_opt(2026, 12, 31).unwrap());
124    }
125
126    #[test]
127    fn parse_period_rejects_garbage() {
128        assert!(parse_period("202").is_err());
129        assert!(parse_period("abcd").is_err());
130        assert!(parse_period("2026Q9").is_err());
131    }
132
133    #[test]
134    fn resolve_date_range_defaults_to_open() {
135        let (s, e) = resolve_date_range(None, None, None).unwrap();
136        assert_eq!(s, NaiveDate::MIN);
137        assert_eq!(e, NaiveDate::MAX);
138    }
139
140    #[test]
141    fn resolve_date_range_prefers_period() {
142        let (s, e) = resolve_date_range(None, None, Some("2026Q1")).unwrap();
143        assert_eq!(s, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap());
144        assert_eq!(e, NaiveDate::from_ymd_opt(2026, 3, 31).unwrap());
145    }
146
147    #[test]
148    fn resolve_date_range_rejects_inverted_range() {
149        let err = resolve_date_range(
150            NaiveDate::from_ymd_opt(2026, 6, 1),
151            NaiveDate::from_ymd_opt(2026, 1, 1),
152            None,
153        )
154        .unwrap_err();
155        assert!(err.contains("is after"));
156    }
157}