Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 110 additions & 18 deletions src/path/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@

//! Path abstraction for Object Storage

use itertools::Itertools;
use percent_encoding::percent_decode;
use std::fmt::Formatter;
#[cfg(not(target_arch = "wasm32"))]
Expand All @@ -29,9 +28,12 @@ pub const DELIMITER: &str = "/";
/// The path delimiter as a single byte
pub const DELIMITER_BYTE: u8 = DELIMITER.as_bytes()[0];

/// The path delimiter as a single char
pub const DELIMITER_CHAR: char = DELIMITER_BYTE as char;

mod parts;

pub use parts::{InvalidPart, PathPart};
pub use parts::{InvalidPart, PathPart, PathParts};

/// Error returned by [`Path::parse`]
#[derive(Debug, thiserror::Error)]
Expand Down Expand Up @@ -157,6 +159,18 @@ pub struct Path {
}

impl Path {
/// An empty [`Path`] that points to the root of the store, equivalent to `Path::from("/")`.
///
/// See also [`Path::is_root`].
///
/// # Example
///
/// ```
/// # use object_store::path::Path;
/// assert_eq!(Path::ROOT, Path::from("/"));
/// ```
pub const ROOT: Self = Self { raw: String::new() };

/// Parse a string as a [`Path`], returning a [`Error`] if invalid,
/// as defined on the docstring for [`Path`]
///
Expand Down Expand Up @@ -255,14 +269,53 @@ impl Path {
Self::parse(decoded)
}

/// Returns the [`PathPart`] of this [`Path`]
pub fn parts(&self) -> impl Iterator<Item = PathPart<'_>> {
self.raw
.split_terminator(DELIMITER)
.map(|s| PathPart { raw: s.into() })
/// Returns the number of [`PathPart`]s in this [`Path`]
///
/// This is equivalent to calling `.parts().count()` manually.
///
/// # Performance
///
/// This operation is `O(n)`.
#[doc(alias = "len")]
pub fn parts_count(&self) -> usize {
self.raw.split_terminator(DELIMITER).count()
}

/// True if this [`Path`] points to the root of the store, equivalent to `Path::from("/")`.
///
/// See also [`Path::root`].
///
/// # Example
///
/// ```
/// # use object_store::path::Path;
/// assert!(Path::from("/").is_root());
/// assert!(Path::parse("").unwrap().is_root());
/// ```
pub fn is_root(&self) -> bool {
self.raw.is_empty()
}

/// Returns the [`PathPart`]s of this [`Path`]
pub fn parts(&self) -> PathParts<'_> {
PathParts::new(&self.raw)
}

/// Returns a copy of this [`Path`] with the last path segment removed
///
/// Returns `None` if this path has zero segments.
#[doc(alias = "folder")]
pub fn prefix(&self) -> Option<Self> {
let (prefix, _filename) = self.raw.rsplit_once(DELIMITER)?;

Some(Self {
raw: prefix.to_string(),
})
}

/// Returns the last path segment containing the filename stored in this [`Path`]
///
/// Returns `None` only if this path is the root path.
pub fn filename(&self) -> Option<&str> {
match self.raw.is_empty() {
true => None,
Expand All @@ -285,16 +338,13 @@ impl Path {

/// Returns an iterator of the [`PathPart`] of this [`Path`] after `prefix`
///
/// Returns `None` if the prefix does not match
/// Returns `None` if the prefix does not match.
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)?;
}
let iter = stripped
.split_terminator(DELIMITER)
.map(|x| PathPart { raw: x.into() });
Some(iter)
Some(PathParts::new(stripped))
}

/// Returns true if this [`Path`] starts with `prefix`
Expand Down Expand Up @@ -348,13 +398,22 @@ where
I: Into<PathPart<'a>>,
{
fn from_iter<T: IntoIterator<Item = I>>(iter: T) -> Self {
let raw = T::into_iter(iter)
.map(|s| s.into())
.filter(|s| !s.raw.is_empty())
.map(|s| s.raw)
.join(DELIMITER);
let mut this = Self::ROOT;
this.extend(iter);
this
}
}

