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.current_turn.saturating_sub(self.config.stale_after_turns);
271 let before_count = self.entries.len();
272 self.entries.retain(|_, entry| {
273 entry.pinned || entry.last_access_turn > stale_threshold
274 });
275 let evicted = before_count - self.entries.len();
276 if evicted > 0 {
277 self.last_eviction = Some(format!("Evicted {} stale file(s)", evicted));
278 }
279 }
280
281 fn maybe_evict(&mut self, new_tokens: usize) -> Option<String> {
282 let mut evicted_files = Vec::new();
283 while self.entries.len() >= self.config.max_files {
284 if let Some(key) = self.find_lru_candidate() {
285 if let Some(entry) = self.entries.remove(&key) {
286 evicted_files.push(entry.path.to_string_lossy().to_string());
287 }
288 } else {
289 break;
290 }
291 }
292 while self.total_tokens() + new_tokens > self.config.max_tokens {
293 if let Some(key) = self.find_lru_candidate() {
294 if let Some(entry) = self.entries.remove(&key) {
295 evicted_files.push(entry.path.to_string_lossy().to_string());
296 }
297 } else {
298 break;
299 }
300 }
301 if evicted_files.is_empty() {
302 None
303 } else {
304 let reason = format!("Evicted: {}", evicted_files.join(", "));
305 self.last_eviction = Some(reason.clone());
306 Some(reason)
307 }
308 }
309
310 fn find_lru_candidate(&self) -> Option<String> {
311 self.entries
312 .iter()
313 .filter(|(_, entry)| !entry.pinned)
314 .min_by_key(|(_, entry)| (entry.last_access_turn, entry.access_count))
315 .map(|(key, _)| key.clone())
316 }
317}
318
319pub fn estimate_tokens(content: &str) -> usize {
321 content.len().div_ceil(4)
322}
323
324pub fn estimate_tokens_from_size(bytes: u64) -> usize {
326 (bytes as usize).div_ceil(4)
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 #[test]
334 fn test_working_set_add_and_access() {
335 let mut ws = WorkingSet::new();
336 ws.add(PathBuf::from("/test/file1.rs"), 1000);
337 assert_eq!(ws.len(), 1);
338 assert!(ws.contains(&PathBuf::from("/test/file1.rs")));
339 }
340
341 #[test]
342 fn test_working_set_lru_eviction() {
343 let config = WorkingSetConfig {
344 max_files: 3,
345 max_tokens: 100_000,
346 stale_after_turns: 10,
347 auto_evict: false,
348 };
349 let mut ws = WorkingSet::with_config(config);
350 ws.add(PathBuf::from("/test/file1.rs"), 100);
351 ws.next_turn();
352 ws.add(PathBuf::from("/test/file2.rs"), 100);
353 ws.next_turn();
354 ws.add(PathBuf::from("/test/file3.rs"), 100);
355 ws.next_turn();
356 ws.add(PathBuf::from("/test/file4.rs"), 100);
357 assert_eq!(ws.len(), 3);
358 assert!(!ws.contains(&PathBuf::from("/test/file1.rs")));
359 }
360
361 #[test]
362 fn test_estimate_tokens() {
363 assert_eq!(estimate_tokens(""), 0);
364 assert_eq!(estimate_tokens("test"), 1);
365 }
366}