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.
185 lines
6.6 KiB
Rust
185 lines
6.6 KiB
Rust
//! 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);
|
|
}
|
|
}
|