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 RevisionParseError {
10 Empty,
12 EmptyRangeSide,
14 ZeroSuffixCount,
16 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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
49pub enum RevisionSelector {
50 Head,
52 Branch(String),
54 Tag(String),
56 Ref(String),
58 Oid(String),
60 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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
79pub enum RevisionSuffix {
80 Parent,
82 ParentNumber(u32),
84 Ancestor(u32),
86}
87
88impl RevisionSuffix {
89 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 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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
128pub enum RevisionRangeKind {
129 TwoDot,
131 ThreeDot,
133}
134
135impl RevisionRangeKind {
136 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
167pub struct GitRevision(String);
168
169impl GitRevision {
170 pub fn new(value: impl AsRef<str>) -> Result<Self, RevisionParseError> {
176 non_empty(value, RevisionParseError::Empty).map(Self)
177 }
178
179 #[must_use]
181 pub fn head() -> Self {
182 Self(String::from("HEAD"))
183 }
184
185 #[must_use]
187 pub fn with_suffix(&self, suffix: RevisionSuffix) -> Self {
188 Self(format!("{}{suffix}", self.as_str()))
189 }
190
191 #[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 #[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#[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 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 #[must_use]
263 pub const fn left(&self) -> &GitRevision {
264 &self.left
265 }
266
267 #[must_use]
269 pub const fn right(&self) -> &GitRevision {
270 &self.right
271 }
272
273 #[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}