use super::{Body, BreakingChange, CommitType, Description, Footer, References, Scope};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum CommitMessageError {
#[error("first line too long: {actual} characters (max {max})")]
FirstLineTooLong { actual: usize, max: usize },
#[error("output failed git-conventional validation: {reason}")]
InvalidConventionalFormat { reason: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConventionalCommit {
commit_type: CommitType,
scope: Scope,
description: Description,
breaking_change: BreakingChange,
body: Body,
references: References,
}
impl ConventionalCommit {
pub const FIRST_LINE_MAX_LENGTH: usize = 72;
pub fn new(
commit_type: CommitType,
scope: Scope,
description: Description,
breaking_change: BreakingChange,
body: Body,
references: References,
) -> Result<Self, CommitMessageError> {
let commit = Self {
commit_type,
scope,
description,
breaking_change,
body,
references,
};
let len = commit.first_line_len();
if len > Self::FIRST_LINE_MAX_LENGTH {
return Err(CommitMessageError::FirstLineTooLong {
actual: len,
max: Self::FIRST_LINE_MAX_LENGTH,
});
}
let formatted = commit.format();
git_conventional::Commit::parse(&formatted).map_err(|e| {
CommitMessageError::InvalidConventionalFormat {
reason: e.to_string(),
}
})?;
Ok(commit)
}
pub fn first_line_len(&self) -> usize {
self.commit_type.len()
+ self.scope.header_segment_len()
+ if self.breaking_change.is_absent() { 0 } else { 1 }
+ 2 + self.description.len()
}
pub fn format(&self) -> String {
Self::format_preview(
self.commit_type,
&self.scope,
&self.description,
&self.breaking_change,
&self.body,
&self.references,
)
}
pub fn format_preview(
commit_type: CommitType,
scope: &Scope,
description: &Description,
breaking_change: &BreakingChange,
body: &Body,
references: &References,
) -> String {
let scope = scope.header_segment();
let breaking_change_header = breaking_change.header_segment();
let breaking_change_footer = breaking_change.as_footer();
let refs_footer = references.as_footer();
format!(
r#"{commit_type}{scope}{breaking_change_header}: {description}
{}
{breaking_change_footer}{refs_footer}"#,
body.format()
)
.trim()
.to_string()
}
}
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::*;
fn test_scope(value: &str) -> Scope {
Scope::parse(value).expect("test scope should be valid")
}
fn test_description(value: &str) -> Description {
Description::parse(value).expect("test description should be valid")
}
fn test_commit(
commit_type: CommitType,
scope: Scope,
description: Description,
breaking_change: BreakingChange,
) -> ConventionalCommit {
ConventionalCommit::new(
commit_type,
scope,
description,
breaking_change,
Body::default(),
References::default(),
)
.expect("test commit should have valid line length")
}
#[test]
fn new_creates_commit_with_all_fields() {
let commit = test_commit(
CommitType::Feat,
test_scope("cli"),
test_description("add new feature"),
BreakingChange::No,
);
assert_eq!(commit.commit_type, CommitType::Feat);
assert_eq!(commit.scope.as_str(), "cli");
assert_eq!(commit.description.as_str(), "add new feature");
}
#[test]
fn new_creates_commit_with_empty_scope() {
let commit = test_commit(
CommitType::Fix,
Scope::empty(),
test_description("fix critical bug"),
BreakingChange::No,
);
assert_eq!(commit.commit_type, CommitType::Fix);
assert!(commit.scope.is_empty());
assert_eq!(commit.description.as_str(), "fix critical bug");
}
#[test]
fn format_with_scope_produces_correct_output() {
let commit = test_commit(
CommitType::Feat,
test_scope("auth"),
test_description("add login"),
BreakingChange::No,
);
assert_eq!(commit.format(), "feat(auth): add login");
}
#[test]
fn format_with_various_scopes() {
let commit1 = test_commit(
CommitType::Fix,
test_scope("user-auth"),
test_description("fix token refresh"),
BreakingChange::No,
);
assert_eq!(commit1.format(), "fix(user-auth): fix token refresh");
let commit2 = test_commit(
CommitType::Docs,
test_scope("api_docs"),
test_description("update README"),
BreakingChange::No,
);
assert_eq!(commit2.format(), "docs(api_docs): update README");
let commit3 = test_commit(
CommitType::Chore,
test_scope("PROJ-123/cleanup"),
test_description("remove unused code"),
BreakingChange::No,
);
assert_eq!(
commit3.format(),
"chore(PROJ-123/cleanup): remove unused code"
);
}
#[test]
fn format_without_scope_produces_correct_output() {
let commit = test_commit(
CommitType::Feat,
Scope::empty(),
test_description("add login"),
BreakingChange::No,
);
assert_eq!(commit.format(), "feat: add login");
}
#[test]
fn format_without_scope_various_descriptions() {
let commit1 = test_commit(
CommitType::Fix,
Scope::empty(),
test_description("fix critical bug"),
BreakingChange::No,
);
assert_eq!(commit1.format(), "fix: fix critical bug");
let commit2 = test_commit(
CommitType::Docs,
Scope::empty(),
test_description("update installation guide"),
BreakingChange::No,
);
assert_eq!(commit2.format(), "docs: update installation guide");
}
#[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 = test_commit(commit_type, scope.clone(), desc.clone(), BreakingChange::No);
assert_eq!(
commit.format(),
expected,
"Format should be correct for {:?}",
commit_type
);
}
}
#[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 = test_commit(
commit_type,
Scope::empty(),
desc.clone(),
BreakingChange::No,
);
assert_eq!(
commit.format(),
expected,
"Format should be correct for {:?}",
commit_type
);
}
}
#[test]
fn display_delegates_to_format() {
let commit = test_commit(
CommitType::Feat,
test_scope("auth"),
test_description("add login"),
BreakingChange::No,
);
let display_output = format!("{}", commit);
let format_output = commit.format();
assert_eq!(display_output, format_output);
}
#[test]
fn display_with_scope() {
let commit = test_commit(
CommitType::Fix,
test_scope("api"),
test_description("handle null response"),
BreakingChange::No,
);
assert_eq!(format!("{}", commit), "fix(api): handle null response");
}
#[test]
fn display_without_scope() {
let commit = test_commit(
CommitType::Docs,
Scope::empty(),
test_description("improve README"),
BreakingChange::No,
);
assert_eq!(format!("{}", commit), "docs: improve README");
}
#[test]
fn display_equals_format_for_all_types() {
for commit_type in CommitType::all() {
let commit_with_scope = test_commit(
*commit_type,
test_scope("test"),
test_description("change"),
BreakingChange::No,
);
assert_eq!(
format!("{}", commit_with_scope),
commit_with_scope.format(),
"Display should equal format() for {:?} with scope",
commit_type
);
let commit_without_scope = test_commit(
*commit_type,
Scope::empty(),
test_description("change"),
BreakingChange::No,
);
assert_eq!(
format!("{}", commit_without_scope),
commit_without_scope.format(),
"Display should equal format() for {:?} without scope",
commit_type
);
}
}
#[test]
fn conventional_commit_is_cloneable() {
let original = test_commit(
CommitType::Feat,
test_scope("cli"),
test_description("add feature"),
BreakingChange::No,
);
let cloned = original.clone();
assert_eq!(original, cloned);
}
#[test]
fn conventional_commit_equality() {
let commit1 = test_commit(
CommitType::Feat,
test_scope("cli"),
test_description("add feature"),
BreakingChange::No,
);
let commit2 = test_commit(
CommitType::Feat,
test_scope("cli"),
test_description("add feature"),
BreakingChange::No,
);
assert_eq!(commit1, commit2);
}
#[test]
fn conventional_commit_inequality_different_type() {
let commit1 = test_commit(
CommitType::Feat,
test_scope("cli"),
test_description("change"),
BreakingChange::No,
);
let commit2 = test_commit(
CommitType::Fix,
test_scope("cli"),
test_description("change"),
BreakingChange::No,
);
assert_ne!(commit1, commit2);
}
#[test]
fn conventional_commit_inequality_different_scope() {
let commit1 = test_commit(
CommitType::Feat,
test_scope("cli"),
test_description("change"),
BreakingChange::No,
);
let commit2 = test_commit(
CommitType::Feat,
test_scope("api"),
test_description("change"),
BreakingChange::No,
);
assert_ne!(commit1, commit2);
}
#[test]
fn conventional_commit_inequality_different_description() {
let commit1 = test_commit(
CommitType::Feat,
test_scope("cli"),
test_description("add feature"),
BreakingChange::No,
);
let commit2 = test_commit(
CommitType::Feat,
test_scope("cli"),
test_description("fix bug"),
BreakingChange::No,
);
assert_ne!(commit1, commit2);
}
#[test]
fn conventional_commit_has_debug() {
let commit = test_commit(
CommitType::Feat,
test_scope("cli"),
test_description("add feature"),
BreakingChange::No,
);
let debug_output = format!("{:?}", commit);
assert!(debug_output.contains("ConventionalCommit"));
assert!(debug_output.contains("Feat"));
}
#[test]
fn real_world_feature_with_scope() {
let commit = test_commit(
CommitType::Feat,
test_scope("auth"),
test_description("implement OAuth2 login flow"),
BreakingChange::No,
);
assert_eq!(commit.format(), "feat(auth): implement OAuth2 login flow");
}
#[test]
fn real_world_bugfix_without_scope() {
let commit = test_commit(
CommitType::Fix,
Scope::empty(),
test_description("prevent crash on empty input"),
BreakingChange::No,
);
assert_eq!(commit.format(), "fix: prevent crash on empty input");
}
#[test]
fn real_world_docs() {
let commit = test_commit(
CommitType::Docs,
test_scope("README"),
test_description("add installation instructions"),
BreakingChange::No,
);
assert_eq!(
commit.format(),
"docs(README): add installation instructions"
);
}
#[test]
fn real_world_refactor() {
let commit = test_commit(
CommitType::Refactor,
test_scope("core"),
test_description("extract validation logic"),
BreakingChange::No,
);
assert_eq!(commit.format(), "refactor(core): extract validation logic");
}
#[test]
fn real_world_ci() {
let commit = test_commit(
CommitType::Ci,
test_scope("github"),
test_description("add release workflow"),
BreakingChange::No,
);
assert_eq!(commit.format(), "ci(github): add release workflow");
}
#[test]
fn format_with_max_length_description() {
let long_desc = "a".repeat(50);
let commit = test_commit(
CommitType::Feat,
Scope::empty(),
Description::parse(&long_desc).unwrap(),
BreakingChange::No,
);
let formatted = commit.format();
assert!(formatted.starts_with("feat: "));
assert_eq!(formatted.len(), 56); }
#[test]
fn format_with_complex_scope() {
let commit = test_commit(
CommitType::Feat,
test_scope("my-scope_v2/feature"),
test_description("add support"),
BreakingChange::No,
);
assert_eq!(commit.format(), "feat(my-scope_v2/feature): add support");
}
#[test]
fn first_line_max_length_constant_is_72() {
assert_eq!(ConventionalCommit::FIRST_LINE_MAX_LENGTH, 72);
}
#[test]
fn first_line_len_without_scope() {
let commit = test_commit(
CommitType::Feat,
Scope::empty(),
test_description("add login"),
BreakingChange::No,
);
assert_eq!(commit.first_line_len(), 15);
}
#[test]
fn first_line_len_with_scope() {
let commit = test_commit(
CommitType::Feat,
test_scope("auth"),
test_description("add login"),
BreakingChange::No,
);
assert_eq!(commit.first_line_len(), 21);
}
#[test]
fn exactly_72_characters_accepted() {
let scope_20 = "a".repeat(20);
let desc_44 = "b".repeat(44);
let result = ConventionalCommit::new(
CommitType::Feat,
Scope::parse(&scope_20).unwrap(),
Description::parse(&desc_44).unwrap(),
BreakingChange::No,
Body::default(),
References::default(),
);
assert!(result.is_ok());
let commit = result.unwrap();
assert_eq!(commit.first_line_len(), 72);
}
#[test]
fn seventy_three_characters_rejected() {
let scope_30 = "a".repeat(30);
let desc_31 = "b".repeat(31);
let result = ConventionalCommit::new(
CommitType::Refactor,
Scope::parse(&scope_30).unwrap(),
Description::parse(&desc_31).unwrap(),
BreakingChange::No,
Body::default(),
References::default(),
);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
CommitMessageError::FirstLineTooLong {
actual: 73,
max: 72
}
);
}
#[test]
fn valid_components_can_exceed_limit() {
let scope_30 = "a".repeat(30);
let desc_40 = "b".repeat(40);
let result = ConventionalCommit::new(
CommitType::Refactor,
Scope::parse(&scope_30).unwrap(),
Description::parse(&desc_40).unwrap(),
BreakingChange::No,
Body::default(),
References::default(),
);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
CommitMessageError::FirstLineTooLong {
actual: 82,
max: 72
}
);
}
#[test]
fn short_commit_without_scope_accepted() {
let result = ConventionalCommit::new(
CommitType::Fix,
Scope::empty(),
test_description("quick fix"),
BreakingChange::No,
Body::default(),
References::default(),
);
assert!(result.is_ok());
}
#[test]
fn short_commit_with_scope_accepted() {
let result = ConventionalCommit::new(
CommitType::Feat,
test_scope("cli"),
test_description("add feature"),
BreakingChange::No,
Body::default(),
References::default(),
);
assert!(result.is_ok());
}
#[test]
fn first_line_too_long_error_display() {
let err = CommitMessageError::FirstLineTooLong {
actual: 80,
max: 72,
};
let msg = format!("{}", err);
assert!(msg.contains("too long"));
assert!(msg.contains("80"));
assert!(msg.contains("72"));
}
#[test]
fn new_returns_result() {
let result = ConventionalCommit::new(
CommitType::Feat,
Scope::empty(),
test_description("test"),
BreakingChange::No,
Body::default(),
References::default(),
);
assert!(result.is_ok());
}
#[test]
fn all_valid_commits_parse_with_git_conventional() {
let cases: &[(&str, Option<&str>)] = &[
("add new feature", None),
("fix critical bug", Some("api")),
("update README", Some("docs")),
("remove unused code", Some("core")),
];
for commit_type in CommitType::all() {
for (desc_str, scope_str) in cases {
let scope = match scope_str {
Some(s) => Scope::parse(*s).unwrap(),
None => Scope::empty(),
};
let desc = Description::parse(*desc_str).unwrap();
let commit = ConventionalCommit::new(
*commit_type,
scope,
desc,
BreakingChange::No,
Body::default(),
References::default(),
);
assert!(
commit.is_ok(),
"git-conventional rejected {:?}/{:?}/{:?}",
commit_type,
scope_str,
desc_str
);
}
}
}
#[test]
fn invalid_conventional_format_error_display() {
let err = CommitMessageError::InvalidConventionalFormat {
reason: "missing type".to_string(),
};
let msg = format!("{}", err);
assert!(msg.contains("git-conventional"));
assert!(msg.contains("missing type"));
}
#[test]
fn format_breaking_change_no_note_no_scope() {
let commit = test_commit(
CommitType::Feat,
Scope::empty(),
test_description("add login"),
BreakingChange::Yes,
);
assert_eq!(commit.format(), "feat!: add login");
}
#[test]
fn format_breaking_change_no_note_with_scope() {
let commit = test_commit(
CommitType::Feat,
test_scope("auth"),
test_description("add login"),
BreakingChange::Yes,
);
assert_eq!(commit.format(), "feat(auth)!: add login");
}
#[test]
fn format_breaking_change_with_note_no_scope() {
let commit = test_commit(
CommitType::Feat,
Scope::empty(),
test_description("drop Node 6"),
"Node 6 is no longer supported".into(),
);
assert_eq!(
commit.format(),
"feat!: drop Node 6\n\nBREAKING CHANGE: Node 6 is no longer supported",
);
}
#[test]
fn format_breaking_change_with_note_and_scope() {
let commit = test_commit(
CommitType::Fix,
test_scope("api"),
test_description("drop Node 6"),
"Node 6 is no longer supported".into(),
);
assert_eq!(
commit.format(),
"fix(api)!: drop Node 6\n\nBREAKING CHANGE: Node 6 is no longer supported",
);
}
#[test]
fn display_breaking_change_with_note() {
let commit = test_commit(
CommitType::Feat,
Scope::empty(),
test_description("drop Node 6"),
"Node 6 is no longer supported".into(),
);
assert_eq!(
format!("{}", commit),
"feat!: drop Node 6\n\nBREAKING CHANGE: Node 6 is no longer supported",
);
}
#[test]
fn first_line_len_breaking_change_no_scope() {
let commit = test_commit(
CommitType::Feat,
Scope::empty(),
test_description("add login"),
BreakingChange::Yes,
);
assert_eq!(commit.first_line_len(), 16);
}
#[test]
fn first_line_len_breaking_change_with_scope() {
let commit = test_commit(
CommitType::Feat,
test_scope("auth"),
test_description("add login"),
BreakingChange::Yes,
);
assert_eq!(commit.first_line_len(), 22);
}
#[test]
fn breaking_change_exclamation_counts_toward_line_limit() {
let scope_20 = "a".repeat(20);
let desc_44 = "b".repeat(44);
let result = ConventionalCommit::new(
CommitType::Feat,
Scope::parse(&scope_20).unwrap(),
Description::parse(&desc_44).unwrap(),
BreakingChange::Yes,
Body::default(),
References::default(),
);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
CommitMessageError::FirstLineTooLong {
actual: 73,
max: 72
},
);
}
#[test]
fn breaking_change_footer_does_not_count_toward_line_limit() {
let long_note = "x".repeat(200);
let result = ConventionalCommit::new(
CommitType::Fix,
Scope::empty(),
test_description("quick fix"),
long_note.into(),
Body::default(),
References::default(),
);
assert!(result.is_ok());
}
#[test]
fn format_preview_matches_format() {
let commit = test_commit(
CommitType::Feat,
test_scope("auth"),
test_description("add login"),
BreakingChange::No,
);
let preview = ConventionalCommit::format_preview(
commit.commit_type,
&commit.scope,
&commit.description,
&BreakingChange::No,
&Body::default(),
&References::default(),
);
assert_eq!(preview, commit.format());
}
#[test]
fn format_preview_breaking_change_with_note() {
let preview = ConventionalCommit::format_preview(
CommitType::Feat,
&Scope::empty(),
&test_description("drop legacy API"),
&"removes legacy endpoint".into(),
&Body::default(),
&References::default(),
);
assert_eq!(
preview,
"feat!: drop legacy API\n\nBREAKING CHANGE: removes legacy endpoint"
);
}
#[test]
fn format_preview_breaking_change_with_scope_and_note() {
let preview = ConventionalCommit::format_preview(
CommitType::Fix,
&test_scope("api"),
&test_description("drop Node 6"),
&"Node 6 is no longer supported".into(),
&Body::default(),
&References::default(),
);
assert_eq!(
preview,
"fix(api)!: drop Node 6\n\nBREAKING CHANGE: Node 6 is no longer supported"
);
}
#[test]
fn format_breaking_change_footer_separator() {
let commit = test_commit(
CommitType::Fix,
Scope::empty(),
test_description("drop old API"),
"old API removed".into(),
);
let formatted = commit.format();
let parts: Vec<&str> = formatted.splitn(2, "\n\n").collect();
assert_eq!(
parts.len(),
2,
"expected header and footer separated by \\n\\n"
);
assert_eq!(parts[0], "fix!: drop old API");
assert_eq!(parts[1], "BREAKING CHANGE: old API removed");
}
#[test]
fn format_has_no_surrounding_whitespace() {
let no_bc = test_commit(
CommitType::Feat,
Scope::empty(),
test_description("add feature"),
BreakingChange::No,
);
let f = no_bc.format();
assert_eq!(
f,
f.trim(),
"format() must not have surrounding whitespace (no breaking change)"
);
let with_note = test_commit(
CommitType::Fix,
Scope::empty(),
test_description("fix bug"),
"important migration required".into(),
);
let f2 = with_note.format();
assert_eq!(
f2,
f2.trim(),
"format() must not have surrounding whitespace (with note)"
);
}
#[test]
fn all_commit_types_format_with_breaking_change_no_note() {
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 = test_commit(
commit_type,
Scope::empty(),
desc.clone(),
BreakingChange::Yes,
);
assert_eq!(
commit.format(),
expected,
"Format should be correct for {:?} with breaking change",
commit_type
);
}
}
#[test]
fn format_with_body_no_breaking_change() {
let commit = ConventionalCommit::new(
CommitType::Feat,
Scope::empty(),
test_description("add feature"),
BreakingChange::No,
Body::from("This explains the change."),
References::default(),
)
.unwrap();
assert_eq!(
commit.format(),
"feat: add feature\n\nThis explains the change."
);
}
#[test]
fn format_with_body_and_scope() {
let commit = ConventionalCommit::new(
CommitType::Fix,
test_scope("api"),
test_description("handle null response"),
BreakingChange::No,
Body::from("Null responses were previously unhandled."),
References::default(),
)
.unwrap();
assert_eq!(
commit.format(),
"fix(api): handle null response\n\nNull responses were previously unhandled."
);
}
#[test]
fn format_with_multiline_body() {
let commit = ConventionalCommit::new(
CommitType::Docs,
Scope::empty(),
test_description("update README"),
BreakingChange::No,
Body::from("First paragraph.\n\nSecond paragraph."),
References::default(),
)
.unwrap();
assert_eq!(
commit.format(),
"docs: update README\n\nFirst paragraph.\n\nSecond paragraph."
);
}
#[test]
fn format_with_body_and_breaking_change_note() {
let commit = ConventionalCommit::new(
CommitType::Feat,
Scope::empty(),
test_description("drop legacy API"),
"removes legacy endpoint".into(),
Body::from("The endpoint was deprecated in v2."),
References::default(),
)
.unwrap();
assert_eq!(
commit.format(),
"feat!: drop legacy API\n\nThe endpoint was deprecated in v2.\n\nBREAKING CHANGE: removes legacy endpoint"
);
}
#[test]
fn format_preview_with_body() {
let preview = ConventionalCommit::format_preview(
CommitType::Feat,
&Scope::empty(),
&test_description("add feature"),
&BreakingChange::No,
&Body::from("This explains the change."),
&References::default(),
);
assert_eq!(preview, "feat: add feature\n\nThis explains the change.");
}
#[test]
fn format_preview_with_body_and_breaking_change() {
let preview = ConventionalCommit::format_preview(
CommitType::Fix,
&Scope::empty(),
&test_description("drop old API"),
&"old API removed".into(),
&Body::from("Migration guide: see CHANGELOG."),
&References::default(),
);
assert_eq!(
preview,
"fix!: drop old API\n\nMigration guide: see CHANGELOG.\n\nBREAKING CHANGE: old API removed"
);
}
}