Skip to main content

jj_cz/commit/types/
scope.rs

1#[derive(Debug, Clone, PartialEq, Eq)]
2#[repr(transparent)]
3pub struct Scope(String);
4
5impl Scope {
6    /// Maximum allowed length for a scope
7    pub const MAX_LENGTH: usize = 30;
8
9    /// Parse and validate a scope string
10    ///
11    /// # Validation
12    /// - Trims leading/trailing whitespace
13    /// - Empty/whitespace-only input returns empty Scope
14    /// - Validates character set
15    /// - Validates maximum length (30 chars)
16    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    /// Create an empty scope (convenience constructor)
39    pub fn empty() -> Self {
40        Self(String::new())
41    }
42
43    /// Returns true if the scope is empty
44    pub fn is_empty(&self) -> bool {
45        self.0.is_empty()
46    }
47
48    /// Returns the inner string slice
49    pub fn as_str(&self) -> &str {
50        self.0.as_str()
51    }
52
53    /// Returns itself as a formatted header segment
54    pub fn header_segment(&self) -> String {
55        if self.is_empty() {
56            "".into()
57        } else {
58            format!("({self})")
59        }
60    }
61
62    /// Returns the visible length of the header segment
63    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/// Error type for Scope validation failures
85#[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 that valid alphanumeric scope is accepted
99    #[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 that valid scope with uppercase letters is accepted
107    #[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 that valid scope with mixed case is accepted
115    #[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 that valid scope with numbers is accepted
123    #[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 that valid scope with hyphens is accepted
131    #[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 that valid scope with underscores is accepted
139    #[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 that valid scope with slashes is accepted (Jira refs)
147    #[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 another Jira-style scope with slashes
155    #[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 scope with all allowed special characters combined
163    #[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 that empty string returns valid empty Scope
171    #[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 that whitespace-only input returns valid empty Scope
181    #[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 that tabs-only input returns valid empty Scope
191    #[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 that mixed whitespace returns valid empty Scope
200    #[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 that leading whitespace is trimmed
209    #[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 that trailing whitespace is trimmed
217    #[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 that both leading and trailing whitespace is trimmed
225    #[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 that spaces within scope are rejected
233    #[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 that dot is rejected
241    #[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 that colon is rejected
249    #[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 that parentheses are rejected
257    #[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 that exclamation mark is rejected
265    #[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 that @ symbol is rejected
273    #[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 that hash is rejected
281    #[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 that emoji is rejected
289    #[test]
290    fn emoji_rejected() {
291        let result = Scope::parse("cli🚀");
292        assert!(result.is_err());
293        // The error should contain the emoji character
294        match result.unwrap_err() {
295            ScopeError::InvalidCharacter(c) => assert_eq!(c, '🚀'),
296            _ => panic!("Expected InvalidCharacter error"),
297        }
298    }
299
300    /// Test that first invalid character is reported
301    #[test]
302    fn first_invalid_character_reported() {
303        let result = Scope::parse("a.b:c");
304        assert!(result.is_err());
305        // Should report the first invalid character (dot)
306        assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter('.'));
307    }
308
309    /// Test that exactly 30 characters is accepted (boundary)
310    #[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 that 31 characters is rejected
319    #[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 that 100 characters is rejected
334    #[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 that length is checked after trimming
349    #[test]
350    fn length_checked_after_trimming() {
351        // 30 chars + leading/trailing spaces = should be valid after trim
352        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 MAX_LENGTH constant is 30
359    #[test]
360    fn max_length_constant_is_30() {
361        assert_eq!(Scope::MAX_LENGTH, 30);
362    }
363
364    /// Test that empty() creates an empty Scope
365    #[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 is_empty() returns true for empty scope
373    #[test]
374    fn is_empty_returns_true_for_empty() {
375        let scope = Scope::parse("").unwrap();
376        assert!(scope.is_empty());
377    }
378
379    /// Test is_empty() returns false for non-empty scope
380    #[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 as_str() returns inner string
387    #[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 Display trait implementation
394    #[test]
395    fn display_outputs_inner_string() {
396        let scope = Scope::parse("cli").unwrap();
397        assert_eq!(format!("{}", scope), "cli");
398    }
399
400    /// Test Display for empty scope
401    #[test]
402    fn display_empty_scope() {
403        let scope = Scope::empty();
404        assert_eq!(format!("{}", scope), "");
405    }
406
407    /// Test Clone trait
408    #[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 PartialEq trait
416    #[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 Debug trait
426    #[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 AsRef<str> trait
435    #[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 ScopeError::InvalidCharacter displays correctly
443    #[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 ScopeError::TooLong displays correctly
453    #[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 header_segment() returns empty string for empty scope
466    #[test]
467    fn header_segment_empty_scope_returns_empty_string() {
468        assert_eq!(Scope::empty().header_segment(), "");
469    }
470
471    /// Test header_segment() wraps a non-empty scope in parentheses
472    #[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 header_segment() for a variety of valid scopes
479    #[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 header_segment_len() is 0 for an empty scope
493    #[test]
494    fn header_segment_len_empty_scope_is_zero() {
495        assert_eq!(Scope::empty().header_segment_len(), 0);
496    }
497
498    /// Test header_segment_len() includes the two parentheses characters
499    #[test]
500    fn header_segment_len_includes_parentheses() {
501        // "(auth)" = 6 chars
502        let scope = Scope::parse("auth").unwrap();
503        assert_eq!(scope.header_segment_len(), 6);
504    }
505
506    /// Test header_segment_len() agrees with header_segment().chars().count()
507    #[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    /// A scope whose byte count exceeds MAX_LENGTH but whose char
522    /// count does not must be rejected with InvalidCharacter, not
523    /// TooLong.
524    ///
525    /// Before the fix the byte-based `.len()` check fired first,
526    /// producing a misleading "too long" error for a string that is
527    /// actually within the limit.
528    #[test]
529    fn length_limit_uses_char_count_not_byte_count() {
530        // "ñ" is 2 bytes in UTF-8; 16 × "ñ" = 16 chars, 32 bytes.
531        // char count 16 ≤ 30  →  length check passes
532        // regex rejects "ñ"   →  should return InvalidCharacter, not TooLong
533        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    /// The actual length reported in TooLong must be the char count,
547    /// not the byte count.
548    ///
549    /// "a".repeat(30) + "é" is 31 chars and 32 bytes. The length
550    /// check should fire on char count (31 > 30) and report actual =
551    /// 31.
552    #[test]
553    fn too_long_error_actual_reports_char_count_not_byte_count() {
554        // 30 ASCII 'a' + 1 two-byte 'é' = 31 chars, 32 bytes
555        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}