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 app_session_id: None,
225 version: 0,
226 started_at: time,
227 updated_at: time,
228 project_root: None,
229 shell_cwd: None,
230 task: None,
231 findings: Vec::new(),
232 decisions: Vec::new(),
233 files_touched: Vec::new(),
234 test_results: None,
235 progress: Vec::new(),
236 next_steps: Vec::new(),
237 evidence: Vec::new(),
238 intents: Vec::new(),
239 active_structured_intent: None,
240 stats: crate::core::session::SessionStats::default(),
241 terse_mode: false,
242 compression_level: String::new(),
243 last_consolidate_ts: None,
244 last_aaak_hash: None,
245 extra_roots: Vec::new(),
246 playbook: crate::core::session::Playbook::default(),
247 last_semantic_query: None,
248 taint: Default::default(),
249 last_flush: None,
250 live_zone: Default::default(),
251 handoff_context: Default::default(),
252 }
253 }
254
255 #[test]
256 fn warming_skips_cached_missing_and_oversized_files() {
257 let mut cache = MemoryCache::default();
258 cache
259 .entries
260 .insert("cached.rs".to_string(), "cached".to_string());
261 let history = [
262 recent("cached.rs"),
263 recent("missing.rs"),
264 recent("large.rs"),
265 recent("good.rs"),
266 ];
267 let contents = HashMap::from([
268 ("large.rs", "x".repeat(MAX_WARM_FILE_BYTES + 1)),
269 ("good.rs", "fn good() {}".to_string()),
270 ]);
271 let mut loaded = Vec::new();
272
273 warm_cache_with(&mut cache, &history, 10_000, |path| {
274 loaded.push(path.to_string());
275 contents.get(path).cloned()
276 });
277
278 assert!(!loaded.contains(&"cached.rs".to_string()));
279 assert!(!cache.entries.contains_key("missing.rs"));
280 assert!(!cache.entries.contains_key("large.rs"));
281 assert_eq!(
282 cache.entries.get("good.rs").map(String::as_str),
283 Some("fn good() {}")
284 );
285 }
286
287 #[test]
288 fn warming_stops_at_eighty_percent_of_budget() {
289 let first_content = "first file content";
290 let first_tokens = count_tokens(first_content);
291 let budget = first_tokens.saturating_mul(5).div_ceil(4);
292 let history = [recent("first.rs"), recent("second.rs")];
293 let mut cache = MemoryCache::default();
294 let mut loaded = Vec::new();
295
296 warm_cache_with(&mut cache, &history, budget, |path| {
297 loaded.push(path.to_string());
298 Some(first_content.to_string())
299 });
300
301 assert!(cache.entries.contains_key("first.rs"));
302 assert!(!cache.entries.contains_key("second.rs"));
303 assert_eq!(loaded, vec!["first.rs"]);
304 assert!(cache.total_tokens() <= budget.saturating_mul(4) / 5);
305 }
306
307 #[test]
308 fn recent_files_are_deduplicated_and_ranked_by_expected_benefit() {
309 let mut older = session_at(10);
310 older.touch_file("frequent.rs", None, "full", 10);
311 older.touch_file("frequent.rs", None, "full", 10);
312 older.touch_file("recent.rs", None, "full", 10);
313 older.updated_at = Utc.timestamp_opt(10, 0).unwrap();
314
315 let mut newer = session_at(20);
316 newer.touch_file("frequent.rs", None, "full", 10);
317 newer.touch_file("recent.rs", None, "full", 10);
318 newer.touch_file("peer.rs", None, "full", 10);
319 newer.updated_at = Utc.timestamp_opt(20, 0).unwrap();
320
321 let result = collect_recent_files(&[older, newer]);
322
323 assert_eq!(
324 result
325 .iter()
326 .map(|file| file.path.as_str())
327 .collect::<Vec<_>>(),
328 vec!["frequent.rs", "recent.rs", "peer.rs"]
329 );
330 assert_eq!(result[0].read_count, 3);
331 assert_eq!(
332 result[0].last_access,
333 SystemTime::UNIX_EPOCH + Duration::from_secs(20)
334 );
335 assert_eq!(result[0].tokens, 10);
336 }
337
338 #[test]
339 fn recent_files_prioritize_read_count_times_tokens() {
340 let mut session = session_at(10);
341 session.touch_file("frequent.rs", None, "full", 10);
342 session.touch_file("frequent.rs", None, "full", 10);
343 session.touch_file("small.rs", None, "full", 1);
344 session.touch_file("large.rs", None, "full", 100);
345
346 let result = collect_recent_files(&[session]);
347
348 assert_eq!(
349 result
350 .iter()
351 .map(|file| file.path.as_str())
352 .collect::<Vec<_>>(),
353 vec!["large.rs", "frequent.rs", "small.rs"]
354 );
355 }
356
357 #[test]
358 fn recent_files_are_capped_at_fifty() {
359 let mut session = session_at(10);
360 for index in 0..60 {
361 session.touch_file(&format!("file-{index:02}.rs"), None, "full", 1);
362 }
363
364 let result = collect_recent_files(&[session]);
365
366 assert_eq!(result.len(), MAX_RECENT_FILES);
367 }
368
369 #[cfg(not(feature = "no-jail"))]
370 #[test]
371 fn project_warming_rejails_paths_and_skips_stale_records() {
372 let _data = crate::core::data_dir::isolated_data_dir();
373 let root = tempfile::tempdir().unwrap();
374 let outside = tempfile::tempdir().unwrap();
375 let good = root.path().join("good.rs");
376 let stale = root.path().join("stale.rs");
377 let secret = outside.path().join("secret.rs");
378 for path in [&good, &stale, &secret] {
379 std::fs::write(path, "fixture").unwrap();
380 }
381 let mut session = session_at(10);
382 session.project_root = Some(root.path().to_string_lossy().into_owned());
383 for path in [&good, &stale, &secret] {
384 session.touch_file(&path.to_string_lossy(), None, "full", 1);
385 }
386 session
387 .files_touched
388 .iter_mut()
389 .find(|file| file.path == stale.to_string_lossy().as_ref())
390 .expect("stale fixture")
391 .stale = true;
392
393 let warmed = collect_recent_files_in_project(
394 &[session],
395 root.path().to_str().expect("UTF-8 temporary root"),
396 );
397
398 assert_eq!(warmed.len(), 1);
399 assert_eq!(
400 warmed[0].path,
401 crate::core::pathutil::safe_canonicalize(&good)
402 .unwrap()
403 .to_string_lossy()
404 .into_owned()
405 );
406 }
407
408 #[cfg(feature = "no-jail")]
409 #[test]
410 fn project_warming_no_jail_skips_stale_and_accepts_outside_root() {
411 let root = tempfile::tempdir().unwrap();
412 let outside = tempfile::tempdir().unwrap();
413 let stale = root.path().join("stale.rs");
414 let outside_file = outside.path().join("outside.rs");
415 for path in [&stale, &outside_file] {
416 std::fs::write(path, "fixture").unwrap();
417 }
418
419 let mut session = session_at(10);
420 session.project_root = Some(root.path().to_string_lossy().into_owned());
421 for path in [&stale, &outside_file] {
422 session.touch_file(&path.to_string_lossy(), None, "full", 1);
423 }
424 session
425 .files_touched
426 .iter_mut()
427 .find(|file| file.path == stale.to_string_lossy().as_ref())
428 .expect("stale fixture")
429 .stale = true;
430
431 let warmed = collect_recent_files_in_project(
432 &[session],
433 root.path().to_str().expect("UTF-8 temporary root"),
434 );
435
436 assert_eq!(warmed.len(), 1);
437 assert_eq!(
438 warmed[0].path,
439 crate::core::pathutil::safe_canonicalize(&outside_file)
440 .unwrap()
441 .to_string_lossy()
442 .into_owned()
443 );
444 }
445}