use std::path::Path;
use std::path::PathBuf;
use std::str::FromStr;
use serde::Deserialize;
use serde::Serialize;
use thiserror::Error;
use unicode_normalization::UnicodeNormalization;
#[derive(Clone, Debug, Eq, Error, PartialEq)]
pub enum RelativePathError {
#[error("path is not encoded as UTF-8")]
NonUtf8,
#[error("path is empty")]
Empty,
#[error("path `{0}` contains a null byte")]
NullByte(String),
#[error("path `{0}` contains Windows path separators (e.g. `\\`)")]
Backslash(String),
#[error("path `{0}` cannot be absolute")]
Absolute(String),
#[error("path `{0}` resolves to empty")]
ResolvesToEmpty(String),
#[error("path `{0}` escapes the module root directory")]
EscapesRoot(String),
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
#[serde(into = "String", try_from = "String")]
pub struct RelativePath(String);
impl RelativePath {
pub fn as_str(&self) -> &str {
&self.0
}
pub fn as_path(&self) -> &Path {
Path::new(&self.0)
}
}
impl AsRef<str> for RelativePath {
fn as_ref(&self) -> &str {
&self.0
}
}
impl AsRef<Path> for RelativePath {
fn as_ref(&self) -> &Path {
Path::new(&self.0)
}
}
impl std::fmt::Display for RelativePath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self.0, f)
}
}
impl From<RelativePath> for String {
fn from(path: RelativePath) -> Self {
path.0
}
}
impl From<RelativePath> for PathBuf {
fn from(path: RelativePath) -> Self {
path.0.into()
}
}
impl TryFrom<&Path> for RelativePath {
type Error = RelativePathError;
fn try_from(path: &Path) -> Result<Self, Self::Error> {
let s = path.to_str().ok_or(RelativePathError::NonUtf8)?;
if s.is_empty() {
return Err(RelativePathError::Empty);
}
if s.contains('\0') {
return Err(RelativePathError::NullByte(s.replace('\0', "\\0")));
}
if s.contains('\\') {
return Err(RelativePathError::Backslash(s.to_string()));
}
if s.starts_with('/') || crate::starts_with_windows_drive(s) {
return Err(RelativePathError::Absolute(s.to_string()));
}
let cleaned = path_clean::clean(path)
.into_os_string()
.into_string()
.map_err(|_| RelativePathError::NonUtf8)?;
let cleaned = cleaned.replace('\\', "/");
if cleaned.is_empty() || cleaned == "." {
return Err(RelativePathError::ResolvesToEmpty(s.to_string()));
}
if cleaned == ".." || cleaned.starts_with("../") {
return Err(RelativePathError::EscapesRoot(s.to_string()));
}
Ok(Self(cleaned.nfc().collect()))
}
}
impl TryFrom<PathBuf> for RelativePath {
type Error = RelativePathError;
fn try_from(path: PathBuf) -> Result<Self, Self::Error> {
Self::try_from(path.as_path())
}
}
impl TryFrom<String> for RelativePath {
type Error = RelativePathError;
fn try_from(s: String) -> Result<Self, Self::Error> {
Self::try_from(Path::new(&s))
}
}
impl FromStr for RelativePath {
type Err = RelativePathError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::try_from(Path::new(s))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_simple_relative_path() {
let p = RelativePath::from_str("foo/bar.wdl").unwrap();
assert_eq!(p.as_str(), "foo/bar.wdl");
}
#[test]
fn cleans_dot_segments() {
let p = RelativePath::from_str("./foo/./bar.wdl").unwrap();
assert_eq!(p.as_str(), "foo/bar.wdl");
}
#[test]
fn cleans_inner_double_dot() {
let p = RelativePath::from_str("foo/../bar.wdl").unwrap();
assert_eq!(p.as_str(), "bar.wdl");
}
#[test]
fn accepts_names_that_start_with_two_dots() {
let file = RelativePath::from_str("..config").unwrap();
assert_eq!(file.as_str(), "..config");
let nested = RelativePath::from_str("..foo/bar.wdl").unwrap();
assert_eq!(nested.as_str(), "..foo/bar.wdl");
}
#[test]
fn collapses_duplicate_separators() {
let p = RelativePath::from_str("foo//bar.wdl").unwrap();
assert_eq!(p.as_str(), "foo/bar.wdl");
}
#[test]
fn nfc_normalizes_on_construction() {
let composed = RelativePath::from_str("caf\u{00E9}.wdl").unwrap();
let decomposed = RelativePath::from_str("cafe\u{0301}.wdl").unwrap();
assert_eq!(composed, decomposed);
assert_eq!(composed.as_str(), "caf\u{00E9}.wdl");
}
#[test]
fn rejects_per_path_violations() {
let err = RelativePath::from_str("").unwrap_err();
assert!(matches!(err, RelativePathError::Empty));
let err = RelativePath::from_str("has\0null").unwrap_err();
assert!(matches!(err, RelativePathError::NullByte(_)));
let err = RelativePath::from_str("a\\b").unwrap_err();
assert!(matches!(err, RelativePathError::Backslash(_)));
let err = RelativePath::from_str("/abs").unwrap_err();
assert!(matches!(err, RelativePathError::Absolute(_)));
let err = RelativePath::from_str("C:/win").unwrap_err();
assert!(matches!(err, RelativePathError::Absolute(_)));
let err = RelativePath::from_str("c:\\win").unwrap_err();
assert!(matches!(err, RelativePathError::Backslash(_)));
let err = RelativePath::from_str(".").unwrap_err();
assert!(matches!(err, RelativePathError::ResolvesToEmpty(_)));
let err = RelativePath::from_str("..").unwrap_err();
assert!(matches!(err, RelativePathError::EscapesRoot(_)));
let err = RelativePath::from_str("../escape").unwrap_err();
assert!(matches!(err, RelativePathError::EscapesRoot(_)));
let err = RelativePath::from_str("a/..").unwrap_err();
assert!(matches!(err, RelativePathError::ResolvesToEmpty(_)));
}
#[test]
fn error_includes_path() {
let err = RelativePath::from_str("/abs/path").unwrap_err();
assert!(
err.to_string().contains("/abs/path"),
"error should include the path: {err}"
);
}
#[test]
fn round_trips_via_serde() {
let p = RelativePath::from_str("foo/bar.wdl").unwrap();
let s = serde_json::to_string(&p).unwrap();
assert_eq!(s, "\"foo/bar.wdl\"");
let back: RelativePath = serde_json::from_str(&s).unwrap();
assert_eq!(back, p);
}
#[test]
fn deserialize_normalizes_input() {
let p: RelativePath = serde_json::from_str("\"./foo/./bar.wdl\"").unwrap();
assert_eq!(p.as_str(), "foo/bar.wdl");
}
#[test]
fn deserialize_rejects_invalid() {
assert!(serde_json::from_str::<RelativePath>("\"/abs\"").is_err());
}
}