1use anyhow::{bail, Result};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum MediaKind {
26 Image,
27 Video,
28}
29
30impl MediaKind {
31 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
51pub fn normalise_ext(s: &str) -> String {
56 s.trim().trim_start_matches('.').to_lowercase()
57}
58
59pub 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
77pub 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#[derive(Debug, Clone)]
108pub enum PlaceQuery {
109 Named { place: String, radius_km: f64 },
110 Coords(GeoFilter),
111}
112
113#[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 pub min_rating: Option<i64>,
127 pub pick: Option<crate::marks::Pick>,
129 pub label: Option<String>,
131 pub liked: bool,
133 pub tags: Vec<String>,
135}
136
137#[derive(Debug, Clone, Default)]
139pub struct SelectionCtx {
140 pub model_id: Option<String>,
143}
144
145#[derive(Debug, Clone, Default)]
147pub struct Resolved {
148 pub hashes: Option<HashSet<String>>,
152 pub distances: Option<HashMap<String, f64>>,
155}
156
157impl RowSelection {
158 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 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 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 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
348fn 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
404fn 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
431fn 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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}