1use anyhow::{Context, Result};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use crate::entry::CacheEntry;
10use crate::key::CACHE_VERSION;
11
12#[derive(Debug, Default, Clone)]
14pub struct CleanupStats {
15 pub removed_count: usize,
17 pub freed_bytes: u64,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct CleanupPolicy {
24 pub max_cache_size_mb: usize,
26
27 pub max_age_days: usize,
29
30 pub max_idle_days: usize,
32
33 pub remove_version_mismatch: bool,
35}
36
37impl Default for CleanupPolicy {
38 fn default() -> Self {
39 Self {
40 max_cache_size_mb: 500,
41 max_age_days: 90,
42 max_idle_days: 30,
43 remove_version_mismatch: true,
44 }
45 }
46}
47
48impl CleanupPolicy {
49 pub fn is_stale(&self, entry: &CacheEntry, current_version: &str) -> bool {
51 if self.remove_version_mismatch && entry.version != current_version {
53 return true;
54 }
55
56 if entry.age_days() > self.max_age_days as i64 {
58 return true;
59 }
60
61 if entry.idle_days() > self.max_idle_days as i64 {
63 return true;
64 }
65
66 false
67 }
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72#[serde(tag = "type", rename_all = "snake_case")]
73pub enum CleanupTrigger {
74 Periodic { days: usize },
76
77 OnSizeLimit { threshold_mb: usize },
79
80 Combined {
82 periodic_days: Option<usize>,
83 size_limit_mb: Option<usize>,
84 },
85
86 Manual,
88}
89
90impl Default for CleanupTrigger {
91 fn default() -> Self {
92 Self::Combined {
93 periodic_days: Some(7),
94 size_limit_mb: Some(500),
95 }
96 }
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101struct CleanupState {
102 last_cleanup_timestamp: DateTime<Utc>,
103 last_cleanup_type: String,
104}
105
106impl Default for CleanupState {
107 fn default() -> Self {
108 Self {
109 last_cleanup_timestamp: DateTime::from_timestamp(0, 0).unwrap(),
110 last_cleanup_type: "none".to_string(),
111 }
112 }
113}
114
115pub struct CleanupManager {
117 cache_dir: PathBuf,
118 policy: CleanupPolicy,
119 trigger: CleanupTrigger,
120 state_file: PathBuf,
121}
122
123impl CleanupManager {
124 pub fn new<P: AsRef<Path>>(cache_dir: P) -> Result<Self> {
126 Self::with_config(
127 cache_dir,
128 CleanupPolicy::default(),
129 CleanupTrigger::default(),
130 )
131 }
132
133 pub fn with_config<P: AsRef<Path>>(
135 cache_dir: P,
136 policy: CleanupPolicy,
137 trigger: CleanupTrigger,
138 ) -> Result<Self> {
139 let cache_dir = cache_dir.as_ref().to_path_buf();
140 let state_file = cache_dir.join("cleanup_state.json");
141
142 Ok(Self {
143 cache_dir,
144 policy,
145 trigger,
146 state_file,
147 })
148 }
149
150 pub fn should_run_periodic_cleanup(&self) -> Result<bool> {
152 match &self.trigger {
153 CleanupTrigger::Manual => Ok(false),
154 CleanupTrigger::Periodic { days } => {
155 let state = self.load_state()?;
156 let elapsed_days = (Utc::now() - state.last_cleanup_timestamp).num_days();
157 Ok(elapsed_days >= *days as i64)
158 }
159 CleanupTrigger::Combined { periodic_days, .. } => {
160 if let Some(days) = periodic_days {
161 let state = self.load_state()?;
162 let elapsed_days = (Utc::now() - state.last_cleanup_timestamp).num_days();
163 Ok(elapsed_days >= *days as i64)
164 } else {
165 Ok(false)
166 }
167 }
168 CleanupTrigger::OnSizeLimit { .. } => Ok(false),
169 }
170 }
171
172 pub fn is_over_size_limit(&self) -> Result<bool> {
174 let threshold_mb = match &self.trigger {
175 CleanupTrigger::OnSizeLimit { threshold_mb } => *threshold_mb,
176 CleanupTrigger::Combined { size_limit_mb, .. } => {
177 if let Some(limit) = size_limit_mb {
178 *limit
179 } else {
180 return Ok(false);
181 }
182 }
183 _ => return Ok(false),
184 };
185
186 let total_size = self.calculate_total_size()?;
187 let threshold_bytes = (threshold_mb * 1_048_576) as u64;
188
189 Ok(total_size > threshold_bytes)
190 }
191
192 fn calculate_total_size(&self) -> Result<u64> {
194 let mut total = 0u64;
195
196 if !self.cache_dir.exists() {
197 return Ok(0);
198 }
199
200 for entry in walkdir::WalkDir::new(&self.cache_dir)
201 .into_iter()
202 .filter_map(|e| e.ok())
203 {
204 if entry.file_type().is_file() {
205 if let Ok(metadata) = entry.metadata() {
206 total += metadata.len();
207 }
208 }
209 }
210
211 Ok(total)
212 }
213
214 pub fn cleanup_stale_entries(&self) -> Result<CleanupStats> {
216 let mut stats = CleanupStats::default();
217
218 if !self.cache_dir.exists() {
219 return Ok(stats);
220 }
221
222 for entry in walkdir::WalkDir::new(&self.cache_dir)
223 .into_iter()
224 .filter_map(|e| e.ok())
225 {
226 if !entry.file_type().is_file() {
227 continue;
228 }
229
230 let path = entry.path();
231 if !path.extension().map_or(false, |ext| ext == "json") {
232 continue;
233 }
234
235 if let Ok(content) = fs::read_to_string(path) {
237 if let Ok(cache_entry) = serde_json::from_str::<CacheEntry>(&content) {
238 if self.policy.is_stale(&cache_entry, CACHE_VERSION) {
239 if let Ok(metadata) = fs::metadata(path) {
241 stats.freed_bytes += metadata.len();
242 }
243
244 if fs::remove_file(path).is_ok() {
246 stats.removed_count += 1;
247 log::debug!("Removed stale cache entry: {}", path.display());
248 }
249 }
250 }
251 }
252 }
253
254 self.save_state(CleanupState {
256 last_cleanup_timestamp: Utc::now(),
257 last_cleanup_type: "stale".to_string(),
258 })?;
259
260 Ok(stats)
261 }
262
263 pub fn cleanup_by_size(&self) -> Result<CleanupStats> {
265 let mut stats = CleanupStats::default();
266
267 if !self.cache_dir.exists() {
268 return Ok(stats);
269 }
270
271 let mut entries: Vec<(PathBuf, CacheEntry, u64)> = Vec::new();
273
274 for entry in walkdir::WalkDir::new(&self.cache_dir)
275 .into_iter()
276 .filter_map(|e| e.ok())
277 {
278 if !entry.file_type().is_file() {
279 continue;
280 }
281
282 let path = entry.path();
283 if !path.extension().map_or(false, |ext| ext == "json") {
284 continue;
285 }
286
287 if let Ok(content) = fs::read_to_string(path) {
288 if let Ok(cache_entry) = serde_json::from_str::<CacheEntry>(&content) {
289 if let Ok(metadata) = fs::metadata(path) {
290 entries.push((path.to_path_buf(), cache_entry, metadata.len()));
291 }
292 }
293 }
294 }
295
296 entries.sort_by_key(|(_, entry, _)| entry.metadata.last_accessed);
298
299 let total_size = entries.iter().map(|(_, _, size)| size).sum::<u64>();
301 let max_size = (self.policy.max_cache_size_mb * 1_048_576) as u64;
302
303 if total_size <= max_size {
304 return Ok(stats);
305 }
306
307 let mut target_removal = total_size - max_size;
308
309 for (path, _, size) in entries {
311 if target_removal == 0 {
312 break;
313 }
314
315 if fs::remove_file(&path).is_ok() {
316 stats.removed_count += 1;
317 stats.freed_bytes += size;
318 target_removal = target_removal.saturating_sub(size);
319 log::debug!("Removed LRU cache entry: {}", path.display());
320 }
321 }
322
323 self.save_state(CleanupState {
325 last_cleanup_timestamp: Utc::now(),
326 last_cleanup_type: "size".to_string(),
327 })?;
328
329 Ok(stats)
330 }
331
332 fn load_state(&self) -> Result<CleanupState> {
334 if !self.state_file.exists() {
335 return Ok(CleanupState::default());
336 }
337
338 let content =
339 fs::read_to_string(&self.state_file).context("Failed to read cleanup state file")?;
340
341 let state: CleanupState =
342 serde_json::from_str(&content).context("Failed to parse cleanup state")?;
343
344 Ok(state)
345 }
346
347 fn save_state(&self, state: CleanupState) -> Result<()> {
349 fs::create_dir_all(self.cache_dir.parent().unwrap_or(&self.cache_dir))?;
350
351 let content =
352 serde_json::to_string_pretty(&state).context("Failed to serialize cleanup state")?;
353
354 fs::write(&self.state_file, content).context("Failed to write cleanup state file")?;
355
356 Ok(())
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use crate::entry::CacheEntry;
364 use tempfile::TempDir;
365
366 fn make_entry(version: &str, ns: &str, key: &str, value: &str) -> CacheEntry {
367 CacheEntry::new(
368 version.to_string(),
369 ns.to_string(),
370 key.to_string(),
371 value.to_string(),
372 10,
373 )
374 }
375
376 #[test]
377 fn test_cleanup_policy_stale_by_age() {
378 let policy = CleanupPolicy {
379 max_age_days: 90,
380 max_idle_days: 30,
381 remove_version_mismatch: true,
382 max_cache_size_mb: 500,
383 };
384
385 let mut entry = make_entry("1.0.0", "ns", "abc123", "test");
386 entry.metadata.created_at = Utc::now() - chrono::Duration::days(100);
387
388 assert!(policy.is_stale(&entry, "1.0.0"));
389 }
390
391 #[test]
392 fn test_cleanup_policy_stale_by_version() {
393 let policy = CleanupPolicy::default();
394 let entry = make_entry("0.9.0", "ns", "abc123", "test");
395 assert!(policy.is_stale(&entry, "1.0.0"));
396 }
397
398 #[test]
399 fn test_cleanup_manager_creation() {
400 let temp_dir = TempDir::new().unwrap();
401 let manager = CleanupManager::new(temp_dir.path()).unwrap();
402 assert!(manager.should_run_periodic_cleanup().unwrap());
403 }
404
405 #[test]
406 fn test_cleanup_stale_entries() {
407 let temp_dir = TempDir::new().unwrap();
408 let cache_dir = temp_dir.path().join("cache");
409 fs::create_dir_all(&cache_dir).unwrap();
410
411 let manager = CleanupManager::new(&cache_dir).unwrap();
412
413 let mut old_entry = make_entry("0.9.0", "ns", "abc123", "test");
414 old_entry.metadata.created_at = Utc::now() - chrono::Duration::days(100);
415
416 let path = cache_dir.join("ns").join("ab");
417 fs::create_dir_all(&path).unwrap();
418 let file_path = path.join("abc123.json");
419 fs::write(&file_path, serde_json::to_string(&old_entry).unwrap()).unwrap();
420
421 let stats = manager.cleanup_stale_entries().unwrap();
422
423 assert_eq!(stats.removed_count, 1);
424 assert!(stats.freed_bytes > 0);
425 assert!(!file_path.exists());
426 }
427
428 #[test]
431 fn test_is_stale_not_stale_when_fresh() {
432 let policy = CleanupPolicy {
433 max_age_days: 90,
434 max_idle_days: 30,
435 remove_version_mismatch: true,
436 max_cache_size_mb: 500,
437 };
438
439 let entry = make_entry("1.0.0", "ns", "abc123", "test");
440 assert!(!policy.is_stale(&entry, "1.0.0"));
441 }
442
443 #[test]
444 fn test_is_stale_version_mismatch_disabled() {
445 let policy = CleanupPolicy {
446 max_age_days: 90,
447 max_idle_days: 30,
448 remove_version_mismatch: false,
449 max_cache_size_mb: 500,
450 };
451
452 let entry = make_entry("0.9.0", "ns", "abc123", "test");
453 assert!(!policy.is_stale(&entry, "1.0.0"));
454 }
455
456 #[test]
457 fn test_is_stale_age_boundary_equal() {
458 let policy = CleanupPolicy {
459 max_age_days: 90,
460 max_idle_days: 30,
461 remove_version_mismatch: false,
462 max_cache_size_mb: 500,
463 };
464
465 let mut entry = make_entry("1.0.0", "ns", "abc123", "test");
466
467 entry.metadata.created_at = Utc::now() - chrono::Duration::days(90);
469 entry.metadata.last_accessed = Utc::now();
470 assert!(!policy.is_stale(&entry, "1.0.0"));
471
472 entry.metadata.created_at = Utc::now() - chrono::Duration::days(91);
474 assert!(policy.is_stale(&entry, "1.0.0"));
475 }
476
477 #[test]
478 fn test_is_stale_idle_boundary_equal() {
479 let policy = CleanupPolicy {
480 max_age_days: 90,
481 max_idle_days: 30,
482 remove_version_mismatch: false,
483 max_cache_size_mb: 500,
484 };
485
486 let mut entry = make_entry("1.0.0", "ns", "abc123", "test");
487
488 entry.metadata.last_accessed = Utc::now() - chrono::Duration::days(30);
489 assert!(!policy.is_stale(&entry, "1.0.0"));
490
491 entry.metadata.last_accessed = Utc::now() - chrono::Duration::days(31);
492 assert!(policy.is_stale(&entry, "1.0.0"));
493 }
494
495 #[test]
496 fn test_is_stale_both_conditions_and() {
497 let policy = CleanupPolicy {
498 max_age_days: 90,
499 max_idle_days: 30,
500 remove_version_mismatch: true,
501 max_cache_size_mb: 500,
502 };
503
504 let mut entry = make_entry("1.0.0", "ns", "abc123", "test");
505 entry.metadata.created_at = Utc::now() - chrono::Duration::days(10);
506 entry.metadata.last_accessed = Utc::now() - chrono::Duration::days(5);
507 assert!(!policy.is_stale(&entry, "1.0.0"));
508 }
509
510 #[test]
513 fn test_should_run_periodic_cleanup_manual_trigger() {
514 let temp_dir = TempDir::new().unwrap();
515 let manager = CleanupManager::with_config(
516 temp_dir.path(),
517 CleanupPolicy::default(),
518 CleanupTrigger::Manual,
519 )
520 .unwrap();
521
522 assert!(!manager.should_run_periodic_cleanup().unwrap());
523 }
524
525 #[test]
526 fn test_should_run_periodic_cleanup_on_size_limit_trigger() {
527 let temp_dir = TempDir::new().unwrap();
528 let manager = CleanupManager::with_config(
529 temp_dir.path(),
530 CleanupPolicy::default(),
531 CleanupTrigger::OnSizeLimit { threshold_mb: 100 },
532 )
533 .unwrap();
534
535 assert!(!manager.should_run_periodic_cleanup().unwrap());
536 }
537
538 #[test]
539 fn test_should_run_periodic_cleanup_periodic_trigger() {
540 let temp_dir = TempDir::new().unwrap();
541 let manager = CleanupManager::with_config(
542 temp_dir.path(),
543 CleanupPolicy::default(),
544 CleanupTrigger::Periodic { days: 7 },
545 )
546 .unwrap();
547
548 assert!(manager.should_run_periodic_cleanup().unwrap());
549 }
550
551 #[test]
552 fn test_should_run_periodic_cleanup_boundary() {
553 let temp_dir = TempDir::new().unwrap();
554 let cache_dir = temp_dir.path();
555
556 let manager = CleanupManager::with_config(
557 cache_dir,
558 CleanupPolicy::default(),
559 CleanupTrigger::Periodic { days: 7 },
560 )
561 .unwrap();
562
563 let state = CleanupState {
564 last_cleanup_timestamp: Utc::now() - chrono::Duration::days(7),
565 last_cleanup_type: "test".to_string(),
566 };
567 manager.save_state(state).unwrap();
568 assert!(manager.should_run_periodic_cleanup().unwrap());
569
570 let state = CleanupState {
571 last_cleanup_timestamp: Utc::now() - chrono::Duration::days(6),
572 last_cleanup_type: "test".to_string(),
573 };
574 manager.save_state(state).unwrap();
575 assert!(!manager.should_run_periodic_cleanup().unwrap());
576 }
577
578 #[test]
579 fn test_should_run_periodic_cleanup_combined_no_periodic() {
580 let temp_dir = TempDir::new().unwrap();
581 let manager = CleanupManager::with_config(
582 temp_dir.path(),
583 CleanupPolicy::default(),
584 CleanupTrigger::Combined {
585 periodic_days: None,
586 size_limit_mb: Some(500),
587 },
588 )
589 .unwrap();
590
591 assert!(!manager.should_run_periodic_cleanup().unwrap());
592 }
593
594 #[test]
597 fn test_is_over_size_limit_manual_trigger() {
598 let temp_dir = TempDir::new().unwrap();
599 let manager = CleanupManager::with_config(
600 temp_dir.path(),
601 CleanupPolicy::default(),
602 CleanupTrigger::Manual,
603 )
604 .unwrap();
605
606 assert!(!manager.is_over_size_limit().unwrap());
607 }
608
609 #[test]
610 fn test_is_over_size_limit_periodic_trigger() {
611 let temp_dir = TempDir::new().unwrap();
612 let manager = CleanupManager::with_config(
613 temp_dir.path(),
614 CleanupPolicy::default(),
615 CleanupTrigger::Periodic { days: 7 },
616 )
617 .unwrap();
618
619 assert!(!manager.is_over_size_limit().unwrap());
620 }
621
622 #[test]
623 fn test_is_over_size_limit_on_size_limit_under() {
624 let temp_dir = TempDir::new().unwrap();
625 let cache_dir = temp_dir.path();
626
627 let manager = CleanupManager::with_config(
628 cache_dir,
629 CleanupPolicy::default(),
630 CleanupTrigger::OnSizeLimit { threshold_mb: 1 },
631 )
632 .unwrap();
633
634 assert!(!manager.is_over_size_limit().unwrap());
635 }
636
637 #[test]
638 fn test_is_over_size_limit_on_size_limit_over() {
639 let temp_dir = TempDir::new().unwrap();
640 let cache_dir = temp_dir.path();
641
642 let big_data = vec![b'x'; 1_048_577];
643 fs::write(cache_dir.join("bigfile.bin"), &big_data).unwrap();
644
645 let manager = CleanupManager::with_config(
646 cache_dir,
647 CleanupPolicy::default(),
648 CleanupTrigger::OnSizeLimit { threshold_mb: 1 },
649 )
650 .unwrap();
651
652 assert!(manager.is_over_size_limit().unwrap());
653 }
654
655 #[test]
656 fn test_is_over_size_limit_combined_with_size() {
657 let temp_dir = TempDir::new().unwrap();
658 let cache_dir = temp_dir.path();
659
660 let big_data = vec![b'x'; 1_100_000];
661 fs::write(cache_dir.join("bigfile.bin"), &big_data).unwrap();
662
663 let manager = CleanupManager::with_config(
664 cache_dir,
665 CleanupPolicy::default(),
666 CleanupTrigger::Combined {
667 periodic_days: Some(7),
668 size_limit_mb: Some(1),
669 },
670 )
671 .unwrap();
672
673 assert!(manager.is_over_size_limit().unwrap());
674 }
675
676 #[test]
677 fn test_is_over_size_limit_combined_no_size_limit() {
678 let temp_dir = TempDir::new().unwrap();
679 let manager = CleanupManager::with_config(
680 temp_dir.path(),
681 CleanupPolicy::default(),
682 CleanupTrigger::Combined {
683 periodic_days: Some(7),
684 size_limit_mb: None,
685 },
686 )
687 .unwrap();
688
689 assert!(!manager.is_over_size_limit().unwrap());
690 }
691
692 #[test]
695 fn test_calculate_total_size_empty() {
696 let temp_dir = TempDir::new().unwrap();
697 let manager = CleanupManager::new(temp_dir.path()).unwrap();
698 assert_eq!(manager.calculate_total_size().unwrap(), 0);
699 }
700
701 #[test]
702 fn test_calculate_total_size_nonexistent_dir() {
703 let temp_dir = TempDir::new().unwrap();
704 let nonexistent = temp_dir.path().join("does_not_exist");
705 let manager = CleanupManager::with_config(
706 &nonexistent,
707 CleanupPolicy::default(),
708 CleanupTrigger::Manual,
709 )
710 .unwrap();
711
712 assert_eq!(manager.calculate_total_size().unwrap(), 0);
713 }
714
715 #[test]
716 fn test_calculate_total_size_with_files() {
717 let temp_dir = TempDir::new().unwrap();
718 let cache_dir = temp_dir.path();
719
720 fs::write(cache_dir.join("file1.txt"), "hello").unwrap();
721 fs::write(cache_dir.join("file2.txt"), "world!").unwrap();
722
723 let manager = CleanupManager::new(cache_dir).unwrap();
724 let size = manager.calculate_total_size().unwrap();
725 assert!(size >= 11, "Expected at least 11 bytes, got {}", size);
726 }
727
728 #[test]
731 fn test_cleanup_by_size_under_limit() {
732 let temp_dir = TempDir::new().unwrap();
733 let cache_dir = temp_dir.path();
734
735 let policy = CleanupPolicy {
736 max_cache_size_mb: 500,
737 max_age_days: 90,
738 max_idle_days: 30,
739 remove_version_mismatch: true,
740 };
741
742 let manager =
743 CleanupManager::with_config(cache_dir, policy, CleanupTrigger::Manual).unwrap();
744
745 let entry = make_entry("1.0.0", "ns", "abc123", "small value");
746 let dir = cache_dir.join("ns").join("ab");
747 fs::create_dir_all(&dir).unwrap();
748 fs::write(
749 dir.join("abc123.json"),
750 serde_json::to_string(&entry).unwrap(),
751 )
752 .unwrap();
753
754 let stats = manager.cleanup_by_size().unwrap();
755 assert_eq!(stats.removed_count, 0);
756 assert_eq!(stats.freed_bytes, 0);
757 }
758
759 #[test]
760 fn test_cleanup_by_size_lru_ordering() {
761 let temp_dir = TempDir::new().unwrap();
762 let cache_dir = temp_dir.path();
763
764 let policy = CleanupPolicy {
765 max_cache_size_mb: 0,
766 max_age_days: 90,
767 max_idle_days: 30,
768 remove_version_mismatch: true,
769 };
770
771 let manager =
772 CleanupManager::with_config(cache_dir, policy, CleanupTrigger::Manual).unwrap();
773
774 let mut old_entry = make_entry("1.0.0", "ns", "old111", "old value");
775 old_entry.metadata.last_accessed = Utc::now() - chrono::Duration::days(10);
776
777 let new_entry = make_entry("1.0.0", "ns", "new222", "new value");
778
779 let dir_old = cache_dir.join("ns").join("ol");
780 fs::create_dir_all(&dir_old).unwrap();
781 fs::write(
782 dir_old.join("old111.json"),
783 serde_json::to_string(&old_entry).unwrap(),
784 )
785 .unwrap();
786
787 let dir_new = cache_dir.join("ns").join("ne");
788 fs::create_dir_all(&dir_new).unwrap();
789 fs::write(
790 dir_new.join("new222.json"),
791 serde_json::to_string(&new_entry).unwrap(),
792 )
793 .unwrap();
794
795 let stats = manager.cleanup_by_size().unwrap();
796 assert!(stats.removed_count >= 1);
797 assert!(stats.freed_bytes > 0);
798 }
799
800 #[test]
801 fn test_cleanup_by_size_removes_oldest_first() {
802 let temp_dir = TempDir::new().unwrap();
803 let cache_dir = temp_dir.path();
804
805 let policy = CleanupPolicy {
806 max_cache_size_mb: 0,
807 max_age_days: 90,
808 max_idle_days: 30,
809 remove_version_mismatch: true,
810 };
811
812 let manager =
813 CleanupManager::with_config(cache_dir, policy, CleanupTrigger::Manual).unwrap();
814
815 let mut entry_oldest = make_entry("1.0.0", "ns", "aaa111", "oldest");
816 entry_oldest.metadata.last_accessed = Utc::now() - chrono::Duration::days(100);
817
818 let dir = cache_dir.join("ns").join("aa");
819 fs::create_dir_all(&dir).unwrap();
820 fs::write(
821 dir.join("aaa111.json"),
822 serde_json::to_string(&entry_oldest).unwrap(),
823 )
824 .unwrap();
825
826 let stats = manager.cleanup_by_size().unwrap();
827 assert_eq!(stats.removed_count, 1);
828 assert!(!dir.join("aaa111.json").exists());
829 }
830
831 #[test]
834 fn test_load_state_returns_default_when_no_file() {
835 let temp_dir = TempDir::new().unwrap();
836 let manager = CleanupManager::new(temp_dir.path()).unwrap();
837
838 let state = manager.load_state().unwrap();
839 assert_eq!(state.last_cleanup_type, "none");
840 assert_eq!(
841 state.last_cleanup_timestamp,
842 DateTime::from_timestamp(0, 0).unwrap()
843 );
844 }
845
846 #[test]
847 fn test_save_state_then_load_state() {
848 let temp_dir = TempDir::new().unwrap();
849 let cache_dir = temp_dir.path();
850 fs::create_dir_all(cache_dir).unwrap();
851
852 let manager = CleanupManager::new(cache_dir).unwrap();
853
854 let now = Utc::now();
855 let state = CleanupState {
856 last_cleanup_timestamp: now,
857 last_cleanup_type: "stale".to_string(),
858 };
859
860 manager.save_state(state.clone()).unwrap();
861
862 let loaded = manager.load_state().unwrap();
863 assert_eq!(loaded.last_cleanup_type, "stale");
864 assert_eq!(loaded.last_cleanup_timestamp.timestamp(), now.timestamp());
865 }
866
867 #[test]
868 fn test_save_state_actually_writes_file() {
869 let temp_dir = TempDir::new().unwrap();
870 let cache_dir = temp_dir.path();
871 fs::create_dir_all(cache_dir).unwrap();
872
873 let manager = CleanupManager::new(cache_dir).unwrap();
874
875 let state = CleanupState {
876 last_cleanup_timestamp: Utc::now(),
877 last_cleanup_type: "size".to_string(),
878 };
879
880 manager.save_state(state).unwrap();
881
882 let state_path = cache_dir.join("cleanup_state.json");
883 assert!(state_path.exists());
884
885 let content = fs::read_to_string(&state_path).unwrap();
886 assert!(content.contains("size"));
887 }
888
889 #[test]
892 fn test_cleanup_stale_entries_preserves_fresh() {
893 let temp_dir = TempDir::new().unwrap();
894 let cache_dir = temp_dir.path();
895 fs::create_dir_all(cache_dir).unwrap();
896
897 let policy = CleanupPolicy {
898 max_age_days: 90,
899 max_idle_days: 30,
900 remove_version_mismatch: true,
901 max_cache_size_mb: 500,
902 };
903
904 let manager =
905 CleanupManager::with_config(cache_dir, policy, CleanupTrigger::Manual).unwrap();
906
907 let entry = make_entry(CACHE_VERSION, "ns", "abc123", "fresh");
908
909 let dir = cache_dir.join("ns").join("ab");
910 fs::create_dir_all(&dir).unwrap();
911 let file_path = dir.join("abc123.json");
912 fs::write(&file_path, serde_json::to_string(&entry).unwrap()).unwrap();
913
914 let stats = manager.cleanup_stale_entries().unwrap();
915 assert_eq!(stats.removed_count, 0);
916 assert_eq!(stats.freed_bytes, 0);
917 assert!(file_path.exists());
918 }
919
920 #[test]
921 fn test_cleanup_stale_entries_by_idle() {
922 let temp_dir = TempDir::new().unwrap();
923 let cache_dir = temp_dir.path();
924 fs::create_dir_all(cache_dir).unwrap();
925
926 let policy = CleanupPolicy {
927 max_age_days: 90,
928 max_idle_days: 30,
929 remove_version_mismatch: false,
930 max_cache_size_mb: 500,
931 };
932
933 let manager =
934 CleanupManager::with_config(cache_dir, policy, CleanupTrigger::Manual).unwrap();
935
936 let mut entry = make_entry(CACHE_VERSION, "ns", "abc123", "idle");
937 entry.metadata.last_accessed = Utc::now() - chrono::Duration::days(31);
938
939 let dir = cache_dir.join("ns").join("ab");
940 fs::create_dir_all(&dir).unwrap();
941 let file_path = dir.join("abc123.json");
942 fs::write(&file_path, serde_json::to_string(&entry).unwrap()).unwrap();
943
944 let stats = manager.cleanup_stale_entries().unwrap();
945 assert_eq!(stats.removed_count, 1);
946 assert!(!file_path.exists());
947 }
948
949 #[test]
950 fn test_cleanup_by_size_nonexistent_dir() {
951 let temp_dir = TempDir::new().unwrap();
952 let nonexistent = temp_dir.path().join("does_not_exist");
953
954 let manager = CleanupManager::with_config(
955 &nonexistent,
956 CleanupPolicy::default(),
957 CleanupTrigger::Manual,
958 )
959 .unwrap();
960
961 let stats = manager.cleanup_by_size().unwrap();
962 assert_eq!(stats.removed_count, 0);
963 assert_eq!(stats.freed_bytes, 0);
964 }
965
966 #[test]
967 fn test_cleanup_stale_entries_nonexistent_dir() {
968 let temp_dir = TempDir::new().unwrap();
969 let nonexistent = temp_dir.path().join("does_not_exist");
970
971 let manager = CleanupManager::with_config(
972 &nonexistent,
973 CleanupPolicy::default(),
974 CleanupTrigger::Manual,
975 )
976 .unwrap();
977
978 let stats = manager.cleanup_stale_entries().unwrap();
979 assert_eq!(stats.removed_count, 0);
980 assert_eq!(stats.freed_bytes, 0);
981 }
982
983 #[test]
984 fn test_is_over_size_limit_threshold_arithmetic() {
985 let temp_dir = TempDir::new().unwrap();
986 let cache_dir = temp_dir.path();
987
988 fs::write(cache_dir.join("small.bin"), &[b'x'; 100]).unwrap();
989
990 let manager = CleanupManager::with_config(
991 cache_dir,
992 CleanupPolicy::default(),
993 CleanupTrigger::OnSizeLimit { threshold_mb: 1 },
994 )
995 .unwrap();
996
997 assert!(!manager.is_over_size_limit().unwrap());
998 }
999
1000 #[test]
1001 fn test_is_over_size_limit_exact_boundary() {
1002 let temp_dir = TempDir::new().unwrap();
1003 let cache_dir = temp_dir.path();
1004
1005 let data = vec![b'x'; 1_048_576];
1006 fs::write(cache_dir.join("exact.bin"), &data).unwrap();
1007
1008 let manager = CleanupManager::with_config(
1009 cache_dir,
1010 CleanupPolicy::default(),
1011 CleanupTrigger::OnSizeLimit { threshold_mb: 1 },
1012 )
1013 .unwrap();
1014
1015 assert!(!manager.is_over_size_limit().unwrap());
1016 }
1017
1018 #[test]
1019 fn test_cleanup_by_size_target_removal_subtraction() {
1020 let temp_dir = TempDir::new().unwrap();
1021 let cache_dir = temp_dir.path();
1022
1023 let policy = CleanupPolicy {
1024 max_cache_size_mb: 0,
1025 max_age_days: 90,
1026 max_idle_days: 30,
1027 remove_version_mismatch: true,
1028 };
1029
1030 let manager =
1031 CleanupManager::with_config(cache_dir, policy, CleanupTrigger::Manual).unwrap();
1032
1033 let mut entry1 = make_entry("1.0.0", "ns", "hash11", "resp1");
1034 entry1.metadata.last_accessed = Utc::now() - chrono::Duration::days(10);
1035
1036 let mut entry2 = make_entry("1.0.0", "ns", "hash22", "resp2");
1037 entry2.metadata.last_accessed = Utc::now() - chrono::Duration::days(5);
1038
1039 let dir1 = cache_dir.join("ns").join("ha");
1040 fs::create_dir_all(&dir1).unwrap();
1041 fs::write(
1042 dir1.join("hash11.json"),
1043 serde_json::to_string(&entry1).unwrap(),
1044 )
1045 .unwrap();
1046 fs::write(
1047 dir1.join("hash22.json"),
1048 serde_json::to_string(&entry2).unwrap(),
1049 )
1050 .unwrap();
1051
1052 let stats = manager.cleanup_by_size().unwrap();
1053 assert_eq!(stats.removed_count, 2);
1054 assert!(stats.freed_bytes > 0);
1055 }
1056}