1use crate::import_providers::ProviderDescriptor;
23use std::path::{Path, PathBuf};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
27pub enum Rung {
28 Database,
29 FolderLayout,
30 AskUser,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum Provenance {
37 Database,
38 Layout(&'static str),
39 UserSupplied,
40}
41
42impl Provenance {
43 pub fn describe(&self) -> String {
44 match self {
45 Provenance::Database => "the provider catalog".to_string(),
46 Provenance::Layout(name) => format!("{name}/"),
47 Provenance::UserSupplied => "--originals".to_string(),
48 }
49 }
50}
51
52#[derive(Debug)]
54pub enum Located {
55 Found {
56 roots: Vec<PathBuf>,
57 via: Provenance,
58 },
59 NotFound { tried: Vec<String> },
62}
63
64pub fn access_is_denied(path: &Path) -> bool {
74 matches!(
75 std::fs::read_dir(path),
76 Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied
77 )
78}
79
80#[derive(Debug, Clone, Copy)]
85pub struct LayoutProbe {
86 pub dir_names: &'static [&'static str],
87 pub requires_sibling: Option<&'static str>,
91}
92
93pub fn probe_layouts(root: &Path, probes: &[LayoutProbe]) -> Option<(PathBuf, Provenance)> {
99 let entries: Vec<(String, PathBuf)> = std::fs::read_dir(root)
100 .ok()?
101 .filter_map(|e| e.ok())
102 .filter(|e| e.path().is_dir())
103 .map(|e| (e.file_name().to_string_lossy().to_lowercase(), e.path()))
104 .collect();
105
106 for probe in probes {
107 if let Some(sibling) = probe.requires_sibling {
108 if !root.join(sibling).exists() {
109 continue;
110 }
111 }
112 for want in probe.dir_names {
113 let want_lower = want.to_lowercase();
114 if let Some((_, path)) = entries.iter().find(|(name, _)| *name == want_lower) {
115 return Some((path.clone(), Provenance::Layout(want)));
116 }
117 }
118 }
119 None
120}
121
122#[derive(Debug, Default)]
123pub struct LocateOptions {
124 pub originals_override: Option<PathBuf>,
128 pub use_database: bool,
131}
132
133pub fn locate_with_database(
138 provider: &ProviderDescriptor,
139 root: &Path,
140 opts: &LocateOptions,
141 db_roots: Option<Vec<PathBuf>>,
142) -> anyhow::Result<Located> {
143 let mut tried: Vec<String> = Vec::new();
144
145 if let Some(dir) = &opts.originals_override {
146 return Ok(Located::Found {
147 roots: vec![dir.clone()],
148 via: Provenance::UserSupplied,
149 });
150 }
151
152 let want_db = opts.use_database || provider.default_rung == Rung::Database;
153 if want_db {
154 match db_roots {
155 Some(roots) if !roots.is_empty() => {
156 return Ok(Located::Found {
157 roots,
158 via: Provenance::Database,
159 })
160 }
161 _ => tried.push("the provider catalog: not readable or no rows".to_string()),
162 }
163 } else if !provider.layouts.is_empty() {
164 tried.push("the provider catalog: not read (pass --use-library-db to try it)".to_string());
165 }
166
167 if let Some((path, via)) = probe_layouts(root, provider.layouts) {
168 return Ok(Located::Found {
169 roots: vec![path],
170 via,
171 });
172 }
173 if !provider.layouts.is_empty() {
174 let names: Vec<&str> = provider
175 .layouts
176 .iter()
177 .flat_map(|l| l.dir_names.iter().copied())
178 .collect();
179 tried.push(format!("known layouts: no {} folder", names.join(", ")));
180 }
181
182 Ok(Located::NotFound { tried })
183}
184
185pub fn locate(
187 provider: &ProviderDescriptor,
188 root: &Path,
189 opts: &LocateOptions,
190) -> anyhow::Result<Located> {
191 locate_with_database(provider, root, opts, None)
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197 use std::fs;
198 use tempfile::tempdir;
199
200 fn probe(dir_names: &'static [&'static str], sibling: Option<&'static str>) -> LayoutProbe {
201 LayoutProbe {
202 dir_names,
203 requires_sibling: sibling,
204 }
205 }
206
207 #[test]
208 fn finds_a_layout_directory_by_name() {
209 let d = tempdir().unwrap();
210 fs::create_dir(d.path().join("originals")).unwrap();
211 let got = probe_layouts(d.path(), &[probe(&["originals"], None)]).unwrap();
212 assert_eq!(got.0, d.path().join("originals"));
213 assert_eq!(got.1, Provenance::Layout("originals"));
214 }
215
216 #[test]
217 fn tries_layout_names_in_order() {
218 let d = tempdir().unwrap();
219 fs::create_dir(d.path().join("Masters")).unwrap();
220 let got = probe_layouts(
221 d.path(),
222 &[probe(&["originals", "Masters", "Originals"], None)],
223 )
224 .unwrap();
225 assert_eq!(
226 got.1,
227 Provenance::Layout("Masters"),
228 "falls through to the second name"
229 );
230 }
231
232 #[test]
233 fn a_required_sibling_must_exist() {
234 let d = tempdir().unwrap();
235 fs::create_dir(d.path().join("originals")).unwrap();
236 assert!(probe_layouts(
238 d.path(),
239 &[probe(&["originals"], Some("database/Photos.sqlite"))]
240 )
241 .is_none());
242
243 fs::create_dir_all(d.path().join("database")).unwrap();
244 fs::write(d.path().join("database/Photos.sqlite"), b"").unwrap();
245 assert!(probe_layouts(
246 d.path(),
247 &[probe(&["originals"], Some("database/Photos.sqlite"))]
248 )
249 .is_some());
250 }
251
252 #[test]
253 fn matches_case_insensitively_because_apple_renamed_the_folder() {
254 let d = tempdir().unwrap();
258 fs::create_dir(d.path().join("ORIGINALS")).unwrap();
259 let got = probe_layouts(d.path(), &[probe(&["originals"], None)]);
260 assert!(got.is_some(), "layout matching must not depend on case");
261 }
262
263 #[test]
264 fn no_layout_present_is_none_not_an_error() {
265 let d = tempdir().unwrap();
266 fs::create_dir(d.path().join("something-else")).unwrap();
267 assert!(probe_layouts(d.path(), &[probe(&["originals"], None)]).is_none());
268 }
269
270 #[test]
271 fn a_file_named_like_the_layout_does_not_match() {
272 let d = tempdir().unwrap();
273 fs::write(d.path().join("originals"), b"not a directory").unwrap();
274 assert!(probe_layouts(d.path(), &[probe(&["originals"], None)]).is_none());
275 }
276
277 fn apple() -> &'static crate::import_providers::ProviderDescriptor {
278 crate::import_providers::PROVIDERS
279 .iter()
280 .find(|p| p.id == "apple-photos")
281 .unwrap()
282 }
283
284 #[test]
285 fn user_supplied_path_wins_over_every_rung() {
286 let d = tempdir().unwrap();
287 fs::create_dir(d.path().join("originals")).unwrap();
288 let elsewhere = tempdir().unwrap();
289
290 let got = locate(
291 apple(),
292 d.path(),
293 &LocateOptions {
294 originals_override: Some(elsewhere.path().to_path_buf()),
295 use_database: false,
296 },
297 )
298 .unwrap();
299
300 match got {
301 Located::Found { roots, via } => {
302 assert_eq!(roots, vec![elsewhere.path().to_path_buf()]);
303 assert_eq!(via, Provenance::UserSupplied);
304 }
305 other => panic!("expected Found, got {other:?}"),
306 }
307 }
308
309 #[test]
310 fn default_uses_the_folder_layout_rung_and_never_opens_a_database() {
311 let d = tempdir().unwrap();
312 fs::create_dir(d.path().join("Masters")).unwrap();
313 let got = locate(apple(), d.path(), &LocateOptions::default()).unwrap();
314 match got {
315 Located::Found { via, .. } => assert_eq!(via, Provenance::Layout("Masters")),
316 other => panic!("expected Found, got {other:?}"),
317 }
318 }
319
320 #[test]
321 fn every_rung_failing_reports_what_was_tried() {
322 let d = tempdir().unwrap();
323 let got = locate(apple(), d.path(), &LocateOptions::default()).unwrap();
324 match got {
325 Located::NotFound { tried } => {
326 assert!(!tried.is_empty(), "must say what it attempted");
327 let joined = tried.join(" ");
328 assert!(
329 joined.contains("originals"),
330 "should name the layouts: {joined}"
331 );
332 }
333 other => panic!("expected NotFound, got {other:?}"),
334 }
335 }
336
337 #[test]
338 fn provenance_describes_how_files_were_found() {
339 assert_eq!(Provenance::Layout("originals").describe(), "originals/");
340 assert_eq!(Provenance::Database.describe(), "the provider catalog");
341 assert_eq!(Provenance::UserSupplied.describe(), "--originals");
342 }
343
344 #[test]
345 fn rungs_order_from_most_to_least_precise() {
346 assert!(Rung::Database < Rung::FolderLayout);
347 assert!(Rung::FolderLayout < Rung::AskUser);
348 }
349}
350
351#[cfg(test)]
352mod access_tests {
353 use super::*;
354 use tempfile::tempdir;
355
356 #[test]
357 fn unreadable_directory_is_denied_not_absent() {
358 #[cfg(unix)]
359 {
360 use std::os::unix::fs::PermissionsExt;
361 let d = tempdir().unwrap();
362 let blocked = d.path().join("blocked");
363 std::fs::create_dir(&blocked).unwrap();
364 std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o000)).unwrap();
365 if std::fs::read_dir(&blocked).is_ok() {
367 return;
368 }
369 assert!(access_is_denied(&blocked));
370 assert!(!access_is_denied(&d.path().join("nope")));
373 std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o755)).unwrap();
374 }
375 }
376}