1use crate::config::CacheConfig;
2use sha2::{Digest, Sha256};
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::time::UNIX_EPOCH;
6
7#[derive(Debug)]
8pub struct CacheInfo {
9 pub total_files: usize,
10 pub total_size: u64,
11 pub cache_dir: PathBuf,
12}
13
14#[derive(Debug, thiserror::Error)]
15pub enum CacheError {
16 #[error("cache I/O error: {0}")]
17 IoError(#[from] std::io::Error),
18 #[error("cache directory not accessible: {0}")]
19 DirError(String),
20}
21
22pub struct CacheManager {
23 cache_dir: PathBuf,
24 max_size: u64,
25}
26
27impl CacheManager {
28 pub fn new(config: &CacheConfig) -> Result<Self, CacheError> {
29 let cache_dir = crate::config::expand_path(&config.dir);
30 if !cache_dir.exists() {
31 fs::create_dir_all(&cache_dir)?;
32 }
33 let max_size = crate::config::parse_size(&config.max_size)
34 .map_err(|e| CacheError::DirError(e.to_string()))?;
35 Ok(Self {
36 cache_dir,
37 max_size,
38 })
39 }
40
41 pub fn cache_image(&self, path: &Path) -> Result<PathBuf, CacheError> {
42 let key = Self::cache_key(path)?;
43 let ext = path.extension().unwrap_or_default().to_string_lossy();
44 let cache_path = self.cache_dir.join(format!("{}.{}", key, ext));
45
46 if !cache_path.exists() {
47 if self.max_size > 0 {
49 self.evict_if_needed(path)?;
50 }
51 fs::copy(path, &cache_path)?;
52 }
53
54 Ok(cache_path)
55 }
56
57 fn evict_if_needed(&self, incoming: &Path) -> Result<(), CacheError> {
58 let incoming_size = fs::metadata(incoming).map(|m| m.len()).unwrap_or(0);
59 let info = self.info()?;
60
61 if info.total_size + incoming_size <= self.max_size {
62 return Ok(());
63 }
64
65 let mut entries: Vec<_> = fs::read_dir(&self.cache_dir)?
66 .filter_map(|e| e.ok())
67 .filter(|e| e.metadata().map(|m| m.is_file()).unwrap_or(false))
68 .filter(|e| e.file_name() != "state.json")
69 .collect();
70
71 entries.sort_by_key(|e| {
72 e.metadata()
73 .and_then(|m| m.modified())
74 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
75 });
76
77 let mut current_size = info.total_size;
78 for entry in entries {
79 if current_size + incoming_size <= self.max_size {
80 break;
81 }
82 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
83 fs::remove_file(entry.path())?;
84 current_size = current_size.saturating_sub(size);
85 }
86
87 Ok(())
88 }
89
90 pub fn clear(&self) -> Result<CacheInfo, CacheError> {
91 let mut freed_size = 0;
92 let mut count = 0;
93
94 if self.cache_dir.exists() {
95 for entry in fs::read_dir(&self.cache_dir)? {
96 let entry = entry?;
97 let meta = entry.metadata()?;
98 if meta.is_file() {
99 freed_size += meta.len();
100 count += 1;
101 fs::remove_file(entry.path())?;
102 }
103 }
104 }
105
106 Ok(CacheInfo {
107 total_files: count,
108 total_size: freed_size,
109 cache_dir: self.cache_dir.clone(),
110 })
111 }
112
113 pub fn info(&self) -> Result<CacheInfo, CacheError> {
114 let mut total_size = 0;
115 let mut count = 0;
116
117 if self.cache_dir.exists() {
118 for entry in fs::read_dir(&self.cache_dir)? {
119 let entry = entry?;
120 let meta = entry.metadata()?;
121 if meta.is_file() {
122 total_size += meta.len();
123 count += 1;
124 }
125 }
126 }
127
128 Ok(CacheInfo {
129 total_files: count,
130 total_size,
131 cache_dir: self.cache_dir.clone(),
132 })
133 }
134
135 pub fn cache_key(path: &Path) -> Result<String, CacheError> {
136 let meta = fs::metadata(path)?;
137 let mtime = meta
138 .modified()?
139 .duration_since(UNIX_EPOCH)
140 .unwrap_or_default()
141 .as_secs();
142
143 let mut hasher = Sha256::new();
144 hasher.update(path.to_string_lossy().as_bytes());
145 hasher.update(mtime.to_string().as_bytes());
146
147 Ok(hex::encode(hasher.finalize()))
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use std::io::Write;
155
156 fn temp_dir() -> PathBuf {
157 let dir = std::env::temp_dir().join(format!("wallr_test_{}", std::process::id()));
158 let _ = fs::create_dir_all(&dir);
159 dir
160 }
161
162 #[test]
163 fn test_cache_key_deterministic() {
164 let dir = temp_dir();
165 let path = dir.join("test_key.txt");
166 let mut file = fs::File::create(&path).unwrap();
167 file.write_all(b"test").unwrap();
168
169 let key1 = CacheManager::cache_key(&path).unwrap();
170 let key2 = CacheManager::cache_key(&path).unwrap();
171 assert_eq!(key1, key2);
172
173 let _ = fs::remove_file(path);
174 }
175
176 #[test]
177 fn test_cache_info_empty() {
178 let dir = temp_dir().join("empty_cache");
179 let config = CacheConfig {
180 dir: dir.to_string_lossy().to_string(),
181 max_size: "1KB".to_string(),
182 };
183 let manager = CacheManager::new(&config).unwrap();
184
185 let info = manager.info().unwrap();
186 assert_eq!(info.total_files, 0);
187 assert_eq!(info.total_size, 0);
188
189 let _ = fs::remove_dir_all(dir);
190 }
191
192 #[test]
193 fn test_clear_cache() {
194 let dir = temp_dir().join("clear_cache");
195 let config = CacheConfig {
196 dir: dir.to_string_lossy().to_string(),
197 max_size: "1KB".to_string(),
198 };
199 let manager = CacheManager::new(&config).unwrap();
200
201 let file_path = dir.join("test.txt");
202 fs::write(&file_path, "test").unwrap();
203
204 let info = manager.clear().unwrap();
205 assert_eq!(info.total_files, 1);
206
207 let info_after = manager.info().unwrap();
208 assert_eq!(info_after.total_files, 0);
209
210 let _ = fs::remove_dir_all(dir);
211 }
212}