pub const DEFAULT_SINCE: i64 = -2_208_988_800;
pub fn date_to_epoch(y: i64, m: i64, d: i64, h: i64, min: i64, s: i64) -> i64 {
let (mut yr, mut mo) = (y, m);
if mo <= 2 {
yr -= 1;
mo += 9;
} else {
mo -= 3;
}
let era = if yr >= 0 { yr } else { yr - 399 } / 400;
let yoe = yr - era * 400;
let doy = (153 * mo + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
days * 86_400 + h * 3600 + min * 60 + s
}
pub fn default_until() -> i64 {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
i64::try_from(secs).unwrap_or(i64::MAX)
}
pub fn epoch_to_year(epoch: i64) -> i64 {
let approx = 1970 + epoch / 31_557_600;
if date_to_epoch(approx + 1, 1, 1, 0, 0, 0) <= epoch {
approx + 1
} else if date_to_epoch(approx, 1, 1, 0, 0, 0) > epoch {
approx - 1
} else {
approx
}
}
pub fn parse(s: &str) -> Result<i64, String> {
let s = s.trim();
if let Ok(v) = s.parse::<i64>() {
if v > 100_000 {
return Ok(v);
}
if (1..=9999).contains(&v) {
return Ok(date_to_epoch(v, 1, 1, 0, 0, 0));
}
return Err(format!(
"ambiguous temporal value: {v}; use a year (1-9999) or epoch seconds (>100000)"
));
}
let parts: Vec<&str> = s.splitn(2, 'T').collect();
let date_part = parts[0];
let time_part = if parts.len() > 1 { parts[1] } else { "" };
let date_segs: Vec<&str> = date_part.split('-').collect();
if date_segs.len() != 3 {
return Err(format!(
"invalid temporal format '{s}'; expected: YYYY, YYYY-MM-DD, YYYY-MM-DDTHH:MM, YYYY-MM-DDTHH:MM:SS, or epoch seconds"
));
}
let y = date_segs[0].parse::<i64>().map_err(|_| format!("invalid year in '{s}'"))?;
let m = date_segs[1].parse::<i64>().map_err(|_| format!("invalid month in '{s}'"))?;
let d = date_segs[2].parse::<i64>().map_err(|_| format!("invalid day in '{s}'"))?;
if !(1..=12).contains(&m) {
return Err(format!("month out of range in '{s}'"));
}
if !(1..=31).contains(&d) {
return Err(format!("day out of range in '{s}'"));
}
if time_part.is_empty() {
return Ok(date_to_epoch(y, m, d, 0, 0, 0));
}
let time_segs: Vec<&str> = time_part.split(':').collect();
let h = time_segs
.first()
.and_then(|s| s.parse::<i64>().ok())
.ok_or_else(|| format!("invalid hour in '{s}'"))?;
let min = time_segs
.get(1)
.and_then(|s| s.parse::<i64>().ok())
.ok_or_else(|| format!("invalid minute in '{s}'"))?;
let sec = match time_segs.get(2) {
Some(s) => s.parse::<i64>().map_err(|_| format!("invalid seconds in '{s}'"))?,
None => 0,
};
if !(0..=23).contains(&h) {
return Err(format!("hour out of range in '{s}'"));
}
if !(0..=59).contains(&min) {
return Err(format!("minute out of range in '{s}'"));
}
Ok(date_to_epoch(y, m, d, h, min, sec))
}
pub fn parse_until(s: &str) -> Result<i64, String> {
let s = s.trim();
if let Ok(v) = s.parse::<i64>() {
if v > 100_000 {
return Ok(v);
}
if (1..=9999).contains(&v) {
return Ok(date_to_epoch(v + 1, 1, 1, 0, 0, 0));
}
}
if !s.contains('T') && s.contains('-') {
let e = parse(s)?;
return Ok(e + 86_400);
}
parse(s)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_year() {
let e = parse("2025").unwrap();
assert_eq!(epoch_to_year(e), 2025);
}
#[test]
fn parse_date() {
let e = parse("2025-01-01").unwrap();
assert_eq!(e, date_to_epoch(2025, 1, 1, 0, 0, 0));
}
#[test]
fn parse_datetime() {
let e = parse("2025-03-28T14:00").unwrap();
assert_eq!(e, date_to_epoch(2025, 3, 28, 14, 0, 0));
}
#[test]
fn parse_datetime_seconds() {
let e = parse("2025-03-28T14:00:30").unwrap();
assert_eq!(e, date_to_epoch(2025, 3, 28, 14, 0, 30));
}
#[test]
fn parse_epoch() {
assert_eq!(parse("1711630800").unwrap(), 1_711_630_800);
}
#[test]
fn parse_invalid() {
assert!(parse("abc").is_err());
assert!(parse("2025-13-01").is_err());
assert!(parse("2025-01-32").is_err());
}
#[test]
fn default_since_is_1900() {
assert_eq!(epoch_to_year(DEFAULT_SINCE), 1900);
}
#[test]
fn until_year_is_exclusive() {
let e = parse_until("2025").unwrap();
assert_eq!(e, date_to_epoch(2026, 1, 1, 0, 0, 0));
assert_eq!(epoch_to_year(e - 1), 2025);
}
#[test]
fn until_date_is_end_of_day() {
let e = parse_until("2025-03-28").unwrap();
let start = parse("2025-03-28").unwrap();
assert_eq!(e, start + 86_400);
}
#[test]
fn until_datetime_is_exact() {
let e = parse_until("2025-03-28T16:00").unwrap();
assert_eq!(e, date_to_epoch(2025, 3, 28, 16, 0, 0));
}
#[test]
fn until_epoch_is_exact() {
assert_eq!(parse_until("1711638000").unwrap(), 1_711_638_000);
}
#[test]
fn roundtrip_year_boundaries() {
for y in [1900, 1970, 1999, 2000, 2001, 2024, 2025, 2038, 2100] {
let e = parse(&y.to_string()).unwrap();
assert_eq!(epoch_to_year(e), y, "roundtrip failed for year {y}");
}
}
#[test]
fn roundtrip_dates() {
let cases = [
("2025-01-01", 2025, 1, 1),
("2000-02-29", 2000, 2, 29), ("1970-01-01", 1970, 1, 1), ("2025-12-31", 2025, 12, 31), ];
for (input, y, m, d) in cases {
let e = parse(input).unwrap();
assert_eq!(e, date_to_epoch(y, m, d, 0, 0, 0), "parse failed for {input}");
}
}
#[test]
fn midnight_vs_2359() {
let midnight = parse("2025-03-28T00:00").unwrap();
let eod = parse("2025-03-28T23:59").unwrap();
assert_eq!(eod - midnight, 23 * 3600 + 59 * 60);
}
#[test]
fn whitespace_trimmed() {
assert_eq!(parse(" 2025 ").unwrap(), parse("2025").unwrap());
assert_eq!(parse(" 2025-03-28 ").unwrap(), parse("2025-03-28").unwrap());
}
#[test]
fn invalid_time_components() {
assert!(parse("2025-01-01T25:00").is_err()); assert!(parse("2025-01-01T12:60").is_err()); }
#[test]
fn ambiguous_small_number() {
assert!(parse("50000").is_err()); }
}