ts32 0.1.0

timestamp in base 32
Documentation
#![cfg_attr(not(test), no_std)]
#![allow(clippy::tabs_in_doc_comments)]
#![cfg_attr(docsrs, feature(doc_auto_cfg))]

/*! # ts32: timestamp in base 32
 * 
 * ```
 * use ts32::Ts32;
 * 
 * /* Example in README */
 * const TEST_TIMESTAMP: i64 = 1735608225233;
 * const TEST_RFC3339: &str = "2024-12-31T01:23:45.233Z";
 * const TEST_TS32: &str = "20rcz:1271.79";
 *
 * #[cfg(feature = "time")]
 * {
 * 	let dt = time::OffsetDateTime::from_unix_timestamp_nanos(TEST_TIMESTAMP as i128 * 1000_000).unwrap();
 * 	let ts32s = dt.to_ts32();
 * 	assert_eq!(ts32s, TEST_TS32);
 * 	assert_eq!(time::OffsetDateTime::try_from_ts32(&ts32s).unwrap(), dt);
 * }
 * 
 * #[cfg(feature = "chrono")]
 * {
 * 	let dt = chrono::DateTime::from_timestamp_millis(TEST_TIMESTAMP).unwrap();
 * 	let ts32s = dt.to_ts32();
 * 	assert_eq!(ts32s, TEST_TS32);
 * 	assert_eq!(chrono::DateTime::try_from_ts32(&ts32s).unwrap(), dt);
 * }
 * ```
 * 
 * ## Features
 * 
 * - `time` enables implementation for [`time::OffsetDateTime`]
 * - `chrono` enables implemenetation for [`chrono::DateTime`]
 * - `large-dates` enables the feature of the same name of the [`time`] crate, increasing supported year range.
 */

use tinystr::TinyStr16;
#[cfg(feature = "chrono")]
use chrono::{Datelike, Timelike};

pub type Ts32Str = TinyStr16;

/** Base32, Crockford variant (minus ILOU) */
const ALPHABET: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz";
const ALPHABET_REV: &[u8] = &[
	0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, /* 0-15 */
	0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, /* 16-31 */
	0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, /* 32-47 */
	0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 0, 0, 0, 0, 0, /* 48-63 */
	0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, /* 64-79 */
	0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, /* 80-95 */
	/*  a   b   c   d   e   f   g   h  i   j   k  l   m   n  o */
	0, 10, 11, 12, 13, 14, 15, 16, 17, 0, 18, 19, 0, 20, 21, 0, /* 96-111 */
	/* p q   r   s   t  u   v   w   x   y   z */
	22, 23, 24, 25, 26, 0, 27, 28, 29, 30, 31, 0, 0, 0, 0, 0, /* 112-127 */
];
const U5MAX: u32 = 0b11111;

const fn encode2(i: u32) -> [u8; 2] {
	debug_assert!(i < 1024 /* 32 * 32 */);
	[((i >> 5) & U5MAX) as u8, (i & U5MAX) as u8]
}

const fn decode2(i: &[u8]) -> u32 {
	((ALPHABET_REV[i[0] as usize] as u32) << 5) + ALPHABET_REV[i[1] as usize] as u32
}

fn values_to_s(y: i32, m: u8, d: u8, h: u8, min: u8, s: u8, ms: u16) -> Ts32Str {
	let full = values_to_s_full(y, m, d, h, min, s, ms);
	let bytes = full.as_bytes();
	let cut_head = if bytes[0] == b'0' { 1 } else { 0 };
	let cut_tail = if bytes[12] == b'0' && bytes[13] == b'0' { 3 } else { 0 };
	full[cut_head..full.len() - cut_tail].parse().unwrap()
}

fn values_to_s_full(y: i32, m: u8, d: u8, h: u8, min: u8, s: u8, ms: u16) -> Ts32Str {
	/* NOTE: This "hardcodes" the only two 3-digit segments relevant in the second millennium. This is enough for
	 * this megaannum (10^6 years), the end of which is so far away I'm pretty much dead by then. Trust our childs.
	 */
	let ya = y.unsigned_abs();
	let y0 = ya % 1000;
	let y1 = ya / 1000;
	let y0e = encode2(y0);
	let y1e = encode2(y1);

	let sih = min as u16 * 60 + s as u16;
	let sixth = sih / 600;
	let si6 = sih % 600;
	let si6e = encode2(si6.into());
	let mse = if ms == 0 { [0, 0] } else { encode2(ms as u32) };

	/* Likewise, hardcoded for this megaannum. */
	let bytes = [
		b'-',
		ALPHABET[y1e[0] as usize], ALPHABET[y1e[1] as usize], ALPHABET[y0e[0] as usize], ALPHABET[y0e[1] as usize],
		ALPHABET[m as usize], ALPHABET[d as usize],
		b':',
		ALPHABET[h as usize], ALPHABET[sixth as usize], ALPHABET[si6e[0] as usize], ALPHABET[si6e[1] as usize],
		b'.',
		ALPHABET[mse[0] as usize], ALPHABET[mse[1] as usize],
	];
	let bytes = if y >= 0 { &bytes[1..] } else { &bytes };
	/* Again, 16 bytes fit this megaannum. */
	TinyStr16::try_from_utf8(bytes).unwrap()
}

#[allow(clippy::type_complexity)]
fn values_from_s(s: &str) -> Result<(i32, u8, u8, u8, u8, u8, u16), Error> {
	let neg = s.starts_with('-');
	let s = if neg { &s[1..] } else { s };
	let bytes = s.as_bytes();

	let sep_date = s.find(':').ok_or(Error::DecodeNoDate)?;
	let sep_ms = s.find('.');

	let s_y = match sep_date - 2 {
		1 => &[0, 0, 0, bytes[0]],
		2 => &[0, 0, bytes[0], bytes[1]],
		3 => &[0, bytes[0], bytes[1], bytes[2]],
		4 => &bytes[..sep_date],
		_ => return Err(Error::DecodeUnimplementedYearRange),
	};
	let y = decode2(&s_y[..2]) * 1000 + decode2(&s_y[2..]);
	let y = if neg { -(y as i32) } else { y as i32 };
	let m = ALPHABET_REV[bytes[sep_date - 2] as usize];
	let d = ALPHABET_REV[bytes[sep_date - 1] as usize];
	let h = ALPHABET_REV[bytes[sep_date + 1] as usize];
	let sixth = ALPHABET_REV[bytes[sep_date + 2] as usize];
	let si6 = decode2(&bytes[sep_date + 3..=sep_date + 4]);
	let sih = sixth as u32 * 600 + si6;
	let s = sih % 60;
	let min = sih / 60;
	let ms = if let Some(sep_ms) = sep_ms {
		decode2(&bytes[sep_ms + 1..])
	} else {
		0
	};
	Ok((y, m, d, h, min as u8, s as u8, ms as u16))
}

pub trait Ts32: Sized {
	fn to_ts32(&self) -> Ts32Str;
	fn try_from_ts32(s: &str) -> Result<Self, Error>;
}

#[cfg(feature = "time")]
impl Ts32 for time::OffsetDateTime {
	fn to_ts32(&self) -> Ts32Str {
		let dt = self;
		values_to_s(dt.year(), dt.month() as u8, dt.day(), dt.hour(), dt.minute(), dt.second(), dt.millisecond())
	}

	fn try_from_ts32(s: &str) -> Result<Self, Error> {
		let (y, m, d, h, min, s, ms) = values_from_s(s)?;
		Ok(time::OffsetDateTime::new_utc(
			time::Date::from_calendar_date(y, m.try_into().unwrap(), d).map_err(|_| Error::DecodeUnsupportedDate)?,
			time::Time::from_hms_milli(h, min, s, ms).map_err(|_| Error::DecodeInvalidTime)?,
		))
	}
}

#[cfg(feature = "chrono")]
impl Ts32 for chrono::DateTime<chrono::Utc> {
	fn to_ts32(&self) -> Ts32Str {
		let dt = self;
		values_to_s(dt.year(), dt.month() as u8, dt.day() as u8, dt.hour() as u8, dt.minute() as u8, dt.second() as u8, dt.timestamp_subsec_millis() as u16)
	}

	fn try_from_ts32(s: &str) -> Result<Self, Error> {
		let (y, m, d, h, min, s, ms) = values_from_s(s)?;
		Ok(chrono::NaiveDate::from_ymd_opt(y, m.into(), d.into()).ok_or(Error::DecodeUnsupportedDate)?
			.and_hms_milli_opt(h.into(), min.into(), s.into(), ms.into()).ok_or(Error::DecodeInvalidTime)?
			.and_utc())
	}
}

#[derive(Debug)]
pub enum Error {
	DecodeNoDate,
	DecodeUnsupportedDate,
	DecodeUnimplementedYearRange,
	DecodeInvalidTime,
}

#[cfg(test)]
mod test {
	use super::*;

	#[test]
	#[cfg(feature = "time")]
	fn time_negative() {
		let t_negative = time::OffsetDateTime::new_utc(
			time::Date::from_calendar_date(-1234, 5.try_into().unwrap(), 16).unwrap(),
			time::Time::from_hms_milli(17, 18, 19, 123).unwrap(),
		);
		let ts32s = t_negative.to_ts32();
		assert_eq!(ts32s, "-017a5g:h1fk.3v");
		assert_eq!(time::OffsetDateTime::try_from_ts32(&ts32s).unwrap(), t_negative);
	}

	#[test]
	#[cfg(feature = "chrono")]
	fn chrono_negative() {
		let t_negative = chrono::NaiveDate::from_ymd_opt(-1234, 5, 16).unwrap()
			.and_hms_milli_opt(17, 18, 19, 123).unwrap()
			.and_utc();
		let ts32s = t_negative.to_ts32();
		assert_eq!(ts32s, "-017a5g:h1fk.3v");
		assert_eq!(chrono::DateTime::try_from_ts32(&ts32s).unwrap(), t_negative);
	}
}