feat: implement breaking change input

This commit is contained in:
2026-03-09 22:57:35 +01:00
parent e794251b98
commit 3e0d82de9a
14 changed files with 976 additions and 143 deletions

View File

@@ -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);
}
}