use pith_digest::SplitMix64;
use pith_text::{canonicalize, jaccard_estimate, signature};
#[test]
fn canonicalize_normalizes_case_and_trailing_newline() {
assert_eq!(canonicalize("Hello WORLD"), "hello world\n");
assert_eq!(canonicalize("text\n"), "text\n");
assert_eq!(canonicalize("text\n\n\n"), "text\n");
assert_eq!(canonicalize("text\r\n"), "text\n");
assert_eq!(canonicalize("text \t \n"), "text\n");
assert_eq!(canonicalize(""), "\n");
assert_eq!(canonicalize(" \n"), "\n");
}
#[test]
fn canonicalize_is_idempotent() {
for input in ["Hello\n\n", "ÜNICODE text", "", "a b\tc\n"] {
let once = canonicalize(input);
assert_eq!(canonicalize(&once), once);
}
}
#[test]
fn canonicalize_nfc_equivalence() {
let decomposed = "Ti\u{00EA}\u{0301}ng Vi\u{00EA}\u{0323}t";
assert_eq!(canonicalize(decomposed), "ti\u{1EBF}ng vi\u{1EC7}t\n");
assert_eq!(canonicalize("CAFE\u{0301}"), "caf\u{00E9}\n");
}
#[test]
fn signature_determinism_and_canonical_invariance() {
let a = signature("The quick brown fox jumps over the lazy dog");
assert_eq!(a, signature("The quick brown fox jumps over the lazy dog"));
assert_eq!(a.len(), 128);
let nfd = pith_unicode::nfd("Tiếng Việt là một ngôn ngữ phổ biến");
let sig_nfc = signature("TIẾNG VIỆT LÀ MỘT NGÔN NGỮ PHỔ BIẾN\n\n\n");
assert_eq!(signature(&nfd), sig_nfc);
assert_eq!(signature("Hello World"), signature("hello world"));
assert_eq!(signature("a b c\n\n"), signature("a b c"));
}
#[test]
fn signature_golden_pin() {
let sig = signature("alpha beta gamma");
assert_eq!(sig[0], 0x5409_aadb_22bb_8479);
assert_eq!(sig[1], 0xb2e6_ff4e_debf_53c6);
let mut fold = 0xcbf2_9ce4_8422_2325u64;
for word in &sig {
for b in word.to_le_bytes() {
fold ^= u64::from(b);
fold = fold.wrapping_mul(0x0000_0100_0000_01b3);
}
}
assert_eq!(fold, 0x46f8_ab1a_50c6_1813);
}
#[test]
fn signature_short_documents() {
assert_eq!(signature(""), vec![u64::MAX; 128]);
assert_eq!(signature(" \n \t "), vec![u64::MAX; 128]);
let one = signature("alpha");
let two = signature("alpha beta");
assert_ne!(one, vec![u64::MAX; 128]);
assert_ne!(two, vec![u64::MAX; 128]);
assert_ne!(one, signature("beta"));
assert_ne!(one, two);
assert_ne!(two, signature("alpha beta gamma"));
}
#[test]
fn jaccard_estimate_bounds_and_edges() {
let a = signature("the same document twice over today");
let b = signature("the same document twice over today");
assert_eq!(jaccard_estimate(&a, &b), 1.0);
assert_eq!(jaccard_estimate(&a, &a[..64]), 1.0); assert_eq!(jaccard_estimate(&[], &[]), 1.0);
assert_eq!(jaccard_estimate(&[], &a), 1.0); let c = signature("completely unrelated words placed here instead");
let j = jaccard_estimate(&a, &c);
assert!(j < 0.2, "unrelated texts estimated {j}");
let d = signature("the same document twice over tomorrow");
let j2 = jaccard_estimate(&a, &d);
assert!(j2 > 0.5, "one-word edit estimated {j2}");
}
#[test]
fn signature_changes_predictably_under_mutation() {
let base = "the quick brown fox jumps over the lazy dog today";
let base_sig = signature(base);
let words: Vec<&str> = base.split_whitespace().collect();
let mut rng = SplitMix64::new(0x0BAD_5EED_0000_0001);
for _ in 0..100 {
let i = (rng.next_u64() as usize) % words.len();
let mut v: Vec<String> = words.iter().map(|w| w.to_string()).collect();
v[i] = format!("w{:016x}", rng.next_u64());
assert_ne!(signature(&v.join(" ")), base_sig, "substitution at {i}");
}
for i in 0..words.len() {
let mut v: Vec<&str> = words.clone();
v.remove(i);
assert_ne!(signature(&v.join(" ")), base_sig, "deletion at {i}");
}
for _ in 0..100 {
let i = (rng.next_u64() as usize) % (words.len() + 1);
let mut v: Vec<String> = words.iter().map(|w| w.to_string()).collect();
v.insert(i, format!("w{:016x}", rng.next_u64()));
assert_ne!(signature(&v.join(" ")), base_sig, "insertion at {i}");
}
assert_ne!(signature(&words.concat()), base_sig);
}
#[test]
fn arbitrary_bytes_and_prefixes_never_panic() {
let mut rng = SplitMix64::new(0xAB17_AB17_AB17_AB17);
let mut buf = vec![0u8; 512];
for round in 0..200 {
rng.fill_bytes(&mut buf);
let text = String::from_utf8_lossy(&buf[..32 + (round % 400)]);
let _ = signature(&text);
let _ = canonicalize(&text);
}
let doc = canonicalize("Xin chào thế giới, đây là văn bản tiếng Việt dài");
let mut prev = signature("");
for i in 0..=doc.len() {
let _ = signature(&String::from_utf8_lossy(&doc.as_bytes()[..i]));
}
let words: Vec<&str> = doc.split_whitespace().collect();
for n in 1..words.len() {
let cur = signature(&words[..n].join(" "));
assert_ne!(cur, prev, "adding word {n} must change the signature");
prev = cur;
}
}
type FingerprintFn = unsafe extern "C" fn(*const u8, usize, *mut *mut u8, *mut usize) -> i32;
type JaccardFn = unsafe extern "C" fn(*const u64, usize, *const u64, usize, *mut u64) -> i32;
type FreeFn = unsafe extern "C" fn(*mut u8, usize);
#[cfg(windows)]
const CDYLIB_FILE: &str = "pith_text.dll";
#[cfg(target_os = "macos")]
const CDYLIB_FILE: &str = "libpith_text.dylib";
#[cfg(all(unix, not(target_os = "macos")))]
const CDYLIB_FILE: &str = "libpith_text.so";
fn find_built_cdylib() -> std::path::PathBuf {
let mut candidates = Vec::new();
let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
if let Ok(dir) = std::env::var("CARGO_TARGET_DIR") {
candidates.push(std::path::PathBuf::from(dir));
}
candidates.push(manifest.join("target/llvm-cov-target"));
candidates.push(manifest.join("target"));
for base in candidates {
for dir in [base.join("debug/deps"), base.join("debug")] {
let lib = dir.join(CDYLIB_FILE);
if lib.is_file() {
return lib;
}
}
}
panic!("cdylib {CDYLIB_FILE} not built; run `cargo build` first");
}
unsafe fn drive_exports(fingerprint: FingerprintFn, jaccard: JaccardFn, free: FreeFn) {
let input = b"alpha beta gamma";
let mut out: *mut u8 = std::ptr::null_mut();
let mut out_len: usize = 0;
let status = unsafe { fingerprint(input.as_ptr(), input.len(), &mut out, &mut out_len) };
assert_eq!(status, 0, "pith_text_fingerprint failed");
let stream = unsafe { std::slice::from_raw_parts(out, out_len) };
assert_eq!(&stream[..8], &[0, 0, 0, 3, 0, 0, 0, 1]);
assert_eq!(stream.len(), 8 + 17 + 1024);
unsafe { free(out, out_len) };
let mut bits: u64 = 0;
let status = unsafe { jaccard(std::ptr::null(), 0, std::ptr::null(), 0, &mut bits) };
assert_eq!(status, 0, "pith_text_jaccard failed");
assert_eq!(bits, 0x3ff0_0000_0000_0000);
let bad: &[u8] = &[0xFF];
let status = unsafe { fingerprint(bad.as_ptr(), bad.len(), &mut out, &mut out_len) };
assert_eq!(status, -2, "non-UTF-8 must be rejected, never crash");
unsafe { free(std::ptr::null_mut(), 0) };
}
#[cfg(windows)]
#[test]
fn cdylib_exports_work_through_loadlibrary() {
use std::os::windows::ffi::OsStrExt;
#[link(name = "kernel32")]
unsafe extern "system" {
fn LoadLibraryW(name: *const u16) -> isize;
fn GetProcAddress(module: isize, name: *const u8) -> isize;
}
let lib = find_built_cdylib();
let wide: Vec<u16> = std::ffi::OsStr::new(&lib)
.encode_wide()
.chain(std::iter::once(0))
.collect();
let module = unsafe { LoadLibraryW(wide.as_ptr()) };
assert_ne!(module, 0, "LoadLibraryW({lib:?}) failed");
unsafe fn symbol<A>(module: isize, name: &[u8]) -> A {
let addr = unsafe { GetProcAddress(module, name.as_ptr()) };
assert_ne!(addr, 0, "GetProcAddress({name:?}) failed");
unsafe { std::mem::transmute_copy::<isize, A>(&addr) }
}
let fingerprint: FingerprintFn = unsafe { symbol(module, c"pith_text_fingerprint".to_bytes()) };
let jaccard: JaccardFn = unsafe { symbol(module, c"pith_text_jaccard".to_bytes()) };
let free: FreeFn = unsafe { symbol(module, c"pith_text_free".to_bytes()) };
unsafe { drive_exports(fingerprint, jaccard, free) };
}
#[cfg(unix)]
#[test]
fn cdylib_exports_work_through_dlopen() {
use std::ffi::{c_char, c_int, c_void};
unsafe extern "C" {
fn dlopen(filename: *const c_char, flags: c_int) -> *mut c_void;
fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
}
const RTLD_NOW: c_int = 2;
let path = find_built_cdylib();
let cpath = std::ffi::CString::new(path.to_str().expect("utf-8 path")).expect("nul-free");
let handle = unsafe { dlopen(cpath.as_ptr(), RTLD_NOW) };
assert!(!handle.is_null(), "dlopen({path:?}) failed");
unsafe fn symbol<F>(handle: *mut c_void, name: &[u8]) -> F {
let addr = unsafe { dlsym(handle, name.as_ptr() as *const c_char) };
assert!(!addr.is_null(), "dlsym({name:?}) failed");
unsafe { std::mem::transmute_copy::<*mut c_void, F>(&addr) }
}
let fingerprint: FingerprintFn = unsafe { symbol(handle, c"pith_text_fingerprint".to_bytes()) };
let jaccard: JaccardFn = unsafe { symbol(handle, c"pith_text_jaccard".to_bytes()) };
let free: FreeFn = unsafe { symbol(handle, c"pith_text_free".to_bytes()) };
unsafe { drive_exports(fingerprint, jaccard, free) };
}