2026-03-07 00:53:13 +01:00
|
|
|
|
//! Prompt abstraction for the interactive commit workflow
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! This module provides the [`Prompter`] trait and its production
|
|
|
|
|
|
//! implementation [`RealPrompts`]. The trait is the seam that allows
|
|
|
|
|
|
//! [`CommitWorkflow`](super::CommitWorkflow) to use real interactive prompts
|
|
|
|
|
|
//! in production while accepting mock implementations in tests.
|
|
|
|
|
|
|
2026-03-09 22:57:35 +01:00
|
|
|
|
use inquire::{Confirm, Text};
|
2026-03-13 23:59:28 +01:00
|
|
|
|
use unicode_width::UnicodeWidthStr;
|
2026-03-09 22:57:35 +01:00
|
|
|
|
|
2026-03-07 00:53:13 +01:00
|
|
|
|
use crate::{
|
2026-03-09 22:57:35 +01:00
|
|
|
|
commit::types::{BreakingChange, CommitType, Description, Scope},
|
2026-03-07 00:53:13 +01:00
|
|
|
|
error::Error,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/// Abstraction over prompt operations used by the commit workflow
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Implement this trait to supply a custom front-end (interactive TUI, mock,
|
|
|
|
|
|
/// headless, etc.) to [`CommitWorkflow`](super::CommitWorkflow).
|
|
|
|
|
|
pub trait Prompter: Send + Sync {
|
|
|
|
|
|
/// Prompt the user to select a commit type
|
|
|
|
|
|
fn select_commit_type(&self) -> Result<CommitType, Error>;
|
|
|
|
|
|
|
|
|
|
|
|
/// Prompt the user to input an optional scope
|
|
|
|
|
|
fn input_scope(&self) -> Result<Scope, Error>;
|
|
|
|
|
|
|
|
|
|
|
|
/// Prompt the user to input a required description
|
|
|
|
|
|
fn input_description(&self) -> Result<Description, Error>;
|
|
|
|
|
|
|
2026-03-09 22:57:35 +01:00
|
|
|
|
/// Prompt the user for breaking change
|
|
|
|
|
|
fn input_breaking_change(&self) -> Result<BreakingChange, Error>;
|
|
|
|
|
|
|
2026-03-07 00:53:13 +01:00
|
|
|
|
/// Prompt the user to confirm applying the commit message
|
|
|
|
|
|
fn confirm_apply(&self, message: &str) -> Result<bool, Error>;
|
|
|
|
|
|
|
|
|
|
|
|
/// Display a message to the user (errors, feedback, status)
|
|
|
|
|
|
///
|
|
|
|
|
|
/// In production this prints to stdout. In tests, implementations
|
|
|
|
|
|
/// typically record the message for later assertion.
|
|
|
|
|
|
fn emit_message(&self, msg: &str);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 22:57:35 +01:00
|
|
|
|
fn format_message_box(message: &str) -> String {
|
2026-03-13 23:59:28 +01:00
|
|
|
|
let preview_width = message
|
|
|
|
|
|
.split('\n')
|
|
|
|
|
|
.map(|line| line.width())
|
|
|
|
|
|
.max()
|
|
|
|
|
|
.unwrap_or(0)
|
|
|
|
|
|
.max(72);
|
2026-03-09 22:57:35 +01:00
|
|
|
|
let mut lines: Vec<String> = Vec::new();
|
2026-03-13 23:59:28 +01:00
|
|
|
|
lines.push(format!("┌{}┐", "─".repeat(preview_width + 2)));
|
|
|
|
|
|
for line in message.split('\n') {
|
|
|
|
|
|
let padding = preview_width.saturating_sub(line.width());
|
2026-03-09 22:57:35 +01:00
|
|
|
|
lines.push(format!("│ {line}{:padding$} │", ""));
|
|
|
|
|
|
}
|
2026-03-13 23:59:28 +01:00
|
|
|
|
lines.push(format!("└{}┘", "─".repeat(preview_width + 2)));
|
2026-03-09 22:57:35 +01:00
|
|
|
|
lines.join("\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 00:53:13 +01:00
|
|
|
|
/// Production implementation of [`Prompter`] using the `inquire` crate
|
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
|
pub struct RealPrompts;
|
|
|
|
|
|
|
|
|
|
|
|
impl Prompter for RealPrompts {
|
|
|
|
|
|
fn select_commit_type(&self) -> Result<CommitType, Error> {
|
|
|
|
|
|
use inquire::Select;
|
|
|
|
|
|
|
|
|
|
|
|
let options: Vec<_> = CommitType::all()
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|ct| format!("{}: {}", ct, ct.description()))
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
|
|
let answer = Select::new("Select commit type:", options)
|
|
|
|
|
|
.with_page_size(11)
|
|
|
|
|
|
.with_help_message(
|
|
|
|
|
|
"Use arrow keys to navigate, Enter to select. See https://www.conventionalcommits.org/ for details.",
|
|
|
|
|
|
)
|
|
|
|
|
|
.prompt()
|
|
|
|
|
|
.map_err(|_| Error::Cancelled)?;
|
|
|
|
|
|
|
|
|
|
|
|
// Extract the commit type from the selected option
|
|
|
|
|
|
let selected_type = answer
|
|
|
|
|
|
.split(':')
|
|
|
|
|
|
.next()
|
|
|
|
|
|
.ok_or_else(|| Error::JjOperation {
|
|
|
|
|
|
context: "Failed to parse selected commit type".to_string(),
|
|
|
|
|
|
})?
|
|
|
|
|
|
.trim();
|
|
|
|
|
|
|
|
|
|
|
|
CommitType::all()
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|ct| ct.as_str() == selected_type)
|
|
|
|
|
|
.copied()
|
|
|
|
|
|
.ok_or_else(|| Error::JjOperation {
|
|
|
|
|
|
context: format!("Unknown commit type: {}", selected_type),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn input_scope(&self) -> Result<Scope, Error> {
|
|
|
|
|
|
use inquire::Text;
|
|
|
|
|
|
|
|
|
|
|
|
let answer = Text::new("Enter scope (optional):")
|
|
|
|
|
|
.with_help_message(
|
|
|
|
|
|
"Scope is optional. If provided, it should be a noun representing the section of code affected (e.g., 'api', 'ui', 'config'). Max 30 characters.",
|
|
|
|
|
|
)
|
|
|
|
|
|
.with_placeholder("Leave empty if no scope")
|
|
|
|
|
|
.prompt_skippable()
|
|
|
|
|
|
.map_err(|_| Error::Cancelled)?;
|
|
|
|
|
|
|
|
|
|
|
|
// Empty input is valid (no scope)
|
|
|
|
|
|
let answer_str = match answer {
|
|
|
|
|
|
Some(s) => s,
|
|
|
|
|
|
None => return Ok(Scope::empty()),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if answer_str.trim().is_empty() {
|
|
|
|
|
|
return Ok(Scope::empty());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Parse and validate the scope
|
|
|
|
|
|
Scope::parse(answer_str.trim()).map_err(|e| Error::InvalidScope(e.to_string()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn input_description(&self) -> Result<Description, Error> {
|
|
|
|
|
|
use inquire::Text;
|
|
|
|
|
|
|
|
|
|
|
|
loop {
|
|
|
|
|
|
let answer = Text::new("Enter description (required):")
|
|
|
|
|
|
.with_help_message(
|
|
|
|
|
|
"Description is required. Short summary in imperative mood \
|
|
|
|
|
|
(e.g., 'add feature', 'fix bug'). Soft limit: 50 characters.",
|
|
|
|
|
|
)
|
|
|
|
|
|
.prompt()
|
|
|
|
|
|
.map_err(|_| Error::Cancelled)?;
|
|
|
|
|
|
|
|
|
|
|
|
let trimmed = answer.trim();
|
|
|
|
|
|
if trimmed.is_empty() {
|
|
|
|
|
|
println!("❌ Description cannot be empty. Please provide a description.");
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// parse() only fails on empty — already handled above
|
|
|
|
|
|
let Ok(desc) = Description::parse(trimmed) else {
|
|
|
|
|
|
println!("❌ Description cannot be empty. Please provide a description.");
|
|
|
|
|
|
continue;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Soft limit warning: over 50 chars is allowed but may push the
|
|
|
|
|
|
// combined first line over 72 characters.
|
|
|
|
|
|
if desc.len() > Description::MAX_LENGTH {
|
|
|
|
|
|
println!(
|
|
|
|
|
|
"⚠️ Description is {} characters (soft limit is {}). \
|
|
|
|
|
|
The combined commit line must still be ≤ 72 characters.",
|
|
|
|
|
|
desc.len(),
|
|
|
|
|
|
Description::MAX_LENGTH
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return Ok(desc);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 22:57:35 +01:00
|
|
|
|
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())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 00:53:13 +01:00
|
|
|
|
fn confirm_apply(&self, message: &str) -> Result<bool, Error> {
|
|
|
|
|
|
use inquire::Confirm;
|
|
|
|
|
|
|
|
|
|
|
|
// Show preview
|
2026-03-09 22:57:35 +01:00
|
|
|
|
println!(
|
|
|
|
|
|
"\n📝 Commit Message Preview:\n{}\n",
|
|
|
|
|
|
format_message_box(message)
|
|
|
|
|
|
);
|
2026-03-07 00:53:13 +01:00
|
|
|
|
|
|
|
|
|
|
// Get confirmation
|
|
|
|
|
|
Confirm::new("Apply this commit message?")
|
|
|
|
|
|
.with_default(true)
|
|
|
|
|
|
.with_help_message("Select 'No' to cancel and start over")
|
|
|
|
|
|
.prompt()
|
|
|
|
|
|
.map_err(|_| Error::Cancelled)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn emit_message(&self, msg: &str) {
|
|
|
|
|
|
println!("{}", msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
|
|
/// Test RealPrompts implements Prompter trait
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn real_prompts_implements_trait() {
|
|
|
|
|
|
let real = RealPrompts;
|
|
|
|
|
|
fn _accepts_prompter(_p: impl Prompter) {}
|
|
|
|
|
|
_accepts_prompter(real);
|
|
|
|
|
|
}
|
2026-03-09 22:57:35 +01:00
|
|
|
|
|
|
|
|
|
|
/// 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);
|
|
|
|
|
|
}
|
2026-03-13 23:59:28 +01:00
|
|
|
|
|
|
|
|
|
|
/// A single CJK character (display width 2) is padded as if it occupies 2 columns,
|
|
|
|
|
|
/// not 1 — so the right-hand padding is 70 spaces, not 71
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn format_message_box_single_cjk_char() {
|
|
|
|
|
|
let result = format_message_box("字");
|
|
|
|
|
|
let lines: Vec<&str> = result.split('\n').collect();
|
|
|
|
|
|
let expected = format!("│ 字{:70} │", "");
|
|
|
|
|
|
assert_eq!(lines[1], expected);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A single emoji (display width 2) is padded as if it occupies 2 columns
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn format_message_box_single_emoji() {
|
|
|
|
|
|
let result = format_message_box("🦀");
|
|
|
|
|
|
let lines: Vec<&str> = result.split('\n').collect();
|
|
|
|
|
|
let expected = format!("│ 🦀{:70} │", "");
|
|
|
|
|
|
assert_eq!(lines[1], expected);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Mixed ASCII and CJK: padding accounts for the display width of the whole line
|
|
|
|
|
|
///
|
|
|
|
|
|
/// "feat: " = 6 display cols, "漢字" = 4 display cols → total 10, padding = 62
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn format_message_box_mixed_ascii_and_cjk() {
|
|
|
|
|
|
let result = format_message_box("feat: 漢字");
|
|
|
|
|
|
let lines: Vec<&str> = result.split('\n').collect();
|
|
|
|
|
|
let expected = format!("│ feat: 漢字{:62} │", "");
|
|
|
|
|
|
assert_eq!(lines[1], expected);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// When a line exceeds 72 display columns the border expands to fit (width + 2 dashes)
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn format_message_box_border_expands_beyond_72() {
|
|
|
|
|
|
let line_73 = "a".repeat(73);
|
|
|
|
|
|
let result = format_message_box(&line_73);
|
|
|
|
|
|
let lines: Vec<&str> = result.split('\n').collect();
|
|
|
|
|
|
let dashes = "─".repeat(75); // 73 + 2
|
|
|
|
|
|
assert_eq!(lines[0], format!("┌{dashes}┐"));
|
|
|
|
|
|
assert_eq!(lines[lines.len() - 1], format!("└{dashes}┘"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A line that sets the box width gets zero right-hand padding
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn format_message_box_widest_line_has_no_padding() {
|
|
|
|
|
|
let line_73 = "a".repeat(73);
|
|
|
|
|
|
let result = format_message_box(&line_73);
|
|
|
|
|
|
let lines: Vec<&str> = result.split('\n').collect();
|
|
|
|
|
|
assert_eq!(lines[1], format!("│ {line_73} │"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// In a multi-line message, shorter lines are padded out to match the widest line
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn format_message_box_shorter_lines_padded_to_widest() {
|
|
|
|
|
|
let long_line = "a".repeat(80);
|
|
|
|
|
|
let result = format_message_box(&format!("{long_line}\nshort"));
|
|
|
|
|
|
let lines: Vec<&str> = result.split('\n').collect();
|
|
|
|
|
|
assert_eq!(lines[1], format!("│ {long_line} │"));
|
|
|
|
|
|
assert_eq!(lines[2], format!("│ short{:75} │", "")); // 80 - 5 = 75
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// All rows have equal char count when the box expands beyond 72
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn format_message_box_all_rows_same_width_when_expanded() {
|
|
|
|
|
|
let long_line = "a".repeat(80);
|
|
|
|
|
|
let result = format_message_box(&format!("{long_line}\nshort"));
|
|
|
|
|
|
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
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Wide characters can also trigger box expansion beyond 72 columns
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 37 CJK characters × 2 display columns = 74 display columns → border uses 76 dashes
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn format_message_box_wide_chars_expand_box() {
|
|
|
|
|
|
let wide_line = "字".repeat(37); // 74 display cols
|
|
|
|
|
|
let result = format_message_box(&wide_line);
|
|
|
|
|
|
let lines: Vec<&str> = result.split('\n').collect();
|
|
|
|
|
|
let dashes = "─".repeat(76); // 74 + 2
|
|
|
|
|
|
assert_eq!(lines[0], format!("┌{dashes}┐"));
|
|
|
|
|
|
assert_eq!(lines[1], format!("│ {wide_line} │")); // no padding
|
|
|
|
|
|
}
|
2026-03-07 00:53:13 +01:00
|
|
|
|
}
|