Files
ImageApi/src/date_resolver.rs
T
Cameron Cordes a97bf53603 feat(dates): filename date class — per-pattern conventions + filename-over-EXIF priority (#116)
The filename date class is now first-class (ADR-0001):

- memories::extract_date_from_filename returns FilenameDate:
  Wall(NaiveDateTime) for the screenshot/dash/compact patterns,
  Utc(DateTime<Utc>) for the unix-timestamp patterns. No patterns
  added or removed; every existing pattern carries its convention.
- Waterfall inversion in both resolve_date_taken and
  resolve_dates_batch: an in-class name is the original date of
  record and resolves BEFORE EXIF / container dates are consulted
  (shared filename_class_resolved step; in-class files skip
  exiftool entirely). Wall patterns run the offset cascade over the
  kamadak prior's evidence (empty evidence -> server-zone
  assumption, so a class file always resolves); timestamp patterns
  are true instants (tz_source utc_direct, no offset).
- Legacy date_taken stays byte-identical: wall-as-UTC for wall
  patterns, epoch for timestamp patterns, via
  FilenameDate::legacy_datetime / legacy_timestamp at the seven
  consumer call sites.
- Out-of-class files keep the global EXIF-wins waterfall unchanged.
- CLAUDE.md waterfall section rewritten (step 0 = filename class;
  the EXIF-beats-filename note becomes out-of-class-only).

Tests: five new waterfall-seam tests (wall no-evidence ->
assumed_server; wall outranks EXIF with offset-tag evidence;
wall GPS evidence; timestamp utc_direct; in-class video name
resolves without exiftool) plus enum-shape assertions in every
extractor test. Code-reviewed on both axes (standards: no
violations, 3 of 4 P2 notes applied; spec: all acceptance
criteria verified, no scope creep).

716 integration + 639 lib tests pass; fmt + clippy clean.
2026-09-25 16:54:46 -04:00

1262 lines
50 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<i64>,
/// Camera offset (minutes) that produced `date_utc`; `None` for direct
/// true instants (no offset is involved).
pub camera_offset_minutes: Option<i32>,
/// Why `date_utc` has the offset it does; `None` on rows written
/// before every branch classified (#116).
pub tz_source: Option<camera_tz::TzSource>,
}
/// 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<ExifPrior> {
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<ResolvedDate> {
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<chrono_tz::Tz> {
static ZONE: OnceLock<Option<chrono_tz::Tz>> = 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<camera_tz::TzfGpsZoneLookup> = 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<ResolvedDate> {
// 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<Utc> = 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<PathBuf, ExifPrior>,
) -> HashMap<PathBuf, ResolvedDate> {
let mut out: HashMap<PathBuf, ResolvedDate> = 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<Utc> = 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.
///
/// 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",
];
/// 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.
fn exiftool_available() -> bool {
static AVAIL: OnceLock<bool> = 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<ResolvedDate> {
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<PathBuf, ResolvedDate> {
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<i32>,
/// `OffsetTimeOriginal`.
offset_time_original: Option<i32>,
/// Unsigned decimal degrees; the Ref fields carry the sign.
gps_lat: Option<f64>,
gps_lon: Option<f64>,
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<DateReading> {
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<NaiveDateTime> {
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<i32> {
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.
fn parse_gps_dms(value: &str) -> Option<f64> {
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<ResolvedDate> {
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 {
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<RawDateTag>,
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<ExiftoolFileData> {
let entries: Vec<serde_json::Value> = 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 = v.trim().eq_ignore_ascii_case("S")
}
"GPSLongitude" => data.evidence.gps_lon = parse_gps_dms(v),
"GPSLongitudeRef" => {
data.evidence.gps_lon_west = v.trim().eq_ignore_ascii_case("W")
}
_ => {}
}
}
}
out.push(data);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn tags(pairs: &[(&str, &str, &str)]) -> Vec<RawDateTag> {
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);
}
#[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 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));
}
}