feat: implement breaking change input
This commit is contained in:
125
src/commit/types/breaking_change.rs
Normal file
125
src/commit/types/breaking_change.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
use super::Footer;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[repr(transparent)]
|
||||
pub struct BreakingChangeNote(String);
|
||||
|
||||
impl Footer for BreakingChangeNote {
|
||||
fn note(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
fn prefix(&self) -> &str {
|
||||
"BREAKING CHANGE"
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<T> for BreakingChangeNote
|
||||
where
|
||||
T: ToString,
|
||||
{
|
||||
fn from(value: T) -> Self {
|
||||
Self(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum BreakingChange {
|
||||
No,
|
||||
Yes,
|
||||
WithNote(BreakingChangeNote),
|
||||
}
|
||||
|
||||
impl BreakingChange {
|
||||
pub fn ignore(&self) -> bool {
|
||||
matches!(self, BreakingChange::No)
|
||||
}
|
||||
|
||||
pub fn header_segment(&self) -> &str {
|
||||
match self {
|
||||
Self::No => "",
|
||||
_ => "!",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_footer(&self) -> String {
|
||||
match self {
|
||||
BreakingChange::WithNote(footer) => footer.as_footer(),
|
||||
_ => "".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<T> for BreakingChange
|
||||
where
|
||||
T: ToString,
|
||||
{
|
||||
fn from(value: T) -> Self {
|
||||
match value.to_string().trim() {
|
||||
"" => Self::Yes,
|
||||
value => Self::WithNote(value.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Empty string produces Yes(None) — no footer, only '!' in the header
|
||||
#[test]
|
||||
fn from_empty_string_yields_yes_none() {
|
||||
assert_eq!(BreakingChange::from(String::new()), BreakingChange::Yes);
|
||||
}
|
||||
|
||||
/// Whitespace-only string produces Yes(None)
|
||||
#[test]
|
||||
fn from_whitespace_string_yields_yes_none() {
|
||||
assert_eq!(BreakingChange::from(" ".to_string()), BreakingChange::Yes);
|
||||
}
|
||||
|
||||
/// Mixed whitespace (tabs, newlines) produces Yes(None)
|
||||
#[test]
|
||||
fn from_tab_newline_string_yields_yes_none() {
|
||||
assert_eq!(
|
||||
BreakingChange::from("\t\n ".to_string()),
|
||||
BreakingChange::Yes
|
||||
);
|
||||
}
|
||||
|
||||
/// Non-empty string produces Yes(Some(...)) with the note preserved
|
||||
#[test]
|
||||
fn from_non_empty_string_yields_yes_some() {
|
||||
assert_eq!(
|
||||
BreakingChange::from("removes old API"),
|
||||
BreakingChange::WithNote("removes old API".into()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Surrounding whitespace is trimmed from the note
|
||||
#[test]
|
||||
fn from_string_trims_surrounding_whitespace() {
|
||||
assert_eq!(
|
||||
BreakingChange::from(" removes old API "),
|
||||
BreakingChange::WithNote("removes old API".into()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Leading whitespace only is trimmed, leaving the non-empty part
|
||||
#[test]
|
||||
fn from_string_trims_leading_whitespace() {
|
||||
assert_eq!(
|
||||
BreakingChange::from(" removes old API"),
|
||||
BreakingChange::WithNote("removes old API".into()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Trailing whitespace only is trimmed, leaving the non-empty part
|
||||
#[test]
|
||||
fn from_string_trims_trailing_whitespace() {
|
||||
assert_eq!(
|
||||
BreakingChange::from("removes old API "),
|
||||
BreakingChange::WithNote("removes old API".into()),
|
||||
);
|
||||
}
|
||||
}
|
||||
13
src/commit/types/footer.rs
Normal file
13
src/commit/types/footer.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
pub trait Footer {
|
||||
fn prefix(&self) -> &str;
|
||||
fn note(&self) -> &str;
|
||||
|
||||
fn as_footer(&self) -> String {
|
||||
let default = format!("{}: {}", self.prefix(), self.note());
|
||||
if default.chars().count() > 72 {
|
||||
textwrap::wrap(&default, 71).join("\n ")
|
||||
} else {
|
||||
default
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{CommitType, Description, Scope};
|
||||
use super::{BreakingChange, CommitType, Description, Scope};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors that can occur when creating a ConventionalCommit
|
||||
@@ -21,6 +21,7 @@ pub struct ConventionalCommit {
|
||||
commit_type: CommitType,
|
||||
scope: Scope,
|
||||
description: Description,
|
||||
breaking_change: BreakingChange,
|
||||
}
|
||||
|
||||
impl ConventionalCommit {
|
||||
@@ -40,11 +41,13 @@ impl ConventionalCommit {
|
||||
commit_type: CommitType,
|
||||
scope: Scope,
|
||||
description: Description,
|
||||
breaking_change: BreakingChange,
|
||||
) -> Result<Self, CommitMessageError> {
|
||||
let commit = Self {
|
||||
commit_type,
|
||||
scope,
|
||||
description,
|
||||
breaking_change,
|
||||
};
|
||||
let len = commit.first_line_len();
|
||||
if len > Self::FIRST_LINE_MAX_LENGTH {
|
||||
@@ -65,13 +68,12 @@ impl ConventionalCommit {
|
||||
/// Calculate the length of the formatted first line
|
||||
///
|
||||
/// Formula:
|
||||
/// - With scope: `len(type) + len(scope) + 4 + len(description)`
|
||||
/// (the 4 accounts for parentheses, colon, and space: "() ")
|
||||
/// - Without scope: `len(type) + 2 + len(description)`
|
||||
/// - `len(type)` + `len(scope)` + `len(breaking_change)` + 2 + `len(description)`
|
||||
/// (the 2 accounts for colon and space: ": ")
|
||||
pub fn first_line_len(&self) -> usize {
|
||||
self.commit_type.len()
|
||||
+ self.scope.header_segment_len()
|
||||
+ if self.breaking_change.ignore() { 0 } else { 1 }
|
||||
+ 2 // ": "
|
||||
+ self.description.len()
|
||||
}
|
||||
@@ -81,7 +83,12 @@ impl ConventionalCommit {
|
||||
/// Returns `type(scope): description` if scope is non-empty, or
|
||||
/// `type: description` if scope is empty
|
||||
pub fn format(&self) -> String {
|
||||
Self::format_preview(self.commit_type, &self.scope, &self.description)
|
||||
Self::format_preview(
|
||||
self.commit_type,
|
||||
&self.scope,
|
||||
&self.description,
|
||||
&self.breaking_change,
|
||||
)
|
||||
}
|
||||
|
||||
/// Format a preview of the commit message without creating a validated instance
|
||||
@@ -93,12 +100,18 @@ impl ConventionalCommit {
|
||||
commit_type: CommitType,
|
||||
scope: &Scope,
|
||||
description: &Description,
|
||||
breaking_change: &BreakingChange,
|
||||
) -> String {
|
||||
if scope.is_empty() {
|
||||
format!("{}: {}", commit_type, description)
|
||||
} else {
|
||||
format!("{}({}): {}", commit_type, scope, description)
|
||||
}
|
||||
let scope = scope.header_segment();
|
||||
let breaking_change_header = breaking_change.header_segment();
|
||||
let breaking_change_footer = breaking_change.as_footer();
|
||||
format!(
|
||||
r#"{commit_type}{scope}{breaking_change_header}: {description}
|
||||
|
||||
{breaking_change_footer}"#,
|
||||
)
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Returns the commit type
|
||||
@@ -142,8 +155,9 @@ mod tests {
|
||||
commit_type: CommitType,
|
||||
scope: Scope,
|
||||
description: Description,
|
||||
breaking_change: BreakingChange,
|
||||
) -> ConventionalCommit {
|
||||
ConventionalCommit::new(commit_type, scope, description)
|
||||
ConventionalCommit::new(commit_type, scope, description, breaking_change)
|
||||
.expect("test commit should have valid line length")
|
||||
}
|
||||
|
||||
@@ -154,6 +168,7 @@ mod tests {
|
||||
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");
|
||||
@@ -167,6 +182,7 @@ mod tests {
|
||||
CommitType::Fix,
|
||||
Scope::empty(),
|
||||
test_description("fix critical bug"),
|
||||
BreakingChange::No,
|
||||
);
|
||||
assert_eq!(commit.commit_type(), CommitType::Fix);
|
||||
assert!(commit.scope().is_empty());
|
||||
@@ -180,6 +196,7 @@ mod tests {
|
||||
CommitType::Feat,
|
||||
test_scope("auth"),
|
||||
test_description("add login"),
|
||||
BreakingChange::No,
|
||||
);
|
||||
assert_eq!(commit.format(), "feat(auth): add login");
|
||||
}
|
||||
@@ -192,6 +209,7 @@ mod tests {
|
||||
CommitType::Fix,
|
||||
test_scope("user-auth"),
|
||||
test_description("fix token refresh"),
|
||||
BreakingChange::No,
|
||||
);
|
||||
assert_eq!(commit1.format(), "fix(user-auth): fix token refresh");
|
||||
|
||||
@@ -200,6 +218,7 @@ mod tests {
|
||||
CommitType::Docs,
|
||||
test_scope("api_docs"),
|
||||
test_description("update README"),
|
||||
BreakingChange::No,
|
||||
);
|
||||
assert_eq!(commit2.format(), "docs(api_docs): update README");
|
||||
|
||||
@@ -208,6 +227,7 @@ mod tests {
|
||||
CommitType::Chore,
|
||||
test_scope("PROJ-123/cleanup"),
|
||||
test_description("remove unused code"),
|
||||
BreakingChange::No,
|
||||
);
|
||||
assert_eq!(
|
||||
commit3.format(),
|
||||
@@ -222,6 +242,7 @@ mod tests {
|
||||
CommitType::Feat,
|
||||
Scope::empty(),
|
||||
test_description("add login"),
|
||||
BreakingChange::No,
|
||||
);
|
||||
assert_eq!(commit.format(), "feat: add login");
|
||||
}
|
||||
@@ -233,6 +254,7 @@ mod tests {
|
||||
CommitType::Fix,
|
||||
Scope::empty(),
|
||||
test_description("fix critical bug"),
|
||||
BreakingChange::No,
|
||||
);
|
||||
assert_eq!(commit1.format(), "fix: fix critical bug");
|
||||
|
||||
@@ -240,6 +262,7 @@ mod tests {
|
||||
CommitType::Docs,
|
||||
Scope::empty(),
|
||||
test_description("update installation guide"),
|
||||
BreakingChange::No,
|
||||
);
|
||||
assert_eq!(commit2.format(), "docs: update installation guide");
|
||||
}
|
||||
@@ -265,7 +288,7 @@ mod tests {
|
||||
];
|
||||
|
||||
for (commit_type, expected) in expected_formats {
|
||||
let commit = test_commit(commit_type, scope.clone(), desc.clone());
|
||||
let commit = test_commit(commit_type, scope.clone(), desc.clone(), BreakingChange::No);
|
||||
assert_eq!(
|
||||
commit.format(),
|
||||
expected,
|
||||
@@ -295,7 +318,12 @@ mod tests {
|
||||
];
|
||||
|
||||
for (commit_type, expected) in expected_formats {
|
||||
let commit = test_commit(commit_type, Scope::empty(), desc.clone());
|
||||
let commit = test_commit(
|
||||
commit_type,
|
||||
Scope::empty(),
|
||||
desc.clone(),
|
||||
BreakingChange::No,
|
||||
);
|
||||
assert_eq!(
|
||||
commit.format(),
|
||||
expected,
|
||||
@@ -312,6 +340,7 @@ mod tests {
|
||||
CommitType::Feat,
|
||||
test_scope("auth"),
|
||||
test_description("add login"),
|
||||
BreakingChange::No,
|
||||
);
|
||||
let display_output = format!("{}", commit);
|
||||
let format_output = commit.format();
|
||||
@@ -325,6 +354,7 @@ mod tests {
|
||||
CommitType::Fix,
|
||||
test_scope("api"),
|
||||
test_description("handle null response"),
|
||||
BreakingChange::No,
|
||||
);
|
||||
assert_eq!(format!("{}", commit), "fix(api): handle null response");
|
||||
}
|
||||
@@ -336,6 +366,7 @@ mod tests {
|
||||
CommitType::Docs,
|
||||
Scope::empty(),
|
||||
test_description("improve README"),
|
||||
BreakingChange::No,
|
||||
);
|
||||
assert_eq!(format!("{}", commit), "docs: improve README");
|
||||
}
|
||||
@@ -345,8 +376,12 @@ mod tests {
|
||||
fn display_equals_format_for_all_types() {
|
||||
for commit_type in CommitType::all() {
|
||||
// With scope
|
||||
let commit_with_scope =
|
||||
test_commit(*commit_type, test_scope("test"), test_description("change"));
|
||||
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(),
|
||||
@@ -355,8 +390,12 @@ mod tests {
|
||||
);
|
||||
|
||||
// Without scope
|
||||
let commit_without_scope =
|
||||
test_commit(*commit_type, Scope::empty(), test_description("change"));
|
||||
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(),
|
||||
@@ -370,7 +409,12 @@ mod tests {
|
||||
#[test]
|
||||
fn commit_type_accessor_returns_correct_type() {
|
||||
for commit_type in CommitType::all() {
|
||||
let commit = test_commit(*commit_type, Scope::empty(), test_description("test"));
|
||||
let commit = test_commit(
|
||||
*commit_type,
|
||||
Scope::empty(),
|
||||
test_description("test"),
|
||||
BreakingChange::No,
|
||||
);
|
||||
assert_eq!(commit.commit_type(), *commit_type);
|
||||
}
|
||||
}
|
||||
@@ -382,6 +426,7 @@ mod tests {
|
||||
CommitType::Feat,
|
||||
test_scope("auth"),
|
||||
test_description("add feature"),
|
||||
BreakingChange::No,
|
||||
);
|
||||
assert_eq!(commit.scope().as_str(), "auth");
|
||||
}
|
||||
@@ -393,6 +438,7 @@ mod tests {
|
||||
CommitType::Feat,
|
||||
Scope::empty(),
|
||||
test_description("add feature"),
|
||||
BreakingChange::No,
|
||||
);
|
||||
assert!(commit.scope().is_empty());
|
||||
}
|
||||
@@ -404,6 +450,7 @@ mod tests {
|
||||
CommitType::Feat,
|
||||
Scope::empty(),
|
||||
test_description("add new authentication flow"),
|
||||
BreakingChange::No,
|
||||
);
|
||||
assert_eq!(commit.description().as_str(), "add new authentication flow");
|
||||
}
|
||||
@@ -415,6 +462,7 @@ mod tests {
|
||||
CommitType::Feat,
|
||||
test_scope("cli"),
|
||||
test_description("add feature"),
|
||||
BreakingChange::No,
|
||||
);
|
||||
let cloned = original.clone();
|
||||
assert_eq!(original, cloned);
|
||||
@@ -427,11 +475,13 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
@@ -443,11 +493,13 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
@@ -459,11 +511,13 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
@@ -475,11 +529,13 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
@@ -491,6 +547,7 @@ mod tests {
|
||||
CommitType::Feat,
|
||||
test_scope("cli"),
|
||||
test_description("add feature"),
|
||||
BreakingChange::No,
|
||||
);
|
||||
let debug_output = format!("{:?}", commit);
|
||||
assert!(debug_output.contains("ConventionalCommit"));
|
||||
@@ -504,6 +561,7 @@ mod tests {
|
||||
CommitType::Feat,
|
||||
test_scope("auth"),
|
||||
test_description("implement OAuth2 login flow"),
|
||||
BreakingChange::No,
|
||||
);
|
||||
assert_eq!(commit.format(), "feat(auth): implement OAuth2 login flow");
|
||||
}
|
||||
@@ -515,6 +573,7 @@ mod tests {
|
||||
CommitType::Fix,
|
||||
Scope::empty(),
|
||||
test_description("prevent crash on empty input"),
|
||||
BreakingChange::No,
|
||||
);
|
||||
assert_eq!(commit.format(), "fix: prevent crash on empty input");
|
||||
}
|
||||
@@ -526,6 +585,7 @@ mod tests {
|
||||
CommitType::Docs,
|
||||
test_scope("README"),
|
||||
test_description("add installation instructions"),
|
||||
BreakingChange::No,
|
||||
);
|
||||
assert_eq!(
|
||||
commit.format(),
|
||||
@@ -540,6 +600,7 @@ mod tests {
|
||||
CommitType::Refactor,
|
||||
test_scope("core"),
|
||||
test_description("extract validation logic"),
|
||||
BreakingChange::No,
|
||||
);
|
||||
assert_eq!(commit.format(), "refactor(core): extract validation logic");
|
||||
}
|
||||
@@ -551,6 +612,7 @@ mod tests {
|
||||
CommitType::Ci,
|
||||
test_scope("github"),
|
||||
test_description("add release workflow"),
|
||||
BreakingChange::No,
|
||||
);
|
||||
assert_eq!(commit.format(), "ci(github): add release workflow");
|
||||
}
|
||||
@@ -563,6 +625,7 @@ mod tests {
|
||||
CommitType::Feat,
|
||||
Scope::empty(),
|
||||
Description::parse(&long_desc).unwrap(),
|
||||
BreakingChange::No,
|
||||
);
|
||||
// Format should be "feat: " + 50 chars = 56 total chars
|
||||
let formatted = commit.format();
|
||||
@@ -577,6 +640,7 @@ mod tests {
|
||||
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");
|
||||
}
|
||||
@@ -594,6 +658,7 @@ mod tests {
|
||||
CommitType::Feat,
|
||||
Scope::empty(),
|
||||
test_description("add login"),
|
||||
BreakingChange::No,
|
||||
);
|
||||
// "feat: add login" = 4 + 2 + 9 = 15
|
||||
assert_eq!(commit.first_line_len(), 15);
|
||||
@@ -606,6 +671,7 @@ mod tests {
|
||||
CommitType::Feat,
|
||||
test_scope("auth"),
|
||||
test_description("add login"),
|
||||
BreakingChange::No,
|
||||
);
|
||||
// "feat(auth): add login" = 4 + 4 + 4 + 9 = 21
|
||||
assert_eq!(commit.first_line_len(), 21);
|
||||
@@ -622,6 +688,7 @@ mod tests {
|
||||
CommitType::Feat,
|
||||
Scope::parse(&scope_20).unwrap(),
|
||||
Description::parse(&desc_44).unwrap(),
|
||||
BreakingChange::No,
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
let commit = result.unwrap();
|
||||
@@ -650,6 +717,7 @@ mod tests {
|
||||
CommitType::Refactor,
|
||||
Scope::parse(&scope_30).unwrap(),
|
||||
Description::parse(&desc_31).unwrap(),
|
||||
BreakingChange::No,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
@@ -672,6 +740,7 @@ mod tests {
|
||||
CommitType::Refactor,
|
||||
Scope::parse(&scope_30).unwrap(),
|
||||
Description::parse(&desc_40).unwrap(),
|
||||
BreakingChange::No,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
@@ -690,6 +759,7 @@ mod tests {
|
||||
CommitType::Fix,
|
||||
Scope::empty(),
|
||||
test_description("quick fix"),
|
||||
BreakingChange::No,
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
@@ -701,6 +771,7 @@ mod tests {
|
||||
CommitType::Feat,
|
||||
test_scope("cli"),
|
||||
test_description("add feature"),
|
||||
BreakingChange::No,
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
@@ -721,8 +792,12 @@ mod tests {
|
||||
/// Test new() returns Result type
|
||||
#[test]
|
||||
fn new_returns_result() {
|
||||
let result =
|
||||
ConventionalCommit::new(CommitType::Feat, Scope::empty(), test_description("test"));
|
||||
let result = ConventionalCommit::new(
|
||||
CommitType::Feat,
|
||||
Scope::empty(),
|
||||
test_description("test"),
|
||||
BreakingChange::No,
|
||||
);
|
||||
// Just verify it's a Result by using is_ok()
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
@@ -747,7 +822,7 @@ mod tests {
|
||||
None => Scope::empty(),
|
||||
};
|
||||
let desc = Description::parse(*desc_str).unwrap();
|
||||
let commit = ConventionalCommit::new(*commit_type, scope, desc);
|
||||
let commit = ConventionalCommit::new(*commit_type, scope, desc, BreakingChange::No);
|
||||
// new() itself calls git_conventional::Commit::parse internally, so
|
||||
// if this is Ok, SC-002 is satisfied for this case.
|
||||
assert!(
|
||||
@@ -771,4 +846,273 @@ mod tests {
|
||||
assert!(msg.contains("git-conventional"));
|
||||
assert!(msg.contains("missing type"));
|
||||
}
|
||||
|
||||
/// Breaking change without note and without scope: header gets '!', no footer
|
||||
#[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");
|
||||
}
|
||||
|
||||
/// Breaking change without note and with scope: '!' goes after closing paren
|
||||
#[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");
|
||||
}
|
||||
|
||||
/// Breaking change with note and without scope: footer is appended after a blank line
|
||||
#[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",
|
||||
);
|
||||
}
|
||||
|
||||
/// Breaking change with note and with scope: both '!' and footer are present
|
||||
#[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",
|
||||
);
|
||||
}
|
||||
|
||||
/// Display with breaking change delegates to format() (no scope, with note)
|
||||
#[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",
|
||||
);
|
||||
}
|
||||
|
||||
/// first_line_len() counts the '!' for a breaking change without scope
|
||||
///
|
||||
/// "feat!: add login" = 4 + 1 + 2 + 9 = 16
|
||||
#[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);
|
||||
}
|
||||
|
||||
/// first_line_len() counts the '!' for a breaking change with scope
|
||||
///
|
||||
/// "feat(auth)!: add login" = 4 + 6 + 1 + 2 + 9 = 22
|
||||
#[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);
|
||||
}
|
||||
|
||||
/// The `!` counts toward the 72-character first-line limit
|
||||
///
|
||||
/// The inputs below produce exactly 72 chars without a breaking change
|
||||
/// (covered by `exactly_72_characters_accepted`). With `!` they reach
|
||||
/// 73 and must be rejected.
|
||||
#[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,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
CommitMessageError::FirstLineTooLong {
|
||||
actual: 73,
|
||||
max: 72
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Breaking change footer does not count toward the 72-character first-line limit
|
||||
#[test]
|
||||
fn breaking_change_footer_does_not_count_toward_line_limit() {
|
||||
// First line is short; the note itself is long — should still be accepted.
|
||||
let long_note = "x".repeat(200);
|
||||
let result = ConventionalCommit::new(
|
||||
CommitType::Fix,
|
||||
Scope::empty(),
|
||||
test_description("quick fix"),
|
||||
long_note.into(),
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
/// format_preview() static method produces the same result as format() for identical inputs
|
||||
#[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,
|
||||
);
|
||||
assert_eq!(preview, commit.format());
|
||||
}
|
||||
|
||||
/// format_preview() with a breaking-change note produces the full multi-line message
|
||||
#[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(),
|
||||
);
|
||||
assert_eq!(
|
||||
preview,
|
||||
"feat!: drop legacy API\n\nBREAKING CHANGE: removes legacy endpoint"
|
||||
);
|
||||
}
|
||||
|
||||
/// format_preview() with scope and breaking-change note
|
||||
#[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(),
|
||||
);
|
||||
assert_eq!(
|
||||
preview,
|
||||
"fix(api)!: drop Node 6\n\nBREAKING CHANGE: Node 6 is no longer supported"
|
||||
);
|
||||
}
|
||||
|
||||
/// Breaking-change footer is separated from the header by exactly one blank line
|
||||
#[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");
|
||||
}
|
||||
|
||||
/// format() output has no leading or trailing whitespace for any variant
|
||||
#[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)"
|
||||
);
|
||||
}
|
||||
|
||||
/// All commit types format correctly with breaking change and no 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
mod footer;
|
||||
pub use footer::Footer;
|
||||
|
||||
mod breaking_change;
|
||||
pub use breaking_change::BreakingChange;
|
||||
|
||||
mod commit_type;
|
||||
pub use commit_type::CommitType;
|
||||
|
||||
|
||||
@@ -18,9 +18,9 @@ impl Scope {
|
||||
if value.is_empty() {
|
||||
return Ok(Self::empty());
|
||||
}
|
||||
if value.len() > Self::MAX_LENGTH {
|
||||
if value.chars().count() > Self::MAX_LENGTH {
|
||||
return Err(ScopeError::TooLong {
|
||||
actual: value.len(),
|
||||
actual: value.chars().count(),
|
||||
max: Self::MAX_LENGTH,
|
||||
});
|
||||
}
|
||||
@@ -458,10 +458,6 @@ mod tests {
|
||||
assert!(msg.contains("30"));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// header_segment() / header_segment_len() tests
|
||||
// =========================================================================
|
||||
|
||||
/// Test header_segment() returns empty string for empty scope
|
||||
#[test]
|
||||
fn header_segment_empty_scope_returns_empty_string() {
|
||||
@@ -517,4 +513,50 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A scope whose byte count exceeds MAX_LENGTH but whose char
|
||||
/// count does not must be rejected with InvalidCharacter, not
|
||||
/// TooLong.
|
||||
///
|
||||
/// Before the fix the byte-based `.len()` check fired first,
|
||||
/// producing a misleading "too long" error for a string that is
|
||||
/// actually within the limit.
|
||||
#[test]
|
||||
fn length_limit_uses_char_count_not_byte_count() {
|
||||
// "ñ" is 2 bytes in UTF-8; 16 × "ñ" = 16 chars, 32 bytes.
|
||||
// char count 16 ≤ 30 → length check passes
|
||||
// regex rejects "ñ" → should return InvalidCharacter, not TooLong
|
||||
let input = "ñ".repeat(16);
|
||||
assert_eq!(input.chars().count(), 16, "sanity: 16 chars");
|
||||
assert_eq!(input.len(), 32, "sanity: 32 bytes");
|
||||
|
||||
let result = Scope::parse(&input);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
ScopeError::InvalidCharacter('ñ'),
|
||||
"expected InvalidCharacter('ñ') for a 16-char / 32-byte input, not TooLong",
|
||||
);
|
||||
}
|
||||
|
||||
/// The actual length reported in TooLong must be the char count,
|
||||
/// not the byte count.
|
||||
///
|
||||
/// "a".repeat(30) + "é" is 31 chars and 32 bytes. The length
|
||||
/// check should fire on char count (31 > 30) and report actual =
|
||||
/// 31.
|
||||
#[test]
|
||||
fn too_long_error_actual_reports_char_count_not_byte_count() {
|
||||
// 30 ASCII 'a' + 1 two-byte 'é' = 31 chars, 32 bytes
|
||||
let input = "a".repeat(30) + "é";
|
||||
assert_eq!(input.chars().count(), 31, "sanity: 31 chars");
|
||||
assert_eq!(input.len(), 32, "sanity: 32 bytes");
|
||||
|
||||
let result = Scope::parse(&input);
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
ScopeError::TooLong { actual: 31, max: 30 },
|
||||
"actual should be the char count (31), not the byte count (32)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ mod prompts;
|
||||
|
||||
pub use crate::{
|
||||
commit::types::{
|
||||
CommitMessageError, CommitType, ConventionalCommit, Description, DescriptionError, Scope,
|
||||
ScopeError,
|
||||
BreakingChange, CommitMessageError, CommitType, ConventionalCommit, Description,
|
||||
DescriptionError, Scope, ScopeError,
|
||||
},
|
||||
error::Error,
|
||||
jj::{JjExecutor, lib_executor::JjLib},
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::{
|
||||
commit::types::{CommitType, Description, Scope},
|
||||
commit::types::{BreakingChange, CommitType, Description, Scope},
|
||||
error::Error,
|
||||
prompts::prompter::Prompter,
|
||||
};
|
||||
@@ -19,6 +19,7 @@ enum MockResponse {
|
||||
CommitType(CommitType),
|
||||
Scope(Scope),
|
||||
Description(Description),
|
||||
BreakingChange(BreakingChange),
|
||||
Confirm(bool),
|
||||
Error(Error),
|
||||
}
|
||||
@@ -70,6 +71,15 @@ impl MockPrompts {
|
||||
self
|
||||
}
|
||||
|
||||
/// Configure the mock to return a specific breaking change response
|
||||
pub fn with_breaking_change(self, breaking_change: BreakingChange) -> Self {
|
||||
self.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(MockResponse::BreakingChange(breaking_change));
|
||||
self
|
||||
}
|
||||
|
||||
/// Configure the mock to return a specific confirmation response
|
||||
pub fn with_confirm(self, confirm: bool) -> Self {
|
||||
self.responses
|
||||
@@ -112,6 +122,14 @@ impl MockPrompts {
|
||||
.contains(&"input_description".to_string())
|
||||
}
|
||||
|
||||
/// Check if input_breaking_change was called
|
||||
pub fn was_breaking_change_called(&self) -> bool {
|
||||
self.prompts_called
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains(&"input_breaking_change".to_string())
|
||||
}
|
||||
|
||||
/// Check if confirm_apply was called
|
||||
pub fn was_confirm_called(&self) -> bool {
|
||||
self.prompts_called
|
||||
@@ -166,6 +184,19 @@ impl Prompter for MockPrompts {
|
||||
}
|
||||
}
|
||||
|
||||
fn input_breaking_change(&self) -> Result<BreakingChange, Error> {
|
||||
self.prompts_called
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push("input_breaking_change".to_string());
|
||||
|
||||
match self.responses.lock().unwrap().remove(0) {
|
||||
MockResponse::BreakingChange(bc) => Ok(bc),
|
||||
MockResponse::Error(e) => Err(e),
|
||||
_ => panic!("MockPrompts: Expected BreakingChange response, got different type"),
|
||||
}
|
||||
}
|
||||
|
||||
fn confirm_apply(&self, _message: &str) -> Result<bool, Error> {
|
||||
self.prompts_called
|
||||
.lock()
|
||||
@@ -281,4 +312,64 @@ mod tests {
|
||||
let mock = MockPrompts::new();
|
||||
assert!(mock.emitted_messages().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_input_breaking_change_no() {
|
||||
let mock = MockPrompts::new().with_breaking_change(BreakingChange::No);
|
||||
let result = mock.input_breaking_change();
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), BreakingChange::No);
|
||||
assert!(mock.was_breaking_change_called());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_input_breaking_change_yes_no_note() {
|
||||
let mock = MockPrompts::new().with_breaking_change(BreakingChange::Yes);
|
||||
let result = mock.input_breaking_change();
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), BreakingChange::Yes);
|
||||
assert!(mock.was_breaking_change_called());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_input_breaking_change_yes_with_note() {
|
||||
let mock = MockPrompts::new().with_breaking_change("removes old API".into());
|
||||
let result = mock.input_breaking_change();
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(
|
||||
result.unwrap(),
|
||||
BreakingChange::WithNote("removes old API".into())
|
||||
);
|
||||
assert!(mock.was_breaking_change_called());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_input_breaking_change_error() {
|
||||
let mock = MockPrompts::new().with_error(Error::Cancelled);
|
||||
let result = mock.input_breaking_change();
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), Error::Cancelled));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_tracks_breaking_change_call() {
|
||||
let mock = MockPrompts::new()
|
||||
.with_commit_type(CommitType::Fix)
|
||||
.with_scope(Scope::empty())
|
||||
.with_description(Description::parse("test").unwrap())
|
||||
.with_breaking_change(BreakingChange::No)
|
||||
.with_confirm(true);
|
||||
|
||||
mock.select_commit_type().unwrap();
|
||||
mock.input_scope().unwrap();
|
||||
mock.input_description().unwrap();
|
||||
mock.input_breaking_change().unwrap();
|
||||
mock.confirm_apply("test").unwrap();
|
||||
|
||||
assert!(mock.was_commit_type_called());
|
||||
assert!(mock.was_scope_called());
|
||||
assert!(mock.was_description_called());
|
||||
assert!(mock.was_breaking_change_called());
|
||||
assert!(mock.was_confirm_called());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
//! [`CommitWorkflow`](super::CommitWorkflow) to use real interactive prompts
|
||||
//! in production while accepting mock implementations in tests.
|
||||
|
||||
use inquire::{Confirm, Text};
|
||||
|
||||
use crate::{
|
||||
commit::types::{CommitType, Description, Scope},
|
||||
commit::types::{BreakingChange, CommitType, Description, Scope},
|
||||
error::Error,
|
||||
};
|
||||
|
||||
@@ -24,6 +26,9 @@ pub trait Prompter: Send + Sync {
|
||||
/// Prompt the user to input a required description
|
||||
fn input_description(&self) -> Result<Description, Error>;
|
||||
|
||||
/// Prompt the user for breaking change
|
||||
fn input_breaking_change(&self) -> Result<BreakingChange, Error>;
|
||||
|
||||
/// Prompt the user to confirm applying the commit message
|
||||
fn confirm_apply(&self, message: &str) -> Result<bool, Error>;
|
||||
|
||||
@@ -34,6 +39,18 @@ pub trait Prompter: Send + Sync {
|
||||
fn emit_message(&self, msg: &str);
|
||||
}
|
||||
|
||||
fn format_message_box(message: &str) -> String {
|
||||
let preview_width = 72 + 2; // max width + space padding
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
lines.push(format!("┌{}┐", "─".repeat(preview_width)));
|
||||
for line in message.split("\n") {
|
||||
let padding = 72_usize.saturating_sub(line.chars().count());
|
||||
lines.push(format!("│ {line}{:padding$} │", ""));
|
||||
}
|
||||
lines.push(format!("└{}┘", "─".repeat(preview_width)));
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Production implementation of [`Prompter`] using the `inquire` crate
|
||||
#[derive(Debug)]
|
||||
pub struct RealPrompts;
|
||||
@@ -137,18 +154,30 @@ impl Prompter for RealPrompts {
|
||||
}
|
||||
}
|
||||
|
||||
fn input_breaking_change(&self) -> Result<BreakingChange, Error> {
|
||||
if !Confirm::new("Does this revision include a breaking change?")
|
||||
.with_default(false)
|
||||
.prompt()
|
||||
.map_err(|_| Error::Cancelled)?
|
||||
{
|
||||
return Ok(BreakingChange::No);
|
||||
}
|
||||
let answer = Text::new("Enter the description of the breaking change:")
|
||||
.with_help_message("Enter an empty message to skip creating a message footer")
|
||||
.prompt()
|
||||
.map_err(|_| Error::Cancelled)?;
|
||||
let trimmed = answer.trim();
|
||||
Ok(trimmed.into())
|
||||
}
|
||||
|
||||
fn confirm_apply(&self, message: &str) -> Result<bool, Error> {
|
||||
use inquire::Confirm;
|
||||
|
||||
// Show preview
|
||||
println!();
|
||||
println!("📝 Commit Message Preview:");
|
||||
println!("┌────────────────────────────────────────────────────────────────────────┐");
|
||||
// Pad with spaces to fill the box
|
||||
let padding = 72_usize.saturating_sub(message.chars().count()) - 1;
|
||||
println!("│ {message}{:padding$}│", "");
|
||||
println!("└────────────────────────────────────────────────────────────────────────┘");
|
||||
println!();
|
||||
println!(
|
||||
"\n📝 Commit Message Preview:\n{}\n",
|
||||
format_message_box(message)
|
||||
);
|
||||
|
||||
// Get confirmation
|
||||
Confirm::new("Apply this commit message?")
|
||||
@@ -174,4 +203,71 @@ mod tests {
|
||||
fn _accepts_prompter(_p: impl Prompter) {}
|
||||
_accepts_prompter(real);
|
||||
}
|
||||
|
||||
/// Top border uses exactly preview_width (74) dashes; bottom likewise
|
||||
#[test]
|
||||
fn format_message_box_borders() {
|
||||
let result = format_message_box("hello");
|
||||
let lines: Vec<&str> = result.split('\n').collect();
|
||||
let dashes = "─".repeat(74);
|
||||
assert_eq!(lines[0], format!("┌{dashes}┐"));
|
||||
assert_eq!(lines[lines.len() - 1], format!("└{dashes}┘"));
|
||||
}
|
||||
|
||||
/// A single-line message produces exactly 3 rows: top, content, bottom
|
||||
#[test]
|
||||
fn format_message_box_single_line_row_count() {
|
||||
let result = format_message_box("feat: add login");
|
||||
assert_eq!(result.split('\n').count(), 3);
|
||||
}
|
||||
|
||||
/// A message with one `\n` produces 4 rows: top, two content, bottom
|
||||
#[test]
|
||||
fn format_message_box_multi_line_row_count() {
|
||||
let result = format_message_box("feat: add login\nsecond line");
|
||||
assert_eq!(result.split('\n').count(), 4);
|
||||
}
|
||||
|
||||
/// A breaking-change message (`\n\n`) produces an empty content row for the blank line
|
||||
#[test]
|
||||
fn format_message_box_blank_separator_line() {
|
||||
let msg = "feat!: drop old API\n\nBREAKING CHANGE: removed";
|
||||
let result = format_message_box(msg);
|
||||
assert_eq!(result.split('\n').count(), 5); // top + 3 content + bottom
|
||||
}
|
||||
|
||||
/// All output rows have identical char counts (the box is rectangular)
|
||||
#[test]
|
||||
fn format_message_box_all_rows_same_width() {
|
||||
let msg = "feat(auth): add login\n\nBREAKING CHANGE: old API removed";
|
||||
let result = format_message_box(msg);
|
||||
let widths: Vec<usize> = result.split('\n').map(|l| l.chars().count()).collect();
|
||||
let expected = widths[0];
|
||||
assert!(
|
||||
widths.iter().all(|&w| w == expected),
|
||||
"rows have differing widths: {:?}",
|
||||
widths
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty message produces a single fully-padded content row
|
||||
#[test]
|
||||
fn format_message_box_empty_message() {
|
||||
let result = format_message_box("");
|
||||
let lines: Vec<&str> = result.split('\n').collect();
|
||||
assert_eq!(lines.len(), 3);
|
||||
// "│ " + 72 spaces + " │" = 76 chars
|
||||
let expected = format!("│ {:72} │", "");
|
||||
assert_eq!(lines[1], expected);
|
||||
}
|
||||
|
||||
/// A line of exactly 72 characters leaves no right-hand padding
|
||||
#[test]
|
||||
fn format_message_box_line_exactly_72_chars() {
|
||||
let line_72 = "a".repeat(72);
|
||||
let result = format_message_box(&line_72);
|
||||
let lines: Vec<&str> = result.split('\n').collect();
|
||||
let expected = format!("│ {line_72} │");
|
||||
assert_eq!(lines[1], expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
//! creating a conventional commit message using interactive prompts.
|
||||
|
||||
use crate::{
|
||||
commit::types::{CommitMessageError, CommitType, ConventionalCommit, Description, Scope},
|
||||
commit::types::{
|
||||
BreakingChange, CommitMessageError, CommitType, ConventionalCommit, Description, Scope,
|
||||
},
|
||||
error::Error,
|
||||
jj::JjExecutor,
|
||||
prompts::prompter::{Prompter, RealPrompts},
|
||||
@@ -52,30 +54,19 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
|
||||
/// - Repository operation fails
|
||||
/// - Message validation fails
|
||||
pub async fn run(&self) -> Result<(), Error> {
|
||||
// Verify we're in a jj repository
|
||||
if !self.executor.is_repository().await? {
|
||||
return Err(Error::NotARepository);
|
||||
}
|
||||
|
||||
// Step 1: Select commit type (kept across retries)
|
||||
let commit_type = self.type_selection().await?;
|
||||
|
||||
// Steps 2–4 loop: re-prompt scope and description when the combined
|
||||
// first line would exceed 72 characters (issue 3.4).
|
||||
loop {
|
||||
// Step 2: Input scope (optional)
|
||||
let scope = self.scope_input().await?;
|
||||
|
||||
// Step 3: Input description (required)
|
||||
let description = self.description_input().await?;
|
||||
|
||||
// Step 4: Preview and confirm
|
||||
let breaking_change = self.breaking_change_input().await?;
|
||||
match self
|
||||
.preview_and_confirm(commit_type, scope, description)
|
||||
.preview_and_confirm(commit_type, scope, description, breaking_change)
|
||||
.await
|
||||
{
|
||||
Ok(conventional_commit) => {
|
||||
// Step 5: Apply the message
|
||||
self.executor
|
||||
.describe(&conventional_commit.to_string())
|
||||
.await?;
|
||||
@@ -99,35 +90,49 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
|
||||
|
||||
/// Prompt user to input an optional scope
|
||||
///
|
||||
/// Returns Ok(Scope) with the validated scope, or Error::Cancelled if user cancels
|
||||
/// Returns Ok(Scope) with the validated scope, or
|
||||
/// Error::Cancelled if user cancels
|
||||
async fn scope_input(&self) -> Result<Scope, Error> {
|
||||
self.prompts.input_scope()
|
||||
}
|
||||
|
||||
/// Prompt user to input a required description
|
||||
///
|
||||
/// Returns Ok(Description) with the validated description, or Error::Cancelled if user cancels
|
||||
/// Returns Ok(Description) with the validated description, or
|
||||
/// Error::Cancelled if user cancels
|
||||
async fn description_input(&self) -> Result<Description, Error> {
|
||||
self.prompts.input_description()
|
||||
}
|
||||
|
||||
/// Prompt user for breaking change
|
||||
///
|
||||
/// Returns Ok(BreakingChange) with the validated breaking change,
|
||||
/// or Error::Cancel if user cancels
|
||||
async fn breaking_change_input(&self) -> Result<BreakingChange, Error> {
|
||||
self.prompts.input_breaking_change()
|
||||
}
|
||||
|
||||
/// Preview the formatted conventional commit message and get user confirmation
|
||||
///
|
||||
/// This method also validates that the complete first line doesn't exceed 72 characters
|
||||
/// This method also validates that the complete first line
|
||||
/// doesn't exceed 72 characters
|
||||
async fn preview_and_confirm(
|
||||
&self,
|
||||
commit_type: CommitType,
|
||||
scope: Scope,
|
||||
description: Description,
|
||||
breaking_change: BreakingChange,
|
||||
) -> Result<ConventionalCommit, Error> {
|
||||
// Format the message for preview
|
||||
let message = ConventionalCommit::format_preview(commit_type, &scope, &description);
|
||||
let message =
|
||||
ConventionalCommit::format_preview(commit_type, &scope, &description, &breaking_change);
|
||||
|
||||
// Try to build the conventional commit (this validates the 72-char limit)
|
||||
let conventional_commit: ConventionalCommit = match ConventionalCommit::new(
|
||||
commit_type,
|
||||
scope.clone(),
|
||||
description.clone(),
|
||||
breaking_change,
|
||||
) {
|
||||
Ok(cc) => cc,
|
||||
Err(CommitMessageError::FirstLineTooLong { actual, max }) => {
|
||||
@@ -252,9 +257,9 @@ mod tests {
|
||||
let commit_type = CommitType::Feat;
|
||||
let scope = Scope::empty();
|
||||
let description = Description::parse("test description").unwrap();
|
||||
|
||||
let breaking_change = BreakingChange::No;
|
||||
let result = workflow
|
||||
.preview_and_confirm(commit_type, scope, description)
|
||||
.preview_and_confirm(commit_type, scope, description, breaking_change)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
@@ -299,6 +304,7 @@ mod tests {
|
||||
.with_commit_type(CommitType::Feat)
|
||||
.with_scope(Scope::empty())
|
||||
.with_description(Description::parse("add new feature").unwrap())
|
||||
.with_breaking_change(BreakingChange::Yes)
|
||||
.with_confirm(true);
|
||||
|
||||
// Create workflow with both mocks
|
||||
@@ -330,6 +336,7 @@ mod tests {
|
||||
.with_commit_type(CommitType::Fix)
|
||||
.with_scope(Scope::parse("api").unwrap())
|
||||
.with_description(Description::parse("fix bug").unwrap())
|
||||
.with_breaking_change(BreakingChange::No)
|
||||
.with_confirm(false); // User cancels at confirmation
|
||||
|
||||
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||
@@ -352,9 +359,11 @@ mod tests {
|
||||
// First iteration: scope + description exceed 72 chars combined
|
||||
.with_scope(Scope::parse("very-long-scope-name").unwrap())
|
||||
.with_description(Description::parse("a".repeat(45)).unwrap())
|
||||
.with_breaking_change(BreakingChange::No)
|
||||
// Second iteration: short enough to succeed
|
||||
.with_scope(Scope::empty())
|
||||
.with_description(Description::parse("short description").unwrap())
|
||||
.with_breaking_change(BreakingChange::No)
|
||||
.with_confirm(true);
|
||||
|
||||
// Clone before moving into workflow so we can inspect emitted messages after
|
||||
@@ -447,6 +456,7 @@ mod tests {
|
||||
.with_commit_type(*commit_type)
|
||||
.with_scope(Scope::empty())
|
||||
.with_description(Description::parse("test").unwrap())
|
||||
.with_breaking_change(BreakingChange::Yes)
|
||||
.with_confirm(true);
|
||||
|
||||
let workflow = CommitWorkflow::with_prompts(
|
||||
@@ -468,6 +478,7 @@ mod tests {
|
||||
.with_commit_type(CommitType::Feat)
|
||||
.with_scope(Scope::empty())
|
||||
.with_description(Description::parse("test").unwrap())
|
||||
.with_breaking_change(BreakingChange::Yes)
|
||||
.with_confirm(true);
|
||||
|
||||
let workflow = CommitWorkflow::with_prompts(
|
||||
@@ -484,6 +495,7 @@ mod tests {
|
||||
.with_commit_type(CommitType::Feat)
|
||||
.with_scope(Scope::parse("api").unwrap())
|
||||
.with_description(Description::parse("test").unwrap())
|
||||
.with_breaking_change(BreakingChange::No)
|
||||
.with_confirm(true);
|
||||
|
||||
let workflow = CommitWorkflow::with_prompts(
|
||||
@@ -509,4 +521,99 @@ mod tests {
|
||||
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||
assert!(matches!(workflow, CommitWorkflow { .. }));
|
||||
}
|
||||
|
||||
/// Preview_and_confirm must forward BreakingChange::Yes to
|
||||
/// ConventionalCommit::new(), producing a commit whose string
|
||||
/// contains '!'.
|
||||
///
|
||||
/// Before the fix the parameter was ignored and
|
||||
/// BreakingChange::No was hard-coded, so a confirmed
|
||||
/// breaking-change commit was silently applied without the '!'
|
||||
/// marker.
|
||||
#[tokio::test]
|
||||
async fn preview_and_confirm_forwards_breaking_change_yes() {
|
||||
let mock_executor = MockJjExecutor::new();
|
||||
let mock_prompts = MockPrompts::new().with_confirm(true);
|
||||
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||
|
||||
let result = workflow
|
||||
.preview_and_confirm(
|
||||
CommitType::Feat,
|
||||
Scope::empty(),
|
||||
Description::parse("remove old API").unwrap(),
|
||||
BreakingChange::Yes,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok(), "expected Ok, got: {:?}", result);
|
||||
let message = result.unwrap().to_string();
|
||||
assert!(
|
||||
message.contains("feat!:"),
|
||||
"expected '!' marker in described message, got: {:?}",
|
||||
message,
|
||||
);
|
||||
}
|
||||
|
||||
/// Preview_and_confirm must forward BreakingChange::WithNote,
|
||||
/// producing a commit with both the '!' header marker and the
|
||||
/// BREAKING CHANGE footer.
|
||||
#[tokio::test]
|
||||
async fn preview_and_confirm_forwards_breaking_change_with_note() {
|
||||
let mock_executor = MockJjExecutor::new();
|
||||
let mock_prompts = MockPrompts::new().with_confirm(true);
|
||||
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||
|
||||
let breaking_change: BreakingChange = "removes legacy endpoint".into();
|
||||
let result = workflow
|
||||
.preview_and_confirm(
|
||||
CommitType::Feat,
|
||||
Scope::empty(),
|
||||
Description::parse("drop legacy API").unwrap(),
|
||||
breaking_change,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok(), "expected Ok, got: {:?}", result);
|
||||
let message = result.unwrap().to_string();
|
||||
assert!(
|
||||
message.contains("feat!:"),
|
||||
"expected '!' header marker in message, got: {:?}",
|
||||
message,
|
||||
);
|
||||
assert!(
|
||||
message.contains("BREAKING CHANGE:"),
|
||||
"expected BREAKING CHANGE footer in message, got: {:?}",
|
||||
message,
|
||||
);
|
||||
}
|
||||
|
||||
/// The message passed to executor.describe() must include the '!'
|
||||
/// marker when the user selects a breaking change.
|
||||
///
|
||||
/// This test exercises the full run() path and inspects what was
|
||||
/// actually handed to the jj executor, which is the authoritative
|
||||
/// check that the described commit is correct.
|
||||
#[tokio::test]
|
||||
async fn full_workflow_describes_commit_with_breaking_change_marker() {
|
||||
let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
|
||||
let mock_prompts = MockPrompts::new()
|
||||
.with_commit_type(CommitType::Feat)
|
||||
.with_scope(Scope::empty())
|
||||
.with_description(Description::parse("remove old API").unwrap())
|
||||
.with_breaking_change(BreakingChange::Yes)
|
||||
.with_confirm(true);
|
||||
|
||||
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||
let result: Result<(), Error> = workflow.run().await;
|
||||
|
||||
assert!(result.is_ok(), "expected workflow to succeed, got: {:?}", result);
|
||||
|
||||
let messages = workflow.executor.describe_messages();
|
||||
assert_eq!(messages.len(), 1, "expected exactly one describe() call");
|
||||
assert!(
|
||||
messages[0].contains("feat!:"),
|
||||
"expected '!' marker in the described message, got: {:?}",
|
||||
messages[0],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user