feat(Description): implement Description and tests
This commit is contained in:
376
src/commit/types/description.rs
Normal file
376
src/commit/types/description.rs
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
use std::fmt::write;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
#[repr(transparent)]
|
||||||
|
pub struct Description(String);
|
||||||
|
|
||||||
|
impl Description {
|
||||||
|
pub const MAX_LENGTH: usize = 72;
|
||||||
|
|
||||||
|
/// Parse and validate a description string
|
||||||
|
///
|
||||||
|
/// # Validation
|
||||||
|
/// - Trims leading/trailing whitespace
|
||||||
|
/// - Rejects empty or whitespace-only input
|
||||||
|
/// - Validates maximum length (72 chars after trim)
|
||||||
|
pub fn parse(value: impl Into<String>) -> Result<Self, DescriptionError> {
|
||||||
|
let value = value.into().trim().to_owned();
|
||||||
|
if value.is_empty() {
|
||||||
|
return Err(DescriptionError::Empty);
|
||||||
|
}
|
||||||
|
if value.len() > Self::MAX_LENGTH {
|
||||||
|
Err(DescriptionError::TooLong {
|
||||||
|
actual: value.len(),
|
||||||
|
max: Self::MAX_LENGTH,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Ok(Self(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the inner string slice
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the length in characters
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.0.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Always returns false for a valid `Description`
|
||||||
|
/// (included for API completeness, but logically always false)
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.0.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsRef<str> for Description {
|
||||||
|
fn as_ref(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Description {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||||
|
pub enum DescriptionError {
|
||||||
|
#[error("Description cannot be empty")]
|
||||||
|
Empty,
|
||||||
|
|
||||||
|
#[error("Description too long ({actual} characters, maximum is {max})")]
|
||||||
|
TooLong { actual: usize, max: usize },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Test that valid description is accepted
|
||||||
|
#[test]
|
||||||
|
fn valid_description_accepted() {
|
||||||
|
let result = Description::parse("add new feature");
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap().as_str(), "add new feature");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that single character description is accepted
|
||||||
|
#[test]
|
||||||
|
fn single_character_description_accepted() {
|
||||||
|
let result = Description::parse("a");
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap().as_str(), "a");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that description with numbers is accepted
|
||||||
|
#[test]
|
||||||
|
fn description_with_numbers_accepted() {
|
||||||
|
let result = Description::parse("fix issue #123");
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap().as_str(), "fix issue #123");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that description with special characters is accepted
|
||||||
|
#[test]
|
||||||
|
fn description_with_special_chars_accepted() {
|
||||||
|
let result = Description::parse("add @decorator support (beta)");
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap().as_str(), "add @decorator support (beta)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that description with punctuation is accepted
|
||||||
|
#[test]
|
||||||
|
fn description_with_punctuation_accepted() {
|
||||||
|
let result = Description::parse("fix: handle edge case!");
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap().as_str(), "fix: handle edge case!");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that empty string is rejected with DescriptionError::Empty
|
||||||
|
#[test]
|
||||||
|
fn empty_string_rejected() {
|
||||||
|
let result = Description::parse("");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(result.unwrap_err(), DescriptionError::Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that whitespace-only is rejected with DescriptionError::Empty
|
||||||
|
#[test]
|
||||||
|
fn whitespace_only_rejected() {
|
||||||
|
let result = Description::parse(" ");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(result.unwrap_err(), DescriptionError::Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that tabs-only is rejected
|
||||||
|
#[test]
|
||||||
|
fn tabs_only_rejected() {
|
||||||
|
let result = Description::parse("\t\t");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(result.unwrap_err(), DescriptionError::Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that mixed whitespace is rejected
|
||||||
|
#[test]
|
||||||
|
fn mixed_whitespace_rejected() {
|
||||||
|
let result = Description::parse(" \t \n ");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(result.unwrap_err(), DescriptionError::Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that newline-only is rejected
|
||||||
|
#[test]
|
||||||
|
fn newline_only_rejected() {
|
||||||
|
let result = Description::parse("\n");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(result.unwrap_err(), DescriptionError::Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that leading whitespace is trimmed
|
||||||
|
#[test]
|
||||||
|
fn leading_whitespace_trimmed() {
|
||||||
|
let result = Description::parse(" add feature");
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap().as_str(), "add feature");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that trailing whitespace is trimmed
|
||||||
|
#[test]
|
||||||
|
fn trailing_whitespace_trimmed() {
|
||||||
|
let result = Description::parse("add feature ");
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap().as_str(), "add feature");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that both leading and trailing whitespace is trimmed
|
||||||
|
#[test]
|
||||||
|
fn leading_and_trailing_whitespace_trimmed() {
|
||||||
|
let result = Description::parse(" add feature ");
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap().as_str(), "add feature");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that internal whitespace is preserved
|
||||||
|
#[test]
|
||||||
|
fn internal_whitespace_preserved() {
|
||||||
|
let result = Description::parse("add multiple spaces");
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap().as_str(), "add multiple spaces");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that exactly 72 characters is accepted (boundary)
|
||||||
|
#[test]
|
||||||
|
fn seventy_two_characters_accepted() {
|
||||||
|
let desc_72 = "a".repeat(72);
|
||||||
|
let result = Description::parse(&desc_72);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap().as_str().len(), 72);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that 73 characters is rejected
|
||||||
|
#[test]
|
||||||
|
fn seventy_three_characters_rejected() {
|
||||||
|
let desc_73 = "a".repeat(73);
|
||||||
|
let result = Description::parse(&desc_73);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(
|
||||||
|
result.unwrap_err(),
|
||||||
|
DescriptionError::TooLong {
|
||||||
|
actual: 73,
|
||||||
|
max: 72
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that 100 characters is rejected
|
||||||
|
#[test]
|
||||||
|
fn hundred_characters_rejected() {
|
||||||
|
let desc_100 = "a".repeat(100);
|
||||||
|
let result = Description::parse(&desc_100);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(
|
||||||
|
result.unwrap_err(),
|
||||||
|
DescriptionError::TooLong {
|
||||||
|
actual: 100,
|
||||||
|
max: 72
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that length is checked after trimming
|
||||||
|
#[test]
|
||||||
|
fn length_checked_after_trimming() {
|
||||||
|
// 72 chars + leading/trailing spaces = should be valid after trim
|
||||||
|
let desc_with_spaces = format!(" {} ", "a".repeat(72));
|
||||||
|
let result = Description::parse(&desc_with_spaces);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap().as_str().len(), 72);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that 50 characters is accepted without issue
|
||||||
|
#[test]
|
||||||
|
fn fifty_characters_accepted() {
|
||||||
|
let desc_50 = "a".repeat(50);
|
||||||
|
let result = Description::parse(&desc_50);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap().as_str().len(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test MAX_LENGTH constant is 72
|
||||||
|
#[test]
|
||||||
|
fn max_length_constant_is_72() {
|
||||||
|
assert_eq!(Description::MAX_LENGTH, 72);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test as_str() returns inner string
|
||||||
|
#[test]
|
||||||
|
fn as_str_returns_inner_string() {
|
||||||
|
let desc = Description::parse("my description").unwrap();
|
||||||
|
assert_eq!(desc.as_str(), "my description");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test len() returns correct length
|
||||||
|
#[test]
|
||||||
|
fn len_returns_correct_length() {
|
||||||
|
let desc = Description::parse("hello").unwrap();
|
||||||
|
assert_eq!(desc.len(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test is_empty() always returns false for valid Description
|
||||||
|
#[test]
|
||||||
|
fn is_empty_always_false_for_valid() {
|
||||||
|
let desc = Description::parse("x").unwrap();
|
||||||
|
assert!(!desc.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test Display trait implementation
|
||||||
|
#[test]
|
||||||
|
fn display_outputs_inner_string() {
|
||||||
|
let desc = Description::parse("add feature").unwrap();
|
||||||
|
assert_eq!(format!("{}", desc), "add feature");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test Clone trait
|
||||||
|
#[test]
|
||||||
|
fn description_is_cloneable() {
|
||||||
|
let original = Description::parse("add feature").unwrap();
|
||||||
|
let cloned = original.clone();
|
||||||
|
assert_eq!(original, cloned);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test PartialEq trait
|
||||||
|
#[test]
|
||||||
|
fn description_equality() {
|
||||||
|
let desc1 = Description::parse("add feature").unwrap();
|
||||||
|
let desc2 = Description::parse("add feature").unwrap();
|
||||||
|
let desc3 = Description::parse("fix bug").unwrap();
|
||||||
|
assert_eq!(desc1, desc2);
|
||||||
|
assert_ne!(desc1, desc3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test Debug trait
|
||||||
|
#[test]
|
||||||
|
fn description_has_debug() {
|
||||||
|
let desc = Description::parse("add feature").unwrap();
|
||||||
|
let debug_output = format!("{:?}", desc);
|
||||||
|
assert!(debug_output.contains("Description"));
|
||||||
|
assert!(debug_output.contains("add feature"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test AsRef<str> trait
|
||||||
|
#[test]
|
||||||
|
fn description_as_ref_str() {
|
||||||
|
let desc = Description::parse("add feature").unwrap();
|
||||||
|
let s: &str = desc.as_ref();
|
||||||
|
assert_eq!(s, "add feature");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test DescriptionError::Empty displays correctly
|
||||||
|
#[test]
|
||||||
|
fn empty_error_display() {
|
||||||
|
let err = DescriptionError::Empty;
|
||||||
|
let msg = format!("{}", err);
|
||||||
|
assert!(msg.contains("cannot be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test DescriptionError::TooLong displays correctly
|
||||||
|
#[test]
|
||||||
|
fn too_long_error_display() {
|
||||||
|
let err = DescriptionError::TooLong {
|
||||||
|
actual: 73,
|
||||||
|
max: 72,
|
||||||
|
};
|
||||||
|
let msg = format!("{}", err);
|
||||||
|
assert!(msg.contains("too long"));
|
||||||
|
assert!(msg.contains("73"));
|
||||||
|
assert!(msg.contains("72"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test description with only whitespace after trim becomes empty
|
||||||
|
#[test]
|
||||||
|
fn whitespace_after_trim_is_empty() {
|
||||||
|
// Ensure various whitespace combinations all result in Empty error
|
||||||
|
let whitespace_variants = [" ", " ", "\t", "\n", "\r\n", " \t \n "];
|
||||||
|
for ws in whitespace_variants {
|
||||||
|
let result = Description::parse(ws);
|
||||||
|
assert!(result.is_err(), "Expected error for whitespace: {:?}", ws);
|
||||||
|
assert_eq!(
|
||||||
|
result.unwrap_err(),
|
||||||
|
DescriptionError::Empty,
|
||||||
|
"Expected Empty error for whitespace: {:?}",
|
||||||
|
ws
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test description at exact boundary after trimming
|
||||||
|
#[test]
|
||||||
|
fn boundary_length_after_trim() {
|
||||||
|
// 72 chars + 2 spaces on each side = 76 chars total, but 72 after trim
|
||||||
|
let desc = format!(" {} ", "x".repeat(72));
|
||||||
|
let result = Description::parse(&desc);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap().len(), 72);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test description just over boundary after trimming
|
||||||
|
#[test]
|
||||||
|
fn over_boundary_after_trim() {
|
||||||
|
// 73 chars + spaces = should fail even after trim
|
||||||
|
let desc = format!(" {} ", "x".repeat(73));
|
||||||
|
let result = Description::parse(&desc);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(
|
||||||
|
result.unwrap_err(),
|
||||||
|
DescriptionError::TooLong {
|
||||||
|
actual: 73,
|
||||||
|
max: 72
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,3 +3,6 @@ pub use commit_type::CommitType;
|
|||||||
|
|
||||||
mod scope;
|
mod scope;
|
||||||
pub use scope::{Scope, ScopeError};
|
pub use scope::{Scope, ScopeError};
|
||||||
|
|
||||||
|
mod description;
|
||||||
|
pub use description::{Description, DescriptionError};
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ impl Scope {
|
|||||||
|
|
||||||
/// Returns true if the scope is empty
|
/// Returns true if the scope is empty
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.0 == String::new()
|
self.0.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the inner string slice
|
/// Returns the inner string slice
|
||||||
|
|||||||
Reference in New Issue
Block a user