Skip to main content

use_git_refspec/
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 refspec vocabulary.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum RefspecParseError {
10    /// The supplied refspec text was empty.
11    Empty,
12    /// The supplied refspec source was empty.
13    EmptySource,
14    /// The supplied refspec destination was empty.
15    EmptyDestination,
16    /// The supplied refspec contained more separators than this crate models.
17    TooManySeparators,
18    /// The supplied direction label was not recognized.
19    UnknownDirection,
20    /// The supplied mode label was not recognized.
21    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/// Refspec direction vocabulary.
44#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
45pub enum RefspecDirection {
46    /// Fetch refspec vocabulary.
47    Fetch,
48    /// Push refspec vocabulary.
49    Push,
50}
51
52impl RefspecDirection {
53    /// Returns the stable label.
54    #[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/// Refspec force mode vocabulary.
83#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
84pub enum RefspecMode {
85    /// A normal refspec.
86    Normal,
87    /// A force refspec prefixed by `+`.
88    Force,
89}
90
91impl RefspecMode {
92    /// Returns the stable label.
93    #[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/// Refspec source text.
131#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
132pub struct RefspecSource(String);
133
134impl RefspecSource {
135    /// Creates a refspec source.
136    ///
137    /// # Errors
138    ///
139    /// Returns [`RefspecParseError::EmptySource`] when the source is empty.
140    pub fn new(value: impl AsRef<str>) -> Result<Self, RefspecParseError> {
141        non_empty(value.as_ref(), RefspecParseError::EmptySource).map(Self)
142    }
143
144    /// Returns the source text.
145    #[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/// Refspec destination text.
164#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
165pub struct RefspecDestination(String);
166
167impl RefspecDestination {
168    /// Creates a refspec destination.
169    ///
170    /// # Errors
171    ///
172    /// Returns [`RefspecParseError::EmptyDestination`] when the destination is empty.
173    pub fn new(value: impl AsRef<str>) -> Result<Self, RefspecParseError> {
174        non_empty(value.as_ref(), RefspecParseError::EmptyDestination).map(Self)
175    }
176
177    /// Returns the destination text.
178    #[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/// A lightweight refspec model.
197#[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    /// Creates a refspec from parts.
207    #[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    /// Parses a fetch-oriented refspec from text.
223    ///
224    /// # Errors
225    ///
226    /// Returns [`RefspecParseError`] when the refspec is empty or malformed.
227    pub fn parse(value: impl AsRef<str>) -> Result<Self, RefspecParseError> {
228        Self::parse_with_direction(value, RefspecDirection::Fetch)
229    }
230
231    /// Parses a refspec from text with explicit direction vocabulary.
232    ///
233    /// # Errors
234    ///
235    /// Returns [`RefspecParseError`] when the refspec is empty or malformed.
236    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    /// Returns the source side.
267    #[must_use]
268    pub const fn source(&self) -> &RefspecSource {
269        &self.source
270    }
271
272    /// Returns the destination side when present.
273    #[must_use]
274    pub const fn destination(&self) -> Option<&RefspecDestination> {
275        self.destination.as_ref()
276    }
277
278    /// Returns the direction vocabulary.
279    #[must_use]
280    pub const fn direction(&self) -> RefspecDirection {
281        self.direction
282    }
283
284    /// Returns the force mode vocabulary.
285    #[must_use]
286    pub const fn mode(&self) -> RefspecMode {
287        self.mode
288    }
289
290    /// Returns true when either side contains `*`.
291    #[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}