use std::fmt::{self, Write as _};
use crate::IdentityDate;
#[must_use]
pub fn quote_path(path: &[u8]) -> QuotedPath<'_> {
QuotedPath(path)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct QuotedPath<'a>(&'a [u8]);
fn needs_escape(byte: u8) -> bool {
!(0x20..0x7f).contains(&byte) || byte == b'"' || byte == b'\\'
}
impl fmt::Display for QuotedPath<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if !self.0.iter().copied().any(needs_escape) {
return self
.0
.iter()
.try_for_each(|&byte| f.write_char(char::from(byte)));
}
f.write_char('"')?;
for &byte in self.0 {
match byte {
b'"' => f.write_str("\\\"")?,
b'\\' => f.write_str("\\\\")?,
b'\x07' => f.write_str("\\a")?,
b'\x08' => f.write_str("\\b")?,
b'\t' => f.write_str("\\t")?,
b'\n' => f.write_str("\\n")?,
b'\x0b' => f.write_str("\\v")?,
b'\x0c' => f.write_str("\\f")?,
b'\r' => f.write_str("\\r")?,
byte if needs_escape(byte) => write!(f, "\\{byte:03o}")?,
byte => f.write_char(char::from(byte))?,
}
}
f.write_char('"')
}
}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum DateFormat {
#[default]
Default,
Iso,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct FormattedDate {
date: IdentityDate,
format: DateFormat,
recorded_zone: Option<i32>,
}
impl FormattedDate {
pub(crate) fn new(date: IdentityDate, format: DateFormat) -> Self {
Self {
date,
format,
recorded_zone: None,
}
}
pub(crate) fn with_recorded_zone(mut self, hhmm: i32) -> Self {
self.recorded_zone = Some(hhmm);
self
}
}
impl fmt::Display for FormattedDate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
const WEEKDAYS: [&str; 7] = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"];
const MONTHS: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
let offset = i128::from(self.date.offset_minutes);
let local = i128::from(self.date.seconds) + offset * 60;
let days = local.div_euclid(86_400);
let time = local.rem_euclid(86_400);
let (year, month, day) = civil_from_days(days);
let (hour, minute, second) = (time / 3600, time / 60 % 60, time % 60);
let hhmm = self
.recorded_zone
.map_or(offset / 60 * 100 + offset % 60, i128::from);
let sign = if hhmm < 0 { '-' } else { '+' };
let zone = hhmm.unsigned_abs();
let (zone_hours, zone_minutes) = (zone / 100, zone % 100);
match self.format {
DateFormat::Default => {
let weekday = WEEKDAYS[usize::try_from(days.rem_euclid(7)).unwrap_or(0)];
let month = MONTHS[usize::try_from(month - 1).unwrap_or(0)];
write!(
f,
"{weekday} {month} {day} {hour:02}:{minute:02}:{second:02} {year}"
)?;
if self.recorded_zone == Some(-1) {
return Ok(());
}
write!(f, " {sign}{zone_hours:02}{zone_minutes:02}")
}
DateFormat::Iso => write!(
f,
"{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02} \
{sign}{zone_hours:02}{zone_minutes:02}"
),
}
}
}
fn civil_from_days(days: i128) -> (i128, i128, i128) {
let shifted = days + 719_468;
let era = shifted.div_euclid(146_097);
let day_of_era = shifted.rem_euclid(146_097);
let year_of_era =
(day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_index = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * month_index + 2) / 5 + 1;
let month = if month_index < 10 {
month_index + 3
} else {
month_index - 9
};
let year = year_of_era + era * 400 + i128::from(month <= 2);
(year, month, day)
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
#[rstest]
#[case::plain(b"dir/file.txt", "dir/file.txt")]
#[case::space(b"a b", "a b")]
#[case::empty(b"", "")]
#[case::quote(b"a\"b", r#""a\"b""#)]
#[case::backslash(b"a\\b", r#""a\\b""#)]
#[case::controls(b"\x07\x08\t\n\x0b\x0c\r", r#""\a\b\t\n\v\f\r""#)]
#[case::octal_control(b"\x01\x1f", r#""\001\037""#)]
#[case::delete(b"\x7f", r#""\177""#)]
#[case::non_ascii(b"\xff/\x80", r#""\377/\200""#)]
fn quotes_paths(#[case] path: &[u8], #[case] expected: &str) {
assert_eq!(quote_path(path).to_string(), expected);
}
#[rstest]
#[case::epoch(0, 0, "Thu Jan 1 00:00:00 1970 +0000", "1970-01-01 00:00:00 +0000")]
#[case::numeric_ninety_minutes(
0,
90,
"Thu Jan 1 01:30:00 1970 +0130",
"1970-01-01 01:30:00 +0130"
)]
#[case::half_hour(
1_700_000_000,
330,
"Wed Nov 15 03:43:20 2023 +0530",
"2023-11-15 03:43:20 +0530"
)]
#[case::west(
1_696_320_000,
-480,
"Tue Oct 3 00:00:00 2023 -0800",
"2023-10-03 00:00:00 -0800"
)]
#[case::leap_day(
1_709_210_096,
840,
"Fri Mar 1 02:34:56 2024 +1400",
"2024-03-01 02:34:56 +1400"
)]
#[case::large_offset(
1_700_000_000,
5999,
"Sun Nov 19 02:12:20 2023 +9959",
"2023-11-19 02:12:20 +9959"
)]
#[case::year_9999(
253_402_300_799,
0,
"Fri Dec 31 23:59:59 9999 +0000",
"9999-12-31 23:59:59 +0000"
)]
#[case::year_5138(
99_999_999_999,
0,
"Wed Nov 16 09:46:39 5138 +0000",
"5138-11-16 09:46:39 +0000"
)]
fn formats_dates(
#[case] seconds: i64,
#[case] offset_minutes: i16,
#[case] default: &str,
#[case] iso: &str,
) {
let date = IdentityDate {
seconds,
offset_minutes,
};
assert_eq!(date.format(DateFormat::Default).to_string(), default);
assert_eq!(date.format(DateFormat::Iso).to_string(), iso);
}
#[test]
fn formats_pre_epoch_dates() {
let date = IdentityDate {
seconds: -86_400,
offset_minutes: 0,
};
assert_eq!(
date.format(DateFormat::Iso).to_string(),
"1969-12-31 00:00:00 +0000"
);
}
#[test]
fn formats_extreme_dates() {
let date = IdentityDate {
seconds: i64::MAX,
offset_minutes: i16::MAX,
};
assert!(
date.format(DateFormat::Default)
.to_string()
.ends_with(" +54607")
);
}
}