//! 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 crate::{ commit::types::{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 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); } /// 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 confirm_apply(&self, message: &str) -> Result { use inquire::Confirm; // Show preview println!(); println!("📝 Commit Message Preview:"); println!( "┌─────────────────────────────────────────────────────────────────────────────────────────────────┐" ); println!("│ {}│", message); // Pad with spaces to fill the box let padding = 72_usize.saturating_sub(message.chars().count()); if padding > 0 { println!("│{:padding$}│", ""); } println!( "└─────────────────────────────────────────────────────────────────────────────────────────────────┘" ); println!(); // 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); } }