Skip to main content

jj_cz/prompts/
workflow.rs

1//! Interactive commit workflow orchestration
2//!
3//! This module provides the CommitWorkflow struct that guides users through
4//! creating a conventional commit message using interactive prompts.
5
6use crate::{
7    commit::types::{
8        Body, BreakingChange, CommitMessageError, CommitType, ConventionalCommit, Description,
9        References, Scope,
10    },
11    error::Error,
12    jj::JjExecutor,
13    prompts::prompter::{Prompter, RealPrompts},
14};
15
16/// Orchestrates the interactive commit workflow
17///
18/// This struct handles the complete user interaction flow:
19/// 1. Check if we're in a jj repository
20/// 2. Select commit type from 11 options
21/// 3. Optionally input scope (validated)
22/// 4. Input required description (validated)
23/// 5. Preview formatted message and confirm
24/// 6. Apply the message to the current change
25///
26/// Uses dependency injection for prompts to enable testing without TUI.
27#[derive(Debug)]
28pub struct CommitWorkflow<J: JjExecutor, P: Prompter = RealPrompts> {
29    executor: J,
30    prompts: P,
31}
32
33impl<J: JjExecutor> CommitWorkflow<J> {
34    /// Create a new CommitWorkflow with the given executor
35    ///
36    /// Uses RealPrompts by default for interactive TUI prompts.
37    pub fn new(executor: J) -> Self {
38        Self::with_prompts(executor, RealPrompts)
39    }
40}
41
42impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
43    /// Create a new CommitWorkflow with custom prompts
44    ///
45    /// This allows using MockPrompts in tests to avoid TUI hanging.
46    pub fn with_prompts(executor: J, prompts: P) -> Self {
47        Self { executor, prompts }
48    }
49
50    /// Run the complete interactive workflow
51    ///
52    /// Returns Ok(()) on successful completion, or an error if:
53    /// - Not in a jj repository
54    /// - User cancels the workflow
55    /// - Repository operation fails
56    /// - Message validation fails
57    pub async fn run_for_revset(&self, revset: &str) -> Result<(), Error> {
58        if !self.executor.is_repository().await? {
59            return Err(Error::NotARepository);
60        }
61        // For future reference
62        let _existing_desc = self.executor.get_description(revset).await.ok();
63        let commit_type = self.type_selection()?;
64        loop {
65            let scope = self.scope_input()?;
66            let description = self.description_input()?;
67            let breaking_change = self.breaking_change_input()?;
68            let references = self.references_input()?;
69            let body = self.body_input()?;
70            match self.preview_and_confirm(
71                commit_type,
72                scope,
73                description,
74                breaking_change,
75                body,
76                references,
77            ) {
78                Ok(conventional_commit) => {
79                    self.executor
80                        .describe(revset, &conventional_commit.to_string())
81                        .await?;
82                    return Ok(());
83                }
84                Err(Error::InvalidCommitMessage(_)) => {
85                    // The scope/description combination exceeds 72 characters.
86                    // The user has already been shown the error via emit_message.
87                    // Loop back to re-prompt scope and description (type is kept).
88                    continue;
89                }
90                Err(e) => return Err(e),
91            }
92        }
93    }
94
95    /// Prompt user to select a commit type from the 11 available options
96    fn type_selection(&self) -> Result<CommitType, Error> {
97        self.prompts.select_commit_type()
98    }
99
100    /// Prompt user to input an optional scope
101    ///
102    /// Returns Ok(Scope) with the validated scope, or
103    /// Error::Cancelled if user cancels
104    fn scope_input(&self) -> Result<Scope, Error> {
105        self.prompts.input_scope()
106    }
107
108    /// Prompt user to input a required description
109    ///
110    /// Returns Ok(Description) with the validated description, or
111    /// Error::Cancelled if user cancels
112    fn description_input(&self) -> Result<Description, Error> {
113        self.prompts.input_description()
114    }
115
116    /// Prompt user for breaking change
117    ///
118    /// Returns Ok(BreakingChange) with the validated breaking change,
119    /// or Error::Cancel if user cancels
120    fn breaking_change_input(&self) -> Result<BreakingChange, Error> {
121        self.prompts.input_breaking_change()
122    }
123
124    /// Prompt user for references
125    fn references_input(&self) -> Result<References, Error> {
126        self.prompts.input_references()
127    }
128
129    /// Prompt user to optionally add a free-form body via an external editor
130    fn body_input(&self) -> Result<Body, Error> {
131        self.prompts.input_body()
132    }
133
134    /// Preview the formatted conventional commit message and get user confirmation
135    ///
136    /// This method also validates that the complete first line
137    /// doesn't exceed 72 characters
138    fn preview_and_confirm(
139        &self,
140        commit_type: CommitType,
141        scope: Scope,
142        description: Description,
143        breaking_change: BreakingChange,
144        body: Body,
145        references: References,
146    ) -> Result<ConventionalCommit, Error> {
147        // Format the message for preview
148        let message = ConventionalCommit::format_preview(
149            commit_type,
150            &scope,
151            &description,
152            &breaking_change,
153            &body,
154            &references,
155        );
156
157        // Try to build the conventional commit (this validates the 72-char limit)
158        let conventional_commit: ConventionalCommit = match ConventionalCommit::new(
159            commit_type,
160            scope.clone(),
161            description.clone(),
162            breaking_change,
163            body,
164            references,
165        ) {
166            Ok(cc) => cc,
167            Err(CommitMessageError::FirstLineTooLong { actual, max }) => {
168                self.prompts.emit_message("❌ Message too long!");
169                self.prompts.emit_message(&format!(
170                    "The complete first line must be ≤ {} characters.",
171                    max
172                ));
173                self.prompts
174                    .emit_message(&format!("Current length: {} characters", actual));
175                self.prompts.emit_message("");
176                self.prompts.emit_message("Formatted message would be:");
177                self.prompts.emit_message(&message);
178                self.prompts.emit_message("");
179                self.prompts
180                    .emit_message("Please try again with a shorter scope or description.");
181                return Err(Error::InvalidCommitMessage(format!(
182                    "First line too long: {} > {}",
183                    actual, max
184                )));
185            }
186            Err(CommitMessageError::InvalidConventionalFormat { reason }) => {
187                return Err(Error::InvalidCommitMessage(format!(
188                    "Internal error: generated message failed conventional commit validation: {}",
189                    reason
190                )));
191            }
192        };
193
194        // Get confirmation from user
195        let confirmed = self.prompts.confirm_apply(&message)?;
196
197        if confirmed {
198            Ok(conventional_commit)
199        } else {
200            Err(Error::Cancelled)
201        }
202    }
203
204    pub async fn new_revision(&self, revset: &str) -> Result<(), Error> {
205        self.executor.new_revision(revset).await
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::error::Error;
213    use crate::jj::mock::MockJjExecutor;
214    use crate::prompts::mock::MockPrompts;
215
216    /// Test that CommitWorkflow can be created with a mock executor
217    #[test]
218    fn workflow_creation() {
219        let mock = MockJjExecutor::new();
220        let workflow = CommitWorkflow::new(mock);
221        // If this compiles, the workflow is properly typed
222        assert!(matches!(workflow, CommitWorkflow { .. }));
223    }
224
225    /// Test workflow returns NotARepository when is_repository() returns false
226    #[tokio::test]
227    async fn workflow_returns_not_a_repository() {
228        let mock = MockJjExecutor::new().with_is_repo_response(Ok(false));
229        let workflow = CommitWorkflow::new(mock);
230        let result: Result<(), Error> = workflow.run_for_revset("@").await;
231        assert!(result.is_err());
232        assert!(matches!(result.unwrap_err(), Error::NotARepository));
233    }
234
235    /// Test workflow returns NotARepository when is_repository() returns error
236    #[tokio::test]
237    async fn workflow_returns_repository_error() {
238        let mock = MockJjExecutor::new().with_is_repo_response(Err(Error::NotARepository));
239        let workflow = CommitWorkflow::new(mock);
240        let result: Result<(), Error> = workflow.run_for_revset("@").await;
241        assert!(result.is_err());
242        assert!(matches!(result.unwrap_err(), Error::NotARepository));
243    }
244
245    /// Test that type_selection returns a valid CommitType
246    #[test]
247    fn type_selection_returns_valid_type() {
248        // Updated to use mock prompts to avoid TUI hanging
249        let mock_executor = MockJjExecutor::new();
250        let mock_prompts = MockPrompts::new().with_commit_type(CommitType::Feat);
251        let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
252
253        // Now we can actually test the method with mock prompts
254        let result = workflow.type_selection();
255        assert!(result.is_ok());
256        assert_eq!(result.unwrap(), CommitType::Feat);
257    }
258
259    /// Test that scope_input returns a valid Scope
260    #[test]
261    fn scope_input_returns_valid_scope() {
262        let mock_executor = MockJjExecutor::new();
263        let mock_prompts = MockPrompts::new().with_scope(Scope::parse("test").unwrap());
264        let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
265
266        let result = workflow.scope_input();
267        assert!(result.is_ok());
268        assert_eq!(result.unwrap(), Scope::parse("test").unwrap());
269    }
270
271    /// Test that description_input returns a valid Description
272    #[test]
273    fn description_input_returns_valid_description() {
274        let mock_executor = MockJjExecutor::new();
275        let mock_prompts = MockPrompts::new().with_description(Description::parse("test").unwrap());
276        let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
277
278        let result = workflow.description_input();
279        assert!(result.is_ok());
280        assert_eq!(result.unwrap(), Description::parse("test").unwrap());
281    }
282
283    /// Test that preview_and_confirm returns a ConventionalCommit
284    #[test]
285    fn preview_and_confirm_returns_conventional_commit() {
286        let mock_executor = MockJjExecutor::new();
287        let mock_prompts = MockPrompts::new().with_confirm(true);
288        let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
289
290        let commit_type = CommitType::Feat;
291        let scope = Scope::empty();
292        let description = Description::parse("test description").unwrap();
293        let breaking_change = BreakingChange::No;
294        let body = Body::default();
295        let references = References::default();
296        let result = workflow.preview_and_confirm(
297            commit_type,
298            scope,
299            description,
300            breaking_change,
301            body,
302            references,
303        );
304        assert!(result.is_ok());
305    }
306
307    /// Test workflow error handling for describe failure
308    #[tokio::test]
309    async fn workflow_handles_describe_error() {
310        // Test the mock executor methods directly
311        let mock = MockJjExecutor::new()
312            .with_is_repo_response(Ok(true))
313            .with_describe_response(Err(Error::RepositoryLocked));
314
315        // Verify the mock behaves as expected
316        assert!(mock.is_repository().await.is_ok());
317        assert!(mock.describe("@", "test").await.is_err());
318
319        // Also test with a working mock
320        let working_mock = MockJjExecutor::new();
321        let workflow = CommitWorkflow::new(working_mock);
322        // We can't complete the full workflow without mocking prompts,
323        // but we can verify the workflow was created successfully
324        assert!(matches!(workflow, CommitWorkflow { .. }));
325    }
326
327    /// Test that workflow implements Debug trait
328    #[test]
329    fn workflow_implements_debug() {
330        let mock = MockJjExecutor::new();
331        let workflow = CommitWorkflow::new(mock);
332        let debug_output = format!("{:?}", workflow);
333        assert!(debug_output.contains("CommitWorkflow"));
334    }
335
336    /// Test complete workflow with mock prompts (happy path)
337    #[tokio::test]
338    async fn test_complete_workflow_happy_path() {
339        // Create mock executor that returns true for is_repository
340        let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
341
342        // Create mock prompts with successful responses
343        let mock_prompts = MockPrompts::new()
344            .with_commit_type(CommitType::Feat)
345            .with_scope(Scope::empty())
346            .with_description(Description::parse("add new feature").unwrap())
347            .with_breaking_change(BreakingChange::Yes)
348            .with_references(References::default())
349            .with_body(Body::default())
350            .with_confirm(true);
351
352        // Create workflow with both mocks
353        let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
354
355        // Run the workflow - should succeed
356        let result: Result<(), Error> = workflow.run_for_revset("@").await;
357        assert!(result.is_ok());
358    }
359
360    /// Test workflow cancellation at type selection
361    #[tokio::test]
362    async fn test_workflow_cancellation_at_type_selection() {
363        let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
364        let mock_prompts = MockPrompts::new().with_error(Error::Cancelled);
365
366        let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
367        let result: Result<(), Error> = workflow.run_for_revset("@").await;
368
369        assert!(result.is_err());
370        assert!(matches!(result.unwrap_err(), Error::Cancelled));
371    }
372
373    /// Test workflow cancellation at confirmation
374    #[tokio::test]
375    async fn test_workflow_cancellation_at_confirmation() {
376        let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
377        let mock_prompts = MockPrompts::new()
378            .with_commit_type(CommitType::Fix)
379            .with_scope(Scope::parse("api").unwrap())
380            .with_description(Description::parse("fix bug").unwrap())
381            .with_breaking_change(BreakingChange::No)
382            .with_references(References::default())
383            .with_body(Body::default())
384            .with_confirm(false); // User cancels at confirmation
385
386        let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
387        let result: Result<(), Error> = workflow.run_for_revset("@").await;
388
389        assert!(result.is_err());
390        assert!(matches!(result.unwrap_err(), Error::Cancelled));
391    }
392
393    /// Test workflow loops back on line length error, re-prompting scope and description
394    ///
395    /// "feat(very-long-scope-name): " + 45 'a's = 4+1+20+3+45 = 73 chars → too long (first pass)
396    /// "feat: short description" = 4+2+17 = 23 chars → fine (second pass)
397    #[tokio::test]
398    async fn test_workflow_line_length_validation() {
399        let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
400
401        let mock_prompts = MockPrompts::new()
402            .with_commit_type(CommitType::Feat)
403            // First iteration: scope + description exceed 72 chars combined
404            .with_scope(Scope::parse("very-long-scope-name").unwrap())
405            .with_description(Description::parse("a".repeat(45)).unwrap())
406            .with_breaking_change(BreakingChange::No)
407            .with_references(References::default())
408            .with_body(Body::default())
409            // Second iteration: short enough to succeed
410            .with_scope(Scope::empty())
411            .with_description(Description::parse("short description").unwrap())
412            .with_breaking_change(BreakingChange::No)
413            .with_references(References::default())
414            .with_body(Body::default())
415            .with_confirm(true);
416
417        // Clone before moving into workflow so we can inspect emitted messages after
418        let mock_prompts_handle = mock_prompts.clone();
419        let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
420        let result: Result<(), Error> = workflow.run_for_revset("@").await;
421
422        // Should succeed after the retry
423        assert!(
424            result.is_ok(),
425            "Workflow should succeed after retry, got: {:?}",
426            result
427        );
428
429        // Error messages about the line being too long must have been emitted
430        // (via emit_message, not bare println) during the first iteration
431        let messages = mock_prompts_handle.emitted_messages();
432        assert!(
433            messages.iter().any(|m| m.contains("too long")),
434            "Expected a 'too long' message, got: {:?}",
435            messages
436        );
437        assert!(
438            messages.iter().any(|m| m.contains("72")),
439            "Expected a message about the 72-char limit, got: {:?}",
440            messages
441        );
442    }
443
444    /// Test workflow with invalid scope
445    #[tokio::test]
446    async fn test_workflow_invalid_scope() {
447        let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
448
449        // Create mock prompts that would return invalid scope
450        let mock_prompts = MockPrompts::new()
451            .with_commit_type(CommitType::Docs)
452            .with_error(Error::InvalidScope(
453                "Invalid characters in scope".to_string(),
454            ));
455
456        let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
457        let result: Result<(), Error> = workflow.run_for_revset("@").await;
458
459        assert!(result.is_err());
460        assert!(matches!(result.unwrap_err(), Error::InvalidScope(_)));
461    }
462
463    /// Test workflow with invalid description
464    #[tokio::test]
465    async fn test_workflow_invalid_description() {
466        let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
467
468        let mock_prompts = MockPrompts::new()
469            .with_commit_type(CommitType::Refactor)
470            .with_scope(Scope::empty())
471            .with_error(Error::InvalidDescription(
472                "Description cannot be empty".to_string(),
473            ));
474
475        let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
476        let result: Result<(), Error> = workflow.run_for_revset("@").await;
477
478        assert!(result.is_err());
479        assert!(matches!(result.unwrap_err(), Error::InvalidDescription(_)));
480    }
481
482    /// Test that mock prompts track method calls correctly
483    #[test]
484    fn test_mock_prompts_track_calls() {
485        let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
486        let mock_prompts = MockPrompts::new()
487            .with_commit_type(CommitType::Feat)
488            .with_scope(Scope::empty())
489            .with_description(Description::parse("test").unwrap())
490            .with_confirm(true);
491
492        let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
493        // We don't need to run the full workflow, just verify the mock was created correctly
494        assert!(matches!(workflow, CommitWorkflow { .. }));
495    }
496
497    /// Test workflow with all commit types
498    #[tokio::test]
499    async fn test_all_commit_types() {
500        let _mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
501
502        for commit_type in CommitType::all() {
503            let mock_prompts = MockPrompts::new()
504                .with_commit_type(*commit_type)
505                .with_scope(Scope::empty())
506                .with_description(Description::parse("test").unwrap())
507                .with_breaking_change(BreakingChange::Yes)
508                .with_references(References::default())
509                .with_body(Body::default())
510                .with_confirm(true);
511
512            let workflow = CommitWorkflow::with_prompts(
513                MockJjExecutor::new().with_is_repo_response(Ok(true)),
514                mock_prompts,
515            );
516            let result: Result<(), Error> = workflow.run_for_revset("@").await;
517            assert!(result.is_ok(), "Failed for commit type: {:?}", commit_type);
518        }
519    }
520
521    /// Test workflow with various scope formats
522    #[tokio::test]
523    async fn test_various_scope_formats() {
524        let _mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
525
526        // Test empty scope
527        let mock_prompts = MockPrompts::new()
528            .with_commit_type(CommitType::Feat)
529            .with_scope(Scope::empty())
530            .with_description(Description::parse("test").unwrap())
531            .with_breaking_change(BreakingChange::Yes)
532            .with_references(References::default())
533            .with_body(Body::default())
534            .with_confirm(true);
535
536        let workflow = CommitWorkflow::with_prompts(
537            MockJjExecutor::new().with_is_repo_response(Ok(true)),
538            mock_prompts,
539        );
540        {
541            let result: Result<(), Error> = workflow.run_for_revset("@").await;
542            assert!(result.is_ok());
543        }
544
545        // Test valid scope
546        let mock_prompts = MockPrompts::new()
547            .with_commit_type(CommitType::Feat)
548            .with_scope(Scope::parse("api").unwrap())
549            .with_description(Description::parse("test").unwrap())
550            .with_breaking_change(BreakingChange::No)
551            .with_references(References::default())
552            .with_body(Body::default())
553            .with_confirm(true);
554
555        let workflow = CommitWorkflow::with_prompts(
556            MockJjExecutor::new().with_is_repo_response(Ok(true)),
557            mock_prompts,
558        );
559        {
560            let result: Result<(), Error> = workflow.run_for_revset("@").await;
561            assert!(result.is_ok());
562        }
563    }
564
565    /// Test that workflow can be used with trait objects for both executor and prompts
566    #[test]
567    fn workflow_works_with_trait_objects() {
568        let mock_executor = MockJjExecutor::new();
569        let mock_prompts = MockPrompts::new()
570            .with_commit_type(CommitType::Feat)
571            .with_scope(Scope::empty())
572            .with_description(Description::parse("test").unwrap())
573            .with_confirm(true);
574
575        let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
576        assert!(matches!(workflow, CommitWorkflow { .. }));
577    }
578
579    /// Preview_and_confirm must forward BreakingChange::Yes to
580    /// ConventionalCommit::new(), producing a commit whose string
581    /// contains '!'.
582    ///
583    /// Before the fix the parameter was ignored and
584    /// BreakingChange::No was hard-coded, so a confirmed
585    /// breaking-change commit was silently applied without the '!'
586    /// marker.
587    #[test]
588    fn preview_and_confirm_forwards_breaking_change_yes() {
589        let mock_executor = MockJjExecutor::new();
590        let mock_prompts = MockPrompts::new().with_confirm(true);
591        let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
592
593        let result = workflow.preview_and_confirm(
594            CommitType::Feat,
595            Scope::empty(),
596            Description::parse("remove old API").unwrap(),
597            BreakingChange::Yes,
598            Body::default(),
599            References::default(),
600        );
601
602        assert!(result.is_ok(), "expected Ok, got: {:?}", result);
603        let message = result.unwrap().to_string();
604        assert!(
605            message.contains("feat!:"),
606            "expected '!' marker in described message, got: {:?}",
607            message,
608        );
609    }
610
611    /// Preview_and_confirm must forward BreakingChange::WithNote,
612    /// producing a commit with both the '!' header marker and the
613    /// BREAKING CHANGE footer.
614    #[test]
615    fn preview_and_confirm_forwards_breaking_change_with_note() {
616        let mock_executor = MockJjExecutor::new();
617        let mock_prompts = MockPrompts::new().with_confirm(true);
618        let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
619
620        let breaking_change: BreakingChange = "removes legacy endpoint".into();
621        let result = workflow.preview_and_confirm(
622            CommitType::Feat,
623            Scope::empty(),
624            Description::parse("drop legacy API").unwrap(),
625            breaking_change,
626            Body::default(),
627            References::default(),
628        );
629
630        assert!(result.is_ok(), "expected Ok, got: {:?}", result);
631        let message = result.unwrap().to_string();
632        assert!(
633            message.contains("feat!:"),
634            "expected '!' header marker in message, got: {:?}",
635            message,
636        );
637        assert!(
638            message.contains("BREAKING CHANGE:"),
639            "expected BREAKING CHANGE footer in message, got: {:?}",
640            message,
641        );
642    }
643
644    /// The message passed to executor.describe() must include the '!'
645    /// marker when the user selects a breaking change.
646    ///
647    /// This test exercises the full run() path and inspects what was
648    /// actually handed to the jj executor, which is the authoritative
649    /// check that the described commit is correct.
650    #[tokio::test]
651    async fn full_workflow_describes_commit_with_breaking_change_marker() {
652        let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
653        let mock_prompts = MockPrompts::new()
654            .with_commit_type(CommitType::Feat)
655            .with_scope(Scope::empty())
656            .with_description(Description::parse("remove old API").unwrap())
657            .with_breaking_change(BreakingChange::Yes)
658            .with_references(References::default())
659            .with_body(Body::default())
660            .with_confirm(true);
661
662        let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
663        let result: Result<(), Error> = workflow.run_for_revset("@").await;
664
665        assert!(
666            result.is_ok(),
667            "expected workflow to succeed, got: {:?}",
668            result
669        );
670
671        let messages = workflow.executor.describe_messages();
672        assert_eq!(messages.len(), 1, "expected exactly one describe() call");
673        assert!(
674            messages[0].contains("feat!:"),
675            "expected '!' marker in the described message, got: {:?}",
676            messages[0],
677        );
678    }
679
680    // --- Body tests ---
681    // preview_and_confirm() tests compile now but will fail until the Body::default()
682    // at line 138 of preview_and_confirm() is replaced with the `body` parameter.
683    // The full_workflow_* tests additionally require MockPrompts::with_body().
684
685    /// preview_and_confirm must forward the body to ConventionalCommit::new()
686    ///
687    /// Currently the implementation passes Body::default() instead of the
688    /// received body, so this test will fail until that is fixed.
689    #[test]
690    fn preview_and_confirm_forwards_body() {
691        let mock_executor = MockJjExecutor::new();
692        let mock_prompts = MockPrompts::new().with_confirm(true);
693        let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
694
695        let result = workflow.preview_and_confirm(
696            CommitType::Feat,
697            Scope::empty(),
698            Description::parse("add feature").unwrap(),
699            BreakingChange::No,
700            Body::from("This explains the change."),
701            References::default(),
702        );
703
704        assert!(result.is_ok(), "expected Ok, got: {:?}", result);
705        assert!(
706            result
707                .unwrap()
708                .to_string()
709                .contains("This explains the change."),
710            "body must appear in the commit message"
711        );
712    }
713
714    /// preview_and_confirm must forward the body even when a breaking change is present
715    ///
716    /// Expected format: "type!: desc\n\nbody\n\nBREAKING CHANGE: note"
717    #[test]
718    fn preview_and_confirm_forwards_body_with_breaking_change() {
719        let mock_executor = MockJjExecutor::new();
720        let mock_prompts = MockPrompts::new().with_confirm(true);
721        let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
722
723        let result = workflow.preview_and_confirm(
724            CommitType::Feat,
725            Scope::empty(),
726            Description::parse("drop legacy API").unwrap(),
727            "removes legacy endpoint".into(),
728            Body::from("The endpoint was deprecated in v2."),
729            References::default(),
730        );
731
732        assert!(result.is_ok(), "expected Ok, got: {:?}", result);
733        let message = result.unwrap().to_string();
734        assert!(
735            message.contains("The endpoint was deprecated in v2."),
736            "body must appear in the commit message, got: {message:?}"
737        );
738        assert!(
739            message.contains("BREAKING CHANGE: removes legacy endpoint"),
740            "breaking change footer must still be present, got: {message:?}"
741        );
742    }
743
744    /// The full run() workflow must collect a body and include it in the
745    /// described commit.
746    ///
747    /// Requires MockPrompts::with_body() and run() to call body_input().
748    #[tokio::test]
749    async fn full_workflow_describes_commit_with_body() {
750        let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
751        let mock_prompts = MockPrompts::new()
752            .with_commit_type(CommitType::Feat)
753            .with_scope(Scope::empty())
754            .with_description(Description::parse("add feature").unwrap())
755            .with_breaking_change(BreakingChange::No)
756            .with_references(References::default())
757            .with_body(Body::from("This explains the change."))
758            .with_confirm(true);
759
760        let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
761        let result: Result<(), Error> = workflow.run_for_revset("@").await;
762
763        assert!(
764            result.is_ok(),
765            "expected workflow to succeed, got: {:?}",
766            result
767        );
768
769        let messages = workflow.executor.describe_messages();
770        assert_eq!(messages.len(), 1, "expected exactly one describe() call");
771        assert!(
772            messages[0].contains("This explains the change."),
773            "body must appear in the described commit, got: {:?}",
774            messages[0]
775        );
776    }
777
778    /// run() must still work correctly when the user declines to add a body
779    ///
780    /// Requires MockPrompts::with_body() returning Body::default().
781    #[tokio::test]
782    async fn full_workflow_with_no_body_succeeds() {
783        let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
784        let mock_prompts = MockPrompts::new()
785            .with_commit_type(CommitType::Fix)
786            .with_scope(Scope::empty())
787            .with_description(Description::parse("fix crash").unwrap())
788            .with_breaking_change(BreakingChange::No)
789            .with_references(References::default())
790            .with_body(Body::default())
791            .with_confirm(true);
792
793        let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
794        let result: Result<(), Error> = workflow.run_for_revset("@").await;
795
796        assert!(
797            result.is_ok(),
798            "expected workflow to succeed, got: {:?}",
799            result
800        );
801
802        let messages = workflow.executor.describe_messages();
803        assert_eq!(messages.len(), 1);
804        assert_eq!(messages[0], "fix: fix crash");
805    }
806
807    /// Test workflow new_revision() records the revset
808    #[tokio::test]
809    async fn workflow_new_revision_records_revset() {
810        let mock_executor = MockJjExecutor::new();
811        let workflow = CommitWorkflow::new(mock_executor);
812
813        let result = workflow.new_revision("@").await;
814        assert!(result.is_ok());
815
816        let calls = workflow.executor.new_revision_calls();
817        assert_eq!(calls, vec!["@"]);
818    }
819
820    /// Test workflow new_revision() propagates executor errors
821    #[tokio::test]
822    async fn workflow_new_revision_propagates_error() {
823        let mock_executor =
824            MockJjExecutor::new().with_new_revision_response(Err(Error::RepositoryLocked));
825        let workflow = CommitWorkflow::new(mock_executor);
826
827        let result = workflow.new_revision("@").await;
828        assert!(result.is_err());
829        assert!(matches!(result.unwrap_err(), Error::RepositoryLocked));
830    }
831
832    /// Test workflow run_for_revset() followed by new_revision() records both
833    ///
834    /// This mirrors the actual usage pattern in main.rs.
835    #[tokio::test]
836    async fn workflow_describe_then_new_revision() {
837        let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
838        let mock_prompts = MockPrompts::new()
839            .with_commit_type(CommitType::Feat)
840            .with_scope(Scope::empty())
841            .with_description(Description::parse("add feature").unwrap())
842            .with_breaking_change(BreakingChange::No)
843            .with_references(References::default())
844            .with_body(Body::default())
845            .with_confirm(true);
846
847        let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
848
849        workflow.run_for_revset("@").await.expect("describe failed");
850        workflow
851            .new_revision("@")
852            .await
853            .expect("new_revision failed");
854
855        let messages = workflow.executor.describe_messages();
856        assert_eq!(messages.len(), 1);
857        assert!(messages[0].contains("feat:"));
858
859        let calls = workflow.executor.new_revision_calls();
860        assert_eq!(calls, vec!["@"]);
861    }
862}