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 Ok(FacesData {
84 people: people.into_values().collect(),
85 clusters: cluster_map.into_values().collect(),
86 singletons,
87 })
88}
89
90pub fn cluster_detail(conn: &Connection, cluster_id: i64) -> Result<ClusterDetail> {
92 let mut stmt = conn.prepare(
93 "SELECT f.id, f.hash, fh.path FROM faces f \
94 JOIN file_hashes fh ON f.hash = fh.hash \
95 WHERE f.cluster_id = ?1 ORDER BY f.id",
96 )?;
97 let faces = stmt
98 .query_map([cluster_id], |r| {
99 Ok(ClusterFaceData {
100 face_id: r.get(0)?,
101 hash: r.get(1)?,
102 path: r.get(2)?,
103 })
104 })?
105 .collect::<rusqlite::Result<Vec<_>>>()?;
106 Ok(ClusterDetail { cluster_id, faces })
107}
108
109pub fn person_detail(conn: &Connection, name: &str) -> Result<PersonDetail> {
111 let mut stmt = conn.prepare(
112 "SELECT f.id, f.hash, fh.path, f.is_primary FROM faces f \
113 JOIN file_hashes fh ON f.hash = fh.hash \
114 WHERE f.person_label = ?1 AND f.confirmed = 1 \
115 ORDER BY f.is_primary DESC, f.id",
116 )?;
117 let faces = stmt
118 .query_map([name], |r| {
119 Ok(PersonFaceData {
120 face_id: r.get(0)?,
121 hash: r.get(1)?,
122 path: r.get(2)?,
123 is_primary: r.get::<_, i64>(3)? != 0,
124 })
125 })?
126 .collect::<rusqlite::Result<Vec<_>>>()?;
127 Ok(PersonDetail {
128 label: name.to_string(),
129 faces,
130 })
131}
132
133pub fn search_person(conn: &Connection, name: &str) -> Result<Vec<String>> {
136 Ok(videre_core::person_search::search_by_person(
137 conn, name, None,
138 )?)
139}
140
141pub fn assign(conn: &Connection, face_ids: &[i64], person_label: &str) -> Result<()> {
144 let label = crate::label::sanitize_person_label(person_label).ok_or(Error::Invalid)?;
145 for id in face_ids {
146 conn.execute(
147 "UPDATE faces SET person_label = ?1, confirmed = 1 WHERE id = ?2",
148 rusqlite::params![label, id],
149 )?;
150 }
151 Ok(())
152}
153
154pub fn new_person(conn: &Connection, face_ids: &[i64], label: &str) -> Result<()> {
158 assign(conn, face_ids, label)
159}
160
161pub fn remove_face(conn: &Connection, face_id: i64) -> Result<()> {
163 conn.execute(
164 "UPDATE faces SET cluster_id = NULL, person_label = NULL, confirmed = 0, is_primary = 0 WHERE id = ?1",
165 [face_id],
166 )?;
167 Ok(())
168}
169
170pub fn dissolve_cluster(conn: &Connection, cluster_id: i64) -> Result<()> {
172 conn.execute(
173 "UPDATE faces SET cluster_id = NULL WHERE cluster_id = ?1",
174 [cluster_id],
175 )?;
176 Ok(())
177}
178
179pub fn delete_person(conn: &Connection, label: &str) -> Result<()> {
183 conn.execute(
184 "UPDATE faces SET person_label = NULL, confirmed = 0, is_primary = 0 WHERE person_label = ?1",
185 rusqlite::params![label],
186 )?;
187 Ok(())
188}
189
190pub fn set_primary(conn: &Connection, face_id: i64, person_label: &str) -> Result<()> {
195 conn.execute_batch("BEGIN")?;
196 let result = (|| -> rusqlite::Result<()> {
197 conn.execute(
198 "UPDATE faces SET is_primary = 0 WHERE person_label = ?1",
199 rusqlite::params![person_label],
200 )?;
201 conn.execute(
202 "UPDATE faces SET is_primary = 1, confirmed = 1, person_label = ?1 WHERE id = ?2 AND person_label = ?1",
203 rusqlite::params![person_label, face_id],
204 )?;
205 Ok(())
206 })();
207 match result {
208 Ok(()) => {
209 conn.execute_batch("COMMIT")?;
210 Ok(())
211 }
212 Err(e) => {
213 let _ = conn.execute_batch("ROLLBACK");
214 Err(Error::Db(e))
215 }
216 }
217}
218
219pub fn rename_person(conn: &Connection, old_label: &str, new_label: &str) -> Result<()> {
223 let sanitized = crate::label::sanitize_person_label(new_label).ok_or(Error::Invalid)?;
224
225 let old_count: i64 = conn
226 .query_row(
227 "SELECT COUNT(*) FROM faces WHERE person_label = ?1",
228 rusqlite::params![old_label],
229 |row| row.get(0),
230 )
231 .unwrap_or(0);
232 if old_count == 0 {
233 return Err(Error::NotFound);
234 }
235
236 let collision_count: i64 = conn
237 .query_row(
238 "SELECT COUNT(*) FROM faces WHERE person_label = ?1",
239 rusqlite::params![sanitized],
240 |row| row.get(0),
241 )
242 .unwrap_or(0);
243 if collision_count > 0 && sanitized != old_label {
244 return Err(Error::Conflict);
245 }
246
247 conn.execute(
248 "UPDATE faces SET person_label = ?1 WHERE person_label = ?2",
249 rusqlite::params![sanitized, old_label],
250 )?;
251 Ok(())
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 pub(super) fn seed() -> Connection {
265 let conn = Connection::open_in_memory().unwrap();
266 videre_core::face_db::create_faces_table(&conn).unwrap();
267 conn.execute_batch(
268 "CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);
269 INSERT INTO file_hashes VALUES ('h1','/p/1.jpg'),('h2','/p/2.jpg'),
270 ('h3','/p/3.jpg'),('h4','/p/4.jpg'),('h5','/p/5.jpg');
271 INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
272 (1,'h1','0,0,9,9',X'0000',NULL,'Alice',1,1),
273 (2,'h2','0,0,9,9',X'0000',NULL,'Alice',1,0),
274 (3,'h3','0,0,9,9',X'0000',7,NULL,0,0),
275 (4,'h4','0,0,9,9',X'0000',7,NULL,0,0),
276 (5,'h5','0,0,9,9',X'0000',NULL,NULL,0,0);",
277 )
278 .unwrap();
279 videre_core::db::ensure_file_hashes_columns(&conn);
280 conn
281 }
282
283 #[test]
284 fn faces_list_splits_people_clusters_singletons() {
285 let conn = seed();
286 let d = faces_list(&conn).unwrap();
287 assert_eq!(d.people.len(), 1);
288 assert_eq!(d.people[0].label, "Alice");
289 assert_eq!(
290 d.people[0].representative_id, 1,
291 "primary face is representative"
292 );
293 assert_eq!(d.clusters.len(), 1);
294 assert_eq!(d.clusters[0].cluster_id, 7);
295 assert_eq!(d.clusters[0].face_ids, vec![3, 4]);
296 assert_eq!(d.singletons.len(), 1);
297 assert_eq!(d.singletons[0].face_id, 5);
298 }
299
300 #[test]
301 fn person_detail_marks_primary() {
302 let conn = seed();
303 let p = person_detail(&conn, "Alice").unwrap();
304 assert_eq!(p.faces.len(), 2);
305 assert!(p.faces[0].is_primary, "primary sorts first and is flagged");
306 assert!(!p.faces[1].is_primary);
307 }
308
309 #[test]
310 fn cluster_detail_lists_faces() {
311 let conn = seed();
312 let c = cluster_detail(&conn, 7).unwrap();
313 assert_eq!(c.cluster_id, 7);
314 assert_eq!(
315 c.faces.iter().map(|f| f.face_id).collect::<Vec<_>>(),
316 vec![3, 4]
317 );
318 }
319
320 #[test]
321 fn assign_labels_and_confirms() {
322 let conn = seed();
323 assign(&conn, &[3, 4], "Bob").unwrap();
324 let p = person_detail(&conn, "Bob").unwrap();
325 assert_eq!(p.faces.len(), 2, "both faces now confirmed under Bob");
326 }
327
328 #[test]
329 fn assign_rejects_empty_label() {
330 let conn = seed();
331 assert!(matches!(assign(&conn, &[3], " "), Err(Error::Invalid)));
332 }
333
334 #[test]
335 fn remove_face_unassigns_everything() {
336 let conn = seed();
337 remove_face(&conn, 1).unwrap();
338 let (cid, label, confirmed, prim): (Option<i64>, Option<String>, i64, i64) = conn
339 .query_row(
340 "SELECT cluster_id, person_label, confirmed, is_primary FROM faces WHERE id=1",
341 [],
342 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
343 )
344 .unwrap();
345 assert_eq!((cid, label, confirmed, prim), (None, None, 0, 0));
346 }
347
348 #[test]
349 fn dissolve_cluster_nulls_cluster_id() {
350 let conn = seed();
351 dissolve_cluster(&conn, 7).unwrap();
352 assert_eq!(faces_list(&conn).unwrap().clusters.len(), 0);
353 assert_eq!(
354 faces_list(&conn).unwrap().singletons.len(),
355 3,
356 "3,4 join 5 as singletons"
357 );
358 }
359
360 #[test]
361 fn delete_person_unassigns_without_touching_cluster() {
362 let conn = seed();
363 conn.execute("UPDATE faces SET cluster_id = 42 WHERE id = 1", [])
367 .unwrap();
368 delete_person(&conn, "Alice").unwrap();
369 assert_eq!(faces_list(&conn).unwrap().people.len(), 0, "Alice is gone");
370 let (cid, label, confirmed): (Option<i64>, Option<String>, i64) = conn
371 .query_row(
372 "SELECT cluster_id, person_label, confirmed FROM faces WHERE id = 1",
373 [],
374 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
375 )
376 .unwrap();
377 assert_eq!(cid, Some(42), "cluster_id must be preserved");
378 assert_eq!(label, None, "person_label cleared");
379 assert_eq!(confirmed, 0, "confirmed cleared");
380 }
381
382 #[test]
383 fn set_primary_is_exclusive_per_person() {
384 let conn = seed();
385 set_primary(&conn, 2, "Alice").unwrap();
386 let primaries: Vec<i64> = {
387 let mut s = conn
388 .prepare("SELECT id FROM faces WHERE person_label='Alice' AND is_primary=1")
389 .unwrap();
390 s.query_map([], |r| r.get(0))
391 .unwrap()
392 .collect::<rusqlite::Result<_>>()
393 .unwrap()
394 };
395 assert_eq!(primaries, vec![2], "exactly one primary, now face 2");
396 }
397
398 #[test]
399 fn rename_missing_person_is_not_found() {
400 let conn = seed();
401 assert!(matches!(
402 rename_person(&conn, "Nobody", "X"),
403 Err(Error::NotFound)
404 ));
405 }
406
407 #[test]
408 fn rename_onto_existing_person_conflicts() {
409 let conn = seed();
410 assign(&conn, &[3], "Bob").unwrap(); assert!(matches!(
412 rename_person(&conn, "Alice", "Bob"),
413 Err(Error::Conflict)
414 ));
415 }
416
417 #[test]
418 fn rename_succeeds() {
419 let conn = seed();
420 rename_person(&conn, "Alice", "Alicia").unwrap();
421 assert_eq!(person_detail(&conn, "Alicia").unwrap().faces.len(), 2);
422 assert_eq!(person_detail(&conn, "Alice").unwrap().faces.len(), 0);
423 }
424
425 #[test]
426 fn rename_to_empty_label_is_invalid() {
427 let conn = seed();
428 assert!(matches!(
429 rename_person(&conn, "Alice", " "),
430 Err(Error::Invalid)
431 ));
432 }
433}