use std::hash::{BuildHasher, Hasher};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
pub(crate) const CROCKFORD: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
const FIRST_LETTER: u64 = 10;
pub const ISSUE_ID_LEN: usize = 8;
pub(crate) fn crockford(value: u128, width: usize) -> String {
let mut out = vec![b'0'; width];
let mut remaining = value;
for slot in out.iter_mut().rev() {
*slot = CROCKFORD[(remaining & 0x1F) as usize];
remaining >>= 5;
}
String::from_utf8(out).expect("Crockford alphabet is ASCII")
}
pub fn generate_issue_id() -> String {
use std::sync::LazyLock;
static COUNTER: AtomicU64 = AtomicU64::new(0);
static SEED: LazyLock<u64> = LazyLock::new(|| {
let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
hasher.write_u64(0);
hasher.finish()
});
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
let mut bits =
splitmix64(*SEED ^ millis.rotate_left(32) ^ counter.wrapping_mul(0x9E37_79B9_7F4A_7C15));
let mut out = String::with_capacity(ISSUE_ID_LEN);
out.push(CROCKFORD[(FIRST_LETTER + (bits % (32 - FIRST_LETTER))) as usize] as char);
bits /= 32 - FIRST_LETTER;
for _ in 1..ISSUE_ID_LEN {
out.push(CROCKFORD[(bits & 0x1F) as usize] as char);
bits >>= 5;
}
out
}
fn splitmix64(mut z: u64) -> u64 {
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
#[must_use]
pub fn is_issue_id(s: &str) -> bool {
let bytes = s.as_bytes();
if bytes.len() != ISSUE_ID_LEN || !bytes[0].is_ascii_alphabetic() {
return false;
}
bytes
.iter()
.all(|b| CROCKFORD.contains(&b.to_ascii_uppercase()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
#[test]
fn crockford_encodes_fixed_width_and_excludes_ambiguous_letters() {
assert_eq!(crockford(0, 8), "00000000");
assert_eq!(crockford(31, 8), "0000000Z");
assert_eq!(crockford(32u128, 2), "10");
for b in b"ILOU" {
assert!(!CROCKFORD.contains(b), "{b} must not be in the alphabet");
}
}
#[test]
fn generated_ids_are_eight_crockford_chars_and_never_numeric() {
for _ in 0..1_000 {
let id = generate_issue_id();
assert_eq!(id.len(), ISSUE_ID_LEN, "{id}");
assert!(
id.parse::<u32>().is_err(),
"id {id} must not parse as an issue number"
);
assert!(is_issue_id(&id), "{id} must be a valid id");
}
}
#[test]
fn generated_ids_are_distinct_within_a_process() {
let ids: HashSet<String> = (0..10_000).map(|_| generate_issue_id()).collect();
assert_eq!(ids.len(), 10_000);
}
#[test]
fn is_issue_id_is_case_insensitive_and_rejects_bad_shapes() {
let id = generate_issue_id();
assert!(is_issue_id(&id));
assert!(is_issue_id(&id.to_ascii_lowercase()));
assert!(!is_issue_id("12345678"), "all digits is not an id");
assert!(!is_issue_id("1234567"), "too short");
assert!(!is_issue_id("123456789"), "too long");
assert!(!is_issue_id("ABCDEFGI"), "I is not in the alphabet");
assert!(!is_issue_id(""), "empty");
}
}