feat: foundational domain types #1
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;
|
||||
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
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0 == String::new()
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
/// Returns the inner string slice
|
||||
|
||||
Reference in New Issue
Block a user