//! Pre-cutover audit utility for the filename date class (ticket #117). //! //! A one-shot, operator-run report listing every file that matches a //! filename-class pattern: the file's current stored date + source and //! the date / source / offset-source the re-based resolver would assign //! it. It reads the live database and walks the libraries (honoring the //! library exclusions and the online gate — offline libraries are //! reported as skipped, never processed). It changes nothing: strictly //! read-only, no writes to any table. //! //! Purpose: the filename class is a documented inversion of EXIF-wins //! and depends on the operator's memory of "what these filenames mean". //! This report is the human spot-check that gates the production //! backfill drain — the operator reviews it, fixes any mis-tagged //! pattern or misclassified file, and only then starts the drain. //! //! The proposed values mirror the drain's inputs: the resolver runs //! with empty priors, exactly as the per-tick drain calls //! `resolve_dates_batch` today. In-class files never touch exiftool, so //! the audit spawns no subprocesses at all. use std::collections::HashMap; use std::path::PathBuf; use diesel::prelude::*; use diesel::sqlite::SqliteConnection; use crate::database::schema::image_exif; use crate::date_resolver::ResolvedDate; use crate::libraries::{Library, LibraryHealth, probe_online}; use crate::memories::{PathExcluder, match_filename_date}; /// The stored date contract for one `image_exif` row: the legacy pair /// plus the new-contract audit value (NULL until the drain converts it). #[derive(Clone, Debug, PartialEq, Eq)] pub struct StoredDate { pub date_taken: Option, pub date_taken_source: Option, pub date_taken_tz_source: Option, } /// One report row: a filename-class file, what the database has for it /// now, and what the re-based resolver would assign. #[derive(Clone, Debug)] pub struct AuditRow { pub library_id: i32, pub library_name: String, /// Root-relative path, slash-separated. pub rel_path: String, /// Which filename-class pattern matched (`screenshot`, `dash`, …). pub pattern: &'static str, /// The row's stored contract; `None` when the file is not indexed /// yet (no `image_exif` row). pub current: Option, /// What the re-based resolver would assign (legacy value, source, /// true instant, offset, offset-source). pub proposed: ResolvedDate, /// True when a stored row exists and its legacy `date_taken` /// disagrees with the proposed legacy value — the rows the operator /// needs to spot-check hardest. pub changed: bool, } /// A library the audit did not process, with the reason. Disabled /// (operator kill switch) and offline (probe failed) are reported, /// never partially processed. #[derive(Clone, Debug, PartialEq, Eq)] pub struct SkippedLibrary { pub library_id: i32, pub name: String, pub reason: String, } /// One `image_exif` row's date contract, as loaded for the audit. #[derive(diesel::Queryable)] struct StoredDateRow { rel_path: String, date_taken: Option, date_taken_source: Option, date_taken_tz_source: Option, } /// Load `rel_path -> stored date fields` for one library. Read-only /// (SELECTs only; `&mut` is diesel's connection contract, not a write). pub fn stored_dates_for_library( conn: &mut SqliteConnection, library_id: i32, ) -> Result, diesel::result::Error> { let rows: Vec = image_exif::table .filter(image_exif::library_id.eq(library_id)) .select(( image_exif::rel_path, image_exif::date_taken, image_exif::date_taken_source, image_exif::date_taken_tz_source, )) .load(conn)?; Ok(rows .into_iter() .map( |StoredDateRow { rel_path, date_taken, date_taken_source, date_taken_tz_source, }| { ( rel_path, StoredDate { date_taken, date_taken_source, date_taken_tz_source, }, ) }, ) .collect()) } /// Audit one online library: walk its root (honoring the effective /// excludes), match the filename class, and compute the proposed /// resolution for every in-class file in a single resolver pass. Rows /// are stable-sorted by `rel_path`. `stored` comes from /// [`stored_dates_for_library`]. pub fn audit_library( library: &Library, global_excludes: &[String], stored: &HashMap, ) -> Vec { let root = PathBuf::from(&library.root_path); let excluder = PathExcluder::new(&root, &library.effective_excluded_dirs(global_excludes)); let mut matched: Vec<(String, PathBuf, &'static str)> = Vec::new(); // Depth 0 (the root itself) is always allowed so a pathological // exclude matching the root can't silently yield an empty report — // same guard as file_scan::walk_library_files. for entry in walkdir::WalkDir::new(&root) .into_iter() .filter_entry(|e| e.depth() == 0 || !excluder.is_excluded(e.path())) .flatten() { if !entry.file_type().is_file() { continue; } let Some(rel) = entry .path() .strip_prefix(&root) .ok() .map(|p| p.to_string_lossy().replace('\\', "/")) else { continue; }; let Some(fm) = match_filename_date(entry.file_name().to_str().unwrap_or("")) else { continue; }; matched.push((rel, entry.path().to_path_buf(), fm.pattern)); } matched.sort_by(|a, b| a.0.cmp(&b.0)); // One resolver pass for the whole library. In-class files resolve // from the name alone, so this spawns no exiftool subprocess. let paths: Vec = matched.iter().map(|(_, p, _)| p.clone()).collect(); let resolved = crate::date_resolver::resolve_dates_batch(&paths, &HashMap::new()); matched .into_iter() .map(|(rel_path, abs, pattern)| { // An in-class name always resolves (the class never fails to // date a file it previously dated); `None` would be a bug in // the resolver, not a missing file. let proposed = resolved .get(&abs) .copied() .expect("in-class filename must resolve without exiftool"); let current = stored.get(&rel_path).cloned(); let changed = current .as_ref() .map(|c| c.date_taken != Some(proposed.timestamp)) .unwrap_or(false); AuditRow { library_id: library.id, library_name: library.name.clone(), rel_path, pattern, current, proposed, changed, } }) .collect() } /// Run the audit over every configured library: disabled libraries are /// reported as skipped; online libraries are walked; probe-failed /// libraries are reported as skipped (never partially processed). The /// result is stable-sorted by library id, then `rel_path`, so two runs /// diff cleanly. pub fn run_audit( conn: &mut SqliteConnection, libraries: Vec, global_excludes: &[String], ) -> (Vec, Vec) { let mut rows: Vec = Vec::new(); let mut skipped: Vec = Vec::new(); for lib in libraries { if !lib.enabled { skipped.push(SkippedLibrary { library_id: lib.id, name: lib.name.clone(), reason: "disabled (enabled=false)".to_string(), }); continue; } // Same online gate as the watcher: probe with prior knowledge // (had_data) from the image_exif row count. let had_data: i64 = image_exif::table .filter(image_exif::library_id.eq(lib.id)) .count() .get_result(conn) .unwrap_or(0); match probe_online(&lib, had_data > 0) { LibraryHealth::Online => { let stored = stored_dates_for_library(conn, lib.id).unwrap_or_default(); rows.extend(audit_library(&lib, global_excludes, &stored)); } LibraryHealth::Stale { reason, .. } => { skipped.push(SkippedLibrary { library_id: lib.id, name: lib.name.clone(), reason: format!("offline ({reason})"), }); } } } rows.sort_by(|a, b| { (a.library_id, a.rel_path.as_str()).cmp(&(b.library_id, b.rel_path.as_str())) }); (rows, skipped) } fn format_epoch_utc(ts: Option) -> String { match ts.and_then(|t| chrono::DateTime::::from_timestamp(t, 0)) { Some(dt) => dt.format("%Y-%m-%d %H:%M:%S").to_string(), None => "-".to_string(), } } /// Render the report. One entry per in-class file — three lines each: /// header, `current:`, `proposed:` — stable-sorted, with a `*` on rows /// whose stored legacy date would change; then the skipped libraries /// and a summary. Deterministic: two runs diff cleanly. pub fn format_report(rows: &[AuditRow], skipped: &[SkippedLibrary]) -> String { let mut out = String::new(); for row in rows { let current = match &row.current { Some(c) => format!( "{} [{}]", format_epoch_utc(c.date_taken), c.date_taken_source.as_deref().unwrap_or("-") ), None => "unindexed".to_string(), }; let proposed_tz = row .proposed .tz_source .map(|t| t.as_str().to_string()) .unwrap_or_else(|| "-".into()); // The numeric offset (minutes ahead of UTC) is the value the // operator actually spot-checks: a wall pattern resolved with // empty evidence carries the server zone's offset for that date. let proposed_offset = row .proposed .camera_offset_minutes .map(|m| format!("{m:+}m")) .unwrap_or_else(|| "-".into()); out.push_str(&format!( "{}\t{:<19}\t{:<20}\tlib={} {}\n current: {}\n proposed: {} [{}] tz_source={} offset={}\n", if row.changed { "*" } else { " " }, row.pattern, row.rel_path, row.library_id, row.library_name, current, format_epoch_utc(Some(row.proposed.timestamp)), row.proposed.source.as_str(), proposed_tz, proposed_offset, )); } if !skipped.is_empty() { out.push_str("\nskipped libraries:\n"); for s in skipped { out.push_str(&format!( " lib={} {} — {}\n", s.library_id, s.name, s.reason )); } } out.push_str(&format!( "\n{} file(s) in the filename class, {} would change, {} library(ies) skipped\n", rows.len(), rows.iter().filter(|r| r.changed).count(), skipped.len() )); out } #[cfg(test)] mod tests { use super::*; use diesel::Connection; use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; use std::fs; use tempfile::tempdir; const DB_MIGRATIONS: EmbeddedMigrations = embed_migrations!(); fn migrated_conn() -> SqliteConnection { let mut conn = SqliteConnection::establish(":memory:").expect("in-memory sqlite"); conn.run_pending_migrations(DB_MIGRATIONS) .expect("migrations"); conn } fn seed_row( conn: &mut SqliteConnection, library_id: i32, rel_path: &str, date_taken: Option, source: Option<&str>, ) { diesel::insert_into(image_exif::table) .values(( image_exif::library_id.eq(library_id), image_exif::rel_path.eq(rel_path), image_exif::date_taken.eq(date_taken), image_exif::date_taken_source.eq(source), image_exif::created_time.eq(1_700_000_000), image_exif::last_modified.eq(1_700_000_000), )) .execute(conn) .expect("seed row"); } fn library(id: i32, name: &str, root: &std::path::Path, enabled: bool) -> Library { Library { id, name: name.to_string(), root_path: root.to_string_lossy().to_string(), enabled, excluded_dirs: Vec::new(), } } /// Fixture library seeded with mixed conventions: /// - `Screenshot_2014-06-01-20-44-50.png` — in-class wall, stored row /// says exif/2021 (the inversion the operator must spot-check). /// - `att_1422489664680106.jpeg` — in-class unix timestamp, stored /// row already agrees. /// - `2015-01-09_02-15-15.jpg` — in-class wall, not indexed yet. /// - `plain_photo.jpg` — out of class, must not appear. /// - `@eaDir/2016-02-02_03-03-03.jpg` — excluded dir, must not appear. fn fixture_library(root: &std::path::Path) { for path in [ "Screenshot_2014-06-01-20-44-50.png", "att_1422489664680106.jpeg", "2015-01-09_02-15-15.jpg", "plain_photo.jpg", "@eaDir/2016-02-02_03-03-03.jpg", ] { let full = root.join(path); if let Some(parent) = full.parent() { fs::create_dir_all(parent).unwrap(); } fs::write(&full, b"fixture").unwrap(); } } #[test] fn audit_shows_inversion_timestamp_and_exclusions() { let tmp = tempdir().unwrap(); fixture_library(tmp.path()); let mut conn = migrated_conn(); // 2021-01-15 12:00:00 UTC — a stale EXIF date the filename // outranks. att_1422489664680106 -> first 10 digits = // 1422489664 = 2015-01-29 07:21:04 UTC. let stale_exif: i64 = 1_610_742_400; seed_row( &mut conn, 1, "Screenshot_2014-06-01-20-44-50.png", Some(stale_exif), Some("exif"), ); seed_row( &mut conn, 1, "att_1422489664680106.jpeg", Some(1422489664), Some("filename"), ); let lib = library(1, "photos", tmp.path(), true); let stored = stored_dates_for_library(&mut conn, 1).unwrap(); // Global excludes as the EXCLUDED_DIRS env var would provide in // production (junk dirs like @eaDir never enter the class walk). let rows = audit_library(&lib, &["@eaDir".to_string()], &stored); // Exactly the three in-class files; out-of-class and excluded // paths are absent. let rels: Vec<&str> = rows.iter().map(|r| r.rel_path.as_str()).collect(); assert_eq!( rels, vec![ "2015-01-09_02-15-15.jpg", "Screenshot_2014-06-01-20-44-50.png", "att_1422489664680106.jpeg" ] ); // The inversion: stored exif/2021, proposed filename/2014 wall // (wall-as-UTC legacy), tz_source assumed_server (empty priors, // mirroring the drain). let inv = rows .iter() .find(|r| r.rel_path == "Screenshot_2014-06-01-20-44-50.png") .unwrap(); assert_eq!(inv.pattern, "screenshot"); assert_eq!(inv.current.as_ref().unwrap().date_taken, Some(stale_exif)); assert_eq!( inv.current.as_ref().unwrap().date_taken_source.as_deref(), Some("exif") ); assert_eq!(inv.proposed.source.as_str(), "filename"); assert_eq!( inv.proposed.timestamp, 1401655490 // 2014-06-01 20:44:50 as UTC (wall-as-UTC legacy) ); assert_eq!( inv.proposed.tz_source.map(|t| t.as_str()), Some("assumed_server") ); // Server zone America/New_York is EDT (UTC-4) in June. assert_eq!(inv.proposed.camera_offset_minutes, Some(-240)); assert!(inv.changed, "stored exif date must flag as changed"); // The unix timestamp: utc_direct, no offset, and the stored row // already agrees -> not flagged. let ts = rows .iter() .find(|r| r.rel_path == "att_1422489664680106.jpeg") .unwrap(); assert_eq!(ts.pattern, "unix_timestamp"); assert_eq!(ts.proposed.source.as_str(), "filename"); assert_eq!(ts.proposed.timestamp, 1422489664); assert_eq!(ts.proposed.date_utc, Some(1422489664)); assert_eq!(ts.proposed.camera_offset_minutes, None); assert_eq!( ts.proposed.tz_source.map(|t| t.as_str()), Some("utc_direct") ); assert!(!ts.changed); // Unindexed in-class file: current is None, not flagged. let unidx = rows .iter() .find(|r| r.rel_path == "2015-01-09_02-15-15.jpg") .unwrap(); assert_eq!(unidx.pattern, "dash"); assert!(unidx.current.is_none()); assert!(!unidx.changed); } #[test] fn audit_leaves_out_counter_and_id_names_with_implausible_years() { // #160: these matched screenshot_compact as years 2229 / 8580 / // 1053 and the audit proposed them as dates of record. let tmp = tempdir().unwrap(); for path in [ "VID_22290524_080551_818.mp4", "VID_185800302_235056_842.mp4", "10530519_10152700326093436_5479865808523667420_o.jpg", "Screenshot_2014-06-01-20-44-50.png", ] { fs::write(tmp.path().join(path), b"fixture").unwrap(); } let mut conn = migrated_conn(); let lib = library(1, "photos", tmp.path(), true); let stored = stored_dates_for_library(&mut conn, 1).unwrap(); let rows = audit_library(&lib, &[], &stored); let rels: Vec<&str> = rows.iter().map(|r| r.rel_path.as_str()).collect(); assert_eq!(rels, vec!["Screenshot_2014-06-01-20-44-50.png"]); } #[test] fn migration_clears_implausible_filename_dates_for_re_resolution() { // Migrate up to just before the #160 data fix, seed rows as the // old matcher left them, then apply it. const FIX: &str = "reresolve_implausible_filename_dates"; let mut conn = SqliteConnection::establish(":memory:").expect("in-memory sqlite"); for m in conn.pending_migrations(DB_MIGRATIONS).expect("pending") { if m.name().to_string().contains(FIX) { break; } conn.run_migration(&*m).expect("migration"); } // 2229-05-24 08:05:51 and 1053-05-19 10:15:27 (wall-as-UTC), a // real 2014 filename date, and an fs_time row that isn't ours to // touch even though its date is odd. seed_row( &mut conn, 1, "VID_22290524_080551_818.mp4", Some(8_185_651_551), Some("filename"), ); seed_row( &mut conn, 1, "10530519_1015270_o.jpg", Some(-28_925_732_673), Some("filename"), ); seed_row( &mut conn, 1, "Screenshot_2014-06-01-20-44-50.png", Some(1_401_655_490), Some("filename"), ); seed_row(&mut conn, 1, "old_scan.jpg", Some(100), Some("fs_time")); diesel::update(image_exif::table) .set(( image_exif::date_utc.eq(image_exif::date_taken), image_exif::date_taken_tz_source.eq("assumed_server"), )) .execute(&mut conn) .unwrap(); conn.run_pending_migrations(DB_MIGRATIONS) .expect("fix migration"); type Dates = ( String, Option, Option, Option, Option, ); let rows: Vec = image_exif::table .select(( image_exif::rel_path, image_exif::date_taken, image_exif::date_taken_source, image_exif::date_utc, image_exif::date_taken_tz_source, )) .order(image_exif::rel_path) .load(&mut conn) .unwrap(); let cleared = |r: &Dates| r.1.is_none() && r.2.is_none() && r.3.is_none() && r.4.is_none(); for r in &rows { let expect_cleared = r.0.starts_with("VID_") || r.0.starts_with("1053"); assert_eq!(cleared(r), expect_cleared, "{:?}", r); } } #[test] fn run_audit_skips_disabled_and_offline_libraries() { let tmp = tempdir().unwrap(); let online_root = tmp.path().join("online"); let missing_root = tmp.path().join("missing"); fs::create_dir_all(&online_root).unwrap(); fixture_library(&online_root); // missing_root is never created -> probe fails. let mut conn = migrated_conn(); // Migrations only seed library 1; image_exif.library_id is a // foreign key, so library 2 must exist before its row does. { use crate::database::schema::libraries; diesel::insert_into(libraries::table) .values(( libraries::id.eq(2), libraries::name.eq("offline"), libraries::root_path.eq(missing_root.to_string_lossy().to_string()), libraries::created_at.eq(1_700_000_000), )) .execute(&mut conn) .expect("seed library"); } // Give the offline library a row so had_data=true (the strict // probe: empty dir with rows is suspicious). seed_row(&mut conn, 2, "whatever.jpg", Some(1), Some("exif")); let libs = vec![ library(2, "offline", &missing_root, true), library(1, "online", &online_root, true), library(3, "disabled", &online_root, false), ]; let (rows, skipped) = run_audit(&mut conn, libs, &[]); let skipped_ids: Vec = skipped.iter().map(|s| s.library_id).collect(); assert_eq!(skipped_ids, vec![2, 3]); assert!(skipped[0].reason.starts_with("offline")); assert_eq!(skipped[1].reason, "disabled (enabled=false)"); // Only the online library was processed. let libs_seen: Vec = rows.iter().map(|r| r.library_id).collect(); assert!(libs_seen.iter().all(|id| *id == 1)); assert!(!rows.is_empty()); } #[test] fn run_audit_sorts_by_library_then_path() { let tmp = tempdir().unwrap(); let a = tmp.path().join("a"); let b = tmp.path().join("b"); fs::create_dir_all(&a).unwrap(); fs::create_dir_all(&b).unwrap(); fs::write(a.join("2015-02-02_02-02-02.jpg"), b"").unwrap(); fs::write(a.join("Screenshot_2014-06-01-20-44-50.png"), b"").unwrap(); fs::write(b.join("1401638400.jpeg"), b"").unwrap(); // Intentionally pass libraries out of order. let libs = vec![library(2, "b", &b, true), library(1, "a", &a, true)]; let mut conn = migrated_conn(); let (rows, skipped) = run_audit(&mut conn, libs, &[]); assert!(skipped.is_empty()); let got: Vec<(i32, String)> = rows .iter() .map(|r| (r.library_id, r.rel_path.clone())) .collect(); assert_eq!( got, vec![ (1, "2015-02-02_02-02-02.jpg".to_string()), (1, "Screenshot_2014-06-01-20-44-50.png".to_string()), (2, "1401638400.jpeg".to_string()), ] ); } #[test] fn format_report_is_deterministic_and_marks_changed() { let tmp = tempdir().unwrap(); fixture_library(tmp.path()); let mut conn = migrated_conn(); seed_row( &mut conn, 1, "Screenshot_2014-06-01-20-44-50.png", Some(1_610_742_400), Some("exif"), ); let lib = library(1, "photos", tmp.path(), true); let (rows, skipped) = run_audit(&mut conn, vec![lib], &["@eaDir".to_string()]); let first = format_report(&rows, &skipped); let second = format_report(&rows, &skipped); assert_eq!(first, second); assert!(first.contains("*\t"), "changed row must be flagged"); assert!(first.contains("screenshot")); assert!(first.contains("utc_direct")); // Numeric offset is printed: the wall row carries the server-zone // offset for that date, timestamp rows have none. assert!(first.contains("offset=-240m")); assert!(first.contains("offset=-\n")); // The stored source renders without debug quotes (P1 from // review): `2021-... [exif]`, never `["exif"]`. assert!(first.contains("[exif]")); assert!(!first.contains("\"exif\"")); assert!(first.contains("3 file(s) in the filename class, 1 would change")); // Excluded path must not appear anywhere in the report. assert!(!first.contains("2016-02-02_03-03-03.jpg")); } }