use anyhow::{Context, Result};
use base64::Engine;
use serde_json::Value;
use sha2::{Digest, Sha512};
const BASE62_CHARS: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum HashAlgorithm {
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.len() < 4 {
anyhow::bail!(
"Invalid ID interpolation pattern format: {}. Expected [hash:digest:encoding:length]",
pattern
);
}
let hash_algorithm = match parts[0] {
"sha512" => HashAlgorithm::Sha512,
_ => anyhow::bail!("Unsupported hash algorithm: {}", parts[0]),
};
let digest_type = match parts[1] {
"contenthash" => DigestType::ContentHash,
_ => anyhow::bail!("Unsupported digest type: {}", parts[1]),
};
let encoding = match parts[2] {
"base64" => Encoding::Base64,
"base64url" => Encoding::Base64Url,
"base62" => Encoding::Base62,
"hex" => Encoding::Hex,
_ => anyhow::bail!("Unsupported encoding: {}", parts[2]),
};
let length: usize = parts[3]
.parse()
.context("Invalid length in ID interpolation pattern")?;
Ok(Self {
hash_algorithm,
digest_type,
encoding,
length,
})
}
pub fn generate(
&self,
default_message: Option<&str>,
description: &Option<Value>,
) -> Result<String> {
let hash = match (self.hash_algorithm, self.digest_type) {
(HashAlgorithm::Sha512, DigestType::ContentHash) => {
let mut hasher = Sha512::new();
if let Some(msg) = default_message {
hasher.update(msg.as_bytes());
}
if let Some(desc) = description {
hasher.update(b"#");
match desc {
Value::String(s) => hasher.update(s.as_bytes()),
_ => hasher.update(desc.to_string().as_bytes()),
}
}
let result = hasher.finalize();
match self.encoding {
Encoding::Base64 => {
base64::engine::general_purpose::STANDARD.encode(&result)
}
Encoding::Base64Url => {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&result)
}
Encoding::Base62 => encode_base62(&result),
Encoding::Hex => hex::encode(result),
}
}
};
Ok(hash.chars().take(self.length).collect())
}
}
#[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_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("[md5: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");
}
}