use std::fmt;
use std::path::{Component, Path};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct SourcePath(String);
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PathError {
#[error("path `{0}` must be relative to the codebase root")]
Absolute(String),
#[error("path `{0}` escapes the codebase root")]
Escapes(String),
#[error("path is empty")]
Empty,
}
impl SourcePath {
pub fn new(raw: impl AsRef<str>) -> Result<Self, PathError> {
let raw = raw.as_ref();
let unified = raw.replace('\\', "/");
if unified.starts_with('/') || unified.as_bytes().get(1) == Some(&b':') {
return Err(PathError::Absolute(raw.to_owned()));
}
let mut parts: Vec<&str> = Vec::new();
for part in unified.split('/') {
match part {
"" | "." => {}
".." => {
if parts.pop().is_none() {
return Err(PathError::Escapes(raw.to_owned()));
}
}
p => parts.push(p),
}
}
if parts.is_empty() {
return Err(PathError::Empty);
}
Ok(Self(parts.join("/")))
}
pub fn relative_to(path: &Path, root: &Path) -> Result<Self, PathError> {
let rel = path.strip_prefix(root).unwrap_or(path);
let joined: Vec<String> = rel
.components()
.filter_map(|c| match c {
Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
Component::ParentDir => Some("..".to_owned()),
_ => None,
})
.collect();
Self::new(joined.join("/"))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn file_name(&self) -> &str {
self.0.rsplit('/').next().unwrap_or(&self.0)
}
pub fn extension(&self) -> Option<&str> {
let name = self.file_name();
name.rfind('.').filter(|&i| i > 0).map(|i| &name[i + 1..])
}
pub fn components(&self) -> impl Iterator<Item = &str> {
self.0.split('/')
}
pub fn with_suffix(&self, suffix: &str) -> Self {
Self(format!("{}{}", self.0, suffix))
}
}
impl fmt::Display for SourcePath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl TryFrom<String> for SourcePath {
type Error = PathError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<SourcePath> for String {
fn from(value: SourcePath) -> Self {
value.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_drive_letters_and_empty() {
assert!(matches!(SourcePath::new("C:\\x.rs"), Err(PathError::Absolute(_))));
assert_eq!(SourcePath::new("./"), Err(PathError::Empty));
}
#[test]
fn extension_and_suffix() {
let p = SourcePath::new("a/b.rs").unwrap();
assert_eq!(p.extension(), Some("rs"));
assert_eq!(p.with_suffix(".mmd").as_str(), "a/b.rs.mmd");
assert_eq!(SourcePath::new(".gitignore").unwrap().extension(), None);
}
}