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 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 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}