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    let mut stmt = conn.prepare(
144        "SELECT f.id, f.hash, fh.path FROM faces f \
145         JOIN file_hashes fh ON f.hash = fh.hash \
146         WHERE f.cluster_id = ?1 ORDER BY f.id",
147    )?;
148    let faces = stmt
149        .query_map([cluster_id], |r| {
150            Ok(ClusterFaceData {
151                face_id: r.get(0)?,
152                hash: r.get(1)?,
153                path: r.get(2)?,
154            })
155        })?
156        .collect::<rusqlite::Result<Vec<_>>>()?;
157    Ok(ClusterDetail { cluster_id, faces })
158}
159
160/// Every confirmed face for one person, primary first and flagged.
161pub fn person_detail(conn: &Connection, name: &str) -> Result<PersonDetail> {
162    // Reads normalize too, so `/people/person/Erhan`, `/people/person/erhan` and the original
163    // spelling all reach the same person. That is what keeps existing links
164    // working across the migration without a redirect table.
165    let name = videre_core::person::normalize(name).unwrap_or_else(|| name.to_string());
166    let name = name.as_str();
167    let mut stmt = conn.prepare(
168        "SELECT f.id, f.hash, fh.path, f.is_primary FROM faces f \
169         JOIN file_hashes fh ON f.hash = fh.hash \
170         WHERE f.person_label = ?1 AND f.confirmed = 1 \
171         ORDER BY f.is_primary DESC, f.id",
172    )?;
173    let faces = stmt
174        .query_map([name], |r| {
175            Ok(PersonFaceData {
176                face_id: r.get(0)?,
177                hash: r.get(1)?,
178                path: r.get(2)?,
179                is_primary: r.get::<_, i64>(3)? != 0,
180            })
181        })?
182        .collect::<rusqlite::Result<Vec<_>>>()?;
183    // Falls back to the identity for a person with no row yet, so a library
184    // opened before the migration still shows something sensible.
185    let full_name: String = conn
186        .query_row(
187            "SELECT full_name FROM people WHERE name = ?1",
188            rusqlite::params![name],
189            |r| r.get(0),
190        )
191        .unwrap_or_else(|_| name.to_string());
192    Ok(PersonDetail {
193        label: name.to_string(),
194        full_name,
195        faces,
196    })
197}
198
199/// Image paths for confirmed faces of a person (prefix match), for the
200/// person-name autocomplete. Delegates to the existing core search.
201pub fn search_person(conn: &Connection, name: &str) -> Result<Vec<String>> {
202    Ok(videre_core::person_search::search_by_person(
203        conn, name, None,
204    )?)
205}
206
207/// Assign faces to an existing/new person: sets person_label + confirmed.
208/// Rejects an empty label after sanitizing.
209pub fn assign(conn: &Connection, face_ids: &[i64], person_label: &str) -> Result<()> {
210    // What was typed becomes the display name; its normalized form is the
211    // identity written to every face row. Upserting keeps `people` complete
212    // without a separate "create person" step.
213    let display = crate::label::sanitize_person_label(person_label).ok_or(Error::Invalid)?;
214    let label = videre_core::person::normalize(&display).ok_or(Error::Invalid)?;
215    // Nothing to assign is a malformed request, not a silent success that would
216    // create a person with no faces.
217    if face_ids.is_empty() {
218        return Err(Error::Invalid);
219    }
220    // All-or-nothing: a face id that matches no row makes the whole assign a
221    // NotFound, and the person insert is rolled back with it so a failed assign
222    // leaves nothing behind. An `UPDATE` matching no row is `Ok(0)`, not an
223    // error, so a partial write would otherwise be reported as success.
224    conn.execute_batch("BEGIN")?;
225    let result = (|| -> Result<()> {
226        conn.execute(
227            "INSERT INTO people (name, full_name) VALUES (?1, ?2) ON CONFLICT(name) DO NOTHING",
228            rusqlite::params![&label, &display],
229        )?;
230        for id in face_ids {
231            let n = conn.execute(
232                "UPDATE faces SET person_label = ?1, confirmed = 1 WHERE id = ?2",
233                rusqlite::params![label, id],
234            )?;
235            if n == 0 {
236                return Err(Error::NotFound);
237            }
238        }
239        Ok(())
240    })();
241    match result {
242        Ok(()) => {
243            conn.execute_batch("COMMIT")?;
244            Ok(())
245        }
246        Err(e) => {
247            let _ = conn.execute_batch("ROLLBACK");
248            Err(e)
249        }
250    }
251}
252
253/// Create a person from faces. Same effect as `assign`; kept as a distinct
254/// operation because callers treat "new person" and "assign to existing" as
255/// separate user intents.
256pub fn new_person(conn: &Connection, face_ids: &[i64], label: &str) -> Result<()> {
257    assign(conn, face_ids, label)
258}
259
260/// Reset one face to fully unassigned (cluster, label, confirmed, primary).
261pub fn remove_face(conn: &Connection, face_id: i64) -> Result<()> {
262    // A face id from the client that matches no row is `Ok(0)`, not an error;
263    // reported as success it would tell the UI a face was reset that never
264    // existed.
265    let n = conn.execute(
266        "UPDATE faces SET cluster_id = NULL, person_label = NULL, confirmed = 0, is_primary = 0 WHERE id = ?1",
267        [face_id],
268    )?;
269    if n == 0 {
270        return Err(Error::NotFound);
271    }
272    Ok(())
273}
274
275/// Ungroup a bad cluster: its faces become unassigned singletons (not deleted).
276pub fn dissolve_cluster(conn: &Connection, cluster_id: i64) -> Result<()> {
277    // A cluster id from the client that matches no row is `Ok(0)`, not an error;
278    // reported as success it would tell the UI a cluster was ungrouped that
279    // never existed.
280    let n = conn.execute(
281        "UPDATE faces SET cluster_id = NULL WHERE cluster_id = ?1",
282        [cluster_id],
283    )?;
284    if n == 0 {
285        return Err(Error::NotFound);
286    }
287    Ok(())
288}
289
290/// Reset every face of a person back to unassigned. Deliberately does NOT touch
291/// cluster_id, so a face rejoins its cluster's unassigned group rather than
292/// scattering to singletons.
293/// Change only what a person is shown as, never their identity.
294///
295/// This is the only rename there is. Identity is permanent: `Erhan` to
296/// `Erhan Gündoğan` is a display correction even though its normalized form
297/// would change too, and there is no way to ask for the other reading. One row,
298/// no face touched, and `/people/person/<name>` keeps working, which is the whole
299/// reason identity and display are separate.
300pub fn set_full_name(conn: &Connection, name: &str, full_name: &str) -> Result<()> {
301    let display = crate::label::sanitize_person_label(full_name).ok_or(Error::Invalid)?;
302    let name = videre_core::person::normalize(name).ok_or(Error::Invalid)?;
303    let n = conn.execute(
304        "UPDATE people SET full_name = ?1 WHERE name = ?2",
305        rusqlite::params![display, name],
306    )?;
307    if n == 0 {
308        return Err(Error::NotFound);
309    }
310    Ok(())
311}
312
313pub fn delete_person(conn: &Connection, label: &str) -> Result<()> {
314    let label = videre_core::person::normalize(label).unwrap_or_else(|| label.to_string());
315    conn.execute(
316        "UPDATE faces SET person_label = NULL, confirmed = 0, is_primary = 0 WHERE person_label = ?1",
317        rusqlite::params![label],
318    )?;
319    Ok(())
320}
321
322/// Mark one face as the person's primary (their labeling-page thumbnail),
323/// clearing any previous primary in the same transaction so exactly one
324/// remains. The target update is guarded by person_label so it can't steal a
325/// face from another person.
326pub fn set_primary(conn: &Connection, face_id: i64, person_label: &str) -> Result<()> {
327    let person_label =
328        videre_core::person::normalize(person_label).unwrap_or_else(|| person_label.to_string());
329    conn.execute_batch("BEGIN")?;
330    let result = (|| -> Result<()> {
331        conn.execute(
332            "UPDATE faces SET is_primary = 0 WHERE person_label = ?1",
333            rusqlite::params![person_label],
334        )?;
335        // The guard on person_label means a face id that does not exist, or
336        // belongs to someone else, matches no row: `Ok(0)`, not an error. That
337        // is a NotFound, and the rollback restores the primary cleared above so
338        // a failed call leaves the person's primary untouched.
339        let n = conn.execute(
340            "UPDATE faces SET is_primary = 1, confirmed = 1, person_label = ?1 WHERE id = ?2 AND person_label = ?1",
341            rusqlite::params![person_label, face_id],
342        )?;
343        if n == 0 {
344            return Err(Error::NotFound);
345        }
346        Ok(())
347    })();
348    match result {
349        Ok(()) => {
350            conn.execute_batch("COMMIT")?;
351            Ok(())
352        }
353        Err(e) => {
354            let _ = conn.execute_batch("ROLLBACK");
355            Err(e)
356        }
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    /// In-memory db with the faces + file_hashes tables and a few rows:
365    /// - face 1: person "Alice", confirmed, is_primary
366    /// - face 2: person "Alice", confirmed
367    /// - face 3: cluster 7 (unassigned)
368    /// - face 4: cluster 7 (unassigned)
369    /// - face 5: singleton (no cluster, unassigned)
370    pub(super) fn seed() -> Connection {
371        let conn = Connection::open_in_memory().unwrap();
372        videre_core::face_db::create_faces_table(&conn).unwrap();
373        conn.execute_batch(
374            "CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);
375             INSERT INTO file_hashes VALUES ('h1','/p/1.jpg'),('h2','/p/2.jpg'),
376                ('h3','/p/3.jpg'),('h4','/p/4.jpg'),('h5','/p/5.jpg');
377             -- Labels are stored in identity form, as `assign` writes them and
378             -- as the migration leaves them; `people` carries what a reader
379             -- sees. Seeding raw 'Alice' would test a state the application no
380             -- longer produces.
381             INSERT INTO people (name, full_name) VALUES ('alice','Alice');
382             INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
383                (1,'h1','0,0,9,9',X'0000',NULL,'alice',1,1),
384                (2,'h2','0,0,9,9',X'0000',NULL,'alice',1,0),
385                (3,'h3','0,0,9,9',X'0000',7,NULL,0,0),
386                (4,'h4','0,0,9,9',X'0000',7,NULL,0,0),
387                (5,'h5','0,0,9,9',X'0000',NULL,NULL,0,0);",
388        )
389        .unwrap();
390        videre_core::db::ensure_file_hashes_columns(&conn);
391        conn
392    }
393
394    #[test]
395    fn the_list_comes_back_in_the_same_order_every_time() {
396        // The labeling UI re-fetches after every assignment, so an unstable
397        // order means the cluster lined up next moves, and so does the person
398        // being dragged onto. Both lists were collected straight out of a
399        // HashMap, which discarded the ORDER BY in the queries above.
400        // `singletons` never had the bug, and the only difference is that it is
401        // built as a Vec.
402        let conn = seed();
403        // The seed has one cluster and one person, which cannot show an
404        // ordering problem. Add enough of both to have an order at all, with
405        // sizes deliberately not matching id order.
406        conn.execute_batch(
407            // Columns named explicitly: `seed` runs ensure_file_hashes_columns,
408            // so the table has more than the two it was created with.
409            "INSERT INTO file_hashes (hash, path) VALUES ('h6','/p/6.jpg'),('h7','/p/7.jpg'),
410                ('h8','/p/8.jpg'),('h9','/p/9.jpg'),('h10','/p/10.jpg');
411             INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
412                (6,'h6','0,0,9,9',X'0000',9,NULL,0,0),
413                (7,'h7','0,0,9,9',X'0000',9,NULL,0,0),
414                (8,'h8','0,0,9,9',X'0000',9,NULL,0,0),
415                (9,'h9','0,0,9,9',X'0000',3,NULL,0,0),
416                (10,'h10','0,0,9,9',X'0000',NULL,'Bob',1,0);",
417        )
418        .unwrap();
419
420        // Two calls on one connection: each builds fresh HashMaps, and Rust
421        // seeds them differently, so an unstable order shows up here.
422        let a = faces_list(&conn).unwrap();
423        let b = faces_list(&conn).unwrap();
424
425        let ids = |f: &FacesData| -> Vec<i64> { f.clusters.iter().map(|c| c.cluster_id).collect() };
426        let names =
427            |f: &FacesData| -> Vec<String> { f.people.iter().map(|p| p.label.clone()).collect() };
428        assert!(ids(&a).len() >= 3, "fixture must have several clusters");
429        assert_eq!(
430            ids(&a),
431            ids(&b),
432            "cluster order must not change between calls"
433        );
434        assert_eq!(
435            names(&a),
436            names(&b),
437            "people order must not change between calls"
438        );
439
440        // And the order is the useful one: biggest first, so the cluster worth
441        // the most labelling effort is where it is expected.
442        let sizes: Vec<usize> = a.clusters.iter().map(|c| c.face_ids.len()).collect();
443        let mut want = sizes.clone();
444        want.sort_unstable_by(|x, y| y.cmp(x));
445        assert_eq!(
446            sizes, want,
447            "clusters must be ordered largest first, got {sizes:?}"
448        );
449    }
450
451    #[test]
452    fn faces_list_splits_people_clusters_singletons() {
453        let conn = seed();
454        let d = faces_list(&conn).unwrap();
455        assert_eq!(d.people.len(), 1);
456        // Identity is the normalized form; what a reader sees is separate.
457        assert_eq!(d.people[0].label, "alice");
458        assert_eq!(d.people[0].full_name, "Alice");
459        assert_eq!(
460            d.people[0].representative_id, 1,
461            "primary face is representative"
462        );
463        assert_eq!(d.clusters.len(), 1);
464        assert_eq!(d.clusters[0].cluster_id, 7);
465        assert_eq!(d.clusters[0].face_ids, vec![3, 4]);
466        assert_eq!(d.singletons.len(), 1);
467        assert_eq!(d.singletons[0].face_id, 5);
468    }
469
470    #[test]
471    fn person_detail_marks_primary() {
472        let conn = seed();
473        let p = person_detail(&conn, "Alice").unwrap();
474        assert_eq!(p.faces.len(), 2);
475        assert!(p.faces[0].is_primary, "primary sorts first and is flagged");
476        assert!(!p.faces[1].is_primary);
477    }
478
479    #[test]
480    fn cluster_detail_lists_faces() {
481        let conn = seed();
482        let c = cluster_detail(&conn, 7).unwrap();
483        assert_eq!(c.cluster_id, 7);
484        assert_eq!(
485            c.faces.iter().map(|f| f.face_id).collect::<Vec<_>>(),
486            vec![3, 4]
487        );
488    }
489
490    #[test]
491    fn assign_labels_and_confirms() {
492        let conn = seed();
493        assign(&conn, &[3, 4], "Bob").unwrap();
494        let p = person_detail(&conn, "Bob").unwrap();
495        assert_eq!(p.faces.len(), 2, "both faces now confirmed under Bob");
496    }
497
498    #[test]
499    fn assign_rejects_empty_label() {
500        let conn = seed();
501        assert!(matches!(assign(&conn, &[3], "   "), Err(Error::Invalid)));
502    }
503
504    #[test]
505    fn remove_face_unassigns_everything() {
506        let conn = seed();
507        remove_face(&conn, 1).unwrap();
508        let (cid, label, confirmed, prim): (Option<i64>, Option<String>, i64, i64) = conn
509            .query_row(
510                "SELECT cluster_id, person_label, confirmed, is_primary FROM faces WHERE id=1",
511                [],
512                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
513            )
514            .unwrap();
515        assert_eq!((cid, label, confirmed, prim), (None, None, 0, 0));
516    }
517
518    #[test]
519    fn dissolve_cluster_nulls_cluster_id() {
520        let conn = seed();
521        dissolve_cluster(&conn, 7).unwrap();
522        assert_eq!(faces_list(&conn).unwrap().clusters.len(), 0);
523        assert_eq!(
524            faces_list(&conn).unwrap().singletons.len(),
525            3,
526            "3,4 join 5 as singletons"
527        );
528    }
529
530    #[test]
531    fn delete_person_unassigns_without_touching_cluster() {
532        let conn = seed();
533        // Give one of Alice's faces a cluster_id so we can prove delete_person
534        // leaves cluster_id intact (it must, so the face rejoins its cluster's
535        // unassigned group rather than scattering to singletons).
536        conn.execute("UPDATE faces SET cluster_id = 42 WHERE id = 1", [])
537            .unwrap();
538        delete_person(&conn, "Alice").unwrap();
539        assert_eq!(faces_list(&conn).unwrap().people.len(), 0, "Alice is gone");
540        let (cid, label, confirmed): (Option<i64>, Option<String>, i64) = conn
541            .query_row(
542                "SELECT cluster_id, person_label, confirmed FROM faces WHERE id = 1",
543                [],
544                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
545            )
546            .unwrap();
547        assert_eq!(cid, Some(42), "cluster_id must be preserved");
548        assert_eq!(label, None, "person_label cleared");
549        assert_eq!(confirmed, 0, "confirmed cleared");
550    }
551
552    #[test]
553    fn set_primary_is_exclusive_per_person() {
554        let conn = seed();
555        set_primary(&conn, 2, "Alice").unwrap();
556        let primaries: Vec<i64> = {
557            let mut s = conn
558                .prepare("SELECT id FROM faces WHERE person_label='alice' AND is_primary=1")
559                .unwrap();
560            s.query_map([], |r| r.get(0))
561                .unwrap()
562                .collect::<rusqlite::Result<_>>()
563                .unwrap()
564        };
565        assert_eq!(primaries, vec![2], "exactly one primary, now face 2");
566    }
567
568    #[test]
569    fn renaming_only_the_spelling_keeps_the_identity() {
570        // The common rename: correcting or extending what is shown, which must
571        // not change the URL or touch a single face row.
572        let conn = seed();
573        set_full_name(&conn, "alice", "Alice Smith").unwrap();
574        let (name, full): (String, String) = conn
575            .query_row("SELECT name, full_name FROM people", [], |r| {
576                Ok((r.get(0)?, r.get(1)?))
577            })
578            .unwrap();
579        assert_eq!(name, "alice", "identity is unchanged");
580        assert_eq!(full, "Alice Smith", "only the display name moved");
581        assert_eq!(person_detail(&conn, "alice").unwrap().faces.len(), 2);
582    }
583
584    // A write against a client-supplied id that matches no row is `Ok(0)` from
585    // rusqlite, not an error. Reported as success it tells the labeling UI an
586    // action worked when nothing changed. Each handler that takes an id from the
587    // client must turn "matched nothing" into NotFound, the way set_full_name
588    // already does.
589
590    #[test]
591    fn assign_a_missing_face_is_not_found() {
592        let conn = seed();
593        assert!(matches!(assign(&conn, &[999], "Bob"), Err(Error::NotFound)));
594    }
595
596    #[test]
597    fn assign_is_atomic_when_one_face_is_missing() {
598        // face 3 exists, 999 does not. All-or-nothing: face 3 must be untouched
599        // and no `Bob` person may be created, so a partial write can never be
600        // reported as success.
601        let conn = seed();
602        assert!(matches!(
603            assign(&conn, &[3, 999], "Bob"),
604            Err(Error::NotFound)
605        ));
606        let (label, confirmed): (Option<String>, i64) = conn
607            .query_row(
608                "SELECT person_label, confirmed FROM faces WHERE id = 3",
609                [],
610                |r| Ok((r.get(0)?, r.get(1)?)),
611            )
612            .unwrap();
613        assert_eq!(label, None, "face 3 must not have been labelled");
614        assert_eq!(confirmed, 0, "face 3 must not have been confirmed");
615        let bob: i64 = conn
616            .query_row("SELECT COUNT(*) FROM people WHERE name = 'bob'", [], |r| {
617                r.get(0)
618            })
619            .unwrap();
620        assert_eq!(
621            bob, 0,
622            "no person may be created when the assign rolls back"
623        );
624    }
625
626    #[test]
627    fn assign_rejects_empty_face_ids() {
628        // Nothing to assign is a malformed request, not a silent success that
629        // creates a person with no faces.
630        let conn = seed();
631        assert!(matches!(assign(&conn, &[], "Bob"), Err(Error::Invalid)));
632    }
633
634    #[test]
635    fn remove_face_missing_is_not_found() {
636        let conn = seed();
637        assert!(matches!(remove_face(&conn, 999), Err(Error::NotFound)));
638    }
639
640    #[test]
641    fn dissolve_cluster_missing_is_not_found() {
642        let conn = seed();
643        assert!(matches!(dissolve_cluster(&conn, 999), Err(Error::NotFound)));
644    }
645
646    #[test]
647    fn set_primary_missing_face_is_not_found() {
648        let conn = seed();
649        assert!(matches!(
650            set_primary(&conn, 999, "Alice"),
651            Err(Error::NotFound)
652        ));
653    }
654
655    #[test]
656    fn set_primary_face_of_another_person_is_not_found_and_rolls_back() {
657        // face 5 is an unassigned singleton, so the guarded update matches no
658        // row for Alice. The failure must roll back the primary-clearing step:
659        // Alice's existing primary (face 1) has to survive.
660        let conn = seed();
661        assert!(matches!(
662            set_primary(&conn, 5, "Alice"),
663            Err(Error::NotFound)
664        ));
665        let primary: i64 = conn
666            .query_row(
667                "SELECT id FROM faces WHERE person_label = 'alice' AND is_primary = 1",
668                [],
669                |r| r.get(0),
670            )
671            .unwrap();
672        assert_eq!(
673            primary, 1,
674            "the original primary must be restored on rollback"
675        );
676    }
677
678    #[test]
679    fn delete_person_missing_is_idempotent_success() {
680        // Delete is idempotent: asking to unassign a person who is already gone
681        // has already achieved its goal. A person can also legitimately have a
682        // `people` row and no confirmed faces, which would make a row-count
683        // check wrongly 404 a real person, so delete stays out of the NotFound
684        // rule by design.
685        let conn = seed();
686        assert!(delete_person(&conn, "Nobody").is_ok());
687    }
688}
689
690#[cfg(test)]
691mod identity_tests {
692    use super::tests::seed;
693    use super::*;
694
695    fn people(conn: &Connection) -> Vec<(String, String)> {
696        conn.prepare("SELECT name, full_name FROM people ORDER BY name")
697            .unwrap()
698            .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
699            .unwrap()
700            .collect::<rusqlite::Result<_>>()
701            .unwrap()
702    }
703
704    #[test]
705    fn assign_stores_the_identity_and_records_the_display_name() {
706        let conn = seed();
707        assign(&conn, &[3], "Işıl Özyeğin").unwrap();
708
709        let label: String = conn
710            .query_row("SELECT person_label FROM faces WHERE id = 3", [], |r| {
711                r.get(0)
712            })
713            .unwrap();
714        assert_eq!(label, "isil_ozyegin", "faces hold the identity");
715        assert!(
716            people(&conn).contains(&("isil_ozyegin".into(), "Işıl Özyeğin".into())),
717            "and the spelling is kept for display"
718        );
719    }
720
721    #[test]
722    fn assigning_an_existing_name_in_another_case_joins_that_person() {
723        // The bug this whole change exists to fix: this used to create a second
724        // person.
725        let conn = seed();
726        assign(&conn, &[3], "ALICE").unwrap();
727        assert_eq!(people(&conn).len(), 1, "still one person, not two");
728        assert_eq!(person_detail(&conn, "alice").unwrap().faces.len(), 3);
729        assert_eq!(
730            people(&conn)[0].1,
731            "Alice",
732            "the existing spelling is not overwritten by the new casing"
733        );
734    }
735
736    #[test]
737    fn assign_rejects_a_name_with_no_usable_identity() {
738        // Punctuation alone leaves nothing to identify a person by, and an
739        // empty identity would be a person nobody could address.
740        let conn = seed();
741        assert!(matches!(assign(&conn, &[3], "!!!"), Err(Error::Invalid)));
742    }
743
744    #[test]
745    fn person_detail_resolves_every_form_of_the_name() {
746        let conn = seed();
747        for form in ["alice", "Alice", "ALICE", "  alice  "] {
748            assert_eq!(
749                person_detail(&conn, form).unwrap().faces.len(),
750                2,
751                "form {form:?}"
752            );
753        }
754    }
755
756    #[test]
757    fn person_detail_reports_the_display_name() {
758        let d = person_detail(&seed(), "alice").unwrap();
759        assert_eq!(d.label, "alice");
760        assert_eq!(d.full_name, "Alice");
761    }
762
763    #[test]
764    fn person_detail_falls_back_when_there_is_no_people_row() {
765        // A label written before the table existed still has to render.
766        let conn = seed();
767        conn.execute(
768            "INSERT INTO faces (id,hash,bbox,embedding,person_label,confirmed) \
769             VALUES (9,'h9','0,0,9,9',X'0000','orphan',1)",
770            [],
771        )
772        .unwrap();
773        let d = person_detail(&conn, "orphan").unwrap();
774        assert_eq!(d.full_name, "orphan", "falls back to the identity");
775    }
776
777    #[test]
778    fn set_full_name_changes_only_the_display_name() {
779        let conn = seed();
780        set_full_name(&conn, "alice", "Alice Smith").unwrap();
781        assert_eq!(people(&conn), vec![("alice".into(), "Alice Smith".into())]);
782        assert_eq!(
783            person_detail(&conn, "alice").unwrap().faces.len(),
784            2,
785            "no face was touched"
786        );
787    }
788
789    #[test]
790    fn set_full_name_accepts_any_form_of_the_identity() {
791        let conn = seed();
792        set_full_name(&conn, "ALICE", "Alice Smith").unwrap();
793        assert_eq!(people(&conn)[0].1, "Alice Smith");
794    }
795
796    #[test]
797    fn set_full_name_on_a_missing_person_is_not_found() {
798        assert!(matches!(
799            set_full_name(&seed(), "nobody", "Someone"),
800            Err(Error::NotFound)
801        ));
802    }
803
804    #[test]
805    fn set_full_name_rejects_an_empty_display_name() {
806        // A person with no name to show is worse than one shown by identity.
807        assert!(matches!(
808            set_full_name(&seed(), "alice", "   "),
809            Err(Error::Invalid)
810        ));
811    }
812
813    #[test]
814    fn delete_person_accepts_any_form_of_the_name() {
815        let conn = seed();
816        delete_person(&conn, "Alice").unwrap();
817        let left: i64 = conn
818            .query_row(
819                "SELECT COUNT(*) FROM faces WHERE person_label IS NOT NULL",
820                [],
821                |r| r.get(0),
822            )
823            .unwrap();
824        assert_eq!(left, 0, "faces are unassigned whichever form was passed");
825    }
826
827    #[test]
828    fn set_primary_accepts_any_form_of_the_name() {
829        let conn = seed();
830        set_primary(&conn, 2, "ALICE").unwrap();
831        let primary: i64 = conn
832            .query_row(
833                "SELECT id FROM faces WHERE person_label='alice' AND is_primary=1",
834                [],
835                |r| r.get(0),
836            )
837            .unwrap();
838        assert_eq!(primary, 2);
839    }
840}
841
842#[cfg(test)]
843mod never_run_tests {
844    use super::*;
845
846    /// :warning: **A scanned-but-never-detected library has no `faces` table.**
847    ///
848    /// `videre scan` creates `file_hashes`, `people` and `pipeline_runs`. The
849    /// faces table arrives with the first `videre faces` run, so every query
850    /// here failed with "no such table" until then. The server turned that into
851    /// a 500 with an empty body, and the page turned the empty body into
852    /// `Unexpected end of JSON input` across the top of the labeling UI.
853    ///
854    /// Every existing test in this file seeds a faces table, which is why none
855    /// of them could see it: they all describe a library that has already run
856    /// detection.
857    #[test]
858    fn a_library_that_never_ran_detection_is_empty_not_an_error() {
859        let conn = Connection::open_in_memory().unwrap();
860        conn.execute_batch(
861            "CREATE TABLE file_hashes (path TEXT PRIMARY KEY, hash TEXT NOT NULL);
862             CREATE TABLE people (name TEXT PRIMARY KEY, full_name TEXT);",
863        )
864        .unwrap();
865
866        let data = faces_list(&conn).expect("a library with no faces table is not an error");
867        assert!(data.people.is_empty());
868        assert!(data.clusters.is_empty());
869        assert!(data.singletons.is_empty());
870    }
871}