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    if kind.is_empty() {
79        return Ok((
80            NaiveDate::from_ymd_opt(year, 1, 1).unwrap(),
81            NaiveDate::from_ymd_opt(year, 12, 31).unwrap(),
82        ));
83    }
84    match kind.to_uppercase().as_str() {
85        "Q1" => Ok((
86            NaiveDate::from_ymd_opt(year, 1, 1).unwrap(),
87            NaiveDate::from_ymd_opt(year, 3, 31).unwrap(),
88        )),
89        "Q2" => Ok((
90            NaiveDate::from_ymd_opt(year, 4, 1).unwrap(),
91            NaiveDate::from_ymd_opt(year, 6, 30).unwrap(),
92        )),
93        "Q3" => Ok((
94            NaiveDate::from_ymd_opt(year, 7, 1).unwrap(),
95            NaiveDate::from_ymd_opt(year, 9, 30).unwrap(),
96        )),
97        "Q4" => Ok((
98            NaiveDate::from_ymd_opt(year, 10, 1).unwrap(),
99            NaiveDate::from_ymd_opt(year, 12, 31).unwrap(),
100        )),
101        "H1" => Ok((
102            NaiveDate::from_ymd_opt(year, 1, 1).unwrap(),
103            NaiveDate::from_ymd_opt(year, 6, 30).unwrap(),
104        )),
105        "H2" => Ok((
106            NaiveDate::from_ymd_opt(year, 7, 1).unwrap(),
107            NaiveDate::from_ymd_opt(year, 12, 31).unwrap(),
108        )),
109        _ => Err(format!(
110            "invalid period: {period} (expected Q1-Q4 or H1-H2)"
111        )),
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn parse_period_bare_year() {
121        let (s, e) = parse_period("2026").unwrap();
122        assert_eq!(s, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap());
123        assert_eq!(e, NaiveDate::from_ymd_opt(2026, 12, 31).unwrap());
124    }
125
126    #[test]
127    fn parse_period_quarters_and_halves() {
128        let (s, e) = parse_period("2026Q2").unwrap();
129        assert_eq!(s, NaiveDate::from_ymd_opt(2026, 4, 1).unwrap());
130        assert_eq!(e, NaiveDate::from_ymd_opt(2026, 6, 30).unwrap());
131        let (s, e) = parse_period("2026H1").unwrap();
132        assert_eq!(s, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap());
133        assert_eq!(e, NaiveDate::from_ymd_opt(2026, 6, 30).unwrap());
134        let (s, e) = parse_period("2026h2").unwrap();
135        assert_eq!(s, NaiveDate::from_ymd_opt(2026, 7, 1).unwrap());
136        assert_eq!(e, NaiveDate::from_ymd_opt(2026, 12, 31).unwrap());
137    }
138
139    #[test]
140    fn parse_period_rejects_garbage() {
141        assert!(parse_period("202").is_err());
142        assert!(parse_period("abcd").is_err());
143        assert!(parse_period("2026Q9").is_err());
144    }
145
146    #[test]
147    fn resolve_date_range_defaults_to_open() {
148        let (s, e) = resolve_date_range(None, None, None).unwrap();
149        assert_eq!(s, NaiveDate::MIN);
150        assert_eq!(e, NaiveDate::MAX);
151    }
152
153    #[test]
154    fn resolve_date_range_prefers_period() {
155        let (s, e) = resolve_date_range(None, None, Some("2026Q1")).unwrap();
156        assert_eq!(s, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap());
157        assert_eq!(e, NaiveDate::from_ymd_opt(2026, 3, 31).unwrap());
158    }
159
160    #[test]
161    fn resolve_date_range_rejects_inverted_range() {
162        let err = resolve_date_range(
163            NaiveDate::from_ymd_opt(2026, 6, 1),
164            NaiveDate::from_ymd_opt(2026, 1, 1),
165            None,
166        )
167        .unwrap_err();
168        assert!(err.contains("is after"));
169    }
170}