use anyhow::{Context, Result};
use base64::Engine;
use md5::{Digest, Md5};
use serde_json::Value;
use sha1::Sha1;
use sha2::{Sha224, Sha256, Sha384, Sha512};
const BASE62_CHARS: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum HashAlgorithm {
Md5,
Sha1,
Sha224,
Sha256,
Sha384,
Sha512,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DigestType {
ContentHash,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Encoding {
Base64,
Base64Url,
Base62,
Hex,
}
fn encode_base62(bytes: &[u8]) -> String {
if bytes.is_empty() {
return String::new();
}
let mut result = Vec::new();
let mut num = bytes.to_vec();
while !num.iter().all(|&b| b == 0) {
let mut remainder = 0u32;
for byte in &mut num {
let value = (remainder << 8) | (*byte as u32);
*byte = (value / 62) as u8;
remainder = value % 62;
}
result.push(BASE62_CHARS[remainder as usize]);
}
for &byte in bytes {
if byte == 0 {
result.push(BASE62_CHARS[0]);
} else {
break;
}
}
result.reverse();
String::from_utf8(result).unwrap_or_default()
}
pub fn generate_id(
pattern: &str,
default_message: Option<&str>,
description: &Option<Value>,
_file_path: Option<&str>,
) -> Result<String> {
IdGenerator::new(pattern)?.generate(default_message, description)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IdGenerator {
hash_algorithm: HashAlgorithm,
digest_type: DigestType,
encoding: Encoding,
length: usize,
}
impl IdGenerator {
pub fn new(pattern: &str) -> Result<Self> {
if !pattern.starts_with('[') || !pattern.ends_with(']') {
anyhow::bail!("Invalid ID interpolation pattern: {}", pattern);
}
let inner = &pattern[1..pattern.len() - 1];
let parts: Vec<&str> = inner.split(':').collect();
if parts.is_empty() || parts.iter().any(|part| part.is_empty()) {
anyhow::bail!("Invalid ID interpolation pattern: {}", pattern);
}
let normalized_parts: Vec<String> = parts.iter().map(|part| part.to_lowercase()).collect();
let mut index = 0;
let hash_algorithm = if is_digest_type(&normalized_parts[index]) {
HashAlgorithm::Md5
} else {
let algorithm = parse_hash_algorithm(&normalized_parts[index])?;
index += 1;
algorithm
};
if index >= normalized_parts.len() {
anyhow::bail!(
"Invalid ID interpolation pattern format: {}. Expected [hash:digest:encoding:length]",
pattern
);
}
let digest_type = match normalized_parts[index].as_str() {
"contenthash" | "hash" => DigestType::ContentHash,
_ => anyhow::bail!("Unsupported digest type: {}", parts[index]),
};
index += 1;
let encoding = if index < normalized_parts.len()
&& !normalized_parts[index]
.chars()
.all(|ch| ch.is_ascii_digit())
{
let encoding = parse_encoding(&normalized_parts[index], parts[index])?;
index += 1;
encoding
} else {
Encoding::Hex
};
if index >= normalized_parts.len() {
anyhow::bail!(
"Invalid ID interpolation pattern format: {}. Expected [hash:digest:encoding:length]",
pattern
);
}
let length: usize = parts[index]
.parse()
.context("Invalid length in ID interpolation pattern")?;
if length == 0 {
anyhow::bail!(
"Invalid length in ID interpolation pattern: {}",
parts[index]
);
}
index += 1;
if index != normalized_parts.len() {
anyhow::bail!("Invalid ID interpolation pattern: {}", pattern);
}
Ok(Self {
hash_algorithm,
digest_type,
encoding,
length,
})
}
pub fn generate(
&self,
default_message: Option<&str>,
description: &Option<Value>,
) -> Result<String> {
let content = hash_content(default_message, description);
let digest = match (self.hash_algorithm, self.digest_type) {
(HashAlgorithm::Md5, DigestType::ContentHash) => hash_with::<Md5>(&content),
(HashAlgorithm::Sha1, DigestType::ContentHash) => hash_with::<Sha1>(&content),
(HashAlgorithm::Sha224, DigestType::ContentHash) => hash_with::<Sha224>(&content),
(HashAlgorithm::Sha256, DigestType::ContentHash) => hash_with::<Sha256>(&content),
(HashAlgorithm::Sha384, DigestType::ContentHash) => hash_with::<Sha384>(&content),
(HashAlgorithm::Sha512, DigestType::ContentHash) => hash_with::<Sha512>(&content),
};
Ok(encode_digest(&digest, self.encoding)
.chars()
.take(self.length)
.collect())
}
}
fn is_digest_type(part: &str) -> bool {
matches!(part, "contenthash" | "hash")
}
fn parse_hash_algorithm(part: &str) -> Result<HashAlgorithm> {
match part {
"md5" => Ok(HashAlgorithm::Md5),
"sha1" => Ok(HashAlgorithm::Sha1),
"sha224" => Ok(HashAlgorithm::Sha224),
"sha256" => Ok(HashAlgorithm::Sha256),
"sha384" => Ok(HashAlgorithm::Sha384),
"sha512" => Ok(HashAlgorithm::Sha512),
_ => anyhow::bail!("Unsupported hash algorithm: {}", part),
}
}
fn parse_encoding(normalized: &str, original: &str) -> Result<Encoding> {
match normalized {
"base64" => Ok(Encoding::Base64),
"base64url" => Ok(Encoding::Base64Url),
"base62" => Ok(Encoding::Base62),
"hex" => Ok(Encoding::Hex),
_ => anyhow::bail!("Unsupported encoding: {}", original),
}
}
fn hash_content(default_message: Option<&str>, description: &Option<Value>) -> Vec<u8> {
let mut content = Vec::new();
if let Some(msg) = default_message {
content.extend_from_slice(msg.as_bytes());
}
if let Some(desc) = description {
content.push(b'#');
match desc {
Value::String(s) => content.extend_from_slice(s.as_bytes()),
_ => content.extend_from_slice(desc.to_string().as_bytes()),
}
}
content
}
fn hash_with<D: Digest>(content: &[u8]) -> Vec<u8> {
let mut hasher = D::new();
hasher.update(content);
hasher.finalize().to_vec()
}
fn encode_digest(digest: &[u8], encoding: Encoding) -> String {
match encoding {
Encoding::Base64 => {
base64::engine::general_purpose::STANDARD.encode(digest)
}
Encoding::Base64Url => {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
}
Encoding::Base62 => encode_base62(digest),
Encoding::Hex => hex::encode(digest),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_id_sha512_base64() {
let id = generate_id(
"[sha512:contenthash:base64:6]",
Some("Hello World"),
&None,
None,
)
.unwrap();
assert_eq!(id, "LHT9F+");
}
#[test]
fn test_generate_id_sha512_base64url() {
let id = generate_id(
"[sha512:contenthash:base64url:6]",
Some("Hello World"),
&None,
None,
)
.unwrap();
assert_eq!(id, "LHT9F-");
}
#[test]
fn test_generate_id_with_description() {
let desc = serde_json::Value::String("A greeting".to_string());
let id = generate_id(
"[sha512:contenthash:base64:8]",
Some("Hello"),
&Some(desc),
None,
)
.unwrap();
assert_eq!(id, "tYLiH0T9");
}
#[test]
fn test_generate_id_hex() {
let id = generate_id("[sha512:contenthash:hex:10]", Some("Test"), &None, None).unwrap();
assert_eq!(id, "c6ee9e33cf");
}
#[test]
fn test_generate_id_legacy_contenthash_shorthand() {
let id = generate_id("[contenthash:5]", Some("Hello World"), &None, None).unwrap();
assert_eq!(id, "b10a8");
}
#[test]
fn test_generate_id_hash_alias_shorthand() {
let id = generate_id("[hash:5]", Some("Hello World"), &None, None).unwrap();
assert_eq!(id, "b10a8");
}
#[test]
fn test_generate_id_sha1() {
let id = generate_id(
"[sha1:contenthash:base64:6]",
Some("Hello World"),
&None,
None,
)
.unwrap();
assert_eq!(id, "Ck1VqN");
}
#[test]
fn test_generate_id_sha256() {
let id = generate_id(
"[sha256:contenthash:hex:5]",
Some("Hello World"),
&None,
None,
)
.unwrap();
assert_eq!(id, "a591a");
}
#[test]
fn test_generate_id_sha224_and_sha384() {
let sha224 = generate_id("[sha224:contenthash:hex:10]", Some("Test"), &None, None).unwrap();
let sha384 = generate_id("[sha384:contenthash:hex:10]", Some("Test"), &None, None).unwrap();
assert_eq!(sha224, "3606346815");
assert_eq!(sha384, "7b8f465407");
}
#[test]
fn test_generate_id_hash_digest_alias() {
let id = generate_id("[sha256:hash:hex:5]", Some("Hello World"), &None, None).unwrap();
assert_eq!(id, "a591a");
}
#[test]
fn test_generate_id_invalid_pattern() {
let result = generate_id("invalid", Some("Test"), &None, None);
assert!(result.is_err());
}
#[test]
fn test_generate_id_different_lengths() {
for length in [4, 6, 8, 10, 16, 32] {
let pattern = format!("[sha512:contenthash:base64:{}]", length);
let id = generate_id(&pattern, Some("Test message"), &None, None).unwrap();
assert_eq!(id.len(), length, "ID should be {} characters", length);
}
}
#[test]
fn test_generate_id_deterministic() {
let id1 = generate_id(
"[sha512:contenthash:base64:10]",
Some("Hello World"),
&None,
None,
)
.unwrap();
let id2 = generate_id(
"[sha512:contenthash:base64:10]",
Some("Hello World"),
&None,
None,
)
.unwrap();
assert_eq!(id1, "LHT9F+2v2A");
assert_eq!(id2, "LHT9F+2v2A");
assert_eq!(id1, id2, "Same input should produce same ID");
}
#[test]
fn test_generate_id_different_messages() {
let id1 = generate_id(
"[sha512:contenthash:base64:10]",
Some("Message 1"),
&None,
None,
)
.unwrap();
let id2 = generate_id(
"[sha512:contenthash:base64:10]",
Some("Message 2"),
&None,
None,
)
.unwrap();
assert_eq!(id1, "ePueQ5h1ce");
assert_eq!(id2, "fTO7rwuRCr");
assert_ne!(id1, id2, "Different messages should produce different IDs");
}
#[test]
fn test_generate_id_with_description_affects_hash() {
let desc = serde_json::Value::String("Description".to_string());
let id1 =
generate_id("[sha512:contenthash:base64:10]", Some("Hello"), &None, None).unwrap();
let id2 = generate_id(
"[sha512:contenthash:base64:10]",
Some("Hello"),
&Some(desc),
None,
)
.unwrap();
assert_eq!(id1, "NhX4DJ0pPt");
assert_eq!(id2, "WgDsrbylG9");
assert_ne!(
id1, id2,
"Adding description should change the generated ID"
);
}
#[test]
fn test_generate_id_hex_vs_base64() {
let id_hex = generate_id("[sha512:contenthash:hex:10]", Some("Test"), &None, None).unwrap();
let id_base64 =
generate_id("[sha512:contenthash:base64:10]", Some("Test"), &None, None).unwrap();
assert_eq!(id_hex, "c6ee9e33cf");
assert_eq!(id_base64, "xu6eM89cZx");
}
#[test]
fn test_generate_id_base64_vs_base64url() {
let id_base64 = generate_id(
"[sha512:contenthash:base64:10]",
Some("Hello World"),
&None,
None,
)
.unwrap();
let id_base64url = generate_id(
"[sha512:contenthash:base64url:10]",
Some("Hello World"),
&None,
None,
)
.unwrap();
assert_eq!(id_base64, "LHT9F+2v2A");
assert_eq!(id_base64url, "LHT9F-2v2A");
}
#[test]
fn test_generate_id_base62() {
let id = generate_id(
"[sha512:contenthash:base62:10]",
Some("Test message"),
&None,
None,
)
.unwrap();
assert_eq!(id, "Gm8I0LBX6B");
}
#[test]
fn test_generate_id_base62_deterministic() {
let id1 = generate_id(
"[sha512:contenthash:base62:10]",
Some("Hello World"),
&None,
None,
)
.unwrap();
let id2 = generate_id(
"[sha512:contenthash:base62:10]",
Some("Hello World"),
&None,
None,
)
.unwrap();
assert_eq!(id1, "AJxnki7plR");
assert_eq!(id2, "AJxnki7plR");
}
#[test]
fn test_generate_id_base62_different_from_base64() {
let id_base62 =
generate_id("[sha512:contenthash:base62:10]", Some("Test"), &None, None).unwrap();
let id_base64 =
generate_id("[sha512:contenthash:base64:10]", Some("Test"), &None, None).unwrap();
assert_eq!(id_base62, "kBedeOfsNe");
assert_eq!(id_base64, "xu6eM89cZx");
}
#[test]
fn test_generate_id_base62_various_lengths() {
for length in [4, 6, 8, 10, 16, 32] {
let pattern = format!("[sha512:contenthash:base62:{}]", length);
let id = generate_id(&pattern, Some("Test message for base62"), &None, None).unwrap();
assert_eq!(id.len(), length);
}
}
#[test]
fn test_generate_id_invalid_encoding() {
let result = generate_id("[sha512:contenthash:base32:10]", Some("Test"), &None, None);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Unsupported encoding")
);
}
#[test]
fn test_generate_id_invalid_hash_algorithm() {
let result = generate_id("[sha3:contenthash:base64:10]", Some("Test"), &None, None);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Unsupported hash algorithm")
);
}
#[test]
fn test_generate_id_invalid_format() {
let result = generate_id("[sha512:contenthash]", Some("Test"), &None, None);
assert!(result.is_err());
let result = generate_id("[sha512:contenthash:base64:abc]", Some("Test"), &None, None);
assert!(result.is_err());
let result = generate_id("sha512:contenthash:base64:10", Some("Test"), &None, None);
assert!(result.is_err());
}
#[test]
fn test_generate_id_matches_typescript_cli() {
let desc = serde_json::Value::String("Test component message".to_string());
let id = generate_id(
"[sha512:contenthash:base64:6]",
Some("This is a test message."),
&Some(desc),
None,
)
.unwrap();
assert_eq!(id, "rFvuOJ", "Hash should match TypeScript CLI output");
}
}