use chrono::{DateTime, Utc};
use rusqlite::Connection;
use std::collections::HashMap;
use std::path::Path;
pub fn mtime_iso(t: std::time::SystemTime) -> String {
let dt: DateTime<Utc> = t.into();
dt.to_rfc3339()
}
#[derive(Clone, Debug)]
pub struct RowSig {
pub size_bytes: u64,
pub modified_at: Option<String>,
pub has_mime: bool,
pub has_phash: bool,
}
pub fn is_current(
sig: &RowSig,
cur_size: u64,
cur_mtime: Option<&str>,
want_similar: bool,
) -> bool {
sig.has_mime
&& (!want_similar || sig.has_phash)
&& sig.size_bytes == cur_size
&& cur_mtime.is_some()
&& sig.modified_at.as_deref() == cur_mtime
}
pub fn stored_signatures(conn: &Connection) -> rusqlite::Result<HashMap<String, RowSig>> {
if !table_exists(conn, "file_hashes")? {
return Ok(HashMap::new());
}
let mut stmt = conn.prepare(
"SELECT path, size_bytes, modified_at, mime IS NOT NULL, phash IS NOT NULL \
FROM file_hashes",
)?;
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
RowSig {
size_bytes: r.get::<_, Option<i64>>(1)?.unwrap_or(0) as u64,
modified_at: r.get::<_, Option<String>>(2)?,
has_mime: r.get::<_, bool>(3)?,
has_phash: r.get::<_, bool>(4)?,
},
))
})?;
rows.collect()
}
pub fn open_wal(path: &Path) -> rusqlite::Result<Connection> {
let conn = Connection::open(path)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
ensure_file_hashes_columns(&conn);
crate::face_db::ensure_people_table(&conn);
let _ = crate::marks::ensure_marks_table(&conn);
Ok(conn)
}
pub fn ensure_file_hashes_columns(conn: &Connection) {
let _ = conn.execute_batch("ALTER TABLE file_hashes ADD COLUMN mime TEXT;");
let _ = conn.execute_batch("ALTER TABLE file_hashes ADD COLUMN duration_secs REAL;");
let _ = conn.execute_batch("ALTER TABLE file_hashes ADD COLUMN codec TEXT;");
}
pub fn table_exists(conn: &Connection, name: &str) -> rusqlite::Result<bool> {
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
[name],
|r| r.get(0),
)?;
Ok(count > 0)
}
pub fn paths_with_known_mime(
conn: &Connection,
) -> rusqlite::Result<std::collections::HashSet<String>> {
if !table_exists(conn, "file_hashes")? {
return Ok(std::collections::HashSet::new());
}
let mut stmt = conn.prepare("SELECT path FROM file_hashes WHERE mime IS NOT NULL")?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
rows.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn is_current_only_when_unchanged_and_complete() {
let base = RowSig {
size_bytes: 10,
modified_at: Some("2024-01-01T00:00:00+00:00".to_string()),
has_mime: true,
has_phash: false,
};
let m = base.modified_at.as_deref();
assert!(is_current(&base, 10, m, false));
assert!(!is_current(&base, 11, m, false));
assert!(!is_current(
&base,
10,
Some("2024-02-02T00:00:00+00:00"),
false
));
assert!(!is_current(&base, 10, None, false));
let no_mime = RowSig {
has_mime: false,
..base.clone()
};
assert!(!is_current(&no_mime, 10, m, false));
assert!(!is_current(&base, 10, m, true));
let with_phash = RowSig {
has_phash: true,
..base.clone()
};
assert!(is_current(&with_phash, 10, m, true));
}
#[test]
fn stored_signatures_reads_size_mtime_and_completeness() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE file_hashes (path TEXT PRIMARY KEY, hash TEXT, size_bytes INTEGER,
modified_at TEXT, mime TEXT, phash INTEGER);
INSERT INTO file_hashes VALUES ('a.jpg','h',10,'2024-01-01T00:00:00+00:00','image/jpeg',NULL);
INSERT INTO file_hashes VALUES ('b.dng','h2',20,'2024-01-02T00:00:00+00:00',NULL,NULL);",
)
.unwrap();
let sigs = stored_signatures(&conn).unwrap();
assert_eq!(sigs["a.jpg"].size_bytes, 10);
assert!(sigs["a.jpg"].has_mime && !sigs["a.jpg"].has_phash);
assert!(!sigs["b.dng"].has_mime);
}
#[test]
fn stored_signatures_empty_without_the_table() {
let conn = Connection::open_in_memory().unwrap();
assert!(stored_signatures(&conn).unwrap().is_empty());
}
#[test]
fn table_exists_true_for_existing_table() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch("CREATE TABLE widgets (id INTEGER);")
.unwrap();
assert!(table_exists(&conn, "widgets").unwrap());
}
#[test]
fn table_exists_false_for_missing_table() {
let conn = Connection::open_in_memory().unwrap();
assert!(!table_exists(&conn, "widgets").unwrap());
}
#[test]
fn open_wal_sets_journal_mode() {
let dir = tempdir().unwrap();
let db_path = dir.path().join("test.db");
let conn = open_wal(&db_path).unwrap();
let mode: String = conn
.query_row("PRAGMA journal_mode", [], |r| r.get(0))
.unwrap();
assert_eq!(mode.to_lowercase(), "wal");
}
#[test]
fn open_wal_is_idempotent_across_repeated_opens() {
let dir = tempdir().unwrap();
let db_path = dir.path().join("test.db");
open_wal(&db_path).unwrap();
let conn = open_wal(&db_path).unwrap();
let mode: String = conn
.query_row("PRAGMA journal_mode", [], |r| r.get(0))
.unwrap();
assert_eq!(mode.to_lowercase(), "wal");
}
fn db_with_rows(rows: &[(&str, Option<&str>)]) -> Connection {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE file_hashes (path TEXT PRIMARY KEY, hash TEXT, ext TEXT, mime TEXT);",
)
.unwrap();
for (path, mime) in rows {
conn.execute(
"INSERT INTO file_hashes (path, hash, ext, mime) VALUES (?1, 'h', 'jpg', ?2)",
rusqlite::params![path, mime],
)
.unwrap();
}
conn
}
#[test]
fn paths_with_known_mime_excludes_null_rows() {
let conn = db_with_rows(&[("/done.jpg", Some("image/jpeg")), ("/todo.jpg", None)]);
let set = paths_with_known_mime(&conn).unwrap();
assert!(set.contains("/done.jpg"));
assert!(
!set.contains("/todo.jpg"),
"NULL means never scanned, so it must be retried"
);
}
#[test]
fn paths_with_known_mime_includes_the_sentinel() {
let conn = db_with_rows(&[("/weird.jpg", Some(crate::mime_probe::UNKNOWN_MIME))]);
assert!(paths_with_known_mime(&conn).unwrap().contains("/weird.jpg"));
}
#[test]
fn paths_with_known_mime_on_a_table_that_does_not_exist_is_empty_not_an_error() {
let conn = Connection::open_in_memory().unwrap();
assert!(paths_with_known_mime(&conn).unwrap().is_empty());
}
}