use std::collections::BTreeMap;
use std::fmt;
use std::hash::{BuildHasher, Hasher};
use std::str::FromStr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
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()))
}
#[derive(Debug, Clone, Copy)]
pub enum IssueRef {
Id {
id: [u8; ISSUE_ID_LEN],
number: Option<u32>,
},
Number(u32),
}
impl IssueRef {
pub fn id(value: impl Into<String>) -> Option<Self> {
let value = value.into();
let value = value.trim();
if !is_issue_id(value) {
return None;
}
let mut bytes = [0; ISSUE_ID_LEN];
for (slot, byte) in bytes.iter_mut().zip(value.bytes()) {
*slot = byte.to_ascii_uppercase();
}
Some(Self::Id {
id: bytes,
number: None,
})
}
pub const fn number(value: u32) -> Self {
Self::Number(value)
}
#[must_use]
pub fn with_number(self, value: u32) -> Self {
match self {
Self::Id { id, .. } => Self::Id {
id,
number: Some(value),
},
Self::Number(_) => self,
}
}
#[must_use]
pub fn as_id(&self) -> Option<&str> {
match self {
Self::Id { id, .. } => std::str::from_utf8(id).ok(),
Self::Number(_) => None,
}
}
#[must_use]
pub const fn as_number(&self) -> Option<u32> {
match self {
Self::Id { number, .. } => *number,
Self::Number(number) => Some(*number),
}
}
#[must_use]
pub fn key(&self) -> String {
match self {
Self::Id { id, .. } => String::from_utf8_lossy(id).into_owned(),
Self::Number(number) => format!("#{number}"),
}
}
#[must_use]
pub fn order_key(&self) -> (u32, String) {
(self.as_number().unwrap_or(u32::MAX), self.key())
}
#[must_use]
pub fn human(&self) -> String {
self.as_number()
.map_or_else(|| self.to_string(), |number| format!("#{number}"))
}
}
impl fmt::Display for IssueRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Id { id, .. } => f.write_str(&String::from_utf8_lossy(id)),
Self::Number(number) => write!(f, "#{number}"),
}
}
}
impl PartialEq for IssueRef {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Id { id: left, .. }, Self::Id { id: right, .. }) => left == right,
(Self::Number(left), Self::Number(right)) => left == right,
_ => false,
}
}
}
impl Eq for IssueRef {}
impl std::hash::Hash for IssueRef {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
match self {
Self::Id { id, .. } => {
0u8.hash(state);
id.hash(state);
}
Self::Number(number) => {
1u8.hash(state);
number.hash(state);
}
}
}
}
impl PartialOrd for IssueRef {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for IssueRef {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
match (self, other) {
(Self::Id { id: left, .. }, Self::Id { id: right, .. }) => left.cmp(right),
(Self::Number(left), Self::Number(right)) => left.cmp(right),
(Self::Id { .. }, Self::Number(_)) => std::cmp::Ordering::Less,
(Self::Number(_), Self::Id { .. }) => std::cmp::Ordering::Greater,
}
}
}
impl From<u32> for IssueRef {
fn from(value: u32) -> Self {
Self::Number(value)
}
}
impl PartialEq<u32> for IssueRef {
fn eq(&self, other: &u32) -> bool {
self.as_number() == Some(*other)
}
}
impl PartialEq<IssueRef> for u32 {
fn eq(&self, other: &IssueRef) -> bool {
other.as_number() == Some(*self)
}
}
impl From<&str> for IssueRef {
fn from(value: &str) -> Self {
Self::id(value).unwrap_or_else(|| {
value.parse::<u32>().map_or_else(
|_| Self::Id {
id: id_bytes_lossy(value),
number: None,
},
Self::Number,
)
})
}
}
fn id_bytes_lossy(value: &str) -> [u8; ISSUE_ID_LEN] {
let mut bytes = [b'0'; ISSUE_ID_LEN];
for (slot, byte) in bytes.iter_mut().zip(value.bytes().take(ISSUE_ID_LEN)) {
*slot = byte.to_ascii_uppercase();
}
bytes
}
impl From<String> for IssueRef {
fn from(value: String) -> Self {
Self::from(value.as_str())
}
}
impl FromStr for IssueRef {
type Err = std::convert::Infallible;
fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
Ok(Self::from(value))
}
}
impl Serialize for IssueRef {
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
match self {
Self::Id { id, .. } => serializer.serialize_str(&String::from_utf8_lossy(id)),
Self::Number(number) => serializer.serialize_u32(*number),
}
}
}
impl<'de> Deserialize<'de> for IssueRef {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
struct Visitor;
impl serde::de::Visitor<'_> for Visitor {
type Value = IssueRef;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("an 8-character issue id or a numeric issue alias")
}
fn visit_u64<E: serde::de::Error>(
self,
value: u64,
) -> std::result::Result<Self::Value, E> {
u32::try_from(value)
.map(IssueRef::Number)
.map_err(|_| E::custom(format!("issue alias {value} exceeds u32")))
}
fn visit_str<E: serde::de::Error>(
self,
value: &str,
) -> std::result::Result<Self::Value, E> {
let value = value.trim();
if let Some(reference) = IssueRef::id(value) {
return Ok(reference);
}
Err(E::custom(format!(
"`{value}` is not an 8-character issue id"
)))
}
}
deserializer.deserialize_any(Visitor)
}
}
pub trait IdentityNode {
fn canonical_id(&self) -> Option<&str>;
fn issue_number(&self) -> u32;
fn issue_filename(&self) -> &str;
fn issue_title(&self) -> &str;
}
#[must_use]
pub fn canonical_ref<N: IdentityNode + ?Sized>(node: &N) -> IssueRef {
node.canonical_id()
.and_then(IssueRef::id)
.map(|reference| reference.with_number(node.issue_number()))
.unwrap_or_else(|| IssueRef::Number(node.issue_number()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AliasError {
Missing(String),
Ambiguous {
alias: String,
candidates: Vec<IssueRef>,
},
DuplicateId {
id: String,
numbers: Vec<u32>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DuplicateIdentityRecord {
pub id: String,
pub number: u32,
pub filename: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AliasIndex {
scanned_nodes: usize,
by_id: BTreeMap<String, IssueRef>,
by_number: BTreeMap<u32, Vec<IssueRef>>,
by_filename: BTreeMap<String, Vec<IssueRef>>,
by_title: BTreeMap<String, Vec<IssueRef>>,
duplicate_ids: BTreeMap<String, Vec<u32>>,
duplicate_id_files: BTreeMap<String, Vec<String>>,
duplicate_records: Vec<DuplicateIdentityRecord>,
canonical: BTreeMap<IssueRef, IssueRef>,
}
impl AliasIndex {
#[must_use]
pub fn len(&self) -> usize {
self.canonical.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.canonical.is_empty()
}
#[must_use]
pub fn scanned_nodes(&self) -> usize {
self.scanned_nodes
}
pub fn build<N: IdentityNode>(nodes: &[N]) -> Self {
let mut index = Self::default();
let mut id_records: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
for node in nodes {
index.scanned_nodes += 1;
let canonical = canonical_ref(node);
index.canonical.insert(canonical, canonical);
index
.by_number
.entry(node.issue_number())
.or_default()
.push(canonical);
index
.by_filename
.entry(node.issue_filename().to_string())
.or_default()
.push(canonical);
index
.by_title
.entry(node.issue_title().to_string())
.or_default()
.push(canonical);
if let Some(id) = node.canonical_id().and_then(IssueRef::id) {
let key = id.key();
index.by_id.insert(key.clone(), canonical);
id_records
.entry(key)
.or_default()
.push((node.issue_number(), node.issue_filename().to_string()));
}
}
for (id, records) in id_records
.into_iter()
.filter(|(_, records)| records.len() > 1)
{
let mut numbers: Vec<u32> = records.iter().map(|(number, _)| *number).collect();
numbers.sort_unstable();
numbers.dedup();
let mut files: Vec<String> = records
.iter()
.map(|(_, filename)| filename.clone())
.collect();
files.sort();
files.dedup();
index.duplicate_ids.insert(id.clone(), numbers);
index.duplicate_id_files.insert(id.clone(), files);
index
.duplicate_records
.extend(
records
.into_iter()
.map(|(number, filename)| DuplicateIdentityRecord {
id: id.clone(),
number,
filename,
}),
);
}
index.duplicate_records.sort_by(|left, right| {
(&left.id, left.number, &left.filename).cmp(&(&right.id, right.number, &right.filename))
});
for values in index
.by_number
.values_mut()
.chain(index.by_filename.values_mut())
.chain(index.by_title.values_mut())
{
values.sort();
values.dedup();
}
index
}
#[must_use]
pub fn duplicate_ids(&self) -> &BTreeMap<String, Vec<u32>> {
&self.duplicate_ids
}
#[must_use]
pub fn duplicate_id_files(&self) -> &BTreeMap<String, Vec<String>> {
&self.duplicate_id_files
}
#[must_use]
pub fn duplicate_records(&self) -> &[DuplicateIdentityRecord] {
&self.duplicate_records
}
pub fn resolve(&self, reference: &IssueRef) -> std::result::Result<IssueRef, AliasError> {
match reference {
IssueRef::Id { .. } => {
let key = reference.key();
if let Some(numbers) = self.duplicate_ids.get(&key) {
return Err(AliasError::DuplicateId {
id: key,
numbers: numbers.clone(),
});
}
self.by_id
.get(&key)
.copied()
.ok_or(AliasError::Missing(key))
}
IssueRef::Number(number) => self.resolve_number(*number),
}
}
pub fn resolve_number(&self, number: u32) -> std::result::Result<IssueRef, AliasError> {
Self::resolve_many(
&self.by_number.get(&number).cloned().unwrap_or_default(),
number.to_string(),
)
}
pub fn resolve_filename(&self, filename: &str) -> std::result::Result<IssueRef, AliasError> {
Self::resolve_many(
&self.by_filename.get(filename).cloned().unwrap_or_default(),
filename.to_string(),
)
}
pub fn resolve_title(&self, title: &str) -> std::result::Result<IssueRef, AliasError> {
Self::resolve_many(
&self.by_title.get(title).cloned().unwrap_or_default(),
title.to_string(),
)
}
fn resolve_many(
candidates: &[IssueRef],
alias: String,
) -> std::result::Result<IssueRef, AliasError> {
match candidates {
[] => Err(AliasError::Missing(alias)),
[one] => Ok(*one),
many => Err(AliasError::Ambiguous {
alias,
candidates: many.to_vec(),
}),
}
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
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");
}
#[test]
fn canonical_graph_references_serialize_ids_and_read_numeric_migration_aliases() {
let id = IssueRef::id("a1b2c3d4").unwrap();
assert_eq!(serde_yaml::to_string(&id).unwrap().trim(), "A1B2C3D4");
let from_id: IssueRef = serde_yaml::from_str("A1B2C3D4").unwrap();
assert_eq!(from_id, id);
let alias: IssueRef = serde_yaml::from_str("7").unwrap();
assert_eq!(alias, IssueRef::Number(7));
assert!(serde_yaml::from_str::<IssueRef>("'7'").is_err());
}
struct Node {
id: Option<&'static str>,
number: u32,
filename: &'static str,
title: &'static str,
}
impl IdentityNode for Node {
fn canonical_id(&self) -> Option<&str> {
self.id
}
fn issue_number(&self) -> u32 {
self.number
}
fn issue_filename(&self) -> &str {
self.filename
}
fn issue_title(&self) -> &str {
self.title
}
}
#[test]
fn alias_index_resolves_unique_aliases_and_refuses_missing_ambiguous_and_duplicate_ids() {
let nodes = [
Node {
id: Some("A1B2C3D4"),
number: 1,
filename: "one.md",
title: "One",
},
Node {
id: Some("B1B2C3D4"),
number: 2,
filename: "two.md",
title: "Two",
},
];
let index = AliasIndex::build(&nodes);
assert_eq!(
index.resolve_number(1).unwrap(),
IssueRef::id("A1B2C3D4").unwrap()
);
assert!(matches!(
index.resolve_number(9),
Err(AliasError::Missing(_))
));
let ambiguous = [
nodes[0].id.map(|_| "A1B2C3D4"),
nodes[1].id.map(|_| "B1B2C3D4"),
]
.into_iter()
.enumerate()
.map(|(i, id)| Node {
id,
number: 7,
filename: if i == 0 { "a.md" } else { "b.md" },
title: if i == 0 { "A" } else { "B" },
})
.collect::<Vec<_>>();
let index = AliasIndex::build(&ambiguous);
assert!(matches!(
index.resolve_number(7),
Err(AliasError::Ambiguous { .. })
));
let duplicates = [
Node {
id: Some("A1B2C3D4"),
number: 1,
filename: "a.md",
title: "A",
},
Node {
id: Some("A1B2C3D4"),
number: 2,
filename: "b.md",
title: "B",
},
];
let index = AliasIndex::build(&duplicates);
assert_eq!(index.duplicate_ids().get("A1B2C3D4"), Some(&vec![1, 2]));
assert_eq!(
index.duplicate_id_files().get("A1B2C3D4"),
Some(&vec!["a.md".to_string(), "b.md".to_string()])
);
assert_eq!(
index
.duplicate_records()
.iter()
.map(|record| (record.number, record.filename.as_str()))
.collect::<Vec<_>>(),
vec![(1, "a.md"), (2, "b.md")]
);
assert!(matches!(
index.resolve(&IssueRef::id("A1B2C3D4").unwrap()),
Err(AliasError::DuplicateId { .. })
));
}
}