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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
52pub enum PresenceField {
53    Gps,
54    Date,
55}
56
57impl PresenceField {
58    pub fn parse(s: &str) -> anyhow::Result<Self> {
59        match s.trim().to_ascii_lowercase().as_str() {
60            "gps" => Ok(Self::Gps),
61            "date" => Ok(Self::Date),
62            other => anyhow::bail!("unknown presence field {other:?}; expected one of: gps, date"),
63        }
64    }
65
66    pub fn as_str(self) -> &'static str {
67        match self {
68            Self::Gps => "gps",
69            Self::Date => "date",
70        }
71    }
72}
73
74/// Normalise a user-supplied extension so `.MOV`, `MOV` and `mov` agree.
75///
76/// Done once at parse time rather than per row: a selection is compared
77/// against tens of thousands of files.
78pub fn normalise_ext(s: &str) -> String {
79    s.trim().trim_start_matches('.').to_lowercase()
80}
81
82/// Whether a row's type matches `kind`.
83///
84/// Resolves through `mime_probe::effective_mime`, which is what every other
85/// consumer does: a file whose magic bytes could not be identified stores the
86/// sentinel `application/octet-stream`, and `effective_mime` falls back to the
87/// extension. A `.jpg` whose header was unreadable is still a photo to its
88/// owner, and treating the sentinel as a type of its own would drop those files
89/// out of `--type image` for a reason the user cannot see.
90pub fn row_matches_kind(kind: MediaKind, mime: Option<&str>, ext: &str) -> bool {
91    let Some(m) = crate::mime_probe::effective_mime(mime, &ext.to_lowercase()) else {
92        return false;
93    };
94    match kind {
95        MediaKind::Image => crate::mime_probe::PHOTO_MIMES.contains(&m),
96        MediaKind::Video => crate::mime_probe::VIDEO_MIMES.contains(&m),
97    }
98}
99
100/// Whether a *path* matches `kind`, judged by extension alone.
101///
102/// :warning: This deliberately differs from `row_matches_kind`. A walk decides
103/// whether to read a file before it has read it, so the magic bytes are not
104/// available and the extension is all there is. On a library whose extensions
105/// are wrong, `scan --type video` and `search --type video` will disagree, and
106/// that is inherent to filtering before reading rather than a bug to fix.
107pub fn path_matches_kind(kind: MediaKind, path: &std::path::Path) -> bool {
108    let ext = path
109        .extension()
110        .and_then(|e| e.to_str())
111        .unwrap_or("")
112        .to_lowercase();
113    if ext.is_empty() {
114        return false;
115    }
116    row_matches_kind(kind, None, &ext)
117}
118
119use crate::query::{self, GeoFilter};
120use rusqlite::Connection;
121use std::collections::{HashMap, HashSet};
122use std::path::{Path, PathBuf};
123
124/// A place to select around: either a name needing geocoding, or coordinates.
125///
126/// Both exist because `search` takes a place *name* and geocodes it (a network
127/// call on a cache miss) while the underlying predicate wants coordinates. The
128/// layer owns both so that every command accepting `--location` geocodes
129/// identically instead of reimplementing it.
130#[derive(Debug, Clone)]
131pub enum PlaceQuery {
132    Named { place: String, radius_km: f64 },
133    Coords(GeoFilter),
134}
135
136/// What a command was asked to work on, over rows already in the database.
137#[derive(Debug, Clone, Default)]
138pub struct RowSelection {
139    pub person: Option<String>,
140    pub category: Option<String>,
141    pub place: Option<PlaceQuery>,
142    pub after: Option<String>,
143    pub before: Option<String>,
144    pub has: Vec<PresenceField>,
145    pub missing: Vec<PresenceField>,
146    pub kinds: Vec<MediaKind>,
147    pub exts: Vec<String>,
148    pub mimes: Vec<String>,
149    pub paths: Vec<PathBuf>,
150    /// Minimum star rating (>=), when filtering by rating.
151    pub min_rating: Option<i64>,
152    /// Exact pick state.
153    pub pick: Option<crate::marks::Pick>,
154    /// Exact colour label.
155    pub label: Option<String>,
156    /// Only liked photos.
157    pub liked: bool,
158    /// Tags that must all be present (AND across multiple --tag values).
159    pub tags: Vec<String>,
160}
161
162/// What a command can offer the resolver about itself.
163#[derive(Debug, Clone, Default)]
164pub struct SelectionCtx {
165    /// Needed to resolve `--category`, which is scoped to an embedding model.
166    /// `None` for commands with no model concept, such as `faces`.
167    pub model_id: Option<String>,
168}
169
170/// The outcome of resolving a selection.
171#[derive(Debug, Clone, Default)]
172pub struct Resolved {
173    /// `None` means nothing was selected: do not constrain, process everything.
174    /// `Some(empty)` means a selection ran and matched nothing: process
175    /// nothing. Collapsing the two would turn a typo into a full-library run.
176    pub hashes: Option<HashSet<String>>,
177    /// Km from the requested place, per surviving hash. `Some` only when a
178    /// place was given. Carried because `search --sort distance` needs it.
179    pub distances: Option<HashMap<String, f64>>,
180}
181
182impl RowSelection {
183    /// True when no predicate was given at all.
184    ///
185    /// Derived from the fields directly rather than kept as a separate
186    /// hand-maintained list, because the failure mode of forgetting to update
187    /// such a list is silent: the command processes the entire library.
188    pub fn is_empty(&self) -> bool {
189        self.person.is_none()
190            && self.category.is_none()
191            && self.place.is_none()
192            && self.after.is_none()
193            && self.before.is_none()
194            && self.has.is_empty()
195            && self.missing.is_empty()
196            && self.kinds.is_empty()
197            && self.exts.is_empty()
198            && self.mimes.is_empty()
199            && self.paths.is_empty()
200            && self.min_rating.is_none()
201            && self.pick.is_none()
202            && self.label.is_none()
203            && !self.liked
204            && self.tags.is_empty()
205    }
206
207    /// Human-readable form for progress lines and confirmations.
208    pub fn describe(&self) -> String {
209        let mut parts: Vec<String> = Vec::new();
210        if let Some(p) = &self.person {
211            parts.push(format!("--person {p:?}"));
212        }
213        if let Some(c) = &self.category {
214            parts.push(format!("--category {c}"));
215        }
216        match &self.place {
217            Some(PlaceQuery::Named { place, radius_km }) => {
218                parts.push(format!("--location {place:?} --radius {radius_km}"))
219            }
220            Some(PlaceQuery::Coords(g)) => parts.push(format!(
221                "--location {},{} --radius {}",
222                g.lat, g.lon, g.radius_km
223            )),
224            None => {}
225        }
226        if let Some(a) = &self.after {
227            parts.push(format!("--after {a}"));
228        }
229        if let Some(b) = &self.before {
230            parts.push(format!("--before {b}"));
231        }
232        for field in &self.has {
233            parts.push(format!("--has {}", field.as_str()));
234        }
235        for field in &self.missing {
236            parts.push(format!("--missing {}", field.as_str()));
237        }
238        for k in &self.kinds {
239            parts.push(format!("--type {}", k.as_str()));
240        }
241        if !self.exts.is_empty() {
242            parts.push(format!("--ext {}", self.exts.join(",")));
243        }
244        if !self.mimes.is_empty() {
245            parts.push(format!("--mime {}", self.mimes.join(",")));
246        }
247        for p in &self.paths {
248            parts.push(format!("--path {}", p.display()));
249        }
250        if let Some(r) = self.min_rating {
251            parts.push(format!("--rating {r}"));
252        }
253        if let Some(p) = self.pick {
254            parts.push(format!(
255                "--pick {}",
256                match p {
257                    crate::marks::Pick::Keep => "keep",
258                    crate::marks::Pick::Reject => "reject",
259                }
260            ));
261        }
262        if let Some(l) = &self.label {
263            parts.push(format!("--label {l}"));
264        }
265        if self.liked {
266            parts.push("--like".to_string());
267        }
268        for t in &self.tags {
269            parts.push(format!("--tag {t}"));
270        }
271        parts.join(" ")
272    }
273
274    /// Run every active predicate and intersect them.
275    ///
276    /// Predicates OR within an axis (`--ext mov,avi` matches either) and AND
277    /// across axes (`--type video --ext jpg` matches nothing), which is how
278    /// every existing filter already composes.
279    ///
280    /// Path filters are resolved against the process's ambient filesystem
281    /// here; a caller holding a library context should use
282    /// [`RowSelection::resolve_in`], which constrains them to that library
283    /// first.
284    pub fn resolve(&self, conn: &Connection, ctx: &SelectionCtx) -> anyhow::Result<Resolved> {
285        self.resolve_selection(conn, ctx, false)
286    }
287
288    /// Resolve one selection against one library, with its path filters
289    /// constrained to that library before anything else runs.
290    ///
291    /// The only difference from [`RowSelection::resolve`] is the path axis:
292    /// every supplied filter is validated by
293    /// [`library_guard::validate_paths`](crate::library_guard::validate_paths)
294    /// (relative filters resolve against the library root, absolute ones must
295    /// physically land inside it), and one bad filter rejects the whole
296    /// invocation rather than being quietly dropped. The guarded forms are
297    /// substituted into a clone and run through the same engine as the
298    /// ambient resolver, so a direct caller cannot bypass the guard by
299    /// skipping CLI parsing, and every other axis behaves identically. A
300    /// selection with no path filters resolves exactly as [`Self::resolve`]
301    /// does.
302    pub fn resolve_in(
303        &self,
304        conn: &Connection,
305        ctx: &SelectionCtx,
306        library: &crate::library::LibraryContext,
307    ) -> anyhow::Result<Resolved> {
308        let mut guarded = self.clone();
309        guarded.paths = crate::library_guard::validate_paths(library, &self.paths)?;
310        guarded.resolve_selection(conn, ctx, true)
311    }
312
313    /// The engine both resolvers share: every active predicate, intersected.
314    /// Private so the only public entrances are `resolve` (ambient paths) and
315    /// `resolve_in` (guarded paths); there is no third way to reach this.
316    fn resolve_selection(
317        &self,
318        conn: &Connection,
319        ctx: &SelectionCtx,
320        paths_are_guarded: bool,
321    ) -> anyhow::Result<Resolved> {
322        if self.is_empty() {
323            return Ok(Resolved::default());
324        }
325
326        let mut acc: Option<HashSet<String>> = None;
327        let narrow = |s: HashSet<String>, acc: &mut Option<HashSet<String>>| match acc {
328            Some(existing) => *acc = Some(existing.intersection(&s).cloned().collect()),
329            None => *acc = Some(s),
330        };
331
332        if let Some(p) = &self.person {
333            narrow(query::by_person(conn, p)?, &mut acc);
334        }
335        if let Some(c) = &self.category {
336            let model = ctx.model_id.as_deref().ok_or_else(|| {
337                anyhow::anyhow!(
338                    "--category needs an embedding model, and this command has none; \
339                     classifications are stored per model"
340                )
341            })?;
342            narrow(query::by_category(conn, model, c)?, &mut acc);
343        }
344        if self.after.is_some() || self.before.is_some() {
345            narrow(
346                query::by_date(conn, self.after.as_deref(), self.before.as_deref())?,
347                &mut acc,
348            );
349        }
350        for field in &self.has {
351            narrow(by_presence(conn, *field, true)?, &mut acc);
352        }
353        for field in &self.missing {
354            narrow(by_presence(conn, *field, false)?, &mut acc);
355        }
356        if !self.kinds.is_empty() {
357            narrow(by_kinds(conn, &self.kinds)?, &mut acc);
358        }
359        if !self.exts.is_empty() {
360            narrow(by_exts(conn, &self.exts)?, &mut acc);
361        }
362        if !self.mimes.is_empty() {
363            narrow(by_mimes(conn, &self.mimes)?, &mut acc);
364        }
365        if !self.paths.is_empty() {
366            narrow(by_paths(conn, &self.paths, paths_are_guarded)?, &mut acc);
367        }
368        if let Some(min) = self.min_rating {
369            narrow(crate::marks::by_rating(conn, min)?, &mut acc);
370        }
371        if let Some(p) = self.pick {
372            narrow(crate::marks::by_pick(conn, p)?, &mut acc);
373        }
374        if let Some(l) = &self.label {
375            narrow(crate::marks::by_label(conn, l)?, &mut acc);
376        }
377        if self.liked {
378            narrow(crate::marks::by_liked(conn)?, &mut acc);
379        }
380        for t in &self.tags {
381            narrow(crate::tags::by_tag(conn, t)?, &mut acc);
382        }
383
384        // Place last: geocoding may hit the network, so an already-empty
385        // candidate set should skip it entirely.
386        let mut distances = None;
387        if let Some(place) = &self.place {
388            if acc.as_ref().is_some_and(|h| h.is_empty()) {
389                distances = Some(HashMap::new());
390            } else {
391                let geo = match place {
392                    PlaceQuery::Coords(g) => *g,
393                    PlaceQuery::Named { place, radius_km } => {
394                        crate::geocode::ensure_geocode_cache_table(conn)?;
395                        let (lat, lon) = crate::geocode::forward_geocode_cached(conn, place)?;
396                        GeoFilter {
397                            lat,
398                            lon,
399                            radius_km: *radius_km,
400                        }
401                    }
402                };
403                let within = query::by_location(conn, geo.lat, geo.lon, geo.radius_km)?;
404                let keep: HashSet<String> = match &acc {
405                    Some(existing) => within
406                        .keys()
407                        .filter(|h| existing.contains(*h))
408                        .cloned()
409                        .collect(),
410                    None => within.keys().cloned().collect(),
411                };
412                distances = Some(
413                    within
414                        .into_iter()
415                        .filter(|(h, _)| keep.contains(h))
416                        .collect(),
417                );
418                acc = Some(keep);
419            }
420        }
421
422        Ok(Resolved {
423            hashes: acc,
424            distances,
425        })
426    }
427}
428
429fn by_presence(
430    conn: &Connection,
431    field: PresenceField,
432    want_present: bool,
433) -> anyhow::Result<HashSet<String>> {
434    let sql = match (field, want_present) {
435        (PresenceField::Gps, true) => "SELECT DISTINCT hash FROM file_hashes
436             WHERE gps_lat IS NOT NULL AND gps_lon IS NOT NULL"
437            .to_string(),
438        (PresenceField::Gps, false) => "SELECT DISTINCT hash FROM file_hashes
439             WHERE gps_lat IS NULL OR gps_lon IS NULL"
440            .to_string(),
441        (PresenceField::Date, true) => format!(
442            "SELECT DISTINCT hash FROM file_hashes WHERE {} IS NOT NULL",
443            crate::query::EFFECTIVE_DATE_SQL
444        ),
445        (PresenceField::Date, false) => format!(
446            "SELECT DISTINCT hash FROM file_hashes WHERE {} IS NULL",
447            crate::query::EFFECTIVE_DATE_SQL
448        ),
449    };
450    let mut stmt = conn.prepare(&sql)?;
451    let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
452    rows.collect::<rusqlite::Result<HashSet<_>>>()
453        .map_err(Into::into)
454}
455
456/// Hashes whose type matches any of `kinds`.
457///
458/// Filtered in Rust rather than SQL because the sentinel-mime fallback lives in
459/// `effective_mime`; expressing it in SQL would duplicate logic that already
460/// exists and would drift from it.
461fn by_kinds(conn: &Connection, kinds: &[MediaKind]) -> anyhow::Result<HashSet<String>> {
462    let mut stmt = conn.prepare("SELECT hash, mime, ext FROM file_hashes")?;
463    let rows = stmt.query_map([], |r| {
464        Ok((
465            r.get::<_, String>(0)?,
466            r.get::<_, Option<String>>(1)?,
467            r.get::<_, Option<String>>(2)?,
468        ))
469    })?;
470    let mut out = HashSet::new();
471    for row in rows {
472        let (hash, mime, ext) = row?;
473        let ext = ext.unwrap_or_default();
474        if kinds
475            .iter()
476            .any(|k| row_matches_kind(*k, mime.as_deref(), &ext))
477        {
478            out.insert(hash);
479        }
480    }
481    Ok(out)
482}
483
484fn by_exts(conn: &Connection, exts: &[String]) -> anyhow::Result<HashSet<String>> {
485    let wanted: HashSet<String> = exts.iter().map(|e| normalise_ext(e)).collect();
486    let mut stmt = conn.prepare("SELECT hash, ext FROM file_hashes WHERE ext IS NOT NULL")?;
487    let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
488    let mut out = HashSet::new();
489    for row in rows {
490        let (hash, ext) = row?;
491        if wanted.contains(&normalise_ext(&ext)) {
492            out.insert(hash);
493        }
494    }
495    Ok(out)
496}
497
498fn by_mimes(conn: &Connection, mimes: &[String]) -> anyhow::Result<HashSet<String>> {
499    let wanted: HashSet<String> = mimes.iter().map(|m| m.trim().to_lowercase()).collect();
500    let mut stmt = conn.prepare("SELECT hash, mime FROM file_hashes WHERE mime IS NOT NULL")?;
501    let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
502    let mut out = HashSet::new();
503    for row in rows {
504        let (hash, mime) = row?;
505        if wanted.contains(&mime.to_lowercase()) {
506            out.insert(hash);
507        }
508    }
509    Ok(out)
510}
511
512#[cfg(test)]
513thread_local! {
514    static AMBIENT_CANONICALIZATIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
515}
516
517#[cfg(test)]
518fn count_ambient_canonicalizations() -> usize {
519    AMBIENT_CANONICALIZATIONS.with(std::cell::Cell::get)
520}
521
522/// Each root, plus its canonical form when that differs.
523///
524/// Used by both selection shapes, which is the point: a root must be matched in
525/// *either* form, because the two sides of the comparison are canonicalised
526/// inconsistently and neither side can be normalised cheaply. Stored paths are
527/// whatever was walked, and canonicalising them at match time would cost a stat
528/// per row. Replacing the root with its canonical form instead is what broke
529/// both shapes independently: on Linux `/lib` canonicalises to `/usr/lib`, so a
530/// `--path /lib` matched none of the rows stored under `/lib`, silently and
531/// while reporting success.
532///
533/// A root that cannot be canonicalised (it may not exist) is kept as given
534/// rather than treated as an error, since selecting a missing directory
535/// legitimately matches nothing.
536fn roots_in_both_forms(roots: &[PathBuf]) -> Vec<PathBuf> {
537    let mut out = Vec::with_capacity(roots.len() * 2);
538    for r in roots {
539        out.push(r.clone());
540        #[cfg(test)]
541        AMBIENT_CANONICALIZATIONS.with(|count| count.set(count.get() + 1));
542        if let Ok(c) = std::fs::canonicalize(r) {
543            if c != *r {
544                out.push(c);
545            }
546        }
547    }
548    out
549}
550
551/// Hashes whose path lies under any of `roots`.
552///
553/// Compares **path components**, not string prefixes, so `/Pictures/2024` does
554/// not also match `/Pictures/2024-old`.
555fn by_paths(
556    conn: &Connection,
557    roots: &[PathBuf],
558    roots_are_guarded: bool,
559) -> anyhow::Result<HashSet<String>> {
560    let expanded;
561    let roots = if roots_are_guarded {
562        roots
563    } else {
564        expanded = roots_in_both_forms(roots);
565        &expanded
566    };
567    let mut stmt = conn.prepare("SELECT hash, path FROM file_hashes")?;
568    let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
569    let mut out = HashSet::new();
570    for row in rows {
571        let (hash, path) = row?;
572        let p = Path::new(&path);
573        if roots.iter().any(|r| under(p, r)) {
574            out.insert(hash);
575        }
576    }
577    Ok(out)
578}
579
580fn under(path: &Path, root: &Path) -> bool {
581    let mut a = path.components();
582    for c in root.components() {
583        match a.next() {
584            Some(x) if x == c => {}
585            _ => return false,
586        }
587    }
588    true
589}
590
591/// What a *walking* command was asked to work on.
592///
593/// Deliberately smaller than `RowSelection` and a separate type: a walk decides
594/// whether to read a file before reading it, so it cannot answer questions
595/// about dates, coordinates, people or true mime type. Excluding those from the
596/// vocabulary is what makes the flags they share safe to name identically -
597/// a `scan --after` meaning "re-scan known rows in this range" while
598/// `embed --after` means "restrict to these files" would be one flag with two
599/// meanings, one command apart.
600#[derive(Debug, Clone, Default)]
601pub struct PathSelection {
602    pub kinds: Vec<MediaKind>,
603    pub exts: Vec<String>,
604    pub paths: Vec<PathBuf>,
605}
606
607impl PathSelection {
608    pub fn is_empty(&self) -> bool {
609        self.kinds.is_empty() && self.exts.is_empty() && self.paths.is_empty()
610    }
611
612    /// Whether the walk should keep this path.
613    ///
614    /// Pure: no database, and no I/O beyond the canonicalisation the roots
615    /// already had. An empty selection accepts everything, so a command with no
616    /// flags behaves exactly as before.
617    pub fn accepts(&self, path: &Path) -> bool {
618        if self.is_empty() {
619            return true;
620        }
621        if !self.kinds.is_empty() && !self.kinds.iter().any(|k| path_matches_kind(*k, path)) {
622            return false;
623        }
624        if !self.exts.is_empty() {
625            let ext = path
626                .extension()
627                .and_then(|e| e.to_str())
628                .map(normalise_ext)
629                .unwrap_or_default();
630            if !self.exts.iter().any(|e| normalise_ext(e) == ext) {
631                return false;
632            }
633        }
634        if !self.paths.is_empty() && !self.paths.iter().any(|r| under(path, r)) {
635            return false;
636        }
637        true
638    }
639
640    /// Add each root's canonical form alongside the one given, once, so
641    /// `accepts` stays cheap across a walk of tens of thousands of entries.
642    ///
643    /// Both forms are kept rather than the canonical one alone, because the
644    /// walk is rooted at the directory the user typed and yields paths in
645    /// *that* form. On macOS a tempdir, `/tmp`, `/var`, and any symlinked photo
646    /// directory all canonicalise to something with a different prefix, so
647    /// replacing the root would make a perfectly correct `--path` match nothing
648    /// at all - silently, since matching nothing is not an error.
649    pub fn canonicalised(mut self) -> Self {
650        self.paths = roots_in_both_forms(&self.paths);
651        self
652    }
653
654    pub fn describe(&self) -> String {
655        let mut parts: Vec<String> = Vec::new();
656        for k in &self.kinds {
657            parts.push(format!("--type {}", k.as_str()));
658        }
659        if !self.exts.is_empty() {
660            parts.push(format!("--ext {}", self.exts.join(",")));
661        }
662        for p in &self.paths {
663            parts.push(format!("--path {}", p.display()));
664        }
665        parts.join(" ")
666    }
667}
668
669#[cfg(test)]
670mod path_selection_tests {
671    use super::*;
672
673    #[test]
674    fn an_empty_selection_accepts_everything() {
675        // A command with no flags must walk exactly as it did before.
676        let s = PathSelection::default();
677        assert!(s.accepts(Path::new("/a/b.jpg")));
678        assert!(s.accepts(Path::new("/a/b.mov")));
679    }
680
681    #[test]
682    fn kinds_and_exts_and_paths_all_narrow() {
683        let s = PathSelection {
684            kinds: vec![MediaKind::Video],
685            ..Default::default()
686        };
687        assert!(s.accepts(Path::new("/a/b.mov")));
688        assert!(!s.accepts(Path::new("/a/b.jpg")));
689
690        let s = PathSelection {
691            exts: vec![".MOV".into()],
692            ..Default::default()
693        };
694        assert!(s.accepts(Path::new("/a/b.mov")), "case and dot normalise");
695
696        let s = PathSelection {
697            paths: vec![PathBuf::from("/lib")],
698            ..Default::default()
699        };
700        assert!(s.accepts(Path::new("/lib/a.jpg")));
701        assert!(
702            !s.accepts(Path::new("/library/a.jpg")),
703            "components, not prefix"
704        );
705    }
706
707    #[test]
708    fn axes_intersect() {
709        let s = PathSelection {
710            kinds: vec![MediaKind::Video],
711            paths: vec![PathBuf::from("/lib")],
712            ..Default::default()
713        };
714        assert!(s.accepts(Path::new("/lib/a.mov")));
715        assert!(!s.accepts(Path::new("/lib/a.jpg")), "wrong kind");
716        assert!(!s.accepts(Path::new("/other/a.mov")), "wrong place");
717    }
718
719    #[test]
720    fn type_here_is_by_extension_and_that_differs_from_rows() {
721        // Asserted on purpose. A walk has not read the file, so a .mov whose
722        // bytes are really a JPEG is accepted by `scan --type video` and
723        // rejected by `search --type video`. Inherent to filtering before
724        // reading; not a bug to "fix" into agreement.
725        let s = PathSelection {
726            kinds: vec![MediaKind::Video],
727            ..Default::default()
728        };
729        assert!(s.accepts(Path::new("/a/mislabelled.mov")));
730        assert!(!row_matches_kind(
731            MediaKind::Video,
732            Some("image/jpeg"),
733            "mov"
734        ));
735    }
736}
737
738#[cfg(test)]
739mod tests {
740    #[test]
741    fn a_symlinked_root_matches_paths_in_either_form() {
742        // The failure this guards is silent: a --path under a symlink (every
743        // macOS tempdir, /tmp, /var) canonicalises to a different prefix than
744        // the walk produces, so the selection would match nothing and report
745        // success.
746        let dir = tempfile::tempdir().unwrap();
747        let given = dir.path().join("sub");
748        std::fs::create_dir(&given).unwrap();
749        let canonical = std::fs::canonicalize(&given).unwrap();
750
751        let sel = PathSelection {
752            paths: vec![given.clone()],
753            ..Default::default()
754        }
755        .canonicalised();
756
757        assert!(sel.accepts(&given.join("a.jpg")), "the form the user typed");
758        assert!(sel.accepts(&canonical.join("a.jpg")), "the canonical form");
759        assert!(!sel.accepts(&dir.path().join("outside.jpg")));
760    }
761
762    use super::*;
763    use std::path::Path;
764
765    #[test]
766    fn parse_names_every_valid_value_on_a_typo() {
767        assert_eq!(MediaKind::parse("image").unwrap(), MediaKind::Image);
768        assert_eq!(MediaKind::parse("VIDEO").unwrap(), MediaKind::Video);
769        assert_eq!(MediaKind::parse("  video  ").unwrap(), MediaKind::Video);
770        let e = MediaKind::parse("vidoe").unwrap_err().to_string();
771        assert!(e.contains("image") && e.contains("video"), "got: {e}");
772    }
773
774    #[test]
775    fn presence_field_parse_names_supported_values() {
776        assert_eq!(PresenceField::parse("gps").unwrap(), PresenceField::Gps);
777        assert_eq!(PresenceField::parse("date").unwrap(), PresenceField::Date);
778
779        let err = PresenceField::parse("faces").unwrap_err().to_string();
780        assert!(err.contains("gps"), "{err}");
781        assert!(err.contains("date"), "{err}");
782    }
783
784    #[test]
785    fn extensions_normalise_to_one_spelling() {
786        for s in [".MOV", "MOV", "mov", " .mov "] {
787            assert_eq!(normalise_ext(s), "mov", "input {s:?}");
788        }
789    }
790
791    #[test]
792    fn an_unidentified_file_is_still_its_extension() {
793        // The sentinel means "could not identify", not "a type of its own".
794        // Dropping these from --type image would be invisible to the user.
795        assert!(row_matches_kind(
796            MediaKind::Image,
797            Some(crate::mime_probe::UNKNOWN_MIME),
798            "jpg"
799        ));
800        assert!(row_matches_kind(MediaKind::Video, None, "mov"));
801    }
802
803    #[test]
804    fn mime_beats_a_wrong_extension_for_rows() {
805        assert!(row_matches_kind(
806            MediaKind::Image,
807            Some("image/jpeg"),
808            "txt"
809        ));
810        assert!(!row_matches_kind(
811            MediaKind::Video,
812            Some("image/jpeg"),
813            "mov"
814        ));
815    }
816
817    #[test]
818    fn paths_are_judged_by_extension_only() {
819        // The divergence from row matching, asserted on purpose: a walk has not
820        // read the file, so this is all it can know.
821        assert!(path_matches_kind(MediaKind::Video, Path::new("/a/b.mov")));
822        assert!(path_matches_kind(MediaKind::Image, Path::new("/a/b.HEIC")));
823        assert!(!path_matches_kind(MediaKind::Image, Path::new("/a/b.mov")));
824        assert!(!path_matches_kind(MediaKind::Image, Path::new("/a/noext")));
825    }
826}
827
828#[cfg(test)]
829mod resolve_tests {
830    use super::*;
831
832    fn db() -> Connection {
833        let c = Connection::open_in_memory().unwrap();
834        c.execute_batch(
835            "CREATE TABLE file_hashes (
836                path TEXT PRIMARY KEY, hash TEXT NOT NULL, size_bytes INTEGER,
837                created_at TEXT, modified_at TEXT, ext TEXT, mime TEXT, phash INTEGER,
838                exif_date TEXT, gps_lat REAL, gps_lon REAL, width INTEGER, height INTEGER);
839             INSERT INTO file_hashes (path, hash, ext, mime, exif_date, modified_at) VALUES
840               ('/lib/a.jpg','h_jpg','jpg','image/jpeg','2024-05-01T10:00:00','2024-05-01T10:00:00'),
841               ('/lib/b.mov','h_mov','mov','video/quicktime','2024-06-01T10:00:00','2024-06-01T10:00:00'),
842               ('/lib/c.heic','h_heic','heic','image/heic','2023-01-01T10:00:00','2023-01-01T10:00:00'),
843               ('/other/d.mp4','h_mp4','mp4','video/mp4','2024-07-01T10:00:00','2024-07-01T10:00:00');",
844        )
845        .unwrap();
846        c
847    }
848
849    fn sel() -> RowSelection {
850        RowSelection::default()
851    }
852
853    fn set<const N: usize>(hashes: [&str; N]) -> HashSet<String> {
854        hashes.into_iter().map(str::to_string).collect()
855    }
856
857    fn presence_db() -> Connection {
858        let c = db();
859        c.execute("DELETE FROM file_hashes", []).unwrap();
860        c
861    }
862
863    fn insert_presence_row(
864        conn: &rusqlite::Connection,
865        hash: &str,
866        path: &str,
867        gps_lat: Option<f64>,
868        gps_lon: Option<f64>,
869        exif_date: Option<&str>,
870        modified_at: Option<&str>,
871    ) {
872        conn.execute(
873            "INSERT INTO file_hashes
874             (path, hash, size_bytes, ext, mime, gps_lat, gps_lon, exif_date, modified_at)
875             VALUES (?1, ?2, 1, 'jpg', 'image/jpeg', ?3, ?4, ?5, ?6)",
876            rusqlite::params![path, hash, gps_lat, gps_lon, exif_date, modified_at],
877        )
878        .unwrap();
879    }
880
881    #[test]
882    fn no_selection_means_unconstrained_not_empty() {
883        // The distinction that matters: None = process everything,
884        // Some(empty) = process nothing. Collapsing them turns a typo into a
885        // full-library run.
886        let r = sel().resolve(&db(), &SelectionCtx::default()).unwrap();
887        assert!(r.hashes.is_none(), "no predicate given must not constrain");
888        assert!(sel().is_empty());
889    }
890
891    #[test]
892    fn presence_filters_select_gps_rows() {
893        let conn = presence_db();
894        insert_presence_row(
895            &conn,
896            "both",
897            "/tmp/both.jpg",
898            Some(52.5),
899            Some(13.4),
900            None,
901            Some("2024-01-01T00:00:00"),
902        );
903        insert_presence_row(
904            &conn,
905            "missing_lat",
906            "/tmp/missing-lat.jpg",
907            None,
908            Some(13.4),
909            None,
910            Some("2024-01-01T00:00:00"),
911        );
912        insert_presence_row(
913            &conn,
914            "missing_lon",
915            "/tmp/missing-lon.jpg",
916            Some(52.5),
917            None,
918            None,
919            Some("2024-01-01T00:00:00"),
920        );
921
922        let mut has = RowSelection::default();
923        has.has.push(PresenceField::Gps);
924        assert_eq!(
925            has.resolve(&conn, &SelectionCtx::default())
926                .unwrap()
927                .hashes
928                .unwrap(),
929            set(["both"])
930        );
931
932        let mut missing = RowSelection::default();
933        missing.missing.push(PresenceField::Gps);
934        assert_eq!(
935            missing
936                .resolve(&conn, &SelectionCtx::default())
937                .unwrap()
938                .hashes
939                .unwrap(),
940            set(["missing_lat", "missing_lon"])
941        );
942    }
943
944    #[test]
945    fn presence_filters_select_effective_date_rows() {
946        let conn = presence_db();
947        insert_presence_row(
948            &conn,
949            "exif",
950            "/tmp/exif.jpg",
951            None,
952            None,
953            Some("2024-05-01T10:00:00"),
954            None,
955        );
956        insert_presence_row(
957            &conn,
958            "mtime",
959            "/tmp/mtime.jpg",
960            None,
961            None,
962            None,
963            Some("2024-05-02T10:00:00"),
964        );
965        insert_presence_row(&conn, "missing", "/tmp/missing.jpg", None, None, None, None);
966
967        let mut has = RowSelection::default();
968        has.has.push(PresenceField::Date);
969        assert_eq!(
970            has.resolve(&conn, &SelectionCtx::default())
971                .unwrap()
972                .hashes
973                .unwrap(),
974            set(["exif", "mtime"])
975        );
976
977        let mut missing = RowSelection::default();
978        missing.missing.push(PresenceField::Date);
979        assert_eq!(
980            missing
981                .resolve(&conn, &SelectionCtx::default())
982                .unwrap()
983                .hashes
984                .unwrap(),
985            set(["missing"])
986        );
987    }
988
989    #[test]
990    fn contradictory_presence_filters_match_nothing() {
991        let conn = presence_db();
992        insert_presence_row(
993            &conn,
994            "both",
995            "/tmp/both.jpg",
996            Some(52.5),
997            Some(13.4),
998            None,
999            Some("2024-01-01T00:00:00"),
1000        );
1001
1002        let mut sel = RowSelection::default();
1003        sel.has.push(PresenceField::Gps);
1004        sel.missing.push(PresenceField::Gps);
1005
1006        let resolved = sel.resolve(&conn, &SelectionCtx::default()).unwrap();
1007        assert!(resolved.hashes.unwrap().is_empty());
1008    }
1009
1010    #[test]
1011    fn presence_fields_participate_in_empty_and_describe() {
1012        let mut sel = RowSelection::default();
1013        assert!(sel.is_empty());
1014
1015        sel.has.push(PresenceField::Gps);
1016        sel.missing.push(PresenceField::Date);
1017
1018        assert!(!sel.is_empty());
1019        assert_eq!(sel.describe(), "--has gps --missing date");
1020    }
1021
1022    #[test]
1023    fn or_within_an_axis() {
1024        let mut s = sel();
1025        s.exts = vec!["mov".into(), "mp4".into()];
1026        let r = s.resolve(&db(), &SelectionCtx::default()).unwrap();
1027        let h = r.hashes.unwrap();
1028        assert_eq!(h.len(), 2);
1029        assert!(h.contains("h_mov") && h.contains("h_mp4"));
1030    }
1031
1032    #[test]
1033    fn tag_predicate_narrows_and_ands_with_other_axes() {
1034        let conn = db();
1035        crate::tags::set_tags(&conn, &["h_jpg".into(), "h_heic".into()], &["beach".into()])
1036            .unwrap();
1037        // The tag alone selects both tagged files.
1038        let mut s = sel();
1039        s.tags = vec!["beach".into()];
1040        let h = s
1041            .resolve(&conn, &SelectionCtx::default())
1042            .unwrap()
1043            .hashes
1044            .unwrap();
1045        assert_eq!(h.len(), 2);
1046        assert!(h.contains("h_jpg") && h.contains("h_heic"));
1047        // And it intersects with another axis.
1048        let mut s = sel();
1049        s.tags = vec!["beach".into()];
1050        s.exts = vec!["jpg".into()];
1051        let h = s
1052            .resolve(&conn, &SelectionCtx::default())
1053            .unwrap()
1054            .hashes
1055            .unwrap();
1056        assert_eq!(h.into_iter().collect::<Vec<_>>(), vec!["h_jpg"]);
1057    }
1058
1059    #[test]
1060    fn and_across_axes_can_be_empty_without_being_unconstrained() {
1061        let mut s = sel();
1062        s.kinds = vec![MediaKind::Video];
1063        s.exts = vec!["jpg".into()];
1064        let r = s.resolve(&db(), &SelectionCtx::default()).unwrap();
1065        let h = r.hashes.expect("an active selection must constrain");
1066        assert!(h.is_empty(), "video AND jpg matches nothing");
1067    }
1068
1069    #[test]
1070    fn kind_uses_mime_and_covers_every_image_type() {
1071        let mut s = sel();
1072        s.kinds = vec![MediaKind::Image];
1073        let h = s
1074            .resolve(&db(), &SelectionCtx::default())
1075            .unwrap()
1076            .hashes
1077            .unwrap();
1078        assert_eq!(h.len(), 2, "jpg and heic");
1079        assert!(h.contains("h_jpg") && h.contains("h_heic"));
1080    }
1081
1082    #[test]
1083    fn dates_and_types_intersect() {
1084        let mut s = sel();
1085        s.kinds = vec![MediaKind::Video];
1086        s.after = Some("2024-06-15T00:00:00".into());
1087        let h = s
1088            .resolve(&db(), &SelectionCtx::default())
1089            .unwrap()
1090            .hashes
1091            .unwrap();
1092        assert_eq!(h.len(), 1, "only the July mp4");
1093        assert!(h.contains("h_mp4"));
1094    }
1095
1096    #[cfg(unix)]
1097    #[test]
1098    fn a_symlinked_root_matches_rows_stored_under_its_target() {
1099        // The CI failure this guards, reproduced without depending on a
1100        // particular platform's layout. On Linux `/lib` is a symlink to
1101        // `/usr/lib`, so canonicalising the root and *replacing* it made a
1102        // correct --path match none of the rows stored under the name the user
1103        // gave. It passed on macOS only because /lib does not exist there, so
1104        // canonicalisation failed and the root survived by accident.
1105        let t = tempfile::tempdir().unwrap();
1106        let real = t.path().join("real");
1107        std::fs::create_dir(&real).unwrap();
1108        let link = t.path().join("link");
1109        std::os::unix::fs::symlink(&real, &link).unwrap();
1110
1111        // Two rows, one stored under each name. Which one a real library holds
1112        // depends only on which path the scan was pointed at, so both must
1113        // match. The row stored under the *symlink* name is the one that
1114        // reproduces the CI failure: canonicalising the root moves it to the
1115        // target, away from the name the row actually holds.
1116        let stored_via_link = link.join("a.jpg");
1117        let stored_via_real = std::fs::canonicalize(&real).unwrap().join("b.jpg");
1118        let c = Connection::open_in_memory().unwrap();
1119        c.execute_batch(
1120            "CREATE TABLE file_hashes (
1121                path TEXT PRIMARY KEY, hash TEXT NOT NULL, size_bytes INTEGER,
1122                created_at TEXT, modified_at TEXT, ext TEXT, mime TEXT, phash INTEGER,
1123                exif_date TEXT, gps_lat REAL, gps_lon REAL, width INTEGER, height INTEGER);",
1124        )
1125        .unwrap();
1126        c.execute(
1127            "INSERT INTO file_hashes (path, hash, ext) VALUES (?1, 'h_link', 'jpg')",
1128            [stored_via_link.to_str().unwrap()],
1129        )
1130        .unwrap();
1131        c.execute(
1132            "INSERT INTO file_hashes (path, hash, ext) VALUES (?1, 'h_real', 'jpg')",
1133            [stored_via_real.to_str().unwrap()],
1134        )
1135        .unwrap();
1136
1137        // ...and the user selects it by the symlink they actually type.
1138        let mut s = sel();
1139        s.paths = vec![link.clone()];
1140        let h = s
1141            .resolve(&c, &SelectionCtx::default())
1142            .unwrap()
1143            .hashes
1144            .unwrap();
1145        assert_eq!(
1146            h.len(),
1147            2,
1148            "a symlinked root must match rows stored under either name"
1149        );
1150
1151        // And the plain case still holds: an unrelated root matches nothing.
1152        let mut s = sel();
1153        s.paths = vec![t.path().join("elsewhere")];
1154        let h = s
1155            .resolve(&c, &SelectionCtx::default())
1156            .unwrap()
1157            .hashes
1158            .unwrap();
1159        assert!(h.is_empty());
1160    }
1161
1162    #[test]
1163    fn path_matches_components_not_string_prefixes() {
1164        // /lib must not also match a sibling like /library.
1165        let mut s = sel();
1166        s.paths = vec![PathBuf::from("/lib")];
1167        let h = s
1168            .resolve(&db(), &SelectionCtx::default())
1169            .unwrap()
1170            .hashes
1171            .unwrap();
1172        assert_eq!(h.len(), 3, "the three under /lib, not /other");
1173        assert!(!h.contains("h_mp4"));
1174
1175        assert!(under(Path::new("/lib/a.jpg"), Path::new("/lib")));
1176        assert!(!under(Path::new("/library/a.jpg"), Path::new("/lib")));
1177    }
1178
1179    #[test]
1180    fn category_without_a_model_is_an_error_naming_the_reason() {
1181        // Silently returning nothing would look like "no files match".
1182        let mut s = sel();
1183        s.category = Some("document".into());
1184        let e = s
1185            .resolve(&db(), &SelectionCtx::default())
1186            .unwrap_err()
1187            .to_string();
1188        assert!(e.contains("model"), "got: {e}");
1189    }
1190
1191    #[test]
1192    fn distances_survive_intersection_with_another_predicate() {
1193        // Ported from query.rs when Filters was retired. The regression it
1194        // guards is specific: an intersection that keeps the right hashes but
1195        // drops their distances leaves `--sort distance` with nothing to sort
1196        // by, and the failure is silent.
1197        let c = db();
1198        c.execute_batch(
1199            "UPDATE file_hashes SET gps_lat = 52.5200, gps_lon = 13.4050 WHERE hash = 'h_jpg';
1200             UPDATE file_hashes SET gps_lat = 48.8566, gps_lon = 2.3522   WHERE hash = 'h_mov';",
1201        )
1202        .unwrap();
1203
1204        let mut s = sel();
1205        s.kinds = vec![MediaKind::Image];
1206        s.place = Some(PlaceQuery::Coords(crate::query::GeoFilter {
1207            lat: 52.5200,
1208            lon: 13.4050,
1209            radius_km: 10.0,
1210        }));
1211        let r = s.resolve(&c, &SelectionCtx::default()).unwrap();
1212
1213        let h = r.hashes.unwrap();
1214        assert_eq!(h.len(), 1, "only the Berlin image");
1215        assert!(h.contains("h_jpg"));
1216        let d = r
1217            .distances
1218            .expect("a place was given, so distances must exist");
1219        assert!(d.contains_key("h_jpg"), "distances kept for survivors");
1220        assert!(!d.contains_key("h_mov"), "and dropped for the excluded");
1221    }
1222
1223    #[test]
1224    fn a_place_alone_still_yields_distances() {
1225        let c = db();
1226        c.execute_batch(
1227            "UPDATE file_hashes SET gps_lat = 52.5200, gps_lon = 13.4050 WHERE hash = 'h_jpg';",
1228        )
1229        .unwrap();
1230        let mut s = sel();
1231        s.place = Some(PlaceQuery::Coords(crate::query::GeoFilter {
1232            lat: 52.5200,
1233            lon: 13.4050,
1234            radius_km: 10.0,
1235        }));
1236        let r = s.resolve(&c, &SelectionCtx::default()).unwrap();
1237        assert_eq!(r.hashes.unwrap().len(), 1);
1238        assert!(r.distances.unwrap().contains_key("h_jpg"));
1239    }
1240
1241    #[test]
1242    fn describe_round_trips_the_flags_a_user_typed() {
1243        let mut s = sel();
1244        s.kinds = vec![MediaKind::Video];
1245        s.after = Some("2024-01-01".into());
1246        let d = s.describe();
1247        assert!(
1248            d.contains("--type video") && d.contains("--after 2024-01-01"),
1249            "{d}"
1250        );
1251    }
1252}
1253
1254#[cfg(test)]
1255mod resolve_in_tests {
1256    use super::*;
1257    use crate::library::LibraryContext;
1258
1259    fn insert_row(conn: &Connection, root: &Path, rel: &str, hash: &str) {
1260        conn.execute(
1261            "INSERT INTO file_hashes (path, hash, ext, mime) VALUES (?1, ?2, 'jpg', 'image/jpeg')",
1262            rusqlite::params![root.join(rel).to_str().unwrap(), hash],
1263        )
1264        .unwrap();
1265    }
1266
1267    /// One library context plus an in-memory index whose rows live under the
1268    /// library's canonical root. Fixture directories are deliberately not
1269    /// created on disk: a filter selects indexed rows, and those rows can
1270    /// outlive their files.
1271    fn library(rows: &[(&str, &str)]) -> (tempfile::TempDir, LibraryContext, Connection) {
1272        let temp = tempfile::tempdir().unwrap();
1273        let root = temp.path().join("photos");
1274        std::fs::create_dir(&root).unwrap();
1275        let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
1276        let conn = Connection::open_in_memory().unwrap();
1277        conn.execute_batch(
1278            "CREATE TABLE file_hashes (
1279                path TEXT PRIMARY KEY, hash TEXT NOT NULL, size_bytes INTEGER,
1280                created_at TEXT, modified_at TEXT, ext TEXT, mime TEXT, phash INTEGER,
1281                exif_date TEXT, gps_lat REAL, gps_lon REAL, width INTEGER, height INTEGER);",
1282        )
1283        .unwrap();
1284        for (rel, hash) in rows {
1285            insert_row(&conn, &ctx.paths.root, rel, hash);
1286        }
1287        (temp, ctx, conn)
1288    }
1289
1290    fn set<const N: usize>(hashes: [&str; N]) -> HashSet<String> {
1291        hashes.into_iter().map(str::to_string).collect()
1292    }
1293
1294    #[test]
1295    fn paths_or_within_the_axis_and_intersect_across_it() {
1296        let (_temp, ctx, conn) = library(&[
1297            ("Trips/a.jpg", "h_trips_a"),
1298            ("Trips/b.jpg", "h_trips_b"),
1299            ("2024/c.jpg", "h_2024"),
1300            ("Misc/d.jpg", "h_misc"),
1301        ]);
1302        // Two paths union, and neither selects the unmentioned directory.
1303        let mut s = RowSelection::default();
1304        s.paths = vec![PathBuf::from("Trips"), PathBuf::from("2024")];
1305        let h = s
1306            .resolve_in(&conn, &SelectionCtx::default(), &ctx)
1307            .unwrap()
1308            .hashes
1309            .unwrap();
1310        assert_eq!(h.len(), 3);
1311        assert!(!h.contains("h_misc"));
1312        // The same axis intersection rule as every other predicate.
1313        let mut s = RowSelection::default();
1314        s.paths = vec![PathBuf::from("Trips")];
1315        s.kinds = vec![MediaKind::Video];
1316        let h = s
1317            .resolve_in(&conn, &SelectionCtx::default(), &ctx)
1318            .unwrap()
1319            .hashes
1320            .unwrap();
1321        assert!(h.is_empty(), "jpg rows AND video matches nothing");
1322    }
1323
1324    #[test]
1325    fn no_filter_is_unconstrained_and_a_matching_nothing_filter_is_empty() {
1326        let (_temp, ctx, conn) = library(&[("Trips/a.jpg", "h_a")]);
1327        let r = RowSelection::default()
1328            .resolve_in(&conn, &SelectionCtx::default(), &ctx)
1329            .unwrap();
1330        assert!(r.hashes.is_none(), "no predicate given must not constrain");
1331        let mut s = RowSelection::default();
1332        s.paths = vec![PathBuf::from("Nothere")];
1333        let r = s.resolve_in(&conn, &SelectionCtx::default(), &ctx).unwrap();
1334        let h = r.hashes.expect("an active path filter must constrain");
1335        assert!(
1336            h.is_empty(),
1337            "a filter matching nothing is empty, not everything"
1338        );
1339    }
1340
1341    #[test]
1342    fn rows_under_a_missing_in_root_directory_stay_selectable() {
1343        // The rows exist in the index; their directory does not exist on
1344        // disk. The guard keeps them selectable rather than treating the
1345        // missing prefix as outside the library.
1346        let (_temp, ctx, conn) = library(&[("Gone/a.jpg", "h_gone_a"), ("Gone/b.jpg", "h_gone_b")]);
1347        assert!(!ctx.paths.root.join("Gone").exists());
1348        let mut s = RowSelection::default();
1349        s.paths = vec![PathBuf::from("Gone")];
1350        let h = s
1351            .resolve_in(&conn, &SelectionCtx::default(), &ctx)
1352            .unwrap()
1353            .hashes
1354            .unwrap();
1355        assert_eq!(h, set(["h_gone_a", "h_gone_b"]));
1356    }
1357
1358    #[test]
1359    fn one_outside_path_rejects_the_whole_invocation_in_either_order() {
1360        let (temp, ctx, conn) = library(&[("Trips/a.jpg", "h_a")]);
1361        let outside = temp.path().join("elsewhere");
1362        std::fs::create_dir(&outside).unwrap();
1363        for order in [
1364            vec![PathBuf::from("Trips"), outside.clone()],
1365            vec![outside.clone(), PathBuf::from("Trips")],
1366        ] {
1367            let mut s = RowSelection::default();
1368            s.paths = order;
1369            let err = s
1370                .resolve_in(&conn, &SelectionCtx::default(), &ctx)
1371                .unwrap_err();
1372            let msg = format!("{err:#}");
1373            assert!(msg.contains("outside library"), "{msg}");
1374            assert!(msg.contains("elsewhere"), "{msg}");
1375        }
1376    }
1377
1378    #[test]
1379    fn relative_filters_resolve_against_the_library_root() {
1380        // The rows were stored under the canonical root, and the test
1381        // process's cwd is the workspace, nowhere near the library, so a
1382        // match here proves a relative filter resolved through the root
1383        // rather than through ambient state.
1384        let (_temp, ctx, conn) = library(&[("Trips/a.jpg", "h_a")]);
1385        let mut s = RowSelection::default();
1386        s.paths = vec![PathBuf::from("Trips")];
1387        let h = s
1388            .resolve_in(&conn, &SelectionCtx::default(), &ctx)
1389            .unwrap()
1390            .hashes
1391            .unwrap();
1392        assert_eq!(h, set(["h_a"]));
1393    }
1394
1395    #[test]
1396    fn resolution_scales_with_supplied_filters_not_with_matched_rows() {
1397        // Two thousand matching rows, one supplied filter. Resolution is
1398        // counted where it happens, so the claim is deterministic rather
1399        // than a timing assertion: were the walk per matched row, the delta
1400        // would be 2000 (and each resolution its own thread spawn inside its
1401        // own budget).
1402        let (_temp, ctx, conn) = library(&[]);
1403        for i in 0..2000 {
1404            insert_row(
1405                &conn,
1406                &ctx.paths.root,
1407                &format!("Big/{i}.jpg"),
1408                &format!("h_{i}"),
1409            );
1410        }
1411        let before = crate::library_guard::count_resolutions();
1412        let mut s = RowSelection::default();
1413        s.paths = vec![PathBuf::from("Big")];
1414        let h = s
1415            .resolve_in(&conn, &SelectionCtx::default(), &ctx)
1416            .unwrap()
1417            .hashes
1418            .unwrap();
1419        assert_eq!(h.len(), 2000);
1420        assert_eq!(
1421            crate::library_guard::count_resolutions() - before,
1422            1,
1423            "one supplied filter means one filesystem resolution, whatever the row count"
1424        );
1425    }
1426
1427    #[test]
1428    fn guarded_paths_do_not_enter_the_ambient_canonicalizer() {
1429        let (_temp, ctx, conn) = library(&[("Trips/a.jpg", "h_a")]);
1430        let before = count_ambient_canonicalizations();
1431        let mut selection = RowSelection::default();
1432        selection.paths = vec![PathBuf::from("Trips")];
1433
1434        let hashes = selection
1435            .resolve_in(&conn, &SelectionCtx::default(), &ctx)
1436            .unwrap()
1437            .hashes
1438            .unwrap();
1439
1440        assert_eq!(hashes, set(["h_a"]));
1441        assert_eq!(count_ambient_canonicalizations() - before, 0);
1442    }
1443}