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}
126
127#[derive(Debug, Clone, Default)]
129pub struct SelectionCtx {
130 pub model_id: Option<String>,
133}
134
135#[derive(Debug, Clone, Default)]
137pub struct Resolved {
138 pub hashes: Option<HashSet<String>>,
142 pub distances: Option<HashMap<String, f64>>,
145}
146
147impl RowSelection {
148 pub fn is_empty(&self) -> bool {
154 self.person.is_none()
155 && self.category.is_none()
156 && self.place.is_none()
157 && self.after.is_none()
158 && self.before.is_none()
159 && self.kinds.is_empty()
160 && self.exts.is_empty()
161 && self.mimes.is_empty()
162 && self.paths.is_empty()
163 }
164
165 pub fn describe(&self) -> String {
167 let mut parts: Vec<String> = Vec::new();
168 if let Some(p) = &self.person {
169 parts.push(format!("--person {p:?}"));
170 }
171 if let Some(c) = &self.category {
172 parts.push(format!("--category {c}"));
173 }
174 match &self.place {
175 Some(PlaceQuery::Named { place, radius_km }) => {
176 parts.push(format!("--location {place:?} --radius {radius_km}"))
177 }
178 Some(PlaceQuery::Coords(g)) => parts.push(format!(
179 "--location {},{} --radius {}",
180 g.lat, g.lon, g.radius_km
181 )),
182 None => {}
183 }
184 if let Some(a) = &self.after {
185 parts.push(format!("--after {a}"));
186 }
187 if let Some(b) = &self.before {
188 parts.push(format!("--before {b}"));
189 }
190 for k in &self.kinds {
191 parts.push(format!("--type {}", k.as_str()));
192 }
193 if !self.exts.is_empty() {
194 parts.push(format!("--ext {}", self.exts.join(",")));
195 }
196 if !self.mimes.is_empty() {
197 parts.push(format!("--mime {}", self.mimes.join(",")));
198 }
199 for p in &self.paths {
200 parts.push(format!("--path {}", p.display()));
201 }
202 parts.join(" ")
203 }
204
205 pub fn resolve(&self, conn: &Connection, ctx: &SelectionCtx) -> anyhow::Result<Resolved> {
211 if self.is_empty() {
212 return Ok(Resolved::default());
213 }
214
215 let mut acc: Option<HashSet<String>> = None;
216 let narrow = |s: HashSet<String>, acc: &mut Option<HashSet<String>>| match acc {
217 Some(existing) => *acc = Some(existing.intersection(&s).cloned().collect()),
218 None => *acc = Some(s),
219 };
220
221 if let Some(p) = &self.person {
222 narrow(query::by_person(conn, p)?, &mut acc);
223 }
224 if let Some(c) = &self.category {
225 let model = ctx.model_id.as_deref().ok_or_else(|| {
226 anyhow::anyhow!(
227 "--category needs an embedding model, and this command has none; \
228 classifications are stored per model"
229 )
230 })?;
231 narrow(query::by_category(conn, model, c)?, &mut acc);
232 }
233 if self.after.is_some() || self.before.is_some() {
234 narrow(
235 query::by_date(conn, self.after.as_deref(), self.before.as_deref())?,
236 &mut acc,
237 );
238 }
239 if !self.kinds.is_empty() {
240 narrow(by_kinds(conn, &self.kinds)?, &mut acc);
241 }
242 if !self.exts.is_empty() {
243 narrow(by_exts(conn, &self.exts)?, &mut acc);
244 }
245 if !self.mimes.is_empty() {
246 narrow(by_mimes(conn, &self.mimes)?, &mut acc);
247 }
248 if !self.paths.is_empty() {
249 narrow(by_paths(conn, &self.paths)?, &mut acc);
250 }
251
252 let mut distances = None;
255 if let Some(place) = &self.place {
256 if acc.as_ref().is_some_and(|h| h.is_empty()) {
257 distances = Some(HashMap::new());
258 } else {
259 let geo = match place {
260 PlaceQuery::Coords(g) => *g,
261 PlaceQuery::Named { place, radius_km } => {
262 crate::geocode::ensure_geocode_cache_table(conn)?;
263 let (lat, lon) = crate::geocode::forward_geocode_cached(conn, place)?;
264 GeoFilter {
265 lat,
266 lon,
267 radius_km: *radius_km,
268 }
269 }
270 };
271 let within = query::by_location(conn, geo.lat, geo.lon, geo.radius_km)?;
272 let keep: HashSet<String> = match &acc {
273 Some(existing) => within
274 .keys()
275 .filter(|h| existing.contains(*h))
276 .cloned()
277 .collect(),
278 None => within.keys().cloned().collect(),
279 };
280 distances = Some(
281 within
282 .into_iter()
283 .filter(|(h, _)| keep.contains(h))
284 .collect(),
285 );
286 acc = Some(keep);
287 }
288 }
289
290 Ok(Resolved {
291 hashes: acc,
292 distances,
293 })
294 }
295}
296
297fn by_kinds(conn: &Connection, kinds: &[MediaKind]) -> anyhow::Result<HashSet<String>> {
303 let mut stmt = conn.prepare("SELECT hash, mime, ext FROM file_hashes")?;
304 let rows = stmt.query_map([], |r| {
305 Ok((
306 r.get::<_, String>(0)?,
307 r.get::<_, Option<String>>(1)?,
308 r.get::<_, Option<String>>(2)?,
309 ))
310 })?;
311 let mut out = HashSet::new();
312 for row in rows {
313 let (hash, mime, ext) = row?;
314 let ext = ext.unwrap_or_default();
315 if kinds
316 .iter()
317 .any(|k| row_matches_kind(*k, mime.as_deref(), &ext))
318 {
319 out.insert(hash);
320 }
321 }
322 Ok(out)
323}
324
325fn by_exts(conn: &Connection, exts: &[String]) -> anyhow::Result<HashSet<String>> {
326 let wanted: HashSet<String> = exts.iter().map(|e| normalise_ext(e)).collect();
327 let mut stmt = conn.prepare("SELECT hash, ext FROM file_hashes WHERE ext IS NOT NULL")?;
328 let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
329 let mut out = HashSet::new();
330 for row in rows {
331 let (hash, ext) = row?;
332 if wanted.contains(&normalise_ext(&ext)) {
333 out.insert(hash);
334 }
335 }
336 Ok(out)
337}
338
339fn by_mimes(conn: &Connection, mimes: &[String]) -> anyhow::Result<HashSet<String>> {
340 let wanted: HashSet<String> = mimes.iter().map(|m| m.trim().to_lowercase()).collect();
341 let mut stmt = conn.prepare("SELECT hash, mime FROM file_hashes WHERE mime IS NOT NULL")?;
342 let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
343 let mut out = HashSet::new();
344 for row in rows {
345 let (hash, mime) = row?;
346 if wanted.contains(&mime.to_lowercase()) {
347 out.insert(hash);
348 }
349 }
350 Ok(out)
351}
352
353fn roots_in_both_forms(roots: &[PathBuf]) -> Vec<PathBuf> {
368 let mut out = Vec::with_capacity(roots.len() * 2);
369 for r in roots {
370 out.push(r.clone());
371 if let Ok(c) = std::fs::canonicalize(r) {
372 if c != *r {
373 out.push(c);
374 }
375 }
376 }
377 out
378}
379
380fn by_paths(conn: &Connection, roots: &[PathBuf]) -> anyhow::Result<HashSet<String>> {
385 let roots = roots_in_both_forms(roots);
386 let mut stmt = conn.prepare("SELECT hash, path FROM file_hashes")?;
387 let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
388 let mut out = HashSet::new();
389 for row in rows {
390 let (hash, path) = row?;
391 let p = Path::new(&path);
392 if roots.iter().any(|r| under(p, r)) {
393 out.insert(hash);
394 }
395 }
396 Ok(out)
397}
398
399fn under(path: &Path, root: &Path) -> bool {
400 let mut a = path.components();
401 for c in root.components() {
402 match a.next() {
403 Some(x) if x == c => {}
404 _ => return false,
405 }
406 }
407 true
408}
409
410#[derive(Debug, Clone, Default)]
420pub struct PathSelection {
421 pub kinds: Vec<MediaKind>,
422 pub exts: Vec<String>,
423 pub paths: Vec<PathBuf>,
424}
425
426impl PathSelection {
427 pub fn is_empty(&self) -> bool {
428 self.kinds.is_empty() && self.exts.is_empty() && self.paths.is_empty()
429 }
430
431 pub fn accepts(&self, path: &Path) -> bool {
437 if self.is_empty() {
438 return true;
439 }
440 if !self.kinds.is_empty() && !self.kinds.iter().any(|k| path_matches_kind(*k, path)) {
441 return false;
442 }
443 if !self.exts.is_empty() {
444 let ext = path
445 .extension()
446 .and_then(|e| e.to_str())
447 .map(normalise_ext)
448 .unwrap_or_default();
449 if !self.exts.iter().any(|e| normalise_ext(e) == ext) {
450 return false;
451 }
452 }
453 if !self.paths.is_empty() && !self.paths.iter().any(|r| under(path, r)) {
454 return false;
455 }
456 true
457 }
458
459 pub fn canonicalised(mut self) -> Self {
469 self.paths = roots_in_both_forms(&self.paths);
470 self
471 }
472
473 pub fn describe(&self) -> String {
474 let mut parts: Vec<String> = Vec::new();
475 for k in &self.kinds {
476 parts.push(format!("--type {}", k.as_str()));
477 }
478 if !self.exts.is_empty() {
479 parts.push(format!("--ext {}", self.exts.join(",")));
480 }
481 for p in &self.paths {
482 parts.push(format!("--path {}", p.display()));
483 }
484 parts.join(" ")
485 }
486}
487
488#[cfg(test)]
489mod path_selection_tests {
490 use super::*;
491
492 #[test]
493 fn an_empty_selection_accepts_everything() {
494 let s = PathSelection::default();
496 assert!(s.accepts(Path::new("/a/b.jpg")));
497 assert!(s.accepts(Path::new("/a/b.mov")));
498 }
499
500 #[test]
501 fn kinds_and_exts_and_paths_all_narrow() {
502 let s = PathSelection {
503 kinds: vec![MediaKind::Video],
504 ..Default::default()
505 };
506 assert!(s.accepts(Path::new("/a/b.mov")));
507 assert!(!s.accepts(Path::new("/a/b.jpg")));
508
509 let s = PathSelection {
510 exts: vec![".MOV".into()],
511 ..Default::default()
512 };
513 assert!(s.accepts(Path::new("/a/b.mov")), "case and dot normalise");
514
515 let s = PathSelection {
516 paths: vec![PathBuf::from("/lib")],
517 ..Default::default()
518 };
519 assert!(s.accepts(Path::new("/lib/a.jpg")));
520 assert!(
521 !s.accepts(Path::new("/library/a.jpg")),
522 "components, not prefix"
523 );
524 }
525
526 #[test]
527 fn axes_intersect() {
528 let s = PathSelection {
529 kinds: vec![MediaKind::Video],
530 paths: vec![PathBuf::from("/lib")],
531 ..Default::default()
532 };
533 assert!(s.accepts(Path::new("/lib/a.mov")));
534 assert!(!s.accepts(Path::new("/lib/a.jpg")), "wrong kind");
535 assert!(!s.accepts(Path::new("/other/a.mov")), "wrong place");
536 }
537
538 #[test]
539 fn type_here_is_by_extension_and_that_differs_from_rows() {
540 let s = PathSelection {
545 kinds: vec![MediaKind::Video],
546 ..Default::default()
547 };
548 assert!(s.accepts(Path::new("/a/mislabelled.mov")));
549 assert!(!row_matches_kind(
550 MediaKind::Video,
551 Some("image/jpeg"),
552 "mov"
553 ));
554 }
555}
556
557#[cfg(test)]
558mod tests {
559 #[test]
560 fn a_symlinked_root_matches_paths_in_either_form() {
561 let dir = tempfile::tempdir().unwrap();
566 let given = dir.path().join("sub");
567 std::fs::create_dir(&given).unwrap();
568 let canonical = std::fs::canonicalize(&given).unwrap();
569
570 let sel = PathSelection {
571 paths: vec![given.clone()],
572 ..Default::default()
573 }
574 .canonicalised();
575
576 assert!(sel.accepts(&given.join("a.jpg")), "the form the user typed");
577 assert!(sel.accepts(&canonical.join("a.jpg")), "the canonical form");
578 assert!(!sel.accepts(&dir.path().join("outside.jpg")));
579 }
580
581 use super::*;
582 use std::path::Path;
583
584 #[test]
585 fn parse_names_every_valid_value_on_a_typo() {
586 assert_eq!(MediaKind::parse("image").unwrap(), MediaKind::Image);
587 assert_eq!(MediaKind::parse("VIDEO").unwrap(), MediaKind::Video);
588 assert_eq!(MediaKind::parse(" video ").unwrap(), MediaKind::Video);
589 let e = MediaKind::parse("vidoe").unwrap_err().to_string();
590 assert!(e.contains("image") && e.contains("video"), "got: {e}");
591 }
592
593 #[test]
594 fn extensions_normalise_to_one_spelling() {
595 for s in [".MOV", "MOV", "mov", " .mov "] {
596 assert_eq!(normalise_ext(s), "mov", "input {s:?}");
597 }
598 }
599
600 #[test]
601 fn an_unidentified_file_is_still_its_extension() {
602 assert!(row_matches_kind(
605 MediaKind::Image,
606 Some(crate::mime_probe::UNKNOWN_MIME),
607 "jpg"
608 ));
609 assert!(row_matches_kind(MediaKind::Video, None, "mov"));
610 }
611
612 #[test]
613 fn mime_beats_a_wrong_extension_for_rows() {
614 assert!(row_matches_kind(
615 MediaKind::Image,
616 Some("image/jpeg"),
617 "txt"
618 ));
619 assert!(!row_matches_kind(
620 MediaKind::Video,
621 Some("image/jpeg"),
622 "mov"
623 ));
624 }
625
626 #[test]
627 fn paths_are_judged_by_extension_only() {
628 assert!(path_matches_kind(MediaKind::Video, Path::new("/a/b.mov")));
631 assert!(path_matches_kind(MediaKind::Image, Path::new("/a/b.HEIC")));
632 assert!(!path_matches_kind(MediaKind::Image, Path::new("/a/b.mov")));
633 assert!(!path_matches_kind(MediaKind::Image, Path::new("/a/noext")));
634 }
635}
636
637#[cfg(test)]
638mod resolve_tests {
639 use super::*;
640
641 fn db() -> Connection {
642 let c = Connection::open_in_memory().unwrap();
643 c.execute_batch(
644 "CREATE TABLE file_hashes (
645 path TEXT PRIMARY KEY, hash TEXT NOT NULL, size_bytes INTEGER,
646 created_at TEXT, modified_at TEXT, ext TEXT, mime TEXT, phash INTEGER,
647 exif_date TEXT, gps_lat REAL, gps_lon REAL, width INTEGER, height INTEGER);
648 INSERT INTO file_hashes (path, hash, ext, mime, exif_date, modified_at) VALUES
649 ('/lib/a.jpg','h_jpg','jpg','image/jpeg','2024-05-01T10:00:00','2024-05-01T10:00:00'),
650 ('/lib/b.mov','h_mov','mov','video/quicktime','2024-06-01T10:00:00','2024-06-01T10:00:00'),
651 ('/lib/c.heic','h_heic','heic','image/heic','2023-01-01T10:00:00','2023-01-01T10:00:00'),
652 ('/other/d.mp4','h_mp4','mp4','video/mp4','2024-07-01T10:00:00','2024-07-01T10:00:00');",
653 )
654 .unwrap();
655 c
656 }
657
658 fn sel() -> RowSelection {
659 RowSelection::default()
660 }
661
662 #[test]
663 fn no_selection_means_unconstrained_not_empty() {
664 let r = sel().resolve(&db(), &SelectionCtx::default()).unwrap();
668 assert!(r.hashes.is_none(), "no predicate given must not constrain");
669 assert!(sel().is_empty());
670 }
671
672 #[test]
673 fn or_within_an_axis() {
674 let mut s = sel();
675 s.exts = vec!["mov".into(), "mp4".into()];
676 let r = s.resolve(&db(), &SelectionCtx::default()).unwrap();
677 let h = r.hashes.unwrap();
678 assert_eq!(h.len(), 2);
679 assert!(h.contains("h_mov") && h.contains("h_mp4"));
680 }
681
682 #[test]
683 fn and_across_axes_can_be_empty_without_being_unconstrained() {
684 let mut s = sel();
685 s.kinds = vec![MediaKind::Video];
686 s.exts = vec!["jpg".into()];
687 let r = s.resolve(&db(), &SelectionCtx::default()).unwrap();
688 let h = r.hashes.expect("an active selection must constrain");
689 assert!(h.is_empty(), "video AND jpg matches nothing");
690 }
691
692 #[test]
693 fn kind_uses_mime_and_covers_every_image_type() {
694 let mut s = sel();
695 s.kinds = vec![MediaKind::Image];
696 let h = s
697 .resolve(&db(), &SelectionCtx::default())
698 .unwrap()
699 .hashes
700 .unwrap();
701 assert_eq!(h.len(), 2, "jpg and heic");
702 assert!(h.contains("h_jpg") && h.contains("h_heic"));
703 }
704
705 #[test]
706 fn dates_and_types_intersect() {
707 let mut s = sel();
708 s.kinds = vec![MediaKind::Video];
709 s.after = Some("2024-06-15T00:00:00".into());
710 let h = s
711 .resolve(&db(), &SelectionCtx::default())
712 .unwrap()
713 .hashes
714 .unwrap();
715 assert_eq!(h.len(), 1, "only the July mp4");
716 assert!(h.contains("h_mp4"));
717 }
718
719 #[cfg(unix)]
720 #[test]
721 fn a_symlinked_root_matches_rows_stored_under_its_target() {
722 let t = tempfile::tempdir().unwrap();
729 let real = t.path().join("real");
730 std::fs::create_dir(&real).unwrap();
731 let link = t.path().join("link");
732 std::os::unix::fs::symlink(&real, &link).unwrap();
733
734 let stored_via_link = link.join("a.jpg");
740 let stored_via_real = std::fs::canonicalize(&real).unwrap().join("b.jpg");
741 let c = Connection::open_in_memory().unwrap();
742 c.execute_batch(
743 "CREATE TABLE file_hashes (
744 path TEXT PRIMARY KEY, hash TEXT NOT NULL, size_bytes INTEGER,
745 created_at TEXT, modified_at TEXT, ext TEXT, mime TEXT, phash INTEGER,
746 exif_date TEXT, gps_lat REAL, gps_lon REAL, width INTEGER, height INTEGER);",
747 )
748 .unwrap();
749 c.execute(
750 "INSERT INTO file_hashes (path, hash, ext) VALUES (?1, 'h_link', 'jpg')",
751 [stored_via_link.to_str().unwrap()],
752 )
753 .unwrap();
754 c.execute(
755 "INSERT INTO file_hashes (path, hash, ext) VALUES (?1, 'h_real', 'jpg')",
756 [stored_via_real.to_str().unwrap()],
757 )
758 .unwrap();
759
760 let mut s = sel();
762 s.paths = vec![link.clone()];
763 let h = s
764 .resolve(&c, &SelectionCtx::default())
765 .unwrap()
766 .hashes
767 .unwrap();
768 assert_eq!(
769 h.len(),
770 2,
771 "a symlinked root must match rows stored under either name"
772 );
773
774 let mut s = sel();
776 s.paths = vec![t.path().join("elsewhere")];
777 let h = s
778 .resolve(&c, &SelectionCtx::default())
779 .unwrap()
780 .hashes
781 .unwrap();
782 assert!(h.is_empty());
783 }
784
785 #[test]
786 fn path_matches_components_not_string_prefixes() {
787 let mut s = sel();
789 s.paths = vec![PathBuf::from("/lib")];
790 let h = s
791 .resolve(&db(), &SelectionCtx::default())
792 .unwrap()
793 .hashes
794 .unwrap();
795 assert_eq!(h.len(), 3, "the three under /lib, not /other");
796 assert!(!h.contains("h_mp4"));
797
798 assert!(under(Path::new("/lib/a.jpg"), Path::new("/lib")));
799 assert!(!under(Path::new("/library/a.jpg"), Path::new("/lib")));
800 }
801
802 #[test]
803 fn category_without_a_model_is_an_error_naming_the_reason() {
804 let mut s = sel();
806 s.category = Some("document".into());
807 let e = s
808 .resolve(&db(), &SelectionCtx::default())
809 .unwrap_err()
810 .to_string();
811 assert!(e.contains("model"), "got: {e}");
812 }
813
814 #[test]
815 fn distances_survive_intersection_with_another_predicate() {
816 let c = db();
821 c.execute_batch(
822 "UPDATE file_hashes SET gps_lat = 52.5200, gps_lon = 13.4050 WHERE hash = 'h_jpg';
823 UPDATE file_hashes SET gps_lat = 48.8566, gps_lon = 2.3522 WHERE hash = 'h_mov';",
824 )
825 .unwrap();
826
827 let mut s = sel();
828 s.kinds = vec![MediaKind::Image];
829 s.place = Some(PlaceQuery::Coords(crate::query::GeoFilter {
830 lat: 52.5200,
831 lon: 13.4050,
832 radius_km: 10.0,
833 }));
834 let r = s.resolve(&c, &SelectionCtx::default()).unwrap();
835
836 let h = r.hashes.unwrap();
837 assert_eq!(h.len(), 1, "only the Berlin image");
838 assert!(h.contains("h_jpg"));
839 let d = r
840 .distances
841 .expect("a place was given, so distances must exist");
842 assert!(d.contains_key("h_jpg"), "distances kept for survivors");
843 assert!(!d.contains_key("h_mov"), "and dropped for the excluded");
844 }
845
846 #[test]
847 fn a_place_alone_still_yields_distances() {
848 let c = db();
849 c.execute_batch(
850 "UPDATE file_hashes SET gps_lat = 52.5200, gps_lon = 13.4050 WHERE hash = 'h_jpg';",
851 )
852 .unwrap();
853 let mut s = sel();
854 s.place = Some(PlaceQuery::Coords(crate::query::GeoFilter {
855 lat: 52.5200,
856 lon: 13.4050,
857 radius_km: 10.0,
858 }));
859 let r = s.resolve(&c, &SelectionCtx::default()).unwrap();
860 assert_eq!(r.hashes.unwrap().len(), 1);
861 assert!(r.distances.unwrap().contains_key("h_jpg"));
862 }
863
864 #[test]
865 fn describe_round_trips_the_flags_a_user_typed() {
866 let mut s = sel();
867 s.kinds = vec![MediaKind::Video];
868 s.after = Some("2024-01-01".into());
869 let d = s.describe();
870 assert!(
871 d.contains("--type video") && d.contains("--after 2024-01-01"),
872 "{d}"
873 );
874 }
875}