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.
513 lines
20 KiB
Rust
513 lines
20 KiB
Rust
//! 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 { .. }));
|
||
}
|
||
}
|