1use std::fmt;
2use std::path::{Component, Path, PathBuf};
3
4use gix::bstr::ByteSlice;
5use serde::{Serialize, Serializer};
6use thiserror::Error;
7
8#[derive(Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
14pub struct RepoPath {
15 components: Vec<RepoPathComponent>,
16}
17
18#[derive(Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
24pub struct RepoPathScope {
25 components: Vec<RepoPathComponent>,
26}
27
28impl RepoPath {
29 pub(crate) fn from_git_path(path: &str) -> Result<Self, PathError> {
34 let components = parse_git_path_components(path)?;
35 Ok(Self { components })
36 }
37
38 pub(crate) fn to_os_path_buf(&self) -> PathBuf {
40 self.components
41 .iter()
42 .map(RepoPathComponent::as_str)
43 .collect()
44 }
45
46 fn to_git_path(&self) -> String {
48 self.components
49 .iter()
50 .map(RepoPathComponent::as_str)
51 .collect::<Vec<_>>()
52 .join("/")
53 }
54}
55
56impl fmt::Display for RepoPath {
57 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58 f.write_str(&self.to_git_path())
59 }
60}
61
62impl Serialize for RepoPath {
63 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
64 where
65 S: Serializer,
66 {
67 serializer.serialize_str(&self.to_git_path())
68 }
69}
70
71impl RepoPathScope {
72 pub(crate) fn contains_file(&self, path: &RepoPath) -> bool {
74 self.components.len() <= path.components.len()
75 && self
76 .components
77 .iter()
78 .zip(path.components.iter())
79 .all(|(scope, path)| scope == path)
80 }
81
82 fn to_git_path(&self) -> String {
83 if self.components.is_empty() {
84 ".".to_owned()
85 } else {
86 self.components
87 .iter()
88 .map(RepoPathComponent::as_str)
89 .collect::<Vec<_>>()
90 .join("/")
91 }
92 }
93}
94
95impl fmt::Display for RepoPathScope {
96 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97 f.write_str(&self.to_git_path())
98 }
99}
100
101#[derive(Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
103struct RepoPathComponent(String);
104
105impl RepoPathComponent {
106 fn parse(component: &str) -> Result<Self, RepoPathComponentError> {
108 match component {
109 "" => Err(RepoPathComponentError::Empty),
110 "." => Err(RepoPathComponentError::CurrentDir),
111 ".." => Err(RepoPathComponentError::ParentDir),
112 value if value.contains('\0') => Err(RepoPathComponentError::NulByte),
113 value => Ok(Self(value.to_owned())),
114 }
115 }
116
117 fn from_os_component(component: &std::ffi::OsStr) -> Result<Self, RepoPathComponentError> {
119 let component = component.to_str().ok_or(RepoPathComponentError::NonUtf8)?;
120 Self::parse(component)
121 }
122
123 fn as_str(&self) -> &str {
125 &self.0
126 }
127}
128
129#[derive(Clone, Copy, Debug, Eq, PartialEq)]
131enum RepoPathComponentError {
132 NonUtf8,
134 Empty,
136 CurrentDir,
138 ParentDir,
140 NulByte,
142}
143
144impl RepoPathComponentError {
145 const fn into_path_error(self) -> PathError {
147 match self {
148 Self::NonUtf8 => PathError::NonUtf8,
149 Self::Empty => PathError::EmptyComponent,
150 Self::CurrentDir => PathError::CurrentDirComponent,
151 Self::ParentDir => PathError::OutsideRepo,
152 Self::NulByte => PathError::NulByte,
153 }
154 }
155}
156
157#[derive(Debug, Error)]
159pub(crate) enum PathError {
160 #[error("path is not valid UTF-8")]
162 NonUtf8,
163 #[error("path escapes the repository root")]
165 OutsideRepo,
166 #[error("path must be absolute")]
168 NonAbsolute,
169 #[error("empty paths are not valid tracked files")]
171 EmptyPath,
172 #[error("repo path contains an empty component")]
174 EmptyComponent,
175 #[error("repo path contains a current-directory component")]
177 CurrentDirComponent,
178 #[error("repo path contains a NUL byte")]
180 NulByte,
181}
182
183pub(crate) fn prefix_from_cwd(root: &Path, cwd: &Path) -> Result<PathBuf, PathError> {
188 let root = normalize_absolute_lexically(root)?;
189 let cwd = normalize_absolute_lexically(cwd)?;
190 cwd.strip_prefix(&root)
191 .map(Path::to_path_buf)
192 .map_err(|_| PathError::OutsideRepo)
193}
194
195pub(crate) fn repo_path_from_bstr(path: &gix::bstr::BStr) -> Result<RepoPath, PathError> {
197 let path = path.to_str().map_err(|_| PathError::NonUtf8)?;
198 RepoPath::from_git_path(path)
199}
200
201pub(crate) fn normalize_absolute_lexically(path: &Path) -> Result<PathBuf, PathError> {
209 if !path.is_absolute() {
210 return Err(PathError::NonAbsolute);
211 }
212
213 Ok(path
214 .components()
215 .fold(PathBuf::new(), |mut normalized, component| {
216 match component {
217 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
218 Component::RootDir => normalized.push(component.as_os_str()),
219 Component::CurDir => {}
220 Component::ParentDir => {
221 normalized.pop();
222 }
223 Component::Normal(part) => normalized.push(part),
224 }
225 normalized
226 }))
227}
228
229pub(crate) fn repo_path_from_relative(path: &Path) -> Result<RepoPath, PathError> {
235 let components = repo_path_components_from_relative(path)?;
236
237 if components.is_empty() {
238 Err(PathError::EmptyPath)
239 } else {
240 Ok(RepoPath { components })
241 }
242}
243
244pub(crate) fn repo_path_scope_from_relative(path: &Path) -> Result<RepoPathScope, PathError> {
246 repo_path_components_from_relative(path).map(|components| RepoPathScope { components })
247}
248
249fn repo_path_components_from_relative(path: &Path) -> Result<Vec<RepoPathComponent>, PathError> {
250 if !path.is_relative() {
251 return Err(PathError::OutsideRepo);
252 }
253
254 path.components()
255 .filter_map(|component| match component {
256 Component::Normal(part) => Some(
257 RepoPathComponent::from_os_component(part)
258 .map_err(RepoPathComponentError::into_path_error),
259 ),
260 Component::CurDir => None,
261 Component::Prefix(_) | Component::RootDir | Component::ParentDir => {
262 Some(Err(PathError::OutsideRepo))
263 }
264 })
265 .collect()
266}
267
268fn parse_git_path_components(path: &str) -> Result<Vec<RepoPathComponent>, PathError> {
270 if path.is_empty() {
271 return Err(PathError::EmptyPath);
272 }
273
274 path.split('/')
275 .map(|component| {
276 RepoPathComponent::parse(component).map_err(RepoPathComponentError::into_path_error)
277 })
278 .collect()
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn repo_path_from_relative_rejects_empty_paths() {
287 assert!(matches!(
288 repo_path_from_relative(Path::new("")),
289 Err(PathError::EmptyPath)
290 ));
291 }
292
293 #[test]
294 fn repo_path_from_relative_rejects_absolute_paths() {
295 assert!(matches!(
296 repo_path_from_relative(Path::new("/src/lib.rs")),
297 Err(PathError::OutsideRepo)
298 ));
299 }
300
301 #[test]
302 fn repo_path_from_git_path_classifies_structural_errors() {
303 assert!(matches!(
304 RepoPath::from_git_path(""),
305 Err(PathError::EmptyPath)
306 ));
307 for path in ["/src/lib.rs", "src//lib.rs", "src/lib.rs/"] {
308 assert!(matches!(
309 RepoPath::from_git_path(path),
310 Err(PathError::EmptyComponent),
311 ));
312 }
313 assert!(matches!(
314 RepoPath::from_git_path("src/./lib.rs"),
315 Err(PathError::CurrentDirComponent)
316 ));
317 assert!(matches!(
318 RepoPath::from_git_path("src/../lib.rs"),
319 Err(PathError::OutsideRepo)
320 ));
321 assert!(matches!(
322 RepoPath::from_git_path("src/\0/lib.rs"),
323 Err(PathError::NulByte)
324 ));
325 }
326
327 #[test]
328 fn repo_path_round_trips_git_path_and_os_path() -> Result<(), PathError> {
329 let path = RepoPath::from_git_path("src/lib.rs")?;
330
331 assert_eq!(path.to_string(), "src/lib.rs");
332 assert_eq!(path.to_os_path_buf(), PathBuf::from("src").join("lib.rs"));
333 Ok(())
334 }
335
336 #[test]
337 fn normalize_absolute_lexically_rejects_relative_paths() {
338 assert!(matches!(
339 normalize_absolute_lexically(Path::new("src/../lib.rs")),
340 Err(PathError::NonAbsolute)
341 ));
342 }
343}