use std::collections::HashSet;
use std::path::Path;
pub const SKETCH_SIZE: usize = 256;
pub const STALE_DRIFT: f64 = 0.4;
pub const MAX_FILE_BYTES: u64 = 1024 * 1024;
const MIN_LINE_CHARS: usize = 3;
#[derive(Clone, Debug, PartialEq)]
pub enum AnchorState {
Fresh,
Drifted(f64),
Missing,
Unknown,
}
impl AnchorState {
pub fn is_stale(&self) -> bool {
matches!(self, AnchorState::Missing | AnchorState::Drifted(_))
}
}
fn fnv1a(bytes: &[u8]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in bytes {
h ^= u64::from(*b);
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
fn line_hashes(text: &str) -> HashSet<u64> {
text.lines()
.map(str::trim)
.filter(|l| l.chars().count() >= MIN_LINE_CHARS)
.map(|l| fnv1a(l.as_bytes()))
.collect()
}
pub fn sketch(text: &str) -> Vec<u64> {
let mut hashes: Vec<u64> = line_hashes(text).into_iter().collect();
hashes.sort_unstable();
hashes.truncate(SKETCH_SIZE);
hashes
}
pub fn encode(sketch: &[u64]) -> Vec<u8> {
sketch.iter().flat_map(|h| h.to_le_bytes()).collect()
}
pub fn decode(bytes: &[u8]) -> Vec<u64> {
bytes
.as_chunks::<8>()
.0
.iter()
.map(|c| u64::from_le_bytes(*c))
.collect()
}
pub fn drift(sketch: &[u64], current: &str) -> f64 {
if sketch.is_empty() {
return 0.0;
}
let now = line_hashes(current);
let gone = sketch.iter().filter(|h| !now.contains(h)).count();
gone as f64 / sketch.len() as f64
}
fn read_text(path: &Path) -> Option<String> {
let meta = std::fs::metadata(path).ok()?;
if !meta.is_file() || meta.len() > MAX_FILE_BYTES {
return None;
}
let bytes = std::fs::read(path).ok()?;
String::from_utf8(bytes).ok()
}
pub fn fingerprint_file(path: &str) -> Option<Vec<u8>> {
let s = sketch(&read_text(Path::new(path))?);
(!s.is_empty()).then(|| encode(&s))
}
pub fn anchor_state(path: &str, fingerprint: Option<&[u8]>) -> AnchorState {
let p = Path::new(path);
if !p.exists() {
return match p.parent() {
Some(dir) if dir.is_dir() => AnchorState::Missing,
_ => AnchorState::Unknown,
};
}
let Some(fp) = fingerprint else {
return AnchorState::Unknown;
};
let Some(text) = read_text(p) else {
return AnchorState::Unknown;
};
let d = drift(&decode(fp), &text);
if d >= STALE_DRIFT {
AnchorState::Drifted(d)
} else {
AnchorState::Fresh
}
}
#[cfg(test)]
#[path = "../../../tests/unit/infrastructure/fingerprint.rs"]
mod tests;