1use std::{
6 fmt,
7 path::{Path, PathBuf},
8};
9
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct RepoRoot(PathBuf);
16
17impl RepoRoot {
18 pub fn new(path: PathBuf) -> Self {
20 RepoRoot(path)
21 }
22
23 pub fn as_path(&self) -> &Path {
25 &self.0
26 }
27}
28
29#[derive(
32 Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
33)]
34#[serde(transparent)]
35#[schemars(extend("pattern" = "^/"))]
36pub struct RepoPath(String);
37
38impl RepoPath {
39 pub fn root() -> Self {
41 RepoPath("/".into())
42 }
43
44 pub fn parse(s: &str) -> Option<Self> {
47 if s == "/" {
48 return Some(Self::root());
49 }
50 let rest = s.strip_prefix('/')?;
51 if rest.is_empty() {
52 return None;
53 }
54 for segment in rest.split('/') {
55 if segment.is_empty() || segment == "." || segment == ".." {
56 return None;
57 }
58 }
59 Some(RepoPath(s.to_string()))
60 }
61
62 pub fn from_fs(root: &RepoRoot, path: &Path) -> Option<Self> {
66 let abs = normalize(&std::path::absolute(path).ok()?);
67 let root_abs = normalize(root.as_path());
68 let rel = abs.strip_prefix(&root_abs).ok()?;
69 let parts = rel
70 .components()
71 .map(|c| c.as_os_str().to_str())
72 .collect::<Option<Vec<_>>>()?;
73 Self::parse(&format!("/{}", parts.join("/")))
74 }
75
76 pub fn to_fs(&self, root: &RepoRoot) -> PathBuf {
78 let mut p = root.as_path().to_path_buf();
79 for segment in self.segments() {
80 p.push(segment);
81 }
82 p
83 }
84
85 pub fn parent(&self) -> Option<RepoPath> {
87 if self.0 == "/" {
88 return None;
89 }
90 match self.0.rfind('/') {
91 Some(0) => Some(Self::root()),
92 Some(i) => Some(RepoPath(self.0[..i].to_string())),
93 None => None,
94 }
95 }
96
97 pub fn starts_with(&self, dir: &RepoPath) -> bool {
99 if dir.0 == "/" {
100 return true;
101 }
102 self.0 == dir.0
103 || self
104 .0
105 .strip_prefix(&dir.0)
106 .is_some_and(|r| r.starts_with('/'))
107 }
108
109 pub fn relative(&self) -> &str {
111 self.0.strip_prefix('/').unwrap_or("")
112 }
113
114 pub fn as_str(&self) -> &str {
116 &self.0
117 }
118
119 pub fn segments(&self) -> impl Iterator<Item = &str> {
121 self.relative().split('/').filter(|s| !s.is_empty())
122 }
123
124 pub fn file_name(&self) -> &str {
126 self.segments().last().unwrap_or("")
127 }
128
129 pub fn extension(&self) -> Option<&str> {
131 let name = self.file_name();
132 name.rfind('.').filter(|&i| i > 0).map(|i| &name[i + 1..])
133 }
134
135 pub fn join(&self, name: &str) -> Option<RepoPath> {
137 if self.0 == "/" {
138 Self::parse(&format!("/{name}"))
139 } else {
140 Self::parse(&format!("{}/{name}", self.0))
141 }
142 }
143
144 pub fn resolve_relative(&self, target: &str) -> Option<RepoPath> {
148 let dir = self.parent()?;
149 let mut parts: Vec<&str> = dir.segments().collect();
150 for segment in target.split('/') {
151 match segment {
152 "" | "." => {}
153 ".." => {
154 parts.pop()?;
155 }
156 s => parts.push(s),
157 }
158 }
159 if parts.is_empty() {
160 return Some(Self::root());
161 }
162 Self::parse(&format!("/{}", parts.join("/")))
163 }
164}
165
166impl fmt::Display for RepoPath {
167 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168 f.write_str(&self.0)
169 }
170}
171
172fn normalize(path: &Path) -> PathBuf {
174 use std::path::Component;
175 let mut out = PathBuf::new();
176 for c in path.components() {
177 match c {
178 Component::CurDir => {}
179 Component::ParentDir => {
180 out.pop();
181 }
182 other => out.push(other.as_os_str()),
183 }
184 }
185 out
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 fn p(s: &str) -> RepoPath {
193 RepoPath::parse(s).unwrap()
194 }
195
196 #[test]
197 fn parses_rooted_paths_only() {
198 assert_eq!(p("/a/b.md").as_str(), "/a/b.md");
199 assert_eq!(p("/").as_str(), "/");
200 for bad in ["a/b", "", "/a/", "/a//b", "/a/../b", "/./a", "/.."] {
201 assert!(RepoPath::parse(bad).is_none(), "{bad:?} must be rejected");
202 }
203 }
204
205 #[test]
206 fn parent_and_starts_with() {
207 assert_eq!(p("/a/b/c.md").parent(), Some(p("/a/b")));
208 assert_eq!(p("/a").parent(), Some(p("/")));
209 assert_eq!(p("/").parent(), None);
210 assert!(p("/a/b/c.md").starts_with(&p("/a/b")));
211 assert!(p("/a/b").starts_with(&p("/a/b")));
212 assert!(!p("/a/bc").starts_with(&p("/a/b")));
213 assert!(p("/x").starts_with(&p("/")));
214 }
215
216 #[test]
217 fn relative_names_and_extension() {
218 assert_eq!(p("/a/b.md").relative(), "a/b.md");
219 assert_eq!(p("/").relative(), "");
220 assert_eq!(p("/a/b.md").file_name(), "b.md");
221 assert_eq!(p("/a/b.md").extension(), Some("md"));
222 assert_eq!(p("/a/Makefile").extension(), None);
223 assert_eq!(p("/a/.hidden").extension(), None);
224 assert_eq!(p("/a").join("b"), Some(p("/a/b")));
225 assert_eq!(p("/").join("b"), Some(p("/b")));
226 assert_eq!(p("/a/b.md").to_string(), "/a/b.md");
227 }
228
229 #[test]
230 fn resolves_relative_links_from_the_directory() {
231 assert_eq!(p("/k/a.md").resolve_relative("b.md"), Some(p("/k/b.md")));
232 assert_eq!(
233 p("/k/sub/a.md").resolve_relative("../b.md"),
234 Some(p("/k/b.md"))
235 );
236 assert_eq!(
237 p("/k/a.md").resolve_relative("./c/d.md"),
238 Some(p("/k/c/d.md"))
239 );
240 assert_eq!(p("/k/a.md").resolve_relative("../../x"), None);
241 assert_eq!(p("/a.md").resolve_relative(".."), None);
242 assert_eq!(p("/k/a.md").resolve_relative(".."), Some(p("/")));
243 }
244
245 #[test]
246 fn converts_to_and_from_the_filesystem() {
247 let dir = std::env::temp_dir().join(format!("tramlines-path-{}", std::process::id()));
248 let root = RepoRoot::new(dir.clone());
249 let fs = p("/a/b.md").to_fs(&root);
250 assert_eq!(fs, dir.join("a").join("b.md"));
251 assert_eq!(RepoPath::from_fs(&root, &fs), Some(p("/a/b.md")));
252 assert_eq!(RepoPath::from_fs(&root, &dir), Some(p("/")));
253 assert_eq!(RepoPath::from_fs(&root, &dir.join("a/../c")), Some(p("/c")));
254 assert_eq!(RepoPath::from_fs(&root, &dir.join("..")), None);
255 }
256
257 #[test]
258 fn serializes_as_a_string() {
259 let json = serde_json::to_string(&p("/a.md")).unwrap();
260 assert_eq!(json, "\"/a.md\"");
261 let back: RepoPath = serde_json::from_str(&json).unwrap();
262 assert_eq!(back, p("/a.md"));
263 }
264}