diff --git a/src/commit/types/description.rs b/src/commit/types/description.rs new file mode 100644 index 0000000..b031ddb --- /dev/null +++ b/src/commit/types/description.rs @@ -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) -> Result { + 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 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 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 + } + ); + } +} diff --git a/src/commit/types/mod.rs b/src/commit/types/mod.rs index b57e687..e4e0973 100644 --- a/src/commit/types/mod.rs +++ b/src/commit/types/mod.rs @@ -3,3 +3,6 @@ pub use commit_type::CommitType; mod scope; pub use scope::{Scope, ScopeError}; + +mod description; +pub use description::{Description, DescriptionError}; diff --git a/src/commit/types/scope.rs b/src/commit/types/scope.rs index c24b9e4..e283fdf 100644 --- a/src/commit/types/scope.rs +++ b/src/commit/types/scope.rs @@ -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