use std::collections::{HashMap, HashSet};
use std::time::Instant;
const DEFAULT_MAX_ENTRIES: usize = 1000;
const HASH_LENGTH: usize = 16;
#[derive(Debug, Clone)]
pub struct ContentFingerprint {
pub hash: String,
pub token_estimate: usize,
pub delivered_at: Instant,
}
#[derive(Debug, Clone, PartialEq)]
pub enum DedupResult {
Fresh,
Unchanged {
hash: String,
saved_tokens: usize,
},
}
#[derive(Debug)]
pub struct ContextDedup {
fingerprints: HashMap<String, ContentFingerprint>,
max_entries: usize,
}
impl ContextDedup {
pub fn new(max_entries: usize) -> Self {
Self {
fingerprints: HashMap::new(),
max_entries: if max_entries == 0 {
DEFAULT_MAX_ENTRIES
} else {
max_entries
},
}
}
pub fn check_and_record(&mut self, path: &str, content: &str) -> DedupResult {
let hash = content_hash(content);
if let Some(fingerprint) = self.fingerprints.get(path)
&& fingerprint.hash == hash
{
return DedupResult::Unchanged {
hash,
saved_tokens: fingerprint.token_estimate,
};
}
if !self.fingerprints.contains_key(path) && self.fingerprints.len() >= self.max_entries {
self.evict_oldest();
}
self.fingerprints.insert(
path.to_owned(),
ContentFingerprint {
hash,
token_estimate: estimate_tokens(content),
delivered_at: Instant::now(),
},
);
DedupResult::Fresh
}
pub fn invalidate(&mut self, path: &str) {
self.fingerprints.remove(path);
}
pub fn clear(&mut self) {
self.fingerprints.clear();
}
pub fn len(&self) -> usize {
self.fingerprints.len()
}
pub fn is_empty(&self) -> bool {
self.fingerprints.is_empty()
}
pub fn evict_oldest(&mut self) {
let oldest = self
.fingerprints
.iter()
.min_by_key(|(_, fingerprint)| fingerprint.delivered_at)
.map(|(path, _)| path.clone());
if let Some(path) = oldest {
self.fingerprints.remove(&path);
}
}
}
pub fn format_unchanged_stub(path: &str, hash: &str) -> String {
let short_hash = &hash[..hash.len().min(8)];
format!("→ {path} unchanged (ref:{short_hash}), already in context\n")
}
pub fn dedup_kernel_blocks(blocks: &str, seen_hashes: &mut HashSet<String>) -> String {
let starts = kernel_block_starts(blocks);
let Some(&first_start) = starts.first() else {
return blocks.to_owned();
};
let mut output = String::with_capacity(blocks.len());
output.push_str(&blocks[..first_start]);
for (index, &start) in starts.iter().enumerate() {
let end = starts.get(index + 1).copied().unwrap_or(blocks.len());
let block = &blocks[start..end];
let hash = content_hash(block.trim_end());
if seen_hashes.insert(hash.clone()) {
output.push_str(block);
} else {
output.push_str(&format_unchanged_stub("kernel context", &hash));
}
}
output
}
pub fn estimate_tokens(content: &str) -> usize {
content.len() / 4
}
fn content_hash(content: &str) -> String {
blake3::hash(content.as_bytes()).to_hex()[..HASH_LENGTH].to_owned()
}
fn kernel_block_starts(blocks: &str) -> Vec<usize> {
let mut starts = Vec::new();
let mut offset = 0;
for line in blocks.split_inclusive('\n') {
if matches!(
line.trim_end_matches(['\r', '\n']),
"## Context Kernel" | "--- kernel context ---"
) {
starts.push(offset);
}
offset += line.len();
}
starts
}
#[cfg(test)]
pub mod tests {
use super::{
ContextDedup, DedupResult, content_hash, dedup_kernel_blocks, estimate_tokens,
format_unchanged_stub,
};
use std::collections::HashSet;
#[test]
fn fresh_on_first_read() {
let mut dedup = ContextDedup::new(1000);
assert_eq!(
dedup.check_and_record("src/lib.rs", "content"),
DedupResult::Fresh
);
}
#[test]
fn unchanged_on_repeat_read() {
let mut dedup = ContextDedup::new(1000);
dedup.check_and_record("src/lib.rs", "eight888");
assert_eq!(
dedup.check_and_record("src/lib.rs", "eight888"),
DedupResult::Unchanged {
hash: content_hash("eight888"),
saved_tokens: 2,
}
);
}
#[test]
fn fresh_after_content_change() {
let mut dedup = ContextDedup::new(1000);
dedup.check_and_record("src/lib.rs", "before");
assert_eq!(
dedup.check_and_record("src/lib.rs", "after"),
DedupResult::Fresh
);
}
#[test]
fn stub_format_is_short() {
let stub = format_unchanged_stub("src/lib.rs", "0123456789abcdef");
assert!(estimate_tokens(&stub) <= 20);
assert!(stub.contains("ref:01234567"));
}
#[test]
fn dedup_kernel_blocks_removes_duplicates() {
let block = "## Context Kernel\nshared enrichment\n";
let input = block.repeat(3);
let mut seen = HashSet::new();
let output = dedup_kernel_blocks(&input, &mut seen);
assert_eq!(output.matches("shared enrichment").count(), 1);
assert_eq!(output.matches("kernel context unchanged").count(), 2);
assert_eq!(seen.len(), 1);
}
#[test]
fn bounded_eviction() {
let mut dedup = ContextDedup::new(1000);
dedup.check_and_record("oldest", "first");
for index in 0..1000 {
dedup.check_and_record(&format!("path-{index}"), &format!("content-{index}"));
}
assert_eq!(dedup.len(), 1000);
assert_eq!(
dedup.check_and_record("oldest", "first"),
DedupResult::Fresh
);
}
#[test]
fn hash_is_deterministic() {
assert_eq!(content_hash("stable"), content_hash("stable"));
assert_eq!(content_hash("stable").len(), 16);
}
#[test]
fn invalidate_and_clear_forget_entries() {
let mut dedup = ContextDedup::new(10);
dedup.check_and_record("a", "one");
dedup.invalidate("a");
assert_eq!(dedup.check_and_record("a", "one"), DedupResult::Fresh);
dedup.clear();
assert!(dedup.is_empty());
}
}