1use std::borrow::Cow;
4
5#[derive(Clone, Debug, Eq, PartialEq)]
16pub struct Branch(Cow<'static, str>);
17
18impl Branch {
19 #[must_use]
21 pub fn as_str(&self) -> &str {
22 &self.0
23 }
24
25 #[must_use]
27 pub fn has_parents(&self) -> bool {
28 self.0.contains('/')
29 }
30
31 const fn is_forbidden_char(byte: u8) -> bool {
32 matches!(byte, b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\')
33 }
34
35 const fn validate(input: &str) -> Result<(), BranchError> {
36 if input.is_empty() {
37 return Err(BranchError::Empty);
38 }
39
40 let bytes = input.as_bytes();
41
42 if bytes.len() == 1 && bytes[0] == b'@' {
44 return Err(BranchError::SingleAt);
45 }
46
47 if bytes[0] == b'-' {
49 return Err(BranchError::StartsWithDash);
50 }
51 if bytes[0] == b'.' {
52 return Err(BranchError::StartsWithDot);
53 }
54 if bytes[0] == b'/' {
55 return Err(BranchError::StartsWithSlash);
56 }
57
58 if bytes[bytes.len() - 1] == b'/' {
60 return Err(BranchError::EndsWithSlash);
61 }
62 if bytes[bytes.len() - 1] == b'.' {
63 return Err(BranchError::EndsWithDot);
64 }
65
66 if bytes.len() >= 5
69 && bytes[bytes.len() - 5] == b'.'
70 && bytes[bytes.len() - 4] == b'l'
71 && bytes[bytes.len() - 3] == b'o'
72 && bytes[bytes.len() - 2] == b'c'
73 && bytes[bytes.len() - 1] == b'k'
74 {
75 return Err(BranchError::EndsWithLock);
76 }
77
78 let mut index = 0;
81 while index < bytes.len() {
82 let byte = bytes[index];
83
84 if byte < 0x20 || byte == 0x7f {
86 return Err(BranchError::ContainsControlCharacter);
87 }
88
89 if byte == b' ' {
91 return Err(BranchError::ContainsSpace);
92 }
93
94 if Self::is_forbidden_char(byte) {
96 return Err(BranchError::ContainsForbiddenCharacter);
97 }
98
99 if byte == b'.' && index + 1 < bytes.len() && bytes[index + 1] == b'.' {
101 return Err(BranchError::ContainsDoubleDot);
102 }
103
104 if byte == b'/' && index + 1 < bytes.len() && bytes[index + 1] == b'/' {
106 return Err(BranchError::ContainsDoubleSlash);
107 }
108
109 if byte == b'@' && index + 1 < bytes.len() && bytes[index + 1] == b'{' {
111 return Err(BranchError::ContainsAtBrace);
112 }
113
114 if byte == b'/' && index + 1 < bytes.len() && bytes[index + 1] == b'.' {
116 return Err(BranchError::ComponentStartsWithDot);
117 }
118
119 if byte == b'.'
122 && index + 5 < bytes.len()
123 && bytes[index + 1] == b'l'
124 && bytes[index + 2] == b'o'
125 && bytes[index + 3] == b'c'
126 && bytes[index + 4] == b'k'
127 && bytes[index + 5] == b'/'
128 {
129 return Err(BranchError::ComponentEndsWithLock);
130 }
131
132 index += 1;
133 }
134
135 Ok(())
136 }
137
138 #[must_use]
142 pub const fn from_static_or_panic(input: &'static str) -> Self {
143 assert!(Self::validate(input).is_ok(), "invalid branch name");
144 Self(Cow::Borrowed(input))
145 }
146}
147
148impl std::fmt::Display for Branch {
149 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150 write!(formatter, "{}", self.0)
151 }
152}
153
154impl AsRef<std::ffi::OsStr> for Branch {
155 fn as_ref(&self) -> &std::ffi::OsStr {
156 self.as_str().as_ref()
157 }
158}
159
160impl std::str::FromStr for Branch {
161 type Err = BranchError;
162
163 fn from_str(input: &str) -> Result<Self, Self::Err> {
164 Self::validate(input)?;
165 Ok(Self(Cow::Owned(input.to_string())))
166 }
167}
168
169#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
171pub enum BranchError {
172 #[error("branch name cannot be empty")]
173 Empty,
174 #[error("branch name cannot be single '@'")]
175 SingleAt,
176 #[error("branch name cannot start with '-'")]
177 StartsWithDash,
178 #[error("branch name cannot start with '.'")]
179 StartsWithDot,
180 #[error("branch name cannot start with '/'")]
181 StartsWithSlash,
182 #[error("branch name cannot end with '/'")]
183 EndsWithSlash,
184 #[error("branch name cannot end with '.'")]
185 EndsWithDot,
186 #[error("branch name cannot end with '.lock'")]
187 EndsWithLock,
188 #[error("branch name cannot contain '..'")]
189 ContainsDoubleDot,
190 #[error("branch name cannot contain '//'")]
191 ContainsDoubleSlash,
192 #[error("branch name cannot contain '@{{'")]
193 ContainsAtBrace,
194 #[error("branch component cannot start with '.'")]
195 ComponentStartsWithDot,
196 #[error("branch component cannot end with '.lock'")]
197 ComponentEndsWithLock,
198 #[error("branch name cannot contain control characters")]
199 ContainsControlCharacter,
200 #[error("branch name cannot contain spaces")]
201 ContainsSpace,
202 #[error("branch name cannot contain forbidden characters (~^:?*[\\)")]
203 ContainsForbiddenCharacter,
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
211 fn test_valid_branch() {
212 assert!("main".parse::<Branch>().is_ok());
213 assert!("feature/login".parse::<Branch>().is_ok());
214 assert!("feature/deeply/nested/branch".parse::<Branch>().is_ok());
215 assert!("fix-123".parse::<Branch>().is_ok());
216 }
217
218 #[test]
219 fn test_has_parents() {
220 assert!(!Branch::from_static_or_panic("main").has_parents());
221 assert!(Branch::from_static_or_panic("feature/login").has_parents());
222 }
223
224 #[test]
225 fn test_empty() {
226 assert!(matches!("".parse::<Branch>(), Err(BranchError::Empty)));
227 }
228
229 #[test]
230 fn test_single_at() {
231 assert!(matches!("@".parse::<Branch>(), Err(BranchError::SingleAt)));
232 }
233
234 #[test]
235 fn test_starts_with_dash() {
236 assert!(matches!(
237 "-branch".parse::<Branch>(),
238 Err(BranchError::StartsWithDash)
239 ));
240 }
241
242 #[test]
243 fn test_starts_with_dot() {
244 assert!(matches!(
245 ".branch".parse::<Branch>(),
246 Err(BranchError::StartsWithDot)
247 ));
248 }
249
250 #[test]
251 fn test_starts_with_slash() {
252 assert!(matches!(
253 "/branch".parse::<Branch>(),
254 Err(BranchError::StartsWithSlash)
255 ));
256 }
257
258 #[test]
259 fn test_ends_with_slash() {
260 assert!(matches!(
261 "branch/".parse::<Branch>(),
262 Err(BranchError::EndsWithSlash)
263 ));
264 }
265
266 #[test]
267 fn test_ends_with_dot() {
268 assert!(matches!(
269 "branch.".parse::<Branch>(),
270 Err(BranchError::EndsWithDot)
271 ));
272 }
273
274 #[test]
275 fn test_ends_with_lock() {
276 assert!(matches!(
277 "branch.lock".parse::<Branch>(),
278 Err(BranchError::EndsWithLock)
279 ));
280 }
281
282 #[test]
283 fn test_contains_double_dot() {
284 assert!(matches!(
285 "branch..name".parse::<Branch>(),
286 Err(BranchError::ContainsDoubleDot)
287 ));
288 }
289
290 #[test]
291 fn test_contains_double_slash() {
292 assert!(matches!(
293 "feature//branch".parse::<Branch>(),
294 Err(BranchError::ContainsDoubleSlash)
295 ));
296 }
297
298 #[test]
299 fn test_contains_at_brace() {
300 assert!(matches!(
301 "branch@{name".parse::<Branch>(),
302 Err(BranchError::ContainsAtBrace)
303 ));
304 }
305
306 #[test]
307 fn test_component_starts_with_dot() {
308 assert!(matches!(
309 "feature/.hidden".parse::<Branch>(),
310 Err(BranchError::ComponentStartsWithDot)
311 ));
312 assert!(matches!(
313 "a/b/.c/d".parse::<Branch>(),
314 Err(BranchError::ComponentStartsWithDot)
315 ));
316 }
317
318 #[test]
319 fn test_component_ends_with_lock() {
320 assert!(matches!(
321 "feature/branch.lock/next".parse::<Branch>(),
322 Err(BranchError::ComponentEndsWithLock)
323 ));
324 }
325
326 #[test]
327 fn test_contains_space() {
328 assert!(matches!(
329 "branch name".parse::<Branch>(),
330 Err(BranchError::ContainsSpace)
331 ));
332 }
333
334 #[test]
335 fn test_contains_control_character() {
336 assert!(matches!(
337 "branch\x00name".parse::<Branch>(),
338 Err(BranchError::ContainsControlCharacter)
339 ));
340 assert!(matches!(
341 "branch\tname".parse::<Branch>(),
342 Err(BranchError::ContainsControlCharacter)
343 ));
344 }
345
346 #[test]
347 fn test_contains_forbidden_characters() {
348 assert!(matches!(
349 "branch~name".parse::<Branch>(),
350 Err(BranchError::ContainsForbiddenCharacter)
351 ));
352 assert!(matches!(
353 "branch^name".parse::<Branch>(),
354 Err(BranchError::ContainsForbiddenCharacter)
355 ));
356 assert!(matches!(
357 "branch:name".parse::<Branch>(),
358 Err(BranchError::ContainsForbiddenCharacter)
359 ));
360 assert!(matches!(
361 "branch?name".parse::<Branch>(),
362 Err(BranchError::ContainsForbiddenCharacter)
363 ));
364 assert!(matches!(
365 "branch*name".parse::<Branch>(),
366 Err(BranchError::ContainsForbiddenCharacter)
367 ));
368 assert!(matches!(
369 "branch[name".parse::<Branch>(),
370 Err(BranchError::ContainsForbiddenCharacter)
371 ));
372 assert!(matches!(
373 "branch\\name".parse::<Branch>(),
374 Err(BranchError::ContainsForbiddenCharacter)
375 ));
376 }
377
378 #[test]
379 fn test_from_static_or_panic() {
380 let branch = Branch::from_static_or_panic("main");
381 assert_eq!(branch.as_str(), "main");
382 }
383
384 #[test]
385 fn test_display() {
386 let branch: Branch = "feature/test".parse().unwrap();
387 assert_eq!(format!("{branch}"), "feature/test");
388 }
389
390 #[test]
391 fn test_as_ref_os_str() {
392 use std::ffi::OsStr;
393 let branch: Branch = "main".parse().unwrap();
394 let os_str: &OsStr = branch.as_ref();
395 assert_eq!(os_str, "main");
396 }
397}