1use chrono::{DateTime, Utc};
2use rusqlite::Connection;
3use std::collections::HashMap;
4use std::path::Path;
5
6pub fn mtime_iso(t: std::time::SystemTime) -> String {
14 let dt: DateTime<Utc> = t.into();
15 dt.to_rfc3339()
16}
17
18#[derive(Clone, Debug)]
21pub struct RowSig {
22 pub size_bytes: u64,
23 pub modified_at: Option<String>,
24 pub has_mime: bool,
25 pub has_phash: bool,
26}
27
28pub fn is_current(
33 sig: &RowSig,
34 cur_size: u64,
35 cur_mtime: Option<&str>,
36 want_similar: bool,
37) -> bool {
38 sig.has_mime
39 && (!want_similar || sig.has_phash)
40 && sig.size_bytes == cur_size
41 && cur_mtime.is_some()
42 && sig.modified_at.as_deref() == cur_mtime
43}
44
45pub fn stored_signatures(conn: &Connection) -> rusqlite::Result<HashMap<String, RowSig>> {
48 if !table_exists(conn, "file_hashes")? {
49 return Ok(HashMap::new());
50 }
51 let mut stmt = conn.prepare(
52 "SELECT path, size_bytes, modified_at, mime IS NOT NULL, phash IS NOT NULL \
53 FROM file_hashes",
54 )?;
55 let rows = stmt.query_map([], |r| {
56 Ok((
57 r.get::<_, String>(0)?,
58 RowSig {
59 size_bytes: r.get::<_, Option<i64>>(1)?.unwrap_or(0) as u64,
60 modified_at: r.get::<_, Option<String>>(2)?,
61 has_mime: r.get::<_, bool>(3)?,
62 has_phash: r.get::<_, bool>(4)?,
63 },
64 ))
65 })?;
66 rows.collect()
67}
68
69pub fn open_wal(path: &Path) -> rusqlite::Result<Connection> {
77 let conn = Connection::open(path)?;
78 conn.pragma_update(None, "journal_mode", "WAL")?;
79 ensure_file_hashes_columns(&conn);
80 crate::face_db::ensure_people_table(&conn);
81 let _ = crate::marks::ensure_marks_table(&conn);
82 Ok(conn)
83}
84
85pub fn ensure_file_hashes_columns(conn: &Connection) {
97 let _ = conn.execute_batch("ALTER TABLE file_hashes ADD COLUMN mime TEXT;");
98 let _ = conn.execute_batch("ALTER TABLE file_hashes ADD COLUMN duration_secs REAL;");
102 let _ = conn.execute_batch("ALTER TABLE file_hashes ADD COLUMN codec TEXT;");
103}
104
105pub fn table_exists(conn: &Connection, name: &str) -> rusqlite::Result<bool> {
109 let count: i64 = conn.query_row(
110 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
111 [name],
112 |r| r.get(0),
113 )?;
114 Ok(count > 0)
115}
116
117pub fn paths_with_known_mime(
132 conn: &Connection,
133) -> rusqlite::Result<std::collections::HashSet<String>> {
134 if !table_exists(conn, "file_hashes")? {
135 return Ok(std::collections::HashSet::new());
136 }
137 let mut stmt = conn.prepare("SELECT path FROM file_hashes WHERE mime IS NOT NULL")?;
138 let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
139 rows.collect()
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145 use tempfile::tempdir;
146
147 #[test]
148 fn is_current_only_when_unchanged_and_complete() {
149 let base = RowSig {
150 size_bytes: 10,
151 modified_at: Some("2024-01-01T00:00:00+00:00".to_string()),
152 has_mime: true,
153 has_phash: false,
154 };
155 let m = base.modified_at.as_deref();
156 assert!(is_current(&base, 10, m, false));
158 assert!(!is_current(&base, 11, m, false));
160 assert!(!is_current(
162 &base,
163 10,
164 Some("2024-02-02T00:00:00+00:00"),
165 false
166 ));
167 assert!(!is_current(&base, 10, None, false));
169 let no_mime = RowSig {
171 has_mime: false,
172 ..base.clone()
173 };
174 assert!(!is_current(&no_mime, 10, m, false));
175 assert!(!is_current(&base, 10, m, true));
177 let with_phash = RowSig {
179 has_phash: true,
180 ..base.clone()
181 };
182 assert!(is_current(&with_phash, 10, m, true));
183 }
184
185 #[test]
186 fn stored_signatures_reads_size_mtime_and_completeness() {
187 let conn = Connection::open_in_memory().unwrap();
188 conn.execute_batch(
189 "CREATE TABLE file_hashes (path TEXT PRIMARY KEY, hash TEXT, size_bytes INTEGER,
190 modified_at TEXT, mime TEXT, phash INTEGER);
191 INSERT INTO file_hashes VALUES ('a.jpg','h',10,'2024-01-01T00:00:00+00:00','image/jpeg',NULL);
192 INSERT INTO file_hashes VALUES ('b.dng','h2',20,'2024-01-02T00:00:00+00:00',NULL,NULL);",
193 )
194 .unwrap();
195 let sigs = stored_signatures(&conn).unwrap();
196 assert_eq!(sigs["a.jpg"].size_bytes, 10);
197 assert!(sigs["a.jpg"].has_mime && !sigs["a.jpg"].has_phash);
198 assert!(!sigs["b.dng"].has_mime);
199 }
200
201 #[test]
202 fn stored_signatures_empty_without_the_table() {
203 let conn = Connection::open_in_memory().unwrap();
204 assert!(stored_signatures(&conn).unwrap().is_empty());
205 }
206
207 #[test]
208 fn table_exists_true_for_existing_table() {
209 let conn = Connection::open_in_memory().unwrap();
210 conn.execute_batch("CREATE TABLE widgets (id INTEGER);")
211 .unwrap();
212 assert!(table_exists(&conn, "widgets").unwrap());
213 }
214
215 #[test]
216 fn table_exists_false_for_missing_table() {
217 let conn = Connection::open_in_memory().unwrap();
218 assert!(!table_exists(&conn, "widgets").unwrap());
219 }
220
221 #[test]
222 fn open_wal_sets_journal_mode() {
223 let dir = tempdir().unwrap();
224 let db_path = dir.path().join("test.db");
225 let conn = open_wal(&db_path).unwrap();
226 let mode: String = conn
227 .query_row("PRAGMA journal_mode", [], |r| r.get(0))
228 .unwrap();
229 assert_eq!(mode.to_lowercase(), "wal");
230 }
231
232 #[test]
233 fn open_wal_is_idempotent_across_repeated_opens() {
234 let dir = tempdir().unwrap();
235 let db_path = dir.path().join("test.db");
236 open_wal(&db_path).unwrap();
237 let conn = open_wal(&db_path).unwrap();
240 let mode: String = conn
241 .query_row("PRAGMA journal_mode", [], |r| r.get(0))
242 .unwrap();
243 assert_eq!(mode.to_lowercase(), "wal");
244 }
245
246 fn db_with_rows(rows: &[(&str, Option<&str>)]) -> Connection {
247 let conn = Connection::open_in_memory().unwrap();
248 conn.execute_batch(
249 "CREATE TABLE file_hashes (path TEXT PRIMARY KEY, hash TEXT, ext TEXT, mime TEXT);",
250 )
251 .unwrap();
252 for (path, mime) in rows {
253 conn.execute(
254 "INSERT INTO file_hashes (path, hash, ext, mime) VALUES (?1, 'h', 'jpg', ?2)",
255 rusqlite::params![path, mime],
256 )
257 .unwrap();
258 }
259 conn
260 }
261
262 #[test]
263 fn paths_with_known_mime_excludes_null_rows() {
264 let conn = db_with_rows(&[("/done.jpg", Some("image/jpeg")), ("/todo.jpg", None)]);
265 let set = paths_with_known_mime(&conn).unwrap();
266 assert!(set.contains("/done.jpg"));
267 assert!(
268 !set.contains("/todo.jpg"),
269 "NULL means never scanned, so it must be retried"
270 );
271 }
272
273 #[test]
274 fn paths_with_known_mime_includes_the_sentinel() {
275 let conn = db_with_rows(&[("/weird.jpg", Some(crate::mime_probe::UNKNOWN_MIME))]);
278 assert!(paths_with_known_mime(&conn).unwrap().contains("/weird.jpg"));
279 }
280
281 #[test]
282 fn paths_with_known_mime_on_a_table_that_does_not_exist_is_empty_not_an_error() {
283 let conn = Connection::open_in_memory().unwrap();
285 assert!(paths_with_known_mime(&conn).unwrap().is_empty());
286 }
287}