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