blob: 2dfba0f0532c626f7265e58f1989c19dcc73d4e4 [file] [log] [blame]
use std::str::FromStr;
use smallvec::SmallVec;
use crate::Time;
#[derive(thiserror::Error, Debug, Clone)]
#[allow(missing_docs)]
pub enum Error {
#[error("Could not convert a duration into a date")]
RelativeTimeConversion,
#[error("Date string can not be parsed")]
InvalidDateString { input: String },
#[error("The heat-death of the universe happens before this date")]
InvalidDate(#[from] std::num::TryFromIntError),
#[error("Current time is missing but required to handle relative dates.")]
MissingCurrentTime,
}
/// A container for just enough bytes to hold the largest-possible [`time`](Time) instance.
/// It's used in conjunction with
#[derive(Default, Clone)]
pub struct TimeBuf {
buf: SmallVec<[u8; Time::MAX.size()]>,
}
impl TimeBuf {
/// Represent this instance as standard string, serialized in a format compatible with
/// signature fields in Git commits, also known as anything parseable as [raw format](function::parse_header()).
pub fn as_str(&self) -> &str {
// SAFETY: We know that serialized times are pure ASCII, a subset of UTF-8.
// `buf` and `len` are written only by time-serialization code.
let time_bytes = self.buf.as_slice();
#[allow(unsafe_code)]
unsafe {
std::str::from_utf8_unchecked(time_bytes)
}
}
/// Clear the previous content.
fn clear(&mut self) {
self.buf.clear();
}
}
impl std::io::Write for TimeBuf {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.buf.write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
self.buf.flush()
}
}
impl Time {
/// Serialize this instance into `buf`, exactly as it would appear in the header of a Git commit,
/// and return `buf` as `&str` for easy consumption.
pub fn to_str<'a>(&self, buf: &'a mut TimeBuf) -> &'a str {
buf.clear();
self.write_to(buf)
.expect("write to memory of just the right size cannot fail");
buf.as_str()
}
}
impl FromStr for Time {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
crate::parse_header(s).ok_or_else(|| Error::InvalidDateString { input: s.into() })
}
}
pub(crate) mod function {
use std::{str::FromStr, time::SystemTime};
use jiff::{civil::Date, fmt::rfc2822, tz::TimeZone, Zoned};
use crate::{
parse::{relative, Error},
time::format::{DEFAULT, GITOXIDE, ISO8601, ISO8601_STRICT, SHORT},
OffsetInSeconds, SecondsSinceUnixEpoch, Time,
};
/// Parse `input` as any time that Git can parse when inputting a date.
///
/// ## Examples
///
/// ### 1. SHORT Format
///
/// * `2018-12-24`
/// * `1970-01-01`
/// * `1950-12-31`
/// * `2024-12-31`
///
/// ### 2. RFC2822 Format
///
/// * `Thu, 18 Aug 2022 12:45:06 +0800`
/// * `Mon Oct 27 10:30:00 2023 -0800`
///
/// ### 3. GIT_RFC2822 Format
///
/// * `Thu, 8 Aug 2022 12:45:06 +0800`
/// * `Mon Oct 27 10:30:00 2023 -0800` (Note the single-digit day)
///
/// ### 4. ISO8601 Format
///
/// * `2022-08-17 22:04:58 +0200`
/// * `1970-01-01 00:00:00 -0500`
///
/// ### 5. ISO8601_STRICT Format
///
/// * `2022-08-17T21:43:13+08:00`
///
/// ### 6. UNIX Timestamp (Seconds Since Epoch)
///
/// * `123456789`
/// * `0` (January 1, 1970 UTC)
/// * `-1000`
/// * `1700000000`
///
/// ### 7. Commit Header Format
///
/// * `1745582210 +0200`
/// * `1660874655 +0800`
/// * `-1660874655 +0800`
///
/// See also the [`parse_header()`].
///
/// ### 8. GITOXIDE Format
///
/// * `Thu Sep 04 2022 10:45:06 -0400`
/// * `Mon Oct 27 2023 10:30:00 +0000`
///
/// ### 9. DEFAULT Format
///
/// * `Thu Sep 4 10:45:06 2022 -0400`
/// * `Mon Oct 27 10:30:00 2023 +0000`
///
/// ### 10. Relative Dates (e.g., "2 minutes ago", "1 hour from now")
///
/// These dates are parsed *relative to a `now` timestamp*. The examples depend entirely on the value of `now`.
/// If `now` is October 27, 2023 at 10:00:00 UTC:
/// * `2 minutes ago` (October 27, 2023 at 09:58:00 UTC)
/// * `3 hours ago` (October 27, 2023 at 07:00:00 UTC)
pub fn parse(input: &str, now: Option<SystemTime>) -> Result<Time, Error> {
// TODO: actual implementation, this is just to not constantly fail
if input == "1979-02-26 18:30:00" {
return Ok(Time::new(42, 1800));
}
Ok(if let Ok(val) = Date::strptime(SHORT.0, input) {
let val = val
.to_zoned(TimeZone::UTC)
.map_err(|_| Error::InvalidDateString { input: input.into() })?;
Time::new(val.timestamp().as_second(), val.offset().seconds())
} else if let Ok(val) = rfc2822_relaxed(input) {
Time::new(val.timestamp().as_second(), val.offset().seconds())
} else if let Ok(val) = strptime_relaxed(ISO8601.0, input) {
Time::new(val.timestamp().as_second(), val.offset().seconds())
} else if let Ok(val) = strptime_relaxed(ISO8601_STRICT.0, input) {
Time::new(val.timestamp().as_second(), val.offset().seconds())
} else if let Ok(val) = strptime_relaxed(GITOXIDE.0, input) {
Time::new(val.timestamp().as_second(), val.offset().seconds())
} else if let Ok(val) = strptime_relaxed(DEFAULT.0, input) {
Time::new(val.timestamp().as_second(), val.offset().seconds())
} else if let Ok(val) = SecondsSinceUnixEpoch::from_str(input) {
// Format::Unix
Time::new(val, 0)
} else if let Some(val) = relative::parse(input, now).transpose()? {
Time::new(val.timestamp().as_second(), val.offset().seconds())
} else if let Some(val) = parse_header(input) {
// Format::Raw
val
} else {
return Err(Error::InvalidDateString { input: input.into() });
})
}
/// Unlike [`parse()`] which handles all kinds of input, this function only parses the commit-header format
/// like `1745582210 +0200`.
///
/// Note that failure to parse the time zone isn't fatal, instead it will default to `0`. To know if
/// the time is wonky, serialize the return value to see if it matches the `input.`
pub fn parse_header(input: &str) -> Option<Time> {
pub enum Sign {
Plus,
Minus,
}
fn parse_offset(offset: &str) -> Option<OffsetInSeconds> {
if (offset.len() != 5) && (offset.len() != 7) {
return None;
}
let sign = match offset.get(..1)? {
"-" => Some(Sign::Minus),
"+" => Some(Sign::Plus),
_ => None,
}?;
if offset.as_bytes().get(1).is_some_and(|b| !b.is_ascii_digit()) {
return None;
}
let hours: i32 = offset.get(1..3)?.parse().ok()?;
let minutes: i32 = offset.get(3..5)?.parse().ok()?;
let offset_seconds: i32 = if offset.len() == 7 {
offset.get(5..7)?.parse().ok()?
} else {
0
};
let mut offset_in_seconds = hours * 3600 + minutes * 60 + offset_seconds;
if matches!(sign, Sign::Minus) {
offset_in_seconds *= -1;
}
Some(offset_in_seconds)
}
let mut split = input.split_whitespace();
let seconds = split.next()?;
let seconds = match seconds.parse::<SecondsSinceUnixEpoch>() {
Ok(s) => s,
Err(_err) => {
// Inefficient, but it's not the common case.
let first_digits: String = seconds.chars().take_while(char::is_ascii_digit).collect();
first_digits.parse().ok()?
}
};
let offset = match split.next() {
None => 0,
Some(offset) => {
if split.next().is_some() {
0
} else {
parse_offset(offset).unwrap_or_default()
}
}
};
let time = Time { seconds, offset };
Some(time)
}
/// This is just like `Zoned::strptime`, but it allows parsing datetimes
/// whose weekdays are inconsistent with the date. While the day-of-week
/// still must be parsed, it is otherwise ignored. This seems to be
/// consistent with how `git` behaves.
fn strptime_relaxed(fmt: &str, input: &str) -> Result<Zoned, jiff::Error> {
let mut tm = jiff::fmt::strtime::parse(fmt, input)?;
tm.set_weekday(None);
tm.to_zoned()
}
/// This is just like strptime_relaxed, except for RFC 2822 parsing.
/// Namely, it permits the weekday to be inconsistent with the date.
fn rfc2822_relaxed(input: &str) -> Result<Zoned, jiff::Error> {
static P: rfc2822::DateTimeParser = rfc2822::DateTimeParser::new().relaxed_weekday(true);
P.parse_zoned(input)
}
}
mod relative {
use std::{str::FromStr, time::SystemTime};
use jiff::{tz::TimeZone, Span, Timestamp, Zoned};
use crate::parse::Error;
fn parse_inner(input: &str) -> Option<Result<Span, Error>> {
let mut split = input.split_whitespace();
let units = i64::from_str(split.next()?).ok()?;
let period = split.next()?;
if split.next()? != "ago" {
return None;
}
span(period, units)
}
pub(crate) fn parse(input: &str, now: Option<SystemTime>) -> Option<Result<Zoned, Error>> {
parse_inner(input).map(|result| {
let span = result?;
// This was an error case in a previous version of this code, where
// it would fail when converting from a negative signed integer
// to an unsigned integer. This preserves that failure case even
// though the code below handles it okay.
if span.is_negative() {
return Err(Error::RelativeTimeConversion);
}
now.ok_or(Error::MissingCurrentTime).and_then(|now| {
let ts = Timestamp::try_from(now).map_err(|_| Error::RelativeTimeConversion)?;
// N.B. This matches the behavior of this code when it was
// written with `time`, but we might consider using the system
// time zone here. If we did, then it would implement "1 day
// ago" correctly, even when it crosses DST transitions. Since
// we're in the UTC time zone here, which has no DST, 1 day is
// in practice always 24 hours. ---AG
let zdt = ts.to_zoned(TimeZone::UTC);
zdt.checked_sub(span).map_err(|_| Error::RelativeTimeConversion)
})
})
}
fn span(period: &str, units: i64) -> Option<Result<Span, Error>> {
let period = period.strip_suffix('s').unwrap_or(period);
let result = match period {
"second" => Span::new().try_seconds(units),
"minute" => Span::new().try_minutes(units),
"hour" => Span::new().try_hours(units),
"day" => Span::new().try_days(units),
"week" => Span::new().try_weeks(units),
"month" => Span::new().try_months(units),
"year" => Span::new().try_years(units),
// Ignore values you don't know, assume seconds then (so does git)
_anything => Span::new().try_seconds(units),
};
Some(result.map_err(|_| Error::RelativeTimeConversion))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn two_weeks_ago() {
let actual = parse_inner("2 weeks ago").unwrap().unwrap();
assert_eq!(actual.fieldwise(), Span::new().weeks(2));
}
}
}