feat(dates): pre-cutover audit utility for the filename date class (#117)

One-shot, operator-run, strictly read-only utility that lists every
filename-class file with its stored date/source and the date/source/
offset-source the re-based resolver would assign it — the human
spot-check that gates the production backfill drain (#118).

- src/filename_audit.rs: StoredDate/AuditRow/SkippedLibrary; walk +
  PathExcluder (depth-0 guarded) + match_filename_date -> one
  resolve_dates_batch pass with EMPTY priors (mirrors the drain
  exactly); disabled and probe-stale libraries reported as skipped,
  never partially processed; output stable-sorted by (library, path).
- src/bin/filename_audit.rs: operator binary (dotenv, DATABASE_URL,
  EXCLUDED_DIRS, libraries::load_all, format_report).
- src/memories.rs: match_filename_date canonical matcher returning
  FilenameMatch { pattern, date }; extract_date_from_filename is now
  a thin wrapper (byte-for-byte behavior-preserving, verified by
  review).

4 audit tests (inversion + utc_direct + exclusions, offline/disabled
skip, stable sort, report determinism incl. no debug quotes) + 2
matcher tests. 645 lib + 718 integration pass; clippy/fmt clean.
Standards review applied: {:?}->{}, depth-0 walk guard, dead
per-file exclude check removed, unused Default derive removed,
pattern column widened, doc comment reworded.
This commit is contained in:
Cameron Cordes committed 2026-09-25 18:03:38 -04:00
1 parent 3ac011abd7
commit 3aa6ce314c
4 files changed
+720 -14

No files matched your search

+88 -14
View File
@@ -146,7 +146,7 @@ pub struct MemoriesResponse {
/// class**: the filename is the original date of record and outranks
/// EXIF / container dates (ADR-0001). Files whose name matches nothing
/// are out of the class and keep the global EXIF-wins waterfall.
#[derive(Copy, Clone, Debug, PartialEq)]
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum FilenameDate {
/// Wall clock the device displayed (screenshot / dash / compact).
Wall(NaiveDateTime),
@@ -174,12 +174,26 @@ impl FilenameDate {
}
}
pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
/// A filename that matched one of the filename-class patterns (#116):
/// which pattern fired and the date it carries.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct FilenameMatch {
/// Stable pattern name for audit / debug surfaces: `screenshot`,
/// `screenshot_compact`, `dash`, `dash_compact`, `compact`,
/// `unix_timestamp`.
pub pattern: &'static str,
pub date: FilenameDate,
}
/// Match a filename against the filename-class patterns, reporting which
/// pattern fired. This is the canonical matcher; `extract_date_from_filename`
/// is the date-only projection of it.
pub fn match_filename_date(filename: &str) -> Option<FilenameMatch> {
// The wall-clock patterns (screenshot / dash / compact) carry the
// device's own clock; the unix-timestamp pattern carries a true
// instant. `legacy_datetime()` reproduces the pre-migration
// interpretation of each (naive-as-UTC / epoch) for the legacy
// column and its consumers.
// instant. `FilenameDate::legacy_datetime()` reproduces the
// pre-migration interpretation of each (naive-as-UTC / epoch) for the
// legacy column and its consumers.
let build_wall_from_ymd_capture = |captures: &regex::Captures| -> Option<NaiveDateTime> {
let year = captures.get(1)?.as_str().parse::<i32>().ok()?;
let month = captures.get(2)?.as_str().parse::<u32>().ok()?;
@@ -197,7 +211,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
.captures(filename)
.and_then(|c| build_wall_from_ymd_capture(&c))
{
return Some(FilenameDate::Wall(naive));
return Some(FilenameMatch {
pattern: "screenshot",
date: FilenameDate::Wall(naive),
});
}
// Screenshot format: Screenshot_20140601[_-]204450.png
@@ -206,7 +223,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
.captures(filename)
.and_then(|c| build_wall_from_ymd_capture(&c))
{
return Some(FilenameDate::Wall(naive));
return Some(FilenameMatch {
pattern: "screenshot_compact",
date: FilenameDate::Wall(naive),
});
}
// 2. Dash format: 2015-01-09_02-15-15.jpg
@@ -215,7 +235,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
.captures(filename)
.and_then(|c| build_wall_from_ymd_capture(&c))
{
return Some(FilenameDate::Wall(naive));
return Some(FilenameMatch {
pattern: "dash",
date: FilenameDate::Wall(naive),
});
}
// Dash with compact time format: 2015-01-09-021515.jpg
@@ -224,7 +247,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
.captures(filename)
.and_then(|c| build_wall_from_ymd_capture(&c))
{
return Some(FilenameDate::Wall(naive));
return Some(FilenameMatch {
pattern: "dash_compact",
date: FilenameDate::Wall(naive),
});
}
// 3. Compact format: 20140927101712.jpg
@@ -233,7 +259,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
.captures(filename)
.and_then(|c| build_wall_from_ymd_capture(&c))
{
return Some(FilenameDate::Wall(naive));
return Some(FilenameMatch {
pattern: "compact",
date: FilenameDate::Wall(naive),
});
}
// 4. Timestamp format: 1401638400.jpeg, att_1422489664680106.jpeg, att_142248967186928.jpeg
@@ -281,7 +310,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
.and_then(DateTime::<Utc>::from_timestamp_millis)
.and_then(plausible_filename_date)
{
return Some(FilenameDate::Utc(date_time));
return Some(FilenameMatch {
pattern: "unix_timestamp",
date: FilenameDate::Utc(date_time),
});
}
// For 14-16 digits, treat first 10 digits as seconds to avoid far future dates
@@ -293,7 +325,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
.and_then(|ts| DateTime::<Utc>::from_timestamp(ts, 0))
.and_then(plausible_filename_date)
{
return Some(FilenameDate::Utc(date_time));
return Some(FilenameMatch {
pattern: "unix_timestamp",
date: FilenameDate::Utc(date_time),
});
}
// Exactly 10 digits - seconds since epoch
@@ -311,7 +346,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
if is_snapchat && date_time.timestamp() < SNAPCHAT_LAUNCH_TS {
return None;
}
return Some(FilenameDate::Utc(date_time));
return Some(FilenameMatch {
pattern: "unix_timestamp",
date: FilenameDate::Utc(date_time),
});
}
// 11-12 digits: try as milliseconds (might be partial millisecond timestamp)
@@ -322,13 +360,23 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
.and_then(DateTime::<Utc>::from_timestamp_millis)
.and_then(plausible_filename_date)
{
return Some(FilenameDate::Utc(date_time));
return Some(FilenameMatch {
pattern: "unix_timestamp",
date: FilenameDate::Utc(date_time),
});
}
}
None
}
/// Date-only projection of [`match_filename_date`]: the class match
/// without the pattern name. Resolver and consumer call sites use this;
/// the audit utility uses the full match.
pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
match_filename_date(filename).map(|m| m.date)
}
/// Sanity gate for filename-derived timestamps. Real photo capture dates
/// live in a narrow window; values outside it are almost always sequential
/// scan IDs (`000227580005.jpg` → 1970) or arbitrary numeric suffixes
@@ -782,6 +830,32 @@ mod tests {
assert_eq!(date_time.timestamp(), 1383929602);
}
// Pattern-name seam (ticket #117): the audit utility prints which
// pattern fired, so every class pattern must report a stable name.
#[test]
fn test_match_filename_date_reports_pattern_names() {
let cases: &[(&str, &str)] = &[
("Screenshot_2014-06-01-20-44-50.png", "screenshot"),
("Screenshot_20140601_204450.png", "screenshot_compact"),
("2015-01-09_02-15-15.jpg", "dash"),
("2015-01-09-021515.jpg", "dash_compact"),
("20140927101712.jpg", "compact"),
("att_1422489664680106.jpeg", "unix_timestamp"),
("1401638400.jpeg", "unix_timestamp"),
];
for (name, pattern) in cases {
let match_ =
match_filename_date(name).unwrap_or_else(|| panic!("no match for {}", name));
assert_eq!(match_.pattern, *pattern, "pattern name for {}", name);
}
}
#[test]
fn test_match_filename_date_out_of_class_is_none() {
assert_eq!(match_filename_date("IMG_12345.jpg"), None);
assert_eq!(match_filename_date("000227580005.jpg"), None);
}
// The obsolete `test_memory_date_priority_*` tests covered the old
// request-time waterfall in `get_memory_date_with_priority`. Their
// replacement lives in `crate::date_resolver::tests` (resolver