1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::time::SystemTime;
6
7use crate::core::session::SessionState;
8use crate::core::tokens::count_tokens;
9
10use super::{SessionCache, max_cache_tokens};
11
12const MAX_WARM_FILE_BYTES: usize = 50 * 1024;
13const MAX_RECENT_FILES: usize = 50;
14
15#[derive(Clone, Debug, Eq, PartialEq)]
17pub struct RecentFile {
18 pub path: String,
19 pub last_access: SystemTime,
20 pub read_count: u32,
21 pub tokens: usize,
23}
24
25trait WarmCache {
26 fn contains(&self, path: &str) -> bool;
27 fn total_tokens(&self) -> usize;
28 fn insert(&mut self, path: &str, content: &str);
29}
30
31impl WarmCache for SessionCache {
32 fn contains(&self, path: &str) -> bool {
33 self.get(path).is_some()
34 }
35
36 fn total_tokens(&self) -> usize {
37 self.total_cached_tokens()
38 }
39
40 fn insert(&mut self, path: &str, content: &str) {
41 self.store(path, content);
42 }
43}
44
45pub fn warm_cache(cache: &mut SessionCache, history: &[RecentFile]) {
50 warm_cache_with(cache, history, max_cache_tokens(), |path| {
51 let metadata = std::fs::metadata(path).ok()?;
52 if !metadata.is_file() || metadata.len() > MAX_WARM_FILE_BYTES as u64 {
53 return None;
54 }
55 crate::core::io_boundary::read_file_lossy(path).ok()
56 });
57}
58
59fn warm_cache_with<C, F>(cache: &mut C, history: &[RecentFile], token_budget: usize, mut load: F)
60where
61 C: WarmCache,
62 F: FnMut(&str) -> Option<String>,
63{
64 let warming_limit = token_budget.saturating_mul(4) / 5;
65
66 for recent in history {
67 if cache.total_tokens() >= warming_limit {
68 break;
69 }
70 if cache.contains(&recent.path) {
71 continue;
72 }
73
74 let Some(content) = load(&recent.path) else {
75 continue;
76 };
77 if content.len() > MAX_WARM_FILE_BYTES {
78 continue;
79 }
80
81 let incoming_tokens = count_tokens(&content);
82 if cache.total_tokens().saturating_add(incoming_tokens) > warming_limit {
83 continue;
84 }
85 cache.insert(&recent.path, &content);
86 }
87}
88
89pub fn collect_recent_files(sessions: &[SessionState]) -> Vec<RecentFile> {
95 collect_recent_files_with(sessions, |_, file| Some(file.path.clone()))
96}
97
98pub fn collect_recent_files_in_project(
101 sessions: &[SessionState],
102 project_root: &str,
103) -> Vec<RecentFile> {
104 let Some(root) = normalized_safe_root(project_root) else {
105 return Vec::new();
106 };
107 let matching: std::collections::HashSet<&str> = sessions
110 .iter()
111 .filter(|session| {
112 session
113 .project_root
114 .as_deref()
115 .and_then(normalized_safe_root)
116 .as_ref()
117 == Some(&root)
118 })
119 .map(|session| session.id.as_str())
120 .collect();
121 collect_recent_files_with(sessions, |session, file| {
122 if file.stale || !matching.contains(session.id.as_str()) {
123 return None;
124 }
125 crate::core::pathjail::jail_path(Path::new(&file.path), &root)
126 .ok()
127 .map(|path| path.to_string_lossy().into_owned())
128 })
129}
130
131fn normalized_safe_root(project_root: &str) -> Option<PathBuf> {
132 let path = Path::new(project_root);
133 if project_root.trim().is_empty() || crate::core::pathutil::is_broad_or_unsafe_root(path) {
134 return None;
135 }
136 Some(crate::core::pathutil::safe_canonicalize_or_self(path))
137}
138
139fn collect_recent_files_with<F>(sessions: &[SessionState], resolve_path: F) -> Vec<RecentFile>
140where
141 F: Fn(&SessionState, &crate::core::session::FileTouched) -> Option<String>,
142{
143 let mut recent_by_path: HashMap<String, RecentFile> = HashMap::new();
144
145 for session in sessions {
146 let last_access = SystemTime::from(session.updated_at);
147 for file in &session.files_touched {
148 let Some(path) = resolve_path(session, file) else {
149 continue;
150 };
151 let recent = recent_by_path
152 .entry(path.clone())
153 .or_insert_with(|| RecentFile {
154 path,
155 last_access,
156 read_count: 0,
157 tokens: file.tokens,
158 });
159 recent.read_count = recent.read_count.saturating_add(file.read_count);
160 recent.tokens = recent.tokens.max(file.tokens);
161 recent.last_access = recent.last_access.max(last_access);
162 }
163 }
164
165 let mut recent: Vec<RecentFile> = recent_by_path.into_values().collect();
166 recent.sort_by(|a, b| {
167 (b.read_count as usize)
168 .saturating_mul(b.tokens)
169 .cmp(&(a.read_count as usize).saturating_mul(a.tokens))
170 .then_with(|| b.last_access.cmp(&a.last_access))
171 .then_with(|| a.path.cmp(&b.path))
172 });
173 recent.truncate(MAX_RECENT_FILES);
174 recent
175}
176
177#[cfg(test)]
178mod tests {
179 use std::collections::HashMap;
180 use std::time::{Duration, SystemTime};
181
182 use chrono::{TimeZone, Utc};
183
184 use super::*;
185
186 #[derive(Default)]
187 struct MemoryCache {
188 entries: HashMap<String, String>,
189 }
190
191 impl WarmCache for MemoryCache {
192 fn contains(&self, path: &str) -> bool {
193 self.entries.contains_key(path)
194 }
195
196 fn total_tokens(&self) -> usize {
197 self.entries
198 .values()
199 .map(|content| count_tokens(content))
200 .sum()
201 }
202
203 fn insert(&mut self, path: &str, content: &str) {
204 self.entries.insert(path.to_string(), content.to_string());
205 }
206 }
207
208 fn recent(path: &str) -> RecentFile {
209 RecentFile {
210 path: path.to_string(),
211 last_access: SystemTime::UNIX_EPOCH,
212 read_count: 1,
213 tokens: 1,
214 }
215 }
216
217 fn session_at(timestamp: i64) -> SessionState {
218 let time = Utc.timestamp_opt(timestamp, 0).unwrap();
219 SessionState {
220 canonical_checkpoint: None,
221 save_gate: Default::default(),
222 last_save_failed: false,
223 id: format!("session-{timestamp}"),
224 version: 0,
225 started_at: time,
226 updated_at: time,
227 project_root: None,
228 shell_cwd: None,
229 task: None,
230 findings: Vec::new(),
231 decisions: Vec::new(),
232 files_touched: Vec::new(),
233 test_results: None,
234 progress: Vec::new(),
235 next_steps: Vec::new(),
236 evidence: Vec::new(),
237 intents: Vec::new(),
238 active_structured_intent: None,
239 stats: crate::core::session::SessionStats::default(),
240 terse_mode: false,
241 compression_level: String::new(),
242 last_consolidate_ts: None,
243 last_aaak_hash: None,
244 extra_roots: Vec::new(),
245 playbook: crate::core::session::Playbook::default(),
246 last_semantic_query: None,
247 last_flush: None,
248 live_zone: Default::default(),
249 handoff_context: Default::default(),
250 }
251 }
252
253 #[test]
254 fn warming_skips_cached_missing_and_oversized_files() {
255 let mut cache = MemoryCache::default();
256 cache
257 .entries
258 .insert("cached.rs".to_string(), "cached".to_string());
259 let history = [
260 recent("cached.rs"),
261 recent("missing.rs"),
262 recent("large.rs"),
263 recent("good.rs"),
264 ];
265 let contents = HashMap::from([
266 ("large.rs", "x".repeat(MAX_WARM_FILE_BYTES + 1)),
267 ("good.rs", "fn good() {}".to_string()),
268 ]);
269 let mut loaded = Vec::new();
270
271 warm_cache_with(&mut cache, &history, 10_000, |path| {
272 loaded.push(path.to_string());
273 contents.get(path).cloned()
274 });
275
276 assert!(!loaded.contains(&"cached.rs".to_string()));
277 assert!(!cache.entries.contains_key("missing.rs"));
278 assert!(!cache.entries.contains_key("large.rs"));
279 assert_eq!(
280 cache.entries.get("good.rs").map(String::as_str),
281 Some("fn good() {}")
282 );
283 }
284
285 #[test]
286 fn warming_stops_at_eighty_percent_of_budget() {
287 let first_content = "first file content";
288 let first_tokens = count_tokens(first_content);
289 let budget = first_tokens.saturating_mul(5).div_ceil(4);
290 let history = [recent("first.rs"), recent("second.rs")];
291 let mut cache = MemoryCache::default();
292 let mut loaded = Vec::new();
293
294 warm_cache_with(&mut cache, &history, budget, |path| {
295 loaded.push(path.to_string());
296 Some(first_content.to_string())
297 });
298
299 assert!(cache.entries.contains_key("first.rs"));
300 assert!(!cache.entries.contains_key("second.rs"));
301 assert_eq!(loaded, vec!["first.rs"]);
302 assert!(cache.total_tokens() <= budget.saturating_mul(4) / 5);
303 }
304
305 #[test]
306 fn recent_files_are_deduplicated_and_ranked_by_expected_benefit() {
307 let mut older = session_at(10);
308 older.touch_file("frequent.rs", None, "full", 10);
309 older.touch_file("frequent.rs", None, "full", 10);
310 older.touch_file("recent.rs", None, "full", 10);
311 older.updated_at = Utc.timestamp_opt(10, 0).unwrap();
312
313 let mut newer = session_at(20);
314 newer.touch_file("frequent.rs", None, "full", 10);
315 newer.touch_file("recent.rs", None, "full", 10);
316 newer.touch_file("peer.rs", None, "full", 10);
317 newer.updated_at = Utc.timestamp_opt(20, 0).unwrap();
318
319 let result = collect_recent_files(&[older, newer]);
320
321 assert_eq!(
322 result
323 .iter()
324 .map(|file| file.path.as_str())
325 .collect::<Vec<_>>(),
326 vec!["frequent.rs", "recent.rs", "peer.rs"]
327 );
328 assert_eq!(result[0].read_count, 3);
329 assert_eq!(
330 result[0].last_access,
331 SystemTime::UNIX_EPOCH + Duration::from_secs(20)
332 );
333 assert_eq!(result[0].tokens, 10);
334 }
335
336 #[test]
337 fn recent_files_prioritize_read_count_times_tokens() {
338 let mut session = session_at(10);
339 session.touch_file("frequent.rs", None, "full", 10);
340 session.touch_file("frequent.rs", None, "full", 10);
341 session.touch_file("small.rs", None, "full", 1);
342 session.touch_file("large.rs", None, "full", 100);
343
344 let result = collect_recent_files(&[session]);
345
346 assert_eq!(
347 result
348 .iter()
349 .map(|file| file.path.as_str())
350 .collect::<Vec<_>>(),
351 vec!["large.rs", "frequent.rs", "small.rs"]
352 );
353 }
354
355 #[test]
356 fn recent_files_are_capped_at_fifty() {
357 let mut session = session_at(10);
358 for index in 0..60 {
359 session.touch_file(&format!("file-{index:02}.rs"), None, "full", 1);
360 }
361
362 let result = collect_recent_files(&[session]);
363
364 assert_eq!(result.len(), MAX_RECENT_FILES);
365 }
366
367 #[cfg(not(feature = "no-jail"))]
368 #[test]
369 fn project_warming_rejails_paths_and_skips_stale_records() {
370 let _data = crate::core::data_dir::isolated_data_dir();
371 let root = tempfile::tempdir().unwrap();
372 let outside = tempfile::tempdir().unwrap();
373 let good = root.path().join("good.rs");
374 let stale = root.path().join("stale.rs");
375 let secret = outside.path().join("secret.rs");
376 for path in [&good, &stale, &secret] {
377 std::fs::write(path, "fixture").unwrap();
378 }
379 let mut session = session_at(10);
380 session.project_root = Some(root.path().to_string_lossy().into_owned());
381 for path in [&good, &stale, &secret] {
382 session.touch_file(&path.to_string_lossy(), None, "full", 1);
383 }
384 session
385 .files_touched
386 .iter_mut()
387 .find(|file| file.path == stale.to_string_lossy().as_ref())
388 .expect("stale fixture")
389 .stale = true;
390
391 let warmed = collect_recent_files_in_project(
392 &[session],
393 root.path().to_str().expect("UTF-8 temporary root"),
394 );
395
396 assert_eq!(warmed.len(), 1);
397 assert_eq!(
398 warmed[0].path,
399 crate::core::pathutil::safe_canonicalize(&good)
400 .unwrap()
401 .to_string_lossy()
402 .into_owned()
403 );
404 }
405
406 #[cfg(feature = "no-jail")]
407 #[test]
408 fn project_warming_no_jail_skips_stale_and_accepts_outside_root() {
409 let root = tempfile::tempdir().unwrap();
410 let outside = tempfile::tempdir().unwrap();
411 let stale = root.path().join("stale.rs");
412 let outside_file = outside.path().join("outside.rs");
413 for path in [&stale, &outside_file] {
414 std::fs::write(path, "fixture").unwrap();
415 }
416
417 let mut session = session_at(10);
418 session.project_root = Some(root.path().to_string_lossy().into_owned());
419 for path in [&stale, &outside_file] {
420 session.touch_file(&path.to_string_lossy(), None, "full", 1);
421 }
422 session
423 .files_touched
424 .iter_mut()
425 .find(|file| file.path == stale.to_string_lossy().as_ref())
426 .expect("stale fixture")
427 .stale = true;
428
429 let warmed = collect_recent_files_in_project(
430 &[session],
431 root.path().to_str().expect("UTF-8 temporary root"),
432 );
433
434 assert_eq!(warmed.len(), 1);
435 assert_eq!(
436 warmed[0].path,
437 crate::core::pathutil::safe_canonicalize(&outside_file)
438 .unwrap()
439 .to_string_lossy()
440 .into_owned()
441 );
442 }
443}