Skip to main content

use_git_revision/
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 revision vocabulary.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum RevisionParseError {
10    /// The supplied revision text was empty.
11    Empty,
12    /// The supplied range side was empty.
13    EmptyRangeSide,
14    /// The supplied suffix number was zero.
15    ZeroSuffixCount,
16    /// The supplied range kind label was not recognized.
17    UnknownRangeKind,
18}
19
20impl fmt::Display for RevisionParseError {
21    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
22        match self {
23            Self::Empty => formatter.write_str("Git revision cannot be empty"),
24            Self::EmptyRangeSide => formatter.write_str("Git revision range sides cannot be empty"),
25            Self::ZeroSuffixCount => {
26                formatter.write_str("Git revision suffix count cannot be zero")
27            },
28            Self::UnknownRangeKind => formatter.write_str("unknown Git revision range kind"),
29        }
30    }
31}
32
33impl Error for RevisionParseError {}
34
35fn non_empty(
36    value: impl AsRef<str>,
37    error: RevisionParseError,
38) -> Result<String, RevisionParseError> {
39    let trimmed = value.as_ref().trim();
40    if trimmed.is_empty() {
41        Err(error)
42    } else {
43        Ok(trimmed.to_string())
44    }
45}
46
47/// A revision selector classification.
48#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
49pub enum RevisionSelector {
50    /// `HEAD` selector.
51    Head,
52    /// A branch selector.
53    Branch(String),
54    /// A tag selector.
55    Tag(String),
56    /// A full ref selector.
57    Ref(String),
58    /// Object identifier text.
59    Oid(String),
60    /// Other selector text.
61    Other(String),
62}
63
64impl fmt::Display for RevisionSelector {
65    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
66        match self {
67            Self::Head => formatter.write_str("HEAD"),
68            Self::Branch(value)
69            | Self::Tag(value)
70            | Self::Ref(value)
71            | Self::Oid(value)
72            | Self::Other(value) => formatter.write_str(value),
73        }
74    }
75}
76
77/// A revision suffix such as `^` or `~2`.
78#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
79pub enum RevisionSuffix {
80    /// First parent suffix, displayed as `^`.
81    Parent,
82    /// Numbered parent suffix, displayed as `^n`.
83    ParentNumber(u32),
84    /// Ancestor suffix, displayed as `~n`.
85    Ancestor(u32),
86}
87
88impl RevisionSuffix {
89    /// Creates a numbered parent suffix.
90    ///
91    /// # Errors
92    ///
93    /// Returns [`RevisionParseError::ZeroSuffixCount`] when `number` is zero.
94    pub const fn parent_number(number: u32) -> Result<Self, RevisionParseError> {
95        if number == 0 {
96            Err(RevisionParseError::ZeroSuffixCount)
97        } else {
98            Ok(Self::ParentNumber(number))
99        }
100    }
101
102    /// Creates an ancestor suffix.
103    ///
104    /// # Errors
105    ///
106    /// Returns [`RevisionParseError::ZeroSuffixCount`] when `count` is zero.
107    pub const fn ancestor(count: u32) -> Result<Self, RevisionParseError> {
108        if count == 0 {
109            Err(RevisionParseError::ZeroSuffixCount)
110        } else {
111            Ok(Self::Ancestor(count))
112        }
113    }
114}
115
116impl fmt::Display for RevisionSuffix {
117    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
118        match self {
119            Self::Parent => formatter.write_str("^"),
120            Self::ParentNumber(number) => write!(formatter, "^{number}"),
121            Self::Ancestor(count) => write!(formatter, "~{count}"),
122        }
123    }
124}
125
126/// Revision range spelling.
127#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
128pub enum RevisionRangeKind {
129    /// Two-dot range, displayed as `A..B`.
130    TwoDot,
131    /// Three-dot range, displayed as `A...B`.
132    ThreeDot,
133}
134
135impl RevisionRangeKind {
136    /// Returns the separator used by this range kind.
137    #[must_use]
138    pub const fn separator(self) -> &'static str {
139        match self {
140            Self::TwoDot => "..",
141            Self::ThreeDot => "...",
142        }
143    }
144}
145
146impl fmt::Display for RevisionRangeKind {
147    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
148        formatter.write_str(self.separator())
149    }
150}
151
152impl FromStr for RevisionRangeKind {
153    type Err = RevisionParseError;
154
155    fn from_str(value: &str) -> Result<Self, Self::Err> {
156        match value.trim() {
157            ".." | "two-dot" | "twodot" => Ok(Self::TwoDot),
158            "..." | "three-dot" | "threedot" => Ok(Self::ThreeDot),
159            "" => Err(RevisionParseError::Empty),
160            _ => Err(RevisionParseError::UnknownRangeKind),
161        }
162    }
163}
164
165/// A lightweight revision selector text.
166#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
167pub struct GitRevision(String);
168
169impl GitRevision {
170    /// Creates a revision selector from text.
171    ///
172    /// # Errors
173    ///
174    /// Returns [`RevisionParseError::Empty`] when the selector is empty.
175    pub fn new(value: impl AsRef<str>) -> Result<Self, RevisionParseError> {
176        non_empty(value, RevisionParseError::Empty).map(Self)
177    }
178
179    /// Returns the `HEAD` selector.
180    #[must_use]
181    pub fn head() -> Self {
182        Self(String::from("HEAD"))
183    }
184
185    /// Returns a new selector with a suffix appended.
186    #[must_use]
187    pub fn with_suffix(&self, suffix: RevisionSuffix) -> Self {
188        Self(format!("{}{suffix}", self.as_str()))
189    }
190
191    /// Returns a broad selector classification.
192    #[must_use]
193    pub fn selector(&self) -> RevisionSelector {
194        let value = self.as_str();
195        if value == "HEAD" {
196            RevisionSelector::Head
197        } else if let Some(branch) = value.strip_prefix("refs/heads/") {
198            RevisionSelector::Branch(branch.to_string())
199        } else if let Some(tag) = value.strip_prefix("refs/tags/") {
200            RevisionSelector::Tag(tag.to_string())
201        } else if value.starts_with("refs/") {
202            RevisionSelector::Ref(value.to_string())
203        } else if is_oid_like(value) {
204            RevisionSelector::Oid(value.to_string())
205        } else {
206            RevisionSelector::Other(value.to_string())
207        }
208    }
209
210    /// Returns the revision text.
211    #[must_use]
212    pub fn as_str(&self) -> &str {
213        &self.0
214    }
215}
216
217impl AsRef<str> for GitRevision {
218    fn as_ref(&self) -> &str {
219        self.as_str()
220    }
221}
222
223impl fmt::Display for GitRevision {
224    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
225        formatter.write_str(self.as_str())
226    }
227}
228
229impl FromStr for GitRevision {
230    type Err = RevisionParseError;
231
232    fn from_str(value: &str) -> Result<Self, Self::Err> {
233        Self::new(value)
234    }
235}
236
237/// A revision range such as `A..B` or `A...B`.
238#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
239pub struct RevisionRange {
240    left: GitRevision,
241    right: GitRevision,
242    kind: RevisionRangeKind,
243}
244
245impl RevisionRange {
246    /// Creates a revision range.
247    ///
248    /// # Errors
249    ///
250    /// Returns [`RevisionParseError::EmptyRangeSide`] when either side is empty.
251    pub fn new(
252        left: impl AsRef<str>,
253        right: impl AsRef<str>,
254        kind: RevisionRangeKind,
255    ) -> Result<Self, RevisionParseError> {
256        let left = GitRevision(non_empty(left, RevisionParseError::EmptyRangeSide)?);
257        let right = GitRevision(non_empty(right, RevisionParseError::EmptyRangeSide)?);
258        Ok(Self { left, right, kind })
259    }
260
261    /// Returns the left side.
262    #[must_use]
263    pub const fn left(&self) -> &GitRevision {
264        &self.left
265    }
266
267    /// Returns the right side.
268    #[must_use]
269    pub const fn right(&self) -> &GitRevision {
270        &self.right
271    }
272
273    /// Returns the range kind.
274    #[must_use]
275    pub const fn kind(&self) -> RevisionRangeKind {
276        self.kind
277    }
278}
279
280impl fmt::Display for RevisionRange {
281    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
282        write!(
283            formatter,
284            "{}{}{}",
285            self.left,
286            self.kind.separator(),
287            self.right
288        )
289    }
290}
291
292fn is_oid_like(value: &str) -> bool {
293    matches!(value.len(), 40 | 64) && value.chars().all(|character| character.is_ascii_hexdigit())
294}
295
296#[cfg(test)]
297mod tests {
298    use super::{
299        GitRevision, RevisionParseError, RevisionRange, RevisionRangeKind, RevisionSelector,
300        RevisionSuffix,
301    };
302
303    #[test]
304    fn models_head_and_suffixes() -> Result<(), RevisionParseError> {
305        let revision = GitRevision::head().with_suffix(RevisionSuffix::Ancestor(2));
306
307        assert_eq!(revision.as_str(), "HEAD~2");
308        assert_eq!(GitRevision::head().selector(), RevisionSelector::Head);
309        assert_eq!(RevisionSuffix::parent_number(2)?.to_string(), "^2");
310        Ok(())
311    }
312
313    #[test]
314    fn models_revision_ranges() -> Result<(), RevisionParseError> {
315        let range = RevisionRange::new("main", "feature/use-git", RevisionRangeKind::ThreeDot)?;
316
317        assert_eq!(range.to_string(), "main...feature/use-git");
318        assert_eq!(range.kind(), RevisionRangeKind::ThreeDot);
319        Ok(())
320    }
321
322    #[test]
323    fn rejects_empty_revisions() {
324        assert_eq!(GitRevision::new(""), Err(RevisionParseError::Empty));
325        assert_eq!(
326            RevisionRange::new("", "main", RevisionRangeKind::TwoDot),
327            Err(RevisionParseError::EmptyRangeSide)
328        );
329    }
330}