use crate::import_providers::ProviderDescriptor;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Rung {
Database,
FolderLayout,
AskUser,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Provenance {
Database,
Layout(&'static str),
UserSupplied,
}
impl Provenance {
pub fn describe(&self) -> String {
match self {
Provenance::Database => "the provider catalog".to_string(),
Provenance::Layout(name) => format!("{name}/"),
Provenance::UserSupplied => "--originals".to_string(),
}
}
}
#[derive(Debug)]
pub enum Located {
Found {
roots: Vec<PathBuf>,
via: Provenance,
},
NotFound { tried: Vec<String> },
}
pub fn access_is_denied(path: &Path) -> bool {
matches!(
std::fs::read_dir(path),
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied
)
}
#[derive(Debug, Clone, Copy)]
pub struct LayoutProbe {
pub dir_names: &'static [&'static str],
pub requires_sibling: Option<&'static str>,
}
pub fn probe_layouts(root: &Path, probes: &[LayoutProbe]) -> Option<(PathBuf, Provenance)> {
let entries: Vec<(String, PathBuf)> = std::fs::read_dir(root)
.ok()?
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.map(|e| (e.file_name().to_string_lossy().to_lowercase(), e.path()))
.collect();
for probe in probes {
if let Some(sibling) = probe.requires_sibling {
if !root.join(sibling).exists() {
continue;
}
}
for want in probe.dir_names {
let want_lower = want.to_lowercase();
if let Some((_, path)) = entries.iter().find(|(name, _)| *name == want_lower) {
return Some((path.clone(), Provenance::Layout(want)));
}
}
}
None
}
#[derive(Debug, Default)]
pub struct LocateOptions {
pub originals_override: Option<PathBuf>,
pub use_database: bool,
}
pub fn locate_with_database(
provider: &ProviderDescriptor,
root: &Path,
opts: &LocateOptions,
db_roots: Option<Vec<PathBuf>>,
) -> anyhow::Result<Located> {
let mut tried: Vec<String> = Vec::new();
if let Some(dir) = &opts.originals_override {
return Ok(Located::Found {
roots: vec![dir.clone()],
via: Provenance::UserSupplied,
});
}
let want_db = opts.use_database || provider.default_rung == Rung::Database;
if want_db {
match db_roots {
Some(roots) if !roots.is_empty() => {
return Ok(Located::Found {
roots,
via: Provenance::Database,
})
}
_ => tried.push("the provider catalog: not readable or no rows".to_string()),
}
} else if !provider.layouts.is_empty() {
tried.push("the provider catalog: not read (pass --use-library-db to try it)".to_string());
}
if let Some((path, via)) = probe_layouts(root, provider.layouts) {
return Ok(Located::Found {
roots: vec![path],
via,
});
}
if !provider.layouts.is_empty() {
let names: Vec<&str> = provider
.layouts
.iter()
.flat_map(|l| l.dir_names.iter().copied())
.collect();
tried.push(format!("known layouts: no {} folder", names.join(", ")));
}
Ok(Located::NotFound { tried })
}
pub fn locate(
provider: &ProviderDescriptor,
root: &Path,
opts: &LocateOptions,
) -> anyhow::Result<Located> {
locate_with_database(provider, root, opts, None)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn probe(dir_names: &'static [&'static str], sibling: Option<&'static str>) -> LayoutProbe {
LayoutProbe {
dir_names,
requires_sibling: sibling,
}
}
#[test]
fn finds_a_layout_directory_by_name() {
let d = tempdir().unwrap();
fs::create_dir(d.path().join("originals")).unwrap();
let got = probe_layouts(d.path(), &[probe(&["originals"], None)]).unwrap();
assert_eq!(got.0, d.path().join("originals"));
assert_eq!(got.1, Provenance::Layout("originals"));
}
#[test]
fn tries_layout_names_in_order() {
let d = tempdir().unwrap();
fs::create_dir(d.path().join("Masters")).unwrap();
let got = probe_layouts(
d.path(),
&[probe(&["originals", "Masters", "Originals"], None)],
)
.unwrap();
assert_eq!(
got.1,
Provenance::Layout("Masters"),
"falls through to the second name"
);
}
#[test]
fn a_required_sibling_must_exist() {
let d = tempdir().unwrap();
fs::create_dir(d.path().join("originals")).unwrap();
assert!(probe_layouts(
d.path(),
&[probe(&["originals"], Some("database/Photos.sqlite"))]
)
.is_none());
fs::create_dir_all(d.path().join("database")).unwrap();
fs::write(d.path().join("database/Photos.sqlite"), b"").unwrap();
assert!(probe_layouts(
d.path(),
&[probe(&["originals"], Some("database/Photos.sqlite"))]
)
.is_some());
}
#[test]
fn matches_case_insensitively_because_apple_renamed_the_folder() {
let d = tempdir().unwrap();
fs::create_dir(d.path().join("ORIGINALS")).unwrap();
let got = probe_layouts(d.path(), &[probe(&["originals"], None)]);
assert!(got.is_some(), "layout matching must not depend on case");
}
#[test]
fn no_layout_present_is_none_not_an_error() {
let d = tempdir().unwrap();
fs::create_dir(d.path().join("something-else")).unwrap();
assert!(probe_layouts(d.path(), &[probe(&["originals"], None)]).is_none());
}
#[test]
fn a_file_named_like_the_layout_does_not_match() {
let d = tempdir().unwrap();
fs::write(d.path().join("originals"), b"not a directory").unwrap();
assert!(probe_layouts(d.path(), &[probe(&["originals"], None)]).is_none());
}
fn apple() -> &'static crate::import_providers::ProviderDescriptor {
crate::import_providers::PROVIDERS
.iter()
.find(|p| p.id == "apple-photos")
.unwrap()
}
#[test]
fn user_supplied_path_wins_over_every_rung() {
let d = tempdir().unwrap();
fs::create_dir(d.path().join("originals")).unwrap();
let elsewhere = tempdir().unwrap();
let got = locate(
apple(),
d.path(),
&LocateOptions {
originals_override: Some(elsewhere.path().to_path_buf()),
use_database: false,
},
)
.unwrap();
match got {
Located::Found { roots, via } => {
assert_eq!(roots, vec![elsewhere.path().to_path_buf()]);
assert_eq!(via, Provenance::UserSupplied);
}
other => panic!("expected Found, got {other:?}"),
}
}
#[test]
fn default_uses_the_folder_layout_rung_and_never_opens_a_database() {
let d = tempdir().unwrap();
fs::create_dir(d.path().join("Masters")).unwrap();
let got = locate(apple(), d.path(), &LocateOptions::default()).unwrap();
match got {
Located::Found { via, .. } => assert_eq!(via, Provenance::Layout("Masters")),
other => panic!("expected Found, got {other:?}"),
}
}
#[test]
fn every_rung_failing_reports_what_was_tried() {
let d = tempdir().unwrap();
let got = locate(apple(), d.path(), &LocateOptions::default()).unwrap();
match got {
Located::NotFound { tried } => {
assert!(!tried.is_empty(), "must say what it attempted");
let joined = tried.join(" ");
assert!(
joined.contains("originals"),
"should name the layouts: {joined}"
);
}
other => panic!("expected NotFound, got {other:?}"),
}
}
#[test]
fn provenance_describes_how_files_were_found() {
assert_eq!(Provenance::Layout("originals").describe(), "originals/");
assert_eq!(Provenance::Database.describe(), "the provider catalog");
assert_eq!(Provenance::UserSupplied.describe(), "--originals");
}
#[test]
fn rungs_order_from_most_to_least_precise() {
assert!(Rung::Database < Rung::FolderLayout);
assert!(Rung::FolderLayout < Rung::AskUser);
}
}
#[cfg(test)]
mod access_tests {
use super::*;
use tempfile::tempdir;
#[test]
fn unreadable_directory_is_denied_not_absent() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let d = tempdir().unwrap();
let blocked = d.path().join("blocked");
std::fs::create_dir(&blocked).unwrap();
std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read_dir(&blocked).is_ok() {
return;
}
assert!(access_is_denied(&blocked));
assert!(!access_is_denied(&d.path().join("nope")));
std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o755)).unwrap();
}
}
}