use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::failure::FailureCode;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum SourceSpec {
Path(PathBuf),
Uri(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum SourceError {
PathMismatch {
command: PathBuf,
source: PathBuf,
},
MissingScheme(String),
InvalidFileUri {
uri: String,
reason: &'static str,
},
NoServerDriver {
scheme: String,
},
}
impl SourceError {
pub fn code(&self) -> FailureCode {
match self {
Self::NoServerDriver { .. } => FailureCode::Unsupported,
_ => FailureCode::InvalidInput,
}
}
}
impl std::fmt::Display for SourceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::PathMismatch { command, source } => write!(
f,
"source path {:?} disagrees with the open path {:?} — the source spec is authoritative; make them agree or drop the path field",
source, command
),
Self::MissingScheme(uri) => write!(
f,
"uri {uri:?} carries no scheme — file and server sources need one (file://, postgres://, …)"
),
Self::InvalidFileUri { uri, reason } => {
write!(f, "invalid file uri {uri:?}: {reason}")
}
Self::NoServerDriver { scheme } => write!(
f,
"uri scheme {scheme:?} has no server driver — server sources are a documented future path (ADR-7); open a local file instead"
),
}
}
}
impl std::fmt::Display for SourceSpec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Path(path) => write!(f, "{}", path.display()),
Self::Uri(uri) => write!(f, "{uri}"),
}
}
}
impl SourceSpec {
pub fn resolve(&self, command_path: &Path) -> Result<PathBuf, SourceError> {
match self {
Self::Path(path) => {
if command_path.as_os_str().is_empty() || command_path == path {
Ok(path.clone())
} else {
Err(SourceError::PathMismatch {
command: command_path.to_path_buf(),
source: path.clone(),
})
}
}
Self::Uri(uri) => resolve_uri(uri),
}
}
}
pub fn effective_path(
command_path: &Path,
source: Option<&SourceSpec>,
) -> Result<PathBuf, SourceError> {
match source {
None => Ok(command_path.to_path_buf()),
Some(spec) => spec.resolve(command_path),
}
}
fn resolve_uri(uri: &str) -> Result<PathBuf, SourceError> {
let Some((raw_scheme, rest)) = uri.split_once(':') else {
return Err(SourceError::MissingScheme(uri.to_owned()));
};
let scheme = raw_scheme.to_ascii_lowercase();
if !valid_scheme(&scheme) || scheme != "file" {
return Err(SourceError::NoServerDriver { scheme });
}
let Some(after_authority) = rest.strip_prefix("//") else {
return Err(SourceError::InvalidFileUri {
uri: uri.to_owned(),
reason: "the file scheme requires the authority form file://…",
});
};
let (authority, raw_path) = match after_authority.split_once('/') {
Some((authority, path)) => (authority, path),
None => (after_authority, ""),
};
if !authority.is_empty() && !authority.eq_ignore_ascii_case("localhost") {
return Err(SourceError::InvalidFileUri {
uri: uri.to_owned(),
reason: "remote file hosts are not supported — only local files open today",
});
}
if raw_path.contains(['?', '#']) {
return Err(SourceError::InvalidFileUri {
uri: uri.to_owned(),
reason: "query and fragment parts are not database paths",
});
}
if raw_path.is_empty() {
return Err(SourceError::InvalidFileUri {
uri: uri.to_owned(),
reason: "the file uri carries no path",
});
}
let decoded = percent_decode(raw_path).map_err(|reason| SourceError::InvalidFileUri {
uri: uri.to_owned(),
reason,
})?;
let mut candidate = format!("/{decoded}");
let bytes = candidate.as_bytes();
if bytes.len() >= 3 && bytes[0] == b'/' && bytes[1].is_ascii_alphabetic() && bytes[2] == b':' {
candidate.remove(0);
}
Ok(PathBuf::from(candidate))
}
fn valid_scheme(scheme: &str) -> bool {
let mut chars = scheme.chars();
matches!(chars.next(), Some(first) if first.is_ascii_alphabetic())
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
}
fn percent_decode(raw: &str) -> Result<String, &'static str> {
let bytes = raw.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
if i + 2 >= bytes.len()
|| !bytes[i + 1].is_ascii_hexdigit()
|| !bytes[i + 2].is_ascii_hexdigit()
{
return Err("invalid percent escape (expected %HH)");
}
let hex = &raw[i + 1..i + 3];
match u8::from_str_radix(hex, 16) {
Ok(byte) => out.push(byte),
Err(_) => return Err("invalid percent escape (expected %HH)"),
}
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
String::from_utf8(out).map_err(|_| "the percent escape decodes to invalid utf-8")
}