Skip to main content

mimick/
sidecar.rs

1//! XMP sidecar file discovery.
2//!
3//! Locates `.xmp` companion files alongside media assets using the same
4//! naming conventions that Immich recognises:
5//!   1. `photo.jpg.xmp`  (preferred -- includes the media extension)
6//!   2. `photo.xmp`      (fallback  -- stem only)
7//!
8//! Both patterns are probed case-insensitively so `photo.CR2.XMP` is found.
9
10use std::path::{Path, PathBuf};
11
12/// Find the XMP sidecar companion for a media file, if one exists on disk.
13///
14/// Returns the preferred `<name>.<ext>.xmp` path when present, otherwise
15/// falls back to `<stem>.xmp`. Returns `None` when neither exists.
16pub fn find_sidecar(media_path: &Path) -> Option<PathBuf> {
17    let parent = media_path.parent()?;
18    let filename = media_path.file_name()?.to_str()?;
19
20    // Preferred: photo.jpg.xmp
21    let preferred = parent.join(format!("{}.xmp", filename));
22    if case_insensitive_exists(&preferred) {
23        return Some(resolve_case_insensitive(&preferred).unwrap_or(preferred));
24    }
25
26    // Fallback: photo.xmp
27    let stem = media_path.file_stem()?.to_str()?;
28    let fallback = parent.join(format!("{}.xmp", stem));
29    if case_insensitive_exists(&fallback) {
30        return Some(resolve_case_insensitive(&fallback).unwrap_or(fallback));
31    }
32
33    None
34}
35/// Check whether a file exists using case-insensitive extension matching.
36///
37/// Tries the exact path first (fast), then scans the parent directory for
38/// a case-variant match (e.g. `.XMP` vs `.xmp`).
39fn case_insensitive_exists(path: &Path) -> bool {
40    if path.exists() {
41        return true;
42    }
43    resolve_case_insensitive(path).is_some()
44}
45
46/// Scan the parent directory for a file whose name matches case-insensitively.
47fn resolve_case_insensitive(path: &Path) -> Option<PathBuf> {
48    let parent = path.parent()?;
49    let target = path.file_name()?.to_str()?.to_ascii_lowercase();
50    let entries = std::fs::read_dir(parent).ok()?;
51    for entry in entries.flatten() {
52        if let Some(name) = entry.file_name().to_str()
53            && name.to_ascii_lowercase() == target
54        {
55            return Some(entry.path());
56        }
57    }
58    None
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use std::fs;
65    use tempfile::TempDir;
66
67    fn setup() -> TempDir {
68        tempfile::tempdir().expect("tempdir")
69    }
70
71    #[test]
72    fn preferred_pattern_found() {
73        let dir = setup();
74        let media = dir.path().join("photo.jpg");
75        let sidecar = dir.path().join("photo.jpg.xmp");
76        fs::write(&media, b"img").unwrap();
77        fs::write(&sidecar, b"<xmp/>").unwrap();
78
79        assert_eq!(find_sidecar(&media), Some(sidecar));
80    }
81
82    #[test]
83    fn fallback_pattern_found() {
84        let dir = setup();
85        let media = dir.path().join("photo.cr2");
86        let sidecar = dir.path().join("photo.xmp");
87        fs::write(&media, b"raw").unwrap();
88        fs::write(&sidecar, b"<xmp/>").unwrap();
89
90        assert_eq!(find_sidecar(&media), Some(sidecar));
91    }
92
93    #[test]
94    fn preferred_takes_priority_over_fallback() {
95        let dir = setup();
96        let media = dir.path().join("photo.dng");
97        let preferred = dir.path().join("photo.dng.xmp");
98        let _fallback = dir.path().join("photo.xmp");
99        fs::write(&media, b"raw").unwrap();
100        fs::write(&preferred, b"<preferred/>").unwrap();
101        fs::write(&_fallback, b"<fallback/>").unwrap();
102
103        assert_eq!(find_sidecar(&media), Some(preferred));
104    }
105
106    #[test]
107    fn no_sidecar_returns_none() {
108        let dir = setup();
109        let media = dir.path().join("photo.jpg");
110        fs::write(&media, b"img").unwrap();
111
112        assert_eq!(find_sidecar(&media), None);
113    }
114
115    #[test]
116    fn case_insensitive_extension() {
117        let dir = setup();
118        let media = dir.path().join("photo.nef");
119        let sidecar = dir.path().join("photo.nef.XMP");
120        fs::write(&media, b"raw").unwrap();
121        fs::write(&sidecar, b"<xmp/>").unwrap();
122
123        let result = find_sidecar(&media);
124        assert!(result.is_some(), "should find .XMP variant");
125        assert_eq!(
126            result.unwrap().file_name().unwrap().to_str().unwrap(),
127            "photo.nef.XMP"
128        );
129    }
130}