use chrono::Utc;
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use thiserror::Error;
use tracing::{debug, warn};
const BASE36_CHARS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
const MAX_NONCE: u32 = 100;
#[derive(Debug, Error)]
pub enum IdGenerationError {
#[error("Unable to generate unique ID after {attempts} attempts")]
CollisionExhausted { attempts: u32 },
#[error("Base36 encoding failed: {0}")]
EncodingFailed(String),
#[error("Length must be greater than 0")]
InvalidLength,
}
#[derive(Debug, Clone)]
pub struct IdGeneratorConfig {
pub prefix: String,
pub database_size: usize,
}
pub struct IdGenerator {
config: IdGeneratorConfig,
existing_ids: HashSet<String>,
child_counters: std::collections::HashMap<String, u32>,
}
impl IdGenerator {
pub fn new(config: IdGeneratorConfig) -> Self {
Self {
config,
existing_ids: HashSet::new(),
child_counters: std::collections::HashMap::new(),
}
}
pub fn register_id(&mut self, id: String) {
self.existing_ids.insert(id);
}
pub fn database_size(&self) -> usize {
self.config.database_size
}
pub fn clear_state(&mut self) {
self.existing_ids.clear();
self.child_counters.clear();
}
pub fn generate(
&mut self,
title: &str,
description: &str,
creator: Option<&str>,
parent_id: Option<&str>,
) -> Result<String, IdGenerationError> {
if let Some(parent) = parent_id {
return self.generate_hierarchical_id(parent);
}
let id_length = self.adaptive_length();
for nonce in 0..MAX_NONCE {
let id = self.generate_hash_id(title, description, creator, nonce, id_length)?;
if !self.existing_ids.contains(&id) {
if nonce > 0 {
debug!(
nonce,
id_length, "Generated unique ID after {} collision retries", nonce
);
}
self.existing_ids.insert(id.clone());
return Ok(id);
}
}
if id_length < 6 {
warn!(
id_length,
max_nonce = MAX_NONCE,
"All nonces exhausted, increasing ID length to {}",
id_length + 1
);
let longer_id = self.generate_hash_id(title, description, creator, 0, id_length + 1)?;
self.existing_ids.insert(longer_id.clone());
return Ok(longer_id);
}
Err(IdGenerationError::CollisionExhausted {
attempts: MAX_NONCE,
})
}
fn generate_hierarchical_id(&mut self, parent_id: &str) -> Result<String, IdGenerationError> {
let counter = self
.child_counters
.entry(parent_id.to_string())
.or_insert(0);
*counter += 1;
let child_id = format!("{}.{}", parent_id, counter);
self.existing_ids.insert(child_id.clone());
Ok(child_id)
}
fn generate_hash_id(
&self,
title: &str,
description: &str,
creator: Option<&str>,
nonce: u32,
length: usize,
) -> Result<String, IdGenerationError> {
let timestamp = Utc::now().timestamp();
let content = format!(
"{}|{}|{}|{}|{}",
title,
description,
creator.unwrap_or(""),
timestamp,
nonce
);
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
let hash_bytes = hasher.finalize();
let hash_str = encode_base36(&hash_bytes[..8], length)?;
Ok(format!("{}-{}", self.config.prefix, hash_str))
}
fn adaptive_length(&self) -> usize {
match self.config.database_size {
0..=500 => 4,
501..=1500 => 5,
_ => 6,
}
}
}
fn encode_base36(bytes: &[u8], length: usize) -> Result<String, IdGenerationError> {
if length == 0 {
return Err(IdGenerationError::InvalidLength);
}
let mut num: u64 = 0;
for &byte in bytes {
num = num.wrapping_shl(8).wrapping_add(u64::from(byte));
}
let mut result = Vec::new();
let mut n = num;
while result.len() < length {
let remainder = (n % 36) as usize;
result.push(BASE36_CHARS[remainder]);
n /= 36;
}
result.reverse();
String::from_utf8(result)
.map_err(|e| IdGenerationError::EncodingFailed(format!("UTF-8 conversion failed: {}", e)))
}
pub fn validate_id(id: &str, prefix: &str) -> bool {
if !id.starts_with(&format!("{}-", prefix)) {
return false;
}
let after_prefix = &id[prefix.len() + 1..];
let parts: Vec<&str> = after_prefix.split('.').collect();
if parts.is_empty() {
return false;
}
let hash = parts[0];
if hash.len() < 4 || hash.len() > 6 {
return false;
}
if !hash.chars().all(|c| c.is_ascii_alphanumeric()) {
return false;
}
for part in &parts[1..] {
if part.parse::<u32>().is_err() {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[test]
fn test_base36_encoding() {
let bytes = &[0x12, 0x34, 0x56, 0x78];
let result = encode_base36(bytes, 4).unwrap();
assert_eq!(result.len(), 4);
assert!(result.chars().all(|c| c.is_ascii_alphanumeric()));
}
#[rstest]
#[case(100, 4)]
#[case(800, 5)]
#[case(2000, 6)]
fn test_adaptive_length(#[case] database_size: usize, #[case] expected_length: usize) {
let config = IdGeneratorConfig {
prefix: "test".to_string(),
database_size,
};
let generator = IdGenerator::new(config);
assert_eq!(generator.adaptive_length(), expected_length);
}
#[test]
fn test_id_generation() {
let config = IdGeneratorConfig {
prefix: "rivets".to_string(),
database_size: 100,
};
let mut generator = IdGenerator::new(config);
let id = generator
.generate("Test Title", "Test Description", Some("alice"), None)
.unwrap();
assert!(id.starts_with("rivets-"));
assert!(validate_id(&id, "rivets"));
}
#[test]
fn test_collision_handling() {
let config = IdGeneratorConfig {
prefix: "test".to_string(),
database_size: 100,
};
let mut generator = IdGenerator::new(config);
let id1 = generator
.generate("Same Title", "Same Description", Some("alice"), None)
.unwrap();
let id2 = generator
.generate("Same Title", "Same Description", Some("alice"), None)
.unwrap();
assert_ne!(id1, id2);
}
#[test]
fn test_hierarchical_ids() {
let config = IdGeneratorConfig {
prefix: "rivets".to_string(),
database_size: 100,
};
let mut generator = IdGenerator::new(config);
let parent_id = generator
.generate("Parent", "Parent issue", None, None)
.unwrap();
let child_id1 = generator
.generate("Child 1", "Child description", None, Some(&parent_id))
.unwrap();
let child_id2 = generator
.generate("Child 2", "Child description", None, Some(&parent_id))
.unwrap();
assert_eq!(child_id1, format!("{}.1", parent_id));
assert_eq!(child_id2, format!("{}.2", parent_id));
assert!(validate_id(&child_id1, "rivets"));
assert!(validate_id(&child_id2, "rivets"));
}
#[test]
fn test_nested_hierarchical_ids() {
let config = IdGeneratorConfig {
prefix: "rivets".to_string(),
database_size: 100,
};
let mut generator = IdGenerator::new(config);
let parent_id = generator.generate("Parent", "P", None, None).unwrap();
let child_id = generator
.generate("Child", "C", None, Some(&parent_id))
.unwrap();
let grandchild_id = generator
.generate("Grandchild", "G", None, Some(&child_id))
.unwrap();
assert_eq!(grandchild_id, format!("{}.1", child_id));
assert!(validate_id(&grandchild_id, "rivets"));
}
#[test]
fn test_id_validation() {
assert!(validate_id("rivets-a3f8", "rivets"));
assert!(validate_id("rivets-abc123", "rivets"));
assert!(validate_id("rivets-a3f8.1", "rivets"));
assert!(validate_id("rivets-a3f8.1.2", "rivets"));
assert!(!validate_id("invalid", "rivets"));
assert!(!validate_id("rivets-", "rivets"));
assert!(!validate_id("rivets-ab", "rivets")); assert!(!validate_id("rivets-abcdefg", "rivets")); assert!(!validate_id("rivets-a3f8.x", "rivets")); assert!(!validate_id("wrong-a3f8", "rivets")); }
#[test]
fn test_register_existing_ids() {
let config = IdGeneratorConfig {
prefix: "test".to_string(),
database_size: 100,
};
let mut generator = IdGenerator::new(config);
generator.register_id("test-a3f8".to_string());
generator.register_id("test-b4g9".to_string());
let new_id = generator.generate("New", "Issue", None, None).unwrap();
assert_ne!(new_id, "test-a3f8");
assert_ne!(new_id, "test-b4g9");
}
}