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/// People / unassigned clusters / singletons for the labeling page.
11pub fn faces_list(conn: &Connection) -> Result<FacesData> {
12    let mut people: HashMap<String, PersonData> = HashMap::new();
13    {
14        let mut stmt = conn.prepare(
15            // LEFT JOIN, not JOIN: a face labelled before the people table
16            // existed still has to appear, showing its raw label until the
17            // migration gives it a row.
18            "SELECT f.id, f.hash, f.person_label, COALESCE(p.full_name, f.person_label) \
19             FROM faces f LEFT JOIN people p ON p.name = f.person_label \
20             WHERE f.confirmed = 1 AND f.person_label IS NOT NULL \
21             ORDER BY f.person_label, f.is_primary DESC, f.id ASC",
22        )?;
23        let rows = stmt.query_map([], |r| {
24            Ok((
25                r.get::<_, i64>(0)?,
26                r.get::<_, String>(1)?,
27                r.get::<_, String>(2)?,
28                r.get::<_, String>(3)?,
29            ))
30        })?;
31        for row in rows {
32            let (id, hash, label, full_name) = row?;
33            let person = people.entry(label.clone()).or_insert(PersonData {
34                label: label.clone(),
35                full_name,
36                face_ids: vec![],
37                representative_id: id,
38                hashes: vec![],
39            });
40            person.face_ids.push(id);
41            if !person.hashes.contains(&hash) {
42                person.hashes.push(hash);
43            }
44        }
45    }
46
47    let mut cluster_map: HashMap<i64, ClusterData> = HashMap::new();
48    {
49        let mut stmt = conn.prepare(
50            "SELECT id, hash, cluster_id FROM faces \
51             WHERE cluster_id IS NOT NULL AND (confirmed = 0 OR person_label IS NULL) \
52             ORDER BY cluster_id, id",
53        )?;
54        let rows = stmt.query_map([], |r| {
55            Ok((
56                r.get::<_, i64>(0)?,
57                r.get::<_, String>(1)?,
58                r.get::<_, i64>(2)?,
59            ))
60        })?;
61        for row in rows {
62            let (id, hash, cid) = row?;
63            let cluster = cluster_map.entry(cid).or_insert(ClusterData {
64                cluster_id: cid,
65                face_ids: vec![],
66                hashes: vec![],
67            });
68            cluster.face_ids.push(id);
69            if !cluster.hashes.contains(&hash) {
70                cluster.hashes.push(hash);
71            }
72        }
73    }
74
75    let mut singletons: Vec<SingletonData> = vec![];
76    {
77        let mut stmt = conn.prepare(
78            "SELECT id, hash FROM faces \
79             WHERE cluster_id IS NULL AND (confirmed = 0 OR person_label IS NULL) \
80             ORDER BY id",
81        )?;
82        let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
83        for row in rows {
84            let (id, hash) = row?;
85            singletons.push(SingletonData { face_id: id, hash });
86        }
87    }
88
89    // Both maps are HashMaps, whose iteration order is arbitrary and differs
90    // between instances, so collecting straight from them threw away the
91    // ORDER BY the queries above establish. The labeling UI re-fetches this
92    // list after every assignment, so the effect was that people and clusters
93    // reshuffled on each drop: the cluster lined up next moved somewhere else,
94    // and so did the person being dragged onto. `singletons` never had the
95    // problem, and the difference is exactly that it is built as a Vec.
96    //
97    // Clusters are ordered largest first, which is the order people label in:
98    // the big clusters are worth the most and are the easiest to recognise.
99    // cluster_id breaks ties so the order is total, not merely sorted.
100    let mut people: Vec<PersonData> = people.into_values().collect();
101    people.sort_by(|a, b| a.full_name.to_lowercase().cmp(&b.full_name.to_lowercase()));
102    let mut clusters: Vec<ClusterData> = cluster_map.into_values().collect();
103    clusters.sort_by(|a, b| {
104        b.face_ids
105            .len()
106            .cmp(&a.face_ids.len())
107            .then(a.cluster_id.cmp(&b.cluster_id))
108    });
109
110    Ok(FacesData {
111        people,
112        clusters,
113        singletons,
114    })
115}
116
117/// Every face in one unassigned cluster (for the cluster detail page).
118pub fn cluster_detail(conn: &Connection, cluster_id: i64) -> Result<ClusterDetail> {
119    let mut stmt = conn.prepare(
120        "SELECT f.id, f.hash, fh.path FROM faces f \
121         JOIN file_hashes fh ON f.hash = fh.hash \
122         WHERE f.cluster_id = ?1 ORDER BY f.id",
123    )?;
124    let faces = stmt
125        .query_map([cluster_id], |r| {
126            Ok(ClusterFaceData {
127                face_id: r.get(0)?,
128                hash: r.get(1)?,
129                path: r.get(2)?,
130            })
131        })?
132        .collect::<rusqlite::Result<Vec<_>>>()?;
133    Ok(ClusterDetail { cluster_id, faces })
134}
135
136/// Every confirmed face for one person, primary first and flagged.
137pub fn person_detail(conn: &Connection, name: &str) -> Result<PersonDetail> {
138    // Reads normalize too, so `/person/Erhan`, `/person/erhan` and the original
139    // spelling all reach the same person. That is what keeps existing links
140    // working across the migration without a redirect table.
141    let name = videre_core::person::normalize(name).unwrap_or_else(|| name.to_string());
142    let name = name.as_str();
143    let mut stmt = conn.prepare(
144        "SELECT f.id, f.hash, fh.path, f.is_primary FROM faces f \
145         JOIN file_hashes fh ON f.hash = fh.hash \
146         WHERE f.person_label = ?1 AND f.confirmed = 1 \
147         ORDER BY f.is_primary DESC, f.id",
148    )?;
149    let faces = stmt
150        .query_map([name], |r| {
151            Ok(PersonFaceData {
152                face_id: r.get(0)?,
153                hash: r.get(1)?,
154                path: r.get(2)?,
155                is_primary: r.get::<_, i64>(3)? != 0,
156            })
157        })?
158        .collect::<rusqlite::Result<Vec<_>>>()?;
159    // Falls back to the identity for a person with no row yet, so a library
160    // opened before the migration still shows something sensible.
161    let full_name: String = conn
162        .query_row(
163            "SELECT full_name FROM people WHERE name = ?1",
164            rusqlite::params![name],
165            |r| r.get(0),
166        )
167        .unwrap_or_else(|_| name.to_string());
168    Ok(PersonDetail {
169        label: name.to_string(),
170        full_name,
171        faces,
172    })
173}
174
175/// Image paths for confirmed faces of a person (prefix match), for the
176/// person-name autocomplete. Delegates to the existing core search.
177pub fn search_person(conn: &Connection, name: &str) -> Result<Vec<String>> {
178    Ok(videre_core::person_search::search_by_person(
179        conn, name, None,
180    )?)
181}
182
183/// Assign faces to an existing/new person: sets person_label + confirmed.
184/// Rejects an empty label after sanitizing.
185pub fn assign(conn: &Connection, face_ids: &[i64], person_label: &str) -> Result<()> {
186    // What was typed becomes the display name; its normalized form is the
187    // identity written to every face row. Upserting keeps `people` complete
188    // without a separate "create person" step.
189    let display = crate::label::sanitize_person_label(person_label).ok_or(Error::Invalid)?;
190    let label = videre_core::person::normalize(&display).ok_or(Error::Invalid)?;
191    conn.execute(
192        "INSERT INTO people (name, full_name) VALUES (?1, ?2) ON CONFLICT(name) DO NOTHING",
193        rusqlite::params![&label, &display],
194    )?;
195    for id in face_ids {
196        conn.execute(
197            "UPDATE faces SET person_label = ?1, confirmed = 1 WHERE id = ?2",
198            rusqlite::params![label, id],
199        )?;
200    }
201    Ok(())
202}
203
204/// Create a person from faces. Same effect as `assign`; kept as a distinct
205/// operation because callers treat "new person" and "assign to existing" as
206/// separate user intents.
207pub fn new_person(conn: &Connection, face_ids: &[i64], label: &str) -> Result<()> {
208    assign(conn, face_ids, label)
209}
210
211/// Reset one face to fully unassigned (cluster, label, confirmed, primary).
212pub fn remove_face(conn: &Connection, face_id: i64) -> Result<()> {
213    conn.execute(
214        "UPDATE faces SET cluster_id = NULL, person_label = NULL, confirmed = 0, is_primary = 0 WHERE id = ?1",
215        [face_id],
216    )?;
217    Ok(())
218}
219
220/// Ungroup a bad cluster: its faces become unassigned singletons (not deleted).
221pub fn dissolve_cluster(conn: &Connection, cluster_id: i64) -> Result<()> {
222    conn.execute(
223        "UPDATE faces SET cluster_id = NULL WHERE cluster_id = ?1",
224        [cluster_id],
225    )?;
226    Ok(())
227}
228
229/// Reset every face of a person back to unassigned. Deliberately does NOT touch
230/// cluster_id, so a face rejoins its cluster's unassigned group rather than
231/// scattering to singletons.
232/// Change only what a person is shown as, never their identity.
233///
234/// A separate operation from `rename_person` on purpose. Intent cannot be
235/// inferred from the new string: `Erhan` to `Erhan Gündoğan` is a display
236/// correction, yet its normalized form changes too, so a single function would
237/// have to guess which the caller meant. One row, no face touched, and the URL
238/// keeps working - which is the whole reason identity and display are separate.
239pub fn set_full_name(conn: &Connection, name: &str, full_name: &str) -> Result<()> {
240    let display = crate::label::sanitize_person_label(full_name).ok_or(Error::Invalid)?;
241    let name = videre_core::person::normalize(name).ok_or(Error::Invalid)?;
242    let n = conn.execute(
243        "UPDATE people SET full_name = ?1 WHERE name = ?2",
244        rusqlite::params![display, name],
245    )?;
246    if n == 0 {
247        return Err(Error::NotFound);
248    }
249    Ok(())
250}
251
252pub fn delete_person(conn: &Connection, label: &str) -> Result<()> {
253    let label = videre_core::person::normalize(label).unwrap_or_else(|| label.to_string());
254    conn.execute(
255        "UPDATE faces SET person_label = NULL, confirmed = 0, is_primary = 0 WHERE person_label = ?1",
256        rusqlite::params![label],
257    )?;
258    Ok(())
259}
260
261/// Mark one face as the person's primary (their labeling-page thumbnail),
262/// clearing any previous primary in the same transaction so exactly one
263/// remains. The target update is guarded by person_label so it can't steal a
264/// face from another person.
265pub fn set_primary(conn: &Connection, face_id: i64, person_label: &str) -> Result<()> {
266    let person_label =
267        videre_core::person::normalize(person_label).unwrap_or_else(|| person_label.to_string());
268    conn.execute_batch("BEGIN")?;
269    let result = (|| -> rusqlite::Result<()> {
270        conn.execute(
271            "UPDATE faces SET is_primary = 0 WHERE person_label = ?1",
272            rusqlite::params![person_label],
273        )?;
274        conn.execute(
275            "UPDATE faces SET is_primary = 1, confirmed = 1, person_label = ?1 WHERE id = ?2 AND person_label = ?1",
276            rusqlite::params![person_label, face_id],
277        )?;
278        Ok(())
279    })();
280    match result {
281        Ok(()) => {
282            conn.execute_batch("COMMIT")?;
283            Ok(())
284        }
285        Err(e) => {
286            let _ = conn.execute_batch("ROLLBACK");
287            Err(Error::Db(e))
288        }
289    }
290}
291
292/// Rename a person. `NotFound` if `old_label` has no faces; `Conflict` if
293/// `new_label` (after sanitizing) already belongs to a different person;
294/// `Invalid` if the new label sanitizes to empty.
295pub fn rename_person(conn: &Connection, old_label: &str, new_label: &str) -> Result<()> {
296    // Both sides normalize, so renaming works whether the caller passes an
297    // identity (`erhan`) or what a human sees (`Erhan Gündoğan`).
298    let display = crate::label::sanitize_person_label(new_label).ok_or(Error::Invalid)?;
299    let new_name = videre_core::person::normalize(&display).ok_or(Error::Invalid)?;
300    let old_name = videre_core::person::normalize(old_label).ok_or(Error::Invalid)?;
301
302    let old_count: i64 = conn
303        .query_row(
304            "SELECT COUNT(*) FROM faces WHERE person_label = ?1",
305            rusqlite::params![&old_name],
306            |row| row.get(0),
307        )
308        .unwrap_or(0);
309    if old_count == 0 {
310        return Err(Error::NotFound);
311    }
312
313    // Changing only the spelling - `Erhan` to `Erhan Gündoğan` - leaves the
314    // identity alone, so it is a one-row update and can never collide. That is
315    // the common rename, and the whole reason display and identity are separate.
316    if new_name == old_name {
317        conn.execute(
318            "INSERT INTO people (name, full_name) VALUES (?1, ?2) \
319             ON CONFLICT(name) DO UPDATE SET full_name = excluded.full_name",
320            rusqlite::params![&new_name, &display],
321        )?;
322        return Ok(());
323    }
324
325    let collision_count: i64 = conn
326        .query_row(
327            "SELECT COUNT(*) FROM faces WHERE person_label = ?1",
328            rusqlite::params![&new_name],
329            |row| row.get(0),
330        )
331        .unwrap_or(0);
332    if collision_count > 0 {
333        return Err(Error::Conflict);
334    }
335
336    // An identity rename moves both the people row and every face pointing at
337    // it. Two statements in one transaction, which is what this did before the
338    // people table existed too.
339    let tx = conn.unchecked_transaction()?;
340    tx.execute(
341        "INSERT INTO people (name, full_name) VALUES (?1, ?2) \
342         ON CONFLICT(name) DO UPDATE SET full_name = excluded.full_name",
343        rusqlite::params![&new_name, &display],
344    )?;
345    tx.execute(
346        "UPDATE faces SET person_label = ?1 WHERE person_label = ?2",
347        rusqlite::params![&new_name, &old_name],
348    )?;
349    tx.execute(
350        "DELETE FROM people WHERE name = ?1",
351        rusqlite::params![&old_name],
352    )?;
353    tx.commit()?;
354    Ok(())
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    /// In-memory db with the faces + file_hashes tables and a few rows:
362    /// - face 1: person "Alice", confirmed, is_primary
363    /// - face 2: person "Alice", confirmed
364    /// - face 3: cluster 7 (unassigned)
365    /// - face 4: cluster 7 (unassigned)
366    /// - face 5: singleton (no cluster, unassigned)
367    pub(super) fn seed() -> Connection {
368        let conn = Connection::open_in_memory().unwrap();
369        videre_core::face_db::create_faces_table(&conn).unwrap();
370        conn.execute_batch(
371            "CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);
372             INSERT INTO file_hashes VALUES ('h1','/p/1.jpg'),('h2','/p/2.jpg'),
373                ('h3','/p/3.jpg'),('h4','/p/4.jpg'),('h5','/p/5.jpg');
374             -- Labels are stored in identity form, as `assign` writes them and
375             -- as the migration leaves them; `people` carries what a reader
376             -- sees. Seeding raw 'Alice' would test a state the application no
377             -- longer produces.
378             INSERT INTO people (name, full_name) VALUES ('alice','Alice');
379             INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
380                (1,'h1','0,0,9,9',X'0000',NULL,'alice',1,1),
381                (2,'h2','0,0,9,9',X'0000',NULL,'alice',1,0),
382                (3,'h3','0,0,9,9',X'0000',7,NULL,0,0),
383                (4,'h4','0,0,9,9',X'0000',7,NULL,0,0),
384                (5,'h5','0,0,9,9',X'0000',NULL,NULL,0,0);",
385        )
386        .unwrap();
387        videre_core::db::ensure_file_hashes_columns(&conn);
388        conn
389    }
390
391    #[test]
392    fn the_list_comes_back_in_the_same_order_every_time() {
393        // The labeling UI re-fetches after every assignment, so an unstable
394        // order means the cluster lined up next moves, and so does the person
395        // being dragged onto. Both lists were collected straight out of a
396        // HashMap, which discarded the ORDER BY in the queries above.
397        // `singletons` never had the bug, and the only difference is that it is
398        // built as a Vec.
399        let conn = seed();
400        // The seed has one cluster and one person, which cannot show an
401        // ordering problem. Add enough of both to have an order at all, with
402        // sizes deliberately not matching id order.
403        conn.execute_batch(
404            // Columns named explicitly: `seed` runs ensure_file_hashes_columns,
405            // so the table has more than the two it was created with.
406            "INSERT INTO file_hashes (hash, path) VALUES ('h6','/p/6.jpg'),('h7','/p/7.jpg'),
407                ('h8','/p/8.jpg'),('h9','/p/9.jpg'),('h10','/p/10.jpg');
408             INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
409                (6,'h6','0,0,9,9',X'0000',9,NULL,0,0),
410                (7,'h7','0,0,9,9',X'0000',9,NULL,0,0),
411                (8,'h8','0,0,9,9',X'0000',9,NULL,0,0),
412                (9,'h9','0,0,9,9',X'0000',3,NULL,0,0),
413                (10,'h10','0,0,9,9',X'0000',NULL,'Bob',1,0);",
414        )
415        .unwrap();
416
417        // Two calls on one connection: each builds fresh HashMaps, and Rust
418        // seeds them differently, so an unstable order shows up here.
419        let a = faces_list(&conn).unwrap();
420        let b = faces_list(&conn).unwrap();
421
422        let ids = |f: &FacesData| -> Vec<i64> { f.clusters.iter().map(|c| c.cluster_id).collect() };
423        let names =
424            |f: &FacesData| -> Vec<String> { f.people.iter().map(|p| p.label.clone()).collect() };
425        assert!(ids(&a).len() >= 3, "fixture must have several clusters");
426        assert_eq!(
427            ids(&a),
428            ids(&b),
429            "cluster order must not change between calls"
430        );
431        assert_eq!(
432            names(&a),
433            names(&b),
434            "people order must not change between calls"
435        );
436
437        // And the order is the useful one: biggest first, so the cluster worth
438        // the most labelling effort is where it is expected.
439        let sizes: Vec<usize> = a.clusters.iter().map(|c| c.face_ids.len()).collect();
440        let mut want = sizes.clone();
441        want.sort_unstable_by(|x, y| y.cmp(x));
442        assert_eq!(
443            sizes, want,
444            "clusters must be ordered largest first, got {sizes:?}"
445        );
446    }
447
448    #[test]
449    fn faces_list_splits_people_clusters_singletons() {
450        let conn = seed();
451        let d = faces_list(&conn).unwrap();
452        assert_eq!(d.people.len(), 1);
453        // Identity is the normalized form; what a reader sees is separate.
454        assert_eq!(d.people[0].label, "alice");
455        assert_eq!(d.people[0].full_name, "Alice");
456        assert_eq!(
457            d.people[0].representative_id, 1,
458            "primary face is representative"
459        );
460        assert_eq!(d.clusters.len(), 1);
461        assert_eq!(d.clusters[0].cluster_id, 7);
462        assert_eq!(d.clusters[0].face_ids, vec![3, 4]);
463        assert_eq!(d.singletons.len(), 1);
464        assert_eq!(d.singletons[0].face_id, 5);
465    }
466
467    #[test]
468    fn person_detail_marks_primary() {
469        let conn = seed();
470        let p = person_detail(&conn, "Alice").unwrap();
471        assert_eq!(p.faces.len(), 2);
472        assert!(p.faces[0].is_primary, "primary sorts first and is flagged");
473        assert!(!p.faces[1].is_primary);
474    }
475
476    #[test]
477    fn cluster_detail_lists_faces() {
478        let conn = seed();
479        let c = cluster_detail(&conn, 7).unwrap();
480        assert_eq!(c.cluster_id, 7);
481        assert_eq!(
482            c.faces.iter().map(|f| f.face_id).collect::<Vec<_>>(),
483            vec![3, 4]
484        );
485    }
486
487    #[test]
488    fn assign_labels_and_confirms() {
489        let conn = seed();
490        assign(&conn, &[3, 4], "Bob").unwrap();
491        let p = person_detail(&conn, "Bob").unwrap();
492        assert_eq!(p.faces.len(), 2, "both faces now confirmed under Bob");
493    }
494
495    #[test]
496    fn assign_rejects_empty_label() {
497        let conn = seed();
498        assert!(matches!(assign(&conn, &[3], "   "), Err(Error::Invalid)));
499    }
500
501    #[test]
502    fn remove_face_unassigns_everything() {
503        let conn = seed();
504        remove_face(&conn, 1).unwrap();
505        let (cid, label, confirmed, prim): (Option<i64>, Option<String>, i64, i64) = conn
506            .query_row(
507                "SELECT cluster_id, person_label, confirmed, is_primary FROM faces WHERE id=1",
508                [],
509                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
510            )
511            .unwrap();
512        assert_eq!((cid, label, confirmed, prim), (None, None, 0, 0));
513    }
514
515    #[test]
516    fn dissolve_cluster_nulls_cluster_id() {
517        let conn = seed();
518        dissolve_cluster(&conn, 7).unwrap();
519        assert_eq!(faces_list(&conn).unwrap().clusters.len(), 0);
520        assert_eq!(
521            faces_list(&conn).unwrap().singletons.len(),
522            3,
523            "3,4 join 5 as singletons"
524        );
525    }
526
527    #[test]
528    fn delete_person_unassigns_without_touching_cluster() {
529        let conn = seed();
530        // Give one of Alice's faces a cluster_id so we can prove delete_person
531        // leaves cluster_id intact (it must, so the face rejoins its cluster's
532        // unassigned group rather than scattering to singletons).
533        conn.execute("UPDATE faces SET cluster_id = 42 WHERE id = 1", [])
534            .unwrap();
535        delete_person(&conn, "Alice").unwrap();
536        assert_eq!(faces_list(&conn).unwrap().people.len(), 0, "Alice is gone");
537        let (cid, label, confirmed): (Option<i64>, Option<String>, i64) = conn
538            .query_row(
539                "SELECT cluster_id, person_label, confirmed FROM faces WHERE id = 1",
540                [],
541                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
542            )
543            .unwrap();
544        assert_eq!(cid, Some(42), "cluster_id must be preserved");
545        assert_eq!(label, None, "person_label cleared");
546        assert_eq!(confirmed, 0, "confirmed cleared");
547    }
548
549    #[test]
550    fn set_primary_is_exclusive_per_person() {
551        let conn = seed();
552        set_primary(&conn, 2, "Alice").unwrap();
553        let primaries: Vec<i64> = {
554            let mut s = conn
555                .prepare("SELECT id FROM faces WHERE person_label='alice' AND is_primary=1")
556                .unwrap();
557            s.query_map([], |r| r.get(0))
558                .unwrap()
559                .collect::<rusqlite::Result<_>>()
560                .unwrap()
561        };
562        assert_eq!(primaries, vec![2], "exactly one primary, now face 2");
563    }
564
565    #[test]
566    fn rename_missing_person_is_not_found() {
567        let conn = seed();
568        assert!(matches!(
569            rename_person(&conn, "Nobody", "X"),
570            Err(Error::NotFound)
571        ));
572    }
573
574    #[test]
575    fn rename_onto_existing_person_conflicts() {
576        let conn = seed();
577        assign(&conn, &[3], "Bob").unwrap(); // Bob now exists
578        assert!(matches!(
579            rename_person(&conn, "Alice", "Bob"),
580            Err(Error::Conflict)
581        ));
582    }
583
584    #[test]
585    fn rename_succeeds() {
586        let conn = seed();
587        rename_person(&conn, "Alice", "Alicia").unwrap();
588        assert_eq!(person_detail(&conn, "Alicia").unwrap().faces.len(), 2);
589        assert_eq!(person_detail(&conn, "Alice").unwrap().faces.len(), 0);
590        // The identity moved with it, so the old people row is gone.
591        let names: Vec<String> = conn
592            .prepare("SELECT name FROM people ORDER BY name")
593            .unwrap()
594            .query_map([], |r| r.get(0))
595            .unwrap()
596            .collect::<rusqlite::Result<_>>()
597            .unwrap();
598        assert_eq!(names, vec!["alicia".to_string()]);
599    }
600
601    #[test]
602    fn renaming_only_the_spelling_keeps_the_identity() {
603        // The common rename: correcting or extending what is shown, which must
604        // not change the URL or touch a single face row.
605        let conn = seed();
606        set_full_name(&conn, "alice", "Alice Smith").unwrap();
607        let (name, full): (String, String) = conn
608            .query_row("SELECT name, full_name FROM people", [], |r| {
609                Ok((r.get(0)?, r.get(1)?))
610            })
611            .unwrap();
612        assert_eq!(name, "alice", "identity is unchanged");
613        assert_eq!(full, "Alice Smith", "only the display name moved");
614        assert_eq!(person_detail(&conn, "alice").unwrap().faces.len(), 2);
615    }
616
617    #[test]
618    fn rename_to_empty_label_is_invalid() {
619        let conn = seed();
620        assert!(matches!(
621            rename_person(&conn, "Alice", "   "),
622            Err(Error::Invalid)
623        ));
624    }
625}
626
627#[cfg(test)]
628mod identity_tests {
629    use super::tests::seed;
630    use super::*;
631
632    fn people(conn: &Connection) -> Vec<(String, String)> {
633        conn.prepare("SELECT name, full_name FROM people ORDER BY name")
634            .unwrap()
635            .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
636            .unwrap()
637            .collect::<rusqlite::Result<_>>()
638            .unwrap()
639    }
640
641    #[test]
642    fn assign_stores_the_identity_and_records_the_display_name() {
643        let conn = seed();
644        assign(&conn, &[3], "Işıl Özyeğin").unwrap();
645
646        let label: String = conn
647            .query_row("SELECT person_label FROM faces WHERE id = 3", [], |r| {
648                r.get(0)
649            })
650            .unwrap();
651        assert_eq!(label, "isil_ozyegin", "faces hold the identity");
652        assert!(
653            people(&conn).contains(&("isil_ozyegin".into(), "Işıl Özyeğin".into())),
654            "and the spelling is kept for display"
655        );
656    }
657
658    #[test]
659    fn assigning_an_existing_name_in_another_case_joins_that_person() {
660        // The bug this whole change exists to fix: this used to create a second
661        // person.
662        let conn = seed();
663        assign(&conn, &[3], "ALICE").unwrap();
664        assert_eq!(people(&conn).len(), 1, "still one person, not two");
665        assert_eq!(person_detail(&conn, "alice").unwrap().faces.len(), 3);
666        assert_eq!(
667            people(&conn)[0].1,
668            "Alice",
669            "the existing spelling is not overwritten by the new casing"
670        );
671    }
672
673    #[test]
674    fn assign_rejects_a_name_with_no_usable_identity() {
675        // Punctuation alone leaves nothing to identify a person by, and an
676        // empty identity would be a person nobody could address.
677        let conn = seed();
678        assert!(matches!(assign(&conn, &[3], "!!!"), Err(Error::Invalid)));
679    }
680
681    #[test]
682    fn person_detail_resolves_every_form_of_the_name() {
683        let conn = seed();
684        for form in ["alice", "Alice", "ALICE", "  alice  "] {
685            assert_eq!(
686                person_detail(&conn, form).unwrap().faces.len(),
687                2,
688                "form {form:?}"
689            );
690        }
691    }
692
693    #[test]
694    fn person_detail_reports_the_display_name() {
695        let d = person_detail(&seed(), "alice").unwrap();
696        assert_eq!(d.label, "alice");
697        assert_eq!(d.full_name, "Alice");
698    }
699
700    #[test]
701    fn person_detail_falls_back_when_there_is_no_people_row() {
702        // A label written before the table existed still has to render.
703        let conn = seed();
704        conn.execute(
705            "INSERT INTO faces (id,hash,bbox,embedding,person_label,confirmed) \
706             VALUES (9,'h9','0,0,9,9',X'0000','orphan',1)",
707            [],
708        )
709        .unwrap();
710        let d = person_detail(&conn, "orphan").unwrap();
711        assert_eq!(d.full_name, "orphan", "falls back to the identity");
712    }
713
714    #[test]
715    fn set_full_name_changes_only_the_display_name() {
716        let conn = seed();
717        set_full_name(&conn, "alice", "Alice Smith").unwrap();
718        assert_eq!(people(&conn), vec![("alice".into(), "Alice Smith".into())]);
719        assert_eq!(
720            person_detail(&conn, "alice").unwrap().faces.len(),
721            2,
722            "no face was touched"
723        );
724    }
725
726    #[test]
727    fn set_full_name_accepts_any_form_of_the_identity() {
728        let conn = seed();
729        set_full_name(&conn, "ALICE", "Alice Smith").unwrap();
730        assert_eq!(people(&conn)[0].1, "Alice Smith");
731    }
732
733    #[test]
734    fn set_full_name_on_a_missing_person_is_not_found() {
735        assert!(matches!(
736            set_full_name(&seed(), "nobody", "Someone"),
737            Err(Error::NotFound)
738        ));
739    }
740
741    #[test]
742    fn set_full_name_rejects_an_empty_display_name() {
743        // A person with no name to show is worse than one shown by identity.
744        assert!(matches!(
745            set_full_name(&seed(), "alice", "   "),
746            Err(Error::Invalid)
747        ));
748    }
749
750    #[test]
751    fn delete_person_accepts_any_form_of_the_name() {
752        let conn = seed();
753        delete_person(&conn, "Alice").unwrap();
754        let left: i64 = conn
755            .query_row(
756                "SELECT COUNT(*) FROM faces WHERE person_label IS NOT NULL",
757                [],
758                |r| r.get(0),
759            )
760            .unwrap();
761        assert_eq!(left, 0, "faces are unassigned whichever form was passed");
762    }
763
764    #[test]
765    fn set_primary_accepts_any_form_of_the_name() {
766        let conn = seed();
767        set_primary(&conn, 2, "ALICE").unwrap();
768        let primary: i64 = conn
769            .query_row(
770                "SELECT id FROM faces WHERE person_label='alice' AND is_primary=1",
771                [],
772                |r| r.get(0),
773            )
774            .unwrap();
775        assert_eq!(primary, 2);
776    }
777}