jj-cz 1.1.0

Conventional commits for Jujutsu
Documentation
#[derive(Debug, Clone, PartialEq, Eq)]
#[repr(transparent)]
pub struct Description(String);

impl Description {
    /// Soft limit for description length.
    ///
    /// Descriptions over this length are warned about at the prompt layer but
    /// are not rejected here - the hard limit is the 72-character total first
    /// line enforced by [`crate::ConventionalCommit`].
    pub const MAX_LENGTH: usize = 50;

    /// Parse and validate a description string
    ///
    /// # Validation
    /// - Trims leading/trailing whitespace
    /// - Rejects empty or whitespace-only input
    ///
    /// The 50-character soft limit is enforced at the prompt layer with a
    /// warning rather than here, to allow descriptions slightly over the
    /// limit where the 72-character total first-line limit is still satisfied.
    pub fn parse(value: impl Into<String>) -> Result<Self, DescriptionError> {
        let value = value.into().trim().to_owned();
        if value.is_empty() {
            Err(DescriptionError::Empty)
        } else {
            Ok(Self(value))
        }
    }

    /// Returns the inner string slice
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Returns the length in characters
    ///
    /// `is_empty()` is intentionally absent: `Description` is guaranteed
    /// non-empty by its constructor, so the concept does not apply.
    #[allow(clippy::len_without_is_empty)]
    pub fn len(&self) -> usize {
        self.0.chars().count()
    }
}

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,
}

#[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 descriptions over the 50-char soft limit are accepted
    ///
    /// The 50-char limit is enforced as a prompt-layer warning only.
    /// The hard limit is the 72-char total first line (ConventionalCommit).
    #[test]
    fn description_over_soft_limit_accepted() {
        let desc_51 = "a".repeat(51);
        let result = Description::parse(&desc_51);
        assert!(result.is_ok());
        assert_eq!(result.unwrap().len(), 51);

        let desc_72 = "a".repeat(72);
        let result = Description::parse(&desc_72);
        assert!(result.is_ok());
        assert_eq!(result.unwrap().len(), 72);
    }

    /// Test that length is checked after trimming
    #[test]
    fn length_checked_after_trimming() {
        // 50 chars + leading/trailing spaces = should be valid after trim
        let desc_with_spaces = format!("  {}  ", "a".repeat(50));
        let result = Description::parse(&desc_with_spaces);
        assert!(result.is_ok());
        assert_eq!(result.unwrap().as_str().len(), 50);
    }

    /// 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 50 (soft limit)
    #[test]
    fn max_length_constant_is_50() {
        assert_eq!(Description::MAX_LENGTH, 50);
    }

    /// 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 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() {
        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 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() {
        // 50 chars + 2 spaces on each side = 54 chars total, but 50 after trim
        let desc = format!("  {}  ", "x".repeat(50));
        let result = Description::parse(&desc);
        assert!(result.is_ok());
        assert_eq!(result.unwrap().len(), 50);
    }

    /// Test description just over soft limit is accepted after trimming
    ///
    /// 51 chars (trimmed) is over the soft limit but still valid as a Description.
    #[test]
    fn over_soft_limit_after_trim_accepted() {
        let desc = format!("  {}  ", "x".repeat(51));
        let result = Description::parse(&desc);
        assert!(result.is_ok());
        assert_eq!(result.unwrap().len(), 51);
    }
}