1use crate::import_location::{LayoutProbe, Rung};
7use std::path::{Path, PathBuf};
8
9#[derive(Debug)]
10pub struct ProviderDescriptor {
11 pub id: &'static str,
12 pub display: &'static str,
13 pub default_rung: Rung,
17 pub layouts: &'static [LayoutProbe],
18 pub package_globs: &'static [&'static str],
20 pub detect: fn(&Path) -> bool,
22}
23
24fn has_hex_fanout(dir: &Path) -> bool {
34 let Ok(entries) = std::fs::read_dir(dir) else {
35 return false;
36 };
37 let (mut hex, mut other) = (0usize, 0usize);
38 for e in entries.filter_map(|e| e.ok()) {
39 if !e.path().is_dir() {
40 continue;
41 }
42 let name = e.file_name();
43 let name = name.to_string_lossy();
44 if name.len() == 1 && name.chars().all(|c| c.is_ascii_hexdigit()) {
46 hex += 1;
47 } else {
48 other += 1;
49 }
50 }
51 hex >= 8 && hex > other
52}
53
54fn is_apple_photos(p: &Path) -> bool {
55 let has_db = p.join("database/Photos.sqlite").exists();
58 let has_originals = ["originals", "Masters", "Originals"]
59 .iter()
60 .any(|d| p.join(d).is_dir());
61 (has_db && has_originals)
62 || p.extension().is_some_and(|e| e == "photoslibrary")
63 || (p.join("Masters").is_dir() && p.join("Database").is_dir())
64 || ["originals", "Masters", "Originals"]
67 .iter()
68 .any(|d| has_hex_fanout(&p.join(d)))
69 || has_hex_fanout(p)
70}
71
72fn is_lightroom(p: &Path) -> bool {
73 p.extension().is_some_and(|e| e == "lrcat")
74 || std::fs::read_dir(p).is_ok_and(|mut d| {
75 d.any(|e| {
76 e.ok()
77 .is_some_and(|e| e.path().extension().is_some_and(|x| x == "lrcat"))
78 })
79 })
80}
81
82fn has_sidecar(dir: &Path) -> bool {
84 std::fs::read_dir(dir).is_ok_and(|d| {
85 d.filter_map(|e| e.ok()).any(|e| {
86 e.file_name()
87 .to_string_lossy()
88 .contains(".supplemental-metadat")
89 })
90 })
91}
92
93fn is_takeout(p: &Path) -> bool {
94 if p.join("Google Photos").is_dir() || p.join("Takeout/Google Photos").is_dir() {
95 return true;
96 }
97 if has_sidecar(p) {
98 return true;
99 }
100 std::fs::read_dir(p).is_ok_and(|d| {
105 d.filter_map(|e| e.ok())
106 .filter(|e| e.path().is_dir())
107 .take(64) .any(|e| has_sidecar(&e.path()))
109 })
110}
111
112pub static PROVIDERS: &[ProviderDescriptor] = &[
113 ProviderDescriptor {
114 id: "apple-photos",
115 display: "Apple Photos / iPhoto",
116 default_rung: Rung::FolderLayout,
117 layouts: &[
118 LayoutProbe {
121 dir_names: &["originals"],
122 requires_sibling: Some("database/Photos.sqlite"),
123 },
124 LayoutProbe {
125 dir_names: &["Masters", "Originals"],
126 requires_sibling: None,
127 },
128 LayoutProbe {
129 dir_names: &["originals"],
130 requires_sibling: None,
131 },
132 ],
133 package_globs: &[
134 "*.photoslibrary",
135 "*.photolibrary",
136 "*.migratedphotolibrary",
137 ],
138 detect: is_apple_photos,
139 },
140 ProviderDescriptor {
141 id: "lightroom",
142 display: "Adobe Lightroom Classic",
143 default_rung: Rung::Database,
144 layouts: &[],
145 package_globs: &["*.lrcat", "Lightroom/*.lrcat"],
146 detect: is_lightroom,
147 },
148 ProviderDescriptor {
149 id: "google-takeout",
150 display: "Google Takeout",
151 default_rung: Rung::FolderLayout,
152 layouts: &[LayoutProbe {
153 dir_names: &["Google Photos"],
154 requires_sibling: None,
155 }],
156 package_globs: &["Takeout", "Takeout*"],
157 detect: is_takeout,
158 },
159];
160
161pub fn detect(path: &Path) -> Option<&'static ProviderDescriptor> {
163 PROVIDERS.iter().find(|p| (p.detect)(path))
164}
165
166#[derive(Debug)]
167pub struct Candidate {
168 pub path: PathBuf,
169 pub provider: &'static ProviderDescriptor,
170}
171
172pub fn default_search_roots() -> Vec<PathBuf> {
179 let home = std::env::var_os("HOME").map(PathBuf::from);
180 let mut roots = Vec::new();
181 if let Some(h) = home {
182 roots.push(h.join("Pictures"));
183 roots.push(h.join("Pictures/Lightroom"));
184 roots.push(h.join("Documents"));
185 roots.push(h.join("Desktop"));
186 roots.push(h.join("Downloads"));
187 }
188 if cfg!(target_os = "macos") {
189 if let Ok(vols) = std::fs::read_dir("/Volumes") {
190 for v in vols.filter_map(|e| e.ok()) {
191 roots.push(v.path());
192 roots.push(v.path().join("Pictures"));
193 }
194 }
195 }
196 roots
197}
198
199pub fn discover_in(roots: &[PathBuf]) -> Vec<Candidate> {
201 let mut out = Vec::new();
202 for root in roots {
203 let Ok(entries) = std::fs::read_dir(root) else {
204 continue; };
206 for entry in entries.filter_map(|e| e.ok()) {
207 if let Some(provider) = detect(&entry.path()) {
208 out.push(Candidate {
209 path: entry.path(),
210 provider,
211 });
212 }
213 }
214 }
215 out
216}
217
218pub fn discover() -> Vec<Candidate> {
219 discover_in(&default_search_roots())
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225 use std::fs;
226 use tempfile::tempdir;
227
228 #[test]
229 fn every_provider_has_a_unique_id_and_a_display_name() {
230 let mut ids: Vec<&str> = PROVIDERS.iter().map(|p| p.id).collect();
231 let before = ids.len();
232 ids.sort_unstable();
233 ids.dedup();
234 assert_eq!(ids.len(), before, "provider ids must be unique");
235 assert!(PROVIDERS.iter().all(|p| !p.display.is_empty()));
236 }
237
238 #[test]
239 fn only_lightroom_starts_on_the_database_rung() {
240 for p in PROVIDERS {
241 let expected = if p.id == "lightroom" {
242 Rung::Database
243 } else {
244 Rung::FolderLayout
245 };
246 assert_eq!(
247 p.default_rung, expected,
248 "{} has the wrong default rung",
249 p.id
250 );
251 }
252 }
253
254 #[test]
255 fn detects_a_modern_apple_library_by_structure_not_name() {
256 let d = tempdir().unwrap();
257 let lib = d.path().join("Some Odd Name");
258 fs::create_dir_all(lib.join("originals")).unwrap();
259 fs::create_dir_all(lib.join("database")).unwrap();
260 fs::write(lib.join("database/Photos.sqlite"), b"").unwrap();
261 assert_eq!(detect(&lib).map(|p| p.id), Some("apple-photos"));
262 }
263
264 #[test]
265 fn a_bare_originals_folder_is_not_an_apple_library() {
266 let d = tempdir().unwrap();
267 fs::create_dir_all(d.path().join("originals")).unwrap();
268 assert_eq!(
269 detect(d.path()).map(|p| p.id),
270 None,
271 "needs the database sibling too"
272 );
273 }
274
275 #[test]
276 fn detects_lightroom_by_catalog_file() {
277 let d = tempdir().unwrap();
278 fs::write(d.path().join("Catalog.lrcat"), b"").unwrap();
279 assert_eq!(detect(d.path()).map(|p| p.id), Some("lightroom"));
280 }
281
282 #[test]
283 fn detects_takeout_by_sidecar_presence() {
284 let d = tempdir().unwrap();
285 fs::write(d.path().join("a.jpg"), b"").unwrap();
286 fs::write(d.path().join("a.jpg.supplemental-metadata.json"), b"{}").unwrap();
287 assert_eq!(detect(d.path()).map(|p| p.id), Some("google-takeout"));
288 }
289
290 #[test]
291 fn finds_a_library_in_a_search_root() {
292 let d = tempdir().unwrap();
293 let pics = d.path().join("Pictures");
294 let lib = pics.join("Photos Library.photoslibrary");
295 fs::create_dir_all(lib.join("originals")).unwrap();
296 fs::create_dir_all(lib.join("database")).unwrap();
297 fs::write(lib.join("database/Photos.sqlite"), b"").unwrap();
298
299 let found = discover_in(&[pics]);
300 assert_eq!(found.len(), 1);
301 assert_eq!(found[0].provider.id, "apple-photos");
302 assert_eq!(found[0].path, lib);
303 }
304
305 #[test]
306 fn finds_several_libraries_and_reports_all() {
307 let d = tempdir().unwrap();
308 let pics = d.path().join("Pictures");
309 let lib = pics.join("A.photoslibrary");
310 fs::create_dir_all(lib.join("originals")).unwrap();
311 fs::create_dir_all(lib.join("database")).unwrap();
312 fs::write(lib.join("database/Photos.sqlite"), b"").unwrap();
313 fs::create_dir_all(pics.join("Lightroom")).unwrap();
314 fs::write(pics.join("Lightroom/Catalog.lrcat"), b"").unwrap();
315
316 let found = discover_in(&[pics]);
317 assert_eq!(
318 found.len(),
319 2,
320 "both libraries must be reported, not the first"
321 );
322 }
323
324 #[test]
325 fn a_missing_search_root_is_skipped_silently() {
326 let found = discover_in(&[std::path::PathBuf::from("/definitely/not/here")]);
327 assert!(found.is_empty());
328 }
329
330 #[test]
331 fn detects_takeout_when_sidecars_are_one_level_down() {
332 let d = tempdir().unwrap();
336 let album = d.path().join("Photos from 2019");
337 fs::create_dir_all(&album).unwrap();
338 fs::write(album.join("a.jpg"), b"").unwrap();
339 fs::write(album.join("a.jpg.supplemental-metadata.json"), b"{}").unwrap();
340 assert_eq!(detect(d.path()).map(|p| p.id), Some("google-takeout"));
341 }
342
343 #[test]
344 #[test]
345 fn detects_a_kept_originals_backup_with_no_catalog_beside_it() {
346 let d = tempdir().unwrap();
350 let orig = d.path().join("originals");
351 for name in "0123456789ABCDEF".chars() {
352 fs::create_dir_all(orig.join(name.to_string())).unwrap();
353 }
354 assert_eq!(detect(d.path()).map(|p| p.id), Some("apple-photos"));
355 assert_eq!(detect(&orig).map(|p| p.id), Some("apple-photos"));
357 }
358
359 #[test]
360 fn a_folder_merely_named_originals_is_not_an_apple_library() {
361 let d = tempdir().unwrap();
364 let orig = d.path().join("originals");
365 fs::create_dir_all(orig.join("2024 Holiday")).unwrap();
366 fs::create_dir_all(orig.join("Wedding")).unwrap();
367 fs::write(orig.join("a.jpg"), b"").unwrap();
368 assert!(detect(d.path()).is_none());
369 }
370
371 #[test]
372 fn an_ordinary_folder_of_photos_detects_as_nothing() {
373 let d = tempdir().unwrap();
374 fs::write(d.path().join("a.jpg"), b"").unwrap();
375 fs::write(d.path().join("b.jpg"), b"").unwrap();
376 assert!(
377 detect(d.path()).is_none(),
378 "a plain folder needs videre scan, not import"
379 );
380 }
381}