use std::path::PathBuf;
use super::error::DrainError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum DestinationScheme {
S3,
File,
Gs,
Az,
}
impl DestinationScheme {
fn parse(scheme: &str) -> Option<Self> {
match scheme {
"s3" => Some(Self::S3),
"file" => Some(Self::File),
"gs" => Some(Self::Gs),
"az" => Some(Self::Az),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum DestinationUri {
S3 {
bucket: String,
prefix: String,
region: Option<String>,
},
File {
path: PathBuf,
},
}
impl DestinationUri {
pub fn parse(uri: &str) -> Result<Self, DrainError> {
let trimmed = uri.trim();
let Some((scheme, rest)) = trimmed.split_once("://") else {
return Err(DrainError::Uri {
uri: uri.to_string(),
reason: "expected `<scheme>://…`, found no `://` separator".to_string(),
});
};
let scheme_lower = scheme.to_ascii_lowercase();
let Some(known) = DestinationScheme::parse(&scheme_lower) else {
return Err(DrainError::UnsupportedScheme {
scheme: scheme_lower,
uri: uri.to_string(),
});
};
match known {
DestinationScheme::S3 => Self::parse_s3(uri, rest),
DestinationScheme::File => Self::parse_file(uri, rest),
DestinationScheme::Gs | DestinationScheme::Az => Err(DrainError::UnsupportedScheme {
scheme: scheme_lower,
uri: uri.to_string(),
}),
}
}
fn parse_s3(uri: &str, rest: &str) -> Result<Self, DrainError> {
let (path_part, query) = match rest.split_once('?') {
Some((p, q)) => (p, Some(q)),
None => (rest, None),
};
let (bucket, prefix) = match path_part.split_once('/') {
Some((b, p)) => (b, p),
None => (path_part, ""),
};
if bucket.is_empty() {
return Err(DrainError::Uri {
uri: uri.to_string(),
reason: "bucket name is empty — expected `s3://<bucket>[/<prefix>]`".to_string(),
});
}
let region = match query {
Some(q) => Some(parse_region_query(uri, q)?),
None => None,
};
Ok(Self::S3 {
bucket: bucket.to_string(),
prefix: normalise_prefix(prefix),
region,
})
}
fn parse_file(uri: &str, rest: &str) -> Result<Self, DrainError> {
if rest.contains('?') {
return Err(DrainError::Uri {
uri: uri.to_string(),
reason: "`file://` takes no query parameters".to_string(),
});
}
if !rest.starts_with('/') {
return Err(DrainError::Uri {
uri: uri.to_string(),
reason: "expected an absolute path — `file:///abs/path`, with three slashes"
.to_string(),
});
}
let path = rest.trim_end_matches('/');
if path.is_empty() {
return Err(DrainError::Uri {
uri: uri.to_string(),
reason: "path is the filesystem root — refusing to drain into `/`".to_string(),
});
}
Ok(Self::File {
path: PathBuf::from(path),
})
}
pub fn prefix(&self) -> &str {
match self {
Self::S3 { prefix, .. } => prefix,
Self::File { .. } => "",
}
}
pub fn scheme(&self) -> DestinationScheme {
match self {
Self::S3 { .. } => DestinationScheme::S3,
Self::File { .. } => DestinationScheme::File,
}
}
}
fn parse_region_query(uri: &str, query: &str) -> Result<String, DrainError> {
let mut region = None;
for pair in query.split('&').filter(|p| !p.is_empty()) {
let (key, value) = pair.split_once('=').ok_or_else(|| DrainError::Uri {
uri: uri.to_string(),
reason: format!("query parameter `{pair}` has no `=`"),
})?;
if key != "region" {
return Err(DrainError::Uri {
uri: uri.to_string(),
reason: format!("unknown query parameter `{key}` — only `region` is accepted"),
});
}
if value.is_empty() {
return Err(DrainError::Uri {
uri: uri.to_string(),
reason: "`region=` is empty".to_string(),
});
}
region = Some(value.to_string());
}
region.ok_or_else(|| DrainError::Uri {
uri: uri.to_string(),
reason: "query string is present but empty".to_string(),
})
}
fn normalise_prefix(prefix: &str) -> String {
prefix.trim_matches('/').to_string()
}