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
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
74pub fn normalise_ext(s: &str) -> String {
79 s.trim().trim_start_matches('.').to_lowercase()
80}
81
82pub 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
100pub 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#[derive(Debug, Clone)]
131pub enum PlaceQuery {
132 Named { place: String, radius_km: f64 },
133 Coords(GeoFilter),
134}
135
136#[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 pub min_rating: Option<i64>,
152 pub pick: Option<crate::marks::Pick>,
154 pub label: Option<String>,
156 pub liked: bool,
158 pub tags: Vec<String>,
160}
161
162#[derive(Debug, Clone, Default)]
164pub struct SelectionCtx {
165 pub model_id: Option<String>,
168}
169
170#[derive(Debug, Clone, Default)]
172pub struct Resolved {
173 pub hashes: Option<HashSet<String>>,
177 pub distances: Option<HashMap<String, f64>>,
180}
181
182impl RowSelection {
183 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 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 pub fn resolve(&self, conn: &Connection, ctx: &SelectionCtx) -> anyhow::Result<Resolved> {
285 self.resolve_selection(conn, ctx, false)
286 }
287
288 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 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 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
456fn 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
522fn 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
551fn 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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}