use std::fmt;
use std::str::FromStr;
pub const TSID_EPOCH_MILLIS: i64 = 1_767_225_600_000;
const MAX_TS_MS: u64 = (1u64 << 42) - 1;
pub const RANDOM_BITS: u32 = 22;
const RANDOM_MASK: u64 = (1u64 << RANDOM_BITS) - 1;
const CROCKFORD_ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
const ENCODED_LEN: usize = 13;
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Tsid(u64);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TsidParseError {
BadLength { actual: usize },
BadChar { position: usize, ch: char },
Overflow,
}
impl fmt::Display for TsidParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TsidParseError::BadLength { actual } => write!(
f,
"TSID must be exactly {ENCODED_LEN} characters, got {actual}"
),
TsidParseError::BadChar { position, ch } => write!(
f,
"invalid Crockford base32 character {ch:?} at position {position}"
),
TsidParseError::Overflow => write!(
f,
"TSID overflows u64: leftmost character's high bit must be zero"
),
}
}
}
impl std::error::Error for TsidParseError {}
impl Tsid {
pub fn from_raw(value: u64) -> Self {
Tsid(value)
}
pub fn as_u64(self) -> u64 {
self.0
}
pub fn from_millis_with_random(unix_ms: i64, random_22_bits: u32) -> Self {
let project_ms = (unix_ms - TSID_EPOCH_MILLIS).max(0) as u64;
let ts = project_ms & MAX_TS_MS;
let rand = (random_22_bits as u64) & RANDOM_MASK;
Tsid((ts << RANDOM_BITS) | rand)
}
pub fn timestamp_millis(self) -> i64 {
let project_ms = (self.0 >> RANDOM_BITS) as i64;
project_ms + TSID_EPOCH_MILLIS
}
pub fn encode(self) -> String {
let mut buf = [0u8; ENCODED_LEN];
let mut value = self.0;
for i in (0..ENCODED_LEN).rev() {
let bits = (value & 0x1F) as usize;
buf[i] = CROCKFORD_ALPHABET[bits];
value >>= 5;
}
String::from_utf8(buf.to_vec()).expect("alphabet is ASCII")
}
pub fn parse(s: &str) -> Result<Self, TsidParseError> {
if s.len() != ENCODED_LEN {
return Err(TsidParseError::BadLength { actual: s.len() });
}
let mut value: u64 = 0;
for (position, ch) in s.chars().enumerate() {
let bits = decode_char(ch).ok_or(TsidParseError::BadChar { position, ch })?;
if position == 0 && bits >= 16 {
return Err(TsidParseError::Overflow);
}
value = (value << 5) | bits as u64;
}
Ok(Tsid(value))
}
}
impl fmt::Display for Tsid {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.encode())
}
}
impl FromStr for Tsid {
type Err = TsidParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl serde::Serialize for Tsid {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.encode())
}
}
impl<'de> serde::Deserialize<'de> for Tsid {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Self::parse(&s).map_err(serde::de::Error::custom)
}
}
fn decode_char(ch: char) -> Option<u8> {
let upper = ch.to_ascii_uppercase();
match upper {
'0' | 'O' => Some(0),
'1' | 'I' | 'L' => Some(1),
'2'..='9' => Some(upper as u8 - b'0'),
'A'..='H' => Some(upper as u8 - b'A' + 10),
'J' => Some(18),
'K' => Some(19),
'M' => Some(20),
'N' => Some(21),
'P'..='T' => Some(upper as u8 - b'P' + 22),
'V'..='Z' => Some(upper as u8 - b'V' + 27),
_ => None,
}
}
#[cfg(test)]
pub mod strategy {
use super::Tsid;
use proptest::prelude::*;
pub fn any_tsid() -> impl Strategy<Value = Tsid> {
any::<u64>().prop_map(Tsid::from_raw)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_has_length_13_and_uppercase() {
let t = Tsid::from_raw(0);
let s = t.encode();
assert_eq!(s.len(), ENCODED_LEN);
assert_eq!(s, "0000000000000");
assert!(s
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()));
}
#[test]
fn encode_known_value() {
let t = Tsid::from_raw(0x1234_5678_90AB_CDEF);
assert_eq!(t.encode().len(), ENCODED_LEN);
assert_eq!(Tsid::parse(&t.encode()), Ok(t));
}
#[test]
fn parse_canonical_round_trips() {
for value in [0u64, 1, 42, MAX_TS_MS, u64::MAX] {
let t = Tsid::from_raw(value);
assert_eq!(Tsid::parse(&t.encode()), Ok(t), "value = {value:#x}");
}
}
#[test]
fn parse_is_case_insensitive() {
let t = Tsid::from_raw(0xABCDEF12345);
let canonical = t.encode();
assert_eq!(Tsid::parse(&canonical.to_lowercase()), Ok(t));
}
#[test]
fn parse_accepts_ambiguous_crockford_chars() {
let t = Tsid::from_raw(1);
let mangled = t.encode().replace('1', "L");
assert_eq!(Tsid::parse(&mangled), Ok(t));
let mangled = t.encode().replace('1', "I");
assert_eq!(Tsid::parse(&mangled), Ok(t));
let t = Tsid::from_raw(0);
assert_eq!(Tsid::parse("OOOOOOOOOOOOO"), Ok(t));
}
#[test]
fn parse_rejects_wrong_length() {
assert!(matches!(
Tsid::parse("ABCD"),
Err(TsidParseError::BadLength { actual: 4 })
));
assert!(matches!(
Tsid::parse("00000000000000"),
Err(TsidParseError::BadLength { actual: 14 })
));
}
#[test]
fn parse_rejects_invalid_char() {
assert!(matches!(
Tsid::parse("U000000000000"),
Err(TsidParseError::BadChar {
position: 0,
ch: 'U'
})
));
}
#[test]
fn parse_rejects_overflow_at_top_char() {
assert_eq!(Tsid::parse("G000000000000"), Err(TsidParseError::Overflow));
}
#[test]
fn from_millis_uses_project_epoch() {
let t = Tsid::from_millis_with_random(TSID_EPOCH_MILLIS, 0);
assert_eq!(t.as_u64(), 0, "epoch ms with zero random == zero TSID");
let t = Tsid::from_millis_with_random(TSID_EPOCH_MILLIS + 1, 0);
assert_eq!(t.as_u64(), 1u64 << RANDOM_BITS);
}
#[test]
fn from_millis_clamps_pre_epoch_input_to_zero() {
let t = Tsid::from_millis_with_random(TSID_EPOCH_MILLIS - 1_000_000, 42);
assert_eq!(t.as_u64(), 42u64, "pre-epoch ms collapses to ts=0");
}
#[test]
fn serde_round_trips_via_string() {
let t = Tsid::from_raw(0xDEAD_BEEF_CAFE_BABE);
let s = serde_json::to_string(&t).unwrap();
assert_eq!(s, format!("\"{t}\""));
let back: Tsid = serde_json::from_str(&s).unwrap();
assert_eq!(back, t);
}
use proptest::prelude::any;
proptest::proptest! {
#[test]
fn prop_round_trip(value: u64) {
let t = Tsid::from_raw(value);
proptest::prop_assert_eq!(Tsid::parse(&t.encode()), Ok(t));
}
#[test]
fn prop_lexicographic_sort_matches_value_sort(values in proptest::collection::vec(any::<u64>(), 0..50)) {
let mut tsids: Vec<Tsid> = values.iter().copied().map(Tsid::from_raw).collect();
tsids.sort();
let encoded: Vec<String> = tsids.iter().map(|t| t.encode()).collect();
let mut sorted_encoded = encoded.clone();
sorted_encoded.sort();
proptest::prop_assert_eq!(encoded, sorted_encoded);
}
#[test]
fn prop_increasing_timestamp_yields_increasing_tsid(
ts1 in 0i64..1_000_000_000_000_i64,
delta in 1i64..1_000_000_i64,
) {
let unix1 = TSID_EPOCH_MILLIS + ts1;
let unix2 = unix1 + delta;
let a = Tsid::from_millis_with_random(unix1, 0);
let b = Tsid::from_millis_with_random(unix2, 0);
proptest::prop_assert!(a < b);
proptest::prop_assert!(a.encode() < b.encode());
}
#[test]
fn prop_same_millisecond_orders_by_random_tiebreaker(
ts in 0i64..1_000_000_000_000_i64,
r1 in 0u32..(RANDOM_MASK as u32),
r2 in 0u32..(RANDOM_MASK as u32),
) {
let unix = TSID_EPOCH_MILLIS + ts;
let a = Tsid::from_millis_with_random(unix, r1);
let b = Tsid::from_millis_with_random(unix, r2);
proptest::prop_assert_eq!(a.cmp(&b), r1.cmp(&r2));
}
}
}