use anyhow::{anyhow, bail, Result};
use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Timelike, Utc};
use clap::Args;
use edtf::level_1::{Date as EdtfDate, Edtf, Precision, Season};
use interim::{parse_date_string, Dialect};
use regex::Regex;
use std::sync::OnceLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Bound {
Start,
End,
}
pub type Bounds = (Option<DateTime<Utc>>, Option<DateTime<Utc>>);
pub const TIME_WINDOW_HEADING: &str = "Time window (formats: `aise dates`)";
#[derive(Debug, Clone, Default, Args)]
pub struct DateRange {
#[arg(long, help_heading = TIME_WINDOW_HEADING)]
pub since: Option<String>,
#[arg(long, help_heading = TIME_WINDOW_HEADING)]
pub until: Option<String>,
#[arg(long, help_heading = TIME_WINDOW_HEADING, conflicts_with_all = ["since", "until"])]
pub when: Option<String>,
}
impl DateRange {
pub fn resolve(&self, now: DateTime<Utc>) -> Result<Bounds> {
if let Some(when) = self.when.as_deref() {
let (start, end) =
parse_span(when, now).map_err(|err| anyhow!("invalid --when '{when}': {err}"))?;
if start > end {
bail!(
"invalid --when '{when}': resolved period is reversed; put the earlier date first"
);
}
return Ok((Some(start), Some(end)));
}
let since = self
.since
.as_deref()
.map(|raw| {
parse_bound(raw, Bound::Start, now)
.map_err(|err| anyhow!("invalid --since '{raw}': {err}"))
})
.transpose()?;
let until = self
.until
.as_deref()
.map(|raw| {
parse_bound(raw, Bound::End, now)
.map_err(|err| anyhow!("invalid --until '{raw}': {err}"))
})
.transpose()?;
if since.zip(until).is_some_and(|(start, end)| start > end) {
bail!("resolved --since is later than --until; swap the bounds or move --until later");
}
Ok((since, until))
}
pub fn resolve_now(&self) -> Result<Bounds> {
self.resolve(Utc::now())
}
}
pub fn parse_bound(input: &str, bound: Bound, now: DateTime<Utc>) -> Result<DateTime<Utc>> {
let (start, end) = parse_span(input, now)?;
Ok(match bound {
Bound::Start => start,
Bound::End => end,
})
}
pub fn parse_span(input: &str, now: DateTime<Utc>) -> Result<(DateTime<Utc>, DateTime<Utc>)> {
let s = input.trim();
if s.is_empty() {
bail!("empty date (try 2026-01-15, 2026-01, 202X, 7d, yesterday — run `aise dates`)");
}
if let Some(caps) = duration_re().captures(s) {
let n: i64 = caps[1]
.parse()
.map_err(|_| anyhow!("duration number too large in '{s}'"))?;
let delta = match caps[2].to_ascii_lowercase().as_str() {
"d" => Duration::try_days(n),
"w" => Duration::try_weeks(n),
"h" => Duration::try_hours(n),
"min" => Duration::try_minutes(n),
"m" => n.checked_mul(30).and_then(Duration::try_days), "y" => n.checked_mul(365).and_then(Duration::try_days), other => bail!("unsupported duration unit '{other}'"),
}
.ok_or_else(|| anyhow!("duration '{s}' is out of range"))?;
let start = now
.checked_sub_signed(delta)
.ok_or_else(|| anyhow!("duration '{s}' is out of range"))?;
return Ok((start, now));
}
let normalized = if datelike_re().is_match(s) {
s.to_ascii_uppercase()
} else {
s.to_string()
};
let s = normalized.as_str();
if let Ok(instant) = DateTime::parse_from_rfc3339(s) {
let instant = instant.with_timezone(&Utc);
return Ok((instant, instant));
}
if let Some(caps) = partial_dt_re().captures(s) {
let date_hh = &caps[1]; let (start_s, end_s) = match caps.get(2).map(|m| m.as_str()) {
None => (format!("{date_hh}:00:00"), format!("{date_hh}:59:59")),
Some(mm) => (format!("{date_hh}:{mm}:00"), format!("{date_hh}:{mm}:59")),
};
return Ok((
parse_iso_dt(&start_s)?,
end_of_second(parse_iso_dt(&end_s)?),
));
}
if let Some(caps) = day_x_re().captures(s) {
let tens = caps[3].chars().next().unwrap_or('X');
let units = caps[4].chars().next().unwrap_or('X');
if tens == 'X' || units == 'X' {
let year: i32 = caps[1].parse()?;
let month: u32 = caps[2].parse()?;
return day_x_span(year, month, tens, units);
}
}
if let Ok(edtf) = Edtf::parse(s) {
return match edtf {
Edtf::Date(date) => date_bounds(&date),
Edtf::Interval(a, b) => Ok((date_bounds(&a)?.0, date_bounds(&b)?.1)),
Edtf::DateTime(dt) => {
let instant = dt.to_chrono(&Utc);
Ok((instant, instant))
}
_ => bail!("unsupported EDTF form '{s}'"),
};
}
if let Ok(instant) = parse_date_string(input.trim(), now, Dialect::Us) {
return day_span_of(instant);
}
bail!(
"unrecognized date/time '{input}' — try 2026-01-15, 2026-01, 2026, 202X, 2026-01-1X, \
2026-01/2026-03, 7d, 2w, 1m, 24h, 1y, yesterday, '3 days ago' (run `aise dates`)"
)
}
fn date_bounds(date: &EdtfDate) -> Result<(DateTime<Utc>, DateTime<Utc>)> {
match date.precision() {
Precision::Century(y) => Ok((start_of_year(y)?, end_of_year(y + 99)?)),
Precision::Decade(y) => Ok((start_of_year(y)?, end_of_year(y + 9)?)),
Precision::Year(y) | Precision::MonthOfYear(y) | Precision::DayOfYear(y) => {
Ok((start_of_year(y)?, end_of_year(y)?))
}
Precision::Month(y, m) | Precision::DayOfMonth(y, m) => month_span(y, m),
Precision::Day(y, m, d) => day_span(y, m, d),
Precision::Season(y, season) => season_span(y, season),
}
}
fn day_x_span(
year: i32,
month: u32,
tens: char,
units: char,
) -> Result<(DateTime<Utc>, DateTime<Utc>)> {
let max_day = days_in_month(year, month)?;
let (lo, hi) = if tens == 'X' && units == 'X' {
(1, max_day)
} else if units == 'X' {
let t = tens.to_digit(10).unwrap_or(0);
((t * 10).max(1), (t * 10 + 9).min(max_day))
} else {
let u = units.to_digit(10).unwrap_or(0);
let lo = (1..=max_day).find(|d| d % 10 == u).unwrap_or(1);
let hi = (1..=max_day).rev().find(|d| d % 10 == u).unwrap_or(lo);
(lo, hi)
};
Ok((
at(year, month, lo, 0, 0, 0)?,
end_of_second(at(year, month, hi, 23, 59, 59)?),
))
}
fn season_span(year: i32, season: Season) -> Result<(DateTime<Utc>, DateTime<Utc>)> {
let (s, e) = match season {
Season::Spring => (month_span(year, 3)?.0, month_span(year, 5)?.1),
Season::Summer => (month_span(year, 6)?.0, month_span(year, 8)?.1),
Season::Autumn => (month_span(year, 9)?.0, month_span(year, 11)?.1),
Season::Winter => (month_span(year, 12)?.0, month_span(year + 1, 2)?.1),
};
Ok((s, e))
}
fn at(year: i32, month: u32, day: u32, h: u32, mi: u32, s: u32) -> Result<DateTime<Utc>> {
Utc.with_ymd_and_hms(year, month, day, h, mi, s)
.single()
.ok_or_else(|| anyhow!("invalid date {year:04}-{month:02}-{day:02}T{h:02}:{mi:02}:{s:02}"))
}
fn day_span(year: i32, month: u32, day: u32) -> Result<(DateTime<Utc>, DateTime<Utc>)> {
Ok((
at(year, month, day, 0, 0, 0)?,
end_of_second(at(year, month, day, 23, 59, 59)?),
))
}
fn day_span_of(dt: DateTime<Utc>) -> Result<(DateTime<Utc>, DateTime<Utc>)> {
let date = dt.date_naive();
day_span(date.year(), date.month(), date.day())
}
fn month_span(year: i32, month: u32) -> Result<(DateTime<Utc>, DateTime<Utc>)> {
let last = days_in_month(year, month)?;
Ok((
at(year, month, 1, 0, 0, 0)?,
end_of_second(at(year, month, last, 23, 59, 59)?),
))
}
fn start_of_year(year: i32) -> Result<DateTime<Utc>> {
at(year, 1, 1, 0, 0, 0)
}
fn end_of_year(year: i32) -> Result<DateTime<Utc>> {
Ok(end_of_second(at(year, 12, 31, 23, 59, 59)?))
}
fn end_of_second(instant: DateTime<Utc>) -> DateTime<Utc> {
instant.with_nanosecond(999_999_999).unwrap_or(instant)
}
fn days_in_month(year: i32, month: u32) -> Result<u32> {
let (ny, nm) = if month == 12 {
(year + 1, 1)
} else {
(year, month + 1)
};
let first_next = NaiveDate::from_ymd_opt(ny, nm, 1)
.ok_or_else(|| anyhow!("invalid month {year}-{month:02}"))?;
let last = first_next
.pred_opt()
.ok_or_else(|| anyhow!("date underflow computing month length"))?;
Ok(last.day())
}
fn parse_iso_dt(s: &str) -> Result<DateTime<Utc>> {
let naive = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")
.map_err(|err| anyhow!("invalid datetime '{s}': {err}"))?;
Ok(naive.and_utc())
}
fn duration_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"(?i)^(\d+)(min|[dwmhy])$").expect("valid duration regex"))
}
fn datelike_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^[0-9XxTt:/.\-]+$").expect("valid datelike regex"))
}
fn partial_dt_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"^(\d{4}-\d{2}-\d{2}T\d{2})(?::(\d{2}))?$").expect("valid partial regex")
})
}
fn day_x_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^(\d{4})-(\d{2})-([0-3X])([0-9X])$").expect("valid day-x regex"))
}
pub fn format_reference() -> String {
[
"aise date/time formats (--since, --until, --when)",
"",
"ISO 8601:",
" 2026-01-15T14:30:25.123Z exact fractional instant (offsets normalize to UTC)",
" 2026-01-15T14:30:25 exact second",
" 2026-01-15T14:30 minute precision (expands to :00..:59)",
" 2026-01-15T14 hour precision (expands to :00:00..:59:59)",
" 2026-01-15 day → 00:00:00 .. 23:59:59",
" 2026-01 month → first .. last day",
" 2026 year → Jan 1 .. Dec 31",
"",
"EDTF (Extended Date/Time Format, via the `edtf` crate):",
" 202X decade → 2020-01-01 .. 2029-12-31",
" 19XX century → 1900-01-01 .. 1999-12-31",
" 2026-XX unspecified month → whole year",
" 2026-01-XX unspecified day → whole month",
" 2026-01-1X partial day digit → 2026-01-10 .. 2026-01-19",
" 2026-01/2026-03 interval → start of A .. end of B",
"",
"Duration shorthand (the window [now - N, now]):",
" 7d 2w 1m 24h 30min 1y (m ≈ 30 days, y ≈ 365 days)",
"",
"Natural language (via the `interim` crate; day-granular):",
" today yesterday \"3 days ago\" \"last friday\"",
"",
"--since uses a period's start, --until its end, --when uses both.",
"Calendar periods and relative days use UTC; RFC 3339 timestamps honor their offset.",
"A duration spans [now - N, now]; a relative word spans its whole day,",
"so `--when 7d` = the last 7 days and `--when yesterday` = all of yesterday.",
]
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
fn now() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 6, 25, 12, 0, 0).unwrap()
}
fn span(s: &str) -> (DateTime<Utc>, DateTime<Utc>) {
parse_span(s, now()).unwrap_or_else(|e| panic!("parse_span({s:?}) failed: {e}"))
}
fn iso(s: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
}
#[test]
fn durations_are_a_window_ending_now() {
assert_eq!(span("7d"), (now() - Duration::days(7), now()));
assert_eq!(span("2w"), (now() - Duration::weeks(2), now()));
assert_eq!(span("24h"), (now() - Duration::hours(24), now()));
assert_eq!(span("30min"), (now() - Duration::minutes(30), now()));
assert_eq!(span("1m"), (now() - Duration::days(30), now())); assert_eq!(span("1y"), (now() - Duration::days(365), now())); let (start, end) = span("7d");
assert!(start < end);
assert_eq!(span("24H"), (now() - Duration::hours(24), now()));
}
#[test]
fn oversized_duration_errors_without_panicking() {
assert!(parse_span("9999999999999999999d", now()).is_err());
assert!(parse_span("999999999999y", now()).is_err());
}
#[test]
fn full_datetime_is_exact_second() {
let (s, e) = span("2026-01-15T14:30:25");
assert_eq!(s, iso("2026-01-15T14:30:25+00:00"));
assert_eq!(s, e);
}
#[test]
fn rfc3339_fractional_second_and_offset_are_exact_instants() {
for input in ["2026-07-07T02:53:22.358Z", "2026-07-06T22:53:22.358-04:00"] {
let (start, end) = span(input);
assert_eq!(start, iso("2026-07-07T02:53:22.358Z"));
assert_eq!(start, end);
}
}
#[test]
fn partial_datetime_expands_by_precision() {
let (s, e) = span("2026-01-15T14");
assert_eq!(s, iso("2026-01-15T14:00:00+00:00"));
assert_eq!(e, iso("2026-01-15T14:59:59.999999999+00:00"));
let (s, e) = span("2026-01-15T14:30");
assert_eq!(s, iso("2026-01-15T14:30:00+00:00"));
assert_eq!(e, iso("2026-01-15T14:30:59.999999999+00:00"));
}
#[test]
fn day_month_year_periods() {
assert_eq!(
span("2026-01-15"),
(
iso("2026-01-15T00:00:00+00:00"),
iso("2026-01-15T23:59:59.999999999+00:00")
)
);
assert_eq!(
span("2026-01"),
(
iso("2026-01-01T00:00:00+00:00"),
iso("2026-01-31T23:59:59.999999999+00:00")
)
);
assert_eq!(
span("2026"),
(
iso("2026-01-01T00:00:00+00:00"),
iso("2026-12-31T23:59:59.999999999+00:00")
)
);
}
#[test]
fn edtf_decade_and_century() {
assert_eq!(
span("202X"),
(
iso("2020-01-01T00:00:00+00:00"),
iso("2029-12-31T23:59:59.999999999+00:00")
)
);
assert_eq!(
span("19XX"),
(
iso("1900-01-01T00:00:00+00:00"),
iso("1999-12-31T23:59:59.999999999+00:00")
)
);
assert_eq!(span("202x"), span("202X"));
}
#[test]
fn edtf_unspecified_month_and_day() {
assert_eq!(span("2026-XX"), span("2026"));
assert_eq!(span("2026-01-XX"), span("2026-01"));
assert_eq!(
span("2026-01-1X"),
(
iso("2026-01-10T00:00:00+00:00"),
iso("2026-01-19T23:59:59.999999999+00:00")
)
);
assert_eq!(
span("2026-01-3X"),
(
iso("2026-01-30T00:00:00+00:00"),
iso("2026-01-31T23:59:59.999999999+00:00")
)
);
}
#[test]
fn edtf_units_fixed_day_reaches_last_matching_day() {
assert_eq!(
span("2026-01-X0"),
(
iso("2026-01-10T00:00:00+00:00"),
iso("2026-01-30T23:59:59.999999999+00:00")
)
);
assert_eq!(
span("2026-01-X1"),
(
iso("2026-01-01T00:00:00+00:00"),
iso("2026-01-31T23:59:59.999999999+00:00")
)
);
assert_eq!(
span("2026-01-X5"),
(
iso("2026-01-05T00:00:00+00:00"),
iso("2026-01-25T23:59:59.999999999+00:00")
)
);
assert_eq!(
span("2026-02-X0"),
(
iso("2026-02-10T00:00:00+00:00"),
iso("2026-02-20T23:59:59.999999999+00:00")
)
);
}
#[test]
fn edtf_interval_uses_start_of_a_end_of_b() {
assert_eq!(
span("2026-01/2026-03"),
(
iso("2026-01-01T00:00:00+00:00"),
iso("2026-03-31T23:59:59.999999999+00:00")
)
);
}
fn day_of(dt: DateTime<Utc>) -> (DateTime<Utc>, DateTime<Utc>) {
let d = dt.date_naive();
(
d.and_hms_opt(0, 0, 0).unwrap().and_utc(),
d.and_hms_nano_opt(23, 59, 59, 999_999_999)
.unwrap()
.and_utc(),
)
}
#[test]
fn nlp_relative_dates_span_the_whole_day() {
assert_eq!(span("today"), day_of(now()));
assert_eq!(span("yesterday"), day_of(now() - Duration::days(1)));
assert_eq!(span("3 days ago"), day_of(now() - Duration::days(3)));
let (s, e) = span("yesterday");
assert!(s < e);
}
#[test]
fn leap_year_february_bounds() {
assert_eq!(
span("2024-02").1,
iso("2024-02-29T23:59:59.999999999+00:00")
);
assert_eq!(
span("2026-02").1,
iso("2026-02-28T23:59:59.999999999+00:00")
);
assert_eq!(
span("2024-02-XX").1,
iso("2024-02-29T23:59:59.999999999+00:00")
);
}
#[test]
fn month_end_bounds() {
assert_eq!(
span("2026-04").1,
iso("2026-04-30T23:59:59.999999999+00:00")
); assert_eq!(
span("2026-12").1,
iso("2026-12-31T23:59:59.999999999+00:00")
); }
#[test]
fn invalid_inputs_error() {
assert!(parse_span("", now()).is_err());
assert!(parse_span("2026-13", now()).is_err()); assert!(parse_span("2026-02-30", now()).is_err()); assert!(parse_span("not-a-date-at-all-xyz", now()).is_err());
}
#[test]
fn unspecified_tens_digit_day() {
assert_eq!(
span("2026-01-X5"),
(
iso("2026-01-05T00:00:00+00:00"),
iso("2026-01-25T23:59:59.999999999+00:00")
)
);
assert_eq!(span("2026-01-XX"), span("2026-01"));
}
#[test]
fn span_start_never_after_end_for_well_formed_periods() {
for input in [
"7d",
"2w",
"24h",
"30min",
"1m",
"1y",
"2026-01-15T14:30:25",
"2026-01-15T14",
"2026-01-15T14:30",
"2026-01-15",
"2026-01",
"2026",
"202X",
"19XX",
"2026-XX",
"2026-01-XX",
"2026-01-1X",
"2026-01-X5",
"2026-01/2026-03",
"today",
"yesterday",
"3 days ago",
] {
let (s, e) = span(input);
assert!(s <= e, "{input}: start {s} must be <= end {e}");
}
}
#[test]
fn reversed_interval_yields_start_after_end_without_panicking() {
let (s, e) = span("2026-03/2026-01");
assert!(s > e);
}
#[test]
fn bound_selects_start_or_end() {
assert_eq!(
parse_bound("2026-01", Bound::Start, now()).unwrap(),
iso("2026-01-01T00:00:00+00:00")
);
assert_eq!(
parse_bound("2026-01", Bound::End, now()).unwrap(),
iso("2026-01-31T23:59:59.999999999+00:00")
);
}
#[test]
fn daterange_since_until_when() {
let r = DateRange {
since: Some("2026-01".into()),
until: Some("2026-03".into()),
when: None,
};
let (since, until) = r.resolve(now()).unwrap();
assert_eq!(since.unwrap(), iso("2026-01-01T00:00:00+00:00"));
assert_eq!(until.unwrap(), iso("2026-03-31T23:59:59.999999999+00:00"));
let r = DateRange {
since: None,
until: None,
when: Some("2026".into()),
};
let (since, until) = r.resolve(now()).unwrap();
assert_eq!(since.unwrap(), iso("2026-01-01T00:00:00+00:00"));
assert_eq!(until.unwrap(), iso("2026-12-31T23:59:59.999999999+00:00"));
let (since, until) = DateRange::default().resolve(now()).unwrap();
assert!(since.is_none() && until.is_none());
}
#[test]
fn daterange_when_interval() {
let r = DateRange {
since: None,
until: None,
when: Some("2026-01/2026-03".into()),
};
let (since, until) = r.resolve(now()).unwrap();
assert_eq!(since.unwrap(), iso("2026-01-01T00:00:00+00:00"));
assert_eq!(until.unwrap(), iso("2026-03-31T23:59:59.999999999+00:00"));
}
#[test]
fn daterange_when_duration_is_the_last_n_window() {
let r = DateRange {
since: None,
until: None,
when: Some("7d".into()),
};
let (since, until) = r.resolve(now()).unwrap();
assert_eq!(since.unwrap(), now() - Duration::days(7));
assert_eq!(until.unwrap(), now());
assert!(since.unwrap() < until.unwrap());
}
#[test]
fn daterange_when_relative_nlp_is_the_whole_day() {
let r = DateRange {
since: None,
until: None,
when: Some("yesterday".into()),
};
let (since, until) = r.resolve(now()).unwrap();
let y = (now() - Duration::days(1)).date_naive();
assert_eq!(since.unwrap(), y.and_hms_opt(0, 0, 0).unwrap().and_utc());
assert_eq!(
until.unwrap(),
y.and_hms_nano_opt(23, 59, 59, 999_999_999)
.unwrap()
.and_utc()
);
let explicit = DateRange {
since: None,
until: None,
when: Some(y.format("%Y-%m-%d").to_string()),
};
assert_eq!(r.resolve(now()).unwrap(), explicit.resolve(now()).unwrap());
}
#[test]
fn daterange_since_until_duration_bound_selection() {
let since_only = DateRange {
since: Some("7d".into()),
until: None,
when: None,
};
let (s, u) = since_only.resolve(now()).unwrap();
assert_eq!(s.unwrap(), now() - Duration::days(7));
assert!(u.is_none());
let until_only = DateRange {
since: None,
until: Some("7d".into()),
when: None,
};
let (s, u) = until_only.resolve(now()).unwrap();
assert!(s.is_none());
assert_eq!(u.unwrap(), now());
}
#[test]
fn daterange_inverted_range_is_rejected() {
let r = DateRange {
since: Some("2026-03".into()),
until: Some("2026-01".into()),
when: None,
};
let error = r.resolve(now()).unwrap_err().to_string();
assert!(error.contains("--since is later than --until"), "{error}");
}
#[test]
fn daterange_when_rejects_reversed_interval() {
let when = DateRange {
since: None,
until: None,
when: Some("2026-03/2026-01".into()),
};
assert!(when
.resolve(now())
.unwrap_err()
.to_string()
.contains("reversed"));
}
#[test]
fn daterange_invalid_value_is_a_clean_error() {
let r = DateRange {
since: Some("notadate".into()),
until: None,
when: None,
};
let err = r.resolve(now()).unwrap_err().to_string();
assert!(
err.contains("--since"),
"error names the offending flag: {err}"
);
}
}