videre_core/
thumb_cache.rs1use std::path::PathBuf;
2
3pub fn cache_dir() -> PathBuf {
23 if let Some(home) = std::env::var_os("VIDERE_HOME") {
24 return PathBuf::from(home).join("cache").join("thumbnails");
25 }
26 dirs_cache_dir().join("videre").join("thumbnails")
27}
28
29pub fn thumb_path(hash: &str, size: u32) -> PathBuf {
32 cache_dir().join(format!("{hash}_{size}.jpg"))
33}
34
35pub fn thumb_exists(hash: &str, size: u32) -> bool {
37 thumb_path(hash, size).exists()
38}
39
40pub fn face_thumb_path(hash: &str, face_id: i64, size: u32) -> PathBuf {
43 cache_dir().join(format!("{hash}_face{face_id}_{size}.jpg"))
44}
45
46pub fn face_thumb_exists(hash: &str, face_id: i64, size: u32) -> bool {
48 face_thumb_path(hash, face_id, size).exists()
49}
50
51pub fn original_path(hash: &str) -> PathBuf {
55 cache_dir().join(format!("{hash}_original.jpg"))
56}
57
58pub fn original_exists(hash: &str) -> bool {
60 original_path(hash).exists()
61}
62
63const HASH_HEX_LEN: usize = 64;
68
69pub fn hash_from_cache_filename(filename: &str) -> Option<&str> {
80 if !filename.ends_with(".jpg") {
81 return None;
82 }
83 let bytes = filename.as_bytes();
84 if bytes.len() <= HASH_HEX_LEN || bytes[HASH_HEX_LEN] != b'_' {
85 return None;
86 }
87 let hash = &filename[..HASH_HEX_LEN];
88 if hash.bytes().all(|b| b.is_ascii_hexdigit()) {
89 Some(hash)
90 } else {
91 None
92 }
93}
94
95pub fn original_tmp_path(hash: &str) -> PathBuf {
99 cache_dir().join(format!("{hash}_original.tmp{}", std::process::id()))
100}
101
102pub fn thumb_tmp_path(hash: &str, size: u32) -> PathBuf {
109 cache_dir().join(format!("{hash}_{size}.tmp{}", std::process::id()))
110}
111
112fn dirs_cache_dir() -> PathBuf {
113 std::env::var_os("HOME")
114 .map(|home| PathBuf::from(home).join(".cache"))
115 .unwrap_or_else(|| PathBuf::from(".cache"))
116}
117
118pub fn migrate_legacy_dupe_cache() {
124 let old = dirs_cache_dir().join("dupe").join("thumbnails");
125 let new = cache_dir();
126 migrate_dir(&old, &new);
127}
128
129fn migrate_dir(old: &std::path::Path, new: &std::path::Path) {
130 if old.is_dir() && !new.exists() {
131 if let Some(parent) = new.parent() {
132 let _ = std::fs::create_dir_all(parent);
133 }
134 let _ = std::fs::rename(old, new);
135 if let Some(old_parent) = old.parent() {
136 let _ = std::fs::remove_dir(old_parent); }
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 #[test]
146 fn thumb_path_is_keyed_by_hash_and_size() {
147 let p1 = thumb_path("abc123", 240);
148 let p2 = thumb_path("abc123", 1200);
149 let p3 = thumb_path("def456", 240);
150 assert_ne!(p1, p2, "different sizes must produce different paths");
151 assert_ne!(p1, p3, "different hashes must produce different paths");
152 assert!(p1.to_string_lossy().contains("abc123_240.jpg"));
153 }
154
155 #[test]
156 fn thumb_exists_false_for_missing_file() {
157 assert!(!thumb_exists("nonexistent-hash-xyz", 240));
158 }
159
160 #[test]
161 fn cache_dir_is_under_videre() {
162 assert!(cache_dir().to_string_lossy().contains("videre"));
163 assert!(!cache_dir().to_string_lossy().contains("/dupe/"));
164 }
165
166 #[test]
167 fn face_thumb_path_is_keyed_by_hash_face_id_and_size() {
168 let p1 = face_thumb_path("abc123", 1, 140);
169 let p2 = face_thumb_path("abc123", 2, 140);
170 let p3 = face_thumb_path("def456", 1, 140);
171 assert_ne!(p1, p2, "different face ids must produce different paths");
172 assert_ne!(p1, p3, "different hashes must produce different paths");
173 assert!(p1.to_string_lossy().contains("abc123_face1_140.jpg"));
174 }
175
176 #[test]
177 fn face_thumb_exists_false_for_missing_file() {
178 assert!(!face_thumb_exists("nonexistent-hash-xyz", 99, 140));
179 }
180
181 #[test]
182 fn original_path_is_keyed_by_hash() {
183 let p1 = original_path("abc123");
184 let p2 = original_path("def456");
185 assert_ne!(p1, p2);
186 assert!(p1.to_string_lossy().contains("abc123_original.jpg"));
187 }
188
189 #[test]
190 fn original_exists_false_for_missing_file() {
191 assert!(!original_exists("nonexistent-hash-xyz"));
192 }
193
194 #[test]
195 fn original_tmp_path_differs_from_final_path_and_is_keyed_by_hash() {
196 let tmp = original_tmp_path("abc123");
197 let final_path = original_path("abc123");
198 assert_ne!(tmp, final_path);
199 assert!(tmp.to_string_lossy().contains("abc123_original.tmp"));
200 }
201
202 fn test_hash(seed: &str) -> String {
203 seed.repeat((HASH_HEX_LEN / seed.len()) + 1)[..HASH_HEX_LEN].to_string()
204 }
205
206 #[test]
207 fn hash_from_cache_filename_parses_thumb_path() {
208 let h1 = test_hash("0123456789abcdef");
209 assert_eq!(
210 hash_from_cache_filename(&format!("{h1}_240.jpg")),
211 Some(h1.as_str())
212 );
213 assert_eq!(
214 hash_from_cache_filename(&format!("{h1}_1200.jpg")),
215 Some(h1.as_str())
216 );
217 }
218
219 #[test]
220 fn hash_from_cache_filename_parses_face_thumb_path() {
221 let h1 = test_hash("0123456789abcdef");
222 assert_eq!(
223 hash_from_cache_filename(&format!("{h1}_face3_140.jpg")),
224 Some(h1.as_str())
225 );
226 }
227
228 #[test]
229 fn hash_from_cache_filename_parses_original_path() {
230 let h1 = test_hash("0123456789abcdef");
231 assert_eq!(
232 hash_from_cache_filename(&format!("{h1}_original.jpg")),
233 Some(h1.as_str())
234 );
235 }
236
237 #[test]
238 fn hash_from_cache_filename_distinguishes_different_hashes() {
239 let h2 = test_hash("fedcba9876543210");
240 assert_eq!(
241 hash_from_cache_filename(&format!("{h2}_240.jpg")),
242 Some(h2.as_str())
243 );
244 }
245
246 #[test]
247 fn hash_from_cache_filename_rejects_tmp_files() {
248 let h1 = test_hash("0123456789abcdef");
249 assert_eq!(
250 hash_from_cache_filename(&format!("{h1}_original.tmp1234")),
251 None
252 );
253 assert_eq!(hash_from_cache_filename(&format!("{h1}_240.tmp5678")), None);
254 }
255
256 #[test]
257 fn hash_from_cache_filename_rejects_too_short_or_malformed_names() {
258 assert_eq!(hash_from_cache_filename("short_240.jpg"), None);
259 assert_eq!(hash_from_cache_filename(".DS_Store"), None);
260 let non_hex_64 = "g".repeat(HASH_HEX_LEN);
261 assert_eq!(
262 hash_from_cache_filename(&format!("{non_hex_64}_240.jpg")),
263 None
264 );
265 }
266
267 #[test]
268 fn migrate_dir_moves_old_into_place() {
269 let tmp = std::env::temp_dir().join(format!("thumb_migrate_{}", std::process::id()));
270 let old = tmp.join("old_cache");
271 let new = tmp.join("new_cache");
272 std::fs::create_dir_all(&old).unwrap();
273 std::fs::write(old.join("h_240.jpg"), b"x").unwrap();
274 migrate_dir(&old, &new);
275 assert!(
276 new.join("h_240.jpg").exists(),
277 "cached file must survive migration"
278 );
279 assert!(!old.exists(), "old dir must be gone after migration");
280 let _ = std::fs::remove_dir_all(&tmp);
281 }
282}