1use crate::error::{Error, Result};
6use crate::types::*;
7use rusqlite::Connection;
8use std::collections::HashMap;
9
10pub fn faces_list(conn: &Connection) -> Result<FacesData> {
12 let mut people: HashMap<String, PersonData> = HashMap::new();
13 {
14 let mut stmt = conn.prepare(
15 "SELECT id, hash, person_label FROM faces \
16 WHERE confirmed = 1 AND person_label IS NOT NULL \
17 ORDER BY person_label, is_primary DESC, id ASC",
18 )?;
19 let rows = stmt.query_map([], |r| {
20 Ok((
21 r.get::<_, i64>(0)?,
22 r.get::<_, String>(1)?,
23 r.get::<_, String>(2)?,
24 ))
25 })?;
26 for row in rows {
27 let (id, hash, label) = row?;
28 let person = people.entry(label.clone()).or_insert(PersonData {
29 label: label.clone(),
30 face_ids: vec![],
31 representative_id: id,
32 hashes: vec![],
33 });
34 person.face_ids.push(id);
35 if !person.hashes.contains(&hash) {
36 person.hashes.push(hash);
37 }
38 }
39 }
40
41 let mut cluster_map: HashMap<i64, ClusterData> = HashMap::new();
42 {
43 let mut stmt = conn.prepare(
44 "SELECT id, hash, cluster_id FROM faces \
45 WHERE cluster_id IS NOT NULL AND (confirmed = 0 OR person_label IS NULL) \
46 ORDER BY cluster_id, id",
47 )?;
48 let rows = stmt.query_map([], |r| {
49 Ok((
50 r.get::<_, i64>(0)?,
51 r.get::<_, String>(1)?,
52 r.get::<_, i64>(2)?,
53 ))
54 })?;
55 for row in rows {
56 let (id, hash, cid) = row?;
57 let cluster = cluster_map.entry(cid).or_insert(ClusterData {
58 cluster_id: cid,
59 face_ids: vec![],
60 hashes: vec![],
61 });
62 cluster.face_ids.push(id);
63 if !cluster.hashes.contains(&hash) {
64 cluster.hashes.push(hash);
65 }
66 }
67 }
68
69 let mut singletons: Vec<SingletonData> = vec![];
70 {
71 let mut stmt = conn.prepare(
72 "SELECT id, hash FROM faces \
73 WHERE cluster_id IS NULL AND (confirmed = 0 OR person_label IS NULL) \
74 ORDER BY id",
75 )?;
76 let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
77 for row in rows {
78 let (id, hash) = row?;
79 singletons.push(SingletonData { face_id: id, hash });
80 }
81 }
82
83 let mut people: Vec<PersonData> = people.into_values().collect();
95 people.sort_by(|a, b| a.label.to_lowercase().cmp(&b.label.to_lowercase()));
96 let mut clusters: Vec<ClusterData> = cluster_map.into_values().collect();
97 clusters.sort_by(|a, b| {
98 b.face_ids
99 .len()
100 .cmp(&a.face_ids.len())
101 .then(a.cluster_id.cmp(&b.cluster_id))
102 });
103
104 Ok(FacesData {
105 people,
106 clusters,
107 singletons,
108 })
109}
110
111pub fn cluster_detail(conn: &Connection, cluster_id: i64) -> Result<ClusterDetail> {
113 let mut stmt = conn.prepare(
114 "SELECT f.id, f.hash, fh.path FROM faces f \
115 JOIN file_hashes fh ON f.hash = fh.hash \
116 WHERE f.cluster_id = ?1 ORDER BY f.id",
117 )?;
118 let faces = stmt
119 .query_map([cluster_id], |r| {
120 Ok(ClusterFaceData {
121 face_id: r.get(0)?,
122 hash: r.get(1)?,
123 path: r.get(2)?,
124 })
125 })?
126 .collect::<rusqlite::Result<Vec<_>>>()?;
127 Ok(ClusterDetail { cluster_id, faces })
128}
129
130pub fn person_detail(conn: &Connection, name: &str) -> Result<PersonDetail> {
132 let mut stmt = conn.prepare(
133 "SELECT f.id, f.hash, fh.path, f.is_primary FROM faces f \
134 JOIN file_hashes fh ON f.hash = fh.hash \
135 WHERE f.person_label = ?1 AND f.confirmed = 1 \
136 ORDER BY f.is_primary DESC, f.id",
137 )?;
138 let faces = stmt
139 .query_map([name], |r| {
140 Ok(PersonFaceData {
141 face_id: r.get(0)?,
142 hash: r.get(1)?,
143 path: r.get(2)?,
144 is_primary: r.get::<_, i64>(3)? != 0,
145 })
146 })?
147 .collect::<rusqlite::Result<Vec<_>>>()?;
148 Ok(PersonDetail {
149 label: name.to_string(),
150 faces,
151 })
152}
153
154pub fn search_person(conn: &Connection, name: &str) -> Result<Vec<String>> {
157 Ok(videre_core::person_search::search_by_person(
158 conn, name, None,
159 )?)
160}
161
162pub fn assign(conn: &Connection, face_ids: &[i64], person_label: &str) -> Result<()> {
165 let label = crate::label::sanitize_person_label(person_label).ok_or(Error::Invalid)?;
166 for id in face_ids {
167 conn.execute(
168 "UPDATE faces SET person_label = ?1, confirmed = 1 WHERE id = ?2",
169 rusqlite::params![label, id],
170 )?;
171 }
172 Ok(())
173}
174
175pub fn new_person(conn: &Connection, face_ids: &[i64], label: &str) -> Result<()> {
179 assign(conn, face_ids, label)
180}
181
182pub fn remove_face(conn: &Connection, face_id: i64) -> Result<()> {
184 conn.execute(
185 "UPDATE faces SET cluster_id = NULL, person_label = NULL, confirmed = 0, is_primary = 0 WHERE id = ?1",
186 [face_id],
187 )?;
188 Ok(())
189}
190
191pub fn dissolve_cluster(conn: &Connection, cluster_id: i64) -> Result<()> {
193 conn.execute(
194 "UPDATE faces SET cluster_id = NULL WHERE cluster_id = ?1",
195 [cluster_id],
196 )?;
197 Ok(())
198}
199
200pub fn delete_person(conn: &Connection, label: &str) -> Result<()> {
204 conn.execute(
205 "UPDATE faces SET person_label = NULL, confirmed = 0, is_primary = 0 WHERE person_label = ?1",
206 rusqlite::params![label],
207 )?;
208 Ok(())
209}
210
211pub fn set_primary(conn: &Connection, face_id: i64, person_label: &str) -> Result<()> {
216 conn.execute_batch("BEGIN")?;
217 let result = (|| -> rusqlite::Result<()> {
218 conn.execute(
219 "UPDATE faces SET is_primary = 0 WHERE person_label = ?1",
220 rusqlite::params![person_label],
221 )?;
222 conn.execute(
223 "UPDATE faces SET is_primary = 1, confirmed = 1, person_label = ?1 WHERE id = ?2 AND person_label = ?1",
224 rusqlite::params![person_label, face_id],
225 )?;
226 Ok(())
227 })();
228 match result {
229 Ok(()) => {
230 conn.execute_batch("COMMIT")?;
231 Ok(())
232 }
233 Err(e) => {
234 let _ = conn.execute_batch("ROLLBACK");
235 Err(Error::Db(e))
236 }
237 }
238}
239
240pub fn rename_person(conn: &Connection, old_label: &str, new_label: &str) -> Result<()> {
244 let sanitized = crate::label::sanitize_person_label(new_label).ok_or(Error::Invalid)?;
245
246 let old_count: i64 = conn
247 .query_row(
248 "SELECT COUNT(*) FROM faces WHERE person_label = ?1",
249 rusqlite::params![old_label],
250 |row| row.get(0),
251 )
252 .unwrap_or(0);
253 if old_count == 0 {
254 return Err(Error::NotFound);
255 }
256
257 let collision_count: i64 = conn
258 .query_row(
259 "SELECT COUNT(*) FROM faces WHERE person_label = ?1",
260 rusqlite::params![sanitized],
261 |row| row.get(0),
262 )
263 .unwrap_or(0);
264 if collision_count > 0 && sanitized != old_label {
265 return Err(Error::Conflict);
266 }
267
268 conn.execute(
269 "UPDATE faces SET person_label = ?1 WHERE person_label = ?2",
270 rusqlite::params![sanitized, old_label],
271 )?;
272 Ok(())
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278
279 pub(super) fn seed() -> Connection {
286 let conn = Connection::open_in_memory().unwrap();
287 videre_core::face_db::create_faces_table(&conn).unwrap();
288 conn.execute_batch(
289 "CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);
290 INSERT INTO file_hashes VALUES ('h1','/p/1.jpg'),('h2','/p/2.jpg'),
291 ('h3','/p/3.jpg'),('h4','/p/4.jpg'),('h5','/p/5.jpg');
292 INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
293 (1,'h1','0,0,9,9',X'0000',NULL,'Alice',1,1),
294 (2,'h2','0,0,9,9',X'0000',NULL,'Alice',1,0),
295 (3,'h3','0,0,9,9',X'0000',7,NULL,0,0),
296 (4,'h4','0,0,9,9',X'0000',7,NULL,0,0),
297 (5,'h5','0,0,9,9',X'0000',NULL,NULL,0,0);",
298 )
299 .unwrap();
300 videre_core::db::ensure_file_hashes_columns(&conn);
301 conn
302 }
303
304 #[test]
305 fn the_list_comes_back_in_the_same_order_every_time() {
306 let conn = seed();
313 conn.execute_batch(
317 "INSERT INTO file_hashes (hash, path) VALUES ('h6','/p/6.jpg'),('h7','/p/7.jpg'),
320 ('h8','/p/8.jpg'),('h9','/p/9.jpg'),('h10','/p/10.jpg');
321 INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
322 (6,'h6','0,0,9,9',X'0000',9,NULL,0,0),
323 (7,'h7','0,0,9,9',X'0000',9,NULL,0,0),
324 (8,'h8','0,0,9,9',X'0000',9,NULL,0,0),
325 (9,'h9','0,0,9,9',X'0000',3,NULL,0,0),
326 (10,'h10','0,0,9,9',X'0000',NULL,'Bob',1,0);",
327 )
328 .unwrap();
329
330 let a = faces_list(&conn).unwrap();
333 let b = faces_list(&conn).unwrap();
334
335 let ids = |f: &FacesData| -> Vec<i64> { f.clusters.iter().map(|c| c.cluster_id).collect() };
336 let names =
337 |f: &FacesData| -> Vec<String> { f.people.iter().map(|p| p.label.clone()).collect() };
338 assert!(ids(&a).len() >= 3, "fixture must have several clusters");
339 assert_eq!(
340 ids(&a),
341 ids(&b),
342 "cluster order must not change between calls"
343 );
344 assert_eq!(
345 names(&a),
346 names(&b),
347 "people order must not change between calls"
348 );
349
350 let sizes: Vec<usize> = a.clusters.iter().map(|c| c.face_ids.len()).collect();
353 let mut want = sizes.clone();
354 want.sort_unstable_by(|x, y| y.cmp(x));
355 assert_eq!(
356 sizes, want,
357 "clusters must be ordered largest first, got {sizes:?}"
358 );
359 }
360
361 #[test]
362 fn faces_list_splits_people_clusters_singletons() {
363 let conn = seed();
364 let d = faces_list(&conn).unwrap();
365 assert_eq!(d.people.len(), 1);
366 assert_eq!(d.people[0].label, "Alice");
367 assert_eq!(
368 d.people[0].representative_id, 1,
369 "primary face is representative"
370 );
371 assert_eq!(d.clusters.len(), 1);
372 assert_eq!(d.clusters[0].cluster_id, 7);
373 assert_eq!(d.clusters[0].face_ids, vec![3, 4]);
374 assert_eq!(d.singletons.len(), 1);
375 assert_eq!(d.singletons[0].face_id, 5);
376 }
377
378 #[test]
379 fn person_detail_marks_primary() {
380 let conn = seed();
381 let p = person_detail(&conn, "Alice").unwrap();
382 assert_eq!(p.faces.len(), 2);
383 assert!(p.faces[0].is_primary, "primary sorts first and is flagged");
384 assert!(!p.faces[1].is_primary);
385 }
386
387 #[test]
388 fn cluster_detail_lists_faces() {
389 let conn = seed();
390 let c = cluster_detail(&conn, 7).unwrap();
391 assert_eq!(c.cluster_id, 7);
392 assert_eq!(
393 c.faces.iter().map(|f| f.face_id).collect::<Vec<_>>(),
394 vec![3, 4]
395 );
396 }
397
398 #[test]
399 fn assign_labels_and_confirms() {
400 let conn = seed();
401 assign(&conn, &[3, 4], "Bob").unwrap();
402 let p = person_detail(&conn, "Bob").unwrap();
403 assert_eq!(p.faces.len(), 2, "both faces now confirmed under Bob");
404 }
405
406 #[test]
407 fn assign_rejects_empty_label() {
408 let conn = seed();
409 assert!(matches!(assign(&conn, &[3], " "), Err(Error::Invalid)));
410 }
411
412 #[test]
413 fn remove_face_unassigns_everything() {
414 let conn = seed();
415 remove_face(&conn, 1).unwrap();
416 let (cid, label, confirmed, prim): (Option<i64>, Option<String>, i64, i64) = conn
417 .query_row(
418 "SELECT cluster_id, person_label, confirmed, is_primary FROM faces WHERE id=1",
419 [],
420 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
421 )
422 .unwrap();
423 assert_eq!((cid, label, confirmed, prim), (None, None, 0, 0));
424 }
425
426 #[test]
427 fn dissolve_cluster_nulls_cluster_id() {
428 let conn = seed();
429 dissolve_cluster(&conn, 7).unwrap();
430 assert_eq!(faces_list(&conn).unwrap().clusters.len(), 0);
431 assert_eq!(
432 faces_list(&conn).unwrap().singletons.len(),
433 3,
434 "3,4 join 5 as singletons"
435 );
436 }
437
438 #[test]
439 fn delete_person_unassigns_without_touching_cluster() {
440 let conn = seed();
441 conn.execute("UPDATE faces SET cluster_id = 42 WHERE id = 1", [])
445 .unwrap();
446 delete_person(&conn, "Alice").unwrap();
447 assert_eq!(faces_list(&conn).unwrap().people.len(), 0, "Alice is gone");
448 let (cid, label, confirmed): (Option<i64>, Option<String>, i64) = conn
449 .query_row(
450 "SELECT cluster_id, person_label, confirmed FROM faces WHERE id = 1",
451 [],
452 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
453 )
454 .unwrap();
455 assert_eq!(cid, Some(42), "cluster_id must be preserved");
456 assert_eq!(label, None, "person_label cleared");
457 assert_eq!(confirmed, 0, "confirmed cleared");
458 }
459
460 #[test]
461 fn set_primary_is_exclusive_per_person() {
462 let conn = seed();
463 set_primary(&conn, 2, "Alice").unwrap();
464 let primaries: Vec<i64> = {
465 let mut s = conn
466 .prepare("SELECT id FROM faces WHERE person_label='Alice' AND is_primary=1")
467 .unwrap();
468 s.query_map([], |r| r.get(0))
469 .unwrap()
470 .collect::<rusqlite::Result<_>>()
471 .unwrap()
472 };
473 assert_eq!(primaries, vec![2], "exactly one primary, now face 2");
474 }
475
476 #[test]
477 fn rename_missing_person_is_not_found() {
478 let conn = seed();
479 assert!(matches!(
480 rename_person(&conn, "Nobody", "X"),
481 Err(Error::NotFound)
482 ));
483 }
484
485 #[test]
486 fn rename_onto_existing_person_conflicts() {
487 let conn = seed();
488 assign(&conn, &[3], "Bob").unwrap(); assert!(matches!(
490 rename_person(&conn, "Alice", "Bob"),
491 Err(Error::Conflict)
492 ));
493 }
494
495 #[test]
496 fn rename_succeeds() {
497 let conn = seed();
498 rename_person(&conn, "Alice", "Alicia").unwrap();
499 assert_eq!(person_detail(&conn, "Alicia").unwrap().faces.len(), 2);
500 assert_eq!(person_detail(&conn, "Alice").unwrap().faces.len(), 0);
501 }
502
503 #[test]
504 fn rename_to_empty_label_is_invalid() {
505 let conn = seed();
506 assert!(matches!(
507 rename_person(&conn, "Alice", " "),
508 Err(Error::Invalid)
509 ));
510 }
511}