//! 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 { executor: J, prompts: P, } impl CommitWorkflow { /// 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 CommitWorkflow { /// 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 { 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 { 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 { 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 { // 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 { .. })); } }