use std::{
borrow::Cow,
fmt,
path::{Path, PathBuf},
};
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, percent_encode};
#[derive(Clone, Default, PartialEq, Eq, Hash)]
pub struct Uri(pub String);
const FILE_PATH_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'%')
.add(b'/')
.add(b'<')
.add(b'>')
.add(b'?')
.add(b'[')
.add(b'\\')
.add(b']')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}');
impl serde::Serialize for Uri {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> serde::Deserialize<'de> for Uri {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::de::Deserializer<'de>,
{
Ok(Uri(<String>::deserialize(deserializer)?))
}
}
impl Uri {
pub fn from_file_path(p: impl AsRef<Path>) -> Self {
let mut p = p.as_ref().as_os_str().to_str().expect("cannot encode non-utf8 path");
while let Some(stripped) = p.strip_prefix('/') {
p = stripped;
}
let mut buf = String::from("file:///");
for (i, segment) in p.split_inclusive('/').enumerate() {
if segment.is_empty() && i != 0 {
continue;
}
let trailing_slash = segment.ends_with('/');
buf.push_str(
&percent_encode(
segment[..segment.len() - trailing_slash as usize].as_bytes(),
FILE_PATH_ENCODE_SET,
)
.to_string(),
);
if trailing_slash {
buf.push('/');
}
}
Uri(buf)
}
pub fn to_file_path(&self) -> Option<PathBuf> {
if let Some(mut path) = self.0.strip_prefix("file://") {
let mut buf = PathBuf::new();
path = path.strip_prefix('/')?;
match path.chars().next() {
Some('a'..='z' | 'A'..='Z')
if path.chars().nth(1) == Some(':') && path.chars().nth(2) == Some('/') =>
{
buf.push(&path[..3]);
path = &path[3..];
}
Some(letter @ ('a'..='z' | 'A'..='Z'))
if path.chars().nth(1) == Some('%')
&& path.chars().nth(2) == Some('3')
&& path.chars().nth(3) == Some('A')
&& path.chars().nth(4) == Some('/') =>
{
buf.push(&format!("{letter}:/"));
path = &path[5..];
}
_ => buf.push("/"),
}
for segment in path.split('/') {
buf.push(&*String::from_utf8_lossy(&Cow::from(percent_decode_str(segment))));
}
Some(buf)
} else {
None
}
}
}
impl fmt::Debug for Uri {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("Uri").field(&self.to_string()).finish()
}
}
impl fmt::Display for Uri {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) }
}
impl PartialEq<str> for Uri {
fn eq(&self, other: &str) -> bool { self.to_string() == other }
}
impl PartialEq<&str> for Uri {
fn eq(&self, other: &&str) -> bool { *self == **other }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn file_path_conversion() {
let uri = Uri::from_file_path("/foo/bar.rs");
assert_eq!(uri, "file:///foo/bar.rs");
assert_eq!(uri.to_file_path().unwrap(), PathBuf::from("/foo/bar.rs"));
}
#[test]
fn from_path_adds_leading_slash() {
assert_eq!(Uri::from_file_path("foo/bar.rs"), "file:///foo/bar.rs");
}
#[test]
fn from_path_keeps_trailing() {
assert_eq!(Uri::from_file_path("/foo/bar"), "file:///foo/bar");
assert_eq!(Uri::from_file_path("/foo/bar/"), "file:///foo/bar/");
}
#[test]
fn to_path_removes_trailing() {
assert_eq!(Uri("file:///foo/bar/".into()).to_file_path().unwrap(), Path::new("/foo/bar"));
}
#[test]
fn from_path_encodes() {
assert_eq!(Uri::from_file_path("/foo bar"), "file:///foo%20bar");
assert_eq!(Uri::from_file_path("/foo#bar"), "file:///foo%23bar");
assert_eq!(Uri::from_file_path("/foo?bar"), "file:///foo%3Fbar");
assert_eq!(Uri::from_file_path("/foo%bar"), "file:///foo%25bar");
assert_eq!(Uri::from_file_path("/foo[bar"), "file:///foo%5Bbar");
assert_eq!(Uri::from_file_path("/foo]bar"), "file:///foo%5Dbar");
}
#[test]
fn to_path_decodes() {
assert_eq!(Uri("file:///foo%20bar".into()).to_file_path().unwrap(), Path::new("/foo bar"));
assert_eq!(Uri("file:///foo%23bar".into()).to_file_path().unwrap(), Path::new("/foo#bar"));
assert_eq!(Uri("file:///foo%3Fbar".into()).to_file_path().unwrap(), Path::new("/foo?bar"));
assert_eq!(Uri("file:///foo%25bar".into()).to_file_path().unwrap(), Path::new("/foo%bar"));
assert_eq!(Uri("file:///foo%5Bbar".into()).to_file_path().unwrap(), Path::new("/foo[bar"));
assert_eq!(Uri("file:///foo%5Dbar".into()).to_file_path().unwrap(), Path::new("/foo]bar"));
}
#[test]
fn from_path_handles_prefix() {
assert_eq!(Uri::from_file_path("C:/foo/bar.txt"), Uri("file:///C:/foo/bar.txt".into()),);
}
#[test]
fn to_path_handles_prefix() {
assert_eq!(
Uri("file:///C:/foo/bar.txt".into()).to_file_path().unwrap(),
Path::new("C:/foo/bar.txt")
);
}
#[test]
fn to_path_handles_percent_prefix() {
assert_eq!(
Uri("file:///C%3A/foo/bar.txt".into()).to_file_path().unwrap(),
Path::new("C:/foo/bar.txt")
);
}
}