2026-03-07 00:53:13 +01:00
|
|
|
//! Interactive commit workflow orchestration
|
|
|
|
|
//!
|
|
|
|
|
//! This module provides the CommitWorkflow struct that guides users through
|
|
|
|
|
//! creating a conventional commit message using interactive prompts.
|
|
|
|
|
|
|
|
|
|
use crate::{
|
2026-03-09 22:57:35 +01:00
|
|
|
commit::types::{
|
|
|
|
|
BreakingChange, CommitMessageError, CommitType, ConventionalCommit, Description, Scope,
|
|
|
|
|
},
|
2026-03-07 00:53:13 +01:00
|
|
|
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> {
|
|
|
|
|
if !self.executor.is_repository().await? {
|
|
|
|
|
return Err(Error::NotARepository);
|
|
|
|
|
}
|
|
|
|
|
let commit_type = self.type_selection().await?;
|
|
|
|
|
loop {
|
|
|
|
|
let scope = self.scope_input().await?;
|
|
|
|
|
let description = self.description_input().await?;
|
2026-03-09 22:57:35 +01:00
|
|
|
let breaking_change = self.breaking_change_input().await?;
|
2026-03-07 00:53:13 +01:00
|
|
|
match self
|
2026-03-09 22:57:35 +01:00
|
|
|
.preview_and_confirm(commit_type, scope, description, breaking_change)
|
2026-03-07 00:53:13 +01:00
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok(conventional_commit) => {
|
|
|
|
|
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
|
|
|
|
|
///
|
2026-03-09 22:57:35 +01:00
|
|
|
/// Returns Ok(Scope) with the validated scope, or
|
|
|
|
|
/// Error::Cancelled if user cancels
|
2026-03-07 00:53:13 +01:00
|
|
|
async fn scope_input(&self) -> Result<Scope, Error> {
|
|
|
|
|
self.prompts.input_scope()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Prompt user to input a required description
|
|
|
|
|
///
|
2026-03-09 22:57:35 +01:00
|
|
|
/// Returns Ok(Description) with the validated description, or
|
|
|
|
|
/// Error::Cancelled if user cancels
|
2026-03-07 00:53:13 +01:00
|
|
|
async fn description_input(&self) -> Result<Description, Error> {
|
|
|
|
|
self.prompts.input_description()
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 22:57:35 +01:00
|
|
|
/// Prompt user for breaking change
|
|
|
|
|
///
|
|
|
|
|
/// Returns Ok(BreakingChange) with the validated breaking change,
|
|
|
|
|
/// or Error::Cancel if user cancels
|
|
|
|
|
async fn breaking_change_input(&self) -> Result<BreakingChange, Error> {
|
|
|
|
|
self.prompts.input_breaking_change()
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 00:53:13 +01:00
|
|
|
/// Preview the formatted conventional commit message and get user confirmation
|
|
|
|
|
///
|
2026-03-09 22:57:35 +01:00
|
|
|
/// This method also validates that the complete first line
|
|
|
|
|
/// doesn't exceed 72 characters
|
2026-03-07 00:53:13 +01:00
|
|
|
async fn preview_and_confirm(
|
|
|
|
|
&self,
|
|
|
|
|
commit_type: CommitType,
|
|
|
|
|
scope: Scope,
|
|
|
|
|
description: Description,
|
2026-03-09 22:57:35 +01:00
|
|
|
breaking_change: BreakingChange,
|
2026-03-07 00:53:13 +01:00
|
|
|
) -> Result<ConventionalCommit, Error> {
|
|
|
|
|
// Format the message for preview
|
2026-03-09 22:57:35 +01:00
|
|
|
let message =
|
|
|
|
|
ConventionalCommit::format_preview(commit_type, &scope, &description, &breaking_change);
|
2026-03-07 00:53:13 +01:00
|
|
|
|
|
|
|
|
// 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(),
|
2026-03-09 22:57:35 +01:00
|
|
|
breaking_change,
|
2026-03-07 00:53:13 +01:00
|
|
|
) {
|
|
|
|
|
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();
|
2026-03-09 22:57:35 +01:00
|
|
|
let breaking_change = BreakingChange::No;
|
2026-03-07 00:53:13 +01:00
|
|
|
let result = workflow
|
2026-03-09 22:57:35 +01:00
|
|
|
.preview_and_confirm(commit_type, scope, description, breaking_change)
|
2026-03-07 00:53:13 +01:00
|
|
|
.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())
|
2026-03-09 22:57:35 +01:00
|
|
|
.with_breaking_change(BreakingChange::Yes)
|
2026-03-07 00:53:13 +01:00
|
|
|
.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())
|
2026-03-09 22:57:35 +01:00
|
|
|
.with_breaking_change(BreakingChange::No)
|
2026-03-07 00:53:13 +01:00
|
|
|
.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())
|
2026-03-09 22:57:35 +01:00
|
|
|
.with_breaking_change(BreakingChange::No)
|
2026-03-07 00:53:13 +01:00
|
|
|
// Second iteration: short enough to succeed
|
|
|
|
|
.with_scope(Scope::empty())
|
|
|
|
|
.with_description(Description::parse("short description").unwrap())
|
2026-03-09 22:57:35 +01:00
|
|
|
.with_breaking_change(BreakingChange::No)
|
2026-03-07 00:53:13 +01:00
|
|
|
.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())
|
2026-03-09 22:57:35 +01:00
|
|
|
.with_breaking_change(BreakingChange::Yes)
|
2026-03-07 00:53:13 +01:00
|
|
|
.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())
|
2026-03-09 22:57:35 +01:00
|
|
|
.with_breaking_change(BreakingChange::Yes)
|
2026-03-07 00:53:13 +01:00
|
|
|
.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())
|
2026-03-09 22:57:35 +01:00
|
|
|
.with_breaking_change(BreakingChange::No)
|
2026-03-07 00:53:13 +01:00
|
|
|
.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 { .. }));
|
|
|
|
|
}
|
2026-03-09 22:57:35 +01:00
|
|
|
|
|
|
|
|
/// Preview_and_confirm must forward BreakingChange::Yes to
|
|
|
|
|
/// ConventionalCommit::new(), producing a commit whose string
|
|
|
|
|
/// contains '!'.
|
|
|
|
|
///
|
|
|
|
|
/// Before the fix the parameter was ignored and
|
|
|
|
|
/// BreakingChange::No was hard-coded, so a confirmed
|
|
|
|
|
/// breaking-change commit was silently applied without the '!'
|
|
|
|
|
/// marker.
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn preview_and_confirm_forwards_breaking_change_yes() {
|
|
|
|
|
let mock_executor = MockJjExecutor::new();
|
|
|
|
|
let mock_prompts = MockPrompts::new().with_confirm(true);
|
|
|
|
|
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
|
|
|
|
|
|
|
|
|
let result = workflow
|
|
|
|
|
.preview_and_confirm(
|
|
|
|
|
CommitType::Feat,
|
|
|
|
|
Scope::empty(),
|
|
|
|
|
Description::parse("remove old API").unwrap(),
|
|
|
|
|
BreakingChange::Yes,
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
assert!(result.is_ok(), "expected Ok, got: {:?}", result);
|
|
|
|
|
let message = result.unwrap().to_string();
|
|
|
|
|
assert!(
|
|
|
|
|
message.contains("feat!:"),
|
|
|
|
|
"expected '!' marker in described message, got: {:?}",
|
|
|
|
|
message,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Preview_and_confirm must forward BreakingChange::WithNote,
|
|
|
|
|
/// producing a commit with both the '!' header marker and the
|
|
|
|
|
/// BREAKING CHANGE footer.
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn preview_and_confirm_forwards_breaking_change_with_note() {
|
|
|
|
|
let mock_executor = MockJjExecutor::new();
|
|
|
|
|
let mock_prompts = MockPrompts::new().with_confirm(true);
|
|
|
|
|
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
|
|
|
|
|
|
|
|
|
let breaking_change: BreakingChange = "removes legacy endpoint".into();
|
|
|
|
|
let result = workflow
|
|
|
|
|
.preview_and_confirm(
|
|
|
|
|
CommitType::Feat,
|
|
|
|
|
Scope::empty(),
|
|
|
|
|
Description::parse("drop legacy API").unwrap(),
|
|
|
|
|
breaking_change,
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
assert!(result.is_ok(), "expected Ok, got: {:?}", result);
|
|
|
|
|
let message = result.unwrap().to_string();
|
|
|
|
|
assert!(
|
|
|
|
|
message.contains("feat!:"),
|
|
|
|
|
"expected '!' header marker in message, got: {:?}",
|
|
|
|
|
message,
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
message.contains("BREAKING CHANGE:"),
|
|
|
|
|
"expected BREAKING CHANGE footer in message, got: {:?}",
|
|
|
|
|
message,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The message passed to executor.describe() must include the '!'
|
|
|
|
|
/// marker when the user selects a breaking change.
|
|
|
|
|
///
|
|
|
|
|
/// This test exercises the full run() path and inspects what was
|
|
|
|
|
/// actually handed to the jj executor, which is the authoritative
|
|
|
|
|
/// check that the described commit is correct.
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn full_workflow_describes_commit_with_breaking_change_marker() {
|
|
|
|
|
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("remove old API").unwrap())
|
|
|
|
|
.with_breaking_change(BreakingChange::Yes)
|
|
|
|
|
.with_confirm(true);
|
|
|
|
|
|
|
|
|
|
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
|
|
|
|
let result: Result<(), Error> = workflow.run().await;
|
|
|
|
|
|
|
|
|
|
assert!(result.is_ok(), "expected workflow to succeed, got: {:?}", result);
|
|
|
|
|
|
|
|
|
|
let messages = workflow.executor.describe_messages();
|
|
|
|
|
assert_eq!(messages.len(), 1, "expected exactly one describe() call");
|
|
|
|
|
assert!(
|
|
|
|
|
messages[0].contains("feat!:"),
|
|
|
|
|
"expected '!' marker in the described message, got: {:?}",
|
|
|
|
|
messages[0],
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-03-07 00:53:13 +01:00
|
|
|
}
|