1use std::fmt;
2use std::path::{Component, Path, PathBuf};
3
4use gix::bstr::ByteSlice;
5use thiserror::Error;
6
7#[derive(Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, serde::Serialize)]
8#[serde(transparent)]
9pub struct RepoPath(String);
10
11impl RepoPath {
12 pub(crate) fn from_git_path(path: &str) -> Result<Self, PathError> {
13 if path.is_empty() {
14 return Err(PathError::EmptyPath);
15 }
16 Ok(Self(path.to_owned()))
17 }
18
19 pub(crate) fn to_path_buf(&self) -> PathBuf {
20 self.0.split('/').collect()
21 }
22}
23
24impl fmt::Display for RepoPath {
25 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26 self.0.fmt(f)
27 }
28}
29
30#[derive(Debug, Error)]
31pub enum PathError {
32 #[error("path is not valid UTF-8: {0}")]
33 NonUtf8Path(String),
34 #[error("path escapes the repository root: {0}")]
35 PathOutsideRepo(String),
36 #[error("empty paths are not valid tracked files")]
37 EmptyPath,
38}
39
40pub fn prefix_from_cwd(root: &Path, cwd: &Path) -> Result<PathBuf, PathError> {
41 let root = normalize_lexically(root);
42 let cwd = normalize_lexically(cwd);
43 cwd.strip_prefix(&root)
44 .map(Path::to_path_buf)
45 .map_err(|_| PathError::PathOutsideRepo(cwd.display().to_string()))
46}
47
48pub fn repo_path_from_bstr(path: &gix::bstr::BStr) -> Result<RepoPath, PathError> {
49 let path = path
50 .to_str()
51 .map_err(|err| PathError::NonUtf8Path(err.to_string()))?;
52 RepoPath::from_git_path(path)
53}
54
55pub fn normalize_lexically(path: &Path) -> PathBuf {
56 path.components()
57 .fold(PathBuf::new(), |mut normalized, component| {
58 match component {
59 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
60 Component::RootDir => normalized.push(component.as_os_str()),
61 Component::CurDir => {}
62 Component::ParentDir => {
63 normalized.pop();
64 }
65 Component::Normal(part) => normalized.push(part),
66 }
67 normalized
68 })
69}
70
71pub fn repo_path_from_relative(path: &Path) -> Result<String, PathError> {
72 let parts = path
73 .components()
74 .map(|component| match component {
75 Component::Normal(part) => part
76 .to_str()
77 .map(ToOwned::to_owned)
78 .ok_or_else(|| PathError::NonUtf8Path(path.display().to_string())),
79 Component::CurDir => Ok(String::new()),
80 Component::Prefix(_) | Component::RootDir | Component::ParentDir => {
81 Err(PathError::PathOutsideRepo(path.display().to_string()))
82 }
83 })
84 .filter(|part| !matches!(part, Ok(value) if value.is_empty()))
85 .collect::<Result<Vec<_>, _>>()?;
86
87 if parts.is_empty() {
88 Err(PathError::EmptyPath)
89 } else {
90 Ok(parts.join("/"))
91 }
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97
98 #[test]
99 fn repo_path_from_relative_rejects_empty_paths() {
100 assert!(matches!(
101 repo_path_from_relative(Path::new("")),
102 Err(PathError::EmptyPath)
103 ));
104 }
105}