Self { raw }
impl<'a, I: Into<PathPart<'a>>> Extend<I> for Path {
fn extend<T: IntoIterator<Item = I>>(&mut self, iter: T) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you please add tests (perhaps doctests) using this feature explicitly to help others find it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, note that I haven't added any docs on Path directly. Since this is a standard trait, I expect people to find it by looking at the list of implemented traits 🤔

for s in iter.into_iter() {
let s = s.into();
if s.raw.is_empty() {
continue;
}
self.raw.push(DELIMITER_CHAR);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add some tests showing how this works for:

  1. Empty iterators
  2. Iterator with a single element
  3. Iterator with multiple elements
  4. Extending an existing (non empty) Path? (it looks like maybe this will add an extra / 🤔 )

self.raw.push_str(&s.raw);
}
}
}

Expand All @@ -370,6 +429,11 @@ pub(crate) fn absolute_path_to_url(path: impl AsRef<std::path::Path>) -> Result<
mod tests {
use super::*;

#[test]
fn delimiter_char_is_forward_slash() {
assert_eq!(DELIMITER_CHAR, '/');
}

#[test]
fn cloud_prefix_with_trailing_delimiter() {
// Use case: files exist in object storage named `foo/bar.json` and
Expand Down Expand Up @@ -469,6 +533,23 @@ mod tests {
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(), path.parts_count());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you also please add an assertion that the count is 3?

}

#[test]
fn prefix_matches_raw_content() {
assert_eq!(Path::ROOT.prefix(), None, "empty path must have no prefix");

assert_eq!(path("foo").prefix().unwrap(), Path::ROOT);
assert_eq!(path("foo/bar").prefix().unwrap(), path("foo"));
assert_eq!(path("foo/bar/baz").prefix().unwrap(), path("foo/bar"));
}

#[test]
fn prefix_matches() {
let haystack = Path::from_iter(["foo/bar", "baz%2Ftest", "something"]);
Expand Down Expand Up @@ -611,4 +692,15 @@ mod tests {
assert_eq!(c.extension(), None);
assert_eq!(d.extension(), Some("qux"));
}

#[test]
fn root_is_root() {
assert!(Path::ROOT.is_root());
}

/// Construct a [`Path`] from a raw `&str`, or panic trying.
#[track_caller]
fn path(raw: &str) -> Path {
Path::parse(raw).unwrap()
}
}
36 changes: 35 additions & 1 deletion src/path/parts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@
// under the License.

use percent_encoding::{AsciiSet, CONTROLS, percent_encode};
use std::borrow::Cow;
use std::{
borrow::Cow,
iter::{self, FusedIterator},
str::SplitTerminator,
};

use crate::path::DELIMITER_BYTE;

Expand Down Expand Up @@ -131,6 +135,36 @@ impl AsRef<str> for PathPart<'_> {
}
}

/// See [`Path::parts`](super::Path::parts)
#[derive(Debug, Clone)]
pub struct PathParts<'a>(iter::Map<SplitTerminator<'a, char>, fn(&str) -> PathPart<'_>>);

impl<'a> PathParts<'a> {
/// Create an iterator over the parts of the provided raw [`Path`].
pub(super) fn new(raw: &'a str) -> Self {
Self(
raw.split_terminator(super::DELIMITER_CHAR)
.map(|s| PathPart { raw: s.into() }),
)
}
}

impl<'a> Iterator for PathParts<'a> {
type Item = PathPart<'a>;

fn next(&mut self) -> Option<Self::Item> {
self.0.next()
}
}

impl<'a> FusedIterator for PathParts<'a> {}

impl<'a> DoubleEndedIterator for PathParts<'a> {
fn next_back(&mut self) -> Option<Self::Item> {
self.0.next_back()
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Loading