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:
1 parent
3ac011abd7
commit
3aa6ce314c
4 files changed
+720
-14
No files matched your search
+88
-14
@@ -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: ®ex::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
|
||||
|
||||
Reference in new issue
Block a user