Skip to main content

use_git_oid/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7const SHA1_HEX_LEN: usize = 40;
8const SHA256_HEX_LEN: usize = 64;
9const MIN_SHORT_HEX_LEN: usize = 4;
10
11/// Supported `Git` object identifier text shapes.
12#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
13pub enum GitOidKind {
14    /// A 40-character `SHA-1` object identifier.
15    Sha1,
16    /// A 64-character `SHA-256` object identifier shape.
17    Sha256,
18}
19
20impl GitOidKind {
21    /// Returns the canonical label.
22    #[must_use]
23    pub const fn as_str(self) -> &'static str {
24        match self {
25            Self::Sha1 => "sha1",
26            Self::Sha256 => "sha256",
27        }
28    }
29
30    /// Returns the full hexadecimal length for this identifier kind.
31    #[must_use]
32    pub const fn hex_len(self) -> usize {
33        match self {
34            Self::Sha1 => SHA1_HEX_LEN,
35            Self::Sha256 => SHA256_HEX_LEN,
36        }
37    }
38}
39
40impl fmt::Display for GitOidKind {
41    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
42        formatter.write_str(self.as_str())
43    }
44}
45
46impl FromStr for GitOidKind {
47    type Err = GitOidParseError;
48
49    fn from_str(value: &str) -> Result<Self, Self::Err> {
50        match value.trim().to_ascii_lowercase().as_str() {
51            "sha1" | "sha-1" => Ok(Self::Sha1),
52            "sha256" | "sha-256" => Ok(Self::Sha256),
53            "" => Err(GitOidParseError::Empty),
54            _ => Err(GitOidParseError::UnknownKind),
55        }
56    }
57}
58
59/// Error returned while parsing object identifier text.
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub enum GitOidParseError {
62    /// The supplied identifier text was empty.
63    Empty,
64    /// The full identifier length was not a supported shape.
65    InvalidLength(usize),
66    /// The short identifier length was outside the accepted range.
67    InvalidShortLength(usize),
68    /// The identifier contained a non-hexadecimal character.
69    NonHexCharacter { index: usize, character: char },
70    /// The object identifier kind label was not recognized.
71    UnknownKind,
72}
73
74impl fmt::Display for GitOidParseError {
75    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
76        match self {
77            Self::Empty => formatter.write_str("Git object identifier cannot be empty"),
78            Self::InvalidLength(length) => write!(
79                formatter,
80                "Git object identifier length must be 40 or 64 hex characters, got {length}"
81            ),
82            Self::InvalidShortLength(length) => write!(
83                formatter,
84                "short Git object identifier length must be between 4 and 64 hex characters, got {length}"
85            ),
86            Self::NonHexCharacter { index, character } => {
87                write!(
88                    formatter,
89                    "invalid hex character `{character}` at index {index}"
90                )
91            },
92            Self::UnknownKind => formatter.write_str("unknown Git object identifier kind"),
93        }
94    }
95}
96
97impl Error for GitOidParseError {}
98
99fn normalized_hex(value: &str) -> Result<String, GitOidParseError> {
100    let trimmed = value.trim();
101
102    if trimmed.is_empty() {
103        return Err(GitOidParseError::Empty);
104    }
105
106    for (index, character) in trimmed.chars().enumerate() {
107        if !character.is_ascii_hexdigit() {
108            return Err(GitOidParseError::NonHexCharacter { index, character });
109        }
110    }
111
112    Ok(trimmed.to_ascii_lowercase())
113}
114
115/// A full `Git` object identifier.
116#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
117pub struct GitOid {
118    value: String,
119    kind: GitOidKind,
120}
121
122impl GitOid {
123    /// Creates a full object identifier from hexadecimal text.
124    ///
125    /// # Errors
126    ///
127    /// Returns [`GitOidParseError`] when the text is empty, has an unsupported
128    /// length, or contains non-hexadecimal characters.
129    pub fn new(value: impl AsRef<str>) -> Result<Self, GitOidParseError> {
130        let value = normalized_hex(value.as_ref())?;
131        let kind = match value.len() {
132            SHA1_HEX_LEN => GitOidKind::Sha1,
133            SHA256_HEX_LEN => GitOidKind::Sha256,
134            length => return Err(GitOidParseError::InvalidLength(length)),
135        };
136
137        Ok(Self { value, kind })
138    }
139
140    /// Creates a `SHA-1` object identifier.
141    ///
142    /// # Errors
143    ///
144    /// Returns [`GitOidParseError`] when validation fails or the length is not 40.
145    pub fn sha1(value: impl AsRef<str>) -> Result<Self, GitOidParseError> {
146        let oid = Self::new(value)?;
147        if oid.kind == GitOidKind::Sha1 {
148            Ok(oid)
149        } else {
150            Err(GitOidParseError::InvalidLength(oid.value.len()))
151        }
152    }
153
154    /// Creates a `SHA-256`-shaped object identifier.
155    ///
156    /// # Errors
157    ///
158    /// Returns [`GitOidParseError`] when validation fails or the length is not 64.
159    pub fn sha256(value: impl AsRef<str>) -> Result<Self, GitOidParseError> {
160        let oid = Self::new(value)?;
161        if oid.kind == GitOidKind::Sha256 {
162            Ok(oid)
163        } else {
164            Err(GitOidParseError::InvalidLength(oid.value.len()))
165        }
166    }
167
168    /// Returns the identifier kind inferred from its length.
169    #[must_use]
170    pub const fn kind(&self) -> GitOidKind {
171        self.kind
172    }
173
174    /// Returns the identifier text.
175    #[must_use]
176    pub fn as_str(&self) -> &str {
177        &self.value
178    }
179
180    /// Consumes the identifier and returns the owned text.
181    #[must_use]
182    pub fn into_string(self) -> String {
183        self.value
184    }
185}
186
187impl AsRef<str> for GitOid {
188    fn as_ref(&self) -> &str {
189        self.as_str()
190    }
191}
192
193impl fmt::Display for GitOid {
194    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
195        formatter.write_str(self.as_str())
196    }
197}
198
199impl FromStr for GitOid {
200    type Err = GitOidParseError;
201
202    fn from_str(value: &str) -> Result<Self, Self::Err> {
203        Self::new(value)
204    }
205}
206
207impl TryFrom<&str> for GitOid {
208    type Error = GitOidParseError;
209
210    fn try_from(value: &str) -> Result<Self, Self::Error> {
211        Self::new(value)
212    }
213}
214
215/// A short object identifier prefix.
216#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
217pub struct ShortGitOid(String);
218
219impl ShortGitOid {
220    /// Creates a short object identifier prefix from hexadecimal text.
221    ///
222    /// # Errors
223    ///
224    /// Returns [`GitOidParseError`] when the text is empty, too short, too long,
225    /// or contains non-hexadecimal characters.
226    pub fn new(value: impl AsRef<str>) -> Result<Self, GitOidParseError> {
227        let value = normalized_hex(value.as_ref())?;
228        let length = value.len();
229
230        if !(MIN_SHORT_HEX_LEN..=SHA256_HEX_LEN).contains(&length) {
231            return Err(GitOidParseError::InvalidShortLength(length));
232        }
233
234        Ok(Self(value))
235    }
236
237    /// Returns the identifier prefix text.
238    #[must_use]
239    pub fn as_str(&self) -> &str {
240        &self.0
241    }
242
243    /// Returns the best known object identifier kind from the prefix length.
244    #[must_use]
245    pub const fn kind_hint(&self) -> Option<GitOidKind> {
246        match self.0.len() {
247            SHA1_HEX_LEN => Some(GitOidKind::Sha1),
248            SHA256_HEX_LEN => Some(GitOidKind::Sha256),
249            _ => None,
250        }
251    }
252}
253
254impl AsRef<str> for ShortGitOid {
255    fn as_ref(&self) -> &str {
256        self.as_str()
257    }
258}
259
260impl fmt::Display for ShortGitOid {
261    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
262        formatter.write_str(self.as_str())
263    }
264}
265
266impl FromStr for ShortGitOid {
267    type Err = GitOidParseError;
268
269    fn from_str(value: &str) -> Result<Self, Self::Err> {
270        Self::new(value)
271    }
272}
273
274impl TryFrom<&str> for ShortGitOid {
275    type Error = GitOidParseError;
276
277    fn try_from(value: &str) -> Result<Self, Self::Error> {
278        Self::new(value)
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::{GitOid, GitOidKind, GitOidParseError, ShortGitOid};
285
286    #[test]
287    fn parses_sha1_oid() -> Result<(), GitOidParseError> {
288        let oid = GitOid::new("0123456789ABCDEF0123456789abcdef01234567")?;
289
290        assert_eq!(oid.kind(), GitOidKind::Sha1);
291        assert_eq!(oid.as_str(), "0123456789abcdef0123456789abcdef01234567");
292        Ok(())
293    }
294
295    #[test]
296    fn parses_sha256_oid() -> Result<(), GitOidParseError> {
297        let oid = GitOid::new("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")?;
298
299        assert_eq!(oid.kind(), GitOidKind::Sha256);
300        Ok(())
301    }
302
303    #[test]
304    fn rejects_invalid_oids() {
305        assert_eq!(GitOid::new(""), Err(GitOidParseError::Empty));
306        assert_eq!(GitOid::new("abc"), Err(GitOidParseError::InvalidLength(3)));
307        assert_eq!(
308            GitOid::new("0123456789abcdef0123456789abcdef0123456z"),
309            Err(GitOidParseError::NonHexCharacter {
310                index: 39,
311                character: 'z'
312            })
313        );
314    }
315
316    #[test]
317    fn parses_short_oid() -> Result<(), GitOidParseError> {
318        let oid = ShortGitOid::new("AbCd")?;
319
320        assert_eq!(oid.as_str(), "abcd");
321        assert_eq!(oid.kind_hint(), None);
322        Ok(())
323    }
324}