Skip to main content

git_vet/
path.rs

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