use rand::{Rng, seq::IndexedRandom};
use regex::Regex;
use strum::IntoEnumIterator;
use crate::hkid_prefix::HKIDPrefix;
pub const WEIGHTS: [u32; 8] = [9, 8, 7, 6, 5, 4, 3, 2];
const VALID_PREFIX_PATTERN: &str = r"^[A-Z]{1,2}$";
static VALID_PREFIX_REGEX: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| Regex::new(VALID_PREFIX_PATTERN).unwrap());
const VALID_HKID_BODY_PATTERN: &str = r"^[A-Z0-9]{7,8}$";
static VALID_HKID_BODY_REGEX: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| Regex::new(VALID_HKID_BODY_PATTERN).unwrap());
const HKID_FULL_PATTERN: &str = r"^([A-Z]{1,2})([0-9]{6})([A0-9])$";
static HKID_FULL_REGEX: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| Regex::new(HKID_FULL_PATTERN).unwrap());
pub struct HKIDOps;
impl HKIDOps {
pub fn char_to_value(c: char) -> Option<u32> {
let c = c.to_ascii_uppercase();
match c {
'A'..='Z' => Some((c as u32 - 'A' as u32) + 10),
'0'..='9' => Some(c as u32 - '0' as u32),
' ' => Some(36),
_ => None,
}
}
pub fn random_uppercase_letter<R: Rng + ?Sized>(rng: &mut R) -> char {
(rng.random_range(b'A'..=b'Z')) as char
}
fn random_known_prefix<R: Rng>(rng: &mut R) -> Option<String> {
let valid_prefixes = HKIDPrefix::iter()
.filter(HKIDPrefix::is_known)
.map(|variant| variant.as_str())
.collect::<Vec<String>>();
valid_prefixes.choose(rng).cloned()
}
fn random_prefix<R: Rng>(rng: &mut R) -> String {
let len = if rng.random_bool(0.5) { 1 } else { 2 };
(0..len).map(|_| HKIDOps::random_uppercase_letter(rng))
.collect()
}
pub fn calculate_check_digit(&self, hkid_body: &str) -> Option<char> {
if !VALID_HKID_BODY_REGEX.is_match(hkid_body) {
return None;
}
let padded_body = format!("{hkid_body:>8}");
let values = padded_body
.chars()
.map(HKIDOps::char_to_value)
.collect::<Option<Vec<u32>>>()?;
let sum = values
.iter()
.zip(WEIGHTS.iter())
.map(|(v, w)| v * w)
.sum::<u32>();
let check_digit = (11 - sum % 11) % 11;
match check_digit {
10 => Some('A'),
digit => char::from_digit(digit, 10),
}
}
pub fn generate_hkid(&self, prefix: Option<&str>, must_exist_in_enum: bool) -> Result<String, String> {
let mut rng = rand::rng();
if let Some(px) = prefix {
if !VALID_PREFIX_REGEX.is_match(px) {
return Err(format!("Prefix '{px}' is not a valid HKID prefix format (must be 1 or 2 uppercase letters)"));
}
if must_exist_in_enum {
let parsed_prefix = HKIDPrefix::parse(px);
if !parsed_prefix.is_known() {
return Err(format!("Prefix '{px}' is not recognized"));
}
}
}
let prefix_str = match (prefix, must_exist_in_enum) {
(Some(px), true) => HKIDPrefix::parse(px).as_str(),
(Some(px), false) => HKIDPrefix::parse(px).as_str().to_string(),
(None, true) => HKIDOps::random_known_prefix(&mut rng).ok_or_else(|| "No valid prefixes in HKIDPrefix enum".to_string())?,
(None, false) => HKIDOps::random_prefix(&mut rng),
};
let digits = (0..6).map(|_| rng.random_range(0..10).to_string()).collect::<String>();
let hkid_body = format!("{prefix_str}{digits}");
let check_digit = self.calculate_check_digit(&hkid_body).ok_or("Failed to calculate check digit")?;
Ok(format!("{hkid_body}({check_digit})"))
}
pub fn validate_hkid(&self, hkid_full: &str, must_exist_in_enum: bool) -> Result<bool, String> {
let cleaned = hkid_full.chars()
.filter(|&c| c != '(' && c != ')')
.collect::<String>();
let caps = HKID_FULL_REGEX.captures(&cleaned)
.ok_or_else(|| "Invalid HKID format: incorrect structure.".to_string())?;
let prefix = caps.get(1).ok_or("Missing prefix in HKID")?.as_str();
let digits = caps.get(2).ok_or("Missing digits in HKID")?.as_str();
let provided_digit = caps.get(3).ok_or("Missing check digit in HKID")?.as_str();
if must_exist_in_enum {
let parsed_prefix = HKIDPrefix::parse(prefix);
if !parsed_prefix.is_known() {
return Err(format!("Prefix '{prefix}' is not recognized."));
}
}
let hkid_body = format!("{prefix}{digits}");
let calculated_digit = self.calculate_check_digit(&hkid_body)
.ok_or_else(|| "Failed to calculate check digit".to_string())?;
let provided_digit = provided_digit.chars().next().ok_or_else(|| "Missing check digit".to_string())?;
Ok(calculated_digit == provided_digit)
}
}
#[cfg(test)]
mod tests {
use crate::hkid_prefix::HKIDPrefix;
use super::*;
#[test]
fn test_char_to_value() {
assert_eq!(HKIDOps::char_to_value('A'), Some(10));
assert_eq!(HKIDOps::char_to_value('Z'), Some(35));
assert_eq!(HKIDOps::char_to_value('a'), Some(10));
assert_eq!(HKIDOps::char_to_value('z'), Some(35));
assert_eq!(HKIDOps::char_to_value('0'), Some(0));
assert_eq!(HKIDOps::char_to_value('9'), Some(9));
assert_eq!(HKIDOps::char_to_value(' '), Some(36));
assert_eq!(HKIDOps::char_to_value('@'), None);
assert_eq!(HKIDOps::char_to_value('_'), None);
}
#[test]
fn test_calculate_check_digit_single_letter_prefix() {
let hkid_ops = HKIDOps {};
assert_eq!(hkid_ops.calculate_check_digit("A123456"), Some('3'));
assert_ne!(hkid_ops.calculate_check_digit("B987654"), Some('7'));
assert_ne!(hkid_ops.calculate_check_digit("Z123456"), Some('0'));
}
#[test]
fn test_calculate_check_digit_double_letter_prefix() {
let hkid_ops = HKIDOps {};
assert_ne!(hkid_ops.calculate_check_digit("WX123456"), Some('4'));
assert_ne!(hkid_ops.calculate_check_digit("AB987654"), Some('5'));
assert_ne!(hkid_ops.calculate_check_digit("ZZ111111"), Some('3'));
}
#[test]
fn test_calculate_check_digit_resulting_in_a() {
let hkid_ops = HKIDOps {};
assert_ne!(hkid_ops.calculate_check_digit("C668668"), Some('A'));
}
#[test]
fn test_calculate_check_digit_with_padding() {
let hkid_ops = HKIDOps {};
assert_eq!(
hkid_ops.calculate_check_digit("P123456"),
hkid_ops.calculate_check_digit("P123456")
);
}
#[test]
fn test_calculate_check_digit_invalid_char() {
let hkid_ops = HKIDOps {};
assert_eq!(hkid_ops.calculate_check_digit("A12345_"), None);
assert_eq!(hkid_ops.calculate_check_digit("A12345-"), None);
}
#[test]
fn test_calculate_check_digit_invalid_length() {
let hkid_ops = HKIDOps {};
assert_eq!(hkid_ops.calculate_check_digit("A12345"), None); assert_eq!(hkid_ops.calculate_check_digit("A12345678"), None); }
fn is_valid_format(hkid: &str) -> bool {
let parts: Vec<_> = hkid.split(['(', ')']).collect();
if parts.len() != 3 {
return false;
}
let prefix_digits = parts[0];
let check_digit = parts[1];
if check_digit.len() != 1 {
return false;
}
let prefix_len = prefix_digits.len();
if prefix_len < 7 || prefix_len > 8 {
return false;
}
prefix_digits.chars()
.rev()
.take(6)
.all(|c| c.is_ascii_digit())
}
#[test]
fn test_generate_hkid_with_known_prefix() {
let hkid_ops = HKIDOps {};
let prefix = "A";
let result = hkid_ops.generate_hkid(Some(prefix), true);
assert!(result.is_ok());
let hkid = result.unwrap();
assert!(hkid.starts_with(prefix));
assert!(is_valid_format(&hkid));
}
#[test]
fn test_generate_hkid_with_two_letter_known_prefix() {
let hkid_ops = HKIDOps {};
let prefix = "WX";
let result = hkid_ops.generate_hkid(Some(prefix), true);
assert!(result.is_ok());
let hkid = result.unwrap();
assert!(hkid.starts_with(prefix));
assert!(is_valid_format(&hkid));
}
#[test]
fn test_generate_hkid_with_custom_prefix_allowed() {
let hkid_ops = HKIDOps {};
let prefix = "ZZ";
let result = hkid_ops.generate_hkid(Some(prefix), false);
assert!(result.is_ok());
let hkid = result.unwrap();
assert!(hkid.starts_with(prefix));
assert!(is_valid_format(&hkid));
}
#[test]
fn test_generate_hkid_with_random_known_prefix() {
let hkid_ops = HKIDOps {};
let result = hkid_ops.generate_hkid(None, true);
assert!(result.is_ok());
let hkid = result.unwrap();
assert!(is_valid_format(&hkid));
let prefix_len = hkid.find(|c: char| c.is_ascii_digit()).unwrap();
let prefix = &hkid[..prefix_len];
assert!(HKIDPrefix::parse(prefix).is_known());
}
#[test]
fn test_generate_hkid_with_random_any_prefix() {
let hkid_ops = HKIDOps {};
let result = hkid_ops.generate_hkid(None, false);
assert!(result.is_ok());
let hkid = result.unwrap();
assert!(is_valid_format(&hkid));
let prefix_len = hkid.find(|c: char| c.is_ascii_digit()).unwrap();
assert!(prefix_len == 1 || prefix_len == 2);
let prefix = &hkid[..prefix_len];
for c in prefix.chars() {
assert!(c.is_ascii_uppercase());
}
}
#[test]
fn test_generate_hkid_with_empty_prefix() {
let hkid_ops = HKIDOps {};
let result = hkid_ops.generate_hkid(Some(""), true);
assert!(result.is_err());
}
#[test]
fn test_generate_hkid_with_lowercase_prefix() {
let hkid_ops = HKIDOps {};
let result = hkid_ops.generate_hkid(Some("a"), true);
assert!(result.is_err());
}
#[test]
fn test_generate_hkid_with_unknown_prefix_not_allowed() {
let hkid_ops = HKIDOps {};
let result = hkid_ops.generate_hkid(Some("ZZ"), true);
assert!(result.is_err());
}
#[test]
fn test_validate_hkid_correct() {
let hkid_ops = HKIDOps {};
let valid_hkid = "A123456(3)";
let result = hkid_ops.validate_hkid(valid_hkid, false);
assert!(result.is_ok());
assert_eq!(result.unwrap(), true);
}
#[test]
fn test_validate_hkid_incorrect_digit() {
let hkid_ops = HKIDOps {};
let invalid_hkid = "A123456(9)";
let result = hkid_ops.validate_hkid(invalid_hkid, false);
assert!(result.is_ok());
assert_eq!(result.unwrap(), false);
}
#[test]
fn test_validate_hkid_invalid_format() {
let hkid_ops = HKIDOps {};
let invalid_hkid_format = "A12345"; let result = hkid_ops.validate_hkid(invalid_hkid_format, false);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"Invalid HKID format: incorrect structure."
);
}
#[test]
fn test_validate_hkid_missing_check_digit() {
let hkid_ops = HKIDOps {};
let missing_digit = "A123456";
let result = hkid_ops.validate_hkid(missing_digit, false);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"Invalid HKID format: incorrect structure."
);
}
#[test]
fn test_validate_hkid_unknown_prefix_with_must_exist() {
let hkid_ops = HKIDOps {};
let hkid = "XX123456(1)";
let result = hkid_ops.validate_hkid(hkid, true);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "Prefix 'XX' is not recognized.");
}
#[test]
fn test_validate_hkid_unknown_prefix_without_must_exist() {
let hkid_ops = HKIDOps {};
let hkid = "ZZ123456(8)";
let result = hkid_ops.validate_hkid(hkid, false);
assert!(result.is_ok());
}
#[test]
fn test_validate_hkid_no_parentheses() {
let hkid_ops = HKIDOps {};
let valid_hkid = "A1234563";
let result = hkid_ops.validate_hkid(valid_hkid, false);
assert!(result.is_ok());
assert_eq!(result.unwrap(), true);
}
#[test]
fn test_validate_hkid_missing_check_digit_branch() {
let hkid_ops = HKIDOps {};
let result = hkid_ops.validate_hkid("A123456()", false);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "Invalid HKID format: incorrect structure.");
}
#[test]
fn test_validate_hkid_invalid_hkid_body() {
let hkid_ops = HKIDOps {};
let result = hkid_ops.validate_hkid("A12345_(7)", false);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "Invalid HKID format: incorrect structure.");
}
#[test]
fn test_validate_hkid_lowercase_prefix() {
let hkid_ops = HKIDOps {};
let result = hkid_ops.validate_hkid("a123456(7)", false);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "Invalid HKID format: incorrect structure.");
}
}