Skip to main content

use_git_pathspec/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Error returned while parsing pathspec vocabulary.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum PathspecParseError {
10    /// The supplied pathspec text was empty.
11    Empty,
12    /// The supplied magic label was not recognized.
13    UnknownMagic,
14    /// The supplied scope label was not recognized.
15    UnknownScope,
16}
17
18impl fmt::Display for PathspecParseError {
19    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
20        match self {
21            Self::Empty => formatter.write_str("Git pathspec cannot be empty"),
22            Self::UnknownMagic => formatter.write_str("unknown Git pathspec magic"),
23            Self::UnknownScope => formatter.write_str("unknown Git pathspec scope"),
24        }
25    }
26}
27
28impl Error for PathspecParseError {}
29
30fn non_empty(value: impl AsRef<str>) -> Result<String, PathspecParseError> {
31    let trimmed = value.as_ref().trim();
32    if trimmed.is_empty() {
33        Err(PathspecParseError::Empty)
34    } else {
35        Ok(trimmed.to_string())
36    }
37}
38
39/// Common pathspec magic labels.
40#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
41pub enum PathspecMagic {
42    /// `top` magic.
43    Top,
44    /// `literal` magic.
45    Literal,
46    /// `glob` magic.
47    Glob,
48    /// `icase` magic.
49    Icase,
50    /// `exclude` magic.
51    Exclude,
52}
53
54impl PathspecMagic {
55    /// Returns the stable magic label.
56    #[must_use]
57    pub const fn as_str(self) -> &'static str {
58        match self {
59            Self::Top => "top",
60            Self::Literal => "literal",
61            Self::Glob => "glob",
62            Self::Icase => "icase",
63            Self::Exclude => "exclude",
64        }
65    }
66}
67
68impl fmt::Display for PathspecMagic {
69    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
70        formatter.write_str(self.as_str())
71    }
72}
73
74impl FromStr for PathspecMagic {
75    type Err = PathspecParseError;
76
77    fn from_str(value: &str) -> Result<Self, Self::Err> {
78        match value.trim().to_ascii_lowercase().as_str() {
79            "top" => Ok(Self::Top),
80            "literal" => Ok(Self::Literal),
81            "glob" => Ok(Self::Glob),
82            "icase" | "ignore-case" => Ok(Self::Icase),
83            "exclude" | "!" => Ok(Self::Exclude),
84            "" => Err(PathspecParseError::Empty),
85            _ => Err(PathspecParseError::UnknownMagic),
86        }
87    }
88}
89
90/// Pathspec scope vocabulary.
91#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
92pub enum PathspecScope {
93    /// Worktree-facing pathspec usage.
94    Worktree,
95    /// Index-facing pathspec usage.
96    Index,
97    /// Scope is not fixed by the value itself.
98    Unspecified,
99}
100
101impl PathspecScope {
102    /// Returns the stable scope label.
103    #[must_use]
104    pub const fn as_str(self) -> &'static str {
105        match self {
106            Self::Worktree => "worktree",
107            Self::Index => "index",
108            Self::Unspecified => "unspecified",
109        }
110    }
111}
112
113impl fmt::Display for PathspecScope {
114    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
115        formatter.write_str(self.as_str())
116    }
117}
118
119impl FromStr for PathspecScope {
120    type Err = PathspecParseError;
121
122    fn from_str(value: &str) -> Result<Self, Self::Err> {
123        match value.trim().to_ascii_lowercase().as_str() {
124            "worktree" => Ok(Self::Worktree),
125            "index" => Ok(Self::Index),
126            "unspecified" | "unknown" => Ok(Self::Unspecified),
127            "" => Err(PathspecParseError::Empty),
128            _ => Err(PathspecParseError::UnknownScope),
129        }
130    }
131}
132
133/// A pathspec pattern string.
134#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
135pub struct PathspecPattern(String);
136
137impl PathspecPattern {
138    /// Creates a pathspec pattern.
139    ///
140    /// # Errors
141    ///
142    /// Returns [`PathspecParseError::Empty`] when the pattern is empty.
143    pub fn new(value: impl AsRef<str>) -> Result<Self, PathspecParseError> {
144        non_empty(value).map(Self)
145    }
146
147    /// Returns the pattern text.
148    #[must_use]
149    pub fn as_str(&self) -> &str {
150        &self.0
151    }
152}
153
154impl AsRef<str> for PathspecPattern {
155    fn as_ref(&self) -> &str {
156        self.as_str()
157    }
158}
159
160impl fmt::Display for PathspecPattern {
161    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
162        formatter.write_str(self.as_str())
163    }
164}
165
166/// A lightweight pathspec wrapper.
167#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
168pub struct GitPathspec(String);
169
170impl GitPathspec {
171    /// Creates a pathspec from text.
172    ///
173    /// # Errors
174    ///
175    /// Returns [`PathspecParseError::Empty`] when the pathspec is empty.
176    pub fn new(value: impl AsRef<str>) -> Result<Self, PathspecParseError> {
177        non_empty(value).map(Self)
178    }
179
180    /// Returns the magic labels declared in `:(...)` syntax.
181    #[must_use]
182    pub fn magic(&self) -> Vec<PathspecMagic> {
183        parse_magic(self.as_str())
184    }
185
186    /// Returns true when a magic label is present.
187    #[must_use]
188    pub fn has_magic(&self, magic: PathspecMagic) -> bool {
189        self.magic().contains(&magic)
190    }
191
192    /// Returns the pathspec text.
193    #[must_use]
194    pub fn as_str(&self) -> &str {
195        &self.0
196    }
197}
198
199impl AsRef<str> for GitPathspec {
200    fn as_ref(&self) -> &str {
201        self.as_str()
202    }
203}
204
205impl fmt::Display for GitPathspec {
206    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
207        formatter.write_str(self.as_str())
208    }
209}
210
211impl FromStr for GitPathspec {
212    type Err = PathspecParseError;
213
214    fn from_str(value: &str) -> Result<Self, Self::Err> {
215        Self::new(value)
216    }
217}
218
219fn parse_magic(value: &str) -> Vec<PathspecMagic> {
220    let Some(rest) = value.strip_prefix(":(") else {
221        return Vec::new();
222    };
223    let Some(end) = rest.find(')') else {
224        return Vec::new();
225    };
226
227    rest[..end]
228        .split(',')
229        .filter_map(|label| label.parse::<PathspecMagic>().ok())
230        .collect()
231}
232
233#[cfg(test)]
234mod tests {
235    use super::{GitPathspec, PathspecMagic, PathspecParseError, PathspecScope};
236
237    #[test]
238    fn parses_pathspec_magic() -> Result<(), PathspecParseError> {
239        let pathspec = GitPathspec::new(":(top,literal)README.md")?;
240
241        assert!(pathspec.has_magic(PathspecMagic::Top));
242        assert!(pathspec.has_magic(PathspecMagic::Literal));
243        assert_eq!(PathspecScope::Index.to_string(), "index");
244        Ok(())
245    }
246
247    #[test]
248    fn rejects_empty_pathspecs() {
249        assert_eq!(GitPathspec::new(""), Err(PathspecParseError::Empty));
250    }
251}