gitbox 2.1.3

Git toolbox to simplify adoption of conventional commits and semantic version, among other things.
Documentation
use std::{fmt::Display, str::FromStr};

use regex::Regex;

use crate::{
    application::error::commit_summary_parsing_error::{
        CommitSummaryParsingError, FreeFormCommitSummaryError,
    },
    domain::{
        commit_summary::CommitSummary, conventional_commit::ConventionalCommit,
        conventional_commit_summary::ConventionalCommitSummary,
    },
};

// Groups: 1 = type, 2 = scope with (), 3 = scope, 4 = breaking change, 5 = summary
const CONVENTIONAL_COMMIT_PATTERN: &str = r"^(\w+)(\(([\w/-]+)\))?(!)?: (.+)$";

impl FromStr for CommitSummary {
    type Err = CommitSummaryParsingError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let regex = Regex::new(CONVENTIONAL_COMMIT_PATTERN)
            .expect("The regex pattern is expected to be correct");
        let captures = regex.captures(s);
        match captures {
            Some(caps) => {
                let commit_type = caps.get(1).expect("Type should be expected").as_str();
                let scope = caps.get(3).map(|it| it.as_str());
                let breaking = caps.get(4).is_some();
                let summary = caps.get(5).expect("summary is expected").as_str();
                Ok(CommitSummary::Conventional(ConventionalCommitSummary::new(
                    commit_type.to_owned(),
                    scope.map(|it| it.to_owned()),
                    breaking.into(),
                    summary.to_owned(),
                )?))
            }
            None => {
                if s.is_empty() {
                    Err(
                        FreeFormCommitSummaryError::new("Free form commit message cannot be empty")
                            .into(),
                    )
                } else {
                    Ok(CommitSummary::FreeForm(s.to_owned()))
                }
            }
        }
    }
}

impl Display for ConventionalCommitSummary {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "{}{}{}: {}",
            &self.typ(),
            &self
                .scope()
                .as_ref()
                .map_or(String::new(), |s| format!("({})", s)),
            if self.breaking() { "!" } else { "" },
            &self.summary()
        )
    }
}

impl Display for ConventionalCommit {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "{}{}",
            self.summary(),
            self.message()
                .map_or_else(String::new, |it| format!("\n\n{}", it))
        )
    }
}

#[cfg(test)]
mod tests {
    use std::str::FromStr;

    use crate::domain::{
        commit_summary::CommitSummary,
        conventional_commit::ConventionalCommit,
        conventional_commit_summary::{
            ConventionalCommitSummary, ConventionalCommitSummaryBreakingFlag,
        },
    };

    #[test]
    fn freeform_commit_correct() {
        let free_form_commit = "Test update #1";
        let c = CommitSummary::from_str(free_form_commit);
        assert!(c.is_ok());
        assert!(matches!(c.expect("Just asserted its OK-ness"),
            CommitSummary::FreeForm(s) if s == *free_form_commit));
    }

    #[test]
    fn freeform_commit_empty() {
        let free_form_commit = "";
        let c = CommitSummary::from_str(free_form_commit);
        assert!(c.is_err());
    }

    #[test]
    fn conventional_commit_basic() {
        let basic_commit = "feat: test";
        let c = CommitSummary::from_str(basic_commit);
        let expected = ConventionalCommitSummary::new(
            "feat".to_string(),
            None,
            ConventionalCommitSummaryBreakingFlag::Disabled,
            "test".to_string(),
        )
        .expect("Hand-crafted conventional commit summary is correct");
        assert!(c.is_ok());
        assert!(match c.expect("Just asserted its OK-ness") {
            CommitSummary::Conventional(conv) => conv == expected,
            _ => false,
        });
    }

    #[test]
    fn conventional_commit_scoped() {
        let scoped_commit = "feat(scope): test";
        let c = CommitSummary::from_str(scoped_commit);
        let expected = ConventionalCommitSummary::new(
            "feat".to_string(),
            Some("scope".to_string()),
            ConventionalCommitSummaryBreakingFlag::Disabled,
            "test".to_string(),
        )
        .expect("Hand-crafted conventional commit summary is correct");
        assert!(c.is_ok());
        assert!(match c.expect("Just asserted its OK-ness") {
            CommitSummary::Conventional(conv) => conv == expected,
            _ => false,
        });
    }

    #[test]
    fn conventional_commit_breaking() {
        let breaking_commit = "feat!: test";
        let c = CommitSummary::from_str(breaking_commit);
        let expected = ConventionalCommitSummary::new(
            "feat".to_string(),
            None,
            ConventionalCommitSummaryBreakingFlag::Enabled,
            "test".to_string(),
        )
        .expect("Hand-crafted conventional commit summary is correct");
        assert!(c.is_ok());
        assert!(match c.expect("Just asserted its OK-ness") {
            CommitSummary::Conventional(conv) => conv == expected,
            _ => false,
        });
    }

    #[test]
    fn conventional_commit_scoped_and_breaking() {
        let breaking_scoped_commit = "feat(scope)!: test";
        let c = CommitSummary::from_str(breaking_scoped_commit);
        let expected = ConventionalCommitSummary::new(
            "feat".to_string(),
            Some("scope".to_string()),
            ConventionalCommitSummaryBreakingFlag::Enabled,
            "test".to_string(),
        )
        .expect("Hand-crafted conventional commit summary is correct");
        assert!(c.is_ok());
        assert!(match c.expect("Just asserted its OK-ness") {
            CommitSummary::Conventional(conv) => conv == expected,
            _ => false,
        });
    }

    #[test]
    fn simple_commit_format() {
        let commit = ConventionalCommitSummary::new(
            "feat".to_string(),
            None,
            ConventionalCommitSummaryBreakingFlag::Disabled,
            "test format".to_string(),
        )
        .expect("Hand-crafted conventional commit summary is correct");
        assert_eq!(&commit.to_string(), "feat: test format");
    }

    #[test]
    fn scoped_commit_format() {
        let commit = ConventionalCommitSummary::new(
            "feat".to_string(),
            Some("domain".to_string()),
            ConventionalCommitSummaryBreakingFlag::Disabled,
            "test format".to_string(),
        )
        .expect("Hand-crafted conventional commit summary is correct");
        assert_eq!(&commit.to_string(), "feat(domain): test format");
    }

    #[test]
    fn breaking_commit_format() {
        let commit = ConventionalCommitSummary::new(
            "feat".to_string(),
            None,
            ConventionalCommitSummaryBreakingFlag::Enabled,
            "test format".to_string(),
        )
        .expect("Hand-crafted conventional commit summary is correct");
        assert_eq!(&commit.to_string(), "feat!: test format");
    }

    #[test]
    fn breaking_and_scoped_commit_format() {
        let commit = ConventionalCommitSummary::new(
            "feat".to_string(),
            Some("domain".to_string()),
            ConventionalCommitSummaryBreakingFlag::Enabled,
            "test format".to_string(),
        )
        .expect("Hand-crafted conventional commit summary is correct");
        assert_eq!(&commit.to_string(), "feat(domain)!: test format");
    }

    #[test]
    fn full_conventional_commit_without_message() {
        let commit = ConventionalCommit::new(
            "feat".to_string(),
            Some("domain".to_string()),
            ConventionalCommitSummaryBreakingFlag::Enabled,
            "test format".to_string(),
            None,
        )
        .expect("Hand-crafted conventional commit summary is correct");
        assert_eq!(&commit.to_string(), "feat(domain)!: test format");
    }

    #[test]
    fn full_conventional_commit_with_message() {
        let commit = ConventionalCommit::new(
            "feat".to_string(),
            Some("domain".to_string()),
            ConventionalCommitSummaryBreakingFlag::Enabled,
            "test format".to_string(),
            Some("Message body".to_string()),
        )
        .expect("Hand-crafted conventional commit summary is correct");
        assert_eq!(
            &commit.to_string(),
            "feat(domain)!: test format\n\nMessage body"
        );
    }
}