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
8use anyhow::Result;
9use rusqlite::Connection;
10use std::collections::HashSet;
11
12/// The date a file is considered to have: its EXIF capture date when that is
13/// present and valid, otherwise the filesystem modification time.
14///
15/// The `0000%` guard matches `output.rs::best_date`: a camera with an unset
16/// clock writes `0000-00-00T00:00:00`, which must fall back rather than being
17/// treated as year zero.
18pub const EFFECTIVE_DATE_SQL: &str = "CASE WHEN exif_date IS NOT NULL \
19     AND exif_date NOT LIKE '0000%' THEN exif_date ELSE modified_at END";
20
21/// Hashes whose effective date is in `[after, before)`.
22///
23/// `before` is exclusive so that adjacent ranges tile without both matching
24/// the boundary instant.
25pub fn by_date(
26    conn: &Connection,
27    after: Option<&str>,
28    before: Option<&str>,
29) -> Result<HashSet<String>> {
30    let mut sql =
31        format!("SELECT DISTINCT hash FROM file_hashes WHERE {EFFECTIVE_DATE_SQL} IS NOT NULL");
32    let mut params: Vec<String> = Vec::new();
33    if let Some(a) = after {
34        sql.push_str(&format!(" AND {EFFECTIVE_DATE_SQL} >= ?"));
35        params.push(a.to_string());
36    }
37    if let Some(b) = before {
38        sql.push_str(&format!(" AND {EFFECTIVE_DATE_SQL} < ?"));
39        params.push(b.to_string());
40    }
41    let mut stmt = conn.prepare(&sql)?;
42    let rows = stmt.query_map(rusqlite::params_from_iter(params), |r| {
43        r.get::<_, String>(0)
44    })?;
45    Ok(rows.collect::<rusqlite::Result<HashSet<String>>>()?)
46}
47
48use chrono::NaiveDate;
49
50const DATE_FORMS: &str = "expected YYYY, YYYY-MM, YYYY-MM-DD, or YYYY-MM-DDTHH:MM:SS";
51
52fn start_of(y: i32, m: u32, d: u32) -> Result<String> {
53    NaiveDate::from_ymd_opt(y, m, d)
54        .map(|x| format!("{}T00:00:00", x.format("%Y-%m-%d")))
55        .ok_or_else(|| anyhow::anyhow!("invalid date {y:04}-{m:02}-{d:02}; {DATE_FORMS}"))
56}
57
58/// Expands `--date` shorthand into a half-open `[start, end)` range.
59pub fn expand_date(spec: &str) -> Result<(String, String)> {
60    let parts: Vec<&str> = spec.split('-').collect();
61    let bad = || anyhow::anyhow!("cannot parse date {spec:?}; {DATE_FORMS}");
62    match parts.as_slice() {
63        [y] => {
64            let y: i32 = y.parse().map_err(|_| bad())?;
65            Ok((start_of(y, 1, 1)?, start_of(y + 1, 1, 1)?))
66        }
67        [y, m] => {
68            let (y, m): (i32, u32) = (y.parse().map_err(|_| bad())?, m.parse().map_err(|_| bad())?);
69            let start = start_of(y, m, 1)?;
70            let end = if m == 12 {
71                start_of(y + 1, 1, 1)?
72            } else {
73                start_of(y, m + 1, 1)?
74            };
75            Ok((start, end))
76        }
77        [y, m, d] => {
78            let (y, m, d): (i32, u32, u32) = (
79                y.parse().map_err(|_| bad())?,
80                m.parse().map_err(|_| bad())?,
81                d.parse().map_err(|_| bad())?,
82            );
83            let day = NaiveDate::from_ymd_opt(y, m, d).ok_or_else(bad)?;
84            let next = day.succ_opt().ok_or_else(bad)?;
85            Ok((
86                format!("{}T00:00:00", day.format("%Y-%m-%d")),
87                format!("{}T00:00:00", next.format("%Y-%m-%d")),
88            ))
89        }
90        _ => Err(bad()),
91    }
92}
93
94/// Normalises an `--after`/`--before` bound to full ISO-8601.
95pub fn normalise_bound(spec: &str) -> Result<String> {
96    if spec.contains('T') {
97        return Ok(spec.to_string());
98    }
99    let (start, _) = expand_date(spec)?;
100    Ok(start)
101}
102
103/// Hashes with at least one confirmed face labelled `name`.
104pub fn by_person(conn: &Connection, name: &str) -> Result<HashSet<String>> {
105    let mut stmt =
106        conn.prepare("SELECT DISTINCT hash FROM faces WHERE person_label = ?1 AND confirmed = 1")?;
107    let rows = stmt.query_map(rusqlite::params![name], |r| r.get::<_, String>(0))?;
108    Ok(rows.collect::<rusqlite::Result<HashSet<String>>>()?)
109}
110
111/// Hashes classified as `category` by `model_id`.
112pub fn by_category(conn: &Connection, model_id: &str, category: &str) -> Result<HashSet<String>> {
113    let mut stmt = conn.prepare(
114        "SELECT DISTINCT hash FROM classifications WHERE model_id = ?1 AND category = ?2",
115    )?;
116    let rows = stmt.query_map(rusqlite::params![model_id, category], |r| {
117        r.get::<_, String>(0)
118    })?;
119    Ok(rows.collect::<rusqlite::Result<HashSet<String>>>()?)
120}
121
122use std::collections::HashMap;
123
124/// Great-circle distance in km.
125fn haversine(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
126    const R: f64 = 6371.0;
127    let (dlat, dlon) = ((lat2 - lat1).to_radians(), (lon2 - lon1).to_radians());
128    let a = (dlat / 2.0).sin().powi(2)
129        + lat1.to_radians().cos() * lat2.to_radians().cos() * (dlon / 2.0).sin().powi(2);
130    2.0 * R * a.sqrt().asin()
131}
132
133/// Hashes within `radius_km` of a point, mapped to their distance in km.
134///
135/// Returns distances rather than a bare set because they are the ranker's
136/// input for `SortField::Distance`.
137pub fn by_location(
138    conn: &Connection,
139    lat: f64,
140    lon: f64,
141    radius_km: f64,
142) -> Result<HashMap<String, f64>> {
143    let mut stmt = conn.prepare(
144        "SELECT hash, gps_lat, gps_lon FROM file_hashes
145         WHERE gps_lat IS NOT NULL AND gps_lon IS NOT NULL",
146    )?;
147    let rows = stmt.query_map([], |r| {
148        Ok((
149            r.get::<_, String>(0)?,
150            r.get::<_, f64>(1)?,
151            r.get::<_, f64>(2)?,
152        ))
153    })?;
154    let mut out = HashMap::new();
155    for row in rows {
156        let (hash, plat, plon) = row?;
157        let d = haversine(lat, lon, plat, plon);
158        if d <= radius_km {
159            // Keep the nearest path for a hash that appears at several coords.
160            out.entry(hash)
161                .and_modify(|e: &mut f64| *e = e.min(d))
162                .or_insert(d);
163        }
164    }
165    Ok(out)
166}
167
168#[derive(Debug, Clone, Copy)]
169pub struct GeoFilter {
170    pub lat: f64,
171    pub lon: f64,
172    pub radius_km: f64,
173}
174
175/// The rows a selection narrowed to, plus their distances when a place was
176/// given.
177///
178/// Kept after `Filters` was retired in favour of
179/// `videre_core::selection::RowSelection`: this is the shape `search`
180/// consumes, and one name for one thing is better than two.
181pub struct Candidates {
182    /// Hashes satisfying every active predicate. `None` means no filter was
183    /// active, i.e. do not constrain.
184    pub hashes: Option<HashSet<String>>,
185    /// Km per surviving hash. `Some` only when `location` was set.
186    pub distances: Option<HashMap<String, f64>>,
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum SortField {
191    Relevance,
192    Distance,
193    Date,
194    Size,
195}
196
197impl SortField {
198    /// The direction people mean when they do not say: best match first,
199    /// nearest first, newest first, largest first.
200    fn default_desc(self) -> bool {
201        !matches!(self, SortField::Distance)
202    }
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub struct SortKey {
207    pub field: SortField,
208    pub desc: bool,
209}
210
211const SORT_FIELDS: &str = "valid fields: relevance, distance, date, size";
212const SORT_DIRS: &str = "valid directions: asc, desc";
213
214pub fn parse_sort(spec: &str) -> Result<Vec<SortKey>> {
215    let mut out: Vec<SortKey> = Vec::new();
216    for raw in spec.split(',') {
217        let part = raw.trim();
218        if part.is_empty() {
219            anyhow::bail!("empty sort field; {SORT_FIELDS}");
220        }
221        let (name, dir) = match part.split_once(':') {
222            Some((n, d)) => (n.trim(), Some(d.trim())),
223            None => (part, None),
224        };
225        let field = match name.to_ascii_lowercase().as_str() {
226            "relevance" => SortField::Relevance,
227            "distance" => SortField::Distance,
228            "date" => SortField::Date,
229            "size" => SortField::Size,
230            other => anyhow::bail!("unknown sort field {other:?}; {SORT_FIELDS}"),
231        };
232        let desc = match dir.map(|d| d.to_ascii_lowercase()) {
233            None => field.default_desc(),
234            Some(d) if d == "asc" => false,
235            Some(d) if d == "desc" => true,
236            Some(d) => anyhow::bail!("unknown sort direction {d:?}; {SORT_DIRS}"),
237        };
238        if out.iter().any(|k| k.field == field) {
239            anyhow::bail!("sort field {name:?} repeated; each field may appear once");
240        }
241        out.push(SortKey { field, desc });
242    }
243    Ok(out)
244}
245
246use std::cmp::Ordering;
247
248/// The fields a sort can key on. `commands/search.rs` builds these from its
249/// own hit type, so the ranker never depends on the CLI's JSON shape.
250#[derive(Debug, Clone)]
251pub struct Sortable {
252    pub path: String,
253    pub score: Option<f32>,
254    pub distance_km: Option<f64>,
255    pub date: Option<String>,
256    pub size_bytes: Option<i64>,
257}
258
259/// Missing values always sort last, whichever direction is asked for, so a row
260/// with no date never outranks one that has one.
261fn cmp_opt<T: PartialOrd>(a: &Option<T>, b: &Option<T>, desc: bool) -> Ordering {
262    match (a, b) {
263        (None, None) => Ordering::Equal,
264        (None, Some(_)) => Ordering::Greater,
265        (Some(_), None) => Ordering::Less,
266        (Some(x), Some(y)) => {
267            let base = x.partial_cmp(y).unwrap_or(Ordering::Equal);
268            if desc {
269                base.reverse()
270            } else {
271                base
272            }
273        }
274    }
275}
276
277/// Sorts in place. `sort_by` is stable, so fully tied rows keep input order.
278pub fn apply_sort(hits: &mut [Sortable], keys: &[SortKey]) {
279    hits.sort_by(|a, b| {
280        for k in keys {
281            let ord = match k.field {
282                SortField::Relevance => cmp_opt(&a.score, &b.score, k.desc),
283                SortField::Distance => cmp_opt(&a.distance_km, &b.distance_km, k.desc),
284                SortField::Date => cmp_opt(&a.date, &b.date, k.desc),
285                SortField::Size => cmp_opt(&a.size_bytes, &b.size_bytes, k.desc),
286            };
287            if ord != Ordering::Equal {
288                return ord;
289            }
290        }
291        Ordering::Equal
292    });
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use rusqlite::Connection;
299
300    fn db() -> Connection {
301        let conn = Connection::open_in_memory().unwrap();
302        conn.execute_batch(
303            "CREATE TABLE file_hashes (
304                path TEXT PRIMARY KEY, hash TEXT NOT NULL,
305                size_bytes INTEGER, modified_at TEXT, exif_date TEXT
306            );",
307        )
308        .unwrap();
309        conn
310    }
311
312    fn add(conn: &Connection, path: &str, hash: &str, exif: Option<&str>, mtime: &str) {
313        conn.execute(
314            "INSERT INTO file_hashes (path, hash, size_bytes, modified_at, exif_date)
315             VALUES (?1, ?2, 100, ?3, ?4)",
316            rusqlite::params![path, hash, mtime, exif],
317        )
318        .unwrap();
319    }
320
321    #[test]
322    fn date_filter_matches_on_exif_when_present() {
323        let conn = db();
324        add(
325            &conn,
326            "/a.jpg",
327            "h1",
328            Some("2025-05-14T10:00:00"),
329            "2026-01-01T00:00:00",
330        );
331        let got = by_date(
332            &conn,
333            Some("2025-05-01T00:00:00"),
334            Some("2025-06-01T00:00:00"),
335        )
336        .unwrap();
337        assert!(got.contains("h1"), "exif_date must win over modified_at");
338    }
339
340    #[test]
341    fn date_filter_falls_back_to_modified_at() {
342        let conn = db();
343        add(&conn, "/b.png", "h2", None, "2025-05-14T10:00:00");
344        let got = by_date(
345            &conn,
346            Some("2025-05-01T00:00:00"),
347            Some("2025-06-01T00:00:00"),
348        )
349        .unwrap();
350        assert!(
351            got.contains("h2"),
352            "a file with no EXIF must match on modified_at"
353        );
354    }
355
356    #[test]
357    fn date_filter_ignores_zero_exif_dates() {
358        let conn = db();
359        add(
360            &conn,
361            "/c.jpg",
362            "h3",
363            Some("0000-00-00T00:00:00"),
364            "2025-05-14T10:00:00",
365        );
366        let got = by_date(
367            &conn,
368            Some("2025-05-01T00:00:00"),
369            Some("2025-06-01T00:00:00"),
370        )
371        .unwrap();
372        assert!(
373            got.contains("h3"),
374            "an unset camera clock must fall back, not match year 0"
375        );
376    }
377
378    #[test]
379    fn before_is_exclusive_so_ranges_tile() {
380        let conn = db();
381        add(
382            &conn,
383            "/d.jpg",
384            "h4",
385            Some("2025-06-01T00:00:00"),
386            "2025-06-01T00:00:00",
387        );
388        let may = by_date(
389            &conn,
390            Some("2025-05-01T00:00:00"),
391            Some("2025-06-01T00:00:00"),
392        )
393        .unwrap();
394        let jun = by_date(
395            &conn,
396            Some("2025-06-01T00:00:00"),
397            Some("2025-07-01T00:00:00"),
398        )
399        .unwrap();
400        assert!(
401            !may.contains("h4"),
402            "the boundary instant belongs to June only"
403        );
404        assert!(jun.contains("h4"));
405    }
406
407    #[test]
408    fn open_ended_ranges_work() {
409        let conn = db();
410        add(
411            &conn,
412            "/e.jpg",
413            "h5",
414            Some("2025-05-14T10:00:00"),
415            "2025-05-14T10:00:00",
416        );
417        assert!(by_date(&conn, Some("2025-01-01T00:00:00"), None)
418            .unwrap()
419            .contains("h5"));
420        assert!(by_date(&conn, None, Some("2026-01-01T00:00:00"))
421            .unwrap()
422            .contains("h5"));
423    }
424
425    #[test]
426    fn date_shorthand_expands_to_half_open_ranges() {
427        assert_eq!(
428            expand_date("2025").unwrap(),
429            ("2025-01-01T00:00:00".into(), "2026-01-01T00:00:00".into())
430        );
431        assert_eq!(
432            expand_date("2025-05").unwrap(),
433            ("2025-05-01T00:00:00".into(), "2025-06-01T00:00:00".into())
434        );
435        assert_eq!(
436            expand_date("2025-12").unwrap(),
437            ("2025-12-01T00:00:00".into(), "2026-01-01T00:00:00".into())
438        );
439        assert_eq!(
440            expand_date("2025-05-14").unwrap(),
441            ("2025-05-14T00:00:00".into(), "2025-05-15T00:00:00".into())
442        );
443    }
444
445    #[test]
446    fn date_shorthand_handles_month_and_year_rollover() {
447        assert_eq!(expand_date("2024-02-29").unwrap().1, "2024-03-01T00:00:00");
448        assert_eq!(expand_date("2025-12-31").unwrap().1, "2026-01-01T00:00:00");
449    }
450
451    #[test]
452    fn normalise_bound_accepts_date_or_datetime() {
453        assert_eq!(
454            normalise_bound("2025-05-14").unwrap(),
455            "2025-05-14T00:00:00"
456        );
457        assert_eq!(
458            normalise_bound("2025-05-14T09:30:00").unwrap(),
459            "2025-05-14T09:30:00"
460        );
461    }
462
463    #[test]
464    fn bad_dates_are_rejected_with_a_helpful_message() {
465        for bad in ["", "May 2025", "2025-13", "2025-02-30", "20250514"] {
466            let err = expand_date(bad).unwrap_err().to_string();
467            assert!(
468                err.contains("YYYY"),
469                "error for {bad:?} should name the accepted forms, got: {err}"
470            );
471        }
472    }
473
474    fn db_with_faces_and_classes() -> Connection {
475        let conn = db();
476        conn.execute_batch(
477            "CREATE TABLE faces (id INTEGER PRIMARY KEY, hash TEXT NOT NULL,
478                person_label TEXT, confirmed INTEGER DEFAULT 0);
479             CREATE TABLE classifications (model_id TEXT NOT NULL, hash TEXT NOT NULL,
480                category TEXT NOT NULL, confidence REAL NOT NULL,
481                classified_at TEXT NOT NULL, PRIMARY KEY (model_id, hash));",
482        )
483        .unwrap();
484        conn
485    }
486
487    #[test]
488    fn person_predicate_returns_confirmed_only() {
489        let conn = db_with_faces_and_classes();
490        conn.execute_batch(
491            "INSERT INTO faces (hash, person_label, confirmed) VALUES
492                ('h1','Alice',1), ('h2','Alice',0), ('h3','Bob',1);",
493        )
494        .unwrap();
495        let got = by_person(&conn, "Alice").unwrap();
496        assert!(got.contains("h1"));
497        assert!(!got.contains("h2"), "unconfirmed faces must not match");
498        assert!(!got.contains("h3"));
499    }
500
501    #[test]
502    fn category_predicate_is_model_scoped() {
503        let conn = db_with_faces_and_classes();
504        conn.execute_batch(
505            "INSERT INTO classifications VALUES
506                ('m1','h1','screenshot',0.9,'now'),
507                ('m2','h2','screenshot',0.9,'now');",
508        )
509        .unwrap();
510        let got = by_category(&conn, "m1", "screenshot").unwrap();
511        assert!(got.contains("h1"));
512        assert!(!got.contains("h2"), "another model's rows must not leak in");
513    }
514
515    #[test]
516    fn predicates_return_empty_not_error_when_nothing_matches() {
517        let conn = db_with_faces_and_classes();
518        assert!(by_person(&conn, "Nobody").unwrap().is_empty());
519        assert!(by_category(&conn, "m1", "meme").unwrap().is_empty());
520    }
521
522    fn db_with_gps() -> Connection {
523        let conn = Connection::open_in_memory().unwrap();
524        conn.execute_batch(
525            "CREATE TABLE file_hashes (
526                path TEXT PRIMARY KEY, hash TEXT NOT NULL, size_bytes INTEGER,
527                modified_at TEXT, exif_date TEXT, gps_lat REAL, gps_lon REAL);
528             INSERT INTO file_hashes VALUES
529                ('/near.jpg','hn',100,'2025-01-01T00:00:00',NULL,52.5200,13.4050),
530                ('/far.jpg','hf',100,'2025-01-01T00:00:00',NULL,48.8566,2.3522),
531                ('/nogps.jpg','hx',100,'2025-01-01T00:00:00',NULL,NULL,NULL);",
532        )
533        .unwrap();
534        conn
535    }
536
537    #[test]
538    fn location_predicate_returns_only_within_radius_with_distances() {
539        let conn = db_with_gps();
540        // Berlin centre, 10 km radius.
541        let got = by_location(&conn, 52.5200, 13.4050, 10.0).unwrap();
542        assert!(got.contains_key("hn"));
543        assert!(
544            !got.contains_key("hf"),
545            "Paris is not within 10 km of Berlin"
546        );
547        assert!(!got.contains_key("hx"), "a file with no GPS cannot match");
548        assert!(
549            got["hn"] < 0.1,
550            "distance to itself should be ~0, got {}",
551            got["hn"]
552        );
553    }
554
555    #[test]
556    fn location_distance_is_roughly_correct_over_a_long_span() {
557        let conn = db_with_gps();
558        let got = by_location(&conn, 52.5200, 13.4050, 2000.0).unwrap();
559        let d = got["hf"];
560        assert!(
561            (870.0..890.0).contains(&d),
562            "Berlin to Paris is ~878 km, got {d}"
563        );
564    }
565
566    #[test]
567    fn sort_defaults_direction_per_field() {
568        assert_eq!(
569            parse_sort("distance,date").unwrap(),
570            vec![
571                SortKey {
572                    field: SortField::Distance,
573                    desc: false
574                },
575                SortKey {
576                    field: SortField::Date,
577                    desc: true
578                },
579            ]
580        );
581        assert_eq!(
582            parse_sort("relevance").unwrap(),
583            vec![SortKey {
584                field: SortField::Relevance,
585                desc: true
586            }]
587        );
588        assert_eq!(
589            parse_sort("size").unwrap(),
590            vec![SortKey {
591                field: SortField::Size,
592                desc: true
593            }]
594        );
595    }
596
597    #[test]
598    fn sort_accepts_explicit_directions() {
599        assert_eq!(
600            parse_sort("distance:desc,date:asc").unwrap(),
601            vec![
602                SortKey {
603                    field: SortField::Distance,
604                    desc: true
605                },
606                SortKey {
607                    field: SortField::Date,
608                    desc: false
609                },
610            ]
611        );
612    }
613
614    #[test]
615    fn sort_tolerates_spaces_and_case() {
616        assert_eq!(parse_sort(" Distance : ASC ").unwrap()[0].desc, false);
617    }
618
619    #[test]
620    fn sort_rejects_bad_specs_naming_valid_values() {
621        for (spec, needle) in [
622            ("bogus", "relevance"),
623            ("date:sideways", "asc"),
624            ("date,date", "repeated"),
625            ("", "relevance"),
626        ] {
627            let err = parse_sort(spec).unwrap_err().to_string();
628            assert!(
629                err.contains(needle),
630                "error for {spec:?} should mention {needle:?}, got: {err}"
631            );
632        }
633    }
634
635    fn hit(path: &str, score: Option<f32>, km: Option<f64>, date: &str, size: i64) -> Sortable {
636        Sortable {
637            path: path.into(),
638            score,
639            distance_km: km,
640            date: Some(date.into()),
641            size_bytes: Some(size),
642        }
643    }
644
645    #[test]
646    fn multi_field_sort_uses_later_fields_as_tie_breakers() {
647        let mut v = vec![
648            hit("/old.jpg", None, Some(1.0), "2024-01-01T00:00:00", 10),
649            hit("/new.jpg", None, Some(1.0), "2025-01-01T00:00:00", 10),
650            hit("/far.jpg", None, Some(9.0), "2026-01-01T00:00:00", 10),
651        ];
652        apply_sort(&mut v, &parse_sort("distance,date").unwrap());
653        let order: Vec<&str> = v.iter().map(|h| h.path.as_str()).collect();
654        assert_eq!(
655            order,
656            vec!["/new.jpg", "/old.jpg", "/far.jpg"],
657            "nearest first, newest first within the same distance"
658        );
659    }
660
661    #[test]
662    fn missing_sort_values_sort_last_in_both_directions() {
663        let mut v = vec![
664            Sortable {
665                path: "/none.jpg".into(),
666                score: None,
667                distance_km: None,
668                date: None,
669                size_bytes: None,
670            },
671            hit("/has.jpg", None, None, "2025-01-01T00:00:00", 10),
672        ];
673        apply_sort(&mut v, &parse_sort("date:desc").unwrap());
674        assert_eq!(
675            v[0].path, "/has.jpg",
676            "a row with no date must not outrank one with a date"
677        );
678        apply_sort(&mut v, &parse_sort("date:asc").unwrap());
679        assert_eq!(v[0].path, "/has.jpg", "and the same when ascending");
680    }
681
682    #[test]
683    fn sort_is_stable_on_full_ties() {
684        let mut v = vec![
685            hit("/a.jpg", None, Some(1.0), "2025-01-01T00:00:00", 10),
686            hit("/b.jpg", None, Some(1.0), "2025-01-01T00:00:00", 10),
687        ];
688        apply_sort(&mut v, &parse_sort("distance,date").unwrap());
689        assert_eq!(v[0].path, "/a.jpg", "equal rows keep their input order");
690    }
691}