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        // Collect cache files sorted by modification time (oldest first)
66        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}