Skip to main content

agentlink_domain/
path.rs

1//! Workspace-relative paths.
2//!
3//! Every path the domain layer touches is expressed as a [`RelPath`]: a
4//! normalised, always `/`-separated path rooted at the workspace. Adapters are
5//! responsible for turning these into native paths.
6//!
7//! This buys us three things that matter a lot for a tool whose entire job is
8//! creating links on three operating systems:
9//!
10//! 1. **No separator bugs.** `.agents/skills` and `.agents\skills` compare equal
11//!    because only one spelling can ever exist.
12//! 2. **No path traversal.** `..`, absolute paths, drive letters and UNC prefixes
13//!    are rejected at construction. A provider manifest — which is community
14//!    contributed data — therefore cannot address anything outside the workspace.
15//! 3. **Trivially testable planning.** The domain never needs a real filesystem
16//!    or a temp directory to reason about paths.
17
18use std::fmt;
19
20use serde::{Deserialize, Serialize};
21
22/// Reasons a string cannot be a workspace-relative path.
23#[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/// A normalised, `/`-separated path relative to the workspace root.
34///
35/// Guaranteed to be non-empty, to contain no `.`, `..` or empty segments, and to
36/// be relative.
37#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
38#[serde(try_from = "String", into = "String")]
39pub struct RelPath(String);
40
41impl RelPath {
42    /// Parses and normalises a workspace-relative path.
43    ///
44    /// Accepts either separator, collapses redundant ones, and drops `.`
45    /// segments. Rejects anything that could point outside the workspace.
46    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        // Windows drive-qualified (`C:\...`, `C:foo`) and UNC (`\\host\share`)
56        // paths. The UNC case is already covered by the leading-slash check
57        // after separator unification, but we keep the drive check explicit.
58        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    /// The normalised `/`-separated representation.
81    pub fn as_str(&self) -> &str {
82        &self.0
83    }
84
85    /// The path segments, never empty.
86    pub fn segments(&self) -> impl Iterator<Item = &str> {
87        self.0.split('/')
88    }
89
90    /// The containing directory, or `None` when this path sits at the workspace
91    /// root.
92    pub fn parent(&self) -> Option<RelPath> {
93        self.0
94            .rsplit_once('/')
95            .map(|(head, _)| Self(head.to_string()))
96    }
97
98    /// The final segment.
99    pub fn file_name(&self) -> &str {
100        self.0
101            .rsplit_once('/')
102            .map_or(self.0.as_str(), |(_, tail)| tail)
103    }
104
105    /// Appends a segment, normalising it in the process.
106    pub fn join(&self, segment: &str) -> Result<Self, PathError> {
107        Self::new(&format!("{}/{}", self.0, segment))
108    }
109
110    /// Renders `self` as a path relative to `dir`, where `None` means the
111    /// workspace root.
112    ///
113    /// This is what a portable symlink stores: linking `.claude/skills` to
114    /// `.agents/skills` should record `../.agents/skills`, not an absolute path,
115    /// so the workspace stays movable.
116    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    /// Whether `self` is `other` or lives underneath it.
132    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        // `.claude/skills` -> `.agents/skills` must be stored as `../.agents/skills`.
225        assert_eq!(
226            rel(".agents/skills").relative_to_dir(Some(&rel(".claude"))),
227            "../.agents/skills"
228        );
229        // A root-level link records a bare name.
230        assert_eq!(rel("AGENTS.md").relative_to_dir(None), "AGENTS.md");
231        // Deeper nesting walks back the right number of levels.
232        assert_eq!(
233            rel(".agents/skills").relative_to_dir(Some(&rel("a/b/c"))),
234            "../../../.agents/skills"
235        );
236        // Shared prefixes are not walked back over.
237        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        // `.agentsfoo` is not inside `.agents`.
248        assert!(!rel(".agentsfoo/x").starts_with(&rel(".agents")));
249    }
250}