//! 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 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; /// Prompt the user to input an optional scope fn input_scope(&self) -> Result; /// Prompt the user to input a required description fn input_description(&self) -> Result; /// Prompt the user for breaking change fn input_breaking_change(&self) -> Result; /// Prompt the user to confirm applying the commit message fn confirm_apply(&self, message: &str) -> Result; /// 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 = 72 + 2; // max width + space padding let mut lines: Vec = 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; impl Prompter for RealPrompts { fn select_commit_type(&self) -> Result { 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 { 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 { 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 { 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 { 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 = 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); } }