use percent_encoding::percent_decode;
use std::fmt::Formatter;
#[cfg(not(target_arch = "wasm32"))]
use url::Url;
pub const DELIMITER: &str = "/";
pub const DELIMITER_BYTE: u8 = DELIMITER.as_bytes()[0];
pub const DELIMITER_CHAR: char = DELIMITER_BYTE as char;
mod parts;
pub use parts::{InvalidPart, PathPart, PathParts};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("Path \"{}\" contained empty path segment", path)]
EmptySegment {
path: String,
},
#[error("Error parsing Path \"{}\": {}", path, source)]
BadSegment {
path: String,
source: InvalidPart,
},
#[error("Failed to canonicalize path \"{}\": {}", path.display(), source)]
Canonicalize {
path: std::path::PathBuf,
source: std::io::Error,
},
#[error("Unable to convert path \"{}\" to URL", path.display())]
InvalidPath {
path: std::path::PathBuf,
},
#[error("Path \"{}\" contained non-unicode characters: {}", path, source)]
NonUnicode {
path: String,
source: std::str::Utf8Error,
},
#[error("Path {} does not start with prefix {}", path, prefix)]
PrefixMismatch {
path: String,
prefix: String,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Ord, PartialOrd)]
pub struct Path {
raw: String,
}
impl Path {
pub const ROOT: Self = Self { raw: String::new() };
pub fn parse(path: impl AsRef<str>) -> Result<Self, Error> {
let path = path.as_ref();
let stripped = path.strip_prefix(DELIMITER).unwrap_or(path);
if stripped.is_empty() {
return Ok(Default::default());
}
let stripped = stripped.strip_suffix(DELIMITER).unwrap_or(stripped);
for segment in stripped.split(DELIMITER) {
if segment.is_empty() {
return Err(Error::EmptySegment { path: path.into() });
}
PathPart::parse(segment).map_err(|source| {
let path = path.into();
Error::BadSegment { source, path }
})?;
}
Ok(Self {
raw: stripped.to_string(),
})
}
#[cfg(not(target_arch = "wasm32"))]
pub fn from_filesystem_path(path: impl AsRef<std::path::Path>) -> Result<Self, Error> {
let absolute = std::fs::canonicalize(&path).map_err(|source| {
let path = path.as_ref().into();
Error::Canonicalize { source, path }
})?;
Self::from_absolute_path(absolute)
}
#[cfg(not(target_arch = "wasm32"))]
pub fn from_absolute_path(path: impl AsRef<std::path::Path>) -> Result<Self, Error> {
Self::from_absolute_path_with_base(path, None)
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn from_absolute_path_with_base(
path: impl AsRef<std::path::Path>,
base: Option<&Url>,
) -> Result<Self, Error> {
let url = absolute_path_to_url(path)?;
let path = match base {
Some(prefix) => {
url.path()
.strip_prefix(prefix.path())
.ok_or_else(|| Error::PrefixMismatch {
path: url.path().to_string(),
prefix: prefix.to_string(),
})?
}
None => url.path(),
};
Self::from_url_path(path)
}
pub fn from_url_path(path: impl AsRef<str>) -> Result<Self, Error> {
let path = path.as_ref();
let decoded = percent_decode(path.as_bytes())
.decode_utf8()
.map_err(|source| {
let path = path.into();
Error::NonUnicode { source, path }
})?;
Self::parse(decoded)
}
#[doc(alias = "len")]
pub fn parts_count(&self) -> usize {
self.raw.split_terminator(DELIMITER).count()
}
pub fn is_root(&self) -> bool {
self.raw.is_empty()
}
pub fn parts(&self) -> PathParts<'_> {
PathParts::new(&self.raw)
}
pub fn parent(&self) -> Option<Self> {
if self.raw.is_empty() {
return None;
}
let Some((prefix, _filename)) = self.raw.rsplit_once(DELIMITER) else {
return Some(Self::ROOT);
};
Some(Self {
raw: prefix.to_string(),
})
}
pub fn filename(&self) -> Option<&str> {
match self.raw.is_empty() {
true => None,
false => self.raw.rsplit(DELIMITER).next(),
}
}
pub fn extension(&self) -> Option<&str> {
self.filename()
.and_then(|f| f.rsplit_once('.'))
.and_then(|(_, extension)| {
if extension.is_empty() {
None
} else {
Some(extension)
}
})
}
pub fn prefix_match(&self, prefix: &Self) -> Option<impl Iterator<Item = PathPart<'_>> + '_> {
let mut stripped = self.raw.strip_prefix(&prefix.raw)?;
if !stripped.is_empty() && !prefix.raw.is_empty() {
stripped = stripped.strip_prefix(DELIMITER)?;
}
Some(PathParts::new(stripped))
}
pub fn prefix_matches(&self, prefix: &Self) -> bool {
self.prefix_match(prefix).is_some()
}
#[deprecated = "use .join() or .clone().join() instead"]
pub fn child<'a>(&self, child: impl Into<PathPart<'a>>) -> Self {
self.clone().join(child)
}
pub fn join<'a>(self, child: impl Into<PathPart<'a>>) -> Self {
let child_cow_str = child.into().raw;
let raw = if self.raw.is_empty() {
child_cow_str.to_string()
} else {
use std::fmt::Write;
let mut raw = self.raw;
write!(raw, "{DELIMITER}{child_cow_str}").expect("failed to append to string");
raw
};
Self { raw }
}
}
impl AsRef<str> for Path {
fn as_ref(&self) -> &str {
&self.raw
}
}
impl From<&str> for Path {
fn from(path: &str) -> Self {
Self::from_iter(path.split(DELIMITER))
}
}
impl From<String> for Path {
fn from(path: String) -> Self {
Self::from_iter(path.split(DELIMITER))
}
}
impl From<Path> for String {
fn from(path: Path) -> Self {
path.raw
}
}
impl std::fmt::Display for Path {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
self.raw.fmt(f)
}
}
impl<'a, I> FromIterator<I> for Path
where
I: Into<PathPart<'a>>,
{
fn from_iter<T: IntoIterator<Item = I>>(iter: T) -> Self {
let mut this = Self::ROOT;
this.extend(iter);
this
}
}
impl<'a> IntoIterator for &'a Path {
type Item = PathPart<'a>;
type IntoIter = PathParts<'a>;
fn into_iter(self) -> Self::IntoIter {
PathParts::new(&self.raw)
}
}
impl<'a, I: Into<PathPart<'a>>> Extend<I> for Path {
fn extend<T: IntoIterator<Item = I>>(&mut self, iter: T) {
for s in iter {
let s = s.into();
if !s.raw.is_empty() {
if !self.raw.is_empty() {
self.raw.push(DELIMITER_CHAR);
}
self.raw.push_str(&s.raw);
}
}
}
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn absolute_path_to_url(path: impl AsRef<std::path::Path>) -> Result<Url, Error> {
Url::from_file_path(&path).map_err(|_| Error::InvalidPath {
path: path.as_ref().into(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn delimiter_char_is_forward_slash() {
assert_eq!(DELIMITER_CHAR, '/');
}
#[test]
fn cloud_prefix_with_trailing_delimiter() {
let prefix = Path::from_iter(["test"]);
assert_eq!(prefix.as_ref(), "test");
}
#[test]
fn push_encodes() {
let location = Path::from_iter(["foo/bar", "baz%2Ftest"]);
assert_eq!(location.as_ref(), "foo%2Fbar/baz%252Ftest");
}
#[test]
fn test_parse() {
assert_eq!(Path::parse("/").unwrap().as_ref(), "");
assert_eq!(Path::parse("").unwrap().as_ref(), "");
let err = Path::parse("//").unwrap_err();
assert!(matches!(err, Error::EmptySegment { .. }));
assert_eq!(Path::parse("/foo/bar/").unwrap().as_ref(), "foo/bar");
assert_eq!(Path::parse("foo/bar/").unwrap().as_ref(), "foo/bar");
assert_eq!(Path::parse("foo/bar").unwrap().as_ref(), "foo/bar");
let err = Path::parse("foo///bar").unwrap_err();
assert!(matches!(err, Error::EmptySegment { .. }));
}
#[test]
fn convert_raw_before_partial_eq() {
let cloud = Path::from("test_dir/test_file.json");
let built = Path::from_iter(["test_dir", "test_file.json"]);
assert_eq!(built, cloud);
let cloud = Path::from("test_dir/test_file");
let built = Path::from_iter(["test_dir", "test_file"]);
assert_eq!(built, cloud);
let cloud = Path::from("test_dir/");
let built = Path::from_iter(["test_dir"]);
assert_eq!(built, cloud);
let cloud = Path::from("test_file.json");
let built = Path::from_iter(["test_file.json"]);
assert_eq!(built, cloud);
let cloud = Path::from("");
let built = Path::from_iter(["", ""]);
assert_eq!(built, cloud);
}
#[test]
fn parts_after_prefix_behavior() {
let existing_path = Path::from("apple/bear/cow/dog/egg.json");
let prefix = Path::from("apple");
let expected_parts: Vec<PathPart<'_>> = vec!["bear", "cow", "dog", "egg.json"]
.into_iter()
.map(Into::into)
.collect();
let parts: Vec<_> = existing_path.prefix_match(&prefix).unwrap().collect();
assert_eq!(parts, expected_parts);
let prefix = Path::from("apple/bear");
let expected_parts: Vec<PathPart<'_>> = vec!["cow", "dog", "egg.json"]
.into_iter()
.map(Into::into)
.collect();
let parts: Vec<_> = existing_path.prefix_match(&prefix).unwrap().collect();
assert_eq!(parts, expected_parts);
let prefix = Path::from("cow");
assert!(existing_path.prefix_match(&prefix).is_none());
let prefix = Path::from("ap");
assert!(existing_path.prefix_match(&prefix).is_none());
let existing = Path::from("apple/bear/cow/dog");
assert_eq!(existing.prefix_match(&existing).unwrap().count(), 0);
assert_eq!(Path::default().parts().count(), 0);
}
#[test]
fn parts_count() {
assert_eq!(Path::ROOT.parts().count(), Path::ROOT.parts_count());
let path = path("foo/bar/baz");
assert_eq!(path.parts_count(), 3);
assert_eq!(path.parts_count(), path.parts().count());
}
#[test]
fn prefix_matches_raw_content() {
assert_eq!(Path::ROOT.parent(), None, "empty path must have no prefix");
assert_eq!(path("foo").parent().unwrap(), Path::ROOT);
assert_eq!(path("foo/bar").parent().unwrap(), path("foo"));
assert_eq!(path("foo/bar/baz").parent().unwrap(), path("foo/bar"));
}
#[test]
fn prefix_matches() {
let haystack = Path::from_iter(["foo/bar", "baz%2Ftest", "something"]);
assert!(
haystack.prefix_matches(&haystack),
"{haystack:?} should have started with {haystack:?}"
);
let needle = haystack.clone().join("longer now");
assert!(
!haystack.prefix_matches(&needle),
"{haystack:?} shouldn't have started with {needle:?}"
);
let needle = Path::from_iter(["foo/bar"]);
assert!(
haystack.prefix_matches(&needle),
"{haystack:?} should have started with {needle:?}"
);
let needle = needle.join("baz%2Ftest");
assert!(
haystack.prefix_matches(&needle),
"{haystack:?} should have started with {needle:?}"
);
let needle = Path::from_iter(["f"]);
assert!(
!haystack.prefix_matches(&needle),
"{haystack:?} should not have started with {needle:?}"
);
let needle = Path::from_iter(["foo/bar", "baz"]);
assert!(
!haystack.prefix_matches(&needle),
"{haystack:?} should not have started with {needle:?}"
);
let needle = Path::from("");
assert!(
haystack.prefix_matches(&needle),
"{haystack:?} should have started with {needle:?}"
);
}
#[test]
fn prefix_matches_with_file_name() {
let haystack = Path::from_iter(["foo/bar", "baz%2Ftest", "something", "foo.segment"]);
let needle = Path::from_iter(["foo/bar", "baz%2Ftest", "something", "foo"]);
assert!(
!haystack.prefix_matches(&needle),
"{haystack:?} should not have started with {needle:?}"
);
let needle = Path::from_iter(["foo/bar", "baz%2Ftest", "something", "e"]);
assert!(
!haystack.prefix_matches(&needle),
"{haystack:?} should not have started with {needle:?}"
);
let needle = Path::from_iter(["foo/bar", "baz%2Ftest", "s"]);
assert!(
!haystack.prefix_matches(&needle),
"{haystack:?} should not have started with {needle:?}"
);
let needle = Path::from_iter(["foo/bar", "baz%2Ftest", "p"]);
assert!(
!haystack.prefix_matches(&needle),
"{haystack:?} should not have started with {needle:?}"
);
}
#[test]
fn path_containing_spaces() {
let a = Path::from_iter(["foo bar", "baz"]);
let b = Path::from("foo bar/baz");
let c = Path::parse("foo bar/baz").unwrap();
assert_eq!(a.raw, "foo bar/baz");
assert_eq!(a.raw, b.raw);
assert_eq!(b.raw, c.raw);
}
#[test]
fn from_url_path() {
let a = Path::from_url_path("foo%20bar").unwrap();
let b = Path::from_url_path("foo/%2E%2E/bar").unwrap_err();
let c = Path::from_url_path("foo%2F%252E%252E%2Fbar").unwrap();
let d = Path::from_url_path("foo/%252E%252E/bar").unwrap();
let e = Path::from_url_path("%48%45%4C%4C%4F").unwrap();
let f = Path::from_url_path("foo/%FF/as").unwrap_err();
assert_eq!(a.raw, "foo bar");
assert!(matches!(b, Error::BadSegment { .. }));
assert_eq!(c.raw, "foo/%2E%2E/bar");
assert_eq!(d.raw, "foo/%2E%2E/bar");
assert_eq!(e.raw, "HELLO");
assert!(matches!(f, Error::NonUnicode { .. }));
}
#[test]
fn filename_from_path() {
let a = Path::from("foo/bar");
let b = Path::from("foo/bar.baz");
let c = Path::from("foo.bar/baz");
assert_eq!(a.filename(), Some("bar"));
assert_eq!(b.filename(), Some("bar.baz"));
assert_eq!(c.filename(), Some("baz"));
}
#[test]
fn file_extension() {
let a = Path::from("foo/bar");
let b = Path::from("foo/bar.baz");
let c = Path::from("foo.bar/baz");
let d = Path::from("foo.bar/baz.qux");
assert_eq!(a.extension(), None);
assert_eq!(b.extension(), Some("baz"));
assert_eq!(c.extension(), None);
assert_eq!(d.extension(), Some("qux"));
}
#[test]
fn root_is_root() {
assert!(Path::ROOT.is_root());
assert!(Path::ROOT.parts().next().is_none());
}
#[test]
fn impl_extend() {
let mut p = Path::ROOT;
p.extend(&Path::ROOT);
assert_eq!(p, Path::ROOT);
p.extend(&path("foo"));
assert_eq!(p, path("foo"));
p.extend(&path("bar/baz"));
assert_eq!(p, path("foo/bar/baz"));
p.extend(&path("a/b/c"));
assert_eq!(p, path("foo/bar/baz/a/b/c"));
}
#[test]
fn impl_extend_for_one_segment() {
let mut p = Path::ROOT;
p.extend(&path("foo"));
assert_eq!(p, path("foo"));
p.extend(&path("bar"));
assert_eq!(p, path("foo/bar"));
p.extend(&path("baz"));
assert_eq!(p, path("foo/bar/baz"));
}
#[test]
fn parent() {
assert_eq!(Path::ROOT.parent(), None);
assert_eq!(path("foo").parent(), Some(Path::ROOT));
assert_eq!(path("foo/bar").parent(), Some(path("foo")));
assert_eq!(path("foo/bar/baz").parent(), Some(path("foo/bar")));
}
#[track_caller]
fn path(raw: &str) -> Path {
Path::parse(raw).unwrap()
}
}