1#[derive(Debug, Clone, PartialEq, Eq)]
2#[repr(transparent)]
3pub struct Scope(String);
4
5impl Scope {
6 pub const MAX_LENGTH: usize = 30;
8
9 pub fn parse(value: impl Into<String>) -> Result<Self, ScopeError> {
17 let value: String = value.into().trim().to_owned();
18 if value.is_empty() {
19 return Ok(Self::empty());
20 }
21 if value.chars().count() > Self::MAX_LENGTH {
22 return Err(ScopeError::TooLong {
23 actual: value.chars().count(),
24 max: Self::MAX_LENGTH,
25 });
26 }
27 match lazy_regex::regex_find!(r"[^-a-zA-Z0-9_/]", &value) {
28 None => Ok(Self(value)),
29 Some(val) => val
30 .chars()
31 .next()
32 .map(ScopeError::InvalidCharacter)
33 .map(Err)
34 .unwrap_or_else(|| unreachable!("regex match is always non-empty")),
35 }
36 }
37
38 pub fn empty() -> Self {
40 Self(String::new())
41 }
42
43 pub fn is_empty(&self) -> bool {
45 self.0.is_empty()
46 }
47
48 pub fn as_str(&self) -> &str {
50 self.0.as_str()
51 }
52
53 pub fn header_segment(&self) -> String {
55 if self.is_empty() {
56 "".into()
57 } else {
58 format!("({self})")
59 }
60 }
61
62 pub fn header_segment_len(&self) -> usize {
64 if self.is_empty() {
65 0
66 } else {
67 self.0.chars().count() + 2
68 }
69 }
70}
71
72impl std::fmt::Display for Scope {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 write!(f, "{}", self.0)
75 }
76}
77
78impl AsRef<str> for Scope {
79 fn as_ref(&self) -> &str {
80 &self.0
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
86pub enum ScopeError {
87 #[error("Invalid character '{0}' in scope (allowed: a-z, A-Z, 0-9, -, _, /)")]
88 InvalidCharacter(char),
89
90 #[error("Scope too long ({actual} characters, maximum is {max})")]
91 TooLong { actual: usize, max: usize },
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97
98 #[test]
100 fn valid_alphanumeric_scope_accepted() {
101 let result = Scope::parse("cli");
102 assert!(result.is_ok());
103 assert_eq!(result.unwrap().as_str(), "cli");
104 }
105
106 #[test]
108 fn valid_uppercase_scope_accepted() {
109 let result = Scope::parse("CLI");
110 assert!(result.is_ok());
111 assert_eq!(result.unwrap().as_str(), "CLI");
112 }
113
114 #[test]
116 fn valid_mixed_case_scope_accepted() {
117 let result = Scope::parse("AuthModule");
118 assert!(result.is_ok());
119 assert_eq!(result.unwrap().as_str(), "AuthModule");
120 }
121
122 #[test]
124 fn valid_scope_with_numbers_accepted() {
125 let result = Scope::parse("api2");
126 assert!(result.is_ok());
127 assert_eq!(result.unwrap().as_str(), "api2");
128 }
129
130 #[test]
132 fn valid_scope_with_hyphens_accepted() {
133 let result = Scope::parse("user-auth");
134 assert!(result.is_ok());
135 assert_eq!(result.unwrap().as_str(), "user-auth");
136 }
137
138 #[test]
140 fn valid_scope_with_underscores_accepted() {
141 let result = Scope::parse("user_auth");
142 assert!(result.is_ok());
143 assert_eq!(result.unwrap().as_str(), "user_auth");
144 }
145
146 #[test]
148 fn valid_scope_with_slashes_accepted() {
149 let result = Scope::parse("PROJ-123/feature");
150 assert!(result.is_ok());
151 assert_eq!(result.unwrap().as_str(), "PROJ-123/feature");
152 }
153
154 #[test]
156 fn valid_jira_style_scope_accepted() {
157 let result = Scope::parse("TEAM-456/bugfix");
158 assert!(result.is_ok());
159 assert_eq!(result.unwrap().as_str(), "TEAM-456/bugfix");
160 }
161
162 #[test]
164 fn valid_scope_with_all_special_chars() {
165 let result = Scope::parse("my-scope_v2/test");
166 assert!(result.is_ok());
167 assert_eq!(result.unwrap().as_str(), "my-scope_v2/test");
168 }
169
170 #[test]
172 fn empty_string_returns_valid_empty_scope() {
173 let result = Scope::parse("");
174 assert!(result.is_ok());
175 let scope = result.unwrap();
176 assert!(scope.is_empty());
177 assert_eq!(scope.as_str(), "");
178 }
179
180 #[test]
182 fn whitespace_only_returns_valid_empty_scope() {
183 let result = Scope::parse(" ");
184 assert!(result.is_ok());
185 let scope = result.unwrap();
186 assert!(scope.is_empty());
187 assert_eq!(scope.as_str(), "");
188 }
189
190 #[test]
192 fn tabs_only_returns_valid_empty_scope() {
193 let result = Scope::parse("\t\t");
194 assert!(result.is_ok());
195 let scope = result.unwrap();
196 assert!(scope.is_empty());
197 }
198
199 #[test]
201 fn mixed_whitespace_returns_valid_empty_scope() {
202 let result = Scope::parse(" \t \n ");
203 assert!(result.is_ok());
204 let scope = result.unwrap();
205 assert!(scope.is_empty());
206 }
207
208 #[test]
210 fn leading_whitespace_trimmed() {
211 let result = Scope::parse(" cli");
212 assert!(result.is_ok());
213 assert_eq!(result.unwrap().as_str(), "cli");
214 }
215
216 #[test]
218 fn trailing_whitespace_trimmed() {
219 let result = Scope::parse("cli ");
220 assert!(result.is_ok());
221 assert_eq!(result.unwrap().as_str(), "cli");
222 }
223
224 #[test]
226 fn leading_and_trailing_whitespace_trimmed() {
227 let result = Scope::parse(" cli ");
228 assert!(result.is_ok());
229 assert_eq!(result.unwrap().as_str(), "cli");
230 }
231
232 #[test]
234 fn space_in_scope_rejected() {
235 let result = Scope::parse("user auth");
236 assert!(result.is_err());
237 assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter(' '));
238 }
239
240 #[test]
242 fn dot_rejected() {
243 let result = Scope::parse("user.auth");
244 assert!(result.is_err());
245 assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter('.'));
246 }
247
248 #[test]
250 fn colon_rejected() {
251 let result = Scope::parse("user:auth");
252 assert!(result.is_err());
253 assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter(':'));
254 }
255
256 #[test]
258 fn parentheses_rejected() {
259 let result = Scope::parse("user(auth)");
260 assert!(result.is_err());
261 assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter('('));
262 }
263
264 #[test]
266 fn exclamation_rejected() {
267 let result = Scope::parse("breaking!");
268 assert!(result.is_err());
269 assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter('!'));
270 }
271
272 #[test]
274 fn at_symbol_rejected() {
275 let result = Scope::parse("user@domain");
276 assert!(result.is_err());
277 assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter('@'));
278 }
279
280 #[test]
282 fn hash_rejected() {
283 let result = Scope::parse("issue#123");
284 assert!(result.is_err());
285 assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter('#'));
286 }
287
288 #[test]
290 fn emoji_rejected() {
291 let result = Scope::parse("cli🚀");
292 assert!(result.is_err());
293 match result.unwrap_err() {
295 ScopeError::InvalidCharacter(c) => assert_eq!(c, '🚀'),
296 _ => panic!("Expected InvalidCharacter error"),
297 }
298 }
299
300 #[test]
302 fn first_invalid_character_reported() {
303 let result = Scope::parse("a.b:c");
304 assert!(result.is_err());
305 assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter('.'));
307 }
308
309 #[test]
311 fn thirty_characters_accepted() {
312 let scope_30 = "a".repeat(30);
313 let result = Scope::parse(&scope_30);
314 assert!(result.is_ok());
315 assert_eq!(result.unwrap().as_str().len(), 30);
316 }
317
318 #[test]
320 fn thirty_one_characters_rejected() {
321 let scope_31 = "a".repeat(31);
322 let result = Scope::parse(&scope_31);
323 assert!(result.is_err());
324 assert_eq!(
325 result.unwrap_err(),
326 ScopeError::TooLong {
327 actual: 31,
328 max: 30
329 }
330 );
331 }
332
333 #[test]
335 fn hundred_characters_rejected() {
336 let scope_100 = "a".repeat(100);
337 let result = Scope::parse(&scope_100);
338 assert!(result.is_err());
339 assert_eq!(
340 result.unwrap_err(),
341 ScopeError::TooLong {
342 actual: 100,
343 max: 30
344 }
345 );
346 }
347
348 #[test]
350 fn length_checked_after_trimming() {
351 let scope_with_spaces = format!(" {} ", "a".repeat(30));
353 let result = Scope::parse(&scope_with_spaces);
354 assert!(result.is_ok());
355 assert_eq!(result.unwrap().as_str().len(), 30);
356 }
357
358 #[test]
360 fn max_length_constant_is_30() {
361 assert_eq!(Scope::MAX_LENGTH, 30);
362 }
363
364 #[test]
366 fn empty_constructor_creates_empty_scope() {
367 let scope = Scope::empty();
368 assert!(scope.is_empty());
369 assert_eq!(scope.as_str(), "");
370 }
371
372 #[test]
374 fn is_empty_returns_true_for_empty() {
375 let scope = Scope::parse("").unwrap();
376 assert!(scope.is_empty());
377 }
378
379 #[test]
381 fn is_empty_returns_false_for_non_empty() {
382 let scope = Scope::parse("cli").unwrap();
383 assert!(!scope.is_empty());
384 }
385
386 #[test]
388 fn as_str_returns_inner_string() {
389 let scope = Scope::parse("my-scope").unwrap();
390 assert_eq!(scope.as_str(), "my-scope");
391 }
392
393 #[test]
395 fn display_outputs_inner_string() {
396 let scope = Scope::parse("cli").unwrap();
397 assert_eq!(format!("{}", scope), "cli");
398 }
399
400 #[test]
402 fn display_empty_scope() {
403 let scope = Scope::empty();
404 assert_eq!(format!("{}", scope), "");
405 }
406
407 #[test]
409 fn scope_is_cloneable() {
410 let original = Scope::parse("cli").unwrap();
411 let cloned = original.clone();
412 assert_eq!(original, cloned);
413 }
414
415 #[test]
417 fn scope_equality() {
418 let scope1 = Scope::parse("cli").unwrap();
419 let scope2 = Scope::parse("cli").unwrap();
420 let scope3 = Scope::parse("api").unwrap();
421 assert_eq!(scope1, scope2);
422 assert_ne!(scope1, scope3);
423 }
424
425 #[test]
427 fn scope_has_debug() {
428 let scope = Scope::parse("cli").unwrap();
429 let debug_output = format!("{:?}", scope);
430 assert!(debug_output.contains("Scope"));
431 assert!(debug_output.contains("cli"));
432 }
433
434 #[test]
436 fn scope_as_ref_str() {
437 let scope = Scope::parse("cli").unwrap();
438 let s: &str = scope.as_ref();
439 assert_eq!(s, "cli");
440 }
441
442 #[test]
444 fn invalid_character_error_display() {
445 let err = ScopeError::InvalidCharacter('.');
446 let msg = format!("{}", err);
447 assert!(msg.contains("Invalid character"));
448 assert!(msg.contains("'.'"));
449 assert!(msg.contains("allowed: a-z, A-Z, 0-9, -, _, /"));
450 }
451
452 #[test]
454 fn too_long_error_display() {
455 let err = ScopeError::TooLong {
456 actual: 31,
457 max: 30,
458 };
459 let msg = format!("{}", err);
460 assert!(msg.contains("too long"));
461 assert!(msg.contains("31"));
462 assert!(msg.contains("30"));
463 }
464
465 #[test]
467 fn header_segment_empty_scope_returns_empty_string() {
468 assert_eq!(Scope::empty().header_segment(), "");
469 }
470
471 #[test]
473 fn header_segment_wraps_scope_in_parentheses() {
474 let scope = Scope::parse("auth").unwrap();
475 assert_eq!(scope.header_segment(), "(auth)");
476 }
477
478 #[test]
480 fn header_segment_various_scopes() {
481 assert_eq!(Scope::parse("cli").unwrap().header_segment(), "(cli)");
482 assert_eq!(
483 Scope::parse("user-auth").unwrap().header_segment(),
484 "(user-auth)"
485 );
486 assert_eq!(
487 Scope::parse("PROJ-123/feature").unwrap().header_segment(),
488 "(PROJ-123/feature)"
489 );
490 }
491
492 #[test]
494 fn header_segment_len_empty_scope_is_zero() {
495 assert_eq!(Scope::empty().header_segment_len(), 0);
496 }
497
498 #[test]
500 fn header_segment_len_includes_parentheses() {
501 let scope = Scope::parse("auth").unwrap();
503 assert_eq!(scope.header_segment_len(), 6);
504 }
505
506 #[test]
508 fn header_segment_len_equals_segment_chars_count() {
509 let values = ["cli", "user-auth", "PROJ-123/feature"];
510 for s in values {
511 let scope = Scope::parse(s).unwrap();
512 assert_eq!(
513 scope.header_segment_len(),
514 scope.header_segment().chars().count(),
515 "header_segment_len() should equal chars().count() for scope {:?}",
516 s
517 );
518 }
519 }
520
521 #[test]
529 fn length_limit_uses_char_count_not_byte_count() {
530 let input = "ñ".repeat(16);
534 assert_eq!(input.chars().count(), 16, "sanity: 16 chars");
535 assert_eq!(input.len(), 32, "sanity: 32 bytes");
536
537 let result = Scope::parse(&input);
538 assert!(result.is_err());
539 assert_eq!(
540 result.unwrap_err(),
541 ScopeError::InvalidCharacter('ñ'),
542 "expected InvalidCharacter('ñ') for a 16-char / 32-byte input, not TooLong",
543 );
544 }
545
546 #[test]
553 fn too_long_error_actual_reports_char_count_not_byte_count() {
554 let input = "a".repeat(30) + "é";
556 assert_eq!(input.chars().count(), 31, "sanity: 31 chars");
557 assert_eq!(input.len(), 32, "sanity: 32 bytes");
558
559 let result = Scope::parse(&input);
560 assert_eq!(
561 result.unwrap_err(),
562 ScopeError::TooLong {
563 actual: 31,
564 max: 30
565 },
566 "actual should be the char count (31), not the byte count (32)",
567 );
568 }
569}