Skip to main content

tramlines_core/
path.rs

1//! `RepoPath`: a `/`-rooted, forward-slash path from the repository root,
2//! the only path type in the engine and its host. Conversion to `Path`
3//! happens only in the host's walk and in file writes.
4
5use std::{
6    fmt,
7    path::{Path, PathBuf},
8};
9
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12
13/// The repository root on disk.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct RepoRoot(PathBuf);
16
17impl RepoRoot {
18    /// Wrap a directory.
19    pub fn new(path: PathBuf) -> Self {
20        RepoRoot(path)
21    }
22
23    /// The directory.
24    pub fn as_path(&self) -> &Path {
25        &self.0
26    }
27}
28
29/// A path relative to the repository root, which starts with `/` and uses
30/// forward slashes. The path `/` is the root itself.
31#[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    /// The repository root.
40    pub fn root() -> Self {
41        RepoPath("/".into())
42    }
43
44    /// Parse a `/`-rooted path. Rejects a relative path, an empty segment, a
45    /// `.` or `..` segment, and a trailing slash (except on the root).
46    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    /// Build from a filesystem path inside `root`. `path` is absolute or
63    /// relative to the current directory; it is normalised lexically, so a
64    /// `..` that leaves the root yields `None`.
65    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    /// The filesystem path under `root`.
77    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    /// The containing directory; `None` for the root.
86    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    /// Whether this path is `dir` or lies under it.
98    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    /// The path without its leading slash, as the report prints it.
110    pub fn relative(&self) -> &str {
111        self.0.strip_prefix('/').unwrap_or("")
112    }
113
114    /// The path as written, `/`-rooted.
115    pub fn as_str(&self) -> &str {
116        &self.0
117    }
118
119    /// The path segments.
120    pub fn segments(&self) -> impl Iterator<Item = &str> {
121        self.relative().split('/').filter(|s| !s.is_empty())
122    }
123
124    /// The last segment; empty for the root.
125    pub fn file_name(&self) -> &str {
126        self.segments().last().unwrap_or("")
127    }
128
129    /// The text after the last `.` of the file name, if any.
130    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    /// `self/name`.
136    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    /// Resolve `target` from this file's directory, as standard markdown
145    /// resolves a relative link. `..` segments are applied; leaving the root
146    /// yields `None`.
147    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
172/// Lexical normalisation: drop `.` and apply `..`, without touching the disk.
173fn 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}