Skip to main content

videre_api/
faces.rs

1//! Facade over videre's faces-labeling read operations. Plain functions over
2//! an open `rusqlite::Connection`, returning serde types and a shared
3//! `Error`. Called by the axum `--faces` server and any other embedder.
4
5use crate::error::{Error, Result};
6use crate::types::*;
7use rusqlite::Connection;
8use std::collections::HashMap;
9
10/// Whether detection has ever run against this library.
11fn faces_table_exists(conn: &Connection) -> bool {
12    conn.query_row(
13        "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='faces'",
14        [],
15        |r| r.get::<_, i64>(0),
16    )
17    .map(|n| n > 0)
18    .unwrap_or(false)
19}
20
21/// People / unassigned clusters / singletons for the labeling page.
22///
23/// :warning: **A library that has never run `videre faces` has no `faces` table
24/// at all**, and that is not an error. `videre scan` creates `file_hashes`,
25/// `people` and `pipeline_runs`; the faces table arrives with the first
26/// detection run. Querying it before then failed with "no such table", which the
27/// server turned into a 500 with an empty body, which the page turned into
28/// `Unexpected end of JSON input` across the top of the labeling UI.
29///
30/// "Nothing detected yet" is a state, not a failure. It returns empty here and
31/// the page says so.
32pub fn faces_list(conn: &Connection) -> Result<FacesData> {
33    if !faces_table_exists(conn) {
34        return Ok(FacesData::default());
35    }
36    let mut people: HashMap<String, PersonData> = HashMap::new();
37    {
38        let mut stmt = conn.prepare(
39            // LEFT JOIN, not JOIN: a face labelled before the people table
40            // existed still has to appear, showing its raw label until the
41            // migration gives it a row.
42            "SELECT f.id, f.hash, f.person_label, COALESCE(p.full_name, f.person_label) \
43             FROM faces f LEFT JOIN people p ON p.name = f.person_label \
44             WHERE f.confirmed = 1 AND f.person_label IS NOT NULL \
45             ORDER BY f.person_label, f.is_primary DESC, f.id ASC",
46        )?;
47        let rows = stmt.query_map([], |r| {
48            Ok((
49                r.get::<_, i64>(0)?,
50                r.get::<_, String>(1)?,
51                r.get::<_, String>(2)?,
52                r.get::<_, String>(3)?,
53            ))
54        })?;
55        for row in rows {
56            let (id, hash, label, full_name) = row?;
57            let person = people.entry(label.clone()).or_insert(PersonData {
58                label: label.clone(),
59                full_name,
60                face_ids: vec![],
61                representative_id: id,
62                hashes: vec![],
63            });
64            person.face_ids.push(id);
65            if !person.hashes.contains(&hash) {
66                person.hashes.push(hash);
67            }
68        }
69    }
70
71    let mut cluster_map: HashMap<i64, ClusterData> = HashMap::new();
72    {
73        let mut stmt = conn.prepare(
74            "SELECT id, hash, cluster_id FROM faces \
75             WHERE cluster_id IS NOT NULL AND (confirmed = 0 OR person_label IS NULL) \
76             ORDER BY cluster_id, id",
77        )?;
78        let rows = stmt.query_map([], |r| {
79            Ok((
80                r.get::<_, i64>(0)?,
81                r.get::<_, String>(1)?,
82                r.get::<_, i64>(2)?,
83            ))
84        })?;
85        for row in rows {
86            let (id, hash, cid) = row?;
87            let cluster = cluster_map.entry(cid).or_insert(ClusterData {
88                cluster_id: cid,
89                face_ids: vec![],
90                hashes: vec![],
91            });
92            cluster.face_ids.push(id);
93            if !cluster.hashes.contains(&hash) {
94                cluster.hashes.push(hash);
95            }
96        }
97    }
98
99    let mut singletons: Vec<SingletonData> = vec![];
100    {
101        let mut stmt = conn.prepare(
102            "SELECT id, hash FROM faces \
103             WHERE cluster_id IS NULL AND (confirmed = 0 OR person_label IS NULL) \
104             ORDER BY id",
105        )?;
106        let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
107        for row in rows {
108            let (id, hash) = row?;
109            singletons.push(SingletonData { face_id: id, hash });
110        }
111    }
112
113    // Both maps are HashMaps, whose iteration order is arbitrary and differs
114    // between instances, so collecting straight from them threw away the
115    // ORDER BY the queries above establish. The labeling UI re-fetches this
116    // list after every assignment, so the effect was that people and clusters
117    // reshuffled on each drop: the cluster lined up next moved somewhere else,
118    // and so did the person being dragged onto. `singletons` never had the
119    // problem, and the difference is exactly that it is built as a Vec.
120    //
121    // Clusters are ordered largest first, which is the order people label in:
122    // the big clusters are worth the most and are the easiest to recognise.
123    // cluster_id breaks ties so the order is total, not merely sorted.
124    let mut people: Vec<PersonData> = people.into_values().collect();
125    people.sort_by_key(|a| a.full_name.to_lowercase());
126    let mut clusters: Vec<ClusterData> = cluster_map.into_values().collect();
127    clusters.sort_by(|a, b| {
128        b.face_ids
129            .len()
130            .cmp(&a.face_ids.len())
131            .then(a.cluster_id.cmp(&b.cluster_id))
132    });
133
134    Ok(FacesData {
135        people,
136        clusters,
137        singletons,
138    })
139}
140
141/// Every face in one unassigned cluster (for the cluster detail page).
142pub fn cluster_detail(conn: &Connection, cluster_id: i64) -> Result<ClusterDetail> {
143    // Same unlabeled filter the cluster card uses: the page a card opens
144    // must show the population the card counted. A labeled face can hold no
145    // cluster id any more; the filter stays so the two queries cannot drift.
146    let mut stmt = conn.prepare(
147        "SELECT f.id, f.hash, fh.path FROM faces f \
148         JOIN file_hashes fh ON f.hash = fh.hash \
149         WHERE f.cluster_id = ?1 AND (f.confirmed = 0 OR f.person_label IS NULL) \
150         ORDER BY f.id",
151    )?;
152    let faces = stmt
153        .query_map([cluster_id], |r| {
154            Ok(ClusterFaceData {
155                face_id: r.get(0)?,
156                hash: r.get(1)?,
157                path: r.get(2)?,
158            })
159        })?
160        .collect::<rusqlite::Result<Vec<_>>>()?;
161    Ok(ClusterDetail { cluster_id, faces })
162}
163
164/// Every confirmed face for one person, primary first and flagged.
165pub fn person_detail(conn: &Connection, name: &str) -> Result<PersonDetail> {
166    // Reads normalize too, so `/people/person/Erhan`, `/people/person/erhan` and the original
167    // spelling all reach the same person. That is what keeps existing links
168    // working across the migration without a redirect table.
169    let name = videre_core::person::normalize(name).unwrap_or_else(|| name.to_string());
170    let name = name.as_str();
171    let mut stmt = conn.prepare(
172        "SELECT f.id, f.hash, fh.path, f.is_primary FROM faces f \
173         JOIN file_hashes fh ON f.hash = fh.hash \
174         WHERE f.person_label = ?1 AND f.confirmed = 1 \
175         ORDER BY f.is_primary DESC, f.id",
176    )?;
177    let faces = stmt
178        .query_map([name], |r| {
179            Ok(PersonFaceData {
180                face_id: r.get(0)?,
181                hash: r.get(1)?,
182                path: r.get(2)?,
183                is_primary: r.get::<_, i64>(3)? != 0,
184            })
185        })?
186        .collect::<rusqlite::Result<Vec<_>>>()?;
187    // Falls back to the identity for a person with no row yet, so a library
188    // opened before the migration still shows something sensible.
189    let full_name: String = conn
190        .query_row(
191            "SELECT full_name FROM people WHERE name = ?1",
192            rusqlite::params![name],
193            |r| r.get(0),
194        )
195        .unwrap_or_else(|_| name.to_string());
196    Ok(PersonDetail {
197        label: name.to_string(),
198        full_name,
199        faces,
200    })
201}
202
203/// Image paths for confirmed faces of a person (prefix match), for the
204/// person-name autocomplete. Delegates to the existing core search.
205pub fn search_person(conn: &Connection, name: &str) -> Result<Vec<String>> {
206    Ok(videre_core::person_search::search_by_person(
207        conn, name, None,
208    )?)
209}
210
211/// Assign faces to an existing/new person: sets person_label + confirmed.
212/// Rejects an empty label after sanitizing.
213pub fn assign(conn: &Connection, face_ids: &[i64], person_label: &str) -> Result<()> {
214    // What was typed becomes the display name; its normalized form is the
215    // identity written to every face row. Upserting keeps `people` complete
216    // without a separate "create person" step.
217    let display = crate::label::sanitize_person_label(person_label).ok_or(Error::Invalid)?;
218    let label = videre_core::person::normalize(&display).ok_or(Error::Invalid)?;
219    // Nothing to assign is a malformed request, not a silent success that would
220    // create a person with no faces.
221    if face_ids.is_empty() {
222        return Err(Error::Invalid);
223    }
224    // All-or-nothing: a face id that matches no row makes the whole assign a
225    // NotFound, and the person insert is rolled back with it so a failed assign
226    // leaves nothing behind. An `UPDATE` matching no row is `Ok(0)`, not an
227    // error, so a partial write would otherwise be reported as success.
228    conn.execute_batch("BEGIN")?;
229    let result = (|| -> Result<()> {
230        conn.execute(
231            "INSERT INTO people (name, full_name) VALUES (?1, ?2) ON CONFLICT(name) DO NOTHING",
232            rusqlite::params![&label, &display],
233        )?;
234        for id in face_ids {
235            // Frozen faces: assignment detaches the face from machine
236            // grouping. A labeled face must never carry a cluster id for a
237            // later recluster to collide with.
238            let n = conn.execute(
239                "UPDATE faces SET person_label = ?1, confirmed = 1, cluster_id = NULL WHERE id = ?2",
240                rusqlite::params![label, id],
241            )?;
242            if n == 0 {
243                return Err(Error::NotFound);
244            }
245        }
246        Ok(())
247    })();
248    match result {
249        Ok(()) => {
250            conn.execute_batch("COMMIT")?;
251            Ok(())
252        }
253        Err(e) => {
254            let _ = conn.execute_batch("ROLLBACK");
255            Err(e)
256        }
257    }
258}
259
260/// Create a person from faces. Same effect as `assign`; kept as a distinct
261/// operation because callers treat "new person" and "assign to existing" as
262/// separate user intents.
263pub fn new_person(conn: &Connection, face_ids: &[i64], label: &str) -> Result<()> {
264    assign(conn, face_ids, label)
265}
266
267/// Reset one face to fully unassigned (cluster, label, confirmed, primary).
268pub fn remove_face(conn: &Connection, face_id: i64) -> Result<()> {
269    // A face id from the client that matches no row is `Ok(0)`, not an error;
270    // reported as success it would tell the UI a face was reset that never
271    // existed.
272    let n = conn.execute(
273        "UPDATE faces SET cluster_id = NULL, person_label = NULL, confirmed = 0, is_primary = 0 WHERE id = ?1",
274        [face_id],
275    )?;
276    if n == 0 {
277        return Err(Error::NotFound);
278    }
279    Ok(())
280}
281
282/// Ungroup a bad cluster: its faces become unassigned singletons (not deleted).
283pub fn dissolve_cluster(conn: &Connection, cluster_id: i64) -> Result<()> {
284    // A cluster id from the client that matches no row is `Ok(0)`, not an error;
285    // reported as success it would tell the UI a cluster was ungrouped that
286    // never existed.
287    let n = conn.execute(
288        "UPDATE faces SET cluster_id = NULL WHERE cluster_id = ?1",
289        [cluster_id],
290    )?;
291    if n == 0 {
292        return Err(Error::NotFound);
293    }
294    Ok(())
295}
296
297/// Reset every face of a person back to unassigned. Deliberately does NOT touch
298/// cluster_id, so a face rejoins its cluster's unassigned group rather than
299/// scattering to singletons.
300/// Change only what a person is shown as, never their identity.
301///
302/// This is the only rename there is. Identity is permanent: `Erhan` to
303/// `Erhan Gündoğan` is a display correction even though its normalized form
304/// would change too, and there is no way to ask for the other reading. One row,
305/// no face touched, and `/people/person/<name>` keeps working, which is the whole
306/// reason identity and display are separate.
307pub fn set_full_name(conn: &Connection, name: &str, full_name: &str) -> Result<()> {
308    let display = crate::label::sanitize_person_label(full_name).ok_or(Error::Invalid)?;
309    let name = videre_core::person::normalize(name).ok_or(Error::Invalid)?;
310    let n = conn.execute(
311        "UPDATE people SET full_name = ?1 WHERE name = ?2",
312        rusqlite::params![display, name],
313    )?;
314    if n == 0 {
315        return Err(Error::NotFound);
316    }
317    Ok(())
318}
319
320pub fn delete_person(conn: &Connection, label: &str) -> Result<()> {
321    let label = videre_core::person::normalize(label).unwrap_or_else(|| label.to_string());
322    // One transaction: the faces either come back unassigned AND the
323    // regroup gate reopens, or nothing changes at all.
324    conn.execute_batch("BEGIN")?;
325    let result = (|| -> Result<()> {
326        let n = conn.execute(
327            "UPDATE faces SET person_label = NULL, confirmed = 0, is_primary = 0, cluster_id = NULL WHERE person_label = ?1",
328            rusqlite::params![label],
329        )?;
330        if n > 0 {
331            // The returned faces sit in the unassigned pool with ids the
332            // recluster watermark already covers: without resetting it, the
333            // gate stays closed and they wait for new faces before any
334            // regroup runs again. One real delete reopens the machine's
335            // regroup for the whole library. A delete that matched nothing
336            // must not schedule that pass for nothing.
337            videre_core::library_state::set(
338                conn,
339                videre_core::library_state::FACE_RECLUSTER_WATERMARK,
340                0,
341            )?;
342        }
343        Ok(())
344    })();
345    match result {
346        Ok(()) => {
347            conn.execute_batch("COMMIT")?;
348            Ok(())
349        }
350        Err(e) => {
351            let _ = conn.execute_batch("ROLLBACK");
352            Err(e)
353        }
354    }
355}
356
357/// Mark one face as the person's primary (their labeling-page thumbnail),
358/// clearing any previous primary in the same transaction so exactly one
359/// remains. The target update is guarded by person_label so it can't steal a
360/// face from another person.
361pub fn set_primary(conn: &Connection, face_id: i64, person_label: &str) -> Result<()> {
362    let person_label =
363        videre_core::person::normalize(person_label).unwrap_or_else(|| person_label.to_string());
364    conn.execute_batch("BEGIN")?;
365    let result = (|| -> Result<()> {
366        conn.execute(
367            "UPDATE faces SET is_primary = 0 WHERE person_label = ?1",
368            rusqlite::params![person_label],
369        )?;
370        // The guard on person_label means a face id that does not exist, or
371        // belongs to someone else, matches no row: `Ok(0)`, not an error. That
372        // is a NotFound, and the rollback restores the primary cleared above so
373        // a failed call leaves the person's primary untouched.
374        let n = conn.execute(
375            "UPDATE faces SET is_primary = 1, confirmed = 1, person_label = ?1 WHERE id = ?2 AND person_label = ?1",
376            rusqlite::params![person_label, face_id],
377        )?;
378        if n == 0 {
379            return Err(Error::NotFound);
380        }
381        Ok(())
382    })();
383    match result {
384        Ok(()) => {
385            conn.execute_batch("COMMIT")?;
386            Ok(())
387        }
388        Err(e) => {
389            let _ = conn.execute_batch("ROLLBACK");
390            Err(e)
391        }
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn assign_detaches_the_face_from_its_cluster() {
401        let conn = seed();
402        // Face 3 sits in cluster 7 (unassigned). Assigning it to a person
403        // must detach the machine grouping in the same write.
404        assign(&conn, &[3], "Bob").unwrap();
405        let (label, confirmed, cid): (Option<String>, i64, Option<i64>) = conn
406            .query_row(
407                "SELECT person_label, confirmed, cluster_id FROM faces WHERE id = 3",
408                [],
409                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
410            )
411            .unwrap();
412        assert_eq!(label.as_deref(), Some("bob"));
413        assert_eq!(confirmed, 1);
414        assert_eq!(cid, None, "assignment must detach the machine grouping");
415    }
416
417    #[test]
418    fn cluster_detail_never_shows_labeled_faces() {
419        let conn = seed();
420        // A tombstone the detach migration should have cleared: a labeled
421        // face still carrying a cluster id. The filter keeps the detail
422        // page from ever showing it, whatever wrote that row.
423        conn.execute(
424            "INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed) VALUES
425                (11,'h6','0,0,9,9',X'0000',7,'alice',1)",
426            [],
427        )
428        .unwrap();
429        conn.execute(
430            "INSERT INTO file_hashes (hash, path) VALUES ('h6','/p/6.jpg')",
431            [],
432        )
433        .unwrap();
434        let detail = cluster_detail(&conn, 7).unwrap();
435        assert_eq!(
436            detail.faces.len(),
437            2,
438            "only the unlabeled faces of cluster 7 belong on the page"
439        );
440    }
441
442    /// In-memory db with the faces + file_hashes tables and a few rows:
443    /// - face 1: person "Alice", confirmed, is_primary
444    /// - face 2: person "Alice", confirmed
445    /// - face 3: cluster 7 (unassigned)
446    /// - face 4: cluster 7 (unassigned)
447    /// - face 5: singleton (no cluster, unassigned)
448    pub(super) fn seed() -> Connection {
449        let conn = Connection::open_in_memory().unwrap();
450        videre_core::face_db::create_faces_table(&conn).unwrap();
451        conn.execute_batch(
452            "CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);
453             INSERT INTO file_hashes VALUES ('h1','/p/1.jpg'),('h2','/p/2.jpg'),
454                ('h3','/p/3.jpg'),('h4','/p/4.jpg'),('h5','/p/5.jpg');
455             -- Labels are stored in identity form, as `assign` writes them and
456             -- as the migration leaves them; `people` carries what a reader
457             -- sees. Seeding raw 'Alice' would test a state the application no
458             -- longer produces.
459             INSERT INTO people (name, full_name) VALUES ('alice','Alice');
460             INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
461                (1,'h1','0,0,9,9',X'0000',NULL,'alice',1,1),
462                (2,'h2','0,0,9,9',X'0000',NULL,'alice',1,0),
463                (3,'h3','0,0,9,9',X'0000',7,NULL,0,0),
464                (4,'h4','0,0,9,9',X'0000',7,NULL,0,0),
465                (5,'h5','0,0,9,9',X'0000',NULL,NULL,0,0);",
466        )
467        .unwrap();
468        videre_core::db::ensure_file_hashes_columns(&conn);
469        conn
470    }
471
472    #[test]
473    fn the_list_comes_back_in_the_same_order_every_time() {
474        // The labeling UI re-fetches after every assignment, so an unstable
475        // order means the cluster lined up next moves, and so does the person
476        // being dragged onto. Both lists were collected straight out of a
477        // HashMap, which discarded the ORDER BY in the queries above.
478        // `singletons` never had the bug, and the only difference is that it is
479        // built as a Vec.
480        let conn = seed();
481        // The seed has one cluster and one person, which cannot show an
482        // ordering problem. Add enough of both to have an order at all, with
483        // sizes deliberately not matching id order.
484        conn.execute_batch(
485            // Columns named explicitly: `seed` runs ensure_file_hashes_columns,
486            // so the table has more than the two it was created with.
487            "INSERT INTO file_hashes (hash, path) VALUES ('h6','/p/6.jpg'),('h7','/p/7.jpg'),
488                ('h8','/p/8.jpg'),('h9','/p/9.jpg'),('h10','/p/10.jpg');
489             INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
490                (6,'h6','0,0,9,9',X'0000',9,NULL,0,0),
491                (7,'h7','0,0,9,9',X'0000',9,NULL,0,0),
492                (8,'h8','0,0,9,9',X'0000',9,NULL,0,0),
493                (9,'h9','0,0,9,9',X'0000',3,NULL,0,0),
494                (10,'h10','0,0,9,9',X'0000',NULL,'Bob',1,0);",
495        )
496        .unwrap();
497
498        // Two calls on one connection: each builds fresh HashMaps, and Rust
499        // seeds them differently, so an unstable order shows up here.
500        let a = faces_list(&conn).unwrap();
501        let b = faces_list(&conn).unwrap();
502
503        let ids = |f: &FacesData| -> Vec<i64> { f.clusters.iter().map(|c| c.cluster_id).collect() };
504        let names =
505            |f: &FacesData| -> Vec<String> { f.people.iter().map(|p| p.label.clone()).collect() };
506        assert!(ids(&a).len() >= 3, "fixture must have several clusters");
507        assert_eq!(
508            ids(&a),
509            ids(&b),
510            "cluster order must not change between calls"
511        );
512        assert_eq!(
513            names(&a),
514            names(&b),
515            "people order must not change between calls"
516        );
517
518        // And the order is the useful one: biggest first, so the cluster worth
519        // the most labelling effort is where it is expected.
520        let sizes: Vec<usize> = a.clusters.iter().map(|c| c.face_ids.len()).collect();
521        let mut want = sizes.clone();
522        want.sort_unstable_by(|x, y| y.cmp(x));
523        assert_eq!(
524            sizes, want,
525            "clusters must be ordered largest first, got {sizes:?}"
526        );
527    }
528
529    #[test]
530    fn faces_list_splits_people_clusters_singletons() {
531        let conn = seed();
532        let d = faces_list(&conn).unwrap();
533        assert_eq!(d.people.len(), 1);
534        // Identity is the normalized form; what a reader sees is separate.
535        assert_eq!(d.people[0].label, "alice");
536        assert_eq!(d.people[0].full_name, "Alice");
537        assert_eq!(
538            d.people[0].representative_id, 1,
539            "primary face is representative"
540        );
541        assert_eq!(d.clusters.len(), 1);
542        assert_eq!(d.clusters[0].cluster_id, 7);
543        assert_eq!(d.clusters[0].face_ids, vec![3, 4]);
544        assert_eq!(d.singletons.len(), 1);
545        assert_eq!(d.singletons[0].face_id, 5);
546    }
547
548    #[test]
549    fn person_detail_marks_primary() {
550        let conn = seed();
551        let p = person_detail(&conn, "Alice").unwrap();
552        assert_eq!(p.faces.len(), 2);
553        assert!(p.faces[0].is_primary, "primary sorts first and is flagged");
554        assert!(!p.faces[1].is_primary);
555    }
556
557    #[test]
558    fn cluster_detail_lists_faces() {
559        let conn = seed();
560        let c = cluster_detail(&conn, 7).unwrap();
561        assert_eq!(c.cluster_id, 7);
562        assert_eq!(
563            c.faces.iter().map(|f| f.face_id).collect::<Vec<_>>(),
564            vec![3, 4]
565        );
566    }
567
568    #[test]
569    fn assign_labels_and_confirms() {
570        let conn = seed();
571        assign(&conn, &[3, 4], "Bob").unwrap();
572        let p = person_detail(&conn, "Bob").unwrap();
573        assert_eq!(p.faces.len(), 2, "both faces now confirmed under Bob");
574    }
575
576    #[test]
577    fn assign_rejects_empty_label() {
578        let conn = seed();
579        assert!(matches!(assign(&conn, &[3], "   "), Err(Error::Invalid)));
580    }
581
582    #[test]
583    fn remove_face_unassigns_everything() {
584        let conn = seed();
585        remove_face(&conn, 1).unwrap();
586        let (cid, label, confirmed, prim): (Option<i64>, Option<String>, i64, i64) = conn
587            .query_row(
588                "SELECT cluster_id, person_label, confirmed, is_primary FROM faces WHERE id=1",
589                [],
590                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
591            )
592            .unwrap();
593        assert_eq!((cid, label, confirmed, prim), (None, None, 0, 0));
594    }
595
596    #[test]
597    fn dissolve_cluster_nulls_cluster_id() {
598        let conn = seed();
599        dissolve_cluster(&conn, 7).unwrap();
600        assert_eq!(faces_list(&conn).unwrap().clusters.len(), 0);
601        assert_eq!(
602            faces_list(&conn).unwrap().singletons.len(),
603            3,
604            "3,4 join 5 as singletons"
605        );
606    }
607
608    #[test]
609    fn deleting_a_missing_person_leaves_the_regrouping_gate_alone() {
610        // A delete that matched zero faces is a no-op by design; it must not
611        // schedule a whole-library regroup (watermark reset) for nothing.
612        let conn = seed();
613        videre_core::face_db::advance_recluster_watermark(&conn).unwrap();
614        let before = videre_core::face_db::recluster_watermark(&conn).unwrap();
615        assert!(before > 0);
616        delete_person(&conn, "ghost").unwrap();
617        assert_eq!(
618            videre_core::face_db::recluster_watermark(&conn).unwrap(),
619            before,
620            "a no-op delete must not reopen the gated regroup"
621        );
622    }
623
624    #[test]
625    fn delete_person_returns_faces_to_the_unassigned_pool_and_reopens_regrouping() {
626        // The real workflow: assign through the public path (which detaches
627        // the cluster, per the frozen-faces contract), then delete the
628        // person. The faces go back to the unassigned pool, and the recluster
629        // watermark resets so the next gated pass regroups them: without the
630        // reset, these pre-existing face ids sit below the watermark and the
631        // gate stays closed forever.
632        let conn = seed();
633        assign(&conn, &[1, 2], "Alice").unwrap();
634        assert_eq!(faces_list(&conn).unwrap().people.len(), 1);
635        // Simulate a completed recluster covering these faces: the watermark
636        // sits at their ids, so the gate would stay closed for them forever.
637        videre_core::face_db::advance_recluster_watermark(&conn).unwrap();
638        assert!(videre_core::face_db::recluster_watermark(&conn).unwrap() > 0);
639
640        delete_person(&conn, "Alice").unwrap();
641        assert_eq!(faces_list(&conn).unwrap().people.len(), 0, "Alice is gone");
642        assert_eq!(
643            videre_core::face_db::recluster_watermark(&conn).unwrap(),
644            0,
645            "deleting a person must reopen the gated regroup for their faces"
646        );
647        let rows: Vec<(Option<i64>, Option<String>, i64)> = {
648            let mut s = conn
649                .prepare("SELECT cluster_id, person_label, confirmed FROM faces WHERE id IN (1, 2) ORDER BY id")
650                .unwrap();
651            s.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
652                .unwrap()
653                .collect::<rusqlite::Result<_>>()
654                .unwrap()
655        };
656        assert!(
657            rows.iter()
658                .all(|(cid, label, confirmed)| cid.is_none() && label.is_none() && *confirmed == 0),
659            "every face returns to the unassigned pool: {rows:?}"
660        );
661    }
662
663    #[test]
664    fn set_primary_is_exclusive_per_person() {
665        let conn = seed();
666        set_primary(&conn, 2, "Alice").unwrap();
667        let primaries: Vec<i64> = {
668            let mut s = conn
669                .prepare("SELECT id FROM faces WHERE person_label='alice' AND is_primary=1")
670                .unwrap();
671            s.query_map([], |r| r.get(0))
672                .unwrap()
673                .collect::<rusqlite::Result<_>>()
674                .unwrap()
675        };
676        assert_eq!(primaries, vec![2], "exactly one primary, now face 2");
677    }
678
679    #[test]
680    fn renaming_only_the_spelling_keeps_the_identity() {
681        // The common rename: correcting or extending what is shown, which must
682        // not change the URL or touch a single face row.
683        let conn = seed();
684        set_full_name(&conn, "alice", "Alice Smith").unwrap();
685        let (name, full): (String, String) = conn
686            .query_row("SELECT name, full_name FROM people", [], |r| {
687                Ok((r.get(0)?, r.get(1)?))
688            })
689            .unwrap();
690        assert_eq!(name, "alice", "identity is unchanged");
691        assert_eq!(full, "Alice Smith", "only the display name moved");
692        assert_eq!(person_detail(&conn, "alice").unwrap().faces.len(), 2);
693    }
694
695    // A write against a client-supplied id that matches no row is `Ok(0)` from
696    // rusqlite, not an error. Reported as success it tells the labeling UI an
697    // action worked when nothing changed. Each handler that takes an id from the
698    // client must turn "matched nothing" into NotFound, the way set_full_name
699    // already does.
700
701    #[test]
702    fn assign_a_missing_face_is_not_found() {
703        let conn = seed();
704        assert!(matches!(assign(&conn, &[999], "Bob"), Err(Error::NotFound)));
705    }
706
707    #[test]
708    fn assign_is_atomic_when_one_face_is_missing() {
709        // face 3 exists, 999 does not. All-or-nothing: face 3 must be untouched
710        // and no `Bob` person may be created, so a partial write can never be
711        // reported as success.
712        let conn = seed();
713        assert!(matches!(
714            assign(&conn, &[3, 999], "Bob"),
715            Err(Error::NotFound)
716        ));
717        let (label, confirmed): (Option<String>, i64) = conn
718            .query_row(
719                "SELECT person_label, confirmed FROM faces WHERE id = 3",
720                [],
721                |r| Ok((r.get(0)?, r.get(1)?)),
722            )
723            .unwrap();
724        assert_eq!(label, None, "face 3 must not have been labelled");
725        assert_eq!(confirmed, 0, "face 3 must not have been confirmed");
726        let bob: i64 = conn
727            .query_row("SELECT COUNT(*) FROM people WHERE name = 'bob'", [], |r| {
728                r.get(0)
729            })
730            .unwrap();
731        assert_eq!(
732            bob, 0,
733            "no person may be created when the assign rolls back"
734        );
735    }
736
737    #[test]
738    fn assign_rejects_empty_face_ids() {
739        // Nothing to assign is a malformed request, not a silent success that
740        // creates a person with no faces.
741        let conn = seed();
742        assert!(matches!(assign(&conn, &[], "Bob"), Err(Error::Invalid)));
743    }
744
745    #[test]
746    fn remove_face_missing_is_not_found() {
747        let conn = seed();
748        assert!(matches!(remove_face(&conn, 999), Err(Error::NotFound)));
749    }
750
751    #[test]
752    fn dissolve_cluster_missing_is_not_found() {
753        let conn = seed();
754        assert!(matches!(dissolve_cluster(&conn, 999), Err(Error::NotFound)));
755    }
756
757    #[test]
758    fn set_primary_missing_face_is_not_found() {
759        let conn = seed();
760        assert!(matches!(
761            set_primary(&conn, 999, "Alice"),
762            Err(Error::NotFound)
763        ));
764    }
765
766    #[test]
767    fn set_primary_face_of_another_person_is_not_found_and_rolls_back() {
768        // face 5 is an unassigned singleton, so the guarded update matches no
769        // row for Alice. The failure must roll back the primary-clearing step:
770        // Alice's existing primary (face 1) has to survive.
771        let conn = seed();
772        assert!(matches!(
773            set_primary(&conn, 5, "Alice"),
774            Err(Error::NotFound)
775        ));
776        let primary: i64 = conn
777            .query_row(
778                "SELECT id FROM faces WHERE person_label = 'alice' AND is_primary = 1",
779                [],
780                |r| r.get(0),
781            )
782            .unwrap();
783        assert_eq!(
784            primary, 1,
785            "the original primary must be restored on rollback"
786        );
787    }
788
789    #[test]
790    fn delete_person_missing_is_idempotent_success() {
791        // Delete is idempotent: asking to unassign a person who is already gone
792        // has already achieved its goal. A person can also legitimately have a
793        // `people` row and no confirmed faces, which would make a row-count
794        // check wrongly 404 a real person, so delete stays out of the NotFound
795        // rule by design.
796        let conn = seed();
797        assert!(delete_person(&conn, "Nobody").is_ok());
798    }
799}
800
801#[cfg(test)]
802mod identity_tests {
803    use super::tests::seed;
804    use super::*;
805
806    fn people(conn: &Connection) -> Vec<(String, String)> {
807        conn.prepare("SELECT name, full_name FROM people ORDER BY name")
808            .unwrap()
809            .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
810            .unwrap()
811            .collect::<rusqlite::Result<_>>()
812            .unwrap()
813    }
814
815    #[test]
816    fn assign_stores_the_identity_and_records_the_display_name() {
817        let conn = seed();
818        assign(&conn, &[3], "Işıl Özyeğin").unwrap();
819
820        let label: String = conn
821            .query_row("SELECT person_label FROM faces WHERE id = 3", [], |r| {
822                r.get(0)
823            })
824            .unwrap();
825        assert_eq!(label, "isil_ozyegin", "faces hold the identity");
826        assert!(
827            people(&conn).contains(&("isil_ozyegin".into(), "Işıl Özyeğin".into())),
828            "and the spelling is kept for display"
829        );
830    }
831
832    #[test]
833    fn assigning_an_existing_name_in_another_case_joins_that_person() {
834        // The bug this whole change exists to fix: this used to create a second
835        // person.
836        let conn = seed();
837        assign(&conn, &[3], "ALICE").unwrap();
838        assert_eq!(people(&conn).len(), 1, "still one person, not two");
839        assert_eq!(person_detail(&conn, "alice").unwrap().faces.len(), 3);
840        assert_eq!(
841            people(&conn)[0].1,
842            "Alice",
843            "the existing spelling is not overwritten by the new casing"
844        );
845    }
846
847    #[test]
848    fn assign_rejects_a_name_with_no_usable_identity() {
849        // Punctuation alone leaves nothing to identify a person by, and an
850        // empty identity would be a person nobody could address.
851        let conn = seed();
852        assert!(matches!(assign(&conn, &[3], "!!!"), Err(Error::Invalid)));
853    }
854
855    #[test]
856    fn person_detail_resolves_every_form_of_the_name() {
857        let conn = seed();
858        for form in ["alice", "Alice", "ALICE", "  alice  "] {
859            assert_eq!(
860                person_detail(&conn, form).unwrap().faces.len(),
861                2,
862                "form {form:?}"
863            );
864        }
865    }
866
867    #[test]
868    fn person_detail_reports_the_display_name() {
869        let d = person_detail(&seed(), "alice").unwrap();
870        assert_eq!(d.label, "alice");
871        assert_eq!(d.full_name, "Alice");
872    }
873
874    #[test]
875    fn person_detail_falls_back_when_there_is_no_people_row() {
876        // A label written before the table existed still has to render.
877        let conn = seed();
878        conn.execute(
879            "INSERT INTO faces (id,hash,bbox,embedding,person_label,confirmed) \
880             VALUES (9,'h9','0,0,9,9',X'0000','orphan',1)",
881            [],
882        )
883        .unwrap();
884        let d = person_detail(&conn, "orphan").unwrap();
885        assert_eq!(d.full_name, "orphan", "falls back to the identity");
886    }
887
888    #[test]
889    fn set_full_name_changes_only_the_display_name() {
890        let conn = seed();
891        set_full_name(&conn, "alice", "Alice Smith").unwrap();
892        assert_eq!(people(&conn), vec![("alice".into(), "Alice Smith".into())]);
893        assert_eq!(
894            person_detail(&conn, "alice").unwrap().faces.len(),
895            2,
896            "no face was touched"
897        );
898    }
899
900    #[test]
901    fn set_full_name_accepts_any_form_of_the_identity() {
902        let conn = seed();
903        set_full_name(&conn, "ALICE", "Alice Smith").unwrap();
904        assert_eq!(people(&conn)[0].1, "Alice Smith");
905    }
906
907    #[test]
908    fn set_full_name_on_a_missing_person_is_not_found() {
909        assert!(matches!(
910            set_full_name(&seed(), "nobody", "Someone"),
911            Err(Error::NotFound)
912        ));
913    }
914
915    #[test]
916    fn set_full_name_rejects_an_empty_display_name() {
917        // A person with no name to show is worse than one shown by identity.
918        assert!(matches!(
919            set_full_name(&seed(), "alice", "   "),
920            Err(Error::Invalid)
921        ));
922    }
923
924    #[test]
925    fn delete_person_accepts_any_form_of_the_name() {
926        let conn = seed();
927        delete_person(&conn, "Alice").unwrap();
928        let left: i64 = conn
929            .query_row(
930                "SELECT COUNT(*) FROM faces WHERE person_label IS NOT NULL",
931                [],
932                |r| r.get(0),
933            )
934            .unwrap();
935        assert_eq!(left, 0, "faces are unassigned whichever form was passed");
936    }
937
938    #[test]
939    fn set_primary_accepts_any_form_of_the_name() {
940        let conn = seed();
941        set_primary(&conn, 2, "ALICE").unwrap();
942        let primary: i64 = conn
943            .query_row(
944                "SELECT id FROM faces WHERE person_label='alice' AND is_primary=1",
945                [],
946                |r| r.get(0),
947            )
948            .unwrap();
949        assert_eq!(primary, 2);
950    }
951}
952
953#[cfg(test)]
954mod never_run_tests {
955    use super::*;
956
957    /// :warning: **A scanned-but-never-detected library has no `faces` table.**
958    ///
959    /// `videre scan` creates `file_hashes`, `people` and `pipeline_runs`. The
960    /// faces table arrives with the first `videre faces` run, so every query
961    /// here failed with "no such table" until then. The server turned that into
962    /// a 500 with an empty body, and the page turned the empty body into
963    /// `Unexpected end of JSON input` across the top of the labeling UI.
964    ///
965    /// Every existing test in this file seeds a faces table, which is why none
966    /// of them could see it: they all describe a library that has already run
967    /// detection.
968    #[test]
969    fn a_library_that_never_ran_detection_is_empty_not_an_error() {
970        let conn = Connection::open_in_memory().unwrap();
971        conn.execute_batch(
972            "CREATE TABLE file_hashes (path TEXT PRIMARY KEY, hash TEXT NOT NULL);
973             CREATE TABLE people (name TEXT PRIMARY KEY, full_name TEXT);",
974        )
975        .unwrap();
976
977        let data = faces_list(&conn).expect("a library with no faces table is not an error");
978        assert!(data.people.is_empty());
979        assert!(data.clusters.is_empty());
980        assert!(data.singletons.is_empty());
981    }
982}