use std::fmt;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PathError {
#[error("path must not be empty")]
Empty,
#[error("path must be relative to the workspace root, got `{0}`")]
NotRelative(String),
#[error("path must not escape the workspace with `..`, got `{0}`")]
Escapes(String),
}
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct RelPath(String);
impl RelPath {
pub fn new(raw: &str) -> Result<Self, PathError> {
let unified = raw.replace('\\', "/");
let trimmed = unified.trim();
if trimmed.is_empty() {
return Err(PathError::Empty);
}
if trimmed.starts_with('/') {
return Err(PathError::NotRelative(raw.to_string()));
}
let mut chars = trimmed.chars();
if let (Some(first), Some(second)) = (chars.next(), chars.next())
&& first.is_ascii_alphabetic()
&& second == ':'
{
return Err(PathError::NotRelative(raw.to_string()));
}
let mut segments = Vec::new();
for segment in trimmed.split('/') {
match segment {
"" | "." => {}
".." => return Err(PathError::Escapes(raw.to_string())),
other => segments.push(other),
}
}
if segments.is_empty() {
return Err(PathError::Empty);
}
Ok(Self(segments.join("/")))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn segments(&self) -> impl Iterator<Item = &str> {
self.0.split('/')
}
pub fn parent(&self) -> Option<RelPath> {
self.0
.rsplit_once('/')
.map(|(head, _)| Self(head.to_string()))
}
pub fn file_name(&self) -> &str {
self.0
.rsplit_once('/')
.map_or(self.0.as_str(), |(_, tail)| tail)
}
pub fn join(&self, segment: &str) -> Result<Self, PathError> {
Self::new(&format!("{}/{}", self.0, segment))
}
pub fn relative_to_dir(&self, dir: Option<&RelPath>) -> String {
let from: Vec<&str> = dir.map(|d| d.segments().collect()).unwrap_or_default();
let to: Vec<&str> = self.segments().collect();
let shared = from.iter().zip(&to).take_while(|(a, b)| a == b).count();
let mut parts: Vec<&str> = vec![".."; from.len() - shared];
parts.extend_from_slice(&to[shared..]);
if parts.is_empty() {
".".to_string()
} else {
parts.join("/")
}
}
pub fn starts_with(&self, other: &RelPath) -> bool {
self.0 == other.0
|| (self.0.starts_with(other.as_str())
&& self.0.as_bytes().get(other.as_str().len()) == Some(&b'/'))
}
}
impl fmt::Display for RelPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl fmt::Debug for RelPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "RelPath({:?})", self.0)
}
}
impl TryFrom<String> for RelPath {
type Error = PathError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(&value)
}
}
impl From<RelPath> for String {
fn from(value: RelPath) -> Self {
value.0
}
}
impl std::str::FromStr for RelPath {
type Err = PathError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rel(s: &str) -> RelPath {
RelPath::new(s).expect("valid path")
}
#[test]
fn normalises_separators_and_redundant_segments() {
assert_eq!(rel(".claude\\skills").as_str(), ".claude/skills");
assert_eq!(rel("./.agents//skills/").as_str(), ".agents/skills");
assert_eq!(rel("AGENTS.md").as_str(), "AGENTS.md");
}
#[test]
fn rejects_paths_that_could_escape_the_workspace() {
assert_eq!(RelPath::new(""), Err(PathError::Empty));
assert_eq!(RelPath::new(" "), Err(PathError::Empty));
assert!(matches!(
RelPath::new("/etc/passwd"),
Err(PathError::NotRelative(_))
));
assert!(matches!(
RelPath::new("C:\\Windows"),
Err(PathError::NotRelative(_))
));
assert!(matches!(
RelPath::new("\\\\host\\share"),
Err(PathError::NotRelative(_))
));
assert!(matches!(
RelPath::new("../../.ssh/id_rsa"),
Err(PathError::Escapes(_))
));
assert!(matches!(
RelPath::new(".claude/../../etc"),
Err(PathError::Escapes(_))
));
}
#[test]
fn parent_and_file_name() {
assert_eq!(rel(".claude/skills").parent(), Some(rel(".claude")));
assert_eq!(rel("CLAUDE.md").parent(), None);
assert_eq!(rel(".claude/skills").file_name(), "skills");
assert_eq!(rel("CLAUDE.md").file_name(), "CLAUDE.md");
}
#[test]
fn relative_to_dir_produces_portable_link_targets() {
assert_eq!(
rel(".agents/skills").relative_to_dir(Some(&rel(".claude"))),
"../.agents/skills"
);
assert_eq!(rel("AGENTS.md").relative_to_dir(None), "AGENTS.md");
assert_eq!(
rel(".agents/skills").relative_to_dir(Some(&rel("a/b/c"))),
"../../../.agents/skills"
);
assert_eq!(
rel(".agents/skills").relative_to_dir(Some(&rel(".agents"))),
"skills"
);
}
#[test]
fn starts_with_respects_segment_boundaries() {
assert!(rel(".agents/skills").starts_with(&rel(".agents")));
assert!(rel(".agents").starts_with(&rel(".agents")));
assert!(!rel(".agentsfoo/x").starts_with(&rel(".agents")));
}
}