feat: add interactive conventional commit workflow with jj-lib backend
Replace CLI executor with jj-lib integration, implement full interactive commit workflow via prompts, and add mock infrastructure for testing. Add CLI integration tests and error handling tests.
This commit is contained in:
184
src/prompts/prompter.rs
Normal file
184
src/prompts/prompter.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
//! 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<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 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);
|
||||
}
|
||||
|
||||
/// 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 confirm_apply(&self, message: &str) -> Result<bool, Error> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user