use std::path::{Path, PathBuf};
use url::Url;
use crate::config::file::{ConfigFileError, ConfigFileResult};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SourceLocation {
Local(PathBuf),
ObjectStore(Url),
Http(Url),
}
impl SourceLocation {
pub fn classify(raw: &str) -> ConfigFileResult<Self> {
if let Some(url) = Self::parse_remote(raw, &["http", "https"])? {
return Ok(Self::Http(url));
}
if let Some(url) = Self::parse_remote(
raw,
&[
"s3", "s3a", "gs", "az", "adl", "azure", "abfs", "abfss", "file",
],
)? {
return Ok(Self::ObjectStore(url));
}
Ok(Self::Local(PathBuf::from(raw)))
}
pub fn parse_remote(raw: &str, schemes: &[&str]) -> ConfigFileResult<Option<Url>> {
let Some((scheme, _)) = raw.split_once("://") else {
return Ok(None);
};
if !schemes.contains(&scheme) {
return Ok(None);
}
Url::parse(raw)
.map(Some)
.map_err(|e| ConfigFileError::InvalidSourceUrl(e, raw.to_owned()))
}
pub fn classify_path(path: &Path) -> ConfigFileResult<Self> {
match path.to_str() {
Some(raw) => Self::classify(raw),
None => Ok(Self::Local(path.to_path_buf())),
}
}
#[must_use]
pub const fn is_remote(&self) -> bool {
!matches!(self, Self::Local(_))
}
#[must_use]
pub const fn url(&self) -> Option<&Url> {
match self {
Self::Local(_) => None,
Self::ObjectStore(url) | Self::Http(url) => Some(url),
}
}
#[must_use]
pub fn into_url(self) -> Option<Url> {
match self {
Self::Local(_) => None,
Self::ObjectStore(url) | Self::Http(url) => Some(url),
}
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use url::Url;
use super::*;
#[rstest]
#[case::s3("s3")]
#[case::s3a("s3a")]
#[case::gs("gs")]
#[case::az("az")]
#[case::adl("adl")]
#[case::azure("azure")]
#[case::abfs("abfs")]
#[case::abfss("abfss")]
#[case::file("file")]
fn object_store_schemes_are_object_store_urls(#[case] scheme: &str) {
let raw = format!("{scheme}://bucket/dir/tiles.pmtiles");
let location = SourceLocation::classify(&raw).expect("classification should succeed");
assert_eq!(
location,
SourceLocation::ObjectStore(raw.parse().expect("valid url"))
);
assert!(location.is_remote());
}
#[rstest]
#[case::http("http")]
#[case::https("https")]
fn http_schemes_are_http_urls(#[case] scheme: &str) {
let raw = format!("{scheme}://example.org/dir/tiles.pmtiles");
let location = SourceLocation::classify(&raw).expect("classification should succeed");
assert_eq!(
location,
SourceLocation::Http(raw.parse().expect("valid url"))
);
assert!(location.is_remote());
}
#[rstest]
#[case::empty("")]
#[case::bare_filename("tiles.pmtiles")]
#[case::relative("relative/dir/tiles.mbtiles")]
#[case::absolute("/var/lib/martin/tiles.mbtiles")]
#[case::windows(r"C:\Users\martin\tiles.pmtiles")]
#[case::sqlite_memory("file:tiles.mbtiles?mode=memory&cache=shared")]
#[case::file_without_authority("file:/var/lib/martin/tiles.mbtiles")]
#[case::s3_without_authority("s3:bucket/tiles.pmtiles")]
#[case::uppercase_scheme("S3://bucket/tiles.pmtiles")]
#[case::unsupported_scheme("ftp://example.org/tiles.pmtiles")]
#[case::postgres("postgresql://localhost/db")]
#[case::separator_inside_path("/var/lib/weird://name.mbtiles")]
fn strings_without_a_recognised_remote_scheme_are_local(#[case] raw: &str) {
let location = SourceLocation::classify(raw).expect("classification should succeed");
assert_eq!(location, SourceLocation::Local(raw.into()));
assert!(!location.is_remote());
assert_eq!(location.url(), None);
}
#[rstest]
#[case::empty_http_host("http://")]
#[case::empty_https_host("https://")]
#[case::unterminated_ipv6("http://[::1")]
fn a_remote_scheme_that_is_not_a_url_is_an_error(#[case] raw: &str) {
SourceLocation::classify(raw).unwrap_err();
}
#[cfg(unix)]
#[test]
fn non_utf8_paths_are_local() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt as _;
use std::path::Path;
let path = Path::new(OsStr::from_bytes(b"/var/lib/\xff.mbtiles"));
assert_eq!(
SourceLocation::classify_path(path).expect("classification should succeed"),
SourceLocation::Local(path.to_path_buf())
);
}
#[test]
fn remote_locations_yield_their_url() {
let location = SourceLocation::classify("s3://bucket/tiles.pmtiles")
.expect("classification should succeed");
assert_eq!(
location.url().map(Url::as_str),
Some("s3://bucket/tiles.pmtiles")
);
assert_eq!(
location.into_url().map(|url| url.to_string()),
Some("s3://bucket/tiles.pmtiles".to_owned())
);
}
#[rstest]
#[case::in_the_table(
"hf://datasets/org/set/data.parquet",
Some("hf://datasets/org/set/data.parquet")
)]
#[case::not_in_the_table("s3://bucket/data.parquet", None)]
#[case::without_authority("hf:datasets/org/set/data.parquet", None)]
fn a_caller_table_parses_the_schemes_it_lists_and_no_others(
#[case] raw: &str,
#[case] expected: Option<&str>,
) {
let url = SourceLocation::parse_remote(raw, &["hf"]).expect("parsing should succeed");
assert_eq!(url.as_ref().map(Url::as_str), expected);
}
#[test]
fn local_locations_yield_no_url() {
let location =
SourceLocation::classify("tiles.pmtiles").expect("classification should succeed");
assert_eq!(location.into_url(), None);
}
}