use std::fmt;
use std::path::PathBuf;
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct ProjectPath(String);
impl ProjectPath {
#[must_use]
pub fn root() -> Self {
Self(String::new())
}
pub fn parse(text: &str) -> Result<Self, String> {
if text.is_empty() {
return Ok(Self::root());
}
if text.starts_with('/') {
return Err(format!(
"`{text}` is absolute; paths must be relative to the project"
));
}
let mut segments = Vec::new();
for segment in text.split('/') {
match segment {
"" => return Err(format!("`{text}` contains an empty path segment")),
"." | ".." => {
return Err(format!(
"`{text}` contains `{segment}`; paths must be normalized"
));
}
_ => {
check_segment(segment).map_err(|error| format!("`{text}`: {error}"))?;
segments.push(segment);
}
}
}
Ok(Self(segments.join("/")))
}
pub fn resolve_relative(base_dir: &Self, target: &str) -> Result<Self, String> {
let mut segments: Vec<&str> = if base_dir.0.is_empty() {
Vec::new()
} else {
base_dir.0.split('/').collect()
};
if target.starts_with('/') {
return Err(format!("`{target}` is absolute; links must be relative"));
}
for segment in target.split('/') {
match segment {
"" | "." => {}
".." => {
if segments.pop().is_none() {
return Err(format!("`{target}` leaves the project"));
}
}
other => {
check_segment(other).map_err(|error| format!("`{target}`: {error}"))?;
segments.push(other);
}
}
}
Ok(Self(segments.join("/")))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn parent(&self) -> Self {
match self.0.rfind('/') {
Some(index) => Self(self.0[..index].to_owned()),
None => Self::root(),
}
}
#[must_use]
pub fn file_name(&self) -> &str {
self.0.rsplit('/').next().unwrap_or("")
}
#[must_use]
pub fn extension(&self) -> Option<&str> {
let name = self.file_name();
name.rfind('.')
.filter(|index| *index > 0)
.map(|index| &name[index + 1..])
}
#[must_use]
pub fn join(&self, tail: &Self) -> Self {
match (self.0.is_empty(), tail.0.is_empty()) {
(true, _) => tail.clone(),
(_, true) => self.clone(),
_ => Self(format!("{}/{}", self.0, tail.0)),
}
}
#[must_use]
pub fn is_within(&self, ancestor: &Self) -> bool {
ancestor.0.is_empty()
|| self.0 == ancestor.0
|| self
.0
.strip_prefix(&ancestor.0)
.is_some_and(|rest| rest.starts_with('/'))
}
#[must_use]
pub fn strip_prefix(&self, ancestor: &Self) -> Option<Self> {
if ancestor.0.is_empty() {
return Some(self.clone());
}
if self.0 == ancestor.0 {
return Some(Self::root());
}
self.0
.strip_prefix(&ancestor.0)
.and_then(|rest| rest.strip_prefix('/'))
.map(|rest| Self(rest.to_owned()))
}
#[must_use]
pub fn to_native(&self) -> PathBuf {
if self.0.is_empty() {
PathBuf::from(".")
} else {
self.0.split('/').collect()
}
}
#[must_use]
pub fn ancestors(&self) -> Vec<Self> {
let mut result = Vec::new();
let mut current = String::new();
for segment in self.0.split('/').filter(|segment| !segment.is_empty()) {
if !current.is_empty() {
current.push('/');
}
current.push_str(segment);
result.push(Self(current.clone()));
}
result
}
#[must_use]
pub fn fold_key(&self) -> String {
self.0.to_lowercase()
}
}
fn check_segment(segment: &str) -> Result<(), String> {
if segment.contains('\\') {
return Err("backslashes are not path separators; use `/`".to_owned());
}
if segment.chars().any(char::is_control) {
return Err("control characters are not allowed in paths".to_owned());
}
if segment.contains(':') {
return Err(
"`:` is not allowed in paths; drive letters and streams are not portable".to_owned(),
);
}
Ok(())
}
impl fmt::Display for ProjectPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.0.is_empty() {
f.write_str(".")
} else {
f.write_str(&self.0)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_forward_slash_paths_only() {
assert_eq!(ProjectPath::parse("a/b/c.md").unwrap().as_str(), "a/b/c.md");
assert_eq!(ProjectPath::parse("").unwrap(), ProjectPath::root());
assert!(
ProjectPath::parse("a\\b/c.md")
.unwrap_err()
.contains("backslash")
);
}
#[test]
fn rejects_escapes_and_absolutes() {
assert!(ProjectPath::parse("/etc/passwd").is_err());
assert!(ProjectPath::parse("a/../b").is_err());
assert!(ProjectPath::parse("./a").is_err());
assert!(ProjectPath::parse("a//b").is_err());
assert!(ProjectPath::parse("C:\\x").is_err());
assert!(ProjectPath::parse("a\u{0}b").is_err());
}
#[test]
fn resolves_relative_links_inside_the_project() {
let base = ProjectPath::parse("docs/guides").unwrap();
assert_eq!(
ProjectPath::resolve_relative(&base, "../reference/x.md")
.unwrap()
.as_str(),
"docs/reference/x.md"
);
assert_eq!(
ProjectPath::resolve_relative(&base, "./y.md")
.unwrap()
.as_str(),
"docs/guides/y.md"
);
assert!(ProjectPath::resolve_relative(&base, "../../../x").is_err());
assert!(ProjectPath::resolve_relative(&base, "/x").is_err());
}
#[test]
fn prefix_relations() {
let root = ProjectPath::parse("generated").unwrap();
assert!(
ProjectPath::parse("generated/a.md")
.unwrap()
.is_within(&root)
);
assert!(
!ProjectPath::parse("generated-x/a.md")
.unwrap()
.is_within(&root)
);
assert!(ProjectPath::parse("generated").unwrap().is_within(&root));
assert_eq!(
ProjectPath::parse("generated/a/b.md")
.unwrap()
.strip_prefix(&root)
.unwrap()
.as_str(),
"a/b.md"
);
assert_eq!(
ProjectPath::parse("a/b.c.md").unwrap().extension(),
Some("md")
);
assert_eq!(ProjectPath::parse("a/b").unwrap().parent().as_str(), "a");
}
}