Skip to main content

videre_core/
selection.rs

1//! Shared vocabulary for saying *which* files a command should work on.
2//!
3//! Two selections exist, deliberately as separate types:
4//!
5//! - `RowSelection` for commands that query rows (`search`, `embed`, `faces`,
6//!   `classify`, `locations`). It can filter on anything recorded.
7//! - `PathSelection` for commands that walk a filesystem (`scan`, `watch`).
8//!   It can only filter on what is knowable *before* a file is read.
9//!
10//! Keeping them separate is the point: adding a predicate to the row side
11//! cannot change what `scan` accepts, because `scan` does not take that type.
12//!
13//! This module holds the primitives both share. The selections themselves and
14//! their resolution follow in the same module.
15
16use anyhow::{bail, Result};
17
18/// The coarse kind of a media file.
19///
20/// `--type image` / `--type video`. A value flag rather than boolean
21/// `--image`/`--video` flags, for symmetry with `--ext` and `--mime`, which
22/// have no boolean form, and because it extends to further kinds without
23/// growing the flag surface.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum MediaKind {
26    Image,
27    Video,
28}
29
30impl MediaKind {
31    /// Parse a user-supplied kind, naming every valid value on failure.
32    ///
33    /// An unknown *kind* is a typo worth reporting, unlike an unknown
34    /// extension, which legitimately matches nothing.
35    pub fn parse(s: &str) -> Result<Self> {
36        match s.trim().to_lowercase().as_str() {
37            "image" => Ok(Self::Image),
38            "video" => Ok(Self::Video),
39            other => bail!("unknown --type {other:?}; valid values are: image, video"),
40        }
41    }
42
43    pub fn as_str(self) -> &'static str {
44        match self {
45            Self::Image => "image",
46            Self::Video => "video",
47        }
48    }
49}
50
51/// Normalise a user-supplied extension so `.MOV`, `MOV` and `mov` agree.
52///
53/// Done once at parse time rather than per row: a selection is compared
54/// against tens of thousands of files.
55pub fn normalise_ext(s: &str) -> String {
56    s.trim().trim_start_matches('.').to_lowercase()
57}
58
59/// Whether a row's type matches `kind`.
60///
61/// Resolves through `mime_probe::effective_mime`, which is what every other
62/// consumer does: a file whose magic bytes could not be identified stores the
63/// sentinel `application/octet-stream`, and `effective_mime` falls back to the
64/// extension. A `.jpg` whose header was unreadable is still a photo to its
65/// owner, and treating the sentinel as a type of its own would drop those files
66/// out of `--type image` for a reason the user cannot see.
67pub fn row_matches_kind(kind: MediaKind, mime: Option<&str>, ext: &str) -> bool {
68    let Some(m) = crate::mime_probe::effective_mime(mime, &ext.to_lowercase()) else {
69        return false;
70    };
71    match kind {
72        MediaKind::Image => crate::mime_probe::PHOTO_MIMES.contains(&m),
73        MediaKind::Video => crate::mime_probe::VIDEO_MIMES.contains(&m),
74    }
75}
76
77/// Whether a *path* matches `kind`, judged by extension alone.
78///
79/// :warning: This deliberately differs from `row_matches_kind`. A walk decides
80/// whether to read a file before it has read it, so the magic bytes are not
81/// available and the extension is all there is. On a library whose extensions
82/// are wrong, `scan --type video` and `search --type video` will disagree, and
83/// that is inherent to filtering before reading rather than a bug to fix.
84pub fn path_matches_kind(kind: MediaKind, path: &std::path::Path) -> bool {
85    let ext = path
86        .extension()
87        .and_then(|e| e.to_str())
88        .unwrap_or("")
89        .to_lowercase();
90    if ext.is_empty() {
91        return false;
92    }
93    row_matches_kind(kind, None, &ext)
94}
95
96use crate::query::{self, GeoFilter};
97use rusqlite::Connection;
98use std::collections::{HashMap, HashSet};
99use std::path::{Path, PathBuf};
100
101/// A place to select around: either a name needing geocoding, or coordinates.
102///
103/// Both exist because `search` takes a place *name* and geocodes it (a network
104/// call on a cache miss) while the underlying predicate wants coordinates. The
105/// layer owns both so that every command accepting `--location` geocodes
106/// identically instead of reimplementing it.
107#[derive(Debug, Clone)]
108pub enum PlaceQuery {
109    Named { place: String, radius_km: f64 },
110    Coords(GeoFilter),
111}
112
113/// What a command was asked to work on, over rows already in the database.
114#[derive(Debug, Clone, Default)]
115pub struct RowSelection {
116    pub person: Option<String>,
117    pub category: Option<String>,
118    pub place: Option<PlaceQuery>,
119    pub after: Option<String>,
120    pub before: Option<String>,
121    pub kinds: Vec<MediaKind>,
122    pub exts: Vec<String>,
123    pub mimes: Vec<String>,
124    pub paths: Vec<PathBuf>,
125}
126
127/// What a command can offer the resolver about itself.
128#[derive(Debug, Clone, Default)]
129pub struct SelectionCtx {
130    /// Needed to resolve `--category`, which is scoped to an embedding model.
131    /// `None` for commands with no model concept, such as `faces`.
132    pub model_id: Option<String>,
133}
134
135/// The outcome of resolving a selection.
136#[derive(Debug, Clone, Default)]
137pub struct Resolved {
138    /// `None` means nothing was selected: do not constrain, process everything.
139    /// `Some(empty)` means a selection ran and matched nothing: process
140    /// nothing. Collapsing the two would turn a typo into a full-library run.
141    pub hashes: Option<HashSet<String>>,
142    /// Km from the requested place, per surviving hash. `Some` only when a
143    /// place was given. Carried because `search --sort distance` needs it.
144    pub distances: Option<HashMap<String, f64>>,
145}
146
147impl RowSelection {
148    /// True when no predicate was given at all.
149    ///
150    /// Derived from the fields directly rather than kept as a separate
151    /// hand-maintained list, because the failure mode of forgetting to update
152    /// such a list is silent: the command processes the entire library.
153    pub fn is_empty(&self) -> bool {
154        self.person.is_none()
155            && self.category.is_none()
156            && self.place.is_none()
157            && self.after.is_none()
158            && self.before.is_none()
159            && self.kinds.is_empty()
160            && self.exts.is_empty()
161            && self.mimes.is_empty()
162            && self.paths.is_empty()
163    }
164
165    /// Human-readable form for progress lines and confirmations.
166    pub fn describe(&self) -> String {
167        let mut parts: Vec<String> = Vec::new();
168        if let Some(p) = &self.person {
169            parts.push(format!("--person {p:?}"));
170        }
171        if let Some(c) = &self.category {
172            parts.push(format!("--category {c}"));
173        }
174        match &self.place {
175            Some(PlaceQuery::Named { place, radius_km }) => {
176                parts.push(format!("--location {place:?} --radius {radius_km}"))
177            }
178            Some(PlaceQuery::Coords(g)) => parts.push(format!(
179                "--location {},{} --radius {}",
180                g.lat, g.lon, g.radius_km
181            )),
182            None => {}
183        }
184        if let Some(a) = &self.after {
185            parts.push(format!("--after {a}"));
186        }
187        if let Some(b) = &self.before {
188            parts.push(format!("--before {b}"));
189        }
190        for k in &self.kinds {
191            parts.push(format!("--type {}", k.as_str()));
192        }
193        if !self.exts.is_empty() {
194            parts.push(format!("--ext {}", self.exts.join(",")));
195        }
196        if !self.mimes.is_empty() {
197            parts.push(format!("--mime {}", self.mimes.join(",")));
198        }
199        for p in &self.paths {
200            parts.push(format!("--path {}", p.display()));
201        }
202        parts.join(" ")
203    }
204
205    /// Run every active predicate and intersect them.
206    ///
207    /// Predicates OR within an axis (`--ext mov,avi` matches either) and AND
208    /// across axes (`--type video --ext jpg` matches nothing), which is how
209    /// every existing filter already composes.
210    pub fn resolve(&self, conn: &Connection, ctx: &SelectionCtx) -> anyhow::Result<Resolved> {
211        if self.is_empty() {
212            return Ok(Resolved::default());
213        }
214
215        let mut acc: Option<HashSet<String>> = None;
216        let narrow = |s: HashSet<String>, acc: &mut Option<HashSet<String>>| match acc {
217            Some(existing) => *acc = Some(existing.intersection(&s).cloned().collect()),
218            None => *acc = Some(s),
219        };
220
221        if let Some(p) = &self.person {
222            narrow(query::by_person(conn, p)?, &mut acc);
223        }
224        if let Some(c) = &self.category {
225            let model = ctx.model_id.as_deref().ok_or_else(|| {
226                anyhow::anyhow!(
227                    "--category needs an embedding model, and this command has none; \
228                     classifications are stored per model"
229                )
230            })?;
231            narrow(query::by_category(conn, model, c)?, &mut acc);
232        }
233        if self.after.is_some() || self.before.is_some() {
234            narrow(
235                query::by_date(conn, self.after.as_deref(), self.before.as_deref())?,
236                &mut acc,
237            );
238        }
239        if !self.kinds.is_empty() {
240            narrow(by_kinds(conn, &self.kinds)?, &mut acc);
241        }
242        if !self.exts.is_empty() {
243            narrow(by_exts(conn, &self.exts)?, &mut acc);
244        }
245        if !self.mimes.is_empty() {
246            narrow(by_mimes(conn, &self.mimes)?, &mut acc);
247        }
248        if !self.paths.is_empty() {
249            narrow(by_paths(conn, &self.paths)?, &mut acc);
250        }
251
252        // Place last: geocoding may hit the network, so an already-empty
253        // candidate set should skip it entirely.
254        let mut distances = None;
255        if let Some(place) = &self.place {
256            if acc.as_ref().is_some_and(|h| h.is_empty()) {
257                distances = Some(HashMap::new());
258            } else {
259                let geo = match place {
260                    PlaceQuery::Coords(g) => *g,
261                    PlaceQuery::Named { place, radius_km } => {
262                        crate::geocode::ensure_geocode_cache_table(conn)?;
263                        let (lat, lon) = crate::geocode::forward_geocode_cached(conn, place)?;
264                        GeoFilter {
265                            lat,
266                            lon,
267                            radius_km: *radius_km,
268                        }
269                    }
270                };
271                let within = query::by_location(conn, geo.lat, geo.lon, geo.radius_km)?;
272                let keep: HashSet<String> = match &acc {
273                    Some(existing) => within
274                        .keys()
275                        .filter(|h| existing.contains(*h))
276                        .cloned()
277                        .collect(),
278                    None => within.keys().cloned().collect(),
279                };
280                distances = Some(
281                    within
282                        .into_iter()
283                        .filter(|(h, _)| keep.contains(h))
284                        .collect(),
285                );
286                acc = Some(keep);
287            }
288        }
289
290        Ok(Resolved {
291            hashes: acc,
292            distances,
293        })
294    }
295}
296
297/// Hashes whose type matches any of `kinds`.
298///
299/// Filtered in Rust rather than SQL because the sentinel-mime fallback lives in
300/// `effective_mime`; expressing it in SQL would duplicate logic that already
301/// exists and would drift from it.
302fn by_kinds(conn: &Connection, kinds: &[MediaKind]) -> anyhow::Result<HashSet<String>> {
303    let mut stmt = conn.prepare("SELECT hash, mime, ext FROM file_hashes")?;
304    let rows = stmt.query_map([], |r| {
305        Ok((
306            r.get::<_, String>(0)?,
307            r.get::<_, Option<String>>(1)?,
308            r.get::<_, Option<String>>(2)?,
309        ))
310    })?;
311    let mut out = HashSet::new();
312    for row in rows {
313        let (hash, mime, ext) = row?;
314        let ext = ext.unwrap_or_default();
315        if kinds
316            .iter()
317            .any(|k| row_matches_kind(*k, mime.as_deref(), &ext))
318        {
319            out.insert(hash);
320        }
321    }
322    Ok(out)
323}
324
325fn by_exts(conn: &Connection, exts: &[String]) -> anyhow::Result<HashSet<String>> {
326    let wanted: HashSet<String> = exts.iter().map(|e| normalise_ext(e)).collect();
327    let mut stmt = conn.prepare("SELECT hash, ext FROM file_hashes WHERE ext IS NOT NULL")?;
328    let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
329    let mut out = HashSet::new();
330    for row in rows {
331        let (hash, ext) = row?;
332        if wanted.contains(&normalise_ext(&ext)) {
333            out.insert(hash);
334        }
335    }
336    Ok(out)
337}
338
339fn by_mimes(conn: &Connection, mimes: &[String]) -> anyhow::Result<HashSet<String>> {
340    let wanted: HashSet<String> = mimes.iter().map(|m| m.trim().to_lowercase()).collect();
341    let mut stmt = conn.prepare("SELECT hash, mime FROM file_hashes WHERE mime IS NOT NULL")?;
342    let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
343    let mut out = HashSet::new();
344    for row in rows {
345        let (hash, mime) = row?;
346        if wanted.contains(&mime.to_lowercase()) {
347            out.insert(hash);
348        }
349    }
350    Ok(out)
351}
352
353/// Each root, plus its canonical form when that differs.
354///
355/// Used by both selection shapes, which is the point: a root must be matched in
356/// *either* form, because the two sides of the comparison are canonicalised
357/// inconsistently and neither side can be normalised cheaply. Stored paths are
358/// whatever was walked, and canonicalising them at match time would cost a stat
359/// per row. Replacing the root with its canonical form instead is what broke
360/// both shapes independently: on Linux `/lib` canonicalises to `/usr/lib`, so a
361/// `--path /lib` matched none of the rows stored under `/lib`, silently and
362/// while reporting success.
363///
364/// A root that cannot be canonicalised (it may not exist) is kept as given
365/// rather than treated as an error, since selecting a missing directory
366/// legitimately matches nothing.
367fn roots_in_both_forms(roots: &[PathBuf]) -> Vec<PathBuf> {
368    let mut out = Vec::with_capacity(roots.len() * 2);
369    for r in roots {
370        out.push(r.clone());
371        if let Ok(c) = std::fs::canonicalize(r) {
372            if c != *r {
373                out.push(c);
374            }
375        }
376    }
377    out
378}
379
380/// Hashes whose path lies under any of `roots`.
381///
382/// Compares **path components**, not string prefixes, so `/Pictures/2024` does
383/// not also match `/Pictures/2024-old`.
384fn by_paths(conn: &Connection, roots: &[PathBuf]) -> anyhow::Result<HashSet<String>> {
385    let roots = roots_in_both_forms(roots);
386    let mut stmt = conn.prepare("SELECT hash, path FROM file_hashes")?;
387    let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
388    let mut out = HashSet::new();
389    for row in rows {
390        let (hash, path) = row?;
391        let p = Path::new(&path);
392        if roots.iter().any(|r| under(p, r)) {
393            out.insert(hash);
394        }
395    }
396    Ok(out)
397}
398
399fn under(path: &Path, root: &Path) -> bool {
400    let mut a = path.components();
401    for c in root.components() {
402        match a.next() {
403            Some(x) if x == c => {}
404            _ => return false,
405        }
406    }
407    true
408}
409
410/// What a *walking* command was asked to work on.
411///
412/// Deliberately smaller than `RowSelection` and a separate type: a walk decides
413/// whether to read a file before reading it, so it cannot answer questions
414/// about dates, coordinates, people or true mime type. Excluding those from the
415/// vocabulary is what makes the flags they share safe to name identically -
416/// a `scan --after` meaning "re-scan known rows in this range" while
417/// `embed --after` means "restrict to these files" would be one flag with two
418/// meanings, one command apart.
419#[derive(Debug, Clone, Default)]
420pub struct PathSelection {
421    pub kinds: Vec<MediaKind>,
422    pub exts: Vec<String>,
423    pub paths: Vec<PathBuf>,
424}
425
426impl PathSelection {
427    pub fn is_empty(&self) -> bool {
428        self.kinds.is_empty() && self.exts.is_empty() && self.paths.is_empty()
429    }
430
431    /// Whether the walk should keep this path.
432    ///
433    /// Pure: no database, and no I/O beyond the canonicalisation the roots
434    /// already had. An empty selection accepts everything, so a command with no
435    /// flags behaves exactly as before.
436    pub fn accepts(&self, path: &Path) -> bool {
437        if self.is_empty() {
438            return true;
439        }
440        if !self.kinds.is_empty() && !self.kinds.iter().any(|k| path_matches_kind(*k, path)) {
441            return false;
442        }
443        if !self.exts.is_empty() {
444            let ext = path
445                .extension()
446                .and_then(|e| e.to_str())
447                .map(normalise_ext)
448                .unwrap_or_default();
449            if !self.exts.iter().any(|e| normalise_ext(e) == ext) {
450                return false;
451            }
452        }
453        if !self.paths.is_empty() && !self.paths.iter().any(|r| under(path, r)) {
454            return false;
455        }
456        true
457    }
458
459    /// Add each root's canonical form alongside the one given, once, so
460    /// `accepts` stays cheap across a walk of tens of thousands of entries.
461    ///
462    /// Both forms are kept rather than the canonical one alone, because the
463    /// walk is rooted at the directory the user typed and yields paths in
464    /// *that* form. On macOS a tempdir, `/tmp`, `/var`, and any symlinked photo
465    /// directory all canonicalise to something with a different prefix, so
466    /// replacing the root would make a perfectly correct `--path` match nothing
467    /// at all - silently, since matching nothing is not an error.
468    pub fn canonicalised(mut self) -> Self {
469        self.paths = roots_in_both_forms(&self.paths);
470        self
471    }
472
473    pub fn describe(&self) -> String {
474        let mut parts: Vec<String> = Vec::new();
475        for k in &self.kinds {
476            parts.push(format!("--type {}", k.as_str()));
477        }
478        if !self.exts.is_empty() {
479            parts.push(format!("--ext {}", self.exts.join(",")));
480        }
481        for p in &self.paths {
482            parts.push(format!("--path {}", p.display()));
483        }
484        parts.join(" ")
485    }
486}
487
488#[cfg(test)]
489mod path_selection_tests {
490    use super::*;
491
492    #[test]
493    fn an_empty_selection_accepts_everything() {
494        // A command with no flags must walk exactly as it did before.
495        let s = PathSelection::default();
496        assert!(s.accepts(Path::new("/a/b.jpg")));
497        assert!(s.accepts(Path::new("/a/b.mov")));
498    }
499
500    #[test]
501    fn kinds_and_exts_and_paths_all_narrow() {
502        let s = PathSelection {
503            kinds: vec![MediaKind::Video],
504            ..Default::default()
505        };
506        assert!(s.accepts(Path::new("/a/b.mov")));
507        assert!(!s.accepts(Path::new("/a/b.jpg")));
508
509        let s = PathSelection {
510            exts: vec![".MOV".into()],
511            ..Default::default()
512        };
513        assert!(s.accepts(Path::new("/a/b.mov")), "case and dot normalise");
514
515        let s = PathSelection {
516            paths: vec![PathBuf::from("/lib")],
517            ..Default::default()
518        };
519        assert!(s.accepts(Path::new("/lib/a.jpg")));
520        assert!(
521            !s.accepts(Path::new("/library/a.jpg")),
522            "components, not prefix"
523        );
524    }
525
526    #[test]
527    fn axes_intersect() {
528        let s = PathSelection {
529            kinds: vec![MediaKind::Video],
530            paths: vec![PathBuf::from("/lib")],
531            ..Default::default()
532        };
533        assert!(s.accepts(Path::new("/lib/a.mov")));
534        assert!(!s.accepts(Path::new("/lib/a.jpg")), "wrong kind");
535        assert!(!s.accepts(Path::new("/other/a.mov")), "wrong place");
536    }
537
538    #[test]
539    fn type_here_is_by_extension_and_that_differs_from_rows() {
540        // Asserted on purpose. A walk has not read the file, so a .mov whose
541        // bytes are really a JPEG is accepted by `scan --type video` and
542        // rejected by `search --type video`. Inherent to filtering before
543        // reading; not a bug to "fix" into agreement.
544        let s = PathSelection {
545            kinds: vec![MediaKind::Video],
546            ..Default::default()
547        };
548        assert!(s.accepts(Path::new("/a/mislabelled.mov")));
549        assert!(!row_matches_kind(
550            MediaKind::Video,
551            Some("image/jpeg"),
552            "mov"
553        ));
554    }
555}
556
557#[cfg(test)]
558mod tests {
559    #[test]
560    fn a_symlinked_root_matches_paths_in_either_form() {
561        // The failure this guards is silent: a --path under a symlink (every
562        // macOS tempdir, /tmp, /var) canonicalises to a different prefix than
563        // the walk produces, so the selection would match nothing and report
564        // success.
565        let dir = tempfile::tempdir().unwrap();
566        let given = dir.path().join("sub");
567        std::fs::create_dir(&given).unwrap();
568        let canonical = std::fs::canonicalize(&given).unwrap();
569
570        let sel = PathSelection {
571            paths: vec![given.clone()],
572            ..Default::default()
573        }
574        .canonicalised();
575
576        assert!(sel.accepts(&given.join("a.jpg")), "the form the user typed");
577        assert!(sel.accepts(&canonical.join("a.jpg")), "the canonical form");
578        assert!(!sel.accepts(&dir.path().join("outside.jpg")));
579    }
580
581    use super::*;
582    use std::path::Path;
583
584    #[test]
585    fn parse_names_every_valid_value_on_a_typo() {
586        assert_eq!(MediaKind::parse("image").unwrap(), MediaKind::Image);
587        assert_eq!(MediaKind::parse("VIDEO").unwrap(), MediaKind::Video);
588        assert_eq!(MediaKind::parse("  video  ").unwrap(), MediaKind::Video);
589        let e = MediaKind::parse("vidoe").unwrap_err().to_string();
590        assert!(e.contains("image") && e.contains("video"), "got: {e}");
591    }
592
593    #[test]
594    fn extensions_normalise_to_one_spelling() {
595        for s in [".MOV", "MOV", "mov", " .mov "] {
596            assert_eq!(normalise_ext(s), "mov", "input {s:?}");
597        }
598    }
599
600    #[test]
601    fn an_unidentified_file_is_still_its_extension() {
602        // The sentinel means "could not identify", not "a type of its own".
603        // Dropping these from --type image would be invisible to the user.
604        assert!(row_matches_kind(
605            MediaKind::Image,
606            Some(crate::mime_probe::UNKNOWN_MIME),
607            "jpg"
608        ));
609        assert!(row_matches_kind(MediaKind::Video, None, "mov"));
610    }
611
612    #[test]
613    fn mime_beats_a_wrong_extension_for_rows() {
614        assert!(row_matches_kind(
615            MediaKind::Image,
616            Some("image/jpeg"),
617            "txt"
618        ));
619        assert!(!row_matches_kind(
620            MediaKind::Video,
621            Some("image/jpeg"),
622            "mov"
623        ));
624    }
625
626    #[test]
627    fn paths_are_judged_by_extension_only() {
628        // The divergence from row matching, asserted on purpose: a walk has not
629        // read the file, so this is all it can know.
630        assert!(path_matches_kind(MediaKind::Video, Path::new("/a/b.mov")));
631        assert!(path_matches_kind(MediaKind::Image, Path::new("/a/b.HEIC")));
632        assert!(!path_matches_kind(MediaKind::Image, Path::new("/a/b.mov")));
633        assert!(!path_matches_kind(MediaKind::Image, Path::new("/a/noext")));
634    }
635}
636
637#[cfg(test)]
638mod resolve_tests {
639    use super::*;
640
641    fn db() -> Connection {
642        let c = Connection::open_in_memory().unwrap();
643        c.execute_batch(
644            "CREATE TABLE file_hashes (
645                path TEXT PRIMARY KEY, hash TEXT NOT NULL, size_bytes INTEGER,
646                created_at TEXT, modified_at TEXT, ext TEXT, mime TEXT, phash INTEGER,
647                exif_date TEXT, gps_lat REAL, gps_lon REAL, width INTEGER, height INTEGER);
648             INSERT INTO file_hashes (path, hash, ext, mime, exif_date, modified_at) VALUES
649               ('/lib/a.jpg','h_jpg','jpg','image/jpeg','2024-05-01T10:00:00','2024-05-01T10:00:00'),
650               ('/lib/b.mov','h_mov','mov','video/quicktime','2024-06-01T10:00:00','2024-06-01T10:00:00'),
651               ('/lib/c.heic','h_heic','heic','image/heic','2023-01-01T10:00:00','2023-01-01T10:00:00'),
652               ('/other/d.mp4','h_mp4','mp4','video/mp4','2024-07-01T10:00:00','2024-07-01T10:00:00');",
653        )
654        .unwrap();
655        c
656    }
657
658    fn sel() -> RowSelection {
659        RowSelection::default()
660    }
661
662    #[test]
663    fn no_selection_means_unconstrained_not_empty() {
664        // The distinction that matters: None = process everything,
665        // Some(empty) = process nothing. Collapsing them turns a typo into a
666        // full-library run.
667        let r = sel().resolve(&db(), &SelectionCtx::default()).unwrap();
668        assert!(r.hashes.is_none(), "no predicate given must not constrain");
669        assert!(sel().is_empty());
670    }
671
672    #[test]
673    fn or_within_an_axis() {
674        let mut s = sel();
675        s.exts = vec!["mov".into(), "mp4".into()];
676        let r = s.resolve(&db(), &SelectionCtx::default()).unwrap();
677        let h = r.hashes.unwrap();
678        assert_eq!(h.len(), 2);
679        assert!(h.contains("h_mov") && h.contains("h_mp4"));
680    }
681
682    #[test]
683    fn and_across_axes_can_be_empty_without_being_unconstrained() {
684        let mut s = sel();
685        s.kinds = vec![MediaKind::Video];
686        s.exts = vec!["jpg".into()];
687        let r = s.resolve(&db(), &SelectionCtx::default()).unwrap();
688        let h = r.hashes.expect("an active selection must constrain");
689        assert!(h.is_empty(), "video AND jpg matches nothing");
690    }
691
692    #[test]
693    fn kind_uses_mime_and_covers_every_image_type() {
694        let mut s = sel();
695        s.kinds = vec![MediaKind::Image];
696        let h = s
697            .resolve(&db(), &SelectionCtx::default())
698            .unwrap()
699            .hashes
700            .unwrap();
701        assert_eq!(h.len(), 2, "jpg and heic");
702        assert!(h.contains("h_jpg") && h.contains("h_heic"));
703    }
704
705    #[test]
706    fn dates_and_types_intersect() {
707        let mut s = sel();
708        s.kinds = vec![MediaKind::Video];
709        s.after = Some("2024-06-15T00:00:00".into());
710        let h = s
711            .resolve(&db(), &SelectionCtx::default())
712            .unwrap()
713            .hashes
714            .unwrap();
715        assert_eq!(h.len(), 1, "only the July mp4");
716        assert!(h.contains("h_mp4"));
717    }
718
719    #[cfg(unix)]
720    #[test]
721    fn a_symlinked_root_matches_rows_stored_under_its_target() {
722        // The CI failure this guards, reproduced without depending on a
723        // particular platform's layout. On Linux `/lib` is a symlink to
724        // `/usr/lib`, so canonicalising the root and *replacing* it made a
725        // correct --path match none of the rows stored under the name the user
726        // gave. It passed on macOS only because /lib does not exist there, so
727        // canonicalisation failed and the root survived by accident.
728        let t = tempfile::tempdir().unwrap();
729        let real = t.path().join("real");
730        std::fs::create_dir(&real).unwrap();
731        let link = t.path().join("link");
732        std::os::unix::fs::symlink(&real, &link).unwrap();
733
734        // Two rows, one stored under each name. Which one a real library holds
735        // depends only on which path the scan was pointed at, so both must
736        // match. The row stored under the *symlink* name is the one that
737        // reproduces the CI failure: canonicalising the root moves it to the
738        // target, away from the name the row actually holds.
739        let stored_via_link = link.join("a.jpg");
740        let stored_via_real = std::fs::canonicalize(&real).unwrap().join("b.jpg");
741        let c = Connection::open_in_memory().unwrap();
742        c.execute_batch(
743            "CREATE TABLE file_hashes (
744                path TEXT PRIMARY KEY, hash TEXT NOT NULL, size_bytes INTEGER,
745                created_at TEXT, modified_at TEXT, ext TEXT, mime TEXT, phash INTEGER,
746                exif_date TEXT, gps_lat REAL, gps_lon REAL, width INTEGER, height INTEGER);",
747        )
748        .unwrap();
749        c.execute(
750            "INSERT INTO file_hashes (path, hash, ext) VALUES (?1, 'h_link', 'jpg')",
751            [stored_via_link.to_str().unwrap()],
752        )
753        .unwrap();
754        c.execute(
755            "INSERT INTO file_hashes (path, hash, ext) VALUES (?1, 'h_real', 'jpg')",
756            [stored_via_real.to_str().unwrap()],
757        )
758        .unwrap();
759
760        // ...and the user selects it by the symlink they actually type.
761        let mut s = sel();
762        s.paths = vec![link.clone()];
763        let h = s
764            .resolve(&c, &SelectionCtx::default())
765            .unwrap()
766            .hashes
767            .unwrap();
768        assert_eq!(
769            h.len(),
770            2,
771            "a symlinked root must match rows stored under either name"
772        );
773
774        // And the plain case still holds: an unrelated root matches nothing.
775        let mut s = sel();
776        s.paths = vec![t.path().join("elsewhere")];
777        let h = s
778            .resolve(&c, &SelectionCtx::default())
779            .unwrap()
780            .hashes
781            .unwrap();
782        assert!(h.is_empty());
783    }
784
785    #[test]
786    fn path_matches_components_not_string_prefixes() {
787        // /lib must not also match a sibling like /library.
788        let mut s = sel();
789        s.paths = vec![PathBuf::from("/lib")];
790        let h = s
791            .resolve(&db(), &SelectionCtx::default())
792            .unwrap()
793            .hashes
794            .unwrap();
795        assert_eq!(h.len(), 3, "the three under /lib, not /other");
796        assert!(!h.contains("h_mp4"));
797
798        assert!(under(Path::new("/lib/a.jpg"), Path::new("/lib")));
799        assert!(!under(Path::new("/library/a.jpg"), Path::new("/lib")));
800    }
801
802    #[test]
803    fn category_without_a_model_is_an_error_naming_the_reason() {
804        // Silently returning nothing would look like "no files match".
805        let mut s = sel();
806        s.category = Some("document".into());
807        let e = s
808            .resolve(&db(), &SelectionCtx::default())
809            .unwrap_err()
810            .to_string();
811        assert!(e.contains("model"), "got: {e}");
812    }
813
814    #[test]
815    fn distances_survive_intersection_with_another_predicate() {
816        // Ported from query.rs when Filters was retired. The regression it
817        // guards is specific: an intersection that keeps the right hashes but
818        // drops their distances leaves `--sort distance` with nothing to sort
819        // by, and the failure is silent.
820        let c = db();
821        c.execute_batch(
822            "UPDATE file_hashes SET gps_lat = 52.5200, gps_lon = 13.4050 WHERE hash = 'h_jpg';
823             UPDATE file_hashes SET gps_lat = 48.8566, gps_lon = 2.3522   WHERE hash = 'h_mov';",
824        )
825        .unwrap();
826
827        let mut s = sel();
828        s.kinds = vec![MediaKind::Image];
829        s.place = Some(PlaceQuery::Coords(crate::query::GeoFilter {
830            lat: 52.5200,
831            lon: 13.4050,
832            radius_km: 10.0,
833        }));
834        let r = s.resolve(&c, &SelectionCtx::default()).unwrap();
835
836        let h = r.hashes.unwrap();
837        assert_eq!(h.len(), 1, "only the Berlin image");
838        assert!(h.contains("h_jpg"));
839        let d = r
840            .distances
841            .expect("a place was given, so distances must exist");
842        assert!(d.contains_key("h_jpg"), "distances kept for survivors");
843        assert!(!d.contains_key("h_mov"), "and dropped for the excluded");
844    }
845
846    #[test]
847    fn a_place_alone_still_yields_distances() {
848        let c = db();
849        c.execute_batch(
850            "UPDATE file_hashes SET gps_lat = 52.5200, gps_lon = 13.4050 WHERE hash = 'h_jpg';",
851        )
852        .unwrap();
853        let mut s = sel();
854        s.place = Some(PlaceQuery::Coords(crate::query::GeoFilter {
855            lat: 52.5200,
856            lon: 13.4050,
857            radius_km: 10.0,
858        }));
859        let r = s.resolve(&c, &SelectionCtx::default()).unwrap();
860        assert_eq!(r.hashes.unwrap().len(), 1);
861        assert!(r.distances.unwrap().contains_key("h_jpg"));
862    }
863
864    #[test]
865    fn describe_round_trips_the_flags_a_user_typed() {
866        let mut s = sel();
867        s.kinds = vec![MediaKind::Video];
868        s.after = Some("2024-01-01".into());
869        let d = s.describe();
870        assert!(
871            d.contains("--type video") && d.contains("--after 2024-01-01"),
872            "{d}"
873        );
874    }
875}