Skip to main content

use_git_ref/
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 ref vocabulary.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum GitRefParseError {
10    /// The supplied ref text was empty.
11    Empty,
12    /// The supplied ref name used syntax this crate rejects.
13    InvalidName,
14    /// The supplied detached `HEAD` target was empty.
15    EmptyDetachedTarget,
16}
17
18impl fmt::Display for GitRefParseError {
19    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
20        match self {
21            Self::Empty => formatter.write_str("Git ref name cannot be empty"),
22            Self::InvalidName => formatter.write_str("invalid Git ref name"),
23            Self::EmptyDetachedTarget => {
24                formatter.write_str("detached HEAD target cannot be empty")
25            },
26        }
27    }
28}
29
30impl Error for GitRefParseError {}
31
32/// A broad ref-name kind.
33#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
34pub enum GitRefKind {
35    /// The `HEAD` pseudo-ref.
36    Head,
37    /// A branch ref under `refs/heads/`.
38    Branch,
39    /// A tag ref under `refs/tags/`.
40    Tag,
41    /// A remote-tracking ref under `refs/remotes/`.
42    Remote,
43    /// Another syntactically valid ref name.
44    Other,
45}
46
47impl GitRefKind {
48    /// Returns the stable kind label.
49    #[must_use]
50    pub const fn as_str(self) -> &'static str {
51        match self {
52            Self::Head => "head",
53            Self::Branch => "branch",
54            Self::Tag => "tag",
55            Self::Remote => "remote",
56            Self::Other => "other",
57        }
58    }
59}
60
61impl fmt::Display for GitRefKind {
62    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
63        formatter.write_str(self.as_str())
64    }
65}
66
67fn ref_kind(value: &str) -> GitRefKind {
68    if value == "HEAD" {
69        GitRefKind::Head
70    } else if value.starts_with("refs/heads/") {
71        GitRefKind::Branch
72    } else if value.starts_with("refs/tags/") {
73        GitRefKind::Tag
74    } else if value.starts_with("refs/remotes/") {
75        GitRefKind::Remote
76    } else {
77        GitRefKind::Other
78    }
79}
80
81fn has_lock_suffix(value: &str) -> bool {
82    value
83        .get(value.len().saturating_sub(5)..)
84        .is_some_and(|suffix| suffix.eq_ignore_ascii_case(".lock"))
85}
86
87fn validate_ref_name(value: impl AsRef<str>) -> Result<String, GitRefParseError> {
88    let trimmed = value.as_ref().trim();
89
90    if trimmed.is_empty() {
91        return Err(GitRefParseError::Empty);
92    }
93
94    if trimmed == "HEAD" {
95        return Ok(trimmed.to_string());
96    }
97
98    let invalid = trimmed.starts_with('/')
99        || trimmed.ends_with('/')
100        || trimmed.starts_with('.')
101        || trimmed.ends_with('.')
102        || has_lock_suffix(trimmed)
103        || trimmed.contains("//")
104        || trimmed.contains("..")
105        || trimmed.contains("@{")
106        || trimmed.chars().any(|character| {
107            character.is_ascii_control()
108                || character.is_ascii_whitespace()
109                || matches!(character, '~' | '^' | ':' | '?' | '*' | '[' | '\\')
110        })
111        || trimmed.split('/').any(|component| component.ends_with('.'));
112
113    if invalid {
114        Err(GitRefParseError::InvalidName)
115    } else {
116        Ok(trimmed.to_string())
117    }
118}
119
120/// A validated ref name.
121#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
122pub struct GitRefName {
123    value: String,
124    kind: GitRefKind,
125}
126
127impl GitRefName {
128    /// Creates a ref name from text.
129    ///
130    /// # Errors
131    ///
132    /// Returns [`GitRefParseError`] when the name is empty or uses rejected syntax.
133    pub fn new(value: impl AsRef<str>) -> Result<Self, GitRefParseError> {
134        let value = validate_ref_name(value)?;
135        let kind = ref_kind(&value);
136        Ok(Self { value, kind })
137    }
138
139    /// Returns the broad ref kind.
140    #[must_use]
141    pub const fn kind(&self) -> GitRefKind {
142        self.kind
143    }
144
145    /// Returns true when this is `HEAD`.
146    #[must_use]
147    pub const fn is_head(&self) -> bool {
148        matches!(self.kind, GitRefKind::Head)
149    }
150
151    /// Returns true when this is a branch ref.
152    #[must_use]
153    pub const fn is_branch(&self) -> bool {
154        matches!(self.kind, GitRefKind::Branch)
155    }
156
157    /// Returns true when this is a tag ref.
158    #[must_use]
159    pub const fn is_tag(&self) -> bool {
160        matches!(self.kind, GitRefKind::Tag)
161    }
162
163    /// Returns true when this is a remote-tracking ref.
164    #[must_use]
165    pub const fn is_remote(&self) -> bool {
166        matches!(self.kind, GitRefKind::Remote)
167    }
168
169    /// Returns the ref name text.
170    #[must_use]
171    pub fn as_str(&self) -> &str {
172        &self.value
173    }
174}
175
176impl AsRef<str> for GitRefName {
177    fn as_ref(&self) -> &str {
178        self.as_str()
179    }
180}
181
182impl fmt::Display for GitRefName {
183    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
184        formatter.write_str(self.as_str())
185    }
186}
187
188impl FromStr for GitRefName {
189    type Err = GitRefParseError;
190
191    fn from_str(value: &str) -> Result<Self, Self::Err> {
192        Self::new(value)
193    }
194}
195
196impl TryFrom<&str> for GitRefName {
197    type Error = GitRefParseError;
198
199    fn try_from(value: &str) -> Result<Self, Self::Error> {
200        Self::new(value)
201    }
202}
203
204/// A concrete ref wrapper.
205#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
206pub struct GitRef(GitRefName);
207
208impl GitRef {
209    /// Creates a concrete ref from a ref name.
210    #[must_use]
211    pub const fn from_name(name: GitRefName) -> Self {
212        Self(name)
213    }
214
215    /// Parses a concrete ref from text.
216    ///
217    /// # Errors
218    ///
219    /// Returns [`GitRefParseError`] when the ref name is invalid.
220    pub fn new(value: impl AsRef<str>) -> Result<Self, GitRefParseError> {
221        GitRefName::new(value).map(Self)
222    }
223
224    /// Returns the ref name.
225    #[must_use]
226    pub const fn name(&self) -> &GitRefName {
227        &self.0
228    }
229
230    /// Returns the ref kind.
231    #[must_use]
232    pub const fn kind(&self) -> GitRefKind {
233        self.0.kind()
234    }
235
236    /// Returns the ref text.
237    #[must_use]
238    pub fn as_str(&self) -> &str {
239        self.0.as_str()
240    }
241}
242
243impl AsRef<str> for GitRef {
244    fn as_ref(&self) -> &str {
245        self.as_str()
246    }
247}
248
249impl fmt::Display for GitRef {
250    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
251        formatter.write_str(self.as_str())
252    }
253}
254
255impl FromStr for GitRef {
256    type Err = GitRefParseError;
257
258    fn from_str(value: &str) -> Result<Self, Self::Err> {
259        Self::new(value)
260    }
261}
262
263/// A symbolic ref target.
264#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
265pub struct SymbolicRef {
266    target: GitRefName,
267}
268
269impl SymbolicRef {
270    /// Creates a symbolic ref target from a validated ref name.
271    #[must_use]
272    pub const fn new(target: GitRefName) -> Self {
273        Self { target }
274    }
275
276    /// Parses a symbolic ref target from text.
277    ///
278    /// # Errors
279    ///
280    /// Returns [`GitRefParseError`] when the target ref name is invalid.
281    pub fn parse(value: impl AsRef<str>) -> Result<Self, GitRefParseError> {
282        GitRefName::new(value).map(Self::new)
283    }
284
285    /// Returns the target ref name.
286    #[must_use]
287    pub const fn target(&self) -> &GitRefName {
288        &self.target
289    }
290
291    /// Returns the target ref text.
292    #[must_use]
293    pub fn as_str(&self) -> &str {
294        self.target.as_str()
295    }
296}
297
298impl fmt::Display for SymbolicRef {
299    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
300        formatter.write_str(self.as_str())
301    }
302}
303
304impl FromStr for SymbolicRef {
305    type Err = GitRefParseError;
306
307    fn from_str(value: &str) -> Result<Self, Self::Err> {
308        Self::parse(value)
309    }
310}
311
312/// A lightweight `HEAD` vocabulary value.
313#[derive(Clone, Debug, Eq, PartialEq)]
314pub enum GitHead {
315    /// `HEAD` exists as a symbolic ref.
316    Symbolic(SymbolicRef),
317    /// `HEAD` names object identifier text directly.
318    Detached(String),
319    /// `HEAD` is known as vocabulary, but no target is modeled.
320    Unborn,
321}
322
323impl GitHead {
324    /// Creates symbolic `HEAD` from a target ref name.
325    #[must_use]
326    pub const fn symbolic(target: GitRefName) -> Self {
327        Self::Symbolic(SymbolicRef::new(target))
328    }
329
330    /// Creates detached `HEAD` from object identifier text.
331    ///
332    /// # Errors
333    ///
334    /// Returns [`GitRefParseError::EmptyDetachedTarget`] when the supplied text is empty.
335    pub fn detached(value: impl AsRef<str>) -> Result<Self, GitRefParseError> {
336        let trimmed = value.as_ref().trim();
337        if trimmed.is_empty() {
338            Err(GitRefParseError::EmptyDetachedTarget)
339        } else {
340            Ok(Self::Detached(trimmed.to_string()))
341        }
342    }
343
344    /// Returns true when `HEAD` is symbolic.
345    #[must_use]
346    pub const fn is_symbolic(&self) -> bool {
347        matches!(self, Self::Symbolic(_))
348    }
349
350    /// Returns true when `HEAD` is detached.
351    #[must_use]
352    pub const fn is_detached(&self) -> bool {
353        matches!(self, Self::Detached(_))
354    }
355
356    /// Returns the symbolic target when present.
357    #[must_use]
358    pub const fn symbolic_ref(&self) -> Option<&SymbolicRef> {
359        match self {
360            Self::Symbolic(symbolic) => Some(symbolic),
361            Self::Detached(_) | Self::Unborn => None,
362        }
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::{GitHead, GitRefKind, GitRefName, GitRefParseError, SymbolicRef};
369
370    #[test]
371    fn classifies_common_refs() -> Result<(), GitRefParseError> {
372        let branch = GitRefName::new("refs/heads/main")?;
373        let tag = GitRefName::new("refs/tags/v1.0.0")?;
374        let remote = GitRefName::new("refs/remotes/origin/main")?;
375
376        assert_eq!(branch.kind(), GitRefKind::Branch);
377        assert_eq!(tag.kind(), GitRefKind::Tag);
378        assert_eq!(remote.kind(), GitRefKind::Remote);
379        Ok(())
380    }
381
382    #[test]
383    fn models_symbolic_head() -> Result<(), GitRefParseError> {
384        let symbolic = SymbolicRef::parse("refs/heads/main")?;
385        let head = GitHead::Symbolic(symbolic);
386
387        assert!(head.is_symbolic());
388        assert_eq!(
389            head.symbolic_ref().map(SymbolicRef::as_str),
390            Some("refs/heads/main")
391        );
392        Ok(())
393    }
394
395    #[test]
396    fn rejects_invalid_refs() {
397        assert_eq!(GitRefName::new(""), Err(GitRefParseError::Empty));
398        assert_eq!(
399            GitRefName::new("refs/heads/main.lock"),
400            Err(GitRefParseError::InvalidName)
401        );
402        assert_eq!(
403            GitRefName::new("refs/heads/with space"),
404            Err(GitRefParseError::InvalidName)
405        );
406    }
407}