fix(message): use unicode char count for text width
All checks were successful
Publish Docker Images / coverage-and-sonar (push) Successful in 18m13s
All checks were successful
Publish Docker Images / coverage-and-sonar (push) Successful in 18m13s
This commit is contained in:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user