1use std::fs;
13use std::io::{Read, Write};
14use std::path::{Path, PathBuf};
15
16use nom_exif::{EntryValue, Exif, ExifTag, read_exif as nom_read_exif};
17use serde::{Deserialize, Serialize};
18
19#[derive(Clone, Debug, Default, Serialize, Deserialize)]
21pub struct LocalExif {
22 pub make: Option<String>,
23 pub model: Option<String>,
24 pub lens_model: Option<String>,
25 pub f_number: Option<f64>,
26 pub focal_length: Option<f64>,
27 pub iso: Option<u32>,
28 pub exposure_time: Option<String>,
29 pub date_time_original: Option<String>,
31 pub latitude: Option<f64>,
32 pub longitude: Option<f64>,
33 pub image_width: Option<u32>,
34 pub image_height: Option<u32>,
35 pub description: Option<String>,
36}
37
38impl LocalExif {
39 pub fn is_empty(&self) -> bool {
41 self.make.is_none()
42 && self.model.is_none()
43 && self.lens_model.is_none()
44 && self.f_number.is_none()
45 && self.focal_length.is_none()
46 && self.iso.is_none()
47 && self.exposure_time.is_none()
48 && self.date_time_original.is_none()
49 && self.latitude.is_none()
50 && self.longitude.is_none()
51 && self.image_width.is_none()
52 && self.image_height.is_none()
53 && self.description.is_none()
54 }
55}
56
57pub fn read_exif(path: &Path) -> Option<LocalExif> {
59 let exif = nom_read_exif(path)
60 .map_err(|err| {
61 log::debug!("nom-exif read failed for {}: {}", path.display(), err);
62 err
63 })
64 .ok()?;
65
66 let local = LocalExif {
67 make: text(&exif, ExifTag::Make),
68 model: text(&exif, ExifTag::Model),
69 lens_model: text(&exif, ExifTag::LensModel).or_else(|| text(&exif, ExifTag::LensMake)),
70 f_number: rational_f64(&exif, ExifTag::FNumber),
71 focal_length: rational_f64(&exif, ExifTag::FocalLength),
72 iso: integer_u32(&exif, ExifTag::ISOSpeedRatings),
73 exposure_time: exposure_time(&exif),
74 date_time_original: datetime_rfc3339(&exif),
75 latitude: exif.gps_info().and_then(|g| g.latitude_decimal()),
76 longitude: exif.gps_info().and_then(|g| g.longitude_decimal()),
77 image_width: integer_u32(&exif, ExifTag::ImageWidth)
78 .or_else(|| integer_u32(&exif, ExifTag::ExifImageWidth)),
79 image_height: integer_u32(&exif, ExifTag::ImageHeight)
80 .or_else(|| integer_u32(&exif, ExifTag::ExifImageHeight)),
81 description: text(&exif, ExifTag::ImageDescription).filter(|s| !s.trim().is_empty()),
82 };
83
84 if local.is_empty() { None } else { Some(local) }
85}
86
87pub fn load_or_extract(cache_root: &Path, path: &Path) -> Option<LocalExif> {
89 let key = cache_key(path)?;
90 let cache_path = cache_root.join("exif").join(format!("{}.json", key.digest));
91
92 if let Ok(mut f) = fs::File::open(&cache_path) {
93 let mut buf = String::new();
94 if f.read_to_string(&mut buf).is_ok()
95 && let Ok(entry) = serde_json::from_str::<CacheEntry>(&buf)
96 && entry.mtime_nanos == key.mtime_nanos
97 && entry.size == key.size
98 {
99 return entry.exif;
100 }
101 }
102
103 let parsed = read_exif(path);
104
105 if let Some(parent) = cache_path.parent() {
106 let _ = fs::create_dir_all(parent);
107 }
108 let entry = CacheEntry {
109 mtime_nanos: key.mtime_nanos,
110 size: key.size,
111 exif: parsed.clone(),
112 };
113 if let Ok(payload) = serde_json::to_vec(&entry)
114 && let Ok(mut f) = fs::File::create(&cache_path)
115 {
116 let _ = f.write_all(&payload);
117 }
118
119 parsed
120}
121
122#[derive(Serialize, Deserialize)]
123struct CacheEntry {
124 mtime_nanos: u128,
125 size: u64,
126 exif: Option<LocalExif>,
127}
128
129struct CacheKey {
130 digest: String,
131 mtime_nanos: u128,
132 size: u64,
133}
134
135fn cache_key(path: &Path) -> Option<CacheKey> {
136 let meta = fs::metadata(path).ok()?;
137 let size = meta.len();
138 let mtime = meta.modified().ok()?;
139 let mtime_nanos = mtime
140 .duration_since(std::time::UNIX_EPOCH)
141 .map_or(0, |d| d.as_nanos());
142 let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
143 let digest = blake3::hash(canonical.as_os_str().as_encoded_bytes())
144 .to_hex()
145 .to_string();
146 Some(CacheKey {
147 digest,
148 mtime_nanos,
149 size,
150 })
151}
152
153fn text(exif: &Exif, tag: ExifTag) -> Option<String> {
154 let value = exif.get(tag)?;
155 value
156 .as_str()
157 .map(|s| s.trim().to_string())
158 .filter(|s| !s.is_empty())
159}
160
161fn integer_u32(exif: &Exif, tag: ExifTag) -> Option<u32> {
162 let value = exif.get(tag)?;
163 match value {
164 EntryValue::U32(v) => Some(*v),
165 EntryValue::U16(v) => Some(u32::from(*v)),
166 EntryValue::U8(v) => Some(u32::from(*v)),
167 _ => None,
168 }
169}
170
171fn rational_f64(exif: &Exif, tag: ExifTag) -> Option<f64> {
172 let value = exif.get(tag)?;
173 if let Some(r) = value.as_urational() {
174 let n = r.numerator();
175 let d = r.denominator();
176 if d == 0 || n == 0 {
177 return None;
178 }
179 return Some(f64::from(n) / f64::from(d));
180 }
181 value.as_f64()
182}
183
184fn exposure_time(exif: &Exif) -> Option<String> {
186 let r = exif.get(ExifTag::ExposureTime)?.as_urational()?;
187 let n = r.numerator();
188 let d = r.denominator();
189 if d == 0 || n == 0 {
190 return None;
191 }
192 if n == 1 {
193 Some(format!("1/{d}"))
194 } else if n > d {
195 let secs = f64::from(n) / f64::from(d);
197 Some(format!("{secs:.1} s"))
198 } else {
199 Some(format!("{n}/{d}"))
201 }
202}
203
204fn datetime_rfc3339(exif: &Exif) -> Option<String> {
207 use nom_exif::ExifDateTime;
208 let dt = exif.get(ExifTag::DateTimeOriginal)?.as_datetime()?;
209 let formatted = match dt {
210 ExifDateTime::Aware(dt) => dt.to_rfc3339(),
211 ExifDateTime::Naive(naive) => {
212 let offset = exif
214 .get(ExifTag::OffsetTimeOriginal)
215 .and_then(|v| v.as_str())
216 .and_then(parse_offset);
217 match offset {
218 Some(off) => format!("{}{}", naive.format("%Y-%m-%dT%H:%M:%S"), off),
219 None => format!("{}Z", naive.format("%Y-%m-%dT%H:%M:%S")),
220 }
221 }
222 };
223 Some(formatted)
224}
225
226fn parse_offset(s: &str) -> Option<String> {
228 let s = s.trim();
229 if s.len() != 6 || !s.is_ascii() {
230 return None;
231 }
232 let sign = s.as_bytes()[0];
233 if sign != b'+' && sign != b'-' {
234 return None;
235 }
236 if s.as_bytes()[3] != b':' {
237 return None;
238 }
239 s[1..3].parse::<u8>().ok().filter(|h| *h <= 14)?;
240 s[4..6].parse::<u8>().ok().filter(|m| *m <= 59)?;
241 Some(s.to_string())
242}
243
244pub fn cache_root() -> PathBuf {
247 crate::profile::cache_dir().unwrap_or_else(std::env::temp_dir)
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 #[test]
255 fn is_empty_detects_no_fields() {
256 assert!(LocalExif::default().is_empty());
257 }
258
259 #[test]
260 fn is_empty_false_when_any_field_set() {
261 let e = LocalExif {
262 iso: Some(400),
263 ..Default::default()
264 };
265 assert!(!e.is_empty());
266
267 let e = LocalExif {
268 description: Some("hi".into()),
269 ..Default::default()
270 };
271 assert!(!e.is_empty());
272 }
273
274 #[test]
275 fn parse_offset_accepts_typical_form() {
276 assert_eq!(parse_offset("+05:30").as_deref(), Some("+05:30"));
277 assert_eq!(parse_offset("-08:00").as_deref(), Some("-08:00"));
278 }
279
280 #[test]
281 fn parse_offset_rejects_bad_strings() {
282 assert!(parse_offset("0530").is_none());
283 assert!(parse_offset("+5:30").is_none());
284 assert!(parse_offset("+15:00").is_none());
285 assert!(parse_offset("+05:60").is_none());
286 assert!(parse_offset("").is_none());
287 }
288
289 #[test]
290 fn cache_key_invalidates_on_mtime_change() {
291 let tmp = tempfile::NamedTempFile::new().expect("tempfile");
292 let k1 = cache_key(tmp.path()).expect("key1");
293 std::thread::sleep(std::time::Duration::from_millis(20));
294 std::fs::write(tmp.path(), b"changed").unwrap();
295 let k2 = cache_key(tmp.path()).expect("key2");
296 assert_eq!(k1.digest, k2.digest, "digest is canonical-path-based");
297 assert_ne!(
298 k1.mtime_nanos, k2.mtime_nanos,
299 "rewriting the file must bump mtime"
300 );
301 }
302
303 #[test]
304 fn load_or_extract_caches_negative_result() {
305 let cache_dir = tempfile::tempdir().expect("cache dir");
308 let mut data = tempfile::NamedTempFile::new().expect("tempfile");
309 use std::io::Write;
310 data.write_all(b"not an image").unwrap();
311 let first = load_or_extract(cache_dir.path(), data.path());
312 let count_after_first = std::fs::read_dir(cache_dir.path().join("exif"))
313 .map(|d| d.count())
314 .unwrap_or(0);
315 let second = load_or_extract(cache_dir.path(), data.path());
316 assert_eq!(first.is_some(), second.is_some());
317 assert_eq!(count_after_first, 1, "negative result is persisted once");
318 }
319}