214 lines · 7.9 KB · Rust
RawDownload
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>>> {    // First try named dates    if let Some(result) = parse_named(input, now.as_ref()) {        return Some(result);    }     // Then try numeric relative dates    Some(subtract_pairs(now, &parse_ago(input)?))} /// Parse named relative dates like "now", "today", "yesterday".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") {        // "today" is treated the same as "now" (current time) for simplicity        SignedDuration::ZERO    } else if input.eq_ignore_ascii_case("yesterday") {        SignedDuration::from_hours(24)    } else {        return None;    };     Some(subtract_duration(now, duration))} /// Parse Git-style relative count-unit pairs, in input order.////// Returns `None` if no pair is recognized.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();     // Git applies a unit the moment it sees one and keeps going, so `2 days 3 hours ago` is both    // of them. Stopping after the first pair would turn that into a plausible-looking two days.    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()} /// The count in front of the unit, either written out in digits or spelled with one of one-ten./// Note that `zero` is deliberately absent: Git's lookup starts at/// one, so `zero days ago` is not a relative date there either.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))} /// A single `<count> <unit>` occurrence, in input order.struct Pair<'a> {    /// The unit name, in singular form, for error messages.    period: &'a str,    /// The count in front of the unit.    count: i64,    /// How the pair is subtracted.    unit: Unit,} /// How a unit is subtracted: Git's `date.c` counts `second` through `week` as fixed numbers of/// seconds, while `month` and `year` step down the calendar fields and leave the day alone.enum Unit {    /// One unit is this many seconds, to be taken off the timestamp.    Seconds(i64),    /// One unit is this many months, to be taken off the year and month fields.    Months(i64),} /// Classify `period`, also returning it in singular form.////// An unknown period returns `None` unless `ago` occurred in the input, in which case it is/// treated as seconds.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 {        // `ago` makes any period be counted as seconds.        Unit::Seconds(1)    } else {        return None;    };    Some((period, unit))} /// Subtract all `pairs` from `now`, in input order, like Git's `approxidate()` applies them.////// Seconds-based units subtract from the timestamp, while months and years only step down the/// respective fields and normalize later, so that repeated units accumulate and a day beyond/// the end of the target month rolls over.fn subtract_pairs(now: Option<Zoned>, pairs: &[Pair<'_>]) -> Result<Zoned, Exn<Error>> {    /// The calendar and clock fields for subtraction.    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 {        /// Turn the fields back into a point in time: a day beyond the end of the month rolls over into the        /// following month. One month before May 31st is thus May 1st, a day after April 30th.        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}")))}