#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum ValidationError {
#[error("score out of range: `{field}` must be within 0.0-1.0, got {value}")]
ScoreOutOfRange {
field: &'static str,
value: f64,
},
#[error("empty value: `{0}` must not be empty or whitespace-only")]
EmptyValue(&'static str),
}
pub fn stable_id(value: &str) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in value.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
pub fn truncate_utf8_safe(text: &str, max_chars: usize) -> String {
if text.chars().count() <= max_chars {
return text.to_string();
}
text.chars().take(max_chars).collect()
}
pub fn preview(text: &str, max_chars: usize, suffix: &str) -> String {
if text.chars().count() <= max_chars {
text.to_string()
} else {
let mut out = truncate_utf8_safe(text, max_chars);
out.push_str(suffix);
out
}
}
pub fn validate_score(field: &'static str, value: f64) -> Result<f64, ValidationError> {
if value.is_finite() && (0.0..=1.0).contains(&value) {
Ok(value)
} else {
Err(ValidationError::ScoreOutOfRange { field, value })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stable_id_is_deterministic_across_processes() {
assert_eq!(stable_id("rust"), stable_id("rust"));
assert_eq!(stable_id(""), 0xcbf2_9ce4_8422_2325);
assert_ne!(stable_id("rust"), stable_id("Rust"));
assert_ne!(stable_id("a"), stable_id("b"));
}
#[test]
fn stable_id_is_independent_of_runtime_counter_state() {
let a = stable_id("machine learning");
let _ = stable_id("noise-1");
let _ = stable_id("noise-2");
assert_eq!(a, stable_id("machine learning"));
}
#[test]
fn truncate_handles_multibyte_and_boundaries() {
assert_eq!(truncate_utf8_safe("hello", 4), "hell");
assert_eq!(truncate_utf8_safe("héllo", 3), "hél");
assert_eq!(truncate_utf8_safe("a🙂b", 2), "a🙂");
assert_eq!(truncate_utf8_safe("e\u{0301}x", 1), "e");
assert_eq!(truncate_utf8_safe("hi", 100), "hi");
assert_eq!(truncate_utf8_safe("hi", 0), "");
assert_eq!(truncate_utf8_safe("", 5), "");
}
#[test]
fn truncate_never_panics_on_arbitrary_unicode() {
let samples = [
"e\u{0301}\u{200d}🙂 emoji",
"\u{0}a\u{7f}b\u{10ffff}",
"日本語のテキスト",
"混合 mixed текст",
];
for s in samples {
for max in 0..=s.chars().count() + 2 {
let out = truncate_utf8_safe(s, max);
assert!(std::str::from_utf8(out.as_bytes()).is_ok());
assert!(out.chars().count() <= max);
}
}
}
#[test]
fn preview_appends_suffix_only_when_truncated() {
assert_eq!(preview("short", 10, "..."), "short");
assert_eq!(preview("héllo world", 5, "..."), "héllo...");
}
#[test]
fn score_validation_accepts_bounds_and_rejects_out_of_range() {
assert_eq!(validate_score("knowledge", 0.0).unwrap(), 0.0);
assert_eq!(validate_score("knowledge", 1.0).unwrap(), 1.0);
assert!(validate_score("knowledge", -0.1).is_err());
assert!(validate_score("knowledge", 1.5).is_err());
assert!(validate_score("knowledge", f64::NAN).is_err());
assert!(validate_score("knowledge", f64::INFINITY).is_err());
}
}