use chrono::NaiveDate;
pub fn resolve_date_range(
since: Option<NaiveDate>,
until: Option<NaiveDate>,
period: Option<&str>,
) -> Result<(NaiveDate, NaiveDate), String> {
if let Some(p) = period {
return parse_period(p);
}
let Some(since) = since else {
return Ok((NaiveDate::MIN, NaiveDate::MAX));
};
let until = until.unwrap_or_else(|| chrono::Local::now().date_naive());
if since > until {
return Err(format!("--since ({since}) is after --until ({until})"));
}
Ok((since, until))
}
pub fn parse_period(period: &str) -> Result<(NaiveDate, NaiveDate), String> {
let period = period.trim();
if period.len() < 4 {
return Err(format!(
"invalid period: {period} (expected e.g. 2026, 2026Q1, or 2026H1)"
));
}
let (year_str, kind) = period.split_at(4);
let year: i32 = year_str
.parse()
.map_err(|_| format!("invalid year in period: {period}"))?;
let (start_month, end_month) = match kind.to_uppercase().as_str() {
"" => (1, 12),
"Q1" => (1, 3),
"Q2" => (4, 6),
"Q3" => (7, 9),
"Q4" => (10, 12),
"H1" => (1, 6),
"H2" => (7, 12),
_ => {
return Err(format!(
"invalid period: {period} (expected Q1-Q4 or H1-H2)"
));
}
};
let start = NaiveDate::from_ymd_opt(year, start_month, 1).unwrap();
let end = NaiveDate::from_ymd_opt(year + i32::from(end_month == 12), end_month % 12 + 1, 1)
.unwrap()
.pred_opt()
.unwrap();
Ok((start, end))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_period_bare_year() {
let (s, e) = parse_period("2026").unwrap();
assert_eq!(s, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap());
assert_eq!(e, NaiveDate::from_ymd_opt(2026, 12, 31).unwrap());
}
#[test]
fn parse_period_quarters_and_halves() {
let (s, e) = parse_period("2026Q2").unwrap();
assert_eq!(s, NaiveDate::from_ymd_opt(2026, 4, 1).unwrap());
assert_eq!(e, NaiveDate::from_ymd_opt(2026, 6, 30).unwrap());
let (s, e) = parse_period("2026H1").unwrap();
assert_eq!(s, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap());
assert_eq!(e, NaiveDate::from_ymd_opt(2026, 6, 30).unwrap());
let (s, e) = parse_period("2026h2").unwrap();
assert_eq!(s, NaiveDate::from_ymd_opt(2026, 7, 1).unwrap());
assert_eq!(e, NaiveDate::from_ymd_opt(2026, 12, 31).unwrap());
}
#[test]
fn parse_period_rejects_garbage() {
assert!(parse_period("202").is_err());
assert!(parse_period("abcd").is_err());
assert!(parse_period("2026Q9").is_err());
}
#[test]
fn resolve_date_range_defaults_to_open() {
let (s, e) = resolve_date_range(None, None, None).unwrap();
assert_eq!(s, NaiveDate::MIN);
assert_eq!(e, NaiveDate::MAX);
}
#[test]
fn resolve_date_range_prefers_period() {
let (s, e) = resolve_date_range(None, None, Some("2026Q1")).unwrap();
assert_eq!(s, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap());
assert_eq!(e, NaiveDate::from_ymd_opt(2026, 3, 31).unwrap());
}
#[test]
fn resolve_date_range_rejects_inverted_range() {
let err = resolve_date_range(
NaiveDate::from_ymd_opt(2026, 6, 1),
NaiveDate::from_ymd_opt(2026, 1, 1),
None,
)
.unwrap_err();
assert!(err.contains("is after"));
}
}