1use std::fmt;
2use std::path::{Component, Path};
3
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
23#[serde(try_from = "String", into = "String")]
24pub struct SourcePath(String);
25
26#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
28pub enum PathError {
29 #[error("path `{0}` must be relative to the codebase root")]
31 Absolute(String),
32 #[error("path `{0}` escapes the codebase root")]
34 Escapes(String),
35 #[error("path is empty")]
37 Empty,
38}
39
40impl SourcePath {
41 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 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 pub fn as_str(&self) -> &str {
88 &self.0
89 }
90
91 pub fn file_name(&self) -> &str {
93 self.0.rsplit('/').next().unwrap_or(&self.0)
94 }
95
96 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 pub fn components(&self) -> impl Iterator<Item = &str> {
104 self.0.split('/')
105 }
106
107 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}