Files
jj-cz/src/prompts/prompter.rs

367 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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.
use inquire::{Confirm, Text};
use unicode_width::UnicodeWidthStr;
use crate::{
commit::types::{BreakingChange, CommitType, Description, Scope},
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>;
/// 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>;
/// 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);
}
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")
}
/// 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);
}
}
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!(
"\n📝 Commit Message Preview:\n{}\n",
format_message_box(message)
);
// 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);
}
/// 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);
}
/// 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
}
}