use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileSignature {
pub size: u64,
pub mtime_nanos: u64,
pub ctime_nanos: u64,
}
impl FileSignature {
#[must_use]
pub const fn new(size: u64, mtime_nanos: u64, ctime_nanos: u64) -> Self {
Self {
size,
mtime_nanos,
ctime_nanos,
}
}
#[must_use]
pub fn from_metadata(meta: &std::fs::Metadata) -> Self {
let mtime_nanos = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map_or(0, |d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX));
let ctime_nanos = platform_ctime_nanos(meta);
Self {
size: meta.len(),
mtime_nanos,
ctime_nanos,
}
}
#[must_use]
const fn quick_check_key(&self) -> (u64, u64) {
(self.size, self.mtime_nanos)
}
}
#[cfg(unix)]
fn platform_ctime_nanos(meta: &std::fs::Metadata) -> u64 {
use std::os::unix::fs::MetadataExt;
let secs = u64::try_from(meta.ctime()).unwrap_or(0);
let nanos = u64::try_from(meta.ctime_nsec()).unwrap_or(0);
secs.saturating_mul(1_000_000_000).saturating_add(nanos)
}
#[cfg(not(unix))]
fn platform_ctime_nanos(_meta: &std::fs::Metadata) -> u64 {
0
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangeVerdict {
Unchanged,
SuspectChanged,
}
impl ChangeVerdict {
#[must_use]
pub const fn is_skippable(self) -> bool {
matches!(self, Self::Unchanged)
}
}
#[must_use]
pub fn classify(prior: Option<&FileSignature>, current: &FileSignature) -> ChangeVerdict {
match prior {
Some(p) if p.quick_check_key() == current.quick_check_key() => ChangeVerdict::Unchanged,
_ => ChangeVerdict::SuspectChanged,
}
}
const SIMHASH_BITS: usize = 64;
#[must_use]
pub fn simhash64(features: impl IntoIterator<Item = u64>) -> u64 {
let mut acc = [0i64; SIMHASH_BITS];
let mut any = false;
for feature in features {
any = true;
for (bit, slot) in acc.iter_mut().enumerate() {
if (feature >> bit) & 1 == 1 {
*slot += 1;
} else {
*slot -= 1;
}
}
}
if !any {
return 0;
}
let mut hash = 0u64;
for (bit, slot) in acc.iter().enumerate() {
if *slot > 0 {
hash |= 1u64 << bit;
}
}
hash
}
#[must_use]
pub fn simhash_of_chunk_ids<'a>(chunk_ids: impl IntoIterator<Item = &'a [u8; 32]>) -> u64 {
simhash64(chunk_ids.into_iter().map(|id| {
let mut bytes = [0u8; 8];
bytes.copy_from_slice(&id[..8]);
u64::from_le_bytes(bytes)
}))
}
#[must_use]
pub fn hamming_similarity(a: u64, b: u64) -> f64 {
let distance = (a ^ b).count_ones();
1.0 - f64::from(distance) / SIMHASH_BITS as f64
}
#[derive(Debug, Clone, PartialEq)]
pub struct RenameMatch {
pub prior_path: String,
pub similarity: f64,
}
#[must_use]
pub fn best_rename_source<'a>(
current_simhash: u64,
candidates: impl IntoIterator<Item = (&'a str, u64)>,
min_similarity: f64,
) -> Option<RenameMatch> {
let mut best: Option<RenameMatch> = None;
for (path, simhash) in candidates {
let similarity = hamming_similarity(current_simhash, simhash);
if similarity < min_similarity {
continue;
}
if best.as_ref().is_none_or(|b| similarity > b.similarity) {
best = Some(RenameMatch {
prior_path: path.to_string(),
similarity,
});
}
}
best
}
#[cfg(test)]
mod tests {
use super::*;
fn sig(size: u64, mtime: u64, ctime: u64) -> FileSignature {
FileSignature::new(size, mtime, ctime)
}
#[test]
fn unchanged_when_size_and_mtime_match() {
let prior = sig(4096, 1_000, 500);
let current = sig(4096, 1_000, 500);
assert_eq!(classify(Some(&prior), ¤t), ChangeVerdict::Unchanged);
assert!(classify(Some(&prior), ¤t).is_skippable());
}
#[test]
fn ctime_only_change_is_ignored() {
let prior = sig(4096, 1_000, 500);
let current = sig(4096, 1_000, 9_999); assert_eq!(classify(Some(&prior), ¤t), ChangeVerdict::Unchanged);
}
#[test]
fn mtime_change_is_suspect() {
let prior = sig(4096, 1_000, 500);
let current = sig(4096, 2_000, 500);
assert_eq!(
classify(Some(&prior), ¤t),
ChangeVerdict::SuspectChanged
);
}
#[test]
fn size_change_is_suspect() {
let prior = sig(4096, 1_000, 500);
let current = sig(8192, 1_000, 500);
assert_eq!(
classify(Some(&prior), ¤t),
ChangeVerdict::SuspectChanged
);
}
#[test]
fn no_prior_is_suspect() {
let current = sig(4096, 1_000, 500);
assert_eq!(classify(None, ¤t), ChangeVerdict::SuspectChanged);
assert!(!classify(None, ¤t).is_skippable());
}
#[test]
fn simhash_is_deterministic_and_order_independent() {
let a = simhash64([1u64, 2, 3, 4]);
let b = simhash64([4u64, 3, 2, 1]);
assert_eq!(a, b, "simhash must not depend on feature order");
assert_eq!(simhash64(std::iter::empty()), 0);
}
#[test]
fn identical_chunk_sets_are_a_perfect_rename_match() {
let ids = [[7u8; 32], [9u8; 32], [11u8; 32]];
let prior = simhash_of_chunk_ids(ids.iter());
let renamed = simhash_of_chunk_ids(ids.iter());
assert_eq!(prior, renamed);
assert!((hamming_similarity(prior, renamed) - 1.0).abs() < f64::EPSILON);
}
#[test]
fn small_edit_stays_highly_similar() {
let base: Vec<[u8; 32]> = (0..32u8).map(|i| [i; 32]).collect();
let mut edited = base.clone();
edited[0] = [200u8; 32]; let h_base = simhash_of_chunk_ids(base.iter());
let h_edited = simhash_of_chunk_ids(edited.iter());
assert!(
hamming_similarity(h_base, h_edited) > 0.7,
"a one-chunk edit should remain a strong delta candidate"
);
}
#[test]
fn best_rename_source_picks_the_nearest_above_threshold() {
let current = 0xfeed_face_cafe_beefu64;
let near = current ^ 0x0000_0000_0000_0001;
let far = current ^ 0x0000_0000_0000_ffff;
let candidates = [("old/far.bin", far), ("old/near.bin", near)];
let m = best_rename_source(current, candidates.iter().map(|(p, h)| (*p, *h)), 0.6)
.expect("a near match clears the threshold");
assert_eq!(m.prior_path, "old/near.bin");
}
#[test]
fn best_rename_source_returns_none_when_nothing_is_similar_enough() {
let current = simhash_of_chunk_ids([[1u8; 32], [2u8; 32]].iter());
let far = simhash_of_chunk_ids([[200u8; 32], [201u8; 32]].iter());
assert!(best_rename_source(current, [("old/x", far)], 0.95).is_none());
}
}