diff --git a/src/commit/types/message.rs b/src/commit/types/message.rs new file mode 100644 index 0000000..d7ab00f --- /dev/null +++ b/src/commit/types/message.rs @@ -0,0 +1,509 @@ +use super::{CommitType, Description, Scope}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConventionalCommit { + commit_type: CommitType, + scope: Scope, + description: Description, +} + +impl ConventionalCommit { + /// Create a new conventional commit message + /// + /// # Arguments + /// All arguments are pre-validated types, so this cannot fail + pub fn new(commit_type: CommitType, scope: Scope, description: Description) -> Self { + Self { + commit_type, + scope, + description, + } + } + + /// Format the complete commit messsage + /// + /// Returns `type(scope): description` if scope is non-empty, or + /// `type: description` if scope is empty + pub fn format(&self) -> String { + if self.scope.is_empty() { + format!("{}: {}", self.commit_type, self.description) + } else { + format!("{}({}): {}", self.commit_type, self.scope, self.description) + } + } + + /// Returns the commit type + pub fn commit_type(&self) -> CommitType { + self.commit_type + } + + /// Returns a reference to the scope + pub fn scope(&self) -> &Scope { + &self.scope + } + + /// Returns a reference to the description + pub fn description(&self) -> &Description { + &self.description + } +} + +impl std::fmt::Display for ConventionalCommit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.format()) + } +} + + +#[cfg(test)] +mod tests { + use super::*; + + /// Helper to create a valid Scope for testing + fn test_scope(value: &str) -> Scope { + Scope::parse(value).expect("test scope should be valid") + } + + /// Helper to create a valid Description for testing + fn test_description(value: &str) -> Description { + Description::parse(value).expect("test description should be valid") + } + + /// Test that ConventionalCommit::new() creates a valid commit with all fields + #[test] + fn new_creates_commit_with_all_fields() { + let commit = ConventionalCommit::new( + CommitType::Feat, + test_scope("cli"), + test_description("add new feature"), + ); + assert_eq!(commit.commit_type(), CommitType::Feat); + assert_eq!(commit.scope().as_str(), "cli"); + assert_eq!(commit.description().as_str(), "add new feature"); + } + + /// Test that ConventionalCommit::new() works with empty scope + #[test] + fn new_creates_commit_with_empty_scope() { + let commit = ConventionalCommit::new( + CommitType::Fix, + Scope::empty(), + test_description("fix critical bug"), + ); + assert_eq!(commit.commit_type(), CommitType::Fix); + assert!(commit.scope().is_empty()); + assert_eq!(commit.description().as_str(), "fix critical bug"); + } + + /// Test that format() produces "type(scope): description" when scope is non-empty + #[test] + fn format_with_scope_produces_correct_output() { + let commit = ConventionalCommit::new( + CommitType::Feat, + test_scope("auth"), + test_description("add login"), + ); + assert_eq!(commit.format(), "feat(auth): add login"); + } + + /// Test format with different scope values + #[test] + fn format_with_various_scopes() { + // Hyphenated scope + let commit1 = ConventionalCommit::new( + CommitType::Fix, + test_scope("user-auth"), + test_description("fix token refresh"), + ); + assert_eq!(commit1.format(), "fix(user-auth): fix token refresh"); + + // Underscored scope + let commit2 = ConventionalCommit::new( + CommitType::Docs, + test_scope("api_docs"), + test_description("update README"), + ); + assert_eq!(commit2.format(), "docs(api_docs): update README"); + + // Scope with slash (Jira-style) + let commit3 = ConventionalCommit::new( + CommitType::Chore, + test_scope("PROJ-123/cleanup"), + test_description("remove unused code"), + ); + assert_eq!( + commit3.format(), + "chore(PROJ-123/cleanup): remove unused code" + ); + } + + /// Test that format() produces "type: description" when scope is empty + #[test] + fn format_without_scope_produces_correct_output() { + let commit = ConventionalCommit::new( + CommitType::Feat, + Scope::empty(), + test_description("add login"), + ); + assert_eq!(commit.format(), "feat: add login"); + } + + /// Test format without scope for various descriptions + #[test] + fn format_without_scope_various_descriptions() { + let commit1 = ConventionalCommit::new( + CommitType::Fix, + Scope::empty(), + test_description("fix critical bug"), + ); + assert_eq!(commit1.format(), "fix: fix critical bug"); + + let commit2 = ConventionalCommit::new( + CommitType::Docs, + Scope::empty(), + test_description("update installation guide"), + ); + assert_eq!(commit2.format(), "docs: update installation guide"); + } + + /// Test that all 11 commit types format correctly with scope + #[test] + fn all_commit_types_format_correctly_with_scope() { + let scope = test_scope("cli"); + let desc = test_description("test change"); + + let expected_formats = [ + (CommitType::Feat, "feat(cli): test change"), + (CommitType::Fix, "fix(cli): test change"), + (CommitType::Docs, "docs(cli): test change"), + (CommitType::Style, "style(cli): test change"), + (CommitType::Refactor, "refactor(cli): test change"), + (CommitType::Perf, "perf(cli): test change"), + (CommitType::Test, "test(cli): test change"), + (CommitType::Build, "build(cli): test change"), + (CommitType::Ci, "ci(cli): test change"), + (CommitType::Chore, "chore(cli): test change"), + (CommitType::Revert, "revert(cli): test change"), + ]; + + for (commit_type, expected) in expected_formats { + let commit = ConventionalCommit::new(commit_type, scope.clone(), desc.clone()); + assert_eq!( + commit.format(), + expected, + "Format should be correct for {:?}", + commit_type + ); + } + } + + /// Test that all 11 commit types format correctly without scope + #[test] + fn all_commit_types_format_correctly_without_scope() { + let desc = test_description("test change"); + + let expected_formats = [ + (CommitType::Feat, "feat: test change"), + (CommitType::Fix, "fix: test change"), + (CommitType::Docs, "docs: test change"), + (CommitType::Style, "style: test change"), + (CommitType::Refactor, "refactor: test change"), + (CommitType::Perf, "perf: test change"), + (CommitType::Test, "test: test change"), + (CommitType::Build, "build: test change"), + (CommitType::Ci, "ci: test change"), + (CommitType::Chore, "chore: test change"), + (CommitType::Revert, "revert: test change"), + ]; + + for (commit_type, expected) in expected_formats { + let commit = ConventionalCommit::new(commit_type, Scope::empty(), desc.clone()); + assert_eq!( + commit.format(), + expected, + "Format should be correct for {:?}", + commit_type + ); + } + } + + /// Test that Display implementation delegates to format() + #[test] + fn display_delegates_to_format() { + let commit = ConventionalCommit::new( + CommitType::Feat, + test_scope("auth"), + test_description("add login"), + ); + let display_output = format!("{}", commit); + let format_output = commit.format(); + assert_eq!(display_output, format_output); + } + + /// Test Display with scope + #[test] + fn display_with_scope() { + let commit = ConventionalCommit::new( + CommitType::Fix, + test_scope("api"), + test_description("handle null response"), + ); + assert_eq!(format!("{}", commit), "fix(api): handle null response"); + } + + /// Test Display without scope + #[test] + fn display_without_scope() { + let commit = ConventionalCommit::new( + CommitType::Docs, + Scope::empty(), + test_description("improve README"), + ); + assert_eq!(format!("{}", commit), "docs: improve README"); + } + + /// Test Display delegates to format for all commit types + #[test] + fn display_equals_format_for_all_types() { + for commit_type in CommitType::all() { + // With scope + let commit_with_scope = ConventionalCommit::new( + *commit_type, + test_scope("test"), + test_description("change"), + ); + assert_eq!( + format!("{}", commit_with_scope), + commit_with_scope.format(), + "Display should equal format() for {:?} with scope", + commit_type + ); + + // Without scope + let commit_without_scope = + ConventionalCommit::new(*commit_type, Scope::empty(), test_description("change")); + assert_eq!( + format!("{}", commit_without_scope), + commit_without_scope.format(), + "Display should equal format() for {:?} without scope", + commit_type + ); + } + } + + /// Test commit_type() returns the correct type + #[test] + fn commit_type_accessor_returns_correct_type() { + for commit_type in CommitType::all() { + let commit = + ConventionalCommit::new(*commit_type, Scope::empty(), test_description("test")); + assert_eq!(commit.commit_type(), *commit_type); + } + } + + /// Test scope() returns reference to scope + #[test] + fn scope_accessor_returns_reference() { + let commit = ConventionalCommit::new( + CommitType::Feat, + test_scope("auth"), + test_description("add feature"), + ); + assert_eq!(commit.scope().as_str(), "auth"); + } + + /// Test scope() returns reference to empty scope + #[test] + fn scope_accessor_returns_empty_scope() { + let commit = ConventionalCommit::new( + CommitType::Feat, + Scope::empty(), + test_description("add feature"), + ); + assert!(commit.scope().is_empty()); + } + + /// Test description() returns reference to description + #[test] + fn description_accessor_returns_reference() { + let commit = ConventionalCommit::new( + CommitType::Feat, + Scope::empty(), + test_description("add new authentication flow"), + ); + assert_eq!(commit.description().as_str(), "add new authentication flow"); + } + + /// Test Clone trait + #[test] + fn conventional_commit_is_cloneable() { + let original = ConventionalCommit::new( + CommitType::Feat, + test_scope("cli"), + test_description("add feature"), + ); + let cloned = original.clone(); + assert_eq!(original, cloned); + } + + /// Test PartialEq trait - equal commits + #[test] + fn conventional_commit_equality() { + let commit1 = ConventionalCommit::new( + CommitType::Feat, + test_scope("cli"), + test_description("add feature"), + ); + let commit2 = ConventionalCommit::new( + CommitType::Feat, + test_scope("cli"), + test_description("add feature"), + ); + assert_eq!(commit1, commit2); + } + + /// Test PartialEq trait - different commit types + #[test] + fn conventional_commit_inequality_different_type() { + let commit1 = ConventionalCommit::new( + CommitType::Feat, + test_scope("cli"), + test_description("change"), + ); + let commit2 = ConventionalCommit::new( + CommitType::Fix, + test_scope("cli"), + test_description("change"), + ); + assert_ne!(commit1, commit2); + } + + /// Test PartialEq trait - different scopes + #[test] + fn conventional_commit_inequality_different_scope() { + let commit1 = ConventionalCommit::new( + CommitType::Feat, + test_scope("cli"), + test_description("change"), + ); + let commit2 = ConventionalCommit::new( + CommitType::Feat, + test_scope("api"), + test_description("change"), + ); + assert_ne!(commit1, commit2); + } + + /// Test PartialEq trait - different descriptions + #[test] + fn conventional_commit_inequality_different_description() { + let commit1 = ConventionalCommit::new( + CommitType::Feat, + test_scope("cli"), + test_description("add feature"), + ); + let commit2 = ConventionalCommit::new( + CommitType::Feat, + test_scope("cli"), + test_description("fix bug"), + ); + assert_ne!(commit1, commit2); + } + + /// Test Debug trait + #[test] + fn conventional_commit_has_debug() { + let commit = ConventionalCommit::new( + CommitType::Feat, + test_scope("cli"), + test_description("add feature"), + ); + let debug_output = format!("{:?}", commit); + assert!(debug_output.contains("ConventionalCommit")); + assert!(debug_output.contains("Feat")); + } + + /// Test real-world commit message example: feature with scope + #[test] + fn real_world_feature_with_scope() { + let commit = ConventionalCommit::new( + CommitType::Feat, + test_scope("auth"), + test_description("implement OAuth2 login flow"), + ); + assert_eq!(commit.format(), "feat(auth): implement OAuth2 login flow"); + } + + /// Test real-world commit message example: bug fix without scope + #[test] + fn real_world_bugfix_without_scope() { + let commit = ConventionalCommit::new( + CommitType::Fix, + Scope::empty(), + test_description("prevent crash on empty input"), + ); + assert_eq!(commit.format(), "fix: prevent crash on empty input"); + } + + /// Test real-world commit message example: documentation + #[test] + fn real_world_docs() { + let commit = ConventionalCommit::new( + CommitType::Docs, + test_scope("README"), + test_description("add installation instructions"), + ); + assert_eq!( + commit.format(), + "docs(README): add installation instructions" + ); + } + + /// Test real-world commit message example: refactoring + #[test] + fn real_world_refactor() { + let commit = ConventionalCommit::new( + CommitType::Refactor, + test_scope("core"), + test_description("extract validation logic"), + ); + assert_eq!(commit.format(), "refactor(core): extract validation logic"); + } + + /// Test real-world commit message example: CI change + #[test] + fn real_world_ci() { + let commit = ConventionalCommit::new( + CommitType::Ci, + test_scope("github"), + test_description("add release workflow"), + ); + assert_eq!(commit.format(), "ci(github): add release workflow"); + } + + /// Test commit message with maximum length description (72 chars) + #[test] + fn format_with_max_length_description() { + let long_desc = "a".repeat(72); + let commit = ConventionalCommit::new( + CommitType::Feat, + Scope::empty(), + Description::parse(&long_desc).unwrap(), + ); + // Format should be "feat: " + 72 chars = 78 total chars + let formatted = commit.format(); + assert!(formatted.starts_with("feat: ")); + assert_eq!(formatted.len(), 78); // "feat: " (6) + 72 = 78 + } + + /// Test commit message with scope containing all valid special chars + #[test] + fn format_with_complex_scope() { + let commit = ConventionalCommit::new( + CommitType::Feat, + test_scope("my-scope_v2/feature"), + test_description("add support"), + ); + assert_eq!(commit.format(), "feat(my-scope_v2/feature): add support"); + } +} diff --git a/src/commit/types/mod.rs b/src/commit/types/mod.rs index e4e0973..5424949 100644 --- a/src/commit/types/mod.rs +++ b/src/commit/types/mod.rs @@ -6,3 +6,5 @@ pub use scope::{Scope, ScopeError}; mod description; pub use description::{Description, DescriptionError}; + +mod message;