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 RefspecParseError {
10 Empty,
12 EmptySource,
14 EmptyDestination,
16 TooManySeparators,
18 UnknownDirection,
20 UnknownMode,
22}
23
24impl fmt::Display for RefspecParseError {
25 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
26 match self {
27 Self::Empty => formatter.write_str("Git refspec cannot be empty"),
28 Self::EmptySource => formatter.write_str("Git refspec source cannot be empty"),
29 Self::EmptyDestination => {
30 formatter.write_str("Git refspec destination cannot be empty")
31 },
32 Self::TooManySeparators => {
33 formatter.write_str("Git refspec contains too many separators")
34 },
35 Self::UnknownDirection => formatter.write_str("unknown Git refspec direction"),
36 Self::UnknownMode => formatter.write_str("unknown Git refspec mode"),
37 }
38 }
39}
40
41impl Error for RefspecParseError {}
42
43#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
45pub enum RefspecDirection {
46 Fetch,
48 Push,
50}
51
52impl RefspecDirection {
53 #[must_use]
55 pub const fn as_str(self) -> &'static str {
56 match self {
57 Self::Fetch => "fetch",
58 Self::Push => "push",
59 }
60 }
61}
62
63impl fmt::Display for RefspecDirection {
64 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
65 formatter.write_str(self.as_str())
66 }
67}
68
69impl FromStr for RefspecDirection {
70 type Err = RefspecParseError;
71
72 fn from_str(value: &str) -> Result<Self, Self::Err> {
73 match value.trim().to_ascii_lowercase().as_str() {
74 "fetch" => Ok(Self::Fetch),
75 "push" => Ok(Self::Push),
76 "" => Err(RefspecParseError::Empty),
77 _ => Err(RefspecParseError::UnknownDirection),
78 }
79 }
80}
81
82#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
84pub enum RefspecMode {
85 Normal,
87 Force,
89}
90
91impl RefspecMode {
92 #[must_use]
94 pub const fn as_str(self) -> &'static str {
95 match self {
96 Self::Normal => "normal",
97 Self::Force => "force",
98 }
99 }
100}
101
102impl fmt::Display for RefspecMode {
103 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
104 formatter.write_str(self.as_str())
105 }
106}
107
108impl FromStr for RefspecMode {
109 type Err = RefspecParseError;
110
111 fn from_str(value: &str) -> Result<Self, Self::Err> {
112 match value.trim().to_ascii_lowercase().as_str() {
113 "normal" => Ok(Self::Normal),
114 "force" | "+" => Ok(Self::Force),
115 "" => Err(RefspecParseError::Empty),
116 _ => Err(RefspecParseError::UnknownMode),
117 }
118 }
119}
120
121fn non_empty(value: &str, error: RefspecParseError) -> Result<String, RefspecParseError> {
122 let trimmed = value.trim();
123 if trimmed.is_empty() {
124 Err(error)
125 } else {
126 Ok(trimmed.to_string())
127 }
128}
129
130#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
132pub struct RefspecSource(String);
133
134impl RefspecSource {
135 pub fn new(value: impl AsRef<str>) -> Result<Self, RefspecParseError> {
141 non_empty(value.as_ref(), RefspecParseError::EmptySource).map(Self)
142 }
143
144 #[must_use]
146 pub fn as_str(&self) -> &str {
147 &self.0
148 }
149}
150
151impl AsRef<str> for RefspecSource {
152 fn as_ref(&self) -> &str {
153 self.as_str()
154 }
155}
156
157impl fmt::Display for RefspecSource {
158 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
159 formatter.write_str(self.as_str())
160 }
161}
162
163#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
165pub struct RefspecDestination(String);
166
167impl RefspecDestination {
168 pub fn new(value: impl AsRef<str>) -> Result<Self, RefspecParseError> {
174 non_empty(value.as_ref(), RefspecParseError::EmptyDestination).map(Self)
175 }
176
177 #[must_use]
179 pub fn as_str(&self) -> &str {
180 &self.0
181 }
182}
183
184impl AsRef<str> for RefspecDestination {
185 fn as_ref(&self) -> &str {
186 self.as_str()
187 }
188}
189
190impl fmt::Display for RefspecDestination {
191 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
192 formatter.write_str(self.as_str())
193 }
194}
195
196#[derive(Clone, Debug, Eq, PartialEq)]
198pub struct GitRefspec {
199 source: RefspecSource,
200 destination: Option<RefspecDestination>,
201 direction: RefspecDirection,
202 mode: RefspecMode,
203}
204
205impl GitRefspec {
206 #[must_use]
208 pub const fn new(
209 source: RefspecSource,
210 destination: Option<RefspecDestination>,
211 direction: RefspecDirection,
212 mode: RefspecMode,
213 ) -> Self {
214 Self {
215 source,
216 destination,
217 direction,
218 mode,
219 }
220 }
221
222 pub fn parse(value: impl AsRef<str>) -> Result<Self, RefspecParseError> {
228 Self::parse_with_direction(value, RefspecDirection::Fetch)
229 }
230
231 pub fn parse_with_direction(
237 value: impl AsRef<str>,
238 direction: RefspecDirection,
239 ) -> Result<Self, RefspecParseError> {
240 let trimmed = value.as_ref().trim();
241 if trimmed.is_empty() {
242 return Err(RefspecParseError::Empty);
243 }
244
245 let (mode, body) = trimmed
246 .strip_prefix('+')
247 .map_or((RefspecMode::Normal, trimmed), |rest| {
248 (RefspecMode::Force, rest)
249 });
250
251 if body.matches(':').count() > 1 {
252 return Err(RefspecParseError::TooManySeparators);
253 }
254
255 let (source, destination) = match body.split_once(':') {
256 Some((source, destination)) => (
257 RefspecSource::new(source)?,
258 Some(RefspecDestination::new(destination)?),
259 ),
260 None => (RefspecSource::new(body)?, None),
261 };
262
263 Ok(Self::new(source, destination, direction, mode))
264 }
265
266 #[must_use]
268 pub const fn source(&self) -> &RefspecSource {
269 &self.source
270 }
271
272 #[must_use]
274 pub const fn destination(&self) -> Option<&RefspecDestination> {
275 self.destination.as_ref()
276 }
277
278 #[must_use]
280 pub const fn direction(&self) -> RefspecDirection {
281 self.direction
282 }
283
284 #[must_use]
286 pub const fn mode(&self) -> RefspecMode {
287 self.mode
288 }
289
290 #[must_use]
292 pub fn is_wildcard(&self) -> bool {
293 self.source.as_str().contains('*')
294 || self
295 .destination
296 .as_ref()
297 .is_some_and(|destination| destination.as_str().contains('*'))
298 }
299}
300
301impl fmt::Display for GitRefspec {
302 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
303 if self.mode == RefspecMode::Force {
304 formatter.write_str("+")?;
305 }
306 formatter.write_str(self.source.as_str())?;
307 if let Some(destination) = &self.destination {
308 write!(formatter, ":{destination}")?;
309 }
310 Ok(())
311 }
312}
313
314impl FromStr for GitRefspec {
315 type Err = RefspecParseError;
316
317 fn from_str(value: &str) -> Result<Self, Self::Err> {
318 Self::parse(value)
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::{GitRefspec, RefspecDirection, RefspecMode, RefspecParseError};
325
326 #[test]
327 fn parses_force_wildcard_refspec() -> Result<(), RefspecParseError> {
328 let spec = GitRefspec::parse("+refs/heads/*:refs/remotes/origin/*")?;
329
330 assert_eq!(spec.mode(), RefspecMode::Force);
331 assert_eq!(spec.direction(), RefspecDirection::Fetch);
332 assert!(spec.is_wildcard());
333 assert_eq!(spec.to_string(), "+refs/heads/*:refs/remotes/origin/*");
334 Ok(())
335 }
336
337 #[test]
338 fn rejects_invalid_refspecs() {
339 assert_eq!(GitRefspec::parse(""), Err(RefspecParseError::Empty));
340 assert_eq!(GitRefspec::parse(":"), Err(RefspecParseError::EmptySource));
341 assert_eq!(
342 GitRefspec::parse("a:b:c"),
343 Err(RefspecParseError::TooManySeparators)
344 );
345 }
346}