Skip to main content

videre_core/
query.rs

1//! Composable search predicates.
2//!
3//! Each predicate independently resolves to a set of content hashes;
4//! `candidates` intersects them. Keeping them here rather than in
5//! `person_search`/`classify`/`geocode` means the intersection logic lives in
6//! one testable place and those modules keep their existing callers unchanged.
7//!
8//! Both search surfaces consume this module: `commands::search` (the CLI) and
9//! `commands::mcp` (over the `QueryEmbedder` trait) call the same predicates, so
10//! a composed CLI query and the equivalent MCP tool call cannot drift.
11
12use anyhow::Result;
13use rusqlite::Connection;
14use std::collections::HashSet;
15
16/// The date a file is considered to have: its EXIF capture date when that is
17/// present and valid, otherwise the filesystem modification time.
18///
19/// The `0000%` guard matches `output.rs::best_date`: a camera with an unset
20/// clock writes `0000-00-00T00:00:00`, which must fall back rather than being
21/// treated as year zero.
22pub 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
25/// Hashes whose effective date is in `[after, before)`.
26///
27/// `before` is exclusive so that adjacent ranges tile without both matching
28/// the boundary instant.
29pub 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
62/// Expands `--date` shorthand into a half-open `[start, end)` range.
63pub 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
98/// Normalises an `--after`/`--before` bound to full ISO-8601.
99pub 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
107/// Hashes with at least one confirmed face labelled `name`.
108pub fn by_person(conn: &Connection, name: &str) -> Result<HashSet<String>> {
109    // Both forms of a person's name resolve here; see `person::resolve_identities`.
110    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
126/// Hashes classified as `category` by `model_id`.
127pub 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
139/// Great-circle distance in km.
140fn 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
148/// Hashes within `radius_km` of a point, mapped to their distance in km.
149///
150/// Returns distances rather than a bare set because they are the ranker's
151/// input for `SortField::Distance`.
152pub 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            // Keep the nearest path for a hash that appears at several coords.
175            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
190/// The rows a selection narrowed to, plus their distances when a place was
191/// given.
192///
193/// Kept after `Filters` was retired in favour of
194/// `videre_core::selection::RowSelection`: this is the shape `search`
195/// consumes, and one name for one thing is better than two.
196pub struct Candidates {
197    /// Hashes satisfying every active predicate. `None` means no filter was
198    /// active, i.e. do not constrain.
199    pub hashes: Option<HashSet<String>>,
200    /// Km per surviving hash. `Some` only when `location` was set.
201    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    /// The direction people mean when they do not say: best match first,
214    /// nearest first, newest first, largest first.
215    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/// The fields a sort can key on. `commands/search.rs` builds these from its
264/// own hit type, so the ranker never depends on the CLI's JSON shape.
265#[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
274/// Missing values always sort last, whichever direction is asked for, so a row
275/// with no date never outranks one that has one.
276fn 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
292/// Sorts in place. `sort_by` is stable, so fully tied rows keep input order.
293pub 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        // Berlin centre, 10 km radius.
558        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    /// One person whose identity and display name differ, which is the case
715    /// every assertion here depends on.
716    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        // What a user sees in the UI and copies out of it.
741        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        // The case that made matching the display name necessary: adding a
747        // surname changes the normalized form, so `Erhan Gündoğan` no longer
748        // normalizes to `erhan`. Searching the name on screen must still work.
749        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        // Face 4 is the same person but unconfirmed. Neither form may include
774        // it: an unreviewed guess is not an answer.
775        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        // The trap: SQLite's LOWER() is ASCII-only, so `LOWER(full_name) = ?`
783        // left `Ö` untouched while Rust's to_lowercase produced `ö`, and the
784        // two never matched. Every Turkish display name was affected, and only
785        // after a rename - when identity and display first disagree - so it
786        // passed every check until someone renamed a person.
787        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        // They remain separate people - the identity is the key - but a search
802        // for the name they share cannot tell them apart, so it returns both.
803        // Silent merging is why an explicit "which people matched" is worth
804        // having; recorded as a separate feature.
805        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        // A library mid-migration, or a label written before the table existed.
834        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}