Skip to main content

wallr_core/cache/
mod.rs

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            // Enforce cache size limit by evicting oldest files first
48            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}