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)?
67 .filter_map(|e| e.ok())
68 .filter(|e| e.metadata().map(|m| m.is_file()).unwrap_or(false))
69 .filter(|e| e.file_name() != "state.json")
70 .collect();
71
72 entries.sort_by_key(|e| {
73 e.metadata()
74 .and_then(|m| m.modified())
75 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
76 });
77
78 let mut current_size = info.total_size;
79 for entry in entries {
80 if current_size + incoming_size <= self.max_size {
81 break;
82 }
83 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
84 fs::remove_file(entry.path())?;
85 current_size = current_size.saturating_sub(size);
86 }
87
88 Ok(())
89 }
90
91 pub fn clear(&self) -> Result<CacheInfo, CacheError> {
92 let mut freed_size = 0;
93 let mut count = 0;
94
95 if self.cache_dir.exists() {
96 for entry in fs::read_dir(&self.cache_dir)? {
97 let entry = entry?;
98 let meta = entry.metadata()?;
99 if meta.is_file() {
100 freed_size += meta.len();
101 count += 1;
102 fs::remove_file(entry.path())?;
103 }
104 }
105 }
106
107 Ok(CacheInfo {
108 total_files: count,
109 total_size: freed_size,
110 cache_dir: self.cache_dir.clone(),
111 })
112 }
113
114 pub fn info(&self) -> Result<CacheInfo, CacheError> {
115 let mut total_size = 0;
116 let mut count = 0;
117
118 if self.cache_dir.exists() {
119 for entry in fs::read_dir(&self.cache_dir)? {
120 let entry = entry?;
121 let meta = entry.metadata()?;
122 if meta.is_file() {
123 total_size += meta.len();
124 count += 1;
125 }
126 }
127 }
128
129 Ok(CacheInfo {
130 total_files: count,
131 total_size,
132 cache_dir: self.cache_dir.clone(),
133 })
134 }
135
136 pub fn cache_key(path: &Path) -> Result<String, CacheError> {
137 let meta = fs::metadata(path)?;
138 let mtime = meta
139 .modified()?
140 .duration_since(UNIX_EPOCH)
141 .unwrap_or_default()
142 .as_secs();
143
144 let mut hasher = Sha256::new();
145 hasher.update(path.to_string_lossy().as_bytes());
146 hasher.update(mtime.to_string().as_bytes());
147
148 Ok(hex::encode(hasher.finalize()))
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use std::io::Write;
156
157 fn temp_dir() -> PathBuf {
158 let dir = std::env::temp_dir().join(format!("wallr_test_{}", std::process::id()));
159 let _ = fs::create_dir_all(&dir);
160 dir
161 }
162
163 #[test]
164 fn test_cache_key_deterministic() {
165 let dir = temp_dir();
166 let path = dir.join("test_key.txt");
167 let mut file = fs::File::create(&path).unwrap();
168 file.write_all(b"test").unwrap();
169
170 let key1 = CacheManager::cache_key(&path).unwrap();
171 let key2 = CacheManager::cache_key(&path).unwrap();
172 assert_eq!(key1, key2);
173
174 let _ = fs::remove_file(path);
175 }
176
177 #[test]
178 fn test_cache_info_empty() {
179 let dir = temp_dir().join("empty_cache");
180 let config = CacheConfig {
181 dir: dir.to_string_lossy().to_string(),
182 max_size: "1KB".to_string(),
183 };
184 let manager = CacheManager::new(&config).unwrap();
185
186 let info = manager.info().unwrap();
187 assert_eq!(info.total_files, 0);
188 assert_eq!(info.total_size, 0);
189
190 let _ = fs::remove_dir_all(dir);
191 }
192
193 #[test]
194 fn test_clear_cache() {
195 let dir = temp_dir().join("clear_cache");
196 let config = CacheConfig {
197 dir: dir.to_string_lossy().to_string(),
198 max_size: "1KB".to_string(),
199 };
200 let manager = CacheManager::new(&config).unwrap();
201
202 let file_path = dir.join("test.txt");
203 fs::write(&file_path, "test").unwrap();
204
205 let info = manager.clear().unwrap();
206 assert_eq!(info.total_files, 1);
207
208 let info_after = manager.info().unwrap();
209 assert_eq!(info_after.total_files, 0);
210
211 let _ = fs::remove_dir_all(dir);
212 }
213}