#![allow(
clippy::redundant_closure_for_method_calls,
clippy::semicolon_if_nothing_returned
)]
use std::fmt;
use crate::LixError;
type PathResult<T> = Result<T, PathError>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LixPath {
path: String,
}
impl LixPath {
pub fn try_from_file_path(path: &str) -> Result<Self, LixError> {
let Some(path) = path.strip_prefix('/') else {
return Err(PathError::MissingLeadingSlash.into_lix_error());
};
if path.is_empty() {
return Err(PathError::InvalidRootUsage.into_lix_error());
}
if path.ends_with('/') {
return Err(PathError::UnexpectedTrailingSlash.into_lix_error());
}
path.split('/')
.try_for_each(validate_segment)
.map_err(PathError::into_lix_error)?;
Ok(Self {
path: format!("/{path}"),
})
}
pub fn try_from_directory_path(path: &str) -> Result<Self, LixError> {
let Some(path) = path.strip_prefix('/') else {
return Err(PathError::MissingLeadingSlash.into_lix_error());
};
if !path.is_empty() {
if path.ends_with('/') {
return Err(PathError::UnexpectedTrailingSlash.into_lix_error());
}
path.split('/')
.try_for_each(validate_segment)
.map_err(PathError::into_lix_error)?;
}
Ok(Self {
path: format!("/{path}"),
})
}
pub fn as_str(&self) -> &str {
&self.path
}
pub fn segments(&self) -> impl Iterator<Item = &str> {
let segments = self.path.strip_prefix('/').unwrap_or(&self.path);
(!segments.is_empty())
.then_some(segments)
.into_iter()
.flat_map(|segments| segments.split('/'))
}
}
impl AsRef<str> for LixPath {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for LixPath {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
fn validate_segment(segment: &str) -> PathResult<()> {
if segment.is_empty() {
return Err(PathError::EmptySegment);
}
if segment == "." || segment == ".." {
return Err(PathError::DotSegment);
}
if segment.contains('\0') {
return Err(PathError::Nul);
}
if segment.contains('/') {
return Err(PathError::SlashInSegment);
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PathError {
MissingLeadingSlash,
UnexpectedTrailingSlash,
EmptySegment,
DotSegment,
Nul,
SlashInSegment,
InvalidRootUsage,
InvalidDirectoryParentPath,
}
impl PathError {
fn into_lix_error(self) -> LixError {
let (code, message, hint) = match self {
Self::MissingLeadingSlash => (
"LIX_ERROR_PATH_MISSING_LEADING_SLASH",
"path must start with '/'",
Some("prefix the path with '/'"),
),
Self::UnexpectedTrailingSlash => (
"LIX_ERROR_PATH_UNEXPECTED_TRAILING_SLASH",
"non-root path must not end with '/'",
Some("remove the trailing slash"),
),
Self::EmptySegment => (
"LIX_ERROR_PATH_EMPTY_SEGMENT",
"path must not contain empty segments",
Some("remove duplicate slashes like '//'"),
),
Self::DotSegment => (
"LIX_ERROR_PATH_DOT_SEGMENT",
"path segment cannot be '.' or '..'",
Some("use a real segment name instead of '.' or '..'"),
),
Self::Nul => (
"LIX_ERROR_PATH_NUL",
"path segment cannot contain NUL",
Some("remove the NUL character from the path segment"),
),
Self::SlashInSegment => (
"LIX_ERROR_PATH_SLASH_IN_SEGMENT",
"path segment must not contain '/'",
Some("pass a single segment name, not a full path"),
),
Self::InvalidRootUsage => (
"LIX_ERROR_PATH_INVALID_ROOT_USAGE",
"root '/' is only valid as a directory path",
Some("use '/' as a directory path, never as a file path"),
),
Self::InvalidDirectoryParentPath => (
"LIX_ERROR_PATH_INVALID_DIRECTORY_PARENT",
"directory parent path must be absolute and must not end with '/'",
Some("pass '/' or a canonical directory path without a trailing slash"),
),
};
let err = LixError::new(code, message);
match hint {
Some(hint) => err.with_hint(hint),
None => err,
}
}
}
fn renderable_segment_text(segment: &str) -> PathResult<&str> {
validate_segment(segment)?;
Ok(segment)
}
pub fn validate_lix_path_segment(segment: &str) -> Result<(), LixError> {
renderable_segment_text(segment)
.map(|_| ())
.map_err(PathError::into_lix_error)
}
pub(crate) fn compose_file_path(
directory_path: Option<&str>,
name: &str,
) -> Result<String, LixError> {
let name_text = renderable_segment_text(name).map_err(PathError::into_lix_error)?;
let parent_path = directory_path.unwrap_or("/");
if parent_path == "/" {
Ok(format!("/{name_text}"))
} else if parent_path.starts_with('/') && !parent_path.ends_with('/') {
Ok(format!("{parent_path}/{name_text}"))
} else {
Err(PathError::InvalidDirectoryParentPath.into_lix_error())
}
}
pub(crate) fn compose_directory_path(
parent_path: Option<&str>,
name: &str,
) -> Result<String, LixError> {
let name_text = renderable_segment_text(name).map_err(PathError::into_lix_error)?;
let parent_path = parent_path.unwrap_or("/");
if parent_path == "/" {
Ok(format!("/{name_text}"))
} else if parent_path.starts_with('/') && !parent_path.ends_with('/') {
Ok(format!("{parent_path}/{name_text}"))
} else {
Err(PathError::InvalidDirectoryParentPath.into_lix_error())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn logical_paths_preserve_literal_utf8_segments() {
let path = LixPath::try_from_file_path("/docs/100% @2x/日本語.txt").unwrap();
assert_eq!(
path.segments().collect::<Vec<_>>(),
vec!["docs", "100% @2x", "日本語.txt"]
);
assert_eq!(path.as_str(), "/docs/100% @2x/日本語.txt");
}
#[test]
fn logical_paths_do_not_normalize_unicode() {
let composed = LixPath::try_from_file_path("/caf\u{e9}.txt").unwrap();
let decomposed = LixPath::try_from_file_path("/cafe\u{301}.txt").unwrap();
assert_ne!(composed, decomposed);
}
#[test]
fn logical_paths_reject_structural_segments_and_nul() {
for path in ["relative", "/", "/a/", "/a//b", "/a/./b", "/a/../b"] {
assert!(LixPath::try_from_file_path(path).is_err(), "{path:?}");
}
let error = LixPath::try_from_file_path("/nul\0name").unwrap_err();
assert_eq!(error.code, "LIX_ERROR_PATH_NUL");
}
}