feat(ConventionalCommit): implement ConventionalCommit and tests

This commit is contained in:
2026-02-05 23:39:29 +01:00
parent 13d1da2d52
commit 50f500e65c
2 changed files with 511 additions and 0 deletions

509
src/commit/types/message.rs Normal file
View 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");
}
}

View File

@@ -6,3 +6,5 @@ pub use scope::{Scope, ScopeError};
mod description;
pub use description::{Description, DescriptionError};
mod message;