use chrono::{DateTime, Utc};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HashEncoding {
Base36,
Hex,
}
const BASE36_ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
fn encode_hex(data: &[u8], length: usize) -> String {
let hex_full = data
.iter()
.map(|b| format!("{:02x}", b))
.collect::<String>();
if hex_full.len() >= length {
hex_full[..length].to_string()
} else {
format!("{:0<width$}", hex_full, width = length)
}
}
fn encode_base36(data: &[u8], length: usize) -> String {
use num_bigint::BigUint;
use num_traits::Zero;
let mut num = BigUint::from_bytes_be(data);
let zero = BigUint::zero();
if num == zero {
return "0".repeat(length);
}
let base = BigUint::from(36u32);
let mut chars = Vec::new();
while num > zero {
let remainder = &num % &base;
num /= &base;
let digit_idx = if let Some(digits) = remainder.to_u32_digits().first() {
*digits as usize
} else {
0
};
chars.push(BASE36_ALPHABET[digit_idx]);
}
chars.reverse();
let mut result = String::from_utf8(chars).unwrap_or_else(|_| String::from("0"));
if result.len() < length {
result = "0".repeat(length - result.len()) + &result;
}
if result.len() > length {
result = result[result.len() - length..].to_string();
}
result
}
pub fn generate_hash_id_with_collision_check<F>(
prefix: &str,
title: &str,
description: &str,
timestamp: DateTime<Utc>,
estimated_db_size: usize,
encoding: HashEncoding,
mut collision_check: F,
) -> anyhow::Result<String>
where
F: FnMut(&str) -> bool,
{
let creator = "user";
let initial_length = match encoding {
HashEncoding::Base36 => {
if estimated_db_size < 10 {
3
} else if estimated_db_size < 100 {
4
} else if estimated_db_size < 1000 {
5
} else if estimated_db_size < 10000 {
6
} else if estimated_db_size < 100000 {
7
} else {
8
}
}
HashEncoding::Hex => {
if estimated_db_size < 100 {
4
} else if estimated_db_size < 1000 {
5
} else if estimated_db_size < 10000 {
6
} else if estimated_db_size < 100000 {
7
} else {
8
}
}
};
for length in initial_length..=8 {
for nonce in 0..10 {
let candidate = generate_hash_id(
prefix,
title,
description,
creator,
timestamp,
length,
nonce,
encoding,
);
if !collision_check(&candidate) {
return Ok(candidate);
}
}
}
anyhow::bail!(
"Failed to generate unique hash ID after trying all lengths and nonces (database has ~{} issues)",
estimated_db_size
)
}
#[allow(clippy::too_many_arguments)]
pub fn generate_hash_id(
prefix: &str,
title: &str,
description: &str,
creator: &str,
timestamp: DateTime<Utc>,
length: usize,
nonce: u32,
encoding: HashEncoding,
) -> String {
let content = format!(
"{}|{}|{}|{}|{}",
title,
description,
creator,
timestamp.timestamp_nanos_opt().unwrap_or(0),
nonce
);
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
let hash_result = hasher.finalize();
let short_hash = match encoding {
HashEncoding::Base36 => {
let num_bytes = match length {
3 => 2, 4 => 3, 5 => 4, 6 => 4, 7 => 5, 8 => 5, _ => 3, };
encode_base36(&hash_result[..num_bytes], length)
}
HashEncoding::Hex => {
let num_bytes = length.div_ceil(2);
encode_hex(&hash_result[..num_bytes], length)
}
};
format!("{}-{}", prefix, short_hash)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn test_generate_hash_id_basic() {
let timestamp = Utc.with_ymd_and_hms(2025, 10, 31, 12, 0, 0).unwrap();
let id = generate_hash_id(
"test",
"First issue",
"Test description",
"user",
timestamp,
4,
0,
HashEncoding::Base36,
);
assert!(id.starts_with("test-"));
assert_eq!(id.len(), "test-".len() + 4);
let hash_part = &id["test-".len()..];
assert!(hash_part
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()));
}
#[test]
fn test_generate_hash_id_deterministic() {
let timestamp = Utc.with_ymd_and_hms(2025, 10, 31, 12, 0, 0).unwrap();
let id1 = generate_hash_id(
"test",
"First issue",
"Test description",
"user",
timestamp,
4,
0,
HashEncoding::Base36,
);
let id2 = generate_hash_id(
"test",
"First issue",
"Test description",
"user",
timestamp,
4,
0,
HashEncoding::Base36,
);
assert_eq!(id1, id2);
}
#[test]
fn test_generate_hash_id_different_nonce() {
let timestamp = Utc.with_ymd_and_hms(2025, 10, 31, 12, 0, 0).unwrap();
let id1 = generate_hash_id(
"test",
"First issue",
"Test description",
"user",
timestamp,
4,
0,
HashEncoding::Base36,
);
let id2 = generate_hash_id(
"test",
"First issue",
"Test description",
"user",
timestamp,
4,
1,
HashEncoding::Base36,
);
assert_ne!(id1, id2);
}
#[test]
fn test_generate_hash_id_different_lengths() {
let timestamp = Utc.with_ymd_and_hms(2025, 10, 31, 12, 0, 0).unwrap();
for length in 3..=8 {
let id = generate_hash_id(
"test",
"First issue",
"Test description",
"user",
timestamp,
length,
0,
HashEncoding::Base36,
);
assert!(id.starts_with("test-"));
assert_eq!(id.len(), "test-".len() + length);
let hash_part = &id["test-".len()..];
assert!(hash_part
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()));
}
}
#[test]
fn test_generate_hash_id_different_inputs() {
let timestamp = Utc.with_ymd_and_hms(2025, 10, 31, 12, 0, 0).unwrap();
let id1 = generate_hash_id(
"test",
"First issue",
"Test description",
"user",
timestamp,
4,
0,
HashEncoding::Base36,
);
let id2 = generate_hash_id(
"test",
"Second issue", "Test description",
"user",
timestamp,
4,
0,
HashEncoding::Base36,
);
assert_ne!(id1, id2);
}
#[test]
fn test_generate_hash_id_hex_encoding() {
let timestamp = Utc.with_ymd_and_hms(2025, 10, 31, 12, 0, 0).unwrap();
let id = generate_hash_id(
"test",
"First issue",
"Test description",
"user",
timestamp,
4,
0,
HashEncoding::Hex,
);
assert!(id.starts_with("test-"));
assert_eq!(id.len(), "test-".len() + 4);
let hash_part = &id["test-".len()..];
assert!(hash_part.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_generate_hash_id_encodings_differ() {
let timestamp = Utc.with_ymd_and_hms(2025, 10, 31, 12, 0, 0).unwrap();
let id_base36 = generate_hash_id(
"test",
"First issue",
"Test description",
"user",
timestamp,
4,
0,
HashEncoding::Base36,
);
let id_hex = generate_hash_id(
"test",
"First issue",
"Test description",
"user",
timestamp,
4,
0,
HashEncoding::Hex,
);
assert_ne!(id_base36, id_hex);
assert!(id_base36.starts_with("test-"));
assert!(id_hex.starts_with("test-"));
}
}