use std::str::FromStr;
use crate::Error;
use gix_error::{Exn, ResultExt, ValidationError};
use jiff::{SignedDuration, Zoned, civil, tz::TimeZone};
pub fn parse(input: &str, now: Option<Zoned>) -> Option<Result<Zoned, Exn<Error>>> {
if let Some(result) = parse_named(input, now.as_ref()) {
return Some(result);
}
Some(subtract_pairs(now, &parse_ago(input)?))
}
fn parse_named(input: &str, now: Option<&Zoned>) -> Option<Result<Zoned, Exn<Error>>> {
let input = input.trim();
let duration = if input.eq_ignore_ascii_case("now") {
SignedDuration::ZERO
} else if input.eq_ignore_ascii_case("today") {
SignedDuration::ZERO
} else if input.eq_ignore_ascii_case("yesterday") {
SignedDuration::from_hours(24)
} else {
return None;
};
Some(subtract_duration(now, duration))
}
fn parse_ago(input: &str) -> Option<Vec<Pair<'_>>> {
let mut words = input
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|s| !s.is_empty())
.peekable();
let mut pairs = Vec::new();
let mut ago = false;
while let Some(word) = words.peek() {
if word.eq_ignore_ascii_case("ago") {
ago = true;
words.next();
continue;
}
let Some(units) = count(word) else {
words.next();
continue;
};
words.next();
let Some(period) = words.next() else { break };
pairs.push((period, units));
}
if pairs.is_empty() {
return None;
}
pairs
.into_iter()
.map(|(period, count)| unit(period, ago).map(|(period, unit)| Pair { period, count, unit }))
.collect()
}
fn count(input: &str) -> Option<i64> {
if let Ok(units) = i64::from_str(input) {
return Some(units);
}
const NAMES: &[&str] = &[
"one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten",
];
NAMES
.iter()
.position(|name| input.eq_ignore_ascii_case(name))
.map(|pos| pos as i64 + 1)
.or_else(|| input.eq_ignore_ascii_case("last").then_some(1))
}
struct Pair<'a> {
period: &'a str,
count: i64,
unit: Unit,
}
enum Unit {
Seconds(i64),
Months(i64),
}
fn unit(period: &str, ago: bool) -> Option<(&str, Unit)> {
let period = period
.strip_suffix('s')
.or_else(|| period.strip_suffix('S'))
.unwrap_or(period);
let unit = if period.eq_ignore_ascii_case("second") {
Unit::Seconds(1)
} else if period.eq_ignore_ascii_case("minute") {
Unit::Seconds(60)
} else if period.eq_ignore_ascii_case("hour") {
Unit::Seconds(60 * 60)
} else if period.eq_ignore_ascii_case("day") {
Unit::Seconds(24 * 60 * 60)
} else if period.eq_ignore_ascii_case("week") {
Unit::Seconds(7 * 24 * 60 * 60)
} else if period.eq_ignore_ascii_case("month") {
Unit::Months(1)
} else if period.eq_ignore_ascii_case("year") {
Unit::Months(12)
} else if ago {
Unit::Seconds(1)
} else {
return None;
};
Some((period, unit))
}
fn subtract_pairs(now: Option<Zoned>, pairs: &[Pair<'_>]) -> Result<Zoned, Exn<Error>> {
struct Fields {
year: i16,
month: i8,
day: i8,
time: civil::Time,
timezone: TimeZone,
}
impl From<Zoned> for Fields {
fn from(zdt: Zoned) -> Self {
Fields {
year: zdt.year(),
month: zdt.month(),
day: zdt.day(),
time: zdt.time(),
timezone: zdt.time_zone().clone(),
}
}
}
impl Fields {
fn normalize(&self) -> Result<Zoned, Exn<Error>> {
let first_of_month = civil::Date::new(self.year, self.month, 1)
.or_raise(|| Error::new(format!("Date lies out of range: {}-{:02}", self.year, self.month)))?;
let days_beyond_first = SignedDuration::from_secs((i64::from(self.day) - 1) * 24 * 60 * 60);
first_of_month
.checked_add(days_beyond_first)
.or_raise(|| Error::new(format!("Day {} lies out of range", self.day)))?
.to_datetime(self.time)
.to_zoned(self.timezone.clone())
.or_raise(|| Error::new("Could not convert date to a point in time"))
}
}
let now = now.ok_or(ValidationError::new("Missing current time"))?;
let mut fields = Fields::from(now);
for Pair { period, count, unit } in pairs {
let err = || Error::new(format!("Couldn't parse span from '{period} {count}'"));
match unit {
Unit::Seconds(factor) => {
let seconds = count
.checked_mul(*factor)
.map(SignedDuration::from_secs)
.ok_or_else(err)?;
let ts = fields.normalize()?.timestamp().checked_sub(seconds).or_raise(err)?;
fields = ts.to_zoned(fields.timezone.clone()).into();
}
Unit::Months(factor) => {
let months = count.checked_mul(*factor).ok_or_else(err)?;
fields = fields.normalize()?.into();
let total = (i64::from(fields.year) * 12 + i64::from(fields.month) - 1)
.checked_sub(months)
.ok_or_else(err)?;
fields.year = i16::try_from(total.div_euclid(12)).ok().ok_or_else(err)?;
fields.month = i8::try_from(total.rem_euclid(12) + 1).expect("a value in 1..=12");
}
}
}
fields.normalize()
}
fn subtract_duration(now: Option<&Zoned>, duration: SignedDuration) -> Result<Zoned, Exn<ValidationError>> {
let now = now.ok_or(ValidationError::new("Missing current time"))?;
now.timestamp()
.checked_sub(duration)
.map(|timestamp| timestamp.to_zoned(now.time_zone().clone()))
.or_raise(|| Error::new(format!("Failed to subtract {duration} from {now}")))
}