feat(Description): implement Description and tests

This commit is contained in:
2026-02-05 23:35:02 +01:00
parent 37e5a41ad0
commit ab0d3f9061
3 changed files with 380 additions and 1 deletions

View 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
}
);
}
}

View File

@@ -3,3 +3,6 @@ pub use commit_type::CommitType;
mod scope;
pub use scope::{Scope, ScopeError};
mod description;
pub use description::{Description, DescriptionError};

View File

@@ -39,7 +39,7 @@ impl Scope {
/// Returns true if the scope is empty
pub fn is_empty(&self) -> bool {
self.0 == String::new()
self.0.is_empty()
}
/// Returns the inner string slice