use std::fmt::{
Display,
Formatter,
Result as FmtResult,
};
use crate::{
FsError,
FsOperation,
FsResult,
};
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct FsPath {
absolute: bool,
normalized: String,
}
impl FsPath {
pub fn parse(path: &str) -> FsResult<Self> {
if path.is_empty() {
return Err(FsError::invalid_path(FsOperation::ParsePath, "path must not be empty"));
}
if path.contains('\0') {
return Err(FsError::invalid_path(
FsOperation::ParsePath,
"path must not contain NUL bytes",
));
}
let absolute = path.starts_with('/');
let mut components = Vec::new();
for component in path.split('/') {
match component {
"" | "." => {}
".." => {
if components.pop().is_none() {
return Err(FsError::invalid_path(
FsOperation::ParsePath,
"path must not escape above its root",
));
}
}
_ => components.push(component),
}
}
let normalized = if absolute {
if components.is_empty() {
"/".to_owned()
} else {
format!("/{}", components.join("/"))
}
} else {
components.join("/")
};
if normalized.is_empty() {
return Err(FsError::invalid_path(
FsOperation::ParsePath,
"relative path must not normalize to empty",
));
}
Ok(Self { absolute, normalized })
}
#[inline]
#[must_use]
pub fn root() -> Self {
Self {
absolute: true,
normalized: "/".to_owned(),
}
}
#[inline]
#[must_use]
pub fn is_absolute(&self) -> bool {
self.absolute
}
#[inline]
#[must_use]
pub fn as_str(&self) -> &str {
&self.normalized
}
pub fn join(&self, child: &str) -> FsResult<Self> {
let child = Self::parse(child)?;
if child.is_absolute() {
return Ok(child);
}
let joined = if self.normalized == "/" {
format!("/{}", child.as_str())
} else {
format!("{}/{}", self.normalized, child.as_str())
};
Self::parse(&joined)
}
#[must_use]
pub fn parent(&self) -> Option<Self> {
if self.normalized == "/" {
return None;
}
let trimmed = self.normalized.trim_end_matches('/');
let index = trimmed.rfind('/')?;
if index == 0 && self.absolute {
Some(Self::root())
} else if index == 0 {
None
} else {
Self::parse(&trimmed[..index]).ok()
}
}
#[must_use]
pub fn file_name(&self) -> Option<&str> {
if self.normalized == "/" {
None
} else {
self.normalized.rsplit('/').next()
}
}
#[must_use]
pub fn file_extension(&self) -> Option<&str> {
let file_name = self.file_name()?;
let index = file_name.rfind('.')?;
if index == 0 || index + 1 == file_name.len() {
None
} else {
Some(&file_name[index + 1..])
}
}
}
impl Display for FsPath {
#[inline]
fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult {
formatter.write_str(&self.normalized)
}
}