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}
34
35impl WorkingSetEntry {
36 pub fn new(path: PathBuf, tokens: usize, current_turn: u32) -> Self {
38 Self {
39 path,
40 tokens,
41 access_count: 1,
42 last_access_turn: current_turn,
43 added_at_turn: current_turn,
44 pinned: false,
45 label: None,
46 }
47 }
48
49 pub fn with_label(mut self, label: impl Into<String>) -> Self {
51 self.label = Some(label.into());
52 self
53 }
54
55 pub fn pinned(mut self) -> Self {
57 self.pinned = true;
58 self
59 }
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct WorkingSetConfig {
65 pub max_files: usize,
67 pub max_tokens: usize,
69 pub stale_after_turns: u32,
71 pub auto_evict: bool,
73}
74
75impl Default for WorkingSetConfig {
76 fn default() -> Self {
77 Self {
78 max_files: DEFAULT_MAX_FILES,
79 max_tokens: DEFAULT_MAX_TOKENS,
80 stale_after_turns: 10,
81 auto_evict: true,
82 }
83 }
84}
85
86#[derive(Debug, Clone, Default)]
88pub struct WorkingSet {
89 entries: HashMap<String, WorkingSetEntry>,
90 config: WorkingSetConfig,
91 current_turn: u32,
92 last_eviction: Option<String>,
93}
94
95impl WorkingSet {
96 pub fn new() -> Self {
98 Self {
99 entries: HashMap::new(),
100 config: WorkingSetConfig::default(),
101 current_turn: 0,
102 last_eviction: None,
103 }
104 }
105
106 pub fn with_config(config: WorkingSetConfig) -> Self {
108 Self {
109 entries: HashMap::new(),
110 config,
111 current_turn: 0,
112 last_eviction: None,
113 }
114 }
115
116 pub fn next_turn(&mut self) {
118 self.current_turn += 1;
119 if self.config.auto_evict {
120 self.evict_stale();
121 }
122 }
123
124 pub fn current_turn(&self) -> u32 {
126 self.current_turn
127 }
128
129 pub fn add(&mut self, path: PathBuf, tokens: usize) -> Option<String> {
131 let key = path.to_string_lossy().to_string();
132 if let Some(entry) = self.entries.get_mut(&key) {
133 entry.access_count += 1;
134 entry.last_access_turn = self.current_turn;
135 return None;
136 }
137 let eviction_reason = self.maybe_evict(tokens);
138 let entry = WorkingSetEntry::new(path, tokens, self.current_turn);
139 self.entries.insert(key, entry);
140 eviction_reason
141 }
142
143 pub fn add_labeled(&mut self, path: PathBuf, tokens: usize, label: &str) -> Option<String> {
145 let key = path.to_string_lossy().to_string();
146 if let Some(entry) = self.entries.get_mut(&key) {
147 entry.access_count += 1;
148 entry.last_access_turn = self.current_turn;
149 entry.label = Some(label.to_string());
150 return None;
151 }
152 let eviction_reason = self.maybe_evict(tokens);
153 let entry = WorkingSetEntry::new(path, tokens, self.current_turn).with_label(label);
154 self.entries.insert(key, entry);
155 eviction_reason
156 }
157
158 pub fn add_pinned(&mut self, path: PathBuf, tokens: usize, label: Option<&str>) {
160 let key = path.to_string_lossy().to_string();
161 if let Some(entry) = self.entries.get_mut(&key) {
162 entry.pinned = true;
163 entry.access_count += 1;
164 entry.last_access_turn = self.current_turn;
165 if let Some(l) = label {
166 entry.label = Some(l.to_string());
167 }
168 return;
169 }
170 let mut entry = WorkingSetEntry::new(path, tokens, self.current_turn).pinned();
171 if let Some(l) = label {
172 entry.label = Some(l.to_string());
173 }
174 self.entries.insert(key, entry);
175 }
176
177 pub fn touch(&mut self, path: &Path) -> bool {
179 let key = path.to_string_lossy().to_string();
180 if let Some(entry) = self.entries.get_mut(&key) {
181 entry.access_count += 1;
182 entry.last_access_turn = self.current_turn;
183 true
184 } else {
185 false
186 }
187 }
188
189 pub fn remove(&mut self, path: &Path) -> bool {
191 let key = path.to_string_lossy().to_string();
192 self.entries.remove(&key).is_some()
193 }
194
195 pub fn pin(&mut self, path: &Path) -> bool {
197 let key = path.to_string_lossy().to_string();
198 if let Some(entry) = self.entries.get_mut(&key) {
199 entry.pinned = true;
200 true
201 } else {
202 false
203 }
204 }
205
206 pub fn unpin(&mut self, path: &Path) -> bool {
208 let key = path.to_string_lossy().to_string();
209 if let Some(entry) = self.entries.get_mut(&key) {
210 entry.pinned = false;
211 true
212 } else {
213 false
214 }
215 }
216
217 pub fn clear(&mut self, keep_pinned: bool) {
219 if keep_pinned {
220 self.entries.retain(|_, entry| entry.pinned);
221 } else {
222 self.entries.clear();
223 }
224 self.last_eviction = None;
225 }
226
227 pub fn entries(&self) -> impl Iterator<Item = &WorkingSetEntry> {
229 self.entries.values()
230 }
231
232 pub fn get(&self, path: &Path) -> Option<&WorkingSetEntry> {
234 let key = path.to_string_lossy().to_string();
235 self.entries.get(&key)
236 }
237
238 pub fn contains(&self, path: &Path) -> bool {
240 let key = path.to_string_lossy().to_string();
241 self.entries.contains_key(&key)
242 }
243
244 pub fn len(&self) -> usize {
246 self.entries.len()
247 }
248
249 pub fn is_empty(&self) -> bool {
251 self.entries.is_empty()
252 }
253
254 pub fn total_tokens(&self) -> usize {
256 self.entries.values().map(|e| e.tokens).sum()
257 }
258
259 pub fn last_eviction(&self) -> Option<&str> {
261 self.last_eviction.as_deref()
262 }
263
264 pub fn file_paths(&self) -> Vec<&PathBuf> {
266 self.entries.values().map(|e| &e.path).collect()
267 }
268
269 fn evict_stale(&mut self) {
270 let stale_threshold = self
271 .current_turn
272 .saturating_sub(self.config.stale_after_turns);
273 let before_count = self.entries.len();
274 self.entries
275 .retain(|_, entry| entry.pinned || entry.last_access_turn > stale_threshold);
276 let evicted = before_count - self.entries.len();
277 if evicted > 0 {
278 self.last_eviction = Some(format!("Evicted {} stale file(s)", evicted));
279 }
280 }
281
282 fn maybe_evict(&mut self, new_tokens: usize) -> Option<String> {
283 let mut evicted_files = Vec::new();
284 while self.entries.len() >= self.config.max_files {
285 if let Some(key) = self.find_lru_candidate() {
286 if let Some(entry) = self.entries.remove(&key) {
287 evicted_files.push(entry.path.to_string_lossy().to_string());
288 }
289 } else {
290 break;
291 }
292 }
293 while self.total_tokens() + new_tokens > self.config.max_tokens {
294 if let Some(key) = self.find_lru_candidate() {
295 if let Some(entry) = self.entries.remove(&key) {
296 evicted_files.push(entry.path.to_string_lossy().to_string());
297 }
298 } else {
299 break;
300 }
301 }
302 if evicted_files.is_empty() {
303 None
304 } else {
305 let reason = format!("Evicted: {}", evicted_files.join(", "));
306 self.last_eviction = Some(reason.clone());
307 Some(reason)
308 }
309 }
310
311 fn find_lru_candidate(&self) -> Option<String> {
312 self.entries
313 .iter()
314 .filter(|(_, entry)| !entry.pinned)
315 .min_by_key(|(_, entry)| (entry.last_access_turn, entry.access_count))
316 .map(|(key, _)| key.clone())
317 }
318}
319
320pub fn estimate_tokens(content: &str) -> usize {
322 content.len().div_ceil(4)
323}
324
325pub fn estimate_tokens_from_size(bytes: u64) -> usize {
327 (bytes as usize).div_ceil(4)
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 #[test]
335 fn test_working_set_add_and_access() {
336 let mut ws = WorkingSet::new();
337 ws.add(PathBuf::from("/test/file1.rs"), 1000);
338 assert_eq!(ws.len(), 1);
339 assert!(ws.contains(&PathBuf::from("/test/file1.rs")));
340 }
341
342 #[test]
343 fn test_working_set_lru_eviction() {
344 let config = WorkingSetConfig {
345 max_files: 3,
346 max_tokens: 100_000,
347 stale_after_turns: 10,
348 auto_evict: false,
349 };
350 let mut ws = WorkingSet::with_config(config);
351 ws.add(PathBuf::from("/test/file1.rs"), 100);
352 ws.next_turn();
353 ws.add(PathBuf::from("/test/file2.rs"), 100);
354 ws.next_turn();
355 ws.add(PathBuf::from("/test/file3.rs"), 100);
356 ws.next_turn();
357 ws.add(PathBuf::from("/test/file4.rs"), 100);
358 assert_eq!(ws.len(), 3);
359 assert!(!ws.contains(&PathBuf::from("/test/file1.rs")));
360 }
361
362 #[test]
363 fn test_estimate_tokens() {
364 assert_eq!(estimate_tokens(""), 0);
365 assert_eq!(estimate_tokens("test"), 1);
366 }
367}