use sha2::{Digest, Sha256};
#[must_use]
pub fn normalise_message(msg: &str) -> String {
let mut out = String::with_capacity(msg.len());
let mut chars = msg.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '0' && chars.peek() == Some(&'x') {
chars.next();
while chars.peek().is_some_and(|c| c.is_ascii_hexdigit()) {
chars.next();
}
out.push('X');
continue;
}
if ch.is_ascii_digit() {
while chars
.peek()
.is_some_and(|c| c.is_ascii_digit() || *c == '.')
{
chars.next();
}
out.push('N');
continue;
}
if ch.is_ascii_lowercase() && "0123456789abcdef".contains(ch) {
let mut hex_buf = String::new();
hex_buf.push(ch);
let saved_start = out.len();
let _ = saved_start; while chars
.peek()
.is_some_and(|c| "0123456789abcdef".contains(*c))
{
hex_buf.push(chars.next().unwrap());
}
let all_hex = hex_buf.chars().all(|c| c.is_ascii_hexdigit());
if all_hex && hex_buf.len() >= 6 {
out.push('H');
} else {
out.push_str(&hex_buf);
}
continue;
}
out.push(ch);
}
let home_normalised = if let Ok(home) = std::env::var("HOME") {
out.replace(&home, "~")
} else {
out
};
let collapsed: String = home_normalised
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
collapsed
}
#[must_use]
pub fn compute_fingerprint(
crate_target: &str,
message: &str,
file: Option<&str>,
line: Option<u32>,
) -> String {
let normalised = normalise_message(message);
let location = match (file, line) {
(Some(f), Some(l)) => format!("{f}:{l}"),
(Some(f), None) => f.to_string(),
_ => "unknown".to_string(),
};
let input = format!("{crate_target}|{normalised}|{location}");
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
let result = hasher.finalize();
result.iter().fold(String::with_capacity(64), |mut acc, b| {
use std::fmt::Write as _;
let _ = write!(acc, "{b:02x}");
acc
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fingerprint_same_for_logically_identical_errors() {
let fp1 = compute_fingerprint(
"trusty_search::server",
"failed to bind to 127.0.0.1:8080",
Some("src/server.rs"),
Some(42),
);
let fp2 = compute_fingerprint(
"trusty_search::server",
"failed to bind to 127.0.0.1:9999",
Some("src/server.rs"),
Some(42),
);
assert_eq!(
fp1, fp2,
"port numbers differ but fingerprints should match"
);
}
#[test]
fn fingerprint_differs_for_different_errors() {
let fp1 = compute_fingerprint(
"trusty_search::server",
"failed to bind socket",
Some("src/server.rs"),
Some(42),
);
let fp2 = compute_fingerprint(
"trusty_search::indexer",
"failed to open index",
Some("src/indexer.rs"),
Some(10),
);
assert_ne!(
fp1, fp2,
"distinct errors must produce distinct fingerprints"
);
}
#[test]
fn fingerprint_differs_when_crate_differs() {
let fp1 = compute_fingerprint("crate_a", "same message", Some("f.rs"), Some(1));
let fp2 = compute_fingerprint("crate_b", "same message", Some("f.rs"), Some(1));
assert_ne!(fp1, fp2);
}
#[test]
fn normalise_strips_digits_and_hex() {
let msg = "error at line 42: memory address 0xdeadbeef";
let norm = normalise_message(msg);
assert!(!norm.contains("42"), "digits should be stripped: {norm}");
assert!(!norm.contains("deadbeef"), "hex should be stripped: {norm}");
assert!(!norm.contains("0x"), "0x prefix should be stripped: {norm}");
}
#[test]
fn normalise_strips_long_hex_token() {
let msg = "digest mismatch: a1b2c3d4e5f6 != expected";
let norm = normalise_message(msg);
assert!(
!norm.contains("a1b2c3d4e5f6"),
"long hex token should be stripped: {norm}"
);
}
#[test]
fn normalise_preserves_normal_words() {
let msg = "bad request from cafe";
let norm = normalise_message(msg);
assert!(
norm.contains("bad"),
"normal word 'bad' should be kept: {norm}"
);
assert!(
norm.contains("cafe"),
"normal word 'cafe' should be kept: {norm}"
);
}
#[test]
fn fingerprint_is_64_hex_chars() {
let fp = compute_fingerprint("crate", "message", None, None);
assert_eq!(fp.len(), 64);
assert!(
fp.chars().all(|c| c.is_ascii_hexdigit()),
"fingerprint must be lowercase hex"
);
}
#[test]
fn fingerprint_same_for_version_number_variation() {
let fp1 = compute_fingerprint(
"trusty_memory",
"incompatible schema version 3",
Some("src/store.rs"),
Some(100),
);
let fp2 = compute_fingerprint(
"trusty_memory",
"incompatible schema version 7",
Some("src/store.rs"),
Some(100),
);
assert_eq!(
fp1, fp2,
"version number variation should not change fingerprint"
);
}
}