//! Canonical `date_taken` resolution for ingest and the per-tick backfill //! drain. //! //! The waterfall (in order; first hit wins): //! //! 1. **kamadak-exif** — fast in-process EXIF read. Already done by //! `exif::extract_exif_from_path` for image-bearing formats; callers //! pass that result in via `prior_exif_date` so we don't re-parse. //! 2. **exiftool** — shell-out fallback that reaches places kamadak-exif //! can't: QuickTime/MP4 (`MediaCreateDate`, `TrackCreateDate`, //! `CreateDate`), Apple's `ContentCreateDate`, MakerNote sub-IFDs. //! Required for videos to land a real date; degrades silently when //! `exiftool` isn't on PATH. Unlike the kamadak branch, this branch //! reads raw date *strings* (no `-d %s`) and interprets them itself: //! QuickTime/MP4 container dates are true instants (UTC-rendered //! `creation_time` fields) and skip the cascade; photo-EXIF wall //! clocks run the cascade over the file's own `OffsetTime*` / GPS //! evidence (ADR-0001, ticket #115). //! 0. **filename date class** (ticket #116) — `memories::extract_date_from_filename` //! covers common screenshot / dash / compact / unix-timestamp patterns. //! A file whose name matches any pattern is **in the class**: the //! filename is the original date of record and wins *before* EXIF / container //! dates are even consulted (wall patterns run the offset cascade over the //! file's own evidence; timestamp patterns are true instants). //! 1. **kamadak-exif** — fast in-process EXIF read. Already done by //! `exif::extract_exif_from_path` for image-bearing formats; callers //! pass that result in via `prior_exif_date` so we don't re-parse. //! 2. **exiftool** — shell-out fallback that reaches places kamadak-exif //! can't: QuickTime/MP4 (`MediaCreateDate`, `TrackCreateDate`, //! `CreateDate`), Apple's `ContentCreateDate`, MakerNote sub-IFDs. //! Required for videos to land a real date; degrades silently when //! `exiftool` isn't on PATH. Unlike the kamadak branch, this branch //! reads raw date *strings* (no `-d %s`) and interprets them itself: //! QuickTime/MP4 container dates are true instants (UTC-rendered //! `creation_time` fields) and skip the cascade; photo-EXIF wall //! clocks run the cascade over the file's own `OffsetTime*` / GPS //! evidence (ADR-0001, ticket #115). //! 3. **earliest filesystem time** — `utils::earliest_fs_time` picks the //! earlier of created / modified, which on copied-from-backup files is //! a better proxy for content age than either alone. //! //! `DateSource` records which step won so the per-tick drain can re-resolve //! weak sources (`fs_time`) once exiftool becomes available, and so the //! UI/debug surface can answer "why does this photo show up under this //! date." For out-of-class files EXIF is authoritative when it exists, //! on the theory that an EXIF `DateTimeOriginal` is more reliable than a //! filename pattern that may reflect import time rather than capture time. //! In-class files invert that rule deliberately (ADR-0001): a renamed //! screenshot or a chat-export timestamp is the only honest date left. use std::collections::HashMap; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::OnceLock; use chrono::{DateTime, Datelike, Local, LocalResult, NaiveDateTime, TimeZone, Utc}; use log::{debug, trace, warn}; use crate::camera_tz; use crate::utils::earliest_fs_time; #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum DateSource { /// kamadak-exif read DateTime/DateTimeOriginal directly. Exif, /// exiftool fallback caught a video / MakerNote / QuickTime tag. Exiftool, /// `extract_date_from_filename` matched a known pattern — the file is /// in the filename date class and the name outranked EXIF (#116). Filename, /// Fell through to `earliest_fs_time(metadata)`. FsTime, } impl DateSource { pub fn as_str(self) -> &'static str { match self { DateSource::Exif => "exif", DateSource::Exiftool => "exiftool", DateSource::Filename => "filename", DateSource::FsTime => "fs_time", } } } /// What the ingest path already knows from kamadak-exif for one file: the /// camera wall-clock reading plus the offset evidence for the cascade. #[derive(Copy, Clone, Debug)] pub struct ExifPrior { /// Camera wall clock, decoded the same way the legacy column stores it /// (naive-as-UTC from `parse_exif_datetime`). The legacy `date_taken` /// value stays byte-identical. pub wall: NaiveDateTime, /// Offset evidence (EXIF `OffsetTime*` tag, GPS coordinates). pub evidence: camera_tz::OffsetEvidence, } #[derive(Copy, Clone, Debug)] pub struct ResolvedDate { /// Legacy `date_taken` value. Kept byte-identical to the pre-migration /// behavior of each branch so rollback stays trivial (ADR-0001). pub timestamp: i64, pub source: DateSource, /// True UTC instant under the new contract (ADR-0001). Every branch /// classifies (wall clocks via the cascade, instants directly); /// `Option` is kept for schema compatibility with rows written before /// the date-class work landed (#116). pub date_utc: Option, /// Camera offset (minutes) that produced `date_utc`; `None` for direct /// true instants (no offset is involved). pub camera_offset_minutes: Option, /// Why `date_utc` has the offset it does; `None` on rows written /// before every branch classified (#116). pub tz_source: Option, } /// Build an [`ExifPrior`] from a kamadak-exif [`crate::exif::ExifData`]: /// the wall clock (naive-as-UTC, matching the legacy column) plus the /// offset evidence (stamped offset tag + GPS). `None` when the file had no /// EXIF date. pub fn exif_prior_from(data: &crate::exif::ExifData) -> Option { let wall = DateTime::from_timestamp(data.date_taken?, 0)?.naive_utc(); Some(ExifPrior { wall, evidence: camera_tz::OffsetEvidence { offset_tag_minutes: data.offset_time_minutes, gps: match (data.gps_latitude, data.gps_longitude) { (Some(lat), Some(lon)) => Some((lat, lon)), _ => None, }, }, }) } /// EXIF-branch resolution: run the offset cascade over the wall clock and /// offset evidence kamadak-exif already extracted. Legacy timestamp is the /// wall clock as-UTC — unchanged from the pre-migration behavior. fn exif_prior_resolved(prior: &ExifPrior) -> ResolvedDate { let resolution = camera_tz::resolve_camera_offset( prior.wall, &prior.evidence, server_zone_cached(), gps_lookup(), ); ResolvedDate { timestamp: prior.wall.and_utc().timestamp(), source: DateSource::Exif, date_utc: Some(camera_tz::wall_to_instant(prior.wall, resolution.minutes)), camera_offset_minutes: Some(resolution.minutes), tz_source: Some(resolution.source), } } /// The filename-class step (ticket #116), shared by both waterfalls: the /// in-class name is the original date of record and outranks EXIF / /// container dates. `evidence` is the kamadak prior's offset evidence when /// the file has one; `None` means empty evidence — the cascade then assumes /// the server zone, so a class file always resolves. fn filename_class_resolved( path: &Path, evidence: Option<&camera_tz::OffsetEvidence>, ) -> Option { let filename_date = path .file_name() .and_then(|f| f.to_str()) .and_then(crate::memories::extract_date_from_filename)?; let ev = evidence.copied().unwrap_or_default(); Some(filename_resolved(&filename_date, &ev)) } /// Classify one filename date (ticket #116). Wall patterns run the offset /// cascade over whatever evidence the file carries; timestamp patterns are /// true instants outright. The legacy value is the pattern's pre-migration /// interpretation: wall-as-UTC / epoch. fn filename_resolved( filename_date: &crate::memories::FilenameDate, evidence: &camera_tz::OffsetEvidence, ) -> ResolvedDate { match filename_date { crate::memories::FilenameDate::Utc(instant) => ResolvedDate { timestamp: instant.timestamp(), source: DateSource::Filename, date_utc: Some(instant.timestamp()), camera_offset_minutes: None, tz_source: Some(camera_tz::TzSource::UtcDirect), }, crate::memories::FilenameDate::Wall(wall) => { let resolution = camera_tz::resolve_camera_offset( *wall, evidence, server_zone_cached(), gps_lookup(), ); ResolvedDate { timestamp: wall.and_utc().timestamp(), source: DateSource::Filename, date_utc: Some(camera_tz::wall_to_instant(*wall, resolution.minutes)), camera_offset_minutes: Some(resolution.minutes), tz_source: Some(resolution.source), } } } } /// Host zone, resolved once per process. fn server_zone_cached() -> Option { static ZONE: OnceLock> = OnceLock::new(); *ZONE.get_or_init(camera_tz::server_zone) } /// Shared GPS → zone adapter (bundled TZF geometry, loaded on first use). fn gps_lookup() -> &'static camera_tz::TzfGpsZoneLookup { static LOOKUP: OnceLock = OnceLock::new(); LOOKUP.get_or_init(camera_tz::TzfGpsZoneLookup::new) } /// Resolve the canonical date for a single file, given an already-extracted /// kamadak-exif reading if available. Returns `None` only if every step in /// the waterfall fails — for files that exist on disk this should be /// vanishingly rare (the fs-time fallback alone almost always succeeds). pub fn resolve_date_taken(path: &Path, prior_exif: Option<&ExifPrior>) -> Option { // Filename date class first (ticket #116): an in-class name is the // original date of record and outranks EXIF / container dates. if let Some(resolved) = filename_class_resolved(path, prior_exif.map(|p| &p.evidence)) { return Some(resolved); } if let Some(prior) = prior_exif { return Some(exif_prior_resolved(prior)); } if let Some(resolved) = exiftool_date_single(path) { return Some(resolved); } if let Ok(meta) = std::fs::metadata(path) && let Some(t) = earliest_fs_time(&meta) { let dt: DateTime = t.into(); return Some(ResolvedDate { timestamp: dt.timestamp(), source: DateSource::FsTime, date_utc: Some(dt.timestamp()), camera_offset_minutes: None, tz_source: Some(camera_tz::TzSource::UtcDirect), }); } None } /// Batch waterfall. In-class filenames resolve first (ticket #116) and /// never hit exiftool; exiftool then runs once over the remaining /// no-prior files (single subprocess); everything else is per-file and /// runs only on misses. `prior_exif_priors` lets the caller pass in /// already-known kamadak readings keyed by path; entries without a prior /// date fall through to exiftool and the rest of the waterfall. /// /// The per-tick backfill drain is the primary caller — it loads ~500 rows /// at a time and uses one exiftool subprocess to drain the lot. pub fn resolve_dates_batch( paths: &[PathBuf], prior_exif_priors: &HashMap, ) -> HashMap { let mut out: HashMap = HashMap::new(); let mut needs_exiftool: Vec<&Path> = Vec::with_capacity(paths.len()); for path in paths { // Filename date class first (ticket #116) — same rule as the // single-file waterfall; in-class files skip exiftool entirely. if let Some(resolved) = filename_class_resolved(path, prior_exif_priors.get(path).map(|p| &p.evidence)) { out.insert(path.clone(), resolved); continue; } if let Some(prior) = prior_exif_priors.get(path) { out.insert(path.clone(), exif_prior_resolved(prior)); } else { needs_exiftool.push(path.as_path()); } } if !needs_exiftool.is_empty() { // One subprocess for the whole batch; each entry is already fully // resolved (legacy + new-contract fields). for (path, resolved) in exiftool_dates_batch(&needs_exiftool) { out.insert(path, resolved); } } // Remaining misses are out-of-class files with no EXIF / container // date — the filename step can't match them (any match would have // resolved in the first loop), so they fall straight to fs-time. for path in paths { if out.contains_key(path) { continue; } if let Ok(meta) = std::fs::metadata(path) && let Some(t) = earliest_fs_time(&meta) { let dt: DateTime = t.into(); out.insert( path.clone(), ResolvedDate { timestamp: dt.timestamp(), source: DateSource::FsTime, date_utc: Some(dt.timestamp()), camera_offset_minutes: None, tz_source: Some(camera_tz::TzSource::UtcDirect), }, ); } } out } /// Tag priority for exiftool extraction. First parseable value wins. /// /// Photos: `DateTimeOriginal` (original capture) and `SubSecDateTimeOriginal` /// are most authoritative. `CreateDate` is a common alias and a sane fallback. /// /// Videos: `MediaCreateDate` / `TrackCreateDate` are the QuickTime/MP4 /// timestamps. `ContentCreateDate` is Apple's iOS-set tag; it often /// reflects local capture time on iPhone exports better than the others. /// /// `ModifyDate` comes last and only from `IFD0` (see /// [`MODIFY_DATE_GROUP`]): that's EXIF `DateTime`, which the kamadak /// ingest path already takes as the capture date when nothing better /// exists. Scans, edits and some downloads carry only that tag, and /// without it here the drain resolved them to fs_time and then refused /// to overwrite their tag-sourced legacy date — stuck forever. /// /// Notably absent: `FileModifyDate` / `FileAccessDate` — those are /// filesystem-derived and the resolver covers them via the `fs_time` /// fallback. Letting exiftool pull them here would mask "no real EXIF /// date" with a `source = exiftool` row that's no better than fs_time. const EXIFTOOL_DATE_TAGS: &[&str] = &[ "DateTimeOriginal", "SubSecDateTimeOriginal", "CreateDate", "MediaCreateDate", "TrackCreateDate", "ContentCreateDate", "ModifyDate", ]; /// The only group whose `ModifyDate` counts as a date: `IFD0`, i.e. EXIF /// `DateTime`. XMP / QuickTime `ModifyDate` record when an editor or /// muxer last touched the file, which isn't a capture time. const MODIFY_DATE_GROUP: &str = "IFD0"; /// Offset + GPS evidence tags requested in the same exiftool pass. They /// feed the camera-offset cascade for naive wall-clock readings that come /// from photo-EXIF groups (ADR-0001). const EXIFTOOL_EVIDENCE_TAGS: &[&str] = &[ "OffsetTime", "OffsetTimeOriginal", "OffsetTimeDigitized", "GPS:GPSLatitude", "GPS:GPSLatitudeRef", "GPS:GPSLongitude", "GPS:GPSLongitudeRef", ]; /// Cache the "exiftool exists on PATH" check across the process lifetime so /// the per-tick backfill doesn't fork a doomed subprocess every iteration on /// deploys without exiftool installed. pub fn exiftool_available() -> bool { static AVAIL: OnceLock = OnceLock::new(); *AVAIL.get_or_init(|| { let ok = Command::new("exiftool") .arg("-ver") .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .map(|s| s.success()) .unwrap_or(false); if !ok { warn!("exiftool not on PATH; date_taken waterfall skips that step"); } ok }) } /// Append the exiftool arguments shared by the single-file and batch /// invocations: raw date strings (no `-d %s` — the resolver interprets /// them itself) with group prefixes for classification, plus the offset /// / GPS evidence tags for the cascade. fn exiftool_common_args(cmd: &mut Command) { cmd.arg("-j").arg("-q").arg("-G1"); for tag in EXIFTOOL_DATE_TAGS { cmd.arg(format!("-{}", tag)); } for tag in EXIFTOOL_EVIDENCE_TAGS { cmd.arg(format!("-{}", tag)); } } /// One-file exiftool invocation. Used by the upload + GPS-write paths, /// which deal with one file at a time. The batch path uses /// `exiftool_dates_batch` so we don't pay subprocess startup per row. /// /// Notably absent: `-fast` / `-fast2`. For QuickTime/MP4 files whose /// `moov` atom sits at the end (non-faststart, common for Snapchat /// exports and any MP4 muxed without `-movflags +faststart`), `-fast2` /// causes exiftool to skip the trailer and return no `CreateDate` / /// `MediaCreateDate`, dropping us to the `fs_time` fallback for files /// that actually have a real capture date. We pre-filter to files that /// kamadak-exif couldn't read, so the JPEG fast-path is already covered /// — paying full-scan cost on the residual is the right trade. fn exiftool_date_single(path: &Path) -> Option { if !exiftool_available() { return None; } let mut cmd = Command::new("exiftool"); exiftool_common_args(&mut cmd); cmd.arg(path); let output = cmd.output().ok()?; if !output.status.success() { trace!("exiftool exited non-zero for {:?}", path); return None; } parse_exiftool_payload(&output.stdout) .into_iter() .next() .and_then(|f| resolve_exiftool_payload(&f.date_tags, &f.evidence)) } /// Drain a batch via a single exiftool subprocess. Paths are fed on stdin /// via `-@ -`, so the argv stays short regardless of batch size — safe for /// libraries with very long path components. Resolution is the same pure /// function as the single-file path, so the drain and ingest produce /// identical results for the same file. fn exiftool_dates_batch(paths: &[&Path]) -> HashMap { let mut out = HashMap::new(); if paths.is_empty() || !exiftool_available() { return out; } let mut cmd = Command::new("exiftool"); // No `-fast2` — see exiftool_date_single for the rationale (QuickTime // moov-at-end files miss CreateDate / MediaCreateDate when the trailer // is skipped). exiftool_common_args(&mut cmd); cmd.arg("-@").arg("-"); cmd.stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()); let mut child = match cmd.spawn() { Ok(c) => c, Err(e) => { warn!("exiftool batch spawn failed: {}", e); return out; } }; if let Some(mut stdin) = child.stdin.take() { for p in paths { // exiftool's argfile reader treats each line as one path; OS // path bytes don't always survive a String round-trip, but // every path we get here originated from rel_path / root_path // strings already, so to-string-lossy is a non-event. if let Err(e) = writeln!(stdin, "{}", p.display()) { warn!("exiftool batch stdin write failed: {}", e); break; } } } let output = match child.wait_with_output() { Ok(o) => o, Err(e) => { warn!("exiftool batch wait failed: {}", e); return out; } }; if !output.status.success() { debug!( "exiftool batch exit status {:?}; partial output may still parse", output.status.code() ); } for file in parse_exiftool_payload(&output.stdout) { if let Some(resolved) = resolve_exiftool_payload(&file.date_tags, &file.evidence) { out.insert(PathBuf::from(file.source_file), resolved); } } out } /// One raw date string from the exiftool payload, with the exiftool group /// it came from (`-G1` prefixes every tag key as `"Group:Tag"`). #[derive(Debug, Clone)] struct RawDateTag { group: String, tag: String, value: String, } /// Per-file offset/GPS evidence from the same exiftool payload. #[derive(Copy, Clone, Debug, Default)] struct ExiftoolEvidence { /// `OffsetTime` / `OffsetTimeDigitized`, whichever was present first. offset_time: Option, /// `OffsetTimeOriginal`. offset_time_original: Option, /// Unsigned decimal degrees; the Ref fields carry the sign. gps_lat: Option, gps_lon: Option, gps_lat_south: bool, gps_lon_west: bool, } impl ExiftoolEvidence { /// Signed `(latitude, longitude)`, when both coordinates are present. fn gps(self) -> Option<(f64, f64)> { let lat = self.gps_lat?; let lon = self.gps_lon?; Some(( if self.gps_lat_south { -lat } else { lat }, if self.gps_lon_west { -lon } else { lon }, )) } } /// How one raw date string should be interpreted. #[derive(Debug, Clone, Copy, PartialEq)] enum DateReading { /// The string carried its own UTC offset (zoned) — already a true /// instant. Zoned(i64), /// Naive wall-clock reading; needs group classification and (for /// photo-EXIF groups) the offset cascade. Naive(NaiveDateTime), } /// Parse one exiftool date string. `None` for empty values, the /// `"0000:00:00 00:00:00"` zero placeholder exiftool emits for /// missing-but-allocated date slots, and anything before 1900. /// /// Zoned strings (a trailing `Z`/`z` or a `±HH:MM[:SS]` offset) are true /// instants — XMP dates, some MakerNotes, and exiftool's own /// `Composite:SubSec*` rendering when an `OffsetTime*` tag is present — /// and are converted to unix seconds directly. Naive strings are returned /// as-is for group classification + the offset cascade. fn parse_exiftool_date_string(value: &str) -> Option { let v = value.trim(); if v.is_empty() { return None; } // Zoned form: split off the trailing offset, parse the naive part, and // fold the offset into a true instant. if let Some((naive_part, offset_minutes)) = split_zoned_suffix(v) { let naive = parse_naive_exiftool(naive_part)?; if naive.year() < 1900 { return None; } let instant = naive.and_utc().timestamp() - (offset_minutes as i64) * 60; return Some(DateReading::Zoned(instant)); } let naive = parse_naive_exiftool(v)?; if naive.year() < 1900 { return None; } Some(DateReading::Naive(naive)) } /// The naive date-time formats exiftool renders, in preference order. EXIF /// uses `:` between date fields; ISO uses `-`. The time separator is ` ` or /// `T`; fractional seconds are optional. const NAIVE_EXIFTOOL_FORMATS: &[&str] = &[ "%Y:%m:%d %H:%M:%S%.f", "%Y-%m-%d %H:%M:%S%.f", "%Y-%m-%dT%H:%M:%S%.f", "%Y:%m:%d %H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", ]; /// Parse a naive (offset-less) exiftool date string. fn parse_naive_exiftool(v: &str) -> Option { for fmt in NAIVE_EXIFTOOL_FORMATS { if let Ok(naive) = NaiveDateTime::parse_from_str(v, fmt) { return Some(naive); } } None } /// Split a zoned date string into its naive part and its UTC offset in /// signed minutes (local − UTC). Recognises a trailing `Z`/`z` (UTC) or a /// `±HH:MM[:SS]` offset. The date's own `-` separators are skipped because /// only a `±` followed by a well-formed offset (digits and an optional /// `:SS` tail) is accepted. fn split_zoned_suffix(v: &str) -> Option<(&str, i32)> { let b = v.as_bytes(); // Trailing Z / z → UTC. if matches!(b.last(), Some(b'Z') | Some(b'z')) { return Some((&v[..v.len() - 1], 0)); } for (i, &c) in b.iter().enumerate() { if (c == b'+' || c == b'-') && i + 3 <= v.len() && b[i + 1].is_ascii_digit() && b[i + 2].is_ascii_digit() { let rest = v[i + 3..].strip_prefix(':').unwrap_or(&v[i + 3..]); if rest.is_empty() || (rest.len() <= 2 && rest.bytes().all(|x| x.is_ascii_digit())) { let sign = if c == b'+' { 1i32 } else { -1 }; let hh: i32 = v[i + 1..i + 3].parse().ok()?; let mm: i32 = if rest.is_empty() { 0 } else { rest.parse().ok()? }; return Some((&v[..i], sign * (hh * 60 + mm))); } } } None } /// Parse an exiftool `OffsetTime*` rendering (`"+05:30"`, `"-06:00"`, /// `"Z"`) into signed minutes (local − UTC). fn parse_offset_minutes(value: &str) -> Option { let v = value.trim(); if v == "Z" { return Some(0); } let (sign, rest) = if let Some(r) = v.strip_prefix('-') { (-1i32, r) } else if let Some(r) = v.strip_prefix('+') { (1, r) } else { return None; }; let (h, m) = rest.split_once(':')?; let h: i32 = h.trim().parse().ok()?; let m: i32 = m.trim().parse().ok()?; Some(sign * (h * 60 + m)) } /// Parse exiftool's DMS GPS rendering, e.g. `"40 deg 42' 36.00\""`, into /// unsigned decimal degrees. The Ref field carries the sign. /// GPS hemisphere ref test. exiftool's print conversion renders the refs /// as words ("West" / "South", ticket #163), `-n` as letters ("W" / "S"): /// match on the first letter, case-insensitively, the same rule as /// `exif::extract_gps_coordinate`. fn gps_ref_is(value: &str, letter: char) -> bool { value .trim() .chars() .next() .is_some_and(|c| c.eq_ignore_ascii_case(&letter)) } fn parse_gps_dms(value: &str) -> Option { let v = value.trim(); let deg_end = v.find(" deg")?; let deg: f64 = v[..deg_end].trim().parse().ok()?; let rest = &v[deg_end + " deg".len()..]; let min_end = rest.find('\'')?; let min: f64 = rest[..min_end].trim().parse().ok()?; let rest = &rest[min_end + 1..]; let sec_end = rest.find('"')?; let sec: f64 = rest[..sec_end].trim().parse().ok()?; Some(deg + min / 60.0 + sec / 3600.0) } /// QuickTime/MP4/MOV container groups. The `creation_time` field in those /// containers is seconds-since-1904 in **UTC** — a true instant — and /// exiftool renders it as a UTC-naive string. Classifying these as camera /// wall clocks (running the cascade) reinterprets a UTC instant in the /// server zone and shifts every video by the server's UTC offset — the /// "videos off by 4-5 hours" bug, ticket #115. fn is_container_group(group: &str) -> bool { group == "QuickTime" || group.starts_with("Track") || group == "Apple" } /// When the same tag name is present in multiple groups (hybrid files), /// prefer the photo-EXIF groups over container / XMP renderings. fn group_rank(group: &str) -> u8 { if group.starts_with("ExifIFD") || group == "IFD0" || group == "IFD1" || group.starts_with("MakerNote") || group.starts_with("IFD") { 0 } else if group == "Composite" { 1 } else if is_container_group(group) { 2 } else if group.starts_with("XMP") { 3 } else { 4 } } /// The legacy column's convention for a naive wall reading: interpret it /// in the *server's* local zone — exactly what exiftool `-d %s` did before /// this rework (verified: it ignores the file's own `OffsetTime*` tags). /// DST-aware: ambiguous fall-back walls take the earlier reading; /// nonexistent spring-forward walls take the earliest local interpretation /// — within an hour of what the old host produced. fn legacy_server_local(wall: NaiveDateTime) -> i64 { match Local.from_local_datetime(&wall) { LocalResult::Single(dt) => dt.timestamp(), LocalResult::Ambiguous(earlier, _) => earlier.timestamp(), // Nonexistent spring-forward wall: interpret the reading as UTC // and take the zone's offset at that instant — within an hour of // what the old exiftool/glibc path produced. LocalResult::None => Local.from_utc_datetime(&wall).timestamp(), } } /// Interpret one file's raw exiftool payload into a [`ResolvedDate`] /// under the new contract (ADR-0001): /// /// - **zoned** strings carry their own UTC offset → true instant, /// `utc_direct`. /// - **container-group** naive strings are UTC-rendered `creation_time` /// fields → true instant, `utc_direct` (no cascade — see /// [`is_container_group`]). /// - **photo-EXIF / other** naive strings are camera wall clocks → offset /// cascade over the file's own `OffsetTime*` + GPS evidence. /// /// Tag priority is [`EXIFTOOL_DATE_TAGS`]; within a tag name the photo /// group wins ([`group_rank`]). Unparseable candidates (zero dates, /// junk) fall through to the next candidate. fn resolve_exiftool_payload( date_tags: &[RawDateTag], evidence: &ExiftoolEvidence, ) -> Option { let mut candidates: Vec<&RawDateTag> = date_tags.iter().collect(); candidates.sort_by(|a, b| { let pa = EXIFTOOL_DATE_TAGS .iter() .position(|t| *t == a.tag) .unwrap_or(usize::MAX); let pb = EXIFTOOL_DATE_TAGS .iter() .position(|t| *t == b.tag) .unwrap_or(usize::MAX); pa.cmp(&pb) .then_with(|| group_rank(&a.group).cmp(&group_rank(&b.group))) }); for cand in candidates { if cand.tag == "ModifyDate" && cand.group != MODIFY_DATE_GROUP { continue; } let Some(reading) = parse_exiftool_date_string(&cand.value) else { continue; }; match reading { DateReading::Zoned(ts) => { return Some(ResolvedDate { timestamp: ts, source: DateSource::Exiftool, date_utc: Some(ts), camera_offset_minutes: None, tz_source: Some(camera_tz::TzSource::UtcDirect), }); } DateReading::Naive(wall) => { if is_container_group(&cand.group) { return Some(ResolvedDate { timestamp: legacy_server_local(wall), source: DateSource::Exiftool, date_utc: Some(wall.and_utc().timestamp()), camera_offset_minutes: None, tz_source: Some(camera_tz::TzSource::UtcDirect), }); } // Camera wall clock: offset-tag pairing mirrors the // kamadak branch (exif.rs) — DateTimeOriginal / SubSec* // pair with OffsetTimeOriginal first, everything else with // OffsetTime first. let prefer_original = cand.tag == "DateTimeOriginal" || cand.tag == "SubSecDateTimeOriginal"; let offset_tag = if prefer_original { evidence.offset_time_original.or(evidence.offset_time) } else { evidence.offset_time.or(evidence.offset_time_original) }; let ev = camera_tz::OffsetEvidence { offset_tag_minutes: offset_tag, gps: evidence.gps(), }; let resolution = camera_tz::resolve_camera_offset(wall, &ev, server_zone_cached(), gps_lookup()); return Some(ResolvedDate { timestamp: legacy_server_local(wall), source: DateSource::Exiftool, date_utc: Some(camera_tz::wall_to_instant(wall, resolution.minutes)), camera_offset_minutes: Some(resolution.minutes), tz_source: Some(resolution.source), }); } } } None } /// One file's share of the exiftool JSON payload. #[derive(Debug)] struct ExiftoolFileData { source_file: String, date_tags: Vec, evidence: ExiftoolEvidence, } /// Split the exiftool `-j -G1` output into per-file date tags + evidence. /// With `-G1` every tag key is `"Group:Tag"` (only `SourceFile` is /// unprefixed); absent tags are simply missing keys. fn parse_exiftool_payload(stdout: &[u8]) -> Vec { let entries: Vec = match serde_json::from_slice(stdout) { Ok(v) => v, Err(e) => { // Empty stdout on total failure isn't a parse error worth // logging at warn — the caller already noted the non-zero // exit status. if !stdout.is_empty() { warn!("exiftool JSON parse failed: {}", e); } return Vec::new(); } }; let mut out = Vec::with_capacity(entries.len()); for entry in entries { let Some(obj) = entry.as_object() else { continue; }; let Some(source_file) = obj.get("SourceFile").and_then(|v| v.as_str()) else { continue; }; let mut data = ExiftoolFileData { source_file: source_file.to_string(), date_tags: Vec::new(), evidence: ExiftoolEvidence::default(), }; for (key, value) in obj { let Some((group, tag)) = key.split_once(':') else { continue; }; let Some(v) = value.as_str() else { continue; }; if EXIFTOOL_DATE_TAGS.contains(&tag) { data.date_tags.push(RawDateTag { group: group.to_string(), tag: tag.to_string(), value: v.to_string(), }); } else { match tag { "OffsetTime" | "OffsetTimeDigitized" => { data.evidence.offset_time = data .evidence .offset_time .or_else(|| parse_offset_minutes(v)); } "OffsetTimeOriginal" => { data.evidence.offset_time_original = parse_offset_minutes(v); } "GPSLatitude" => data.evidence.gps_lat = parse_gps_dms(v), "GPSLatitudeRef" => data.evidence.gps_lat_south = gps_ref_is(v, 'S'), "GPSLongitude" => data.evidence.gps_lon = parse_gps_dms(v), "GPSLongitudeRef" => data.evidence.gps_lon_west = gps_ref_is(v, 'W'), _ => {} } } } out.push(data); } out } #[cfg(test)] mod tests { use super::*; fn tags(pairs: &[(&str, &str, &str)]) -> Vec { pairs .iter() .map(|(g, t, v)| RawDateTag { group: g.to_string(), tag: t.to_string(), value: v.to_string(), }) .collect() } #[test] fn parse_date_string_zoned_forms_are_true_instants() { // exiftool's Composite:SubSecDateTimeOriginal rendering when an // OffsetTime tag is present: EXIF date separator, zoned time. assert_eq!( parse_exiftool_date_string("2024:09:17 07:33:59.414-06:00"), Some(DateReading::Zoned(1_726_580_039)), // 13:33:59Z ); assert_eq!( parse_exiftool_date_string("2024-09-17T07:33:59Z"), Some(DateReading::Zoned(1_726_558_439)), // 07:33:59Z ); } #[test] fn parse_date_string_naive_and_rejects() { assert_eq!( parse_exiftool_date_string("2024:09:17 07:33:59"), Some(DateReading::Naive( NaiveDateTime::parse_from_str("2024-09-17 07:33:59", "%Y-%m-%d %H:%M:%S").unwrap() )), ); // Zero placeholder, empty, junk, pre-1900. assert_eq!(parse_exiftool_date_string("0000:00:00 00:00:00"), None); assert_eq!(parse_exiftool_date_string(""), None); assert_eq!(parse_exiftool_date_string("nonsense"), None); assert_eq!(parse_exiftool_date_string("1899:12:31 23:59:59"), None); } #[test] fn parse_offset_minutes_forms() { assert_eq!(parse_offset_minutes("+05:30"), Some(330)); assert_eq!(parse_offset_minutes("-06:00"), Some(-360)); assert_eq!(parse_offset_minutes("Z"), Some(0)); assert_eq!(parse_offset_minutes("junk"), None); } #[test] fn parse_gps_dms_decimal() { // 40 deg 42' 45.60" = 40.712666...; the Ref fields carry the sign. let v = parse_gps_dms("40 deg 42' 45.60\"").unwrap(); assert!((v - 40.7126666667).abs() < 1e-9); assert_eq!(parse_gps_dms("nope"), None); } #[test] fn parse_payload_groups_dates_and_evidence() { let json = br#"[{ "SourceFile": "/lib/a.jpg", "ExifIFD:DateTimeOriginal": "2024:07:04 12:00:00", "ExifIFD:OffsetTimeOriginal": "-04:00", "GPS:GPSLatitude": "40 deg 42' 45.60\"", "GPS:GPSLatitudeRef": "N", "GPS:GPSLongitude": "74 deg 0' 21.60\"", "GPS:GPSLongitudeRef": "W" }]"#; let files = parse_exiftool_payload(json); assert_eq!(files.len(), 1); assert_eq!(files[0].source_file, "/lib/a.jpg"); assert_eq!(files[0].date_tags.len(), 1); assert_eq!(files[0].date_tags[0].group, "ExifIFD"); assert_eq!(files[0].date_tags[0].tag, "DateTimeOriginal"); assert_eq!(files[0].evidence.offset_time_original, Some(-240)); let (lat, lon) = files[0].evidence.gps().unwrap(); assert!((lat - 40.7126666667).abs() < 1e-9); assert!((lon - (-74.006)).abs() < 1e-9); } /// #163: exiftool's default print conversion renders hemisphere refs as /// words. Before the fix "West" / "South" never matched, so western / /// southern coordinates came out positive and the GPS offset lookup hit /// the mirrored point (Ohio → China, UTC+8). #[test] fn parse_payload_reads_exiftool_word_hemisphere_refs() { let json = br#"[{ "SourceFile": "/lib/ohio.jpg", "ExifIFD:DateTimeOriginal": "2014:01:12 20:55:48", "GPS:GPSLatitude": "41 deg 22' 19.20\"", "GPS:GPSLatitudeRef": "North", "GPS:GPSLongitude": "81 deg 50' 34.80\"", "GPS:GPSLongitudeRef": "West" }, { "SourceFile": "/lib/sydney.jpg", "ExifIFD:DateTimeOriginal": "2019:12:31 23:30:00", "GPS:GPSLatitude": "33 deg 52' 4.00\"", "GPS:GPSLatitudeRef": "South", "GPS:GPSLongitude": "151 deg 12' 26.00\"", "GPS:GPSLongitudeRef": "East" }]"#; let files = parse_exiftool_payload(json); let (lat, lon) = files[0].evidence.gps().unwrap(); assert!((lat - 41.372).abs() < 1e-9, "lat {lat}"); assert!((lon - (-81.843)).abs() < 1e-9, "lon {lon}"); let (lat, lon) = files[1].evidence.gps().unwrap(); assert!(lat < 0.0 && lon > 0.0, "sydney ({lat}, {lon})"); } #[test] fn gps_ref_is_matches_letter_and_word_forms() { for v in ["W", "w", "West", " west "] { assert!(gps_ref_is(v, 'W'), "{v:?}"); } for v in ["E", "East", "", "N"] { assert!(!gps_ref_is(v, 'W'), "{v:?}"); } assert!(gps_ref_is("South", 'S')); assert!(!gps_ref_is("North", 'S')); } #[test] fn container_date_is_true_instant_not_wall_clock() { // QuickTime creation_time renders as a UTC-naive string: the // 15:58:23 reading IS the instant. The pre-rework `-d %s` path // reinterpreted it in the server zone — the "videos off by 4-5 // hours" bug — and the legacy column keeps exactly that (frozen // for rollback). let wall = NaiveDateTime::parse_from_str("2017-06-11 15:58:23", "%Y-%m-%d %H:%M:%S").unwrap(); let resolved = resolve_exiftool_payload( &tags(&[("QuickTime", "CreateDate", "2017:06:11 15:58:23")]), &ExiftoolEvidence::default(), ) .unwrap(); assert_eq!(resolved.date_utc, Some(wall.and_utc().timestamp())); assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::UtcDirect)); assert_eq!(resolved.camera_offset_minutes, None); assert_eq!(resolved.timestamp, legacy_server_local(wall)); assert_eq!(resolved.source, DateSource::Exiftool); } #[test] fn photo_and_video_at_same_instant_agree() { // Photo: EXIF wall 2024-07-04 12:00:00 with an OffsetTime tag of // -04:00. Video: QuickTime container date 2024:07:04 16:00:00 — // the same moment, UTC-rendered. Both must land on 16:00:00Z. let photo = resolve_exiftool_payload( &tags(&[("ExifIFD", "DateTimeOriginal", "2024:07:04 12:00:00")]), &ExiftoolEvidence { offset_time_original: Some(-240), ..Default::default() }, ) .unwrap(); let video = resolve_exiftool_payload( &tags(&[("QuickTime", "CreateDate", "2024:07:04 16:00:00")]), &ExiftoolEvidence::default(), ) .unwrap(); assert_eq!(photo.date_utc, video.date_utc); assert_eq!(photo.date_utc, Some(1_720_108_800)); assert_eq!(photo.tz_source, Some(camera_tz::TzSource::OffsetTag)); assert_eq!(photo.camera_offset_minutes, Some(-240)); assert_eq!(video.tz_source, Some(camera_tz::TzSource::UtcDirect)); } #[test] fn zoned_string_short_circuits_to_utc_direct() { let resolved = resolve_exiftool_payload( &tags(&[("XMP-xmp", "CreateDate", "2024-09-17T07:33:59-06:00")]), &ExiftoolEvidence::default(), ) .unwrap(); assert_eq!(resolved.date_utc, Some(1_726_580_039)); assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::UtcDirect)); assert_eq!(resolved.camera_offset_minutes, None); } #[test] fn exif_naive_with_gps_uses_capture_zone() { // No offset tag; GPS at Manhattan → America/New_York, EDT in July. let resolved = resolve_exiftool_payload( &tags(&[("ExifIFD", "CreateDate", "2024:07:04 12:00:00")]), &ExiftoolEvidence { gps_lat: Some(40.7128), gps_lon: Some(74.006), gps_lon_west: true, ..Default::default() }, ) .unwrap(); assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::Gps)); assert_eq!(resolved.camera_offset_minutes, Some(-240)); assert_eq!(resolved.date_utc, Some(1_720_108_800)); } #[test] fn ifd0_modify_date_is_a_last_resort() { // Scans / edits with only EXIF DateTime (exiftool: IFD0:ModifyDate) // resolve from it as a camera wall clock, like the kamadak path. let resolved = resolve_exiftool_payload( &tags(&[ ("IFD0", "ModifyDate", "2009:03:04 14:55:48"), ("ExifIFD", "DateTimeOriginal", ""), ]), &ExiftoolEvidence { offset_time: Some(-300), ..Default::default() }, ) .unwrap(); assert_eq!(resolved.source, DateSource::Exiftool); assert_eq!(resolved.date_utc, Some(1_236_196_548)); assert_eq!(resolved.camera_offset_minutes, Some(-300)); // Any capture tag outranks it. let resolved = resolve_exiftool_payload( &tags(&[ ("IFD0", "ModifyDate", "2022:11:08 15:18:20"), ("ExifIFD", "CreateDate", "2022:11:01 09:00:00"), ]), &ExiftoolEvidence { offset_time: Some(-240), ..Default::default() }, ) .unwrap(); assert_eq!(resolved.date_utc, Some(1_667_307_600)); } #[test] fn non_exif_modify_date_is_ignored() { // XMP / QuickTime ModifyDate is an edit or mux time, not capture. for group in ["XMP-xmp", "QuickTime"] { assert!( resolve_exiftool_payload( &tags(&[(group, "ModifyDate", "2024:01:01 00:00:00")]), &ExiftoolEvidence::default(), ) .is_none(), "{group}" ); } } #[test] fn zero_date_falls_through_to_next_tag() { // exiftool emits "0000:00:00 00:00:00" for missing-but-allocated // date slots (iPhone QuickTime files); the resolver skips it and // picks the next candidate. let wall = NaiveDateTime::parse_from_str("2020-01-02 03:04:05", "%Y-%m-%d %H:%M:%S").unwrap(); let resolved = resolve_exiftool_payload( &tags(&[ ("QuickTime", "CreateDate", "0000:00:00 00:00:00"), ("QuickTime", "MediaCreateDate", "2020:01:02 03:04:05"), ]), &ExiftoolEvidence::default(), ) .unwrap(); assert_eq!(resolved.date_utc, Some(wall.and_utc().timestamp())); } #[test] fn photo_group_wins_over_container_for_same_tag() { // Hybrid file: ExifIFD and QuickTime both carry CreateDate. The // photo-EXIF reading (a wall clock) is preferred and runs the // cascade over the file's own offset tag. let resolved = resolve_exiftool_payload( &tags(&[ ("QuickTime", "CreateDate", "2024:07:04 16:00:00"), ("ExifIFD", "CreateDate", "2024:07:04 12:00:00"), ]), &ExiftoolEvidence { offset_time: Some(-240), ..Default::default() }, ) .unwrap(); assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::OffsetTag)); assert_eq!(resolved.camera_offset_minutes, Some(-240)); assert_eq!(resolved.date_utc, Some(1_720_108_800)); } #[test] fn no_usable_dates_returns_none() { assert!( resolve_exiftool_payload( &tags(&[("QuickTime", "CreateDate", "0000:00:00 00:00:00")]), &ExiftoolEvidence::default() ) .is_none() ); } #[test] fn date_source_as_str_round_trip() { for src in [ DateSource::Exif, DateSource::Exiftool, DateSource::Filename, DateSource::FsTime, ] { assert!(!src.as_str().is_empty()); } } #[test] fn resolve_uses_prior_exif_when_present() { // Path doesn't need to exist when a prior EXIF reading // short-circuits. Legacy timestamp stays the wall-clock-as-UTC // value; the new-contract fields come from the cascade. let prior = ExifPrior { wall: chrono::DateTime::from_timestamp(1_700_000_000, 0) .unwrap() .naive_utc(), evidence: camera_tz::OffsetEvidence::default(), }; let resolved = resolve_date_taken(Path::new("/nonexistent/file.jpg"), Some(&prior)).unwrap(); assert_eq!(resolved.timestamp, 1_700_000_000); assert_eq!(resolved.source, DateSource::Exif); // No offset evidence in the stub-less test env: cascade lands on // assumed_server (or the local-offset fallback when the host zone // doesn't parse), and date_utc is always offset-correct. assert!(resolved.camera_offset_minutes.is_some()); assert!(resolved.date_utc.is_some()); assert_eq!( resolved.date_utc.unwrap(), resolved.timestamp - (resolved.camera_offset_minutes.unwrap() as i64) * 60 ); } #[test] fn resolve_exif_offset_tag_gives_true_instant_and_source() { // Wall 2024-07-04 12:00:00 with a camera-stamped EDT offset. // Deterministic: the offset tag short-circuits the cascade before // GPS / server-zone lookup. let prior = ExifPrior { wall: NaiveDateTime::parse_from_str("2024-07-04 12:00:00", "%Y-%m-%d %H:%M:%S") .unwrap(), evidence: camera_tz::OffsetEvidence { offset_tag_minutes: Some(-240), gps: None, }, }; let resolved = resolve_date_taken(Path::new("/nonexistent/IMG_0001.jpg"), Some(&prior)).unwrap(); assert_eq!(resolved.timestamp, 1_720_094_400); // legacy wall-as-UTC assert_eq!(resolved.date_utc, Some(1_720_108_800)); // 16:00:00Z assert_eq!(resolved.camera_offset_minutes, Some(-240)); assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::OffsetTag)); } #[test] fn resolve_exif_gps_evidence_uses_capture_zone() { // No offset tag; GPS points at Manhattan. The bundled TZF geometry // resolves America/New_York, whose offset for the July wall time // is EDT (-240) — DST-correct for the capture date, not "now". let prior = ExifPrior { wall: NaiveDateTime::parse_from_str("2024-07-04 12:00:00", "%Y-%m-%d %H:%M:%S") .unwrap(), evidence: camera_tz::OffsetEvidence { offset_tag_minutes: None, gps: Some((40.7128, -74.0060)), }, }; let resolved = resolve_date_taken(Path::new("/nonexistent/IMG_0001.jpg"), Some(&prior)).unwrap(); assert_eq!(resolved.camera_offset_minutes, Some(-240)); assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::Gps)); assert_eq!(resolved.date_utc, Some(1_720_108_800)); } #[test] fn filename_class_wall_without_evidence_assumes_server_zone() { // In-class screenshot name, no prior EXIF, file missing. The wall // clock runs the cascade with empty evidence and lands on // assumed_server — a class file always resolves (#116). Legacy // stays the pre-migration wall-as-UTC interpretation. let resolved = resolve_date_taken( Path::new("/nonexistent/Screenshot_2014-06-01-20-44-50.png"), None, ) .unwrap(); let wall = NaiveDateTime::parse_from_str("2014-06-01 20:44:50", "%Y-%m-%d %H:%M:%S").unwrap(); assert_eq!(resolved.source, DateSource::Filename); assert_eq!(resolved.timestamp, wall.and_utc().timestamp()); assert!(resolved.date_utc.is_some()); assert!(resolved.camera_offset_minutes.is_some()); assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::AssumedServer)); assert_eq!( resolved.date_utc.unwrap(), resolved.timestamp - (resolved.camera_offset_minutes.unwrap() as i64) * 60 ); } #[test] fn filename_class_wall_outranks_exif_and_uses_its_evidence() { // In-class filename beats the EXIF date entirely (the priority // inversion), and the wall cascade uses the prior's offset-tag // evidence. The EXIF wall (2020) never reaches the result. let prior = ExifPrior { wall: NaiveDateTime::parse_from_str("2020-05-05 05:05:05", "%Y-%m-%d %H:%M:%S") .unwrap(), evidence: camera_tz::OffsetEvidence { offset_tag_minutes: Some(-240), gps: None, }, }; let resolved = resolve_date_taken( Path::new("/nonexistent/2015-01-09_02-15-15.jpg"), Some(&prior), ) .unwrap(); assert_eq!(resolved.source, DateSource::Filename); assert_eq!(resolved.timestamp, 1_420_769_715); // wall-as-UTC legacy assert_eq!(resolved.camera_offset_minutes, Some(-240)); assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::OffsetTag)); // The offset tag short-circuits the cascade and is used as-stamped: // wall − (−240) → 2015-01-09 06:15:15Z. assert_eq!(resolved.date_utc, Some(1_420_784_115)); } #[test] fn filename_class_wall_gps_evidence_uses_capture_zone() { // No offset tag; the prior carries Manhattan GPS. The bundled TZF // geometry resolves America/New_York, whose offset for the // July wall is EDT (-240). let prior = ExifPrior { wall: NaiveDateTime::parse_from_str("2024-07-04 12:00:00", "%Y-%m-%d %H:%M:%S") .unwrap(), evidence: camera_tz::OffsetEvidence { offset_tag_minutes: None, gps: Some((40.7128, -74.0060)), }, }; let resolved = resolve_date_taken( Path::new("/nonexistent/Screenshot_2024-07-04-12-00-00.png"), Some(&prior), ) .unwrap(); assert_eq!(resolved.source, DateSource::Filename); assert_eq!(resolved.camera_offset_minutes, Some(-240)); assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::Gps)); assert_eq!(resolved.date_utc, Some(1_720_108_800)); // 16:00:00Z } #[test] fn filename_class_timestamp_is_utc_direct() { // A unix-timestamp name is a true instant: no cascade, no offset, // and legacy equals the epoch (the pre-migration interpretation). let resolved = resolve_date_taken(Path::new("/nonexistent/xyz_1401638400.jpeg"), None).unwrap(); assert_eq!(resolved.source, DateSource::Filename); assert_eq!(resolved.timestamp, 1_401_638_400); assert_eq!(resolved.date_utc, Some(1_401_638_400)); assert_eq!(resolved.camera_offset_minutes, None); assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::UtcDirect)); } #[test] fn in_class_video_name_resolves_without_exiftool() { // A chat-export-style name on a video path: the class check runs // before exiftool is consulted — the file need not even exist for // the result to be class-resolved. let resolved = resolve_date_taken(Path::new("/nonexistent/att_1422489664680106.mp4"), None).unwrap(); assert_eq!(resolved.source, DateSource::Filename); assert_eq!(resolved.date_utc, Some(1_422_489_664)); assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::UtcDirect)); } #[test] fn resolve_fs_time_when_only_metadata_available() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("plain.jpg"); std::fs::File::create(&path).unwrap(); let resolved = resolve_date_taken(&path, None).unwrap(); // exiftool may or may not be installed in the test env; either // way the file has no EXIF and no filename date, so we should // fall to fs_time. assert_eq!(resolved.source, DateSource::FsTime); // fs_time is already a true UTC instant — recorded as utc_direct. assert_eq!(resolved.date_utc, Some(resolved.timestamp)); assert_eq!(resolved.camera_offset_minutes, None); assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::UtcDirect)); } }