diff --git a/src/commit/types/commit_type.rs b/src/commit/types/commit_type.rs index abb05fa..c80c53c 100644 --- a/src/commit/types/commit_type.rs +++ b/src/commit/types/commit_type.rs @@ -61,6 +61,15 @@ impl CommitType { Self::Revert => "revert", } } + + /// Returns the length in characters + /// + /// `is_empty()` is intentionally absent: `CommitType` is + /// guaranteed non-empty, so the concept does not apply. + #[allow(clippy::len_without_is_empty)] + pub fn len(&self) -> usize { + self.as_str().chars().count() + } } impl std::fmt::Display for CommitType { @@ -274,4 +283,33 @@ mod tests { let debug_output = format!("{:?}", CommitType::Feat); assert!(debug_output.contains("Feat")); } + + /// Test len() returns the correct character count for each variant + #[test] + fn len_returns_correct_character_count() { + assert_eq!(CommitType::Feat.len(), 4); + assert_eq!(CommitType::Fix.len(), 3); + assert_eq!(CommitType::Docs.len(), 4); + assert_eq!(CommitType::Style.len(), 5); + assert_eq!(CommitType::Refactor.len(), 8); + assert_eq!(CommitType::Perf.len(), 4); + assert_eq!(CommitType::Test.len(), 4); + assert_eq!(CommitType::Build.len(), 5); + assert_eq!(CommitType::Ci.len(), 2); + assert_eq!(CommitType::Chore.len(), 5); + assert_eq!(CommitType::Revert.len(), 6); + } + + /// Test len() agrees with as_str().chars().count() for all variants + #[test] + fn len_equals_chars_count_for_all_variants() { + for commit_type in CommitType::all() { + assert_eq!( + commit_type.len(), + commit_type.as_str().chars().count(), + "len() should equal chars().count() for {:?}", + commit_type + ); + } + } } diff --git a/src/commit/types/description.rs b/src/commit/types/description.rs index b2ee206..b442490 100644 --- a/src/commit/types/description.rs +++ b/src/commit/types/description.rs @@ -39,7 +39,7 @@ impl Description { /// non-empty by its constructor, so the concept does not apply. #[allow(clippy::len_without_is_empty)] pub fn len(&self) -> usize { - self.0.len() + self.0.chars().count() } } @@ -226,13 +226,28 @@ mod tests { assert_eq!(desc.as_str(), "my description"); } - /// Test len() returns correct length + /// Test len() returns correct length for ASCII input #[test] fn len_returns_correct_length() { let desc = Description::parse("hello").unwrap(); assert_eq!(desc.len(), 5); } + /// Test len() counts Unicode scalar values, not bytes + /// + /// Multi-byte characters (accented letters, CJK, emoji) must count as one + /// character each so that the 72-char first-line limit is applied correctly. + #[test] + fn len_counts_unicode_chars_not_bytes() { + // "café" = 4 chars, 5 bytes (é is 2 bytes in UTF-8) + let desc = Description::parse("café").unwrap(); + assert_eq!(desc.len(), 4); + + // Emoji: "fix 🐛" = 5 chars, 9 bytes (🐛 is 4 bytes) + let desc = Description::parse("fix 🐛").unwrap(); + assert_eq!(desc.len(), 5); + } + /// Test Display trait implementation #[test] fn display_outputs_inner_string() { diff --git a/src/commit/types/message.rs b/src/commit/types/message.rs index 4e4d72b..0182fb2 100644 --- a/src/commit/types/message.rs +++ b/src/commit/types/message.rs @@ -70,13 +70,10 @@ impl ConventionalCommit { /// - Without scope: `len(type) + 2 + len(description)` /// (the 2 accounts for colon and space: ": ") pub fn first_line_len(&self) -> usize { - if self.scope.is_empty() { - // type: description - self.commit_type.as_str().len() + 2 + self.description.len() - } else { - // type(scope): description - self.commit_type.as_str().len() + self.scope.as_str().len() + 4 + self.description.len() - } + self.commit_type.len() + + self.scope.header_segment_len() + + 2 // ": " + + self.description.len() } /// Format the complete commit messsage @@ -584,10 +581,6 @@ mod tests { assert_eq!(commit.format(), "feat(my-scope_v2/feature): add support"); } - // ========================================================================= - // Line Length Validation Tests - // ========================================================================= - /// Test FIRST_LINE_MAX_LENGTH constant is 72 #[test] fn first_line_max_length_constant_is_72() { diff --git a/src/commit/types/scope.rs b/src/commit/types/scope.rs index 338383a..653cbc5 100644 --- a/src/commit/types/scope.rs +++ b/src/commit/types/scope.rs @@ -25,8 +25,13 @@ impl Scope { }); } match lazy_regex::regex_find!(r"[^-a-zA-Z0-9_/]", &value) { - Some(val) => Err(ScopeError::InvalidCharacter(val.chars().next().unwrap())), None => Ok(Self(value)), + Some(val) => val + .chars() + .next() + .map(ScopeError::InvalidCharacter) + .map(Err) + .unwrap_or_else(|| unreachable!("regex match is always non-empty")), } } @@ -44,6 +49,20 @@ impl Scope { pub fn as_str(&self) -> &str { self.0.as_str() } + + /// Returns itself as a formatted header segment + pub fn header_segment(&self) -> String { + if self.is_empty() { + "".into() + } else { + format!("({self})") + } + } + + /// Returns the visible length of the header segment + pub fn header_segment_len(&self) -> usize { + self.header_segment().chars().count() + } } impl std::fmt::Display for Scope { @@ -438,4 +457,64 @@ mod tests { assert!(msg.contains("31")); assert!(msg.contains("30")); } + + // ========================================================================= + // header_segment() / header_segment_len() tests + // ========================================================================= + + /// Test header_segment() returns empty string for empty scope + #[test] + fn header_segment_empty_scope_returns_empty_string() { + assert_eq!(Scope::empty().header_segment(), ""); + } + + /// Test header_segment() wraps a non-empty scope in parentheses + #[test] + fn header_segment_wraps_scope_in_parentheses() { + let scope = Scope::parse("auth").unwrap(); + assert_eq!(scope.header_segment(), "(auth)"); + } + + /// Test header_segment() for a variety of valid scopes + #[test] + fn header_segment_various_scopes() { + assert_eq!(Scope::parse("cli").unwrap().header_segment(), "(cli)"); + assert_eq!( + Scope::parse("user-auth").unwrap().header_segment(), + "(user-auth)" + ); + assert_eq!( + Scope::parse("PROJ-123/feature").unwrap().header_segment(), + "(PROJ-123/feature)" + ); + } + + /// Test header_segment_len() is 0 for an empty scope + #[test] + fn header_segment_len_empty_scope_is_zero() { + assert_eq!(Scope::empty().header_segment_len(), 0); + } + + /// Test header_segment_len() includes the two parentheses characters + #[test] + fn header_segment_len_includes_parentheses() { + // "(auth)" = 6 chars + let scope = Scope::parse("auth").unwrap(); + assert_eq!(scope.header_segment_len(), 6); + } + + /// Test header_segment_len() agrees with header_segment().chars().count() + #[test] + fn header_segment_len_equals_segment_chars_count() { + let values = ["cli", "user-auth", "PROJ-123/feature"]; + for s in values { + let scope = Scope::parse(s).unwrap(); + assert_eq!( + scope.header_segment_len(), + scope.header_segment().chars().count(), + "header_segment_len() should equal chars().count() for scope {:?}", + s + ); + } + } }