1use chrono::NaiveDate;
31
32pub 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
59pub 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}