1use std::collections::HashMap;
20use std::fmt;
21use std::path::{Path, PathBuf};
22use std::time::{Duration, Instant};
23
24mod text;
25
26pub use text::TextBackend;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum BackendKind {
33 Text,
35}
36
37impl fmt::Display for BackendKind {
38 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39 match self {
40 Self::Text => write!(f, "text"),
41 }
42 }
43}
44
45#[derive(Debug, Clone)]
47pub enum CacheOp {
48 Read {
50 start: usize,
52 end: usize,
54 },
55 Grep {
57 pattern: String,
59 context_lines: usize,
61 },
62 Head {
64 lines: usize,
66 },
67 Tail {
69 lines: usize,
71 },
72 Stats,
74}
75
76#[derive(Debug, Clone)]
78pub struct CacheStats {
79 pub line_count: usize,
81 pub disk_bytes: u64,
83 pub summary: String,
85}
86
87#[derive(Debug, thiserror::Error)]
89pub enum CacheError {
90 #[error("I/O error: {0}")]
92 Io(#[from] std::io::Error),
93
94 #[error("invalid regex pattern: {0}")]
96 InvalidPattern(String),
97
98 #[error("cache entry not found: {ref_id}")]
100 NotFound {
101 ref_id: String,
103 },
104
105 #[error("cache entry expired: {ref_id}")]
107 Expired {
108 ref_id: String,
110 },
111
112 #[error("line range out of bounds: requested {start}..{end}, have {total} lines")]
114 OutOfBounds {
115 start: usize,
117 end: usize,
119 total: usize,
121 },
122}
123
124pub trait CacheBackend: Send + Sync {
138 fn kind(&self) -> BackendKind;
140
141 fn execute(&self, op: CacheOp) -> Result<String, CacheError>;
143
144 fn stats(&self) -> Result<CacheStats, CacheError>;
146
147 fn preview(&self, max_lines: usize) -> Result<String, CacheError>;
149
150 fn disk_bytes(&self) -> Result<u64, CacheError>;
152}
153
154pub struct CacheEntry {
158 pub ref_id: String,
160 pub backend: Box<dyn CacheBackend>,
162 pub tool_name: String,
164 pub created_at: Instant,
166 pub expires_at: Instant,
168}
169
170impl fmt::Debug for CacheEntry {
171 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172 f.debug_struct("CacheEntry")
173 .field("ref_id", &self.ref_id)
174 .field("tool_name", &self.tool_name)
175 .field("kind", &self.backend.kind())
176 .finish_non_exhaustive()
177 }
178}
179
180#[derive(Debug, Clone)]
184pub struct ResultCacheConfig {
185 pub ttl: Duration,
187 pub max_disk_bytes: u64,
189 pub preview_lines: usize,
191}
192
193impl Default for ResultCacheConfig {
194 fn default() -> Self {
195 Self {
196 ttl: Duration::from_secs(30 * 60), max_disk_bytes: 100 * 1024 * 1024, preview_lines: 20,
199 }
200 }
201}
202
203pub struct ResultCache {
211 entries: HashMap<String, CacheEntry>,
212 config: ResultCacheConfig,
213 base_dir: PathBuf,
214 next_id: u64,
215}
216
217impl fmt::Debug for ResultCache {
218 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219 f.debug_struct("ResultCache")
220 .field("entries", &self.entries.len())
221 .field("base_dir", &self.base_dir)
222 .finish_non_exhaustive()
223 }
224}
225
226impl ResultCache {
227 pub fn new(
235 base_dir: impl Into<PathBuf>,
236 config: ResultCacheConfig,
237 ) -> Result<Self, CacheError> {
238 let base_dir = base_dir.into();
239 std::fs::create_dir_all(&base_dir)?;
240 Ok(Self {
241 entries: HashMap::new(),
242 config,
243 base_dir,
244 next_id: 0,
245 })
246 }
247
248 pub fn store(
257 &mut self,
258 tool_name: &str,
259 content: &str,
260 backend_kind: BackendKind,
261 ) -> Result<String, CacheError> {
262 let ref_id = self.generate_ref_id();
263 let now = Instant::now();
264
265 let backend: Box<dyn CacheBackend> = match backend_kind {
266 BackendKind::Text => {
267 let path = self.base_dir.join(format!("{ref_id}.txt"));
268 Box::new(TextBackend::store(content, &path)?)
269 }
270 };
271
272 let entry = CacheEntry {
273 ref_id: ref_id.clone(),
274 backend,
275 tool_name: tool_name.to_string(),
276 created_at: now,
277 expires_at: now + self.config.ttl,
278 };
279
280 self.entries.insert(ref_id.clone(), entry);
281 Ok(ref_id)
282 }
283
284 pub fn get(&self, ref_id: &str) -> Result<&CacheEntry, CacheError> {
286 let entry = self
287 .entries
288 .get(ref_id)
289 .ok_or_else(|| CacheError::NotFound {
290 ref_id: ref_id.to_string(),
291 })?;
292
293 if Instant::now() >= entry.expires_at {
294 return Err(CacheError::Expired {
295 ref_id: ref_id.to_string(),
296 });
297 }
298
299 Ok(entry)
300 }
301
302 pub fn execute_op(&self, ref_id: &str, op: CacheOp) -> Result<String, CacheError> {
304 let entry = self.get(ref_id)?;
305 entry.backend.execute(op)
306 }
307
308 pub fn evict_expired(&mut self) -> usize {
310 let now = Instant::now();
311 let expired: Vec<String> = self
312 .entries
313 .iter()
314 .filter(|(_, e)| now >= e.expires_at)
315 .map(|(k, _)| k.clone())
316 .collect();
317
318 let count = expired.len();
319 for ref_id in &expired {
320 if let Some(entry) = self.entries.remove(ref_id) {
321 self.cleanup_entry(&entry);
323 }
324 }
325 count
326 }
327
328 pub fn total_bytes(&self) -> u64 {
330 self.entries
331 .values()
332 .filter_map(|e| e.backend.disk_bytes().ok())
333 .sum()
334 }
335
336 pub fn len(&self) -> usize {
338 self.entries.len()
339 }
340
341 pub fn is_empty(&self) -> bool {
343 self.entries.is_empty()
344 }
345
346 pub fn iter(&self) -> impl Iterator<Item = (&str, &CacheEntry)> {
350 self.entries.iter().map(|(k, v)| (k.as_str(), v))
351 }
352
353 pub fn preview_lines(&self) -> usize {
355 self.config.preview_lines
356 }
357
358 pub fn base_dir(&self) -> &Path {
360 &self.base_dir
361 }
362
363 fn generate_ref_id(&mut self) -> String {
364 self.next_id += 1;
365 format!("ref_{:04x}", self.next_id)
366 }
367
368 fn cleanup_entry(&self, entry: &CacheEntry) {
369 match entry.backend.kind() {
370 BackendKind::Text => {
371 let path = self.base_dir.join(format!("{}.txt", entry.ref_id));
372 let _ = std::fs::remove_file(path);
373 }
374 }
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 fn test_cache(dir: &Path) -> ResultCache {
383 ResultCache::new(dir, ResultCacheConfig::default()).unwrap()
384 }
385
386 #[test]
387 fn test_store_and_get() {
388 let dir = tempfile::tempdir().unwrap();
389 let mut cache = test_cache(dir.path());
390
391 let ref_id = cache
392 .store("db_sql", "line1\nline2\nline3", BackendKind::Text)
393 .unwrap();
394 assert!(ref_id.starts_with("ref_"));
395
396 let entry = cache.get(&ref_id).unwrap();
397 assert_eq!(entry.tool_name, "db_sql");
398 assert_eq!(entry.backend.kind(), BackendKind::Text);
399 }
400
401 #[test]
402 fn test_get_not_found() {
403 let dir = tempfile::tempdir().unwrap();
404 let cache = test_cache(dir.path());
405
406 let result = cache.get("ref_nonexistent");
407 assert!(matches!(result, Err(CacheError::NotFound { .. })));
408 }
409
410 #[test]
411 fn test_execute_op() {
412 let dir = tempfile::tempdir().unwrap();
413 let mut cache = test_cache(dir.path());
414
415 let ref_id = cache
416 .store("test", "alpha\nbeta\ngamma\ndelta", BackendKind::Text)
417 .unwrap();
418
419 let result = cache
420 .execute_op(&ref_id, CacheOp::Head { lines: 2 })
421 .unwrap();
422 assert_eq!(result, "alpha\nbeta");
423 }
424
425 #[test]
426 fn test_evict_expired() {
427 let dir = tempfile::tempdir().unwrap();
428 let config = ResultCacheConfig {
429 ttl: Duration::from_millis(1),
430 ..Default::default()
431 };
432 let mut cache = ResultCache::new(dir.path(), config).unwrap();
433
434 cache.store("test", "data", BackendKind::Text).unwrap();
435 assert_eq!(cache.len(), 1);
436
437 std::thread::sleep(Duration::from_millis(10));
439
440 let evicted = cache.evict_expired();
441 assert_eq!(evicted, 1);
442 assert!(cache.is_empty());
443 }
444
445 #[test]
446 fn test_expired_entry_returns_error() {
447 let dir = tempfile::tempdir().unwrap();
448 let config = ResultCacheConfig {
449 ttl: Duration::from_millis(1),
450 ..Default::default()
451 };
452 let mut cache = ResultCache::new(dir.path(), config).unwrap();
453
454 let ref_id = cache.store("test", "data", BackendKind::Text).unwrap();
455
456 std::thread::sleep(Duration::from_millis(10));
457
458 assert!(matches!(
459 cache.get(&ref_id),
460 Err(CacheError::Expired { .. })
461 ));
462 }
463
464 #[test]
465 fn test_total_bytes() {
466 let dir = tempfile::tempdir().unwrap();
467 let mut cache = test_cache(dir.path());
468
469 cache
470 .store("test", "hello world", BackendKind::Text)
471 .unwrap();
472 assert!(cache.total_bytes() > 0);
473 }
474
475 #[test]
476 fn test_multiple_entries() {
477 let dir = tempfile::tempdir().unwrap();
478 let mut cache = test_cache(dir.path());
479
480 let r1 = cache.store("t1", "data1", BackendKind::Text).unwrap();
481 let r2 = cache.store("t2", "data2", BackendKind::Text).unwrap();
482
483 assert_ne!(r1, r2);
484 assert_eq!(cache.len(), 2);
485
486 assert_eq!(cache.get(&r1).unwrap().tool_name, "t1");
487 assert_eq!(cache.get(&r2).unwrap().tool_name, "t2");
488 }
489
490 #[test]
491 fn test_iter_returns_all_entries() {
492 let dir = tempfile::tempdir().unwrap();
493 let mut cache = test_cache(dir.path());
494
495 let r1 = cache
496 .store("db_sql", "SELECT 1", BackendKind::Text)
497 .unwrap();
498 let r2 = cache
499 .store("web_search", "results here", BackendKind::Text)
500 .unwrap();
501 let r3 = cache
502 .store("db_sql", "SELECT 2", BackendKind::Text)
503 .unwrap();
504
505 let entries: HashMap<String, String> = cache
506 .iter()
507 .map(|(ref_id, entry)| (ref_id.to_string(), entry.tool_name.clone()))
508 .collect();
509
510 assert_eq!(entries.len(), 3);
511 assert_eq!(entries[&r1], "db_sql");
512 assert_eq!(entries[&r2], "web_search");
513 assert_eq!(entries[&r3], "db_sql");
514 }
515
516 #[test]
517 fn test_backend_kind_display() {
518 assert_eq!(format!("{}", BackendKind::Text), "text");
519 }
520
521 #[test]
522 fn test_cache_debug() {
523 let dir = tempfile::tempdir().unwrap();
524 let cache = test_cache(dir.path());
525 let debug = format!("{cache:?}");
526 assert!(debug.contains("ResultCache"));
527 }
528
529 #[test]
530 fn test_entry_debug() {
531 let dir = tempfile::tempdir().unwrap();
532 let mut cache = test_cache(dir.path());
533 let ref_id = cache.store("tool", "data", BackendKind::Text).unwrap();
534 let entry = cache.get(&ref_id).unwrap();
535 let debug = format!("{entry:?}");
536 assert!(debug.contains("CacheEntry"));
537 assert!(debug.contains(&ref_id));
538 }
539
540 #[test]
541 fn test_config_default() {
542 let config = ResultCacheConfig::default();
543 assert_eq!(config.ttl, Duration::from_secs(30 * 60));
544 assert_eq!(config.max_disk_bytes, 100 * 1024 * 1024);
545 assert_eq!(config.preview_lines, 20);
546 }
547
548 #[test]
549 fn test_evict_cleans_files() {
550 let dir = tempfile::tempdir().unwrap();
551 let config = ResultCacheConfig {
552 ttl: Duration::from_millis(1),
553 ..Default::default()
554 };
555 let mut cache = ResultCache::new(dir.path(), config).unwrap();
556
557 let ref_id = cache.store("test", "data", BackendKind::Text).unwrap();
558 let file_path = dir.path().join(format!("{ref_id}.txt"));
559 assert!(file_path.exists());
560
561 std::thread::sleep(Duration::from_millis(10));
562 cache.evict_expired();
563
564 assert!(!file_path.exists());
565 }
566}