Skip to main content

smriti/services/
reindexer.rs

1//! Incremental re-indexing service.
2
3use std::collections::{HashMap, HashSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use rusqlite::{params, Connection, Result as SqliteResult};
8use walkdir::WalkDir;
9
10use crate::db::face_repo::FaceRepo;
11use crate::services::exclusions::ExclusionMatcher;
12use crate::services::path_util::safe_join_relative;
13use crate::services::scanner::calculate_fast_hash;
14
15#[derive(Debug, Default, Clone)]
16pub struct IndexChanges {
17    pub added: Vec<PathBuf>,
18    pub removed: Vec<(i64, PathBuf)>,
19    pub moved: Vec<(i64, PathBuf, PathBuf)>,
20    pub modified: Vec<(i64, PathBuf)>,
21}
22
23impl IndexChanges {
24    pub fn is_empty(&self) -> bool {
25        self.added.is_empty()
26            && self.removed.is_empty()
27            && self.moved.is_empty()
28            && self.modified.is_empty()
29    }
30}
31
32#[derive(Debug, Default, Clone)]
33pub struct ApplyResult {
34    pub new_files: usize,
35    pub moves_applied: usize,
36    pub removals_applied: usize,
37    pub updates_applied: usize,
38}
39
40pub struct Reindexer {
41    supported_extensions: HashSet<String>,
42    skip_patterns: Vec<String>,
43    scan_hidden_folders: bool,
44}
45
46impl Default for Reindexer {
47    fn default() -> Self {
48        Self::new()
49    }
50}
51
52impl Reindexer {
53    pub fn new() -> Self {
54        Self::new_with_options(false)
55    }
56
57    pub fn new_with_options(scan_hidden_folders: bool) -> Self {
58        // Keep this list in sync with scanner.rs's SUPPORTED_EXTENSIONS.
59        // The reindexer walks the same files as a fresh scan, so they
60        // need identical allowlists or the reindex misses formats the
61        // scanner indexes.
62        let mut supported_extensions = HashSet::new();
63        for ext in [
64            // Stills
65            "jpg", "jpeg", "png", "heic", "heif", "webp", "tif", "tiff", "avif", "bmp", "gif",
66            // RAWs (TIFF-based, embedded-JPEG path)
67            "nef", "cr2", "cr3", "arw", "dng", "orf", "rw2", "pef", "rwl", "srw", "raf",
68            // Videos
69            "mp4", "m4v", "mov", "webm", "mkv", "avi", "3gp", "3g2", "mts", "m2ts",
70        ] {
71            supported_extensions.insert(ext.to_string());
72        }
73
74        let skip_patterns = vec![
75            "System Volume Information".to_string(),
76            "$RECYCLE.BIN".to_string(),
77            ".Trash".to_string(),
78            ".photovault".to_string(),
79        ];
80
81        Self {
82            supported_extensions,
83            skip_patterns,
84            scan_hidden_folders,
85        }
86    }
87
88    pub fn detect_changes(
89        &self,
90        conn: &Connection,
91        drive_root: &Path,
92    ) -> SqliteResult<IndexChanges> {
93        let mut changes = IndexChanges::default();
94        let exclusions = ExclusionMatcher::from_db(conn)?;
95
96        // Use a temp table instead of loading everything into a HashMap.
97        // This keeps memory usage O(1) in Rust regardless of library size.
98        conn.execute_batch(
99            "CREATE TEMP TABLE IF NOT EXISTS found_files (
100                path TEXT PRIMARY KEY,
101                mtime INTEGER
102            );
103            DELETE FROM found_files;",
104        )?;
105
106        let mut insert_stmt =
107            conn.prepare("INSERT OR IGNORE INTO found_files (path, mtime) VALUES (?1, ?2)")?;
108
109        for entry in WalkDir::new(drive_root)
110            .follow_links(false)
111            .into_iter()
112            .filter_entry(|e| !self.should_skip(drive_root, e.path(), &exclusions))
113        {
114            let entry = match entry {
115                Ok(e) => e,
116                Err(_) => continue,
117            };
118
119            if !entry.file_type().is_file() {
120                continue;
121            }
122
123            let ext = entry
124                .path()
125                .extension()
126                .and_then(|e| e.to_str())
127                .map(|e| e.to_lowercase())
128                .unwrap_or_default();
129
130            if !self.supported_extensions.contains(&ext) {
131                continue;
132            }
133
134            // Forward-slash-normalized so a Windows write and a Linux
135            // write of the same drive produce identical strings.
136            let relative_path = match entry.path().strip_prefix(drive_root).ok() {
137                Some(p) => crate::services::path_util::relative_path_for_storage(p),
138                None => continue,
139            };
140
141            let metadata = match fs::metadata(entry.path()) {
142                Ok(metadata) => metadata,
143                Err(_) => continue,
144            };
145            let mtime = metadata
146                .modified()
147                .ok()
148                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
149                .map(|d| d.as_secs() as i64);
150
151            insert_stmt.execute(params![relative_path, mtime])?;
152        }
153        drop(insert_stmt);
154
155        // Added files: on disk but not in DB
156        {
157            let mut stmt = conn.prepare(
158                "SELECT f.path FROM temp.found_files f
159                 LEFT JOIN photos p ON p.file_path = f.path AND p.is_trashed = FALSE
160                 WHERE p.id IS NULL",
161            )?;
162            let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
163            for row in rows {
164                let path = row?;
165                if let Ok(full) = safe_join_relative(drive_root, &path) {
166                    changes.added.push(full);
167                }
168            }
169        }
170
171        // Modified files: matching path but newer mtime
172        {
173            let mut stmt = conn.prepare(
174                "SELECT p.id, p.file_path FROM photos p
175                 INNER JOIN temp.found_files f ON f.path = p.file_path
176                 WHERE p.is_trashed = FALSE
177                   AND f.mtime IS NOT NULL
178                   AND (p.file_mtime IS NULL OR f.mtime > p.file_mtime)",
179            )?;
180            let rows = stmt.query_map([], |row| {
181                Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
182            })?;
183            for row in rows {
184                let (id, path) = row?;
185                if let Ok(full) = safe_join_relative(drive_root, &path) {
186                    changes.modified.push((id, full));
187                }
188            }
189        }
190
191        // Removed files: in DB but not on disk
192        // Also check for moves (same hash, different path)
193        {
194            let mut stmt = conn.prepare(
195                "SELECT p.id, p.file_path, p.file_hash FROM photos p
196                 LEFT JOIN temp.found_files f ON f.path = p.file_path
197                 WHERE p.is_trashed = FALSE AND f.path IS NULL",
198            )?;
199            let rows = stmt.query_map([], |row| {
200                Ok((
201                    row.get::<_, i64>(0)?,
202                    row.get::<_, String>(1)?,
203                    row.get::<_, String>(2)?,
204                ))
205            })?;
206
207            let mut missing: Vec<(i64, String, String)> = Vec::new();
208            for row in rows {
209                missing.push(row?);
210            }
211
212            // Pre-hash all candidate (added) files exactly once into a hash → path map.
213            // Without this, we'd re-hash every candidate for every missing file (N×M).
214            // Hashes computed here use the same fast hash as the scanner so
215            // the result matches the stored `photos.file_hash` column.
216            let candidate_paths: Vec<String> = changes
217                .added
218                .iter()
219                .filter_map(|p| p.strip_prefix(drive_root).ok())
220                .map(crate::services::path_util::relative_path_for_storage)
221                .collect();
222
223            let mut hash_to_candidate: HashMap<String, String> = HashMap::new();
224            for relative in &candidate_paths {
225                let Ok(full) = safe_join_relative(drive_root, relative) else {
226                    continue;
227                };
228                let Ok(meta) = fs::metadata(&full) else {
229                    continue;
230                };
231                let mtime = meta
232                    .modified()
233                    .ok()
234                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
235                    .map(|d| d.as_secs() as i64);
236                if let Ok(hash) = calculate_fast_hash(&full, meta.len(), mtime) {
237                    hash_to_candidate
238                        .entry(hash)
239                        .or_insert_with(|| relative.clone());
240                }
241            }
242
243            let mut consumed_candidates: HashSet<String> = HashSet::new();
244            for (id, old_path, hash) in &missing {
245                match hash_to_candidate.get(hash) {
246                    Some(new_path) if !consumed_candidates.contains(new_path) => {
247                        consumed_candidates.insert(new_path.clone());
248                        changes
249                            .moved
250                            .push((*id, PathBuf::from(old_path), PathBuf::from(new_path)));
251                    }
252                    _ => {
253                        changes.removed.push((*id, PathBuf::from(old_path)));
254                    }
255                }
256            }
257
258            // Files matched as moves are no longer "added"; drop them from the added list.
259            if !consumed_candidates.is_empty() {
260                changes.added.retain(|p| {
261                    let relative = p
262                        .strip_prefix(drive_root)
263                        .ok()
264                        .map(crate::services::path_util::relative_path_for_storage)
265                        .unwrap_or_default();
266                    !consumed_candidates.contains(&relative)
267                });
268            }
269        }
270
271        conn.execute("DROP TABLE IF EXISTS temp.found_files", [])?;
272
273        Ok(changes)
274    }
275
276    pub fn apply_changes(
277        &self,
278        conn: &Connection,
279        changes: &IndexChanges,
280    ) -> SqliteResult<ApplyResult> {
281        let mut result = ApplyResult::default();
282        let tx = conn.unchecked_transaction()?;
283
284        for (photo_id, _old_path, new_path) in &changes.moved {
285            let new_relative = crate::services::path_util::relative_path_for_storage(new_path);
286            let new_file_name = new_path
287                .file_name()
288                .and_then(|n| n.to_str())
289                .unwrap_or(&new_relative)
290                .to_string();
291            tx.execute(
292                "UPDATE photos
293                    SET file_path = ?1,
294                        file_name = ?2,
295                        updated_at = CURRENT_TIMESTAMP
296                  WHERE id = ?3",
297                params![new_relative, new_file_name, photo_id],
298            )?;
299            result.moves_applied += 1;
300        }
301
302        for (photo_id, path) in &changes.removed {
303            let original_path = path.to_string_lossy().to_string();
304            tx.execute(
305                "INSERT OR IGNORE INTO trash (photo_id, original_path) VALUES (?1, ?2)",
306                params![photo_id, original_path],
307            )?;
308            tx.execute(
309                "UPDATE photos SET is_trashed = TRUE, trashed_at = CURRENT_TIMESTAMP WHERE id = ?1",
310                params![photo_id],
311            )?;
312            result.removals_applied += 1;
313        }
314
315        for (photo_id, path) in &changes.modified {
316            clear_face_derivatives_for_photo(&tx, *photo_id)?;
317            let file_facts = fs::metadata(path).ok().and_then(|meta| {
318                let size = i64::try_from(meta.len()).ok()?;
319                let mtime = meta
320                    .modified()
321                    .ok()
322                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
323                    .map(|d| d.as_secs() as i64);
324                let hash = calculate_fast_hash(path, meta.len(), mtime).ok()?;
325                Some((size, mtime, hash))
326            });
327            let (file_size, file_mtime, file_hash) = match file_facts {
328                Some((size, mtime, hash)) => (Some(size), mtime, Some(hash)),
329                None => (None, None, None),
330            };
331            tx.execute(
332                "UPDATE photos
333                    SET file_size = COALESCE(?2, file_size),
334                        file_mtime = COALESCE(?3, file_mtime),
335                        file_hash = COALESCE(?4, file_hash),
336                        metadata_extracted = FALSE,
337                        thumbnailed = FALSE,
338                        thumbnail_path = NULL,
339                        faces_processed = FALSE,
340                        updated_at = CURRENT_TIMESTAMP
341                  WHERE id = ?1",
342                params![photo_id, file_size, file_mtime, file_hash],
343            )?;
344            result.updates_applied += 1;
345        }
346
347        tx.commit()?;
348        if !changes.modified.is_empty() {
349            FaceRepo::new(conn).normalize_cluster_stats()?;
350        }
351        result.new_files = changes.added.len();
352        Ok(result)
353    }
354
355    fn should_skip(&self, drive_root: &Path, path: &Path, exclusions: &ExclusionMatcher) -> bool {
356        let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
357        if !self.scan_hidden_folders && name.starts_with('.') {
358            return true;
359        }
360        self.skip_patterns.iter().any(|p| name.starts_with(p))
361            || exclusions.should_skip_path(drive_root, path)
362    }
363}
364
365fn clear_face_derivatives_for_photo(
366    tx: &rusqlite::Transaction<'_>,
367    photo_id: i64,
368) -> SqliteResult<()> {
369    let face_ids = {
370        let mut stmt = tx.prepare("SELECT id FROM faces WHERE photo_id = ?1")?;
371        let rows = stmt.query_map(params![photo_id], |row| row.get::<_, i64>(0))?;
372        rows.collect::<SqliteResult<Vec<_>>>()?
373    };
374
375    for face_id in face_ids {
376        tx.execute(
377            "DELETE FROM face_review_queue WHERE face_id = ?1",
378            params![face_id],
379        )?;
380        tx.execute(
381            "DELETE FROM face_negatives WHERE face_id = ?1",
382            params![face_id],
383        )?;
384        tx.execute(
385            "DELETE FROM person_gallery_embeddings WHERE face_id = ?1",
386            params![face_id],
387        )?;
388        tx.execute("DELETE FROM faces WHERE id = ?1", params![face_id])?;
389    }
390
391    tx.execute(
392        "DELETE FROM photo_inferred_identities WHERE photo_id = ?1 OR source_photo_id = ?1",
393        params![photo_id],
394    )?;
395
396    Ok(())
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use crate::db::create_schema;
403    use crate::services::scanner::calculate_hash;
404    use std::io::Write;
405    use tempfile::tempdir;
406
407    /// Two files sharing a 64 KB prefix but differing afterwards must hash
408    /// differently. The previous quick_hash (64 KB only) collided here, which
409    /// caused the reindexer to misidentify distinct files as moves of each
410    /// other.
411    #[test]
412    fn full_hash_distinguishes_files_with_shared_prefix() {
413        let temp = tempdir().unwrap();
414        let prefix = vec![0xABu8; 65_536];
415
416        let path_a = temp.path().join("a.bin");
417        let mut f = fs::File::create(&path_a).unwrap();
418        f.write_all(&prefix).unwrap();
419        f.write_all(&[0x01u8; 4096]).unwrap();
420        drop(f);
421
422        let path_b = temp.path().join("b.bin");
423        let mut f = fs::File::create(&path_b).unwrap();
424        f.write_all(&prefix).unwrap();
425        f.write_all(&[0x02u8; 4096]).unwrap();
426        drop(f);
427
428        let hash_a = calculate_hash(&path_a).unwrap();
429        let hash_b = calculate_hash(&path_b).unwrap();
430        assert_ne!(
431            hash_a, hash_b,
432            "files differing past the first 64 KB must produce different hashes"
433        );
434    }
435
436    #[test]
437    fn reindexer_excludes_folder_descendants() {
438        let temp = tempdir().unwrap();
439        let excluded = temp.path().join("Trips").join("Goa");
440        let similar = temp.path().join("Trips").join("Goa2");
441        fs::create_dir_all(&excluded).unwrap();
442        fs::create_dir_all(&similar).unwrap();
443
444        let matcher = ExclusionMatcher::new(vec!["Trips/Goa".into()]);
445        let reindexer = Reindexer::new();
446
447        assert!(reindexer.should_skip(temp.path(), &excluded, &matcher));
448        assert!(!reindexer.should_skip(temp.path(), &similar, &matcher));
449    }
450
451    #[test]
452    fn apply_removed_files_creates_trash_rows() {
453        let conn = Connection::open_in_memory().unwrap();
454        create_schema(&conn).unwrap();
455        conn.execute(
456            "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, is_trashed)
457             VALUES (1, 'missing.jpg', 'missing.jpg', 'hash', 8, FALSE)",
458            [],
459        )
460        .unwrap();
461
462        let changes = IndexChanges {
463            removed: vec![(1, PathBuf::from("missing.jpg"))],
464            ..IndexChanges::default()
465        };
466        let result = Reindexer::new().apply_changes(&conn, &changes).unwrap();
467
468        assert_eq!(result.removals_applied, 1);
469        let is_trashed: bool = conn
470            .query_row("SELECT is_trashed FROM photos WHERE id = 1", [], |row| {
471                row.get(0)
472            })
473            .unwrap();
474        assert!(is_trashed);
475        let original_path: String = conn
476            .query_row(
477                "SELECT original_path FROM trash WHERE photo_id = 1",
478                [],
479                |row| row.get(0),
480            )
481            .unwrap();
482        assert_eq!(original_path, "missing.jpg");
483    }
484
485    #[test]
486    fn detect_changes_uses_file_mtime_for_modified_files() {
487        let temp = tempdir().unwrap();
488        let path = temp.path().join("photo.jpg");
489        fs::write(&path, vec![0xABu8; 12_000]).unwrap();
490        let metadata = fs::metadata(&path).unwrap();
491        let current_mtime = metadata
492            .modified()
493            .unwrap()
494            .duration_since(std::time::UNIX_EPOCH)
495            .unwrap()
496            .as_secs() as i64;
497
498        let conn = Connection::open_in_memory().unwrap();
499        create_schema(&conn).unwrap();
500        conn.execute(
501            "INSERT INTO photos (
502                id, file_path, file_name, file_hash, file_size, file_mtime,
503                metadata_extracted, thumbnailed, faces_processed, updated_at
504             ) VALUES (
505                1, 'photo.jpg', 'photo.jpg', 'hash', 12000, ?1,
506                TRUE, TRUE, TRUE, datetime('now', '+1 day')
507             )",
508            [current_mtime - 10],
509        )
510        .unwrap();
511
512        let changes = Reindexer::new_with_options(true)
513            .detect_changes(&conn, temp.path())
514            .unwrap();
515        assert_eq!(changes.modified.len(), 1);
516        assert_eq!(changes.modified[0].0, 1);
517    }
518
519    #[test]
520    fn detect_changes_identifies_moved_files() {
521        let temp = tempdir().unwrap();
522        let new_dir = temp.path().join("new");
523        fs::create_dir_all(&new_dir).unwrap();
524        let new_path = new_dir.join("photo.jpg");
525        fs::write(&new_path, vec![0xEFu8; 12_000]).unwrap();
526        let meta = fs::metadata(&new_path).unwrap();
527        let mtime = meta
528            .modified()
529            .ok()
530            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
531            .map(|d| d.as_secs() as i64);
532        let hash = calculate_fast_hash(&new_path, meta.len(), mtime).unwrap();
533
534        let conn = Connection::open_in_memory().unwrap();
535        create_schema(&conn).unwrap();
536        conn.execute(
537            "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, is_trashed)
538             VALUES (1, 'old/photo.jpg', 'photo.jpg', ?1, ?2, FALSE)",
539            params![hash, meta.len() as i64],
540        )
541        .unwrap();
542
543        let changes = Reindexer::new_with_options(true)
544            .detect_changes(&conn, temp.path())
545            .unwrap();
546
547        assert_eq!(changes.moved.len(), 1);
548        assert_eq!(changes.moved[0].0, 1);
549        assert_eq!(changes.moved[0].1, PathBuf::from("old/photo.jpg"));
550        assert_eq!(changes.moved[0].2, PathBuf::from("new/photo.jpg"));
551        assert!(changes.added.is_empty());
552        assert!(changes.removed.is_empty());
553    }
554
555    #[test]
556    fn apply_modified_files_resets_stale_processing_state() {
557        let temp = tempdir().unwrap();
558        let changed_path = temp.path().join("changed.jpg");
559        fs::write(&changed_path, vec![0xCDu8; 12_000]).unwrap();
560        let meta = fs::metadata(&changed_path).unwrap();
561        let mtime = meta
562            .modified()
563            .ok()
564            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
565            .map(|d| d.as_secs() as i64);
566        let expected_hash = calculate_fast_hash(&changed_path, meta.len(), mtime).unwrap();
567
568        let conn = Connection::open_in_memory().unwrap();
569        create_schema(&conn).unwrap();
570        conn.execute(
571            "INSERT INTO photos (
572                id, file_path, file_name, file_hash, file_size, thumbnail_path,
573                file_mtime,
574                metadata_extracted, thumbnailed, faces_processed
575             ) VALUES (
576                1, 'changed.jpg', 'changed.jpg', 'old-hash', 8, '.photovault/thumbnails/medium/v2/ha/hash.jpg',
577                1,
578                TRUE, TRUE, TRUE
579             )",
580            [],
581        )
582        .unwrap();
583        conn.execute(
584            "INSERT INTO photos (id, file_path, file_name, file_hash, file_size)
585             VALUES (2, 'other.jpg', 'other.jpg', 'other-hash', 8)",
586            [],
587        )
588        .unwrap();
589        conn.execute(
590            "INSERT INTO face_clusters (id, name, representative_face_id, face_count, photo_count)
591             VALUES (10, 'Stale', NULL, 1, 2), (20, 'Other', NULL, 0, 0)",
592            [],
593        )
594        .unwrap();
595        conn.execute(
596            "INSERT INTO faces (
597                id, photo_id, bbox_x, bbox_y, bbox_width, bbox_height,
598                embedding, cluster_id, confidence, user_confirmed
599             )
600             VALUES (100, 1, 0.1, 0.1, 0.2, 0.2, zeroblob(16), 10, 0.99, 0)",
601            [],
602        )
603        .unwrap();
604        conn.execute(
605            "UPDATE face_clusters SET representative_face_id = 100 WHERE id = 10",
606            [],
607        )
608        .unwrap();
609        conn.execute(
610            "INSERT INTO face_review_queue (face_id, candidate_cluster_id, score)
611             VALUES (100, 20, 0.75)",
612            [],
613        )
614        .unwrap();
615        conn.execute(
616            "INSERT INTO face_negatives (face_id, not_cluster_id) VALUES (100, 20)",
617            [],
618        )
619        .unwrap();
620        conn.execute(
621            "INSERT INTO person_gallery_embeddings (cluster_id, face_id, embedding)
622             VALUES (10, 100, zeroblob(16))",
623            [],
624        )
625        .unwrap();
626        conn.execute(
627            "INSERT INTO photo_inferred_identities (photo_id, cluster_id, source_photo_id, confidence)
628             VALUES (1, 10, 2, 0.5)",
629            [],
630        )
631        .unwrap();
632        conn.execute(
633            "INSERT INTO photo_inferred_identities (photo_id, cluster_id, source_photo_id, confidence)
634             VALUES (2, 10, 1, 0.5)",
635            [],
636        )
637        .unwrap();
638
639        let changes = IndexChanges {
640            modified: vec![(1, changed_path.clone())],
641            ..IndexChanges::default()
642        };
643        let result = Reindexer::new().apply_changes(&conn, &changes).unwrap();
644        assert_eq!(result.updates_applied, 1);
645
646        let row: (bool, bool, bool, Option<String>, i64, Option<i64>, String) = conn
647            .query_row(
648                "SELECT metadata_extracted, thumbnailed, faces_processed, thumbnail_path,
649                        file_size, file_mtime, file_hash
650                   FROM photos WHERE id = 1",
651                [],
652                |row| {
653                    Ok((
654                        row.get(0)?,
655                        row.get(1)?,
656                        row.get(2)?,
657                        row.get(3)?,
658                        row.get(4)?,
659                        row.get(5)?,
660                        row.get(6)?,
661                    ))
662                },
663            )
664            .unwrap();
665        assert_eq!(
666            row,
667            (
668                false,
669                false,
670                false,
671                None,
672                meta.len() as i64,
673                mtime,
674                expected_hash
675            )
676        );
677
678        for table in [
679            "faces",
680            "face_review_queue",
681            "face_negatives",
682            "person_gallery_embeddings",
683            "photo_inferred_identities",
684        ] {
685            let count: i64 = conn
686                .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| {
687                    row.get(0)
688                })
689                .unwrap();
690            assert_eq!(count, 0, "{table} should be cleared");
691        }
692        let cluster_count: i64 = conn
693            .query_row(
694                "SELECT COUNT(*) FROM face_clusters WHERE id = 10",
695                [],
696                |row| row.get(0),
697            )
698            .unwrap();
699        assert_eq!(cluster_count, 0, "empty stale cluster should be pruned");
700    }
701
702    #[test]
703    fn apply_moved_files_updates_file_name() {
704        let conn = Connection::open_in_memory().unwrap();
705        create_schema(&conn).unwrap();
706        conn.execute(
707            "INSERT INTO photos (id, file_path, file_name, file_hash, file_size)
708             VALUES (1, 'old/name.jpg', 'name.jpg', 'hash', 8)",
709            [],
710        )
711        .unwrap();
712
713        let changes = IndexChanges {
714            moved: vec![(
715                1,
716                PathBuf::from("old/name.jpg"),
717                PathBuf::from("new/renamed.jpg"),
718            )],
719            ..IndexChanges::default()
720        };
721        let result = Reindexer::new().apply_changes(&conn, &changes).unwrap();
722        assert_eq!(result.moves_applied, 1);
723
724        let row: (String, String) = conn
725            .query_row(
726                "SELECT file_path, file_name FROM photos WHERE id = 1",
727                [],
728                |row| Ok((row.get(0)?, row.get(1)?)),
729            )
730            .unwrap();
731        assert_eq!(row, ("new/renamed.jpg".into(), "renamed.jpg".into()));
732    }
733}