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 GitRefParseError {
10 Empty,
12 InvalidName,
14 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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
34pub enum GitRefKind {
35 Head,
37 Branch,
39 Tag,
41 Remote,
43 Other,
45}
46
47impl GitRefKind {
48 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
122pub struct GitRefName {
123 value: String,
124 kind: GitRefKind,
125}
126
127impl GitRefName {
128 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 #[must_use]
141 pub const fn kind(&self) -> GitRefKind {
142 self.kind
143 }
144
145 #[must_use]
147 pub const fn is_head(&self) -> bool {
148 matches!(self.kind, GitRefKind::Head)
149 }
150
151 #[must_use]
153 pub const fn is_branch(&self) -> bool {
154 matches!(self.kind, GitRefKind::Branch)
155 }
156
157 #[must_use]
159 pub const fn is_tag(&self) -> bool {
160 matches!(self.kind, GitRefKind::Tag)
161 }
162
163 #[must_use]
165 pub const fn is_remote(&self) -> bool {
166 matches!(self.kind, GitRefKind::Remote)
167 }
168
169 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
206pub struct GitRef(GitRefName);
207
208impl GitRef {
209 #[must_use]
211 pub const fn from_name(name: GitRefName) -> Self {
212 Self(name)
213 }
214
215 pub fn new(value: impl AsRef<str>) -> Result<Self, GitRefParseError> {
221 GitRefName::new(value).map(Self)
222 }
223
224 #[must_use]
226 pub const fn name(&self) -> &GitRefName {
227 &self.0
228 }
229
230 #[must_use]
232 pub const fn kind(&self) -> GitRefKind {
233 self.0.kind()
234 }
235
236 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
265pub struct SymbolicRef {
266 target: GitRefName,
267}
268
269impl SymbolicRef {
270 #[must_use]
272 pub const fn new(target: GitRefName) -> Self {
273 Self { target }
274 }
275
276 pub fn parse(value: impl AsRef<str>) -> Result<Self, GitRefParseError> {
282 GitRefName::new(value).map(Self::new)
283 }
284
285 #[must_use]
287 pub const fn target(&self) -> &GitRefName {
288 &self.target
289 }
290
291 #[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#[derive(Clone, Debug, Eq, PartialEq)]
314pub enum GitHead {
315 Symbolic(SymbolicRef),
317 Detached(String),
319 Unborn,
321}
322
323impl GitHead {
324 #[must_use]
326 pub const fn symbolic(target: GitRefName) -> Self {
327 Self::Symbolic(SymbolicRef::new(target))
328 }
329
330 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 #[must_use]
346 pub const fn is_symbolic(&self) -> bool {
347 matches!(self, Self::Symbolic(_))
348 }
349
350 #[must_use]
352 pub const fn is_detached(&self) -> bool {
353 matches!(self, Self::Detached(_))
354 }
355
356 #[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}