relay_knowledge/watcher/
hash_cache.rs1use std::collections::{HashMap, VecDeque};
2use std::path::PathBuf;
3
4#[derive(Debug, Clone)]
5pub struct ContentHashCache {
6 capacity: usize,
7 entries: HashMap<PathBuf, u64>,
8 insertion_order: VecDeque<PathBuf>,
9}
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct ContentHashObservation {
13 pub changed: bool,
14 pub hash: u64,
15}
16
17impl ContentHashCache {
18 pub fn new(capacity: usize) -> Self {
19 Self {
20 capacity,
21 entries: HashMap::new(),
22 insertion_order: VecDeque::new(),
23 }
24 }
25
26 pub fn check_and_update(&mut self, path: PathBuf, content: &[u8]) -> ContentHashObservation {
27 self.check_hash_and_update(path, content_hash64(content))
28 }
29
30 pub fn check_hash_and_update(
31 &mut self,
32 path: PathBuf,
33 new_hash: u64,
34 ) -> ContentHashObservation {
35 let observation = self.observe_hash(&path, new_hash);
36 if observation.changed {
37 self.record_hash(path, new_hash);
38 }
39 observation
40 }
41
42 pub fn observe_hash(&self, path: &PathBuf, new_hash: u64) -> ContentHashObservation {
43 if self.capacity == 0 {
44 return ContentHashObservation {
45 changed: true,
46 hash: new_hash,
47 };
48 }
49 let changed = match self.entries.get(path) {
50 Some(&existing) => existing != new_hash,
51 None => true,
52 };
53 ContentHashObservation {
54 changed,
55 hash: new_hash,
56 }
57 }
58
59 pub fn record_hash(&mut self, path: PathBuf, new_hash: u64) {
60 if self.capacity == 0 {
61 return;
62 }
63 if self.entries.len() >= self.capacity && !self.entries.contains_key(&path) {
64 self.evict_oldest();
65 }
66 if !self.entries.contains_key(&path) {
67 self.insertion_order.push_back(path.clone());
68 }
69 self.entries.insert(path, new_hash);
70 }
71
72 pub fn remove(&mut self, path: &PathBuf) {
73 self.entries.remove(path);
74 self.insertion_order.retain(|entry| entry != path);
75 }
76
77 pub fn len(&self) -> usize {
78 self.entries.len()
79 }
80
81 pub fn is_empty(&self) -> bool {
82 self.entries.is_empty()
83 }
84
85 pub fn capacity(&self) -> usize {
86 self.capacity
87 }
88
89 pub fn clear(&mut self) {
90 self.entries.clear();
91 self.insertion_order.clear();
92 }
93
94 fn evict_oldest(&mut self) {
95 while let Some(oldest_key) = self.insertion_order.pop_front() {
96 if self.entries.remove(&oldest_key).is_some() {
97 break;
98 }
99 }
100 }
101}
102
103pub(super) fn content_hash64(bytes: &[u8]) -> u64 {
104 const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
105 const FNV_PRIME: u64 = 0x100000001b3;
106
107 let mut hash = FNV_OFFSET_BASIS;
108 for byte in bytes {
109 hash ^= u64::from(*byte);
110 hash = hash.wrapping_mul(FNV_PRIME);
111 }
112 hash
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 #[test]
120 fn detects_new_file_as_changed() {
121 let mut cache = ContentHashCache::new(100);
122 assert!(
123 cache
124 .check_and_update(PathBuf::from("a.rs"), b"hello")
125 .changed
126 );
127 }
128
129 #[test]
130 fn detects_unchanged_file_as_not_changed() {
131 let mut cache = ContentHashCache::new(100);
132 cache.check_and_update(PathBuf::from("a.rs"), b"hello");
133 assert!(
134 !cache
135 .check_and_update(PathBuf::from("a.rs"), b"hello")
136 .changed
137 );
138 }
139
140 #[test]
141 fn detects_modified_file_as_changed() {
142 let mut cache = ContentHashCache::new(100);
143 cache.check_and_update(PathBuf::from("a.rs"), b"hello");
144 assert!(
145 cache
146 .check_and_update(PathBuf::from("a.rs"), b"world")
147 .changed
148 );
149 }
150
151 #[test]
152 fn returns_stable_content_hash_for_same_bytes() {
153 let mut cache = ContentHashCache::new(100);
154 let first = cache.check_and_update(PathBuf::from("a.rs"), b"hello");
155 let second = cache.check_and_update(PathBuf::from("a.rs"), b"hello");
156 assert_eq!(first.hash, second.hash);
157 assert!(!second.changed);
158 }
159
160 #[test]
161 fn evicts_when_at_capacity() {
162 let mut cache = ContentHashCache::new(2);
163 cache.check_and_update(PathBuf::from("a.rs"), b"a");
164 cache.check_and_update(PathBuf::from("b.rs"), b"b");
165 cache.check_and_update(PathBuf::from("c.rs"), b"c");
166 assert_eq!(cache.len(), 2);
167 }
168
169 #[test]
170 fn remove_clears_entry() {
171 let mut cache = ContentHashCache::new(100);
172 cache.check_and_update(PathBuf::from("a.rs"), b"a");
173 let key = PathBuf::from("a.rs");
174 cache.remove(&key);
175 assert!(cache.is_empty());
176 }
177
178 #[test]
179 fn clear_empties_all_entries() {
180 let mut cache = ContentHashCache::new(100);
181 cache.check_and_update(PathBuf::from("a.rs"), b"a");
182 cache.check_and_update(PathBuf::from("b.rs"), b"b");
183 cache.clear();
184 assert!(cache.is_empty());
185 }
186
187 #[test]
188 fn update_at_capacity_replaces_existing_without_eviction() {
189 let mut cache = ContentHashCache::new(2);
190 cache.check_and_update(PathBuf::from("a.rs"), b"a");
191 cache.check_and_update(PathBuf::from("b.rs"), b"b");
192 assert!(!cache.check_and_update(PathBuf::from("a.rs"), b"a").changed);
193 assert_eq!(cache.len(), 2);
194 }
195
196 #[test]
197 fn evicts_in_insertion_order() {
198 let mut cache = ContentHashCache::new(2);
199 cache.check_and_update(PathBuf::from("a.rs"), b"a");
200 cache.check_and_update(PathBuf::from("b.rs"), b"b");
201 cache.check_and_update(PathBuf::from("c.rs"), b"c");
202 assert_eq!(cache.len(), 2);
203 assert!(cache.check_and_update(PathBuf::from("a.rs"), b"a").changed);
204 assert_eq!(cache.len(), 2);
205 }
206
207 #[test]
208 fn zero_capacity_tracks_no_entries() {
209 let mut cache = ContentHashCache::new(0);
210 let observation = cache.check_and_update(PathBuf::from("a.rs"), b"a");
211 assert!(observation.changed);
212 assert_ne!(observation.hash, 0);
213 assert!(cache.is_empty());
214 }
215}