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:
284
src/prompts/mock.rs
Normal file
284
src/prompts/mock.rs
Normal file
@@ -0,0 +1,284 @@
|
||||
//! Mock implementation of [`Prompter`] for testing
|
||||
//!
|
||||
//! This module is gated via `#[cfg(any(test, feature = "test-utils"))]` on its
|
||||
//! declaration in `mod.rs`, so it is never compiled into production binaries.
|
||||
//!
|
||||
//! [`Prompter`]: super::prompter::Prompter
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::{
|
||||
commit::types::{CommitType, Description, Scope},
|
||||
error::Error,
|
||||
prompts::prompter::Prompter,
|
||||
};
|
||||
|
||||
/// Enum representing different types of mock responses
|
||||
#[derive(Debug)]
|
||||
enum MockResponse {
|
||||
CommitType(CommitType),
|
||||
Scope(Scope),
|
||||
Description(Description),
|
||||
Confirm(bool),
|
||||
Error(Error),
|
||||
}
|
||||
|
||||
/// Mock implementation of [`Prompter`] for testing
|
||||
///
|
||||
/// This struct allows configuring responses for each prompt type and tracks
|
||||
/// which prompts were called during test execution.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct MockPrompts {
|
||||
/// Queue of responses to return for each prompt call
|
||||
responses: Arc<Mutex<Vec<MockResponse>>>,
|
||||
/// Track which prompts were called (for verification)
|
||||
prompts_called: Arc<Mutex<Vec<String>>>,
|
||||
/// Messages emitted via emit_message() for test assertion
|
||||
messages: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl MockPrompts {
|
||||
/// Create a new MockPrompts with empty response queue
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Configure the mock to return a specific commit type
|
||||
pub fn with_commit_type(self, commit_type: CommitType) -> Self {
|
||||
self.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(MockResponse::CommitType(commit_type));
|
||||
self
|
||||
}
|
||||
|
||||
/// Configure the mock to return a specific scope
|
||||
pub fn with_scope(self, scope: Scope) -> Self {
|
||||
self.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(MockResponse::Scope(scope));
|
||||
self
|
||||
}
|
||||
|
||||
/// Configure the mock to return a specific description
|
||||
pub fn with_description(self, description: Description) -> Self {
|
||||
self.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(MockResponse::Description(description));
|
||||
self
|
||||
}
|
||||
|
||||
/// Configure the mock to return a specific confirmation response
|
||||
pub fn with_confirm(self, confirm: bool) -> Self {
|
||||
self.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(MockResponse::Confirm(confirm));
|
||||
self
|
||||
}
|
||||
|
||||
/// Configure the mock to return an error
|
||||
pub fn with_error(self, error: Error) -> Self {
|
||||
self.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(MockResponse::Error(error));
|
||||
self
|
||||
}
|
||||
|
||||
/// Check if select_commit_type was called
|
||||
pub fn was_commit_type_called(&self) -> bool {
|
||||
self.prompts_called
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains(&"select_commit_type".to_string())
|
||||
}
|
||||
|
||||
/// Check if input_scope was called
|
||||
pub fn was_scope_called(&self) -> bool {
|
||||
self.prompts_called
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains(&"input_scope".to_string())
|
||||
}
|
||||
|
||||
/// Check if input_description was called
|
||||
pub fn was_description_called(&self) -> bool {
|
||||
self.prompts_called
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains(&"input_description".to_string())
|
||||
}
|
||||
|
||||
/// Check if confirm_apply was called
|
||||
pub fn was_confirm_called(&self) -> bool {
|
||||
self.prompts_called
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains(&"confirm_apply".to_string())
|
||||
}
|
||||
|
||||
/// Get all messages emitted via emit_message()
|
||||
pub fn emitted_messages(&self) -> Vec<String> {
|
||||
self.messages.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Prompter for MockPrompts {
|
||||
fn select_commit_type(&self) -> Result<CommitType, Error> {
|
||||
self.prompts_called
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push("select_commit_type".to_string());
|
||||
|
||||
match self.responses.lock().unwrap().remove(0) {
|
||||
MockResponse::CommitType(ct) => Ok(ct),
|
||||
MockResponse::Error(e) => Err(e),
|
||||
_ => panic!("MockPrompts: Expected CommitType response, got different type"),
|
||||
}
|
||||
}
|
||||
|
||||
fn input_scope(&self) -> Result<Scope, Error> {
|
||||
self.prompts_called
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push("input_scope".to_string());
|
||||
|
||||
match self.responses.lock().unwrap().remove(0) {
|
||||
MockResponse::Scope(scope) => Ok(scope),
|
||||
MockResponse::Error(e) => Err(e),
|
||||
_ => panic!("MockPrompts: Expected Scope response, got different type"),
|
||||
}
|
||||
}
|
||||
|
||||
fn input_description(&self) -> Result<Description, Error> {
|
||||
self.prompts_called
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push("input_description".to_string());
|
||||
|
||||
match self.responses.lock().unwrap().remove(0) {
|
||||
MockResponse::Description(desc) => Ok(desc),
|
||||
MockResponse::Error(e) => Err(e),
|
||||
_ => panic!("MockPrompts: Expected Description response, got different type"),
|
||||
}
|
||||
}
|
||||
|
||||
fn confirm_apply(&self, _message: &str) -> Result<bool, Error> {
|
||||
self.prompts_called
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push("confirm_apply".to_string());
|
||||
|
||||
match self.responses.lock().unwrap().remove(0) {
|
||||
MockResponse::Confirm(confirm) => Ok(confirm),
|
||||
MockResponse::Error(e) => Err(e),
|
||||
_ => panic!("MockPrompts: Expected Confirm response, got different type"),
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_message(&self, msg: &str) {
|
||||
self.messages.lock().unwrap().push(msg.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::commit::types::{CommitType, Description, Scope};
|
||||
|
||||
#[test]
|
||||
fn mock_prompts_creation() {
|
||||
let mock = MockPrompts::new();
|
||||
assert!(matches!(mock, MockPrompts { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_prompts_implements_trait() {
|
||||
let mock = MockPrompts::new();
|
||||
fn _accepts_prompter(_p: impl Prompter) {}
|
||||
_accepts_prompter(mock);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_select_commit_type() {
|
||||
let mock = MockPrompts::new().with_commit_type(CommitType::Feat);
|
||||
let result = mock.select_commit_type();
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), CommitType::Feat);
|
||||
assert!(mock.was_commit_type_called());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_input_scope() {
|
||||
let scope = Scope::parse("test-scope").unwrap();
|
||||
let mock = MockPrompts::new().with_scope(scope.clone());
|
||||
let result = mock.input_scope();
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), scope);
|
||||
assert!(mock.was_scope_called());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_input_description() {
|
||||
let desc = Description::parse("test description").unwrap();
|
||||
let mock = MockPrompts::new().with_description(desc.clone());
|
||||
let result = mock.input_description();
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), desc);
|
||||
assert!(mock.was_description_called());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_confirm_apply() {
|
||||
let mock = MockPrompts::new().with_confirm(true);
|
||||
let result = mock.confirm_apply("test message");
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap());
|
||||
assert!(mock.was_confirm_called());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_error_response() {
|
||||
let mock = MockPrompts::new().with_error(Error::Cancelled);
|
||||
let result = mock.select_commit_type();
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), Error::Cancelled));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_tracks_prompt_calls() {
|
||||
let mock = MockPrompts::new()
|
||||
.with_commit_type(CommitType::Fix)
|
||||
.with_scope(Scope::empty())
|
||||
.with_description(Description::parse("test").unwrap())
|
||||
.with_confirm(true);
|
||||
|
||||
mock.select_commit_type().unwrap();
|
||||
mock.input_scope().unwrap();
|
||||
mock.input_description().unwrap();
|
||||
mock.confirm_apply("test").unwrap();
|
||||
|
||||
assert!(mock.was_commit_type_called());
|
||||
assert!(mock.was_scope_called());
|
||||
assert!(mock.was_description_called());
|
||||
assert!(mock.was_confirm_called());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_emit_message_records_messages() {
|
||||
let mock = MockPrompts::new();
|
||||
mock.emit_message("hello");
|
||||
mock.emit_message("world");
|
||||
let msgs = mock.emitted_messages();
|
||||
assert_eq!(msgs, vec!["hello", "world"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_emit_message_starts_empty() {
|
||||
let mock = MockPrompts::new();
|
||||
assert!(mock.emitted_messages().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1 +1,7 @@
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
pub mod mock;
|
||||
pub mod prompter;
|
||||
pub mod workflow;
|
||||
|
||||
pub use prompter::Prompter;
|
||||
pub use workflow::CommitWorkflow;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
512
src/prompts/workflow.rs
Normal file
512
src/prompts/workflow.rs
Normal file
@@ -0,0 +1,512 @@
|
||||
//! Interactive commit workflow orchestration
|
||||
//!
|
||||
//! This module provides the CommitWorkflow struct that guides users through
|
||||
//! creating a conventional commit message using interactive prompts.
|
||||
|
||||
use crate::{
|
||||
commit::types::{CommitMessageError, CommitType, ConventionalCommit, Description, Scope},
|
||||
error::Error,
|
||||
jj::JjExecutor,
|
||||
prompts::prompter::{Prompter, RealPrompts},
|
||||
};
|
||||
|
||||
/// Orchestrates the interactive commit workflow
|
||||
///
|
||||
/// This struct handles the complete user interaction flow:
|
||||
/// 1. Check if we're in a jj repository
|
||||
/// 2. Select commit type from 11 options
|
||||
/// 3. Optionally input scope (validated)
|
||||
/// 4. Input required description (validated)
|
||||
/// 5. Preview formatted message and confirm
|
||||
/// 6. Apply the message to the current change
|
||||
///
|
||||
/// Uses dependency injection for prompts to enable testing without TUI.
|
||||
#[derive(Debug)]
|
||||
pub struct CommitWorkflow<J: JjExecutor, P: Prompter = RealPrompts> {
|
||||
executor: J,
|
||||
prompts: P,
|
||||
}
|
||||
|
||||
impl<J: JjExecutor> CommitWorkflow<J> {
|
||||
/// Create a new CommitWorkflow with the given executor
|
||||
///
|
||||
/// Uses RealPrompts by default for interactive TUI prompts.
|
||||
pub fn new(executor: J) -> Self {
|
||||
Self::with_prompts(executor, RealPrompts)
|
||||
}
|
||||
}
|
||||
|
||||
impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
|
||||
/// Create a new CommitWorkflow with custom prompts
|
||||
///
|
||||
/// This allows using MockPrompts in tests to avoid TUI hanging.
|
||||
pub fn with_prompts(executor: J, prompts: P) -> Self {
|
||||
Self { executor, prompts }
|
||||
}
|
||||
|
||||
/// Run the complete interactive workflow
|
||||
///
|
||||
/// Returns Ok(()) on successful completion, or an error if:
|
||||
/// - Not in a jj repository
|
||||
/// - User cancels the workflow
|
||||
/// - Repository operation fails
|
||||
/// - Message validation fails
|
||||
pub async fn run(&self) -> Result<(), Error> {
|
||||
// Verify we're in a jj repository
|
||||
if !self.executor.is_repository().await? {
|
||||
return Err(Error::NotARepository);
|
||||
}
|
||||
|
||||
// Step 1: Select commit type (kept across retries)
|
||||
let commit_type = self.type_selection().await?;
|
||||
|
||||
// Steps 2–4 loop: re-prompt scope and description when the combined
|
||||
// first line would exceed 72 characters (issue 3.4).
|
||||
loop {
|
||||
// Step 2: Input scope (optional)
|
||||
let scope = self.scope_input().await?;
|
||||
|
||||
// Step 3: Input description (required)
|
||||
let description = self.description_input().await?;
|
||||
|
||||
// Step 4: Preview and confirm
|
||||
match self
|
||||
.preview_and_confirm(commit_type, scope, description)
|
||||
.await
|
||||
{
|
||||
Ok(conventional_commit) => {
|
||||
// Step 5: Apply the message
|
||||
self.executor
|
||||
.describe(&conventional_commit.to_string())
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
Err(Error::InvalidCommitMessage(_)) => {
|
||||
// The scope/description combination exceeds 72 characters.
|
||||
// The user has already been shown the error via emit_message.
|
||||
// Loop back to re-prompt scope and description (type is kept).
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt user to select a commit type from the 11 available options
|
||||
async fn type_selection(&self) -> Result<CommitType, Error> {
|
||||
self.prompts.select_commit_type()
|
||||
}
|
||||
|
||||
/// Prompt user to input an optional scope
|
||||
///
|
||||
/// Returns Ok(Scope) with the validated scope, or Error::Cancelled if user cancels
|
||||
async fn scope_input(&self) -> Result<Scope, Error> {
|
||||
self.prompts.input_scope()
|
||||
}
|
||||
|
||||
/// Prompt user to input a required description
|
||||
///
|
||||
/// Returns Ok(Description) with the validated description, or Error::Cancelled if user cancels
|
||||
async fn description_input(&self) -> Result<Description, Error> {
|
||||
self.prompts.input_description()
|
||||
}
|
||||
|
||||
/// Preview the formatted conventional commit message and get user confirmation
|
||||
///
|
||||
/// This method also validates that the complete first line doesn't exceed 72 characters
|
||||
async fn preview_and_confirm(
|
||||
&self,
|
||||
commit_type: CommitType,
|
||||
scope: Scope,
|
||||
description: Description,
|
||||
) -> Result<ConventionalCommit, Error> {
|
||||
// Format the message for preview
|
||||
let message = ConventionalCommit::format_preview(commit_type, &scope, &description);
|
||||
|
||||
// Try to build the conventional commit (this validates the 72-char limit)
|
||||
let conventional_commit: ConventionalCommit = match ConventionalCommit::new(
|
||||
commit_type,
|
||||
scope.clone(),
|
||||
description.clone(),
|
||||
) {
|
||||
Ok(cc) => cc,
|
||||
Err(CommitMessageError::FirstLineTooLong { actual, max }) => {
|
||||
self.prompts.emit_message("❌ Message too long!");
|
||||
self.prompts.emit_message(&format!(
|
||||
"The complete first line must be ≤ {} characters.",
|
||||
max
|
||||
));
|
||||
self.prompts
|
||||
.emit_message(&format!("Current length: {} characters", actual));
|
||||
self.prompts.emit_message("");
|
||||
self.prompts.emit_message("Formatted message would be:");
|
||||
self.prompts.emit_message(&message);
|
||||
self.prompts.emit_message("");
|
||||
self.prompts
|
||||
.emit_message("Please try again with a shorter scope or description.");
|
||||
return Err(Error::InvalidCommitMessage(format!(
|
||||
"First line too long: {} > {}",
|
||||
actual, max
|
||||
)));
|
||||
}
|
||||
Err(CommitMessageError::InvalidConventionalFormat { reason }) => {
|
||||
return Err(Error::InvalidCommitMessage(format!(
|
||||
"Internal error: generated message failed conventional commit validation: {}",
|
||||
reason
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
// Get confirmation from user
|
||||
let confirmed = self.prompts.confirm_apply(&message)?;
|
||||
|
||||
if confirmed {
|
||||
Ok(conventional_commit)
|
||||
} else {
|
||||
Err(Error::Cancelled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::Error;
|
||||
use crate::jj::mock::MockJjExecutor;
|
||||
use crate::prompts::mock::MockPrompts;
|
||||
|
||||
/// Test that CommitWorkflow can be created with a mock executor
|
||||
#[test]
|
||||
fn workflow_creation() {
|
||||
let mock = MockJjExecutor::new();
|
||||
let workflow = CommitWorkflow::new(mock);
|
||||
// If this compiles, the workflow is properly typed
|
||||
assert!(matches!(workflow, CommitWorkflow { .. }));
|
||||
}
|
||||
|
||||
/// Test workflow returns NotARepository when is_repository() returns false
|
||||
#[tokio::test]
|
||||
async fn workflow_returns_not_a_repository() {
|
||||
let mock = MockJjExecutor::new().with_is_repo_response(Ok(false));
|
||||
let workflow = CommitWorkflow::new(mock);
|
||||
let result: Result<(), Error> = workflow.run().await;
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), Error::NotARepository));
|
||||
}
|
||||
|
||||
/// Test workflow returns NotARepository when is_repository() returns error
|
||||
#[tokio::test]
|
||||
async fn workflow_returns_repository_error() {
|
||||
let mock = MockJjExecutor::new().with_is_repo_response(Err(Error::NotARepository));
|
||||
let workflow = CommitWorkflow::new(mock);
|
||||
let result: Result<(), Error> = workflow.run().await;
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), Error::NotARepository));
|
||||
}
|
||||
|
||||
/// Test that type_selection returns a valid CommitType
|
||||
#[tokio::test]
|
||||
async fn type_selection_returns_valid_type() {
|
||||
// Updated to use mock prompts to avoid TUI hanging
|
||||
let mock_executor = MockJjExecutor::new();
|
||||
let mock_prompts = MockPrompts::new().with_commit_type(CommitType::Feat);
|
||||
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||
|
||||
// Now we can actually test the method with mock prompts
|
||||
let result = workflow.type_selection().await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), CommitType::Feat);
|
||||
}
|
||||
|
||||
/// Test that scope_input returns a valid Scope
|
||||
#[tokio::test]
|
||||
async fn scope_input_returns_valid_scope() {
|
||||
let mock_executor = MockJjExecutor::new();
|
||||
let mock_prompts = MockPrompts::new().with_scope(Scope::parse("test").unwrap());
|
||||
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||
|
||||
let result = workflow.scope_input().await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), Scope::parse("test").unwrap());
|
||||
}
|
||||
|
||||
/// Test that description_input returns a valid Description
|
||||
#[tokio::test]
|
||||
async fn description_input_returns_valid_description() {
|
||||
let mock_executor = MockJjExecutor::new();
|
||||
let mock_prompts = MockPrompts::new().with_description(Description::parse("test").unwrap());
|
||||
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||
|
||||
let result = workflow.description_input().await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), Description::parse("test").unwrap());
|
||||
}
|
||||
|
||||
/// Test that preview_and_confirm returns a ConventionalCommit
|
||||
#[tokio::test]
|
||||
async fn preview_and_confirm_returns_conventional_commit() {
|
||||
let mock_executor = MockJjExecutor::new();
|
||||
let mock_prompts = MockPrompts::new().with_confirm(true);
|
||||
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||
|
||||
let commit_type = CommitType::Feat;
|
||||
let scope = Scope::empty();
|
||||
let description = Description::parse("test description").unwrap();
|
||||
|
||||
let result = workflow
|
||||
.preview_and_confirm(commit_type, scope, description)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
/// Test workflow error handling for describe failure
|
||||
#[tokio::test]
|
||||
async fn workflow_handles_describe_error() {
|
||||
// Test the mock executor methods directly
|
||||
let mock = MockJjExecutor::new()
|
||||
.with_is_repo_response(Ok(true))
|
||||
.with_describe_response(Err(Error::RepositoryLocked));
|
||||
|
||||
// Verify the mock behaves as expected
|
||||
assert!(mock.is_repository().await.is_ok());
|
||||
assert!(mock.describe("test").await.is_err());
|
||||
|
||||
// Also test with a working mock
|
||||
let working_mock = MockJjExecutor::new();
|
||||
let workflow = CommitWorkflow::new(working_mock);
|
||||
// We can't complete the full workflow without mocking prompts,
|
||||
// but we can verify the workflow was created successfully
|
||||
assert!(matches!(workflow, CommitWorkflow { .. }));
|
||||
}
|
||||
|
||||
/// Test that workflow implements Debug trait
|
||||
#[test]
|
||||
fn workflow_implements_debug() {
|
||||
let mock = MockJjExecutor::new();
|
||||
let workflow = CommitWorkflow::new(mock);
|
||||
let debug_output = format!("{:?}", workflow);
|
||||
assert!(debug_output.contains("CommitWorkflow"));
|
||||
}
|
||||
|
||||
/// Test complete workflow with mock prompts (happy path)
|
||||
#[tokio::test]
|
||||
async fn test_complete_workflow_happy_path() {
|
||||
// Create mock executor that returns true for is_repository
|
||||
let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
|
||||
|
||||
// Create mock prompts with successful responses
|
||||
let mock_prompts = MockPrompts::new()
|
||||
.with_commit_type(CommitType::Feat)
|
||||
.with_scope(Scope::empty())
|
||||
.with_description(Description::parse("add new feature").unwrap())
|
||||
.with_confirm(true);
|
||||
|
||||
// Create workflow with both mocks
|
||||
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||
|
||||
// Run the workflow - should succeed
|
||||
let result: Result<(), Error> = workflow.run().await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
/// Test workflow cancellation at type selection
|
||||
#[tokio::test]
|
||||
async fn test_workflow_cancellation_at_type_selection() {
|
||||
let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
|
||||
let mock_prompts = MockPrompts::new().with_error(Error::Cancelled);
|
||||
|
||||
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||
let result: Result<(), Error> = workflow.run().await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), Error::Cancelled));
|
||||
}
|
||||
|
||||
/// Test workflow cancellation at confirmation
|
||||
#[tokio::test]
|
||||
async fn test_workflow_cancellation_at_confirmation() {
|
||||
let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
|
||||
let mock_prompts = MockPrompts::new()
|
||||
.with_commit_type(CommitType::Fix)
|
||||
.with_scope(Scope::parse("api").unwrap())
|
||||
.with_description(Description::parse("fix bug").unwrap())
|
||||
.with_confirm(false); // User cancels at confirmation
|
||||
|
||||
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||
let result: Result<(), Error> = workflow.run().await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), Error::Cancelled));
|
||||
}
|
||||
|
||||
/// Test workflow loops back on line length error, re-prompting scope and description
|
||||
///
|
||||
/// "feat(very-long-scope-name): " + 45 'a's = 4+1+20+3+45 = 73 chars → too long (first pass)
|
||||
/// "feat: short description" = 4+2+17 = 23 chars → fine (second pass)
|
||||
#[tokio::test]
|
||||
async fn test_workflow_line_length_validation() {
|
||||
let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
|
||||
|
||||
let mock_prompts = MockPrompts::new()
|
||||
.with_commit_type(CommitType::Feat)
|
||||
// First iteration: scope + description exceed 72 chars combined
|
||||
.with_scope(Scope::parse("very-long-scope-name").unwrap())
|
||||
.with_description(Description::parse("a".repeat(45)).unwrap())
|
||||
// Second iteration: short enough to succeed
|
||||
.with_scope(Scope::empty())
|
||||
.with_description(Description::parse("short description").unwrap())
|
||||
.with_confirm(true);
|
||||
|
||||
// Clone before moving into workflow so we can inspect emitted messages after
|
||||
let mock_prompts_handle = mock_prompts.clone();
|
||||
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||
let result: Result<(), Error> = workflow.run().await;
|
||||
|
||||
// Should succeed after the retry
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Workflow should succeed after retry, got: {:?}",
|
||||
result
|
||||
);
|
||||
|
||||
// Error messages about the line being too long must have been emitted
|
||||
// (via emit_message, not bare println) during the first iteration
|
||||
let messages = mock_prompts_handle.emitted_messages();
|
||||
assert!(
|
||||
messages.iter().any(|m| m.contains("too long")),
|
||||
"Expected a 'too long' message, got: {:?}",
|
||||
messages
|
||||
);
|
||||
assert!(
|
||||
messages.iter().any(|m| m.contains("72")),
|
||||
"Expected a message about the 72-char limit, got: {:?}",
|
||||
messages
|
||||
);
|
||||
}
|
||||
|
||||
/// Test workflow with invalid scope
|
||||
#[tokio::test]
|
||||
async fn test_workflow_invalid_scope() {
|
||||
let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
|
||||
|
||||
// Create mock prompts that would return invalid scope
|
||||
let mock_prompts = MockPrompts::new()
|
||||
.with_commit_type(CommitType::Docs)
|
||||
.with_error(Error::InvalidScope(
|
||||
"Invalid characters in scope".to_string(),
|
||||
));
|
||||
|
||||
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||
let result: Result<(), Error> = workflow.run().await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), Error::InvalidScope(_)));
|
||||
}
|
||||
|
||||
/// Test workflow with invalid description
|
||||
#[tokio::test]
|
||||
async fn test_workflow_invalid_description() {
|
||||
let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
|
||||
|
||||
let mock_prompts = MockPrompts::new()
|
||||
.with_commit_type(CommitType::Refactor)
|
||||
.with_scope(Scope::empty())
|
||||
.with_error(Error::InvalidDescription(
|
||||
"Description cannot be empty".to_string(),
|
||||
));
|
||||
|
||||
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||
let result: Result<(), Error> = workflow.run().await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), Error::InvalidDescription(_)));
|
||||
}
|
||||
|
||||
/// Test that mock prompts track method calls correctly
|
||||
#[tokio::test]
|
||||
async fn test_mock_prompts_track_calls() {
|
||||
let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
|
||||
let mock_prompts = MockPrompts::new()
|
||||
.with_commit_type(CommitType::Feat)
|
||||
.with_scope(Scope::empty())
|
||||
.with_description(Description::parse("test").unwrap())
|
||||
.with_confirm(true);
|
||||
|
||||
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||
// We don't need to run the full workflow, just verify the mock was created correctly
|
||||
assert!(matches!(workflow, CommitWorkflow { .. }));
|
||||
}
|
||||
|
||||
/// Test workflow with all commit types
|
||||
#[tokio::test]
|
||||
async fn test_all_commit_types() {
|
||||
let _mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
|
||||
|
||||
for commit_type in CommitType::all() {
|
||||
let mock_prompts = MockPrompts::new()
|
||||
.with_commit_type(*commit_type)
|
||||
.with_scope(Scope::empty())
|
||||
.with_description(Description::parse("test").unwrap())
|
||||
.with_confirm(true);
|
||||
|
||||
let workflow = CommitWorkflow::with_prompts(
|
||||
MockJjExecutor::new().with_is_repo_response(Ok(true)),
|
||||
mock_prompts,
|
||||
);
|
||||
let result: Result<(), Error> = workflow.run().await;
|
||||
assert!(result.is_ok(), "Failed for commit type: {:?}", commit_type);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test workflow with various scope formats
|
||||
#[tokio::test]
|
||||
async fn test_various_scope_formats() {
|
||||
let _mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
|
||||
|
||||
// Test empty scope
|
||||
let mock_prompts = MockPrompts::new()
|
||||
.with_commit_type(CommitType::Feat)
|
||||
.with_scope(Scope::empty())
|
||||
.with_description(Description::parse("test").unwrap())
|
||||
.with_confirm(true);
|
||||
|
||||
let workflow = CommitWorkflow::with_prompts(
|
||||
MockJjExecutor::new().with_is_repo_response(Ok(true)),
|
||||
mock_prompts,
|
||||
);
|
||||
{
|
||||
let result: Result<(), Error> = workflow.run().await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
// Test valid scope
|
||||
let mock_prompts = MockPrompts::new()
|
||||
.with_commit_type(CommitType::Feat)
|
||||
.with_scope(Scope::parse("api").unwrap())
|
||||
.with_description(Description::parse("test").unwrap())
|
||||
.with_confirm(true);
|
||||
|
||||
let workflow = CommitWorkflow::with_prompts(
|
||||
MockJjExecutor::new().with_is_repo_response(Ok(true)),
|
||||
mock_prompts,
|
||||
);
|
||||
{
|
||||
let result: Result<(), Error> = workflow.run().await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
/// Test that workflow can be used with trait objects for both executor and prompts
|
||||
#[test]
|
||||
fn workflow_works_with_trait_objects() {
|
||||
let mock_executor = MockJjExecutor::new();
|
||||
let mock_prompts = MockPrompts::new()
|
||||
.with_commit_type(CommitType::Feat)
|
||||
.with_scope(Scope::empty())
|
||||
.with_description(Description::parse("test").unwrap())
|
||||
.with_confirm(true);
|
||||
|
||||
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||
assert!(matches!(workflow, CommitWorkflow { .. }));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user