use inquire::{Confirm, Text};
use unicode_width::UnicodeWidthStr;
use crate::{
commit::types::{Body, BreakingChange, CommitType, Description, References, Scope},
error::Error,
};
pub trait Prompter {
fn select_commit_type(&self) -> Result<CommitType, Error>;
fn input_scope(&self) -> Result<Scope, Error>;
fn input_description(&self) -> Result<Description, Error>;
fn input_breaking_change(&self) -> Result<BreakingChange, Error>;
fn input_body(&self) -> Result<Body, Error>;
fn input_references(&self) -> Result<References, Error>;
fn confirm_apply(&self, message: &str) -> Result<bool, Error>;
fn emit_message(&self, msg: &str);
}
fn format_message_box(message: &str) -> String {
let preview_width = message
.split('\n')
.map(|line| line.width())
.max()
.unwrap_or(0)
.max(72);
let mut lines: Vec<String> = Vec::new();
lines.push(format!("┌{}┐", "─".repeat(preview_width + 2)));
for line in message.split('\n') {
let padding = preview_width.saturating_sub(line.width());
lines.push(format!("│ {line}{:padding$} │", ""));
}
lines.push(format!("└{}┘", "─".repeat(preview_width + 2)));
lines.join("\n")
}
#[derive(Debug)]
pub struct RealPrompts;
impl Prompter for RealPrompts {
fn select_commit_type(&self) -> Result<CommitType, Error> {
inquire::Select::new("Select commit type:", CommitType::all().to_vec())
.with_page_size(11)
.with_help_message(
"Use arrow keys to navigate, Enter to select. See https://www.conventionalcommits.org/ for details.",
)
.with_formatter(&|option| format!("{}: {}", option.value.as_str(), option.value.description()))
.prompt()
.map_err(|_| Error::Cancelled)
}
fn input_scope(&self) -> Result<Scope, Error> {
let answer = inquire::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)?;
match answer {
Some(s) if s.trim().is_empty() => Ok(Scope::empty()),
Some(s) => Scope::parse(s.trim()).map_err(|e| Error::InvalidScope(e.to_string())),
None => Ok(Scope::empty()),
}
}
fn input_references(&self) -> Result<References, Error> {
let answer = inquire::Text::new("Enter comma-separated references (optional):")
.with_help_message("References are optional. If provided, will become footer(s) in the commit message. References must be comma-separated.")
.with_placeholder("Leave empty if no references")
.prompt_skippable()
.map_err(|_| Error::Cancelled)?;
match answer {
None => Ok(References::default()),
Some(s) if s.trim().is_empty() => Ok(References::default()),
Some(s) => Ok(References::from(s)),
}
}
fn input_description(&self) -> Result<Description, Error> {
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;
}
let Ok(desc) = Description::parse(trimmed) else {
println!("❌ Description cannot be empty. Please provide a description.");
continue;
};
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);
}
}
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 input_body(&self) -> Result<Body, Error> {
let wants_body = Confirm::new("Add a body?")
.with_default(false)
.prompt()
.map_err(|_| Error::Cancelled)?;
if !wants_body {
return Ok(Body::default());
}
let template = "\
JJ: Body (optional). Markdown is supported.\n\
JJ: Wrap prose lines at 72 characters where possible.\n\
JJ: Lines starting with \"JJ:\" will be removed.\n";
let raw = inquire::Editor::new("Body:")
.with_predefined_text(template)
.with_file_extension(".md")
.prompt()
.map_err(|_| Error::Cancelled)?;
let stripped: String = raw
.lines()
.filter(|line| !line.starts_with("JJ:"))
.collect::<Vec<_>>()
.join("\n");
Ok(Body::from(stripped))
}
fn confirm_apply(&self, message: &str) -> Result<bool, Error> {
println!(
"\n📝 Commit Message Preview:\n{}\n",
format_message_box(message)
);
inquire::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]
fn real_prompts_implements_trait() {
let real = RealPrompts;
fn _accepts_prompter(_p: impl Prompter) {}
_accepts_prompter(real);
}
#[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}┘"));
}
#[test]
fn format_message_box_single_line_row_count() {
let result = format_message_box("feat: add login");
assert_eq!(result.split('\n').count(), 3);
}
#[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);
}
#[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); }
#[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
);
}
#[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);
let expected = format!("│ {:72} │", "");
assert_eq!(lines[1], expected);
}
#[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);
}
#[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);
}
#[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);
}
#[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);
}
#[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); assert_eq!(lines[0], format!("┌{dashes}┐"));
assert_eq!(lines[lines.len() - 1], format!("└{dashes}┘"));
}
#[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} │"));
}
#[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} │", "")); }
#[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
);
}
#[test]
fn format_message_box_wide_chars_expand_box() {
let wide_line = "字".repeat(37); let result = format_message_box(&wide_line);
let lines: Vec<&str> = result.split('\n').collect();
let dashes = "─".repeat(76); assert_eq!(lines[0], format!("┌{dashes}┐"));
assert_eq!(lines[1], format!("│ {wide_line} │")); }
}