1use std::fmt;
19
20use serde::{Deserialize, Serialize};
21
22#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
24pub enum PathError {
25 #[error("path must not be empty")]
26 Empty,
27 #[error("path must be relative to the workspace root, got `{0}`")]
28 NotRelative(String),
29 #[error("path must not escape the workspace with `..`, got `{0}`")]
30 Escapes(String),
31}
32
33#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
38#[serde(try_from = "String", into = "String")]
39pub struct RelPath(String);
40
41impl RelPath {
42 pub fn new(raw: &str) -> Result<Self, PathError> {
47 let unified = raw.replace('\\', "/");
48 let trimmed = unified.trim();
49 if trimmed.is_empty() {
50 return Err(PathError::Empty);
51 }
52 if trimmed.starts_with('/') {
53 return Err(PathError::NotRelative(raw.to_string()));
54 }
55 let mut chars = trimmed.chars();
59 if let (Some(first), Some(second)) = (chars.next(), chars.next())
60 && first.is_ascii_alphabetic()
61 && second == ':'
62 {
63 return Err(PathError::NotRelative(raw.to_string()));
64 }
65
66 let mut segments = Vec::new();
67 for segment in trimmed.split('/') {
68 match segment {
69 "" | "." => {}
70 ".." => return Err(PathError::Escapes(raw.to_string())),
71 other => segments.push(other),
72 }
73 }
74 if segments.is_empty() {
75 return Err(PathError::Empty);
76 }
77 Ok(Self(segments.join("/")))
78 }
79
80 pub fn as_str(&self) -> &str {
82 &self.0
83 }
84
85 pub fn segments(&self) -> impl Iterator<Item = &str> {
87 self.0.split('/')
88 }
89
90 pub fn parent(&self) -> Option<RelPath> {
93 self.0
94 .rsplit_once('/')
95 .map(|(head, _)| Self(head.to_string()))
96 }
97
98 pub fn file_name(&self) -> &str {
100 self.0
101 .rsplit_once('/')
102 .map_or(self.0.as_str(), |(_, tail)| tail)
103 }
104
105 pub fn join(&self, segment: &str) -> Result<Self, PathError> {
107 Self::new(&format!("{}/{}", self.0, segment))
108 }
109
110 pub fn relative_to_dir(&self, dir: Option<&RelPath>) -> String {
117 let from: Vec<&str> = dir.map(|d| d.segments().collect()).unwrap_or_default();
118 let to: Vec<&str> = self.segments().collect();
119
120 let shared = from.iter().zip(&to).take_while(|(a, b)| a == b).count();
121 let mut parts: Vec<&str> = vec![".."; from.len() - shared];
122 parts.extend_from_slice(&to[shared..]);
123
124 if parts.is_empty() {
125 ".".to_string()
126 } else {
127 parts.join("/")
128 }
129 }
130
131 pub fn starts_with(&self, other: &RelPath) -> bool {
133 self.0 == other.0
134 || (self.0.starts_with(other.as_str())
135 && self.0.as_bytes().get(other.as_str().len()) == Some(&b'/'))
136 }
137}
138
139impl fmt::Display for RelPath {
140 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141 f.write_str(&self.0)
142 }
143}
144
145impl fmt::Debug for RelPath {
146 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147 write!(f, "RelPath({:?})", self.0)
148 }
149}
150
151impl TryFrom<String> for RelPath {
152 type Error = PathError;
153
154 fn try_from(value: String) -> Result<Self, Self::Error> {
155 Self::new(&value)
156 }
157}
158
159impl From<RelPath> for String {
160 fn from(value: RelPath) -> Self {
161 value.0
162 }
163}
164
165impl std::str::FromStr for RelPath {
166 type Err = PathError;
167
168 fn from_str(s: &str) -> Result<Self, Self::Err> {
169 Self::new(s)
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 fn rel(s: &str) -> RelPath {
178 RelPath::new(s).expect("valid path")
179 }
180
181 #[test]
182 fn normalises_separators_and_redundant_segments() {
183 assert_eq!(rel(".claude\\skills").as_str(), ".claude/skills");
184 assert_eq!(rel("./.agents//skills/").as_str(), ".agents/skills");
185 assert_eq!(rel("AGENTS.md").as_str(), "AGENTS.md");
186 }
187
188 #[test]
189 fn rejects_paths_that_could_escape_the_workspace() {
190 assert_eq!(RelPath::new(""), Err(PathError::Empty));
191 assert_eq!(RelPath::new(" "), Err(PathError::Empty));
192 assert!(matches!(
193 RelPath::new("/etc/passwd"),
194 Err(PathError::NotRelative(_))
195 ));
196 assert!(matches!(
197 RelPath::new("C:\\Windows"),
198 Err(PathError::NotRelative(_))
199 ));
200 assert!(matches!(
201 RelPath::new("\\\\host\\share"),
202 Err(PathError::NotRelative(_))
203 ));
204 assert!(matches!(
205 RelPath::new("../../.ssh/id_rsa"),
206 Err(PathError::Escapes(_))
207 ));
208 assert!(matches!(
209 RelPath::new(".claude/../../etc"),
210 Err(PathError::Escapes(_))
211 ));
212 }
213
214 #[test]
215 fn parent_and_file_name() {
216 assert_eq!(rel(".claude/skills").parent(), Some(rel(".claude")));
217 assert_eq!(rel("CLAUDE.md").parent(), None);
218 assert_eq!(rel(".claude/skills").file_name(), "skills");
219 assert_eq!(rel("CLAUDE.md").file_name(), "CLAUDE.md");
220 }
221
222 #[test]
223 fn relative_to_dir_produces_portable_link_targets() {
224 assert_eq!(
226 rel(".agents/skills").relative_to_dir(Some(&rel(".claude"))),
227 "../.agents/skills"
228 );
229 assert_eq!(rel("AGENTS.md").relative_to_dir(None), "AGENTS.md");
231 assert_eq!(
233 rel(".agents/skills").relative_to_dir(Some(&rel("a/b/c"))),
234 "../../../.agents/skills"
235 );
236 assert_eq!(
238 rel(".agents/skills").relative_to_dir(Some(&rel(".agents"))),
239 "skills"
240 );
241 }
242
243 #[test]
244 fn starts_with_respects_segment_boundaries() {
245 assert!(rel(".agents/skills").starts_with(&rel(".agents")));
246 assert!(rel(".agents").starts_with(&rel(".agents")));
247 assert!(!rel(".agentsfoo/x").starts_with(&rel(".agents")));
249 }
250}