1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum PathspecParseError {
10 Empty,
12 UnknownMagic,
14 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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
41pub enum PathspecMagic {
42 Top,
44 Literal,
46 Glob,
48 Icase,
50 Exclude,
52}
53
54impl PathspecMagic {
55 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
92pub enum PathspecScope {
93 Worktree,
95 Index,
97 Unspecified,
99}
100
101impl PathspecScope {
102 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
135pub struct PathspecPattern(String);
136
137impl PathspecPattern {
138 pub fn new(value: impl AsRef<str>) -> Result<Self, PathspecParseError> {
144 non_empty(value).map(Self)
145 }
146
147 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
168pub struct GitPathspec(String);
169
170impl GitPathspec {
171 pub fn new(value: impl AsRef<str>) -> Result<Self, PathspecParseError> {
177 non_empty(value).map(Self)
178 }
179
180 #[must_use]
182 pub fn magic(&self) -> Vec<PathspecMagic> {
183 parse_magic(self.as_str())
184 }
185
186 #[must_use]
188 pub fn has_magic(&self, magic: PathspecMagic) -> bool {
189 self.magic().contains(&magic)
190 }
191
192 #[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}