Skip to main content

sealmap_model/
path.rs

1use std::fmt;
2use std::path::{Component, Path};
3
4use serde::{Deserialize, Serialize};
5
6/// A normalised, relative, `/`-separated path to a source file, used as the
7/// primary key for files everywhere in sealmap.
8///
9/// Normalisation makes keys identical on every platform:
10///
11/// ```
12/// use sealmap_model::SourcePath;
13///
14/// let a = SourcePath::new("src/./net/../lib.rs").unwrap();
15/// let b = SourcePath::new("src\\lib.rs").unwrap();
16/// assert_eq!(a, b);
17/// assert_eq!(a.as_str(), "src/lib.rs");
18///
19/// assert!(SourcePath::new("/etc/passwd").is_err());
20/// assert!(SourcePath::new("../outside.rs").is_err());
21/// ```
22#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
23#[serde(try_from = "String", into = "String")]
24pub struct SourcePath(String);
25
26/// Why a string could not become a [`SourcePath`].
27#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
28pub enum PathError {
29    /// The path was absolute (or had a drive / root prefix).
30    #[error("path `{0}` must be relative to the codebase root")]
31    Absolute(String),
32    /// The path climbs above its root with `..`.
33    #[error("path `{0}` escapes the codebase root")]
34    Escapes(String),
35    /// The path normalised to nothing.
36    #[error("path is empty")]
37    Empty,
38}
39
40impl SourcePath {
41    /// Normalise `raw` into a [`SourcePath`].
42    ///
43    /// Backslashes are treated as separators, `.` components are dropped and
44    /// `..` components are resolved lexically. Absolute paths and paths that
45    /// escape the root are rejected.
46    pub fn new(raw: impl AsRef<str>) -> Result<Self, PathError> {
47        let raw = raw.as_ref();
48        let unified = raw.replace('\\', "/");
49        if unified.starts_with('/') || unified.as_bytes().get(1) == Some(&b':') {
50            return Err(PathError::Absolute(raw.to_owned()));
51        }
52        let mut parts: Vec<&str> = Vec::new();
53        for part in unified.split('/') {
54            match part {
55                "" | "." => {}
56                ".." => {
57                    if parts.pop().is_none() {
58                        return Err(PathError::Escapes(raw.to_owned()));
59                    }
60                }
61                p => parts.push(p),
62            }
63        }
64        if parts.is_empty() {
65            return Err(PathError::Empty);
66        }
67        Ok(Self(parts.join("/")))
68    }
69
70    /// Make `path` relative to `root` and normalise it.
71    ///
72    /// Non-UTF-8 components are converted lossily.
73    pub fn relative_to(path: &Path, root: &Path) -> Result<Self, PathError> {
74        let rel = path.strip_prefix(root).unwrap_or(path);
75        let joined: Vec<String> = rel
76            .components()
77            .filter_map(|c| match c {
78                Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
79                Component::ParentDir => Some("..".to_owned()),
80                _ => None,
81            })
82            .collect();
83        Self::new(joined.join("/"))
84    }
85
86    /// The normalised path string.
87    pub fn as_str(&self) -> &str {
88        &self.0
89    }
90
91    /// The final component, e.g. `lib.rs`.
92    pub fn file_name(&self) -> &str {
93        self.0.rsplit('/').next().unwrap_or(&self.0)
94    }
95
96    /// The extension without the dot, if any.
97    pub fn extension(&self) -> Option<&str> {
98        let name = self.file_name();
99        name.rfind('.').filter(|&i| i > 0).map(|i| &name[i + 1..])
100    }
101
102    /// Path components in order.
103    pub fn components(&self) -> impl Iterator<Item = &str> {
104        self.0.split('/')
105    }
106
107    /// Append a suffix to the whole path (`src/lib.rs` + `.mmd` →
108    /// `src/lib.rs.mmd`). This is how the corpus maps sources 1:1 to diagrams.
109    pub fn with_suffix(&self, suffix: &str) -> Self {
110        Self(format!("{}{}", self.0, suffix))
111    }
112}
113
114impl fmt::Display for SourcePath {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        f.write_str(&self.0)
117    }
118}
119
120impl TryFrom<String> for SourcePath {
121    type Error = PathError;
122    fn try_from(value: String) -> Result<Self, Self::Error> {
123        Self::new(value)
124    }
125}
126
127impl From<SourcePath> for String {
128    fn from(value: SourcePath) -> Self {
129        value.0
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn rejects_drive_letters_and_empty() {
139        assert!(matches!(SourcePath::new("C:\\x.rs"), Err(PathError::Absolute(_))));
140        assert_eq!(SourcePath::new("./"), Err(PathError::Empty));
141    }
142
143    #[test]
144    fn extension_and_suffix() {
145        let p = SourcePath::new("a/b.rs").unwrap();
146        assert_eq!(p.extension(), Some("rs"));
147        assert_eq!(p.with_suffix(".mmd").as_str(), "a/b.rs.mmd");
148        assert_eq!(SourcePath::new(".gitignore").unwrap().extension(), None);
149    }
150}