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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
13pub enum GitOidKind {
14 Sha1,
16 Sha256,
18}
19
20impl GitOidKind {
21 #[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 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub enum GitOidParseError {
62 Empty,
64 InvalidLength(usize),
66 InvalidShortLength(usize),
68 NonHexCharacter { index: usize, character: char },
70 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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
117pub struct GitOid {
118 value: String,
119 kind: GitOidKind,
120}
121
122impl GitOid {
123 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 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 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 #[must_use]
170 pub const fn kind(&self) -> GitOidKind {
171 self.kind
172 }
173
174 #[must_use]
176 pub fn as_str(&self) -> &str {
177 &self.value
178 }
179
180 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
217pub struct ShortGitOid(String);
218
219impl ShortGitOid {
220 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 #[must_use]
239 pub fn as_str(&self) -> &str {
240 &self.0
241 }
242
243 #[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}