use anyhow::{Result, bail};
use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339};
pub fn parse_date(input: &str) -> Result<String> {
let input = input.trim();
if let Some(duration) = parse_relative(input) {
let now = OffsetDateTime::now_utc();
let target = now - duration;
return Ok(target.format(&Rfc3339)?);
}
if input.len() == 10 && input.chars().nth(4) == Some('-') && input.chars().nth(7) == Some('-') {
let parts: Vec<&str> = input.split('-').collect();
if parts.len() == 3 {
let year: i32 = parts[0]
.parse()
.map_err(|_| anyhow::anyhow!("Invalid year"))?;
let month: u8 = parts[1]
.parse()
.map_err(|_| anyhow::anyhow!("Invalid month"))?;
let day: u8 = parts[2]
.parse()
.map_err(|_| anyhow::anyhow!("Invalid day"))?;
if !(1..=12).contains(&month) {
bail!("Invalid month: {}", month);
}
if !(1..=31).contains(&day) {
bail!("Invalid day: {}", day);
}
return Ok(format!("{:04}-{:02}-{:02}T00:00:00Z", year, month, day));
}
}
if input.contains('T') {
match OffsetDateTime::parse(input, &Rfc3339) {
Ok(dt) => return Ok(dt.format(&Rfc3339)?),
Err(_) => {
let with_z =
if !input.ends_with('Z') && !input.contains('+') && !input.contains('-') {
format!("{}Z", input)
} else {
input.to_string()
};
if let Ok(dt) = OffsetDateTime::parse(&with_z, &Rfc3339) {
return Ok(dt.format(&Rfc3339)?);
}
}
}
}
bail!(
"Invalid date format: '{}'. Expected ISO 8601 (e.g., 2026-03-24) or relative (e.g., 3d, 1w, 2h)",
input
)
}
pub fn add_duration_to_date(date_str: &str, duration_str: &str) -> Result<String> {
let duration = parse_relative(duration_str).ok_or_else(|| {
anyhow::anyhow!(
"Invalid duration: '{}'. Expected format like 1w, 2w, 10d",
duration_str
)
})?;
let start = parse_date(date_str)?;
let start_dt = OffsetDateTime::parse(&start, &Rfc3339)?;
let end = start_dt + duration;
Ok(end.format(&Rfc3339)?)
}
fn parse_relative(input: &str) -> Option<Duration> {
let input = input.trim();
if input.is_empty() {
return None;
}
let (num_str, unit) = input.split_at(input.len() - 1);
let num: i64 = num_str.parse().ok()?;
if num <= 0 {
return None;
}
match unit {
"h" => Some(Duration::hours(num)),
"d" => Some(Duration::days(num)),
"w" => Some(Duration::weeks(num)),
"m" => Some(Duration::days(num * 30)), _ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_iso_date() {
let result = parse_date("2026-03-24").unwrap();
assert_eq!(result, "2026-03-24T00:00:00Z");
}
#[test]
fn parse_iso_datetime() {
let result = parse_date("2026-03-24T10:30:00Z").unwrap();
assert!(result.contains("2026-03-24"));
assert!(result.contains("10:30:00"));
}
#[test]
fn parse_relative_days() {
let result = parse_date("3d").unwrap();
assert!(result.contains("T"));
assert!(result.ends_with("Z"));
}
#[test]
fn parse_relative_weeks() {
let result = parse_date("1w").unwrap();
assert!(result.contains("T"));
assert!(result.ends_with("Z"));
}
#[test]
fn parse_relative_hours() {
let result = parse_date("2h").unwrap();
assert!(result.contains("T"));
assert!(result.ends_with("Z"));
}
#[test]
fn invalid_date_errors() {
assert!(parse_date("invalid").is_err());
assert!(parse_date("2026-13-01").is_err()); assert!(parse_date("2026-01-32").is_err()); }
#[test]
fn parse_relative_months() {
let result = parse_date("2m").unwrap();
assert!(result.contains("T"));
assert!(result.ends_with("Z"));
}
#[test]
fn zero_relative_fails() {
assert!(parse_date("0d").is_err());
}
#[test]
fn negative_relative_fails() {
assert!(parse_date("-3d").is_err());
}
#[test]
fn add_duration_one_week() {
let result = add_duration_to_date("2026-04-07", "1w").unwrap();
assert!(result.starts_with("2026-04-14"));
}
#[test]
fn add_duration_ten_days() {
let result = add_duration_to_date("2026-04-01", "10d").unwrap();
assert!(result.starts_with("2026-04-11"));
}
#[test]
fn add_duration_invalid() {
assert!(add_duration_to_date("2026-04-01", "abc").is_err());
}
}