#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TimeOutput {
pub(crate) text: String,
pub(crate) unsupported: bool,
}
const SECS_PER_DAY: i64 = 86_400;
const WEEKDAY_ABBR: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const WEEKDAY_FULL: [&str; 7] = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
const MONTH_ABBR: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
const MONTH_FULL: [&str; 12] = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Civil {
year: i64,
month: i64,
day: i64,
hour: i64,
minute: i64,
second: i64,
wday: i64,
yday: i64,
iso_week: i64,
}
fn civil_from_days(z: i64) -> (i64, i64, i64) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
(y + i64::from(m <= 2), m, d)
}
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = y - i64::from(m <= 2);
let era = if y >= 0 { y } else { y - 399 }.div_euclid(400);
let yoe = y - era * 400;
let mp = if m > 2 { m - 3 } else { m + 9 };
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
fn is_leap(year: i64) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
fn days_in_month(year: i64, month: i64) -> i64 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
_ => {
if is_leap(year) {
29
} else {
28
}
}
}
}
fn raw_iso_week(y: i64, m: i64, d: i64) -> i64 {
let z = days_from_civil(y, m, d);
let wday = (z.rem_euclid(7) + 4) % 7;
let iso_wd = if wday == 0 { 7 } else { wday };
let doy1 = z - days_from_civil(y, 1, 1) + 1;
(doy1 - iso_wd + 10).div_euclid(7)
}
fn weeks_in_year(year: i64) -> i64 {
raw_iso_week(year, 12, 28)
}
fn iso_week(y: i64, m: i64, d: i64) -> i64 {
let raw = raw_iso_week(y, m, d);
if raw < 1 {
weeks_in_year(y - 1)
} else if raw > weeks_in_year(y) {
1
} else {
raw
}
}
fn civil_from_unix(unix_secs: i64) -> Civil {
let days = unix_secs.div_euclid(SECS_PER_DAY);
let secs = unix_secs.rem_euclid(SECS_PER_DAY);
let (year, month, day) = civil_from_days(days);
let wday = (days.rem_euclid(7) + 4) % 7;
let yday = days - days_from_civil(year, 1, 1);
Civil {
year,
month,
day,
hour: secs / 3_600,
minute: (secs % 3_600) / 60,
second: secs % 60,
wday,
yday,
iso_week: iso_week(year, month, day),
}
}
fn format_year(year: i64) -> String {
if year < 0 {
format!("-{:04}", -year)
} else {
format!("{year:04}")
}
}
fn hour12(hour: i64) -> i64 {
let h = hour % 12;
if h == 0 { 12 } else { h }
}
fn render_code(code: char, c: &Civil, unix_secs: i64, out: &mut String) -> bool {
match code {
'Y' => out.push_str(&format_year(c.year)),
'y' => out.push_str(&format!("{:02}", c.year.rem_euclid(100))),
'n' => out.push_str(&c.month.to_string()),
'm' => out.push_str(&format!("{:02}", c.month)),
'M' => out.push_str(MONTH_ABBR[(c.month - 1) as usize]),
'F' => out.push_str(MONTH_FULL[(c.month - 1) as usize]),
'j' => out.push_str(&c.day.to_string()),
'd' => out.push_str(&format!("{:02}", c.day)),
'z' => out.push_str(&c.yday.to_string()),
'D' => out.push_str(WEEKDAY_ABBR[c.wday as usize]),
'l' => out.push_str(WEEKDAY_FULL[c.wday as usize]),
'N' => out.push_str(&(if c.wday == 0 { 7 } else { c.wday }).to_string()),
'w' => out.push_str(&c.wday.to_string()),
'W' => out.push_str(&format!("{:02}", c.iso_week)),
'H' => out.push_str(&format!("{:02}", c.hour)),
'G' => out.push_str(&c.hour.to_string()),
'h' => out.push_str(&format!("{:02}", hour12(c.hour))),
'g' => out.push_str(&hour12(c.hour).to_string()),
'i' => out.push_str(&format!("{:02}", c.minute)),
's' => out.push_str(&format!("{:02}", c.second)),
'A' => out.push_str(if c.hour < 12 { "AM" } else { "PM" }),
'a' => out.push_str(if c.hour < 12 { "am" } else { "pm" }),
'U' => out.push_str(&unix_secs.to_string()),
'L' => out.push_str(if is_leap(c.year) { "1" } else { "0" }),
't' => out.push_str(&days_in_month(c.year, c.month).to_string()),
'r' => out.push_str(&format_rfc2822(c)),
'c' => out.push_str(&format_iso8601(c)),
_ => return false,
}
true
}
fn format_rfc2822(c: &Civil) -> String {
format!(
"{}, {:02} {} {} {:02}:{:02}:{:02} +0000",
WEEKDAY_ABBR[c.wday as usize],
c.day,
MONTH_ABBR[(c.month - 1) as usize],
format_year(c.year),
c.hour,
c.minute,
c.second,
)
}
fn format_iso8601(c: &Civil) -> String {
format!(
"{}-{:02}-{:02}T{:02}:{:02}:{:02}+00:00",
format_year(c.year),
c.month,
c.day,
c.hour,
c.minute,
c.second,
)
}
fn find_closing_quote(chars: &[char], open: usize) -> Option<usize> {
chars[open + 1..]
.iter()
.position(|&c| c == '"')
.map(|rel| open + 1 + rel)
}
fn is_unsupported_code(ch: char) -> bool {
matches!(ch, 'o' | 'e' | 'I' | 'O' | 'P' | 'T' | 'Z')
}
pub(crate) fn format_time(format: &str, unix_secs: i64) -> TimeOutput {
let c = civil_from_unix(unix_secs);
let chars: Vec<char> = format.chars().collect();
let mut out = String::with_capacity(format.len());
let mut unsupported = false;
let mut i = 0;
while i < chars.len() {
let ch = chars[i];
match ch {
'\\' => {
i += 1;
if i < chars.len() {
out.push(chars[i]);
i += 1;
}
}
'"' => {
if let Some(close) = find_closing_quote(&chars, i) {
for &qc in &chars[i + 1..close] {
out.push(qc);
}
i = close + 1;
} else {
out.push('"');
i += 1;
}
}
'x' => {
match chars.get(i + 1).copied() {
Some('g') => {
out.push_str(MONTH_FULL[(c.month - 1) as usize]);
i += 2;
}
Some('n') => {
i += 2;
}
Some('x') => {
out.push('x');
i += 2;
}
Some(l) if l.is_ascii_alphabetic() => {
out.push('x');
out.push(l);
unsupported = true;
i += 2;
}
_ => {
out.push('x');
i += 1;
}
}
}
_ => {
if render_code(ch, &c, unix_secs, &mut out) {
i += 1;
} else {
if is_unsupported_code(ch) {
unsupported = true;
}
out.push(ch);
i += 1;
}
}
}
}
TimeOutput {
text: out,
unsupported,
}
}
#[cfg(test)]
mod tests {
use super::*;
const Y2K: i64 = 946_684_800;
fn f(fmt: &str, secs: i64) -> String {
format_time(fmt, secs).text
}
#[test]
fn civil_decodes_epoch() {
let c = civil_from_unix(0);
assert_eq!(c.year, 1970);
assert_eq!(c.month, 1);
assert_eq!(c.day, 1);
assert_eq!(c.wday, 4); assert_eq!(c.yday, 0);
}
#[test]
fn civil_decodes_y2k() {
let c = civil_from_unix(Y2K);
assert_eq!((c.year, c.month, c.day), (2000, 1, 1));
assert_eq!((c.hour, c.minute, c.second), (0, 0, 0));
assert_eq!(c.wday, 6); }
#[test]
fn date_codes_match_live_api() {
assert_eq!(f("Y-m-d", Y2K), "2000-01-01");
assert_eq!(f("y", Y2K), "00");
assert_eq!(f("n j", Y2K), "1 1");
assert_eq!(f("M F", Y2K), "Jan January");
assert_eq!(f("D l", Y2K), "Sat Saturday");
assert_eq!(f("N w", Y2K), "6 6");
assert_eq!(f("W z", Y2K), "52 0");
assert_eq!(f("L t", Y2K), "1 31");
}
#[test]
fn time_codes_match_live_api() {
assert_eq!(f("H:i:s", Y2K), "00:00:00");
assert_eq!(f("G g h", Y2K), "0 12 12");
assert_eq!(f("A a", Y2K), "AM am");
assert_eq!(f("U", Y2K), "946684800");
}
#[test]
fn afternoon_twelve_hour_clock() {
let pm = Y2K + 13 * 3_600;
assert_eq!(f("h g H G A a", pm), "01 1 13 13 PM pm");
}
#[test]
fn rfc2822_and_iso8601() {
assert_eq!(f("r", Y2K), "Sat, 01 Jan 2000 00:00:00 +0000");
assert_eq!(f("c", Y2K), "2000-01-01T00:00:00+00:00");
}
#[test]
fn pre_1970_uses_floor_division() {
assert_eq!(
f("Y-m-d H:i:s D N w W z", -1),
"1969-12-31 23:59:59 Wed 3 3 01 364"
);
}
#[test]
fn leap_day_2000() {
let feb29 = 951_782_400;
assert_eq!(f("d F Y t L z N", feb29), "29 February 2000 29 1 59 2");
}
#[test]
fn iso_week_year_boundary() {
assert_eq!(f("W", 1_609_718_400), "01");
}
#[test]
fn leap_year_flag_and_non_leap() {
assert_eq!(f("L", Y2K), "1");
assert_eq!(f("L", 0), "0");
assert_eq!(f("L t", -2_208_988_800), "0 31");
}
#[test]
fn days_in_february_leap_and_common() {
assert_eq!(f("t", 949_363_200), "29"); assert_eq!(f("t", 980_985_600), "28"); }
#[test]
fn all_month_names_and_days() {
let expected = [
("Jan", "January", 31),
("Feb", "February", 29),
("Mar", "March", 31),
("Apr", "April", 30),
("May", "May", 31),
("Jun", "June", 30),
("Jul", "July", 31),
("Aug", "August", 31),
("Sep", "September", 30),
("Oct", "October", 31),
("Nov", "November", 30),
("Dec", "December", 31),
];
let mut secs = Y2K; for (month, (abbr, full, days)) in expected.iter().enumerate() {
assert_eq!(
f("n M F t", secs),
format!("{} {abbr} {full} {days}", month + 1)
);
secs += days * SECS_PER_DAY;
}
}
#[test]
fn backslash_escapes_next_char() {
assert_eq!(f("\\Y=Y", Y2K), "Y=2000");
assert_eq!(f("d\\", Y2K), "01");
}
#[test]
fn double_quoted_run_is_literal() {
assert_eq!(f("\"literal Y\"", Y2K), "literal Y");
assert_eq!(f("Y\" at \"H", Y2K), "2000 at 00");
}
#[test]
fn unmatched_quote_is_literal() {
assert_eq!(f("Y\"m", Y2K), "2000\"01");
}
#[test]
fn xg_genitive_is_full_month() {
assert_eq!(f("xg", Y2K), "January");
}
#[test]
fn xn_is_transparent_and_supported() {
let out = format_time("xnj xnm", Y2K);
assert_eq!(out.text, "1 01");
assert!(!out.unsupported);
}
#[test]
fn xx_digraph_is_literal_x() {
let out = format_time("xx", Y2K);
assert_eq!(out.text, "x");
assert!(!out.unsupported);
}
#[test]
fn x_before_non_letter_is_literal() {
let out = format_time("x-", Y2K);
assert_eq!(out.text, "x-");
assert!(!out.unsupported);
assert_eq!(format_time("x", Y2K).text, "x");
}
#[test]
fn extended_code_is_unsupported() {
let out = format_time("xN", Y2K);
assert_eq!(out.text, "xN");
assert!(out.unsupported);
}
#[test]
fn unsupported_single_codes_are_flagged() {
for code in ['o', 'e', 'I', 'O', 'P', 'T', 'Z'] {
let out = format_time(&code.to_string(), Y2K);
assert_eq!(out.text, code.to_string());
assert!(out.unsupported, "{code} should be unsupported");
}
}
#[test]
fn literal_punctuation_is_not_unsupported() {
let out = format_time("- : / .", Y2K);
assert_eq!(out.text, "- : / .");
assert!(!out.unsupported);
}
#[test]
fn supported_format_is_not_flagged() {
assert!(!format_time("Y-m-d", Y2K).unsupported);
}
#[test]
fn year_padding_and_negatives() {
assert_eq!(f("Y", -46_388_678_400), "0500");
assert_eq!(f("y", 1_609_718_400), "21");
}
#[test]
fn negative_year_formats_with_sign() {
assert_eq!(format_year(-44), "-0044");
assert_eq!(format_year(12_345), "12345");
}
#[test]
fn weekday_cycle_is_correct() {
let expected = ["Sat", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri"];
for (offset, name) in expected.iter().enumerate() {
assert_eq!(
f("D l N w", Y2K + offset as i64 * SECS_PER_DAY)
.split(' ')
.next()
.unwrap(),
*name
);
}
}
#[test]
fn iso_weekday_and_full_names_all_days() {
let expected = [
("Sunday", "7", "0"),
("Monday", "1", "1"),
("Tuesday", "2", "2"),
("Wednesday", "3", "3"),
("Thursday", "4", "4"),
("Friday", "5", "5"),
("Saturday", "6", "6"),
];
let sunday = Y2K + SECS_PER_DAY;
for (offset, (full, n, w)) in expected.iter().enumerate() {
assert_eq!(
f("l N w", sunday + offset as i64 * SECS_PER_DAY),
format!("{full} {n} {w}")
);
}
}
#[test]
fn day_of_year_end_of_year() {
assert_eq!(f("z", 978_220_800), "365");
}
#[test]
fn iso_week_53_year() {
assert_eq!(f("W", 1_609_372_800), "53");
}
#[test]
fn empty_format_is_empty() {
let out = format_time("", Y2K);
assert_eq!(out.text, "");
assert!(!out.unsupported);
}
#[test]
fn days_in_month_all_lengths() {
assert_eq!(days_in_month(2001, 4), 30);
assert_eq!(days_in_month(2001, 1), 31);
assert_eq!(days_in_month(2000, 2), 29);
assert_eq!(days_in_month(2001, 2), 28);
}
}