1use anyhow::Result;
13use rusqlite::Connection;
14use std::collections::HashSet;
15
16pub const EFFECTIVE_DATE_SQL: &str = "CASE WHEN exif_date IS NOT NULL \
23 AND exif_date NOT LIKE '0000%' THEN exif_date ELSE modified_at END";
24
25pub fn by_date(
30 conn: &Connection,
31 after: Option<&str>,
32 before: Option<&str>,
33) -> Result<HashSet<String>> {
34 let mut sql =
35 format!("SELECT DISTINCT hash FROM file_hashes WHERE {EFFECTIVE_DATE_SQL} IS NOT NULL");
36 let mut params: Vec<String> = Vec::new();
37 if let Some(a) = after {
38 sql.push_str(&format!(" AND {EFFECTIVE_DATE_SQL} >= ?"));
39 params.push(a.to_string());
40 }
41 if let Some(b) = before {
42 sql.push_str(&format!(" AND {EFFECTIVE_DATE_SQL} < ?"));
43 params.push(b.to_string());
44 }
45 let mut stmt = conn.prepare(&sql)?;
46 let rows = stmt.query_map(rusqlite::params_from_iter(params), |r| {
47 r.get::<_, String>(0)
48 })?;
49 Ok(rows.collect::<rusqlite::Result<HashSet<String>>>()?)
50}
51
52use chrono::NaiveDate;
53
54const DATE_FORMS: &str = "expected YYYY, YYYY-MM, YYYY-MM-DD, or YYYY-MM-DDTHH:MM:SS";
55
56fn start_of(y: i32, m: u32, d: u32) -> Result<String> {
57 NaiveDate::from_ymd_opt(y, m, d)
58 .map(|x| format!("{}T00:00:00", x.format("%Y-%m-%d")))
59 .ok_or_else(|| anyhow::anyhow!("invalid date {y:04}-{m:02}-{d:02}; {DATE_FORMS}"))
60}
61
62pub fn expand_date(spec: &str) -> Result<(String, String)> {
64 let parts: Vec<&str> = spec.split('-').collect();
65 let bad = || anyhow::anyhow!("cannot parse date {spec:?}; {DATE_FORMS}");
66 match parts.as_slice() {
67 [y] => {
68 let y: i32 = y.parse().map_err(|_| bad())?;
69 Ok((start_of(y, 1, 1)?, start_of(y + 1, 1, 1)?))
70 }
71 [y, m] => {
72 let (y, m): (i32, u32) = (y.parse().map_err(|_| bad())?, m.parse().map_err(|_| bad())?);
73 let start = start_of(y, m, 1)?;
74 let end = if m == 12 {
75 start_of(y + 1, 1, 1)?
76 } else {
77 start_of(y, m + 1, 1)?
78 };
79 Ok((start, end))
80 }
81 [y, m, d] => {
82 let (y, m, d): (i32, u32, u32) = (
83 y.parse().map_err(|_| bad())?,
84 m.parse().map_err(|_| bad())?,
85 d.parse().map_err(|_| bad())?,
86 );
87 let day = NaiveDate::from_ymd_opt(y, m, d).ok_or_else(bad)?;
88 let next = day.succ_opt().ok_or_else(bad)?;
89 Ok((
90 format!("{}T00:00:00", day.format("%Y-%m-%d")),
91 format!("{}T00:00:00", next.format("%Y-%m-%d")),
92 ))
93 }
94 _ => Err(bad()),
95 }
96}
97
98pub fn normalise_bound(spec: &str) -> Result<String> {
100 if spec.contains('T') {
101 return Ok(spec.to_string());
102 }
103 let (start, _) = expand_date(spec)?;
104 Ok(start)
105}
106
107pub fn by_person(conn: &Connection, name: &str) -> Result<HashSet<String>> {
109 let identities = crate::person::resolve_identities(conn, name)?;
111
112 let placeholders = std::iter::repeat_n("?", identities.len())
113 .collect::<Vec<_>>()
114 .join(",");
115 let sql = format!(
116 "SELECT DISTINCT hash FROM faces \
117 WHERE confirmed = 1 AND person_label IN ({placeholders})"
118 );
119 let mut stmt = conn.prepare(&sql)?;
120 let rows = stmt.query_map(rusqlite::params_from_iter(identities.iter()), |r| {
121 r.get::<_, String>(0)
122 })?;
123 Ok(rows.collect::<rusqlite::Result<HashSet<String>>>()?)
124}
125
126pub fn by_category(conn: &Connection, model_id: &str, category: &str) -> Result<HashSet<String>> {
128 let mut stmt = conn.prepare(
129 "SELECT DISTINCT hash FROM classifications WHERE model_id = ?1 AND category = ?2",
130 )?;
131 let rows = stmt.query_map(rusqlite::params![model_id, category], |r| {
132 r.get::<_, String>(0)
133 })?;
134 Ok(rows.collect::<rusqlite::Result<HashSet<String>>>()?)
135}
136
137use std::collections::HashMap;
138
139fn haversine(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
141 const R: f64 = 6371.0;
142 let (dlat, dlon) = ((lat2 - lat1).to_radians(), (lon2 - lon1).to_radians());
143 let a = (dlat / 2.0).sin().powi(2)
144 + lat1.to_radians().cos() * lat2.to_radians().cos() * (dlon / 2.0).sin().powi(2);
145 2.0 * R * a.sqrt().asin()
146}
147
148pub fn by_location(
153 conn: &Connection,
154 lat: f64,
155 lon: f64,
156 radius_km: f64,
157) -> Result<HashMap<String, f64>> {
158 let mut stmt = conn.prepare(
159 "SELECT hash, gps_lat, gps_lon FROM file_hashes
160 WHERE gps_lat IS NOT NULL AND gps_lon IS NOT NULL",
161 )?;
162 let rows = stmt.query_map([], |r| {
163 Ok((
164 r.get::<_, String>(0)?,
165 r.get::<_, f64>(1)?,
166 r.get::<_, f64>(2)?,
167 ))
168 })?;
169 let mut out = HashMap::new();
170 for row in rows {
171 let (hash, plat, plon) = row?;
172 let d = haversine(lat, lon, plat, plon);
173 if d <= radius_km {
174 out.entry(hash)
176 .and_modify(|e: &mut f64| *e = e.min(d))
177 .or_insert(d);
178 }
179 }
180 Ok(out)
181}
182
183#[derive(Debug, Clone, Copy)]
184pub struct GeoFilter {
185 pub lat: f64,
186 pub lon: f64,
187 pub radius_km: f64,
188}
189
190pub struct Candidates {
197 pub hashes: Option<HashSet<String>>,
200 pub distances: Option<HashMap<String, f64>>,
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum SortField {
206 Relevance,
207 Distance,
208 Date,
209 Size,
210}
211
212impl SortField {
213 fn default_desc(self) -> bool {
216 !matches!(self, SortField::Distance)
217 }
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221pub struct SortKey {
222 pub field: SortField,
223 pub desc: bool,
224}
225
226const SORT_FIELDS: &str = "valid fields: relevance, distance, date, size";
227const SORT_DIRS: &str = "valid directions: asc, desc";
228
229pub fn parse_sort(spec: &str) -> Result<Vec<SortKey>> {
230 let mut out: Vec<SortKey> = Vec::new();
231 for raw in spec.split(',') {
232 let part = raw.trim();
233 if part.is_empty() {
234 anyhow::bail!("empty sort field; {SORT_FIELDS}");
235 }
236 let (name, dir) = match part.split_once(':') {
237 Some((n, d)) => (n.trim(), Some(d.trim())),
238 None => (part, None),
239 };
240 let field = match name.to_ascii_lowercase().as_str() {
241 "relevance" => SortField::Relevance,
242 "distance" => SortField::Distance,
243 "date" => SortField::Date,
244 "size" => SortField::Size,
245 other => anyhow::bail!("unknown sort field {other:?}; {SORT_FIELDS}"),
246 };
247 let desc = match dir.map(|d| d.to_ascii_lowercase()) {
248 None => field.default_desc(),
249 Some(d) if d == "asc" => false,
250 Some(d) if d == "desc" => true,
251 Some(d) => anyhow::bail!("unknown sort direction {d:?}; {SORT_DIRS}"),
252 };
253 if out.iter().any(|k| k.field == field) {
254 anyhow::bail!("sort field {name:?} repeated; each field may appear once");
255 }
256 out.push(SortKey { field, desc });
257 }
258 Ok(out)
259}
260
261use std::cmp::Ordering;
262
263#[derive(Debug, Clone)]
266pub struct Sortable {
267 pub path: String,
268 pub score: Option<f32>,
269 pub distance_km: Option<f64>,
270 pub date: Option<String>,
271 pub size_bytes: Option<i64>,
272}
273
274fn cmp_opt<T: PartialOrd>(a: &Option<T>, b: &Option<T>, desc: bool) -> Ordering {
277 match (a, b) {
278 (None, None) => Ordering::Equal,
279 (None, Some(_)) => Ordering::Greater,
280 (Some(_), None) => Ordering::Less,
281 (Some(x), Some(y)) => {
282 let base = x.partial_cmp(y).unwrap_or(Ordering::Equal);
283 if desc {
284 base.reverse()
285 } else {
286 base
287 }
288 }
289 }
290}
291
292pub fn apply_sort(hits: &mut [Sortable], keys: &[SortKey]) {
294 hits.sort_by(|a, b| {
295 for k in keys {
296 let ord = match k.field {
297 SortField::Relevance => cmp_opt(&a.score, &b.score, k.desc),
298 SortField::Distance => cmp_opt(&a.distance_km, &b.distance_km, k.desc),
299 SortField::Date => cmp_opt(&a.date, &b.date, k.desc),
300 SortField::Size => cmp_opt(&a.size_bytes, &b.size_bytes, k.desc),
301 };
302 if ord != Ordering::Equal {
303 return ord;
304 }
305 }
306 Ordering::Equal
307 });
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313 use rusqlite::Connection;
314
315 fn db() -> Connection {
316 let conn = Connection::open_in_memory().unwrap();
317 conn.execute_batch(
318 "CREATE TABLE file_hashes (
319 path TEXT PRIMARY KEY, hash TEXT NOT NULL,
320 size_bytes INTEGER, modified_at TEXT, exif_date TEXT
321 );",
322 )
323 .unwrap();
324 conn
325 }
326
327 fn add(conn: &Connection, path: &str, hash: &str, exif: Option<&str>, mtime: &str) {
328 conn.execute(
329 "INSERT INTO file_hashes (path, hash, size_bytes, modified_at, exif_date)
330 VALUES (?1, ?2, 100, ?3, ?4)",
331 rusqlite::params![path, hash, mtime, exif],
332 )
333 .unwrap();
334 }
335
336 #[test]
337 fn date_filter_matches_on_exif_when_present() {
338 let conn = db();
339 add(
340 &conn,
341 "/a.jpg",
342 "h1",
343 Some("2025-05-14T10:00:00"),
344 "2026-01-01T00:00:00",
345 );
346 let got = by_date(
347 &conn,
348 Some("2025-05-01T00:00:00"),
349 Some("2025-06-01T00:00:00"),
350 )
351 .unwrap();
352 assert!(got.contains("h1"), "exif_date must win over modified_at");
353 }
354
355 #[test]
356 fn date_filter_falls_back_to_modified_at() {
357 let conn = db();
358 add(&conn, "/b.png", "h2", None, "2025-05-14T10:00:00");
359 let got = by_date(
360 &conn,
361 Some("2025-05-01T00:00:00"),
362 Some("2025-06-01T00:00:00"),
363 )
364 .unwrap();
365 assert!(
366 got.contains("h2"),
367 "a file with no EXIF must match on modified_at"
368 );
369 }
370
371 #[test]
372 fn date_filter_ignores_zero_exif_dates() {
373 let conn = db();
374 add(
375 &conn,
376 "/c.jpg",
377 "h3",
378 Some("0000-00-00T00:00:00"),
379 "2025-05-14T10:00:00",
380 );
381 let got = by_date(
382 &conn,
383 Some("2025-05-01T00:00:00"),
384 Some("2025-06-01T00:00:00"),
385 )
386 .unwrap();
387 assert!(
388 got.contains("h3"),
389 "an unset camera clock must fall back, not match year 0"
390 );
391 }
392
393 #[test]
394 fn before_is_exclusive_so_ranges_tile() {
395 let conn = db();
396 add(
397 &conn,
398 "/d.jpg",
399 "h4",
400 Some("2025-06-01T00:00:00"),
401 "2025-06-01T00:00:00",
402 );
403 let may = by_date(
404 &conn,
405 Some("2025-05-01T00:00:00"),
406 Some("2025-06-01T00:00:00"),
407 )
408 .unwrap();
409 let jun = by_date(
410 &conn,
411 Some("2025-06-01T00:00:00"),
412 Some("2025-07-01T00:00:00"),
413 )
414 .unwrap();
415 assert!(
416 !may.contains("h4"),
417 "the boundary instant belongs to June only"
418 );
419 assert!(jun.contains("h4"));
420 }
421
422 #[test]
423 fn open_ended_ranges_work() {
424 let conn = db();
425 add(
426 &conn,
427 "/e.jpg",
428 "h5",
429 Some("2025-05-14T10:00:00"),
430 "2025-05-14T10:00:00",
431 );
432 assert!(by_date(&conn, Some("2025-01-01T00:00:00"), None)
433 .unwrap()
434 .contains("h5"));
435 assert!(by_date(&conn, None, Some("2026-01-01T00:00:00"))
436 .unwrap()
437 .contains("h5"));
438 }
439
440 #[test]
441 fn date_shorthand_expands_to_half_open_ranges() {
442 assert_eq!(
443 expand_date("2025").unwrap(),
444 ("2025-01-01T00:00:00".into(), "2026-01-01T00:00:00".into())
445 );
446 assert_eq!(
447 expand_date("2025-05").unwrap(),
448 ("2025-05-01T00:00:00".into(), "2025-06-01T00:00:00".into())
449 );
450 assert_eq!(
451 expand_date("2025-12").unwrap(),
452 ("2025-12-01T00:00:00".into(), "2026-01-01T00:00:00".into())
453 );
454 assert_eq!(
455 expand_date("2025-05-14").unwrap(),
456 ("2025-05-14T00:00:00".into(), "2025-05-15T00:00:00".into())
457 );
458 }
459
460 #[test]
461 fn date_shorthand_handles_month_and_year_rollover() {
462 assert_eq!(expand_date("2024-02-29").unwrap().1, "2024-03-01T00:00:00");
463 assert_eq!(expand_date("2025-12-31").unwrap().1, "2026-01-01T00:00:00");
464 }
465
466 #[test]
467 fn normalise_bound_accepts_date_or_datetime() {
468 assert_eq!(
469 normalise_bound("2025-05-14").unwrap(),
470 "2025-05-14T00:00:00"
471 );
472 assert_eq!(
473 normalise_bound("2025-05-14T09:30:00").unwrap(),
474 "2025-05-14T09:30:00"
475 );
476 }
477
478 #[test]
479 fn bad_dates_are_rejected_with_a_helpful_message() {
480 for bad in ["", "May 2025", "2025-13", "2025-02-30", "20250514"] {
481 let err = expand_date(bad).unwrap_err().to_string();
482 assert!(
483 err.contains("YYYY"),
484 "error for {bad:?} should name the accepted forms, got: {err}"
485 );
486 }
487 }
488
489 fn db_with_faces_and_classes() -> Connection {
490 let conn = db();
491 conn.execute_batch(
492 "CREATE TABLE faces (id INTEGER PRIMARY KEY, hash TEXT NOT NULL,
493 person_label TEXT, confirmed INTEGER DEFAULT 0);
494 CREATE TABLE IF NOT EXISTS people (name TEXT PRIMARY KEY, full_name TEXT NOT NULL);
495 INSERT INTO people (name, full_name) VALUES ('alice','Alice');
496 CREATE TABLE classifications (model_id TEXT NOT NULL, hash TEXT NOT NULL,
497 category TEXT NOT NULL, confidence REAL NOT NULL,
498 classified_at TEXT NOT NULL, PRIMARY KEY (model_id, hash));",
499 )
500 .unwrap();
501 conn
502 }
503
504 #[test]
505 fn person_predicate_returns_confirmed_only() {
506 let conn = db_with_faces_and_classes();
507 conn.execute_batch(
508 "INSERT INTO faces (hash, person_label, confirmed) VALUES
509 ('h1','alice',1), ('h2','alice',0), ('h3','Bob',1);",
510 )
511 .unwrap();
512 let got = by_person(&conn, "Alice").unwrap();
513 assert!(got.contains("h1"));
514 assert!(!got.contains("h2"), "unconfirmed faces must not match");
515 assert!(!got.contains("h3"));
516 }
517
518 #[test]
519 fn category_predicate_is_model_scoped() {
520 let conn = db_with_faces_and_classes();
521 conn.execute_batch(
522 "INSERT INTO classifications VALUES
523 ('m1','h1','screenshot',0.9,'now'),
524 ('m2','h2','screenshot',0.9,'now');",
525 )
526 .unwrap();
527 let got = by_category(&conn, "m1", "screenshot").unwrap();
528 assert!(got.contains("h1"));
529 assert!(!got.contains("h2"), "another model's rows must not leak in");
530 }
531
532 #[test]
533 fn predicates_return_empty_not_error_when_nothing_matches() {
534 let conn = db_with_faces_and_classes();
535 assert!(by_person(&conn, "Nobody").unwrap().is_empty());
536 assert!(by_category(&conn, "m1", "meme").unwrap().is_empty());
537 }
538
539 fn db_with_gps() -> Connection {
540 let conn = Connection::open_in_memory().unwrap();
541 conn.execute_batch(
542 "CREATE TABLE file_hashes (
543 path TEXT PRIMARY KEY, hash TEXT NOT NULL, size_bytes INTEGER,
544 modified_at TEXT, exif_date TEXT, gps_lat REAL, gps_lon REAL);
545 INSERT INTO file_hashes VALUES
546 ('/near.jpg','hn',100,'2025-01-01T00:00:00',NULL,52.5200,13.4050),
547 ('/far.jpg','hf',100,'2025-01-01T00:00:00',NULL,48.8566,2.3522),
548 ('/nogps.jpg','hx',100,'2025-01-01T00:00:00',NULL,NULL,NULL);",
549 )
550 .unwrap();
551 conn
552 }
553
554 #[test]
555 fn location_predicate_returns_only_within_radius_with_distances() {
556 let conn = db_with_gps();
557 let got = by_location(&conn, 52.5200, 13.4050, 10.0).unwrap();
559 assert!(got.contains_key("hn"));
560 assert!(
561 !got.contains_key("hf"),
562 "Paris is not within 10 km of Berlin"
563 );
564 assert!(!got.contains_key("hx"), "a file with no GPS cannot match");
565 assert!(
566 got["hn"] < 0.1,
567 "distance to itself should be ~0, got {}",
568 got["hn"]
569 );
570 }
571
572 #[test]
573 fn location_distance_is_roughly_correct_over_a_long_span() {
574 let conn = db_with_gps();
575 let got = by_location(&conn, 52.5200, 13.4050, 2000.0).unwrap();
576 let d = got["hf"];
577 assert!(
578 (870.0..890.0).contains(&d),
579 "Berlin to Paris is ~878 km, got {d}"
580 );
581 }
582
583 #[test]
584 fn sort_defaults_direction_per_field() {
585 assert_eq!(
586 parse_sort("distance,date").unwrap(),
587 vec![
588 SortKey {
589 field: SortField::Distance,
590 desc: false
591 },
592 SortKey {
593 field: SortField::Date,
594 desc: true
595 },
596 ]
597 );
598 assert_eq!(
599 parse_sort("relevance").unwrap(),
600 vec![SortKey {
601 field: SortField::Relevance,
602 desc: true
603 }]
604 );
605 assert_eq!(
606 parse_sort("size").unwrap(),
607 vec![SortKey {
608 field: SortField::Size,
609 desc: true
610 }]
611 );
612 }
613
614 #[test]
615 fn sort_accepts_explicit_directions() {
616 assert_eq!(
617 parse_sort("distance:desc,date:asc").unwrap(),
618 vec![
619 SortKey {
620 field: SortField::Distance,
621 desc: true
622 },
623 SortKey {
624 field: SortField::Date,
625 desc: false
626 },
627 ]
628 );
629 }
630
631 #[test]
632 fn sort_tolerates_spaces_and_case() {
633 assert_eq!(parse_sort(" Distance : ASC ").unwrap()[0].desc, false);
634 }
635
636 #[test]
637 fn sort_rejects_bad_specs_naming_valid_values() {
638 for (spec, needle) in [
639 ("bogus", "relevance"),
640 ("date:sideways", "asc"),
641 ("date,date", "repeated"),
642 ("", "relevance"),
643 ] {
644 let err = parse_sort(spec).unwrap_err().to_string();
645 assert!(
646 err.contains(needle),
647 "error for {spec:?} should mention {needle:?}, got: {err}"
648 );
649 }
650 }
651
652 fn hit(path: &str, score: Option<f32>, km: Option<f64>, date: &str, size: i64) -> Sortable {
653 Sortable {
654 path: path.into(),
655 score,
656 distance_km: km,
657 date: Some(date.into()),
658 size_bytes: Some(size),
659 }
660 }
661
662 #[test]
663 fn multi_field_sort_uses_later_fields_as_tie_breakers() {
664 let mut v = vec![
665 hit("/old.jpg", None, Some(1.0), "2024-01-01T00:00:00", 10),
666 hit("/new.jpg", None, Some(1.0), "2025-01-01T00:00:00", 10),
667 hit("/far.jpg", None, Some(9.0), "2026-01-01T00:00:00", 10),
668 ];
669 apply_sort(&mut v, &parse_sort("distance,date").unwrap());
670 let order: Vec<&str> = v.iter().map(|h| h.path.as_str()).collect();
671 assert_eq!(
672 order,
673 vec!["/new.jpg", "/old.jpg", "/far.jpg"],
674 "nearest first, newest first within the same distance"
675 );
676 }
677
678 #[test]
679 fn missing_sort_values_sort_last_in_both_directions() {
680 let mut v = vec![
681 Sortable {
682 path: "/none.jpg".into(),
683 score: None,
684 distance_km: None,
685 date: None,
686 size_bytes: None,
687 },
688 hit("/has.jpg", None, None, "2025-01-01T00:00:00", 10),
689 ];
690 apply_sort(&mut v, &parse_sort("date:desc").unwrap());
691 assert_eq!(
692 v[0].path, "/has.jpg",
693 "a row with no date must not outrank one with a date"
694 );
695 apply_sort(&mut v, &parse_sort("date:asc").unwrap());
696 assert_eq!(v[0].path, "/has.jpg", "and the same when ascending");
697 }
698
699 #[test]
700 fn sort_is_stable_on_full_ties() {
701 let mut v = vec![
702 hit("/a.jpg", None, Some(1.0), "2025-01-01T00:00:00", 10),
703 hit("/b.jpg", None, Some(1.0), "2025-01-01T00:00:00", 10),
704 ];
705 apply_sort(&mut v, &parse_sort("distance,date").unwrap());
706 assert_eq!(v[0].path, "/a.jpg", "equal rows keep their input order");
707 }
708}
709
710#[cfg(test)]
711mod person_matching_tests {
712 use super::*;
713
714 fn db() -> Connection {
717 let c = Connection::open_in_memory().unwrap();
718 c.execute_batch(
719 "CREATE TABLE faces (id INTEGER PRIMARY KEY, hash TEXT NOT NULL,
720 person_label TEXT, confirmed INTEGER DEFAULT 0);
721 CREATE TABLE people (name TEXT PRIMARY KEY, full_name TEXT NOT NULL);
722 INSERT INTO people (name, full_name) VALUES
723 ('ahmet_ari','Ahmet Arı'), ('erhan','Erhan Gündoğan');
724 INSERT INTO faces (id, hash, person_label, confirmed) VALUES
725 (1,'h1','ahmet_ari',1), (2,'h2','ahmet_ari',1),
726 (3,'h3','erhan',1),
727 (4,'h4','ahmet_ari',0);",
728 )
729 .unwrap();
730 c
731 }
732
733 #[test]
734 fn the_identity_matches() {
735 assert_eq!(by_person(&db(), "ahmet_ari").unwrap().len(), 2);
736 }
737
738 #[test]
739 fn the_display_name_matches_too() {
740 assert_eq!(by_person(&db(), "Ahmet Arı").unwrap().len(), 2);
742 }
743
744 #[test]
745 fn a_display_name_that_normalizes_differently_still_matches() {
746 let c = db();
750 assert_eq!(
751 videre_core_normalize("Erhan Gündoğan").as_deref(),
752 Some("erhan_gundogan"),
753 "precondition: the two forms genuinely disagree"
754 );
755 assert_eq!(by_person(&c, "Erhan Gündoğan").unwrap().len(), 1);
756 assert_eq!(by_person(&c, "erhan").unwrap().len(), 1, "identity too");
757 }
758
759 fn videre_core_normalize(s: &str) -> Option<String> {
760 crate::person::normalize(s)
761 }
762
763 #[test]
764 fn case_and_accents_are_ignored_on_both_forms() {
765 let c = db();
766 for q in ["AHMET ARI", "ahmet arı", "Ahmet_Ari", "ahmet_ari"] {
767 assert_eq!(by_person(&c, q).unwrap().len(), 2, "query {q:?}");
768 }
769 }
770
771 #[test]
772 fn unconfirmed_faces_are_excluded_whichever_form_is_used() {
773 let c = db();
776 assert_eq!(by_person(&c, "ahmet_ari").unwrap().len(), 2);
777 assert_eq!(by_person(&c, "Ahmet Arı").unwrap().len(), 2);
778 }
779
780 #[test]
781 fn a_renamed_person_is_found_by_a_turkish_display_name() {
782 let c = db();
788 c.execute(
789 "UPDATE people SET full_name = 'Özgür' WHERE name = 'erhan'",
790 [],
791 )
792 .unwrap();
793 assert_eq!(by_person(&c, "Özgür").unwrap().len(), 1, "as displayed");
794 assert_eq!(by_person(&c, "özgür").unwrap().len(), 1, "lowercased");
795 assert_eq!(by_person(&c, "ÖZGÜR").unwrap().len(), 1, "uppercased");
796 assert_eq!(by_person(&c, "erhan").unwrap().len(), 1, "identity still");
797 }
798
799 #[test]
800 fn two_people_sharing_a_display_name_are_both_returned() {
801 let c = db();
806 c.execute(
807 "INSERT INTO people (name, full_name) VALUES ('ozgur_tamer','Özgür')",
808 [],
809 )
810 .unwrap();
811 c.execute(
812 "UPDATE people SET full_name = 'Özgür' WHERE name = 'erhan'",
813 [],
814 )
815 .unwrap();
816 c.execute(
817 "INSERT INTO faces (id, hash, person_label, confirmed) VALUES (7,'h7','ozgur_tamer',1)",
818 [],
819 )
820 .unwrap();
821 assert_eq!(by_person(&c, "Özgür").unwrap().len(), 2, "both people");
822 assert_eq!(by_person(&c, "erhan").unwrap().len(), 1, "identity narrows");
823 assert_eq!(by_person(&c, "ozgur_tamer").unwrap().len(), 1);
824 }
825
826 #[test]
827 fn an_unknown_person_is_empty_not_an_error() {
828 assert!(by_person(&db(), "Nobody At All").unwrap().is_empty());
829 }
830
831 #[test]
832 fn a_label_with_no_people_row_still_matches_by_identity() {
833 let c = db();
835 c.execute(
836 "INSERT INTO faces (id, hash, person_label, confirmed) VALUES (9,'h9','orphan',1)",
837 [],
838 )
839 .unwrap();
840 assert_eq!(by_person(&c, "orphan").unwrap().len(), 1);
841 assert_eq!(
842 by_person(&c, "Orphan").unwrap().len(),
843 1,
844 "case-insensitive"
845 );
846 }
847}