jj-cz 1.1.0

Conventional commits for Jujutsu
Documentation
#[derive(Debug, Default, Clone, PartialEq, Eq)]
#[repr(transparent)]
pub struct Body(Option<String>);

impl<T: ToString> From<T> for Body {
    fn from(value: T) -> Self {
        let value = value.to_string();
        let lines: Vec<&str> = value
            .trim_end()
            .lines()
            .map(|line| line.trim_end())
            .skip_while(|line| line.is_empty())
            .collect();
        match lines.join("\n").as_str() {
            "" => Self::default(),
            value => Self(Some(value.into())),
        }
    }
}

impl Body {
    pub fn format(&self) -> String {
        match &self.0 {
            None => String::new(),
            Some(value) if value.trim().is_empty() => String::new(),
            Some(body) => format!("\n{body}\n"),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Default produces Body(None) - no body
    #[test]
    fn default_produces_none() {
        assert_eq!(Body::default(), Body(None));
    }

    /// Empty string produces Body(None)
    #[test]
    fn from_empty_string_produces_none() {
        assert_eq!(Body::from(""), Body(None));
    }

    /// Whitespace-only string produces Body(None)
    #[test]
    fn from_whitespace_only_produces_none() {
        assert_eq!(Body::from("   "), Body(None));
    }

    /// Tabs and newlines only produce Body(None)
    #[test]
    fn from_tab_and_newline_only_produces_none() {
        assert_eq!(Body::from("\t\n  "), Body(None));
    }

    /// A single newline (typical empty-editor save) produces Body(None)
    #[test]
    fn from_single_newline_produces_none() {
        assert_eq!(Body::from("\n"), Body(None));
    }

    /// Non-empty string produces Body(Some(...)) with content preserved
    #[test]
    fn from_non_empty_string_produces_some() {
        assert_eq!(
            Body::from("some body text"),
            Body(Some("some body text".to_string())),
        );
    }

    /// Leading and internal whitespace is preserved - users may write
    /// indented lists, ASCII art, file trees, etc.
    #[test]
    fn from_preserves_leading_whitespace() {
        assert_eq!(
            Body::from("  content  "),
            Body(Some("  content".to_string())),
        );
    }

    /// Leading whitespace on individual lines is preserved
    #[test]
    fn from_preserves_per_line_leading_whitespace() {
        let input = "- item one\n  - nested item\n    - deeply nested";
        assert_eq!(Body::from(input), Body(Some(input.to_string())),);
    }

    /// Trailing newline (typical editor output) is stripped
    #[test]
    fn from_trims_trailing_newline() {
        assert_eq!(
            Body::from("editor content\n"),
            Body(Some("editor content".to_string())),
        );
    }

    /// Leading blank lines (e.g. from editor artefacts after JJ: comment
    /// stripping) are dropped
    #[test]
    fn from_drops_leading_blank_lines() {
        assert_eq!(Body::from("\n\ncontent"), Body(Some("content".to_string())),);
    }

    /// Windows-style CRLF line endings are normalised to LF
    #[test]
    fn from_normalises_crlf_to_lf() {
        assert_eq!(
            Body::from("line one\r\nline two"),
            Body(Some("line one\nline two".to_string())),
        );
    }

    /// Internal newlines are preserved for multi-line bodies
    #[test]
    fn from_preserves_internal_newlines() {
        assert_eq!(
            Body::from("line one\nline two"),
            Body(Some("line one\nline two".to_string())),
        );
    }

    /// Into<Body> conversion works via `.into()`
    #[test]
    fn into_conversion_works() {
        let body: Body = "content".into();
        assert_eq!(body, Body(Some("content".to_string())));
    }

    /// Clone produces a value equal to the original
    #[test]
    fn clone_produces_equal_value() {
        let body = Body::from("content");
        assert_eq!(body.clone(), body);
    }

    /// Two bodies constructed from the same string are equal
    #[test]
    fn equality_same_content() {
        assert_eq!(Body::from("same"), Body::from("same"));
    }

    /// Bodies with different content are not equal
    #[test]
    fn inequality_different_content() {
        assert_ne!(Body::from("first"), Body::from("second"));
    }

    /// None body is not equal to a body with content
    #[test]
    fn inequality_none_vs_some() {
        assert_ne!(Body::default(), Body::from("content"));
    }

    /// Debug output is available and mentions Body
    #[test]
    fn debug_output_is_available() {
        let body = Body::from("test");
        assert!(format!("{:?}", body).contains("Body"));
    }

    /// format() on a None body returns an empty string
    #[test]
    fn format_none_returns_empty_string() {
        assert_eq!(Body::default().format(), "");
    }

    /// format() on a Some body returns "\ncontent\n"
    /// (leading \n creates the blank line after the commit header;
    /// trailing \n creates the blank line before the footer)
    #[test]
    fn format_some_returns_newline_wrapped_content() {
        let body = Body::from("some body text");
        assert_eq!(body.format(), "\nsome body text\n");
    }

    /// format() preserves internal newlines in multi-line bodies
    #[test]
    fn format_some_multiline_preserves_content() {
        let body = Body::from("line one\nline two");
        assert_eq!(body.format(), "\nline one\nline two\n");
    }
}