Skip to main content

smriti/services/
exif_extractor.rs

1//! EXIF metadata extraction service
2//!
3//! Extracts date, GPS, camera info from image files.
4//! Falls back to filename parsing and file mtime when EXIF unavailable.
5
6use std::path::Path;
7
8use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, TimeZone, Utc};
9use exif::{In, Reader as ExifReader, Tag, Value};
10use lazy_static::lazy_static;
11use regex::Regex;
12
13/// Extracted metadata from an image
14#[derive(Debug, Clone, Default)]
15pub struct ImageMetadata {
16    // Date information
17    pub date_taken: Option<DateTime<Utc>>,
18    pub date_taken_source: Option<String>, // "exif", "filename", "mtime"
19    /// True when EXIF date tags exist, even if parsing fails.
20    pub had_exif_date_field: bool,
21
22    // GPS information
23    pub gps_latitude: Option<f64>,
24    pub gps_longitude: Option<f64>,
25
26    // Camera information
27    pub camera_make: Option<String>,
28    pub camera_model: Option<String>,
29
30    // Shooting parameters
31    pub iso: Option<i32>,
32    pub aperture: Option<String>,      // e.g. "f/2.8"
33    pub shutter_speed: Option<String>, // e.g. "1/125"
34    pub focal_length: Option<String>,  // e.g. "50mm"
35    pub lens_model: Option<String>,    // e.g. "iPhone 15 Pro back camera"
36    pub flash: Option<String>,         // e.g. "Off" or "Fired"
37    pub gps_altitude: Option<f64>,     // meters
38
39    // Image dimensions
40    pub width: Option<u32>,
41    pub height: Option<u32>,
42    pub orientation: Option<u16>,
43}
44
45/// EXIF extractor service
46pub struct ExifExtractor;
47
48impl ExifExtractor {
49    /// Extract metadata from an image file.
50    ///
51    /// Date priority (most-trusted first):
52    ///   1. EXIF DateTimeOriginal — when the shutter fired
53    ///   2. EXIF DateTimeDigitized — when the JPEG/file was digitized
54    ///   3. Filename pattern (IMG_/PXL_/Screenshot_/etc.)
55    ///   4. EXIF DateTime — image last-modified, often the copy time
56    ///      on phone exports rather than the capture time
57    ///   5. File mtime — last resort for entirely undated files
58    ///
59    /// `had_exif_date_field` is set ONLY by capture tags (Original or
60    /// Digitized). The modification `DateTime` tag does not block the
61    /// filename fallback, because it's frequently wrong for our purpose.
62    pub fn extract<P: AsRef<Path>>(path: P) -> ImageMetadata {
63        let path = path.as_ref();
64        let mut metadata = ImageMetadata::default();
65        let mut datetime_candidate: Option<DateTime<Utc>> = None;
66
67        // Try EXIF extraction
68        if let Some((exif_data, candidate)) = Self::extract_exif(path) {
69            metadata = exif_data;
70            datetime_candidate = candidate;
71        }
72
73        // Filename fallback. A malformed capture tag should not block a strict
74        // camera/export filename; corrupted EXIF is common in copied libraries.
75        if metadata.date_taken.is_none() {
76            if let Some(date) = Self::parse_date_from_filename(path) {
77                metadata.date_taken = Some(date);
78                metadata.date_taken_source = Some("filename".to_string());
79            }
80        }
81
82        // EXIF DateTime fallback. The modification tag is often stale — we've
83        // seen phones whose firmware clock reset to 2012-01-01 stamp every
84        // photo's `DateTime` tag with that value while mtime / shutter time
85        // is years later. Sanity check against mtime: when the two disagree
86        // by ≥ 2 years, prefer mtime (it's at least the file's real lifetime
87        // on this disk).
88        if metadata.date_taken.is_none() {
89            if let Some(candidate) = datetime_candidate {
90                let mtime = Self::get_file_mtime(path);
91                let stale = match mtime {
92                    Some(m) => (candidate.year() - m.year()).abs() >= 2,
93                    None => false,
94                };
95                if stale {
96                    if let Some(m) = mtime {
97                        metadata.date_taken = Some(m);
98                        metadata.date_taken_source = Some("mtime".to_string());
99                    } else {
100                        metadata.date_taken = Some(candidate);
101                        metadata.date_taken_source = Some("file_meta".to_string());
102                    }
103                } else {
104                    metadata.date_taken = Some(candidate);
105                    metadata.date_taken_source = Some("file_meta".to_string());
106                }
107            }
108        }
109
110        // File mtime — last resort.
111        if metadata.date_taken.is_none() {
112            if let Some(date) = Self::get_file_mtime(path) {
113                metadata.date_taken = Some(date);
114                metadata.date_taken_source = Some("mtime".to_string());
115            }
116        }
117
118        // Get image dimensions if not from EXIF
119        if metadata.width.is_none() || metadata.height.is_none() {
120            if let Some((w, h)) = Self::get_image_dimensions(path) {
121                metadata.width = Some(w);
122                metadata.height = Some(h);
123            }
124        }
125
126        metadata
127    }
128
129    /// Extract EXIF data from a file. Returns the metadata block plus
130    /// (separately) a `DateTime` candidate — the modification-time tag
131    /// — so the caller can decide whether to prefer it over the
132    /// filename fallback.
133    fn extract_exif<P: AsRef<Path>>(path: P) -> Option<(ImageMetadata, Option<DateTime<Utc>>)> {
134        let file = std::fs::File::open(path.as_ref()).ok()?;
135        let mut bufreader = std::io::BufReader::new(&file);
136        let exif = ExifReader::new().read_from_container(&mut bufreader).ok()?;
137
138        let mut metadata = ImageMetadata::default();
139        let mut datetime_candidate: Option<DateTime<Utc>> = None;
140
141        // Capture-time tag #1: when the shutter fired. Most trustworthy.
142        if let Some(field) = exif.get_field(Tag::DateTimeOriginal, In::PRIMARY) {
143            metadata.had_exif_date_field = true;
144            let date_str: String = field.display_value().to_string();
145            if let Some(date) = Self::parse_exif_date(&date_str) {
146                metadata.date_taken = Some(date);
147                metadata.date_taken_source = Some("exif".to_string());
148            }
149        }
150
151        // Capture-time tag #2: when the file was digitized. Phone
152        // exports often carry this when DateTimeOriginal is missing.
153        if metadata.date_taken.is_none() {
154            if let Some(field) = exif.get_field(Tag::DateTimeDigitized, In::PRIMARY) {
155                metadata.had_exif_date_field = true;
156                let date_str: String = field.display_value().to_string();
157                if let Some(date) = Self::parse_exif_date(&date_str) {
158                    metadata.date_taken = Some(date);
159                    metadata.date_taken_source = Some("exif_digitized".to_string());
160                }
161            }
162        }
163
164        // Modification-time tag — held aside as a candidate. The caller
165        // tries the filename pattern first (often more accurate for
166        // copied phone photos than this tag's "I was modified in 2012"
167        // value) and only falls back to this when nothing else worked.
168        if let Some(field) = exif.get_field(Tag::DateTime, In::PRIMARY) {
169            let date_str: String = field.display_value().to_string();
170            if let Some(date) = Self::parse_exif_date(&date_str) {
171                datetime_candidate = Some(date);
172            }
173        }
174
175        // GPS coordinates
176        metadata.gps_latitude =
177            Self::extract_gps_coord(&exif, Tag::GPSLatitude, Tag::GPSLatitudeRef);
178        metadata.gps_longitude =
179            Self::extract_gps_coord(&exif, Tag::GPSLongitude, Tag::GPSLongitudeRef);
180
181        // Camera make
182        if let Some(field) = exif.get_field(Tag::Make, In::PRIMARY) {
183            metadata.camera_make = Self::clean_exif_text(field);
184        }
185
186        // Camera model
187        if let Some(field) = exif.get_field(Tag::Model, In::PRIMARY) {
188            metadata.camera_model = Self::clean_exif_text(field);
189        }
190
191        // Image dimensions
192        if let Some(field) = exif.get_field(Tag::PixelXDimension, In::PRIMARY) {
193            if let Value::Long(ref vec) = field.value {
194                if let Some(&w) = vec.first() {
195                    metadata.width = Some(w);
196                }
197            }
198        }
199        if let Some(field) = exif.get_field(Tag::PixelYDimension, In::PRIMARY) {
200            if let Value::Long(ref vec) = field.value {
201                if let Some(&h) = vec.first() {
202                    metadata.height = Some(h);
203                }
204            }
205        }
206
207        // Orientation
208        if let Some(field) = exif.get_field(Tag::Orientation, In::PRIMARY) {
209            if let Value::Short(ref vec) = field.value {
210                if let Some(&o) = vec.first() {
211                    metadata.orientation = Some(o);
212                }
213            }
214        }
215
216        // ISO
217        if let Some(field) = exif.get_field(Tag::PhotographicSensitivity, In::PRIMARY) {
218            if let Value::Short(ref vec) = field.value {
219                if let Some(&iso) = vec.first() {
220                    metadata.iso = Some(iso as i32);
221                }
222            } else if let Value::Long(ref vec) = field.value {
223                if let Some(&iso) = vec.first() {
224                    metadata.iso = Some(iso as i32);
225                }
226            }
227        }
228
229        // Aperture (FNumber)
230        if let Some(field) = exif.get_field(Tag::FNumber, In::PRIMARY) {
231            if let Value::Rational(ref vec) = field.value {
232                if let Some(r) = vec.first() {
233                    let f = r.to_f64();
234                    if f > 0.0 {
235                        metadata.aperture = Some(format!("f/{:.1}", f));
236                    }
237                }
238            }
239        }
240
241        // Shutter speed (ExposureTime)
242        if let Some(field) = exif.get_field(Tag::ExposureTime, In::PRIMARY) {
243            if let Value::Rational(ref vec) = field.value {
244                if let Some(r) = vec.first() {
245                    if r.num > 0 && r.denom > 0 {
246                        if r.num >= r.denom {
247                            metadata.shutter_speed = Some(format!("{}s", r.to_f64()));
248                        } else {
249                            metadata.shutter_speed = Some(format!("{}/{}", r.num, r.denom));
250                        }
251                    }
252                }
253            }
254        }
255
256        // Focal length
257        if let Some(field) = exif.get_field(Tag::FocalLength, In::PRIMARY) {
258            if let Value::Rational(ref vec) = field.value {
259                if let Some(r) = vec.first() {
260                    let fl = r.to_f64();
261                    if fl > 0.0 {
262                        metadata.focal_length = Some(format!("{:.0}mm", fl));
263                    }
264                }
265            }
266        }
267
268        // Lens model
269        if let Some(field) = exif.get_field(Tag::LensModel, In::PRIMARY) {
270            metadata.lens_model = Self::clean_exif_text(field);
271        }
272
273        // Flash
274        if let Some(field) = exif.get_field(Tag::Flash, In::PRIMARY) {
275            if let Value::Short(ref vec) = field.value {
276                if let Some(&flash_val) = vec.first() {
277                    metadata.flash = Some(if flash_val & 1 == 1 {
278                        "Fired".to_string()
279                    } else {
280                        "Off".to_string()
281                    });
282                }
283            }
284        }
285
286        // GPS Altitude
287        if let Some(field) = exif.get_field(Tag::GPSAltitude, In::PRIMARY) {
288            if let Value::Rational(ref vec) = field.value {
289                if let Some(r) = vec.first() {
290                    let mut alt = r.to_f64();
291                    // Check altitude ref (0 = above sea level, 1 = below)
292                    if let Some(ref_field) = exif.get_field(Tag::GPSAltitudeRef, In::PRIMARY) {
293                        if let Value::Byte(ref bytes) = ref_field.value {
294                            if bytes.first() == Some(&1) {
295                                alt = -alt;
296                            }
297                        }
298                    }
299                    metadata.gps_altitude = Some(alt);
300                }
301            }
302        }
303
304        Some((metadata, datetime_candidate))
305    }
306
307    fn clean_exif_text(field: &exif::Field) -> Option<String> {
308        if let Value::Ascii(chunks) = &field.value {
309            for chunk in chunks {
310                let s = String::from_utf8_lossy(chunk);
311                if let Some(clean) = Self::clean_text_candidate(&s) {
312                    return Some(clean);
313                }
314            }
315        }
316
317        Self::clean_text_candidate(&field.display_value().to_string())
318    }
319
320    fn clean_text_candidate(raw: &str) -> Option<String> {
321        let mut best: Option<String> = None;
322        let parts: Vec<&str> = if raw.contains("\",") || raw.contains(", \"") {
323            raw.split(',').collect()
324        } else {
325            vec![raw]
326        };
327        for part in parts {
328            let clean = part
329                .replace('\0', "")
330                .trim()
331                .trim_matches('"')
332                .trim()
333                .to_string();
334            if clean.is_empty() {
335                continue;
336            }
337            best = Some(clean);
338            break;
339        }
340        best
341    }
342
343    /// Extract GPS coordinate from EXIF
344    fn extract_gps_coord(exif: &exif::Exif, coord_tag: Tag, ref_tag: Tag) -> Option<f64> {
345        let field = exif.get_field(coord_tag, In::PRIMARY)?;
346        let ref_field = exif.get_field(ref_tag, In::PRIMARY)?;
347
348        // Parse coordinate value (degrees, minutes, seconds)
349        let coord = match &field.value {
350            Value::Rational(rationals) if rationals.len() >= 3 => {
351                let degrees = rationals[0].to_f64();
352                let minutes = rationals[1].to_f64();
353                let seconds = rationals[2].to_f64();
354                degrees + minutes / 60.0 + seconds / 3600.0
355            }
356            _ => return None,
357        };
358
359        // Apply reference (N/S or E/W)
360        let ref_str: String = ref_field.display_value().to_string();
361        let multiplier = if ref_str.contains('S') || ref_str.contains('W') {
362            -1.0
363        } else {
364            1.0
365        };
366
367        Some(coord * multiplier)
368    }
369
370    /// Parse EXIF date format: "2019:03:15 14:30:22".
371    ///
372    /// Strips any trailing timezone suffix (`Z`, `+HH:MM`, `-HH:MM`) and
373    /// keeps the wall-clock numbers the camera recorded. We deliberately
374    /// don't apply the offset: users remember photos by *local* time
375    /// ("dinner at 10pm"), not by the UTC equivalent. The resulting
376    /// `DateTime<Utc>` carries those wall-clock numbers verbatim, and the
377    /// frontend treats the serialized string as local time.
378    pub(crate) fn parse_exif_date(date_str: &str) -> Option<DateTime<Utc>> {
379        let mut clean = date_str
380            .trim()
381            .trim_matches('"')
382            .replace('\0', "")
383            .trim()
384            .to_string();
385
386        // Strip trailing TZ suffix, if any. Looks like `Z`, `+05:30`,
387        // `-08:00`, or `+0530`.
388        if let Some(stripped) = strip_tz_suffix(&clean) {
389            clean = stripped;
390        }
391
392        let formats = [
393            "%Y:%m:%d %H:%M:%S",
394            "%Y:%m:%d %H:%M:%S%.f",
395            "%Y:%m:%d %H:%M",
396            "%Y-%m-%d %H:%M:%S",
397            "%Y-%m-%d %H:%M:%S%.f",
398            "%Y-%m-%dT%H:%M:%S",
399            "%Y-%m-%dT%H:%M:%S%.f",
400        ];
401        for fmt in formats {
402            if let Ok(parsed) = NaiveDateTime::parse_from_str(&clean, fmt) {
403                return Self::plausible_datetime(parsed).map(|d| Utc.from_utc_datetime(&d));
404            }
405        }
406
407        // Some cameras use '-' instead of ':' in the date segment.
408        if clean.len() >= 10 {
409            let mut normalized = clean.clone();
410            let bytes = normalized.as_bytes().to_vec();
411            if bytes.get(4) == Some(&b'-') && bytes.get(7) == Some(&b'-') {
412                normalized.replace_range(4..5, ":");
413                normalized.replace_range(7..8, ":");
414                for fmt in formats {
415                    if let Ok(parsed) = NaiveDateTime::parse_from_str(&normalized, fmt) {
416                        return Self::plausible_datetime(parsed).map(|d| Utc.from_utc_datetime(&d));
417                    }
418                }
419            }
420        }
421
422        for fmt in ["%Y:%m:%d", "%Y-%m-%d"] {
423            if let Ok(parsed) = NaiveDate::parse_from_str(&clean, fmt) {
424                let datetime = parsed.and_hms_opt(0, 0, 0)?;
425                return Self::plausible_datetime(datetime).map(|d| Utc.from_utc_datetime(&d));
426            }
427        }
428
429        None
430    }
431
432    pub(crate) fn plausible_datetime(dt: NaiveDateTime) -> Option<NaiveDateTime> {
433        let year = dt.year();
434        let max_year = Utc::now().year() + 1;
435        if (1900..=max_year).contains(&year) {
436            Some(dt)
437        } else {
438            None
439        }
440    }
441
442    /// Parse date from filename patterns
443    pub fn parse_date_from_filename<P: AsRef<Path>>(path: P) -> Option<DateTime<Utc>> {
444        lazy_static! {
445            // Common filename date patterns
446            static ref PATTERNS: Vec<(Regex, &'static str)> = vec![
447                // IMG_20190315_143022.jpg
448                (Regex::new(r"IMG_(\d{8})_(\d{6})").unwrap(), "%Y%m%d%H%M%S"),
449                // 20190315_143022.jpg
450                (Regex::new(r"(\d{8})_(\d{6})").unwrap(), "%Y%m%d%H%M%S"),
451                // 2019-03-15 14.30.22.jpg
452                (Regex::new(r"(\d{4}-\d{2}-\d{2}) (\d{2}\.\d{2}\.\d{2})").unwrap(), "%Y-%m-%d%H.%M.%S"),
453                // Screenshot_20190315-143022.png / Screenshot_20190315_143022.png
454                (Regex::new(r"Screenshot_(\d{8})[-_](\d{6})").unwrap(), "%Y%m%d%H%M%S"),
455                // VID_20190315_143022.mp4
456                (Regex::new(r"VID_(\d{8})_(\d{6})").unwrap(), "%Y%m%d%H%M%S"),
457                // PXL_20190315_143022.jpg / PXL_20190315_143022123.jpg (Pixel phones)
458                (Regex::new(r"PXL_(\d{8})_(\d{6})(?:\d{3})?").unwrap(), "%Y%m%d%H%M%S"),
459                // IMG_20190315_143022_123.jpg
460                (Regex::new(r"(?:IMG|VID|PXL)_(\d{8})_(\d{6})[_\.-]\d+").unwrap(), "%Y%m%d%H%M%S"),
461                // WhatsApp: IMG-20190315-WA0001.jpg, VID-20190315-WA0001.mp4
462                (Regex::new(r"(?:IMG|VID)-(\d{8})-WA\d+").unwrap(), "%Y%m%d"),
463                // Screenshot 2019-03-15 at 14.30.22.png
464                (Regex::new(r"Screenshot[ _](\d{4})-(\d{2})-(\d{2}) at (\d{2})\.(\d{2})\.(\d{2})").unwrap(), "%Y%m%d%H%M%S"),
465                // Signal: signal-2019-03-15-143022.jpg
466                (Regex::new(r"signal-(\d{4})-(\d{2})-(\d{2})-(\d{6})").unwrap(), "%Y%m%d%H%M%S"),
467                // Compact ISO: 20190315-143022.jpg
468                (Regex::new(r"(\d{8})-(\d{6})").unwrap(), "%Y%m%d%H%M%S"),
469                // Snapchat / Android camera apps: 2019-03-15-14-30-22.jpg
470                (Regex::new(r"(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})").unwrap(), "%Y%m%d%H%M%S"),
471                // Generic ISO with separators: 2019-03-15_14-30-22.jpg
472                (Regex::new(r"(\d{4})-(\d{2})-(\d{2})[ _](\d{2})[\.\-_](\d{2})[\.\-_](\d{2})").unwrap(), "%Y%m%d%H%M%S"),
473                // Just date — anchored so an ID like ABCD20120000.jpg can't
474                // hijack the parse. Requires non-digit boundary or start.
475                (Regex::new(r"(?:^|[^\d])(\d{8})\.").unwrap(), "%Y%m%d"),
476            ];
477        }
478
479        let filename = path.as_ref().file_name()?.to_str()?;
480
481        for (regex, format) in PATTERNS.iter() {
482            if let Some(caps) = regex.captures(filename) {
483                // Combine captured groups
484                let date_str: String = caps
485                    .iter()
486                    .skip(1)
487                    .filter_map(|m| m.map(|m| m.as_str()))
488                    .collect();
489
490                // Try to parse as datetime
491                if let Ok(parsed) = NaiveDateTime::parse_from_str(&date_str, format) {
492                    if let Some(parsed) = Self::plausible_datetime(parsed) {
493                        return Some(Utc.from_utc_datetime(&parsed));
494                    }
495                }
496
497                // Try date only format
498                if let Ok(parsed) = chrono::NaiveDate::parse_from_str(&date_str, "%Y%m%d") {
499                    let datetime = parsed.and_hms_opt(0, 0, 0)?;
500                    if let Some(datetime) = Self::plausible_datetime(datetime) {
501                        return Some(Utc.from_utc_datetime(&datetime));
502                    }
503                }
504            }
505        }
506
507        None
508    }
509
510    /// Get file modification time
511    fn get_file_mtime<P: AsRef<Path>>(path: P) -> Option<DateTime<Utc>> {
512        let metadata = std::fs::metadata(path.as_ref()).ok()?;
513        let mtime = metadata.modified().ok()?;
514        let duration = mtime.duration_since(std::time::UNIX_EPOCH).ok()?;
515
516        DateTime::from_timestamp(duration.as_secs() as i64, 0)
517    }
518
519    /// Get image dimensions by reading the header only
520    fn get_image_dimensions<P: AsRef<Path>>(path: P) -> Option<(u32, u32)> {
521        let reader = image::ImageReader::open(path.as_ref()).ok()?;
522        let (w, h) = reader.into_dimensions().ok()?;
523        Some((w, h))
524    }
525}
526
527/// If `s` ends with a recognisable EXIF timezone suffix (`Z`, `+HH:MM`,
528/// `-HH:MM`, `+HHMM`, `-HHMM`), return `s` with the suffix removed and
529/// any trailing whitespace trimmed. Otherwise returns `None` so the
530/// caller can use the original string.
531fn strip_tz_suffix(s: &str) -> Option<String> {
532    let trimmed = s.trim_end();
533    if let Some(without_z) = trimmed.strip_suffix('Z') {
534        return Some(without_z.trim_end().to_string());
535    }
536    // Look for the last + or - that could plausibly be a TZ marker.
537    // Time portion is at the end (HH:MM:SS), so a TZ marker only makes
538    // sense after position 10 (past the date).
539    let bytes = trimmed.as_bytes();
540    let mut tz_start = None;
541    for (i, &b) in bytes.iter().enumerate().rev() {
542        if i < 10 {
543            break;
544        }
545        if b == b'+' || b == b'-' {
546            // The chars before this position should be ':SS' (i.e.
547            // we're sitting just past the seconds field).
548            if i >= 2
549                && bytes[i - 1].is_ascii_digit()
550                && (bytes[i - 2].is_ascii_digit() || bytes[i - 2] == b':')
551            {
552                tz_start = Some(i);
553                break;
554            }
555        }
556    }
557    let start = tz_start?;
558    let suffix = &trimmed[start..];
559    // Validate suffix looks like ±HH:MM or ±HHMM.
560    let body = &suffix[1..];
561    let valid = match body.len() {
562        4 => body.chars().all(|c| c.is_ascii_digit()),
563        5 => {
564            let b = body.as_bytes();
565            b[0].is_ascii_digit()
566                && b[1].is_ascii_digit()
567                && b[2] == b':'
568                && b[3].is_ascii_digit()
569                && b[4].is_ascii_digit()
570        }
571        _ => false,
572    };
573    if !valid {
574        return None;
575    }
576    Some(trimmed[..start].trim_end().to_string())
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582    use chrono::{Datelike, Timelike};
583
584    #[test]
585    fn test_filename_date_parsing() {
586        let path = std::path::Path::new("IMG_20190315_143022.jpg");
587        let date = ExifExtractor::parse_date_from_filename(path);
588        assert!(date.is_some());
589
590        let dt = date.unwrap();
591        assert_eq!(dt.year(), 2019);
592        assert_eq!(dt.month(), 3);
593        assert_eq!(dt.day(), 15);
594    }
595
596    #[test]
597    fn test_filename_date_parsing_plain() {
598        let path = std::path::Path::new("20190315_143022.jpg");
599        let date = ExifExtractor::parse_date_from_filename(path);
600        assert!(date.is_some());
601
602        let dt = date.unwrap();
603        assert_eq!(dt.year(), 2019);
604    }
605
606    #[test]
607    fn test_filename_date_parsing_pixel() {
608        let path = std::path::Path::new("PXL_20210801_120000.jpg");
609        let date = ExifExtractor::parse_date_from_filename(path);
610        assert!(date.is_some());
611
612        let dt = date.unwrap();
613        assert_eq!(dt.year(), 2021);
614        assert_eq!(dt.month(), 8);
615    }
616
617    #[test]
618    fn test_filename_date_parsing_pixel_with_millis() {
619        let path = std::path::Path::new("PXL_20210801_120000123.NIGHT.jpg");
620        let dt = ExifExtractor::parse_date_from_filename(path).unwrap();
621        assert_eq!(dt.year(), 2021);
622        assert_eq!(dt.second(), 0);
623    }
624
625    #[test]
626    fn test_filename_date_parsing_social_exports() {
627        let signal = std::path::Path::new("signal-2024-01-15-101600.jpg");
628        let dt = ExifExtractor::parse_date_from_filename(signal).unwrap();
629        assert_eq!(dt.year(), 2024);
630        assert_eq!(dt.hour(), 10);
631
632        let screenshot = std::path::Path::new("Screenshot 2024-01-15 at 10.16.00.png");
633        let dt = ExifExtractor::parse_date_from_filename(screenshot).unwrap();
634        assert_eq!(dt.minute(), 16);
635    }
636
637    #[test]
638    fn test_exif_date_parsing() {
639        let date = ExifExtractor::parse_exif_date("2019:03:15 14:30:22");
640        assert!(date.is_some());
641
642        let dt = date.unwrap();
643        assert_eq!(dt.year(), 2019);
644    }
645
646    #[test]
647    fn test_exif_date_parsing_iso_variants() {
648        let dt = ExifExtractor::parse_exif_date("2024-01-15T10:16:00.123+05:30").unwrap();
649        assert_eq!(dt.year(), 2024);
650        assert_eq!(dt.hour(), 10);
651
652        let date_only = ExifExtractor::parse_exif_date("2024-01-15").unwrap();
653        assert_eq!(date_only.hour(), 0);
654    }
655
656    #[test]
657    fn test_exif_date_rejects_placeholder_years() {
658        assert!(ExifExtractor::parse_exif_date("0000:00:00 00:00:00").is_none());
659        assert!(ExifExtractor::parse_exif_date("1899:12:31 23:59:59").is_none());
660    }
661
662    #[test]
663    fn test_no_date_from_random_filename() {
664        let path = std::path::Path::new("vacation_photo.jpg");
665        let date = ExifExtractor::parse_date_from_filename(path);
666        assert!(date.is_none());
667    }
668
669    #[test]
670    fn test_whatsapp_filename() {
671        let path = std::path::Path::new("IMG-20260307-WA0001.jpg");
672        let date = ExifExtractor::parse_date_from_filename(path);
673        assert!(date.is_some());
674        let dt = date.unwrap();
675        assert_eq!(dt.year(), 2026);
676        assert_eq!(dt.month(), 3);
677        assert_eq!(dt.day(), 7);
678    }
679
680    #[test]
681    fn test_dashed_iso_filename() {
682        let path = std::path::Path::new("2026-03-07-14-30-22.jpg");
683        let date = ExifExtractor::parse_date_from_filename(path);
684        assert!(date.is_some());
685        let dt = date.unwrap();
686        assert_eq!(dt.year(), 2026);
687        assert_eq!(dt.month(), 3);
688    }
689
690    #[test]
691    fn test_loose_8digit_no_longer_hijacks_id_string() {
692        // ID-only filename with 8 digits embedded — the pattern requires a
693        // non-digit boundary before the 8 digits, so a 10-digit ID like
694        // mmexport1234567890.jpg won't get parsed as 1234-56-78.
695        let path = std::path::Path::new("mmexport1234567890.jpg");
696        let date = ExifExtractor::parse_date_from_filename(path);
697        // Falls through to the loose pattern, but the leading char before
698        // the 8-digit window is a digit, so it should not match.
699        assert!(date.is_none());
700    }
701
702    #[test]
703    fn test_exif_date_strips_tz_offset() {
704        // `+05:30` should be discarded; wall-clock 10:16 must survive.
705        let dt = ExifExtractor::parse_exif_date("2024:01:15 10:16:00+05:30").unwrap();
706        assert_eq!(dt.hour(), 10);
707        assert_eq!(dt.minute(), 16);
708    }
709
710    #[test]
711    fn test_exif_date_strips_z_suffix() {
712        let dt = ExifExtractor::parse_exif_date("2024:01:15 10:16:00Z").unwrap();
713        assert_eq!(dt.hour(), 10);
714    }
715
716    #[test]
717    fn test_exif_date_strips_compact_tz() {
718        let dt = ExifExtractor::parse_exif_date("2024:01:15 22:16:00-0500").unwrap();
719        assert_eq!(dt.hour(), 22);
720    }
721
722    #[test]
723    fn test_loose_8digit_still_works_with_letter_prefix() {
724        let path = std::path::Path::new("Photo20190315.jpg");
725        let date = ExifExtractor::parse_date_from_filename(path);
726        assert!(date.is_some());
727        let dt = date.unwrap();
728        assert_eq!(dt.year(), 2019);
729    }
730}