Skip to main content

videre_core/
import_location.rs

1//! Where an import's source files live.
2//!
3//! Every `videre import` source resolves file locations through the same
4//! ladder, declared per provider in `import_providers`:
5//!
6//! ```text
7//! [ provider database ]   only when opted in, or when there is no alternative
8//!         |
9//!   known folder layouts  the default entry point
10//!         |
11//!     ask the user        --originals <dir>, or plain videre scan
12//! ```
13//!
14//! The default never opens a provider database. A source that invents its own
15//! discovery scheme is a defect in that source: a vendor changing their layout
16//! should cost one rung, not the feature.
17//!
18//! Asking a catalog *where to look* is not the same as asking it *what is
19//! there*. Location may come from a database; content always comes from the
20//! files.
21
22use crate::import_providers::ProviderDescriptor;
23use std::path::{Path, PathBuf};
24
25/// Ordered most precise first, which is also the order they are tried.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
27pub enum Rung {
28    Database,
29    FolderLayout,
30    AskUser,
31}
32
33/// How a given run actually found the files. Reported so a bug report can
34/// distinguish a schema change from a layout change.
35#[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/// The outcome of locating a source's files.
53#[derive(Debug)]
54pub enum Located {
55    Found {
56        roots: Vec<PathBuf>,
57        via: Provenance,
58    },
59    /// Every rung failed. `tried` is human-readable, one line per rung, and is
60    /// printed verbatim so the user can see what was attempted.
61    NotFound { tried: Vec<String> },
62}
63
64/// True when `path` exists but cannot be read because the OS denied access.
65///
66/// Layout probing goes through `Path::is_dir`, which answers false for both
67/// "absent" and "blocked", so without this a permission failure is reported as
68/// a missing folder and the user is told the vendor changed their structure.
69/// On macOS a `.photoslibrary` is TCC-protected, so this is the *normal* first
70/// experience of `videre import` until Full Disk Access is granted; measured
71/// against a real library, where `originals/` returned EPERM while a genuinely
72/// absent `Masters/` returned ENOENT.
73pub 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/// One candidate folder layout for a provider.
81///
82/// `dir_names` are tried in order, so a provider that renamed its folder across
83/// versions lists them newest first.
84#[derive(Debug, Clone, Copy)]
85pub struct LayoutProbe {
86    pub dir_names: &'static [&'static str],
87    /// A path relative to the library root that must also exist for this layout
88    /// to count. Distinguishes "a folder happens to be called originals" from
89    /// "this is an Apple Photos library".
90    pub requires_sibling: Option<&'static str>,
91}
92
93/// Finds the first matching layout directory beneath `root`.
94///
95/// Matching is case-insensitive: Apple used `Originals/`, then `Masters/`, then
96/// lowercase `originals/`, and on a case-insensitive filesystem the on-disk
97/// spelling cannot be relied on.
98pub 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    /// `--originals <dir>`: overrides every rung. The pressure valve for the
125    /// day a vendor changes their structure, so the feature keeps working by
126    /// hand immediately rather than after videre ships a fix.
127    pub originals_override: Option<PathBuf>,
128    /// `--use-library-db`: adds the database rung above the layout rung. Off by
129    /// default, which is why the default run never opens a provider catalog.
130    pub use_database: bool,
131}
132
133/// Resolves where a provider's files live, per the location contract.
134///
135/// `db_roots` is supplied by the caller for providers whose database rung is in
136/// play, since reading a vendor catalog is command-level work, not core's.
137pub 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
185/// The common case: no database rung in play.
186pub 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        // No database/Photos.sqlite, so the probe must not match.
237        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        // Early iPhoto used Originals/, iPhoto 9 used Masters/, modern Photos
255        // uses lowercase originals/. On a case-insensitive filesystem the same
256        // name can arrive either way.
257        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            // Meaningless as root, which ignores the mode bits entirely.
366            if std::fs::read_dir(&blocked).is_ok() {
367                return;
368            }
369            assert!(access_is_denied(&blocked));
370            // The distinction that matters: a genuinely absent folder is not
371            // "denied", so the two failure modes stay separable.
372            assert!(!access_is_denied(&d.path().join("nope")));
373            std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o755)).unwrap();
374        }
375    }
376}