Skip to main content

parsentry_cache/
cleanup.rs

1//! Cache cleanup and maintenance
2
3use 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/// Cleanup statistics
13#[derive(Debug, Default, Clone)]
14pub struct CleanupStats {
15    /// Number of entries removed
16    pub removed_count: usize,
17    /// Bytes freed
18    pub freed_bytes: u64,
19}
20
21/// Cleanup policy configuration
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct CleanupPolicy {
24    /// Maximum cache size in MB
25    pub max_cache_size_mb: usize,
26
27    /// Maximum age in days before entry is considered stale
28    pub max_age_days: usize,
29
30    /// Maximum idle days (since last access) before entry is stale
31    pub max_idle_days: usize,
32
33    /// Remove entries with version mismatch
34    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    /// Check if an entry is stale according to the policy
50    pub fn is_stale(&self, entry: &CacheEntry, current_version: &str) -> bool {
51        // Version mismatch
52        if self.remove_version_mismatch && entry.version != current_version {
53            return true;
54        }
55
56        // Age check
57        if entry.age_days() > self.max_age_days as i64 {
58            return true;
59        }
60
61        // Idle check
62        if entry.idle_days() > self.max_idle_days as i64 {
63            return true;
64        }
65
66        false
67    }
68}
69
70/// Cleanup trigger configuration
71#[derive(Debug, Clone, Serialize, Deserialize)]
72#[serde(tag = "type", rename_all = "snake_case")]
73pub enum CleanupTrigger {
74    /// Periodic cleanup based on days since last cleanup
75    Periodic { days: usize },
76
77    /// Cleanup when size limit is exceeded
78    OnSizeLimit { threshold_mb: usize },
79
80    /// Combined triggers
81    Combined {
82        periodic_days: Option<usize>,
83        size_limit_mb: Option<usize>,
84    },
85
86    /// Manual cleanup only
87    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/// State of the last cleanup operation
100#[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
115/// Cache cleanup manager
116pub struct CleanupManager {
117    cache_dir: PathBuf,
118    policy: CleanupPolicy,
119    trigger: CleanupTrigger,
120    state_file: PathBuf,
121}
122
123impl CleanupManager {
124    /// Create a new cleanup manager
125    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    /// Create a cleanup manager with custom configuration
134    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    /// Check if periodic cleanup should run (fast check)
151    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    /// Check if cache is over size limit (heavier check)
173    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    /// Calculate total cache size in bytes
193    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    /// Clean up stale entries according to policy
215    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            // Read and check cache entry
236            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                        // Get file size before deleting
240                        if let Ok(metadata) = fs::metadata(path) {
241                            stats.freed_bytes += metadata.len();
242                        }
243
244                        // Delete the file
245                        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        // Update state
255        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    /// Clean up by size using LRU (Least Recently Used) strategy
264    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        // Collect all entries with their metadata
272        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        // Sort by last accessed (LRU)
297        entries.sort_by_key(|(_, entry, _)| entry.metadata.last_accessed);
298
299        // Calculate how much to remove
300        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        // Remove oldest entries until under limit
310        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        // Update state
324        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    /// Load cleanup state
333    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    /// Save cleanup state
348    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    // --- is_stale boundary tests ---
429
430    #[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        // age_days == max_age_days (boundary: > not >=, so NOT stale)
468        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        // age_days == max_age_days + 1 (stale)
473        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    // --- should_run_periodic_cleanup tests ---
511
512    #[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    // --- is_over_size_limit tests ---
595
596    #[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    // --- calculate_total_size tests ---
693
694    #[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    // --- cleanup_by_size LRU tests ---
729
730    #[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    // --- load_state / save_state tests ---
832
833    #[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    // --- cleanup_stale_entries preserves fresh entries ---
890
891    #[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}