diff --git a/crates/turbopath/Cargo.toml b/crates/turbopath/Cargo.toml new file mode 100644 index 0000000000000..b6fb4d764961d --- /dev/null +++ b/crates/turbopath/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "turbopath" +version = "0.1.0" +license = "MPL-2.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +path-slash = "0.2.1" +# TODO: Make this a crate feature +serde = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/turbopath/src/absolute_system_path_buf.rs b/crates/turbopath/src/absolute_system_path_buf.rs new file mode 100644 index 0000000000000..5c0194446d09b --- /dev/null +++ b/crates/turbopath/src/absolute_system_path_buf.rs @@ -0,0 +1,225 @@ +use std::{ + borrow::Cow, + ffi::OsStr, + fmt, + path::{Components, Path, PathBuf}, +}; + +use serde::Serialize; + +use crate::{AnchoredSystemPathBuf, IntoSystem, PathValidationError, RelativeSystemPathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize)] +pub struct AbsoluteSystemPathBuf(PathBuf); + +impl AbsoluteSystemPathBuf { + /// Create a new AbsoluteSystemPathBuf from `unchecked_path`. + /// Confirms that `unchecked_path` is absolute and converts it to a system + /// path. + /// + /// # Arguments + /// + /// * `unchecked_path`: The path to be validated and converted to an + /// `AbsoluteSystemPathBuf`. + /// + /// returns: Result + /// + /// # Examples + /// + /// ``` + /// use std::path::{Path, PathBuf}; + /// use turbopath::AbsoluteSystemPathBuf; + /// #[cfg(windows)] + /// let path = PathBuf::from("C:/Users/user"); + /// #[cfg(not(windows))] + /// let path = PathBuf::from("/Users/user"); + /// + /// let absolute_path = AbsoluteSystemPathBuf::new(path).unwrap(); + /// + /// #[cfg(windows)] + /// assert_eq!(absolute_path.as_path(), Path::new("C:\\Users\\user")); + /// #[cfg(not(windows))] + /// assert_eq!(absolute_path.as_path(), Path::new("/Users/user")); + /// ``` + pub fn new(unchecked_path: impl Into) -> Result { + let unchecked_path = unchecked_path.into(); + if !unchecked_path.is_absolute() { + return Err(PathValidationError::NotAbsolute(unchecked_path)); + } + + let system_path = unchecked_path.into_system()?; + Ok(AbsoluteSystemPathBuf(system_path)) + } + + /// Anchors `path` at `self`. + /// + /// # Arguments + /// + /// * `path`: The path to be anchored at `self` + /// + /// returns: Result + /// + /// # Examples + /// + /// ``` + /// use std::path::Path; + /// use turbopath::{AbsoluteSystemPathBuf, AnchoredSystemPathBuf}; + /// #[cfg(not(windows))] + /// { + /// let base = AbsoluteSystemPathBuf::new("/Users/user").unwrap(); + /// let anchored_path = AbsoluteSystemPathBuf::new("/Users/user/Documents").unwrap(); + /// let anchored_path = base.anchor(&anchored_path).unwrap(); + /// assert_eq!(anchored_path.as_path(), Path::new("Documents")); + /// } + /// + /// #[cfg(windows)] + /// { + /// let base = AbsoluteSystemPathBuf::new("C:\\Users\\user").unwrap(); + /// let anchored_path = AbsoluteSystemPathBuf::new("C:\\Users\\user\\Documents").unwrap(); + /// let anchored_path = base.anchor(&anchored_path).unwrap(); + /// assert_eq!(anchored_path.as_path(), Path::new("Documents")); + /// } + /// ``` + pub fn anchor( + &self, + path: &AbsoluteSystemPathBuf, + ) -> Result { + AnchoredSystemPathBuf::new(self, path) + } + + /// Resolves `path` with `self` as anchor. + /// + /// # Arguments + /// + /// * `path`: The path to be anchored at `self` + /// + /// returns: AbsoluteSystemPathBuf + /// + /// # Examples + /// + /// ``` + /// use std::path::Path; + /// use turbopath::{AbsoluteSystemPathBuf, AnchoredSystemPathBuf}; + /// #[cfg(not(windows))] + /// let absolute_path = AbsoluteSystemPathBuf::new("/Users/user").unwrap(); + /// #[cfg(windows)] + /// let absolute_path = AbsoluteSystemPathBuf::new("C:\\Users\\user").unwrap(); + /// + /// let anchored_path = Path::new("Documents").try_into().unwrap(); + /// let resolved_path = absolute_path.resolve(&anchored_path); + /// + /// #[cfg(not(windows))] + /// assert_eq!(resolved_path.as_path(), Path::new("/Users/user/Documents")); + /// #[cfg(windows)] + /// assert_eq!(resolved_path.as_path(), Path::new("C:\\Users\\user\\Documents")); + /// ``` + pub fn resolve(&self, path: &AnchoredSystemPathBuf) -> AbsoluteSystemPathBuf { + AbsoluteSystemPathBuf(self.0.join(path.as_path())) + } + + pub fn as_path(&self) -> &Path { + self.0.as_path() + } + + pub fn components(&self) -> Components<'_> { + self.0.components() + } + + pub fn parent(&self) -> Option { + self.0 + .parent() + .map(|p| AbsoluteSystemPathBuf(p.to_path_buf())) + } + + pub fn starts_with>(&self, base: P) -> bool { + self.0.starts_with(base.as_ref()) + } + + pub fn ends_with>(&self, child: P) -> bool { + self.0.ends_with(child.as_ref()) + } + + pub fn join_relative(&self, path: RelativeSystemPathBuf) -> AbsoluteSystemPathBuf { + AbsoluteSystemPathBuf(self.0.join(path.as_path())) + } + + pub fn to_str(&self) -> Result<&str, PathValidationError> { + self.0 + .to_str() + .ok_or_else(|| PathValidationError::InvalidUnicode(self.0.clone())) + } + + pub fn to_string_lossy(&self) -> Cow<'_, str> { + self.0.to_string_lossy() + } + + pub fn file_name(&self) -> Option<&OsStr> { + self.0.file_name() + } + + pub fn exists(&self) -> bool { + self.0.exists() + } + + pub fn extension(&self) -> Option<&OsStr> { + self.0.extension() + } +} + +impl Into for AbsoluteSystemPathBuf { + fn into(self) -> PathBuf { + self.0 + } +} + +impl fmt::Display for AbsoluteSystemPathBuf { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.display().fmt(f) + } +} + +impl AsRef for AbsoluteSystemPathBuf { + fn as_ref(&self) -> &Path { + self.0.as_path() + } +} + +#[cfg(test)] +mod tests { + use std::assert_matches::assert_matches; + + use crate::{AbsoluteSystemPathBuf, PathValidationError}; + + #[cfg(not(windows))] + #[test] + fn test_absolute_system_path_buf_on_unix() { + assert!(AbsoluteSystemPathBuf::new("/Users/user").is_ok()); + assert_matches!( + AbsoluteSystemPathBuf::new("./Users/user/"), + Err(PathValidationError::NotAbsolute(_)) + ); + + assert_matches!( + AbsoluteSystemPathBuf::new("Users"), + Err(PathValidationError::NotAbsolute(_)) + ); + } + + #[cfg(windows)] + #[test] + fn test_absolute_system_path_buf_on_windows() { + assert!(AbsoluteSystemPathBuf::new("C:\\Users\\user").is_ok()); + assert_matches!( + AbsoluteSystemPathBuf::new(".\\Users\\user\\"), + Err(PathValidationError::NotAbsolute(_)) + ); + assert_matches!( + AbsoluteSystemPathBuf::new("Users"), + Err(PathValidationError::NotAbsolute(_)) + ); + assert_matches!( + AbsoluteSystemPathBuf::new("/Users/home"), + Err(PathValidationError::NotAbsolute(_)) + ) + } +} diff --git a/crates/turbopath/src/anchored_system_path_buf.rs b/crates/turbopath/src/anchored_system_path_buf.rs new file mode 100644 index 0000000000000..6b9e67aed986d --- /dev/null +++ b/crates/turbopath/src/anchored_system_path_buf.rs @@ -0,0 +1,51 @@ +use std::path::{Path, PathBuf}; + +use serde::Serialize; + +use crate::{AbsoluteSystemPathBuf, IntoSystem, PathValidationError}; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize)] +pub struct AnchoredSystemPathBuf(PathBuf); + +impl TryFrom<&Path> for AnchoredSystemPathBuf { + type Error = PathValidationError; + + fn try_from(path: &Path) -> Result { + if path.is_absolute() { + return Err(PathValidationError::NotRelative(path.to_path_buf())); + } + + Ok(AnchoredSystemPathBuf(path.into_system()?)) + } +} + +impl AnchoredSystemPathBuf { + pub fn new( + root: &AbsoluteSystemPathBuf, + path: &AbsoluteSystemPathBuf, + ) -> Result { + let stripped_path = path + .as_path() + .strip_prefix(root.as_path()) + .map_err(|_| PathValidationError::NotParent(root.to_string(), path.to_string()))? + .to_path_buf(); + + Ok(AnchoredSystemPathBuf(stripped_path)) + } + + pub fn as_path(&self) -> &Path { + self.0.as_path() + } + + pub fn to_str(&self) -> Result<&str, PathValidationError> { + self.0 + .to_str() + .ok_or_else(|| PathValidationError::InvalidUnicode(self.0.clone())) + } +} + +impl Into for AnchoredSystemPathBuf { + fn into(self) -> PathBuf { + self.0 + } +} diff --git a/crates/turbopath/src/lib.rs b/crates/turbopath/src/lib.rs new file mode 100644 index 0000000000000..fa0b3c902ce3b --- /dev/null +++ b/crates/turbopath/src/lib.rs @@ -0,0 +1,59 @@ +#![feature(assert_matches)] + +mod absolute_system_path_buf; +mod anchored_system_path_buf; +mod relative_system_path_buf; +mod relative_unix_path_buf; + +use std::path::{Path, PathBuf}; + +pub use absolute_system_path_buf::AbsoluteSystemPathBuf; +pub use anchored_system_path_buf::AnchoredSystemPathBuf; +use path_slash::{PathBufExt, PathExt}; +pub use relative_system_path_buf::RelativeSystemPathBuf; +pub use relative_unix_path_buf::RelativeUnixPathBuf; +use thiserror::Error; + +// Custom error type for path validation errors +#[derive(Debug, Error)] +pub enum PathValidationError { + #[error("Path is non-UTF-8: {0}")] + InvalidUnicode(PathBuf), + #[error("Path is not absolute: {0}")] + NotAbsolute(PathBuf), + #[error("Path is not relative: {0}")] + NotRelative(PathBuf), + #[error("Path {0} is not parent of {1}")] + NotParent(String, String), +} + +trait IntoSystem { + fn into_system(self) -> Result; +} + +trait IntoUnix { + fn into_unix(self) -> Result; +} + +impl IntoSystem for &Path { + fn into_system(self) -> Result { + let path_str = self + .to_str() + .ok_or_else(|| PathValidationError::InvalidUnicode(self.to_owned()))?; + + Ok(PathBuf::from_slash(path_str)) + } +} + +impl IntoUnix for &Path { + /// NOTE: `into_unix` *only* converts Windows paths to Unix paths *on* a + /// Windows system. Do not pass a Windows path on a Unix system and + /// assume it'll be converted. + fn into_unix(self) -> Result { + Ok(PathBuf::from( + self.to_slash() + .ok_or_else(|| PathValidationError::InvalidUnicode(self.to_owned()))? + .as_ref(), + )) + } +} diff --git a/crates/turbopath/src/relative_system_path_buf.rs b/crates/turbopath/src/relative_system_path_buf.rs new file mode 100644 index 0000000000000..663c18692815c --- /dev/null +++ b/crates/turbopath/src/relative_system_path_buf.rs @@ -0,0 +1,101 @@ +use std::{ + ffi::OsStr, + fmt, + path::{Components, Path, PathBuf}, +}; + +use serde::Serialize; + +use crate::{IntoSystem, PathValidationError}; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize)] +pub struct RelativeSystemPathBuf(PathBuf); + +impl RelativeSystemPathBuf { + /// Create a new RelativeSystemPathBuf from `unchecked_path`. + /// Validates that `unchecked_path` is relative and converts it to a system + /// path + /// + /// # Arguments + /// + /// * `unchecked_path`: Path to be converted to a RelativeSystemPathBuf + /// + /// returns: Result + /// + /// # Examples + /// + /// ``` + /// use std::path::{Path, PathBuf}; + /// use turbopath::RelativeSystemPathBuf; + /// let path = PathBuf::from("Users/user"); + /// let relative_path = RelativeSystemPathBuf::new(path).unwrap(); + /// #[cfg(windows)] + /// assert_eq!(relative_path.as_path(), Path::new("Users\\user")); + /// assert_eq!(relative_path.as_path(), Path::new("Users/user")); + /// ``` + pub fn new(unchecked_path: impl Into) -> Result { + let unchecked_path = unchecked_path.into(); + if unchecked_path.is_absolute() { + return Err(PathValidationError::NotRelative(unchecked_path)); + } + + let system_path = unchecked_path.into_system()?; + Ok(RelativeSystemPathBuf(system_path)) + } + + pub fn as_path(&self) -> &Path { + &self.0 + } + + pub fn components(&self) -> Components<'_> { + self.0.components() + } + + pub fn parent(&self) -> Option { + self.0 + .parent() + .map(|p| RelativeSystemPathBuf(p.to_path_buf())) + } + + pub fn starts_with>(&self, base: P) -> bool { + self.0.starts_with(base.as_ref()) + } + + pub fn ends_with>(&self, child: P) -> bool { + self.0.ends_with(child.as_ref()) + } + + pub fn join>(&self, path: P) -> RelativeSystemPathBuf { + RelativeSystemPathBuf(self.0.join(path)) + } + + pub fn to_str(&self) -> Result<&str, PathValidationError> { + self.0 + .to_str() + .ok_or_else(|| PathValidationError::InvalidUnicode(self.0.clone())) + } + + pub fn file_name(&self) -> Option<&OsStr> { + self.0.file_name() + } + + pub fn into_path_buf(self) -> PathBuf { + self.0 + } + + pub fn extension(&self) -> Option<&OsStr> { + self.0.extension() + } +} + +impl fmt::Display for RelativeSystemPathBuf { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.display().fmt(f) + } +} + +impl AsRef for RelativeSystemPathBuf { + fn as_ref(&self) -> &Path { + self.0.as_ref() + } +} diff --git a/crates/turbopath/src/relative_unix_path_buf.rs b/crates/turbopath/src/relative_unix_path_buf.rs new file mode 100644 index 0000000000000..c20505dddd4e7 --- /dev/null +++ b/crates/turbopath/src/relative_unix_path_buf.rs @@ -0,0 +1,130 @@ +use std::{ + ffi::OsStr, + path::{Components, Path, PathBuf}, +}; + +use serde::Serialize; + +use crate::{IntoUnix, PathValidationError}; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize)] +pub struct RelativeUnixPathBuf(PathBuf); + +impl RelativeUnixPathBuf { + /// Create a new RelativeUnixPathBuf from a PathBuf by calling `into_unix()` + /// + /// NOTE: `into_unix` *only* converts Windows paths to Unix paths *on* a + /// Windows system. Do not pass a Windows path on a Unix system and + /// assume it'll be converted. + /// + /// # Arguments + /// + /// * `path`: + /// + /// returns: Result + /// + /// # Examples + /// + /// ``` + /// ``` + pub fn new(path: impl Into) -> Result { + let path = path.into(); + if path.is_absolute() { + return Err(PathValidationError::NotRelative(path)); + } + + Ok(RelativeUnixPathBuf(path.into_unix()?)) + } + + pub fn as_path(&self) -> &Path { + &self.0 + } + + pub fn components(&self) -> Components<'_> { + self.0.components() + } + + pub fn parent(&self) -> Option { + self.0 + .parent() + .map(|p| RelativeUnixPathBuf(p.to_path_buf())) + } + + pub fn starts_with>(&self, base: P) -> bool { + self.0.starts_with(base.as_ref()) + } + + pub fn ends_with>(&self, child: P) -> bool { + self.0.ends_with(child.as_ref()) + } + + pub fn join>(&self, path: P) -> RelativeUnixPathBuf { + RelativeUnixPathBuf(self.0.join(path)) + } + + pub fn to_str(&self) -> Result<&str, PathValidationError> { + self.0 + .to_str() + .ok_or_else(|| PathValidationError::InvalidUnicode(self.0.clone())) + } + + pub fn file_name(&self) -> Option<&OsStr> { + self.0.file_name() + } + + pub fn extension(&self) -> Option<&OsStr> { + self.0.extension() + } + + pub fn into_path_buf(self) -> PathBuf { + self.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_relative_unix_path_buf() { + let path = RelativeUnixPathBuf::new(PathBuf::from("foo/bar")).unwrap(); + assert_eq!(path.as_path(), Path::new("foo/bar")); + assert_eq!(path.components().count(), 2); + assert_eq!(path.parent().unwrap().as_path(), Path::new("foo")); + assert!(path.starts_with("foo")); + assert!(path.ends_with("bar")); + assert_eq!(path.join("baz").as_path(), Path::new("foo/bar/baz")); + assert_eq!(path.to_str().unwrap(), "foo/bar"); + assert_eq!(path.file_name(), Some(OsStr::new("bar"))); + assert_eq!(path.extension(), None); + } + + #[test] + fn test_relative_unix_path_buf_with_extension() { + let path = RelativeUnixPathBuf::new(PathBuf::from("foo/bar.txt")).unwrap(); + assert_eq!(path.as_path(), Path::new("foo/bar.txt")); + assert_eq!(path.components().count(), 2); + assert_eq!(path.parent().unwrap().as_path(), Path::new("foo")); + assert!(path.starts_with("foo")); + assert!(path.ends_with("bar.txt")); + assert_eq!(path.join("baz").as_path(), Path::new("foo/bar.txt/baz")); + assert_eq!(path.to_str().unwrap(), "foo/bar.txt"); + assert_eq!(path.file_name(), Some(OsStr::new("bar.txt"))); + assert_eq!(path.extension(), Some(OsStr::new("txt"))); + } + + #[test] + fn test_relative_unix_path_buf_errors() { + #[cfg(not(windows))] + assert!(RelativeUnixPathBuf::new(PathBuf::from("/foo/bar")).is_err()); + #[cfg(windows)] + assert!(RelativeUnixPathBuf::new(PathBuf::from("C:\\foo\\bar")).is_err()); + } + + #[cfg(windows)] + #[test] + fn test_convert_from_windows_path() { + let path = RelativeUnixPathBuf::new(PathBuf::from("foo\\bar")).unwrap(); + assert_eq!(path.as_path(), Path::new("foo/bar")); + } +}