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}"))?;
if kind.is_empty() {
return Ok((
NaiveDate::from_ymd_opt(year, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(year, 12, 31).unwrap(),
));
}
match kind.to_uppercase().as_str() {
"Q1" => Ok((
NaiveDate::from_ymd_opt(year, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(year, 3, 31).unwrap(),
)),
"Q2" => Ok((
NaiveDate::from_ymd_opt(year, 4, 1).unwrap(),
NaiveDate::from_ymd_opt(year, 6, 30).unwrap(),
)),
"Q3" => Ok((
NaiveDate::from_ymd_opt(year, 7, 1).unwrap(),
NaiveDate::from_ymd_opt(year, 9, 30).unwrap(),
)),
"Q4" => Ok((
NaiveDate::from_ymd_opt(year, 10, 1).unwrap(),
NaiveDate::from_ymd_opt(year, 12, 31).unwrap(),
)),
"H1" => Ok((
NaiveDate::from_ymd_opt(year, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(year, 6, 30).unwrap(),
)),
"H2" => Ok((
NaiveDate::from_ymd_opt(year, 7, 1).unwrap(),
NaiveDate::from_ymd_opt(year, 12, 31).unwrap(),
)),
_ => Err(format!(
"invalid period: {period} (expected Q1-Q4 or H1-H2)"
)),
}
}
#[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"));
}
}