use crate::{
DAY_IN_SECONDS,
HOUR_IN_SECONDS,
MINUTE_IN_SECONDS,
Month,
Utc2k,
Utc2kError,
Year,
};
use std::num::NonZeroU32;
macro_rules! merge_digits {
($src:ident $idx1:literal $idx2:literal) => ( $src[$idx1] * 10 + $src[$idx2] );
($src:ident $idx1:literal $idx2:literal $idx3:literal $idx4:literal) => (
$src[$idx1] as u16 * 1000 +
$src[$idx2] as u16 * 100 +
$src[$idx3] as u16 * 10 +
$src[$idx4] as u16
);
}
#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)]
pub(super) struct Abacus {
y: u16,
m: u16,
d: u16,
hh: u16,
mm: u16,
ss: u16,
}
impl Abacus {
const MAX_SECONDS: u32 = Utc2k::MAX_UNIXTIME - Utc2k::MIN_UNIXTIME + 1;
}
impl Abacus {
#[must_use]
pub(super) const fn new(y: u16, m: u8, d: u8, hh: u8, mm: u8, ss: u8) -> Self {
let mut out = Self {
y,
m: m as u16,
d: d as u16,
hh: hh as u16,
mm: mm as u16,
ss: ss as u16,
};
out.rebalance();
out
}
#[cfg(feature = "local")]
#[must_use]
pub(super) const fn new_with_offset(
y: u16, m: u8, d: u8, hh: u8, mm: u8, ss: u8,
offset: i32,
) -> Self {
let mut out = Self {
y,
m: m as u16,
d: d as u16,
hh: hh as u16,
mm: mm as u16,
ss: ss as u16,
};
out.apply_offset(offset);
out.rebalance();
out
}
#[must_use]
pub(super) const fn from_utc2k(src: Utc2k) -> Self {
let (y, m, d, hh, mm, ss) = src.parts();
Self {
y,
m: m as u16,
d: d as u16,
hh: hh as u16,
mm: mm as u16,
ss: ss as u16,
}
}
#[expect(clippy::cast_possible_truncation, reason = "False positive.")]
#[must_use]
pub(super) const fn parts(&self) -> (Year, Month, u8, u8, u8, u8) {
if let Some(y) = Year::from_u16_checked(self.y) {
(
y,
Month::from_u8(self.m as u8),
self.d as u8,
self.hh as u8,
self.mm as u8,
self.ss as u8,
)
}
else if self.y < 2000 { (Year::Y2k00, Month::January, 1, 0, 0, 0) }
else { (Year::Y2k99, Month::December, 31, 23, 59, 59) }
}
#[expect(clippy::cast_possible_truncation, reason = "False positive.")]
pub(super) const fn parts_checked(&self)
-> Result<(Year, Month, u8, u8, u8, u8), Utc2kError> {
if let Some(y) = Year::from_u16_checked(self.y) {
Ok((
y,
Month::from_u8(self.m as u8),
self.d as u8,
self.hh as u8,
self.mm as u8,
self.ss as u8,
))
}
else if self.y < 2000 { Err(Utc2kError::Underflow) }
else { Err(Utc2kError::Overflow) }
}
}
impl Abacus {
#[expect(clippy::cast_possible_truncation, reason = "False positive.")]
const fn rebalance(&mut self) {
if 59 < self.ss {
self.mm += self.ss.wrapping_div(MINUTE_IN_SECONDS as u16);
self.ss %= MINUTE_IN_SECONDS as u16;
}
if 59 < self.mm {
self.hh += self.mm.wrapping_div(60);
self.mm %= 60;
}
if 23 < self.hh {
self.d += self.hh.wrapping_div(24);
self.hh %= 24;
}
self.rebalance_date();
}
const fn rebalance_date(&mut self) {
if self.y < 1992 || 2100 < self.y {
return self.rebalance_over_under(2100 < self.y);
}
if self.m == 0 {
self.y -= 1;
self.m = 12;
}
else if 12 < self.m {
let div = (self.m - 1).wrapping_div(12);
self.y += div;
self.m -= div * 12;
}
if self.d == 0 {
if self.m == 1 {
self.y -= 1;
self.m = 12;
self.d = 31;
}
else {
self.m -= 1;
self.d = self.month_days();
}
}
else {
loop {
let size = self.month_days();
if size < self.d {
self.d -= size;
if self.m == 12 {
self.y += 1;
self.m = 1;
}
else { self.m += 1; }
}
else { return; }
}
}
}
#[inline(never)]
const fn rebalance_date_cold(&mut self) { self.rebalance_date(); }
#[inline(never)]
const fn rebalance_over_under(&mut self, over: bool) {
self.y = if over { 2200 } else { 1900 };
self.m = 1;
self.d = 1;
self.hh = 0;
self.mm = 0;
self.ss = 0;
}
#[must_use]
const fn month_days(&self) -> u16 {
match self.m {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if self.y.trailing_zeros() >= 2 && (! self.y.is_multiple_of(100) || self.y.is_multiple_of(400)) => 29,
_ => 28,
}
}
}
impl Abacus {
#[expect(clippy::cast_possible_truncation, reason = "False positive.")]
#[must_use]
pub(super) const fn plus_seconds(mut self, mut offset: u32) -> Self {
if DAY_IN_SECONDS <= offset {
if Self::MAX_SECONDS < offset {
self.rebalance_over_under(true);
return self;
}
if let Some(more) = ss_split_off_days(&mut offset) {
self.d += more.get() as u16;
}
}
if let Some(more) = ss_split_off_hours(&mut offset) {
self.hh += more.get() as u16;
}
if let Some(more) = ss_split_off_minutes(&mut offset) {
self.mm += more.get() as u16;
}
self.ss += offset as u16;
self.rebalance();
self
}
#[expect(clippy::cast_possible_truncation, reason = "False positive.")]
const fn apply_offset(&mut self, signed_offset: i32) {
if signed_offset == 0 { return; }
let mut offset = signed_offset.unsigned_abs();
debug_assert!(
offset < DAY_IN_SECONDS,
"BUG: parsed offsets are supposed to be capped to a day!",
);
if 0 < signed_offset {
let mut balance =
self.mm as u32 * MINUTE_IN_SECONDS +
self.hh as u32 * HOUR_IN_SECONDS;
self.hh = 0;
self.mm = 0;
if balance < offset {
if 0 == self.d { self.rebalance_date_cold(); }
balance += DAY_IN_SECONDS;
self.d -= 1;
}
offset = balance - offset;
}
if let Some(more) = ss_split_off_days(&mut offset) {
self.d += more.get() as u16;
}
if let Some(more) = ss_split_off_days(&mut offset) {
self.hh += more.get() as u16;
}
if let Some(more) = ss_split_off_minutes(&mut offset) {
self.mm += more.get() as u16;
}
self.ss += offset as u16;
}
}
impl Abacus {
#[must_use]
pub(super) const fn from_ascii(src: &[u8]) -> Option<Self> {
if let Some(mut out) = Self::parse_ascii_raw(src) {
out.rebalance();
Some(out)
}
else { None }
}
#[must_use]
pub(super) const fn from_rfc2822(src: &[u8]) -> Option<Self> {
if let Some(mut out) = Self::parse_rfc822_raw(src) {
out.rebalance();
Some(out)
}
else { None }
}
#[must_use]
const fn parse_ascii_raw(src: &[u8]) -> Option<Self> {
match src {
[ y1, y2, y3, y4, m1 @ b'0'..=b'9', m2, d1, d2, ] |
[ y1, y2, y3, y4, _, m1, m2, _, d1, d2, ] => {
let chunk = u64::from_le_bytes([
*y1, *y2, *y3, *y4, *m1, *m2, *d1, *d2,
]) ^ 0x3030_3030_3030_3030_u64;
let chk = chunk.wrapping_add(0x7676_7676_7676_7676_u64);
if (chunk & 0xf0f0_f0f0_f0f0_f0f0_u64) | (chk & 0x8080_8080_8080_8080_u64) == 0 {
let chunk = chunk.to_le_bytes();
return Some(Self {
y: merge_digits!(chunk 0 1 2 3),
m: merge_digits!(chunk 4 5) as u16,
d: merge_digits!(chunk 6 7) as u16,
hh: 0, mm: 0, ss: 0,
});
}
},
[ y1, y2, y3, y4, m1 @ b'0'..=b'9', m2, d1, d2, hh1, hh2, mm1, mm2, ss1, ss2, rest @ .. ] |
[ y1, y2, y3, y4, _, m1, m2, _, d1, d2, _, hh1, hh2, _, mm1, mm2, _, ss1, ss2, rest @ .. ] => {
let chunk = u128::from_le_bytes([
*y1, *y2, *y3, *y4, *m1, *m2, *d1, *d2,
*hh1, *hh2, *mm1, *mm2, *ss1, *ss2,
0, 0, ]) ^ 0x3030_3030_3030_3030_3030_3030_3030_u128;
let chk = chunk.wrapping_add(0x7676_7676_7676_7676_7676_7676_7676_u128);
if (chunk & 0xf0f0_f0f0_f0f0_f0f0_f0f0_f0f0_f0f0_u128) | (chk & 0x8080_8080_8080_8080_8080_8080_8080_u128) == 0 {
let chunk = chunk.to_le_bytes();
let mut out = Self {
y: merge_digits!(chunk 0 1 2 3),
m: merge_digits!(chunk 4 5) as u16,
d: merge_digits!(chunk 6 7) as u16,
hh: merge_digits!(chunk 8 9) as u16,
mm: merge_digits!(chunk 10 11) as u16,
ss: merge_digits!(chunk 12 13) as u16,
};
if rest.is_empty() { return Some(out); }
if let Some(offset) = parse_offset_cold(rest) {
out.apply_offset(offset);
return Some(out);
}
}
},
_ => {},
}
None
}
#[must_use]
const fn parse_rfc822_raw(src: &[u8]) -> Option<Self> {
if let Some((y, m, d, src)) = parse_rfc2822_date(src) {
let mut out = Self {
y,
m: m as u16,
d: d as u16,
hh: 0, mm: 0, ss: 0,
};
if let [ _, a, b, _, c, d, _, e, f, src @ .. ] = src {
let chunk = u64::from_le_bytes([
*a, *b, *c, *d, *e, *f, 0, 0 ]) ^ 0x3030_3030_3030_u64;
let chk = chunk.wrapping_add(0x7676_7676_7676_u64);
if (chunk & 0xf0f0_f0f0_f0f0_u64) | (chk & 0x8080_8080_8080_u64) == 0 {
let chunk = chunk.to_le_bytes();
out.hh = merge_digits!(chunk 0 1) as u16;
out.mm = merge_digits!(chunk 2 3) as u16;
out.ss = merge_digits!(chunk 4 5) as u16;
if let Some(offset) = parse_offset(src) {
out.apply_offset(offset);
return Some(out);
}
}
}
else if src.is_empty() { return Some(out); }
}
None
}
}
#[must_use]
const fn parse_offset(src: &[u8]) -> Option<i32> {
const fn is_gmt_utc(a: u8, b: u8, c: u8) -> bool {
matches!(crate::needle3(a, b, c), 1_668_576_512_u32 | 1_953_326_848_u32)
}
let src = strip_fractional_seconds(src);
match src.len() {
0 => Some(0),
1 if src[0] == b'Z' || src[0] == b'z' => Some(0),
2 if (src[0] == b'U' || src[0] == b'u') && (src[1] == b'T' || src[1] == b't') => Some(0),
3 if is_gmt_utc(src[0], src[1], src[2]) => Some(0),
5 => parse_offset_fixed(src[0], [src[1], src[2], src[3], src[4]]),
6 if src[3] == b':' => parse_offset_fixed(src[0], [src[1], src[2], src[4], src[5]]),
8 if is_gmt_utc(src[0], src[1], src[2]) => parse_offset_fixed(src[3], [src[4], src[5], src[6], src[7]]),
9 if is_gmt_utc(src[0], src[1], src[2]) && src[6] == b':' => parse_offset_fixed(src[3], [src[4], src[5], src[7], src[8]]),
_ => None,
}
}
#[inline(never)]
#[must_use]
const fn parse_offset_cold(src: &[u8]) -> Option<i32> { parse_offset(src) }
#[expect(clippy::cast_possible_wrap, reason = "False positive.")]
#[inline(never)]
const fn parse_offset_fixed(sign: u8, chunk: [u8; 4]) -> Option<i32> {
let chunk = u32::from_le_bytes(chunk) ^ 0x3030_3030_u32;
if (chunk & 0xf0f0_f0f0_u32) | (chunk.wrapping_add(0x7676_7676_u32) & 0x8080_8080_u32) != 0 {
return None;
}
let chunk = chunk.to_le_bytes();
let offset: i32 =
(
merge_digits!(chunk 0 1) as i32 * HOUR_IN_SECONDS as i32 +
merge_digits!(chunk 2 3) as i32 * MINUTE_IN_SECONDS as i32
) % DAY_IN_SECONDS as i32;
if sign == b'-' { Some(0_i32 - offset) }
else if sign == b'+' { Some(offset) }
else { None }
}
#[must_use]
const fn parse_rfc2822_date(mut src: &[u8]) -> Option<(u16, Month, u8, &[u8])> {
const MASK: u8 = 0b0000_1111;
if let [ _, _, _, b',', b' ', rest @ .. ] = src { src = rest; }
let d = match src {
[ b @ b'0'..=b'9', b' ', rest @ .. ] |
[ b' ', b @ b'0'..=b'9', b' ', rest @ .. ] => {
src = rest;
*b & MASK
},
[ a @ b'0'..=b'9', b @ b'0'..=b'9', b' ', rest @ .. ] => {
src = rest;
(*a & MASK) * 10 + (*b & MASK)
},
_ => return None,
};
if
let [ m1, m2, m3, b' ', y1, y2, y3, y4, rest @ .. ] = src &&
let Some(m) = Month::from_abbreviation(*m1, *m2, *m3)
{
let chunk = u32::from_le_bytes([*y1, *y2, *y3, *y4]) ^ 0x3030_3030_u32;
if (chunk & 0xf0f0_f0f0_u32) | (chunk.wrapping_add(0x7676_7676_u32) & 0x8080_8080_u32) == 0 {
let chunk = chunk.to_le_bytes();
return Some((merge_digits!(chunk 0 1 2 3), m, d, rest));
}
}
None
}
#[inline]
#[must_use]
pub(super) const fn ss_split_off_days(sec: &mut u32) -> Option<NonZeroU32> {
if let Some(out) = NonZeroU32::new(*sec / DAY_IN_SECONDS) {
*sec %= DAY_IN_SECONDS;
Some(out)
}
else { None }
}
#[inline]
#[must_use]
pub(super) const fn ss_split_off_hours(sec: &mut u32) -> Option<NonZeroU32> {
if let Some(out) = NonZeroU32::new(*sec / HOUR_IN_SECONDS) {
*sec %= HOUR_IN_SECONDS;
Some(out)
}
else { None }
}
#[inline]
#[must_use]
pub(super) const fn ss_split_off_minutes(sec: &mut u32) -> Option<NonZeroU32> {
if let Some(out) = NonZeroU32::new(*sec / MINUTE_IN_SECONDS) {
*sec %= MINUTE_IN_SECONDS;
Some(out)
}
else { None }
}
#[must_use]
const fn strip_fractional_seconds(mut src: &[u8]) -> &[u8] {
if let [ b'.', b'0'..=b'9', rest @ .. ] = src {
src = rest;
while let [ b'0'..=b'9', rest @ .. ] = src { src = rest }
}
src.trim_ascii_start()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn t_addition() {
macro_rules! add {
($($start:ident + $num:literal = ($y2:ident, $m2:ident, $d2:literal, $hh2:literal, $mm2:literal, $ss2:literal)),+) => ($(
assert_eq!(
$start.plus_seconds($num).parts(),
(Year::$y2, Month::$m2, $d2, $hh2, $mm2, $ss2)
);
)+);
}
let start = Abacus::new(2000, 1, 1, 0, 0, 0);
add!(
start + 0 = (Y2k00, January, 1, 0, 0, 0),
start + 1 = (Y2k00, January, 1, 0, 0, 1),
start + 60 = (Y2k00, January, 1, 0, 1, 0),
start + 3600 = (Y2k00, January, 1, 1, 0, 0),
start + 3661 = (Y2k00, January, 1, 1, 1, 1),
start + 31_622_400 = (Y2k01, January, 1, 0, 0, 0),
start + 4_294_967_295 = (Y2k99, December, 31, 23, 59, 59)
);
let start = Abacus::from_utc2k(Utc2k::MAX);
let end = start.plus_seconds(Abacus::MAX_SECONDS);
assert_eq!(
end.parts(),
(Year::Y2k99, Month::December, 31, 23, 59, 59)
);
let mut start = Abacus {
y: 9999,
m: 99,
d: 99,
hh: 99,
mm: 99,
ss: 99,
};
start.apply_offset(-86_399);
start.rebalance();
assert_eq!(
start.parts(),
(Year::Y2k99, Month::December, 31, 23, 59, 59)
);
start.y = 0;
start.m = 0;
start.d = 0;
start.hh = 0;
start.mm = 0;
start.ss = 0;
start.apply_offset(86_399);
start.rebalance();
assert_eq!(
start.parts(),
(Year::Y2k00, Month::January, 1, 0, 0, 0)
);
}
#[test]
fn t_carries() {
macro_rules! carry {
($(($y:literal, $m:literal, $d:literal, $hh:literal, $mm:literal, $ss:literal) ($y2:ident, $m2:ident, $d2:literal, $hh2:literal, $mm2:literal, $ss2:literal) $fail:literal),+) => ($(
assert_eq!(
Abacus::new($y, $m, $d, $hh, $mm, $ss).parts(),
(Year::$y2, Month::$m2, $d2, $hh2, $mm2, $ss2),
$fail
);
)+);
}
carry!(
(2000, 13, 32, 24, 60, 60) (Y2k01, February, 2, 1, 1, 0) "Overage of one everywhere.",
(2000, 25, 99, 1, 1, 1) (Y2k02, April, 9, 1, 1, 1) "Large month/day overages.",
(2000, 1, 1, 99, 99, 99) (Y2k00, January, 5, 4, 40, 39) "Large time overflows.",
(2000, 255, 255, 255, 255, 255) (Y2k21, November, 20, 19, 19, 15) "Max overflows.",
(1970, 25, 99, 1, 1, 1) (Y2k00, January, 1, 0, 0, 0) "Saturating low.",
(3000, 25, 99, 1, 1, 1) (Y2k99, December, 31, 23, 59, 59) "Saturating high #1.",
(2099, 25, 99, 1, 1, 1) (Y2k99, December, 31, 23, 59, 59) "Saturating high #2.",
(2010, 0, 0, 1, 1, 1) (Y2k09, November, 30, 1, 1, 1) "Zero month, zero day.",
(2010, 0, 32, 1, 1, 1) (Y2k10, January, 1, 1, 1, 1) "Zero month, overflowing day.",
(2010, 1, 0, 1, 1, 1) (Y2k09, December, 31, 1, 1, 1) "Zero day into zero month.",
(2010, 2, 30, 1, 1, 1) (Y2k10, March, 2, 1, 1, 1) "Too many days for month.",
(2010, 24, 1, 1, 1, 1) (Y2k11, December, 1, 1, 1, 1) "Exactly 24 months."
);
}
#[test]
fn t_month_days() {
let mut abacus = Abacus::new(2000, 2, 15, 0, 0, 0);
for i in 2000..=2099_u16 {
abacus.y = i;
let days = abacus.month_days();
let leap = Utc2k::from_abacus(abacus).leap_year();
assert_eq!(
28_u16 + u16::from(leap),
days,
"Disagreement over February {i}: {days} ({leap})",
);
}
}
#[test]
fn t_hh_mm_offset() {
for (raw, expected) in [
(
"2025-01-01T11:44:25.838394-0800",
(Year::Y2k25, Month::January, 1, 19, 44, 25)
),
(
"2025-01-01T11:44:25.838394-08:00",
(Year::Y2k25, Month::January, 1, 19, 44, 25)
),
(
"2025-01-01T11:44:25.838394+04:00",
(Year::Y2k25, Month::January, 1, 7, 44, 25)
),
] {
assert_eq!(
Abacus::from_ascii(raw.as_bytes()).unwrap().parts(),
expected,
);
}
}
}