gix-date 0.16.0

A crate of the gitoxide project parsing dates the way git does
Documentation
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}")))
}