1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10pub const DEFAULT_MAX_FILES: usize = 15;
12
13pub const DEFAULT_MAX_TOKENS: usize = 100_000;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct WorkingSetEntry {
19 pub path: PathBuf,
21 pub tokens: usize,
23 pub access_count: u32,
25 pub last_access_turn: u32,
27 pub added_at_turn: u32,
29 pub pinned: bool,
31 pub label: Option<String>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub intended_hash: Option<[u8; 32]>,
45}
46
47impl WorkingSetEntry {
48 pub fn new(path: PathBuf, tokens: usize, current_turn: u32) -> Self {
50 Self {
51 path,
52 tokens,
53 access_count: 1,
54 last_access_turn: current_turn,
55 added_at_turn: current_turn,
56 pinned: false,
57 label: None,
58 intended_hash: None,
59 }
60 }
61
62 pub fn with_label(mut self, label: impl Into<String>) -> Self {
64 self.label = Some(label.into());
65 self
66 }
67
68 pub fn pinned(mut self) -> Self {
70 self.pinned = true;
71 self
72 }
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct WorkingSetConfig {
78 pub max_files: usize,
80 pub max_tokens: usize,
82 pub stale_after_turns: u32,
84 pub auto_evict: bool,
86}
87
88impl Default for WorkingSetConfig {
89 fn default() -> Self {
90 Self {
91 max_files: DEFAULT_MAX_FILES,
92 max_tokens: DEFAULT_MAX_TOKENS,
93 stale_after_turns: 10,
94 auto_evict: true,
95 }
96 }
97}
98
99#[derive(Debug, Clone, Default)]
101pub struct WorkingSet {
102 entries: HashMap<String, WorkingSetEntry>,
103 config: WorkingSetConfig,
104 current_turn: u32,
105 last_eviction: Option<String>,
106}
107
108impl WorkingSet {
109 pub fn new() -> Self {
111 Self {
112 entries: HashMap::new(),
113 config: WorkingSetConfig::default(),
114 current_turn: 0,
115 last_eviction: None,
116 }
117 }
118
119 pub fn with_config(config: WorkingSetConfig) -> Self {
121 Self {
122 entries: HashMap::new(),
123 config,
124 current_turn: 0,
125 last_eviction: None,
126 }
127 }
128
129 pub fn next_turn(&mut self) {
131 self.current_turn += 1;
132 if self.config.auto_evict {
133 self.evict_stale();
134 }
135 }
136
137 pub fn current_turn(&self) -> u32 {
139 self.current_turn
140 }
141
142 pub fn add(&mut self, path: PathBuf, tokens: usize) -> Option<String> {
144 let key = path.to_string_lossy().to_string();
145 if let Some(entry) = self.entries.get_mut(&key) {
146 entry.access_count += 1;
147 entry.last_access_turn = self.current_turn;
148 return None;
149 }
150 let eviction_reason = self.maybe_evict(tokens);
151 let entry = WorkingSetEntry::new(path, tokens, self.current_turn);
152 self.entries.insert(key, entry);
153 eviction_reason
154 }
155
156 pub fn add_labeled(&mut self, path: PathBuf, tokens: usize, label: &str) -> Option<String> {
158 let key = path.to_string_lossy().to_string();
159 if let Some(entry) = self.entries.get_mut(&key) {
160 entry.access_count += 1;
161 entry.last_access_turn = self.current_turn;
162 entry.label = Some(label.to_string());
163 return None;
164 }
165 let eviction_reason = self.maybe_evict(tokens);
166 let entry = WorkingSetEntry::new(path, tokens, self.current_turn).with_label(label);
167 self.entries.insert(key, entry);
168 eviction_reason
169 }
170
171 pub fn add_pinned(&mut self, path: PathBuf, tokens: usize, label: Option<&str>) {
173 let key = path.to_string_lossy().to_string();
174 if let Some(entry) = self.entries.get_mut(&key) {
175 entry.pinned = true;
176 entry.access_count += 1;
177 entry.last_access_turn = self.current_turn;
178 if let Some(l) = label {
179 entry.label = Some(l.to_string());
180 }
181 return;
182 }
183 let mut entry = WorkingSetEntry::new(path, tokens, self.current_turn).pinned();
184 if let Some(l) = label {
185 entry.label = Some(l.to_string());
186 }
187 self.entries.insert(key, entry);
188 }
189
190 pub fn touch(&mut self, path: &Path) -> bool {
192 let key = path.to_string_lossy().to_string();
193 if let Some(entry) = self.entries.get_mut(&key) {
194 entry.access_count += 1;
195 entry.last_access_turn = self.current_turn;
196 true
197 } else {
198 false
199 }
200 }
201
202 pub fn remove(&mut self, path: &Path) -> bool {
204 let key = path.to_string_lossy().to_string();
205 self.entries.remove(&key).is_some()
206 }
207
208 pub fn pin(&mut self, path: &Path) -> bool {
210 let key = path.to_string_lossy().to_string();
211 if let Some(entry) = self.entries.get_mut(&key) {
212 entry.pinned = true;
213 true
214 } else {
215 false
216 }
217 }
218
219 pub fn unpin(&mut self, path: &Path) -> bool {
221 let key = path.to_string_lossy().to_string();
222 if let Some(entry) = self.entries.get_mut(&key) {
223 entry.pinned = false;
224 true
225 } else {
226 false
227 }
228 }
229
230 pub fn clear(&mut self, keep_pinned: bool) {
232 if keep_pinned {
233 self.entries.retain(|_, entry| entry.pinned);
234 } else {
235 self.entries.clear();
236 }
237 self.last_eviction = None;
238 }
239
240 pub fn entries(&self) -> impl Iterator<Item = &WorkingSetEntry> {
242 self.entries.values()
243 }
244
245 pub fn get(&self, path: &Path) -> Option<&WorkingSetEntry> {
247 let key = path.to_string_lossy().to_string();
248 self.entries.get(&key)
249 }
250
251 pub fn contains(&self, path: &Path) -> bool {
253 let key = path.to_string_lossy().to_string();
254 self.entries.contains_key(&key)
255 }
256
257 pub fn len(&self) -> usize {
259 self.entries.len()
260 }
261
262 pub fn is_empty(&self) -> bool {
264 self.entries.is_empty()
265 }
266
267 pub fn total_tokens(&self) -> usize {
269 self.entries.values().map(|e| e.tokens).sum()
270 }
271
272 pub fn last_eviction(&self) -> Option<&str> {
274 self.last_eviction.as_deref()
275 }
276
277 pub fn file_paths(&self) -> Vec<&PathBuf> {
279 self.entries.values().map(|e| &e.path).collect()
280 }
281
282 pub fn record_write(&mut self, path: &Path, hash: [u8; 32]) {
290 let key = path.to_string_lossy().to_string();
291 if let Some(entry) = self.entries.get_mut(&key) {
292 entry.intended_hash = Some(hash);
293 entry.access_count += 1;
294 entry.last_access_turn = self.current_turn;
295 return;
296 }
297 let mut entry = WorkingSetEntry::new(path.to_path_buf(), 0, self.current_turn);
298 entry.intended_hash = Some(hash);
299 self.entries.insert(key, entry);
300 }
301
302 pub fn get_intended_hash(&self, path: &Path) -> Option<[u8; 32]> {
305 let key = path.to_string_lossy().to_string();
306 self.entries.get(&key).and_then(|e| e.intended_hash)
307 }
308
309 fn evict_stale(&mut self) {
310 let stale_threshold = self
311 .current_turn
312 .saturating_sub(self.config.stale_after_turns);
313 let before_count = self.entries.len();
314 self.entries
315 .retain(|_, entry| entry.pinned || entry.last_access_turn > stale_threshold);
316 let evicted = before_count - self.entries.len();
317 if evicted > 0 {
318 self.last_eviction = Some(format!("Evicted {} stale file(s)", evicted));
319 }
320 }
321
322 fn maybe_evict(&mut self, new_tokens: usize) -> Option<String> {
323 let mut evicted_files = Vec::new();
324 while self.entries.len() >= self.config.max_files {
325 if let Some(key) = self.find_lru_candidate() {
326 if let Some(entry) = self.entries.remove(&key) {
327 evicted_files.push(entry.path.to_string_lossy().to_string());
328 }
329 } else {
330 break;
331 }
332 }
333 while self.total_tokens() + new_tokens > self.config.max_tokens {
334 if let Some(key) = self.find_lru_candidate() {
335 if let Some(entry) = self.entries.remove(&key) {
336 evicted_files.push(entry.path.to_string_lossy().to_string());
337 }
338 } else {
339 break;
340 }
341 }
342 if evicted_files.is_empty() {
343 None
344 } else {
345 let reason = format!("Evicted: {}", evicted_files.join(", "));
346 self.last_eviction = Some(reason.clone());
347 Some(reason)
348 }
349 }
350
351 fn find_lru_candidate(&self) -> Option<String> {
352 self.entries
353 .iter()
354 .filter(|(_, entry)| !entry.pinned)
355 .min_by_key(|(_, entry)| (entry.last_access_turn, entry.access_count))
356 .map(|(key, _)| key.clone())
357 }
358}
359
360pub fn estimate_tokens(content: &str) -> usize {
362 content.len().div_ceil(4)
363}
364
365pub fn estimate_tokens_from_size(bytes: u64) -> usize {
367 (bytes as usize).div_ceil(4)
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 #[test]
375 fn test_working_set_add_and_access() {
376 let mut ws = WorkingSet::new();
377 ws.add(PathBuf::from("/test/file1.rs"), 1000);
378 assert_eq!(ws.len(), 1);
379 assert!(ws.contains(&PathBuf::from("/test/file1.rs")));
380 }
381
382 #[test]
383 fn test_working_set_lru_eviction() {
384 let config = WorkingSetConfig {
385 max_files: 3,
386 max_tokens: 100_000,
387 stale_after_turns: 10,
388 auto_evict: false,
389 };
390 let mut ws = WorkingSet::with_config(config);
391 ws.add(PathBuf::from("/test/file1.rs"), 100);
392 ws.next_turn();
393 ws.add(PathBuf::from("/test/file2.rs"), 100);
394 ws.next_turn();
395 ws.add(PathBuf::from("/test/file3.rs"), 100);
396 ws.next_turn();
397 ws.add(PathBuf::from("/test/file4.rs"), 100);
398 assert_eq!(ws.len(), 3);
399 assert!(!ws.contains(&PathBuf::from("/test/file1.rs")));
400 }
401
402 #[test]
403 fn test_estimate_tokens() {
404 assert_eq!(estimate_tokens(""), 0);
405 assert_eq!(estimate_tokens("test"), 1);
406 }
407
408 #[test]
409 fn test_working_set_records_and_retrieves_hash() {
410 let mut ws = WorkingSet::new();
411 let path = PathBuf::from("/tmp/claim.txt");
412 let hash = [7u8; 32];
413 ws.record_write(&path, hash);
414 assert_eq!(ws.get_intended_hash(&path), Some(hash));
415 assert!(
416 ws.get_intended_hash(&PathBuf::from("/tmp/other.txt"))
417 .is_none(),
418 "unrecorded path must return None"
419 );
420
421 let newer = [42u8; 32];
424 ws.record_write(&path, newer);
425 assert_eq!(ws.get_intended_hash(&path), Some(newer));
426 assert_eq!(ws.len(), 1);
427 }
428}