feat(ConventionalCommit): implement ConventionalCommit and tests
This commit is contained in:
509
src/commit/types/message.rs
Normal file
509
src/commit/types/message.rs
Normal file
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,3 +6,5 @@ pub use scope::{Scope, ScopeError};
|
|||||||
|
|
||||||
mod description;
|
mod description;
|
||||||
pub use description::{Description, DescriptionError};
|
pub use description::{Description, DescriptionError};
|
||||||
|
|
||||||
|
mod message;
|
||||||
|
|||||||
Reference in New Issue
Block a user