videre_core/
thumb_cache.rs1use std::path::PathBuf;
2
3pub fn cache_dir() -> PathBuf {
8 dirs_cache_dir().join("videre").join("thumbnails")
9}
10
11pub fn thumb_path(hash: &str, size: u32) -> PathBuf {
14 cache_dir().join(format!("{hash}_{size}.jpg"))
15}
16
17pub fn thumb_exists(hash: &str, size: u32) -> bool {
19 thumb_path(hash, size).exists()
20}
21
22pub fn face_thumb_path(hash: &str, face_id: i64, size: u32) -> PathBuf {
25 cache_dir().join(format!("{hash}_face{face_id}_{size}.jpg"))
26}
27
28pub fn face_thumb_exists(hash: &str, face_id: i64, size: u32) -> bool {
30 face_thumb_path(hash, face_id, size).exists()
31}
32
33pub fn original_path(hash: &str) -> PathBuf {
37 cache_dir().join(format!("{hash}_original.jpg"))
38}
39
40pub fn original_exists(hash: &str) -> bool {
42 original_path(hash).exists()
43}
44
45const HASH_HEX_LEN: usize = 64;
50
51pub fn hash_from_cache_filename(filename: &str) -> Option<&str> {
62 if !filename.ends_with(".jpg") {
63 return None;
64 }
65 let bytes = filename.as_bytes();
66 if bytes.len() <= HASH_HEX_LEN || bytes[HASH_HEX_LEN] != b'_' {
67 return None;
68 }
69 let hash = &filename[..HASH_HEX_LEN];
70 if hash.bytes().all(|b| b.is_ascii_hexdigit()) {
71 Some(hash)
72 } else {
73 None
74 }
75}
76
77pub fn original_tmp_path(hash: &str) -> PathBuf {
81 cache_dir().join(format!("{hash}_original.tmp{}", std::process::id()))
82}
83
84pub fn thumb_tmp_path(hash: &str, size: u32) -> PathBuf {
91 cache_dir().join(format!("{hash}_{size}.tmp{}", std::process::id()))
92}
93
94fn dirs_cache_dir() -> PathBuf {
95 std::env::var_os("HOME")
96 .map(|home| PathBuf::from(home).join(".cache"))
97 .unwrap_or_else(|| PathBuf::from(".cache"))
98}
99
100pub fn migrate_legacy_dupe_cache() {
106 let old = dirs_cache_dir().join("dupe").join("thumbnails");
107 let new = cache_dir();
108 migrate_dir(&old, &new);
109}
110
111fn migrate_dir(old: &std::path::Path, new: &std::path::Path) {
112 if old.is_dir() && !new.exists() {
113 if let Some(parent) = new.parent() {
114 let _ = std::fs::create_dir_all(parent);
115 }
116 let _ = std::fs::rename(old, new);
117 if let Some(old_parent) = old.parent() {
118 let _ = std::fs::remove_dir(old_parent); }
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 #[test]
128 fn thumb_path_is_keyed_by_hash_and_size() {
129 let p1 = thumb_path("abc123", 240);
130 let p2 = thumb_path("abc123", 1200);
131 let p3 = thumb_path("def456", 240);
132 assert_ne!(p1, p2, "different sizes must produce different paths");
133 assert_ne!(p1, p3, "different hashes must produce different paths");
134 assert!(p1.to_string_lossy().contains("abc123_240.jpg"));
135 }
136
137 #[test]
138 fn thumb_exists_false_for_missing_file() {
139 assert!(!thumb_exists("nonexistent-hash-xyz", 240));
140 }
141
142 #[test]
143 fn cache_dir_is_under_videre() {
144 assert!(cache_dir().to_string_lossy().contains("videre"));
145 assert!(!cache_dir().to_string_lossy().contains("/dupe/"));
146 }
147
148 #[test]
149 fn face_thumb_path_is_keyed_by_hash_face_id_and_size() {
150 let p1 = face_thumb_path("abc123", 1, 140);
151 let p2 = face_thumb_path("abc123", 2, 140);
152 let p3 = face_thumb_path("def456", 1, 140);
153 assert_ne!(p1, p2, "different face ids must produce different paths");
154 assert_ne!(p1, p3, "different hashes must produce different paths");
155 assert!(p1.to_string_lossy().contains("abc123_face1_140.jpg"));
156 }
157
158 #[test]
159 fn face_thumb_exists_false_for_missing_file() {
160 assert!(!face_thumb_exists("nonexistent-hash-xyz", 99, 140));
161 }
162
163 #[test]
164 fn original_path_is_keyed_by_hash() {
165 let p1 = original_path("abc123");
166 let p2 = original_path("def456");
167 assert_ne!(p1, p2);
168 assert!(p1.to_string_lossy().contains("abc123_original.jpg"));
169 }
170
171 #[test]
172 fn original_exists_false_for_missing_file() {
173 assert!(!original_exists("nonexistent-hash-xyz"));
174 }
175
176 #[test]
177 fn original_tmp_path_differs_from_final_path_and_is_keyed_by_hash() {
178 let tmp = original_tmp_path("abc123");
179 let final_path = original_path("abc123");
180 assert_ne!(tmp, final_path);
181 assert!(tmp.to_string_lossy().contains("abc123_original.tmp"));
182 }
183
184 fn test_hash(seed: &str) -> String {
185 seed.repeat((HASH_HEX_LEN / seed.len()) + 1)[..HASH_HEX_LEN].to_string()
186 }
187
188 #[test]
189 fn hash_from_cache_filename_parses_thumb_path() {
190 let h1 = test_hash("0123456789abcdef");
191 assert_eq!(hash_from_cache_filename(&format!("{h1}_240.jpg")), Some(h1.as_str()));
192 assert_eq!(hash_from_cache_filename(&format!("{h1}_1200.jpg")), Some(h1.as_str()));
193 }
194
195 #[test]
196 fn hash_from_cache_filename_parses_face_thumb_path() {
197 let h1 = test_hash("0123456789abcdef");
198 assert_eq!(hash_from_cache_filename(&format!("{h1}_face3_140.jpg")), Some(h1.as_str()));
199 }
200
201 #[test]
202 fn hash_from_cache_filename_parses_original_path() {
203 let h1 = test_hash("0123456789abcdef");
204 assert_eq!(hash_from_cache_filename(&format!("{h1}_original.jpg")), Some(h1.as_str()));
205 }
206
207 #[test]
208 fn hash_from_cache_filename_distinguishes_different_hashes() {
209 let h2 = test_hash("fedcba9876543210");
210 assert_eq!(hash_from_cache_filename(&format!("{h2}_240.jpg")), Some(h2.as_str()));
211 }
212
213 #[test]
214 fn hash_from_cache_filename_rejects_tmp_files() {
215 let h1 = test_hash("0123456789abcdef");
216 assert_eq!(hash_from_cache_filename(&format!("{h1}_original.tmp1234")), None);
217 assert_eq!(hash_from_cache_filename(&format!("{h1}_240.tmp5678")), None);
218 }
219
220 #[test]
221 fn hash_from_cache_filename_rejects_too_short_or_malformed_names() {
222 assert_eq!(hash_from_cache_filename("short_240.jpg"), None);
223 assert_eq!(hash_from_cache_filename(".DS_Store"), None);
224 let non_hex_64 = "g".repeat(HASH_HEX_LEN);
225 assert_eq!(hash_from_cache_filename(&format!("{non_hex_64}_240.jpg")), None);
226 }
227
228 #[test]
229 fn migrate_dir_moves_old_into_place() {
230 let tmp = std::env::temp_dir().join(format!("thumb_migrate_{}", std::process::id()));
231 let old = tmp.join("old_cache");
232 let new = tmp.join("new_cache");
233 std::fs::create_dir_all(&old).unwrap();
234 std::fs::write(old.join("h_240.jpg"), b"x").unwrap();
235 migrate_dir(&old, &new);
236 assert!(new.join("h_240.jpg").exists(), "cached file must survive migration");
237 assert!(!old.exists(), "old dir must be gone after migration");
238 let _ = std::fs::remove_dir_all(&tmp);
239 }
240}