use crate::{
commit::types::{
Body, BreakingChange, CommitMessageError, CommitType, ConventionalCommit, Description,
References, Scope,
},
error::Error,
jj::JjExecutor,
prompts::prompter::{Prompter, RealPrompts},
};
#[derive(Debug)]
pub struct CommitWorkflow<J: JjExecutor, P: Prompter = RealPrompts> {
executor: J,
prompts: P,
}
impl<J: JjExecutor> CommitWorkflow<J> {
pub fn new(executor: J) -> Self {
Self::with_prompts(executor, RealPrompts)
}
}
impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
pub fn with_prompts(executor: J, prompts: P) -> Self {
Self { executor, prompts }
}
pub async fn run_for_revset(&self, revset: &str) -> Result<(), Error> {
if !self.executor.is_repository().await? {
return Err(Error::NotARepository);
}
let _existing_desc = self.executor.get_description(revset).await.ok();
let commit_type = self.type_selection()?;
loop {
let scope = self.scope_input()?;
let description = self.description_input()?;
let breaking_change = self.breaking_change_input()?;
let references = self.references_input()?;
let body = self.body_input()?;
match self.preview_and_confirm(
commit_type,
scope,
description,
breaking_change,
body,
references,
) {
Ok(conventional_commit) => {
self.executor
.describe(revset, &conventional_commit.to_string())
.await?;
return Ok(());
}
Err(Error::InvalidCommitMessage(_)) => {
continue;
}
Err(e) => return Err(e),
}
}
}
fn type_selection(&self) -> Result<CommitType, Error> {
self.prompts.select_commit_type()
}
fn scope_input(&self) -> Result<Scope, Error> {
self.prompts.input_scope()
}
fn description_input(&self) -> Result<Description, Error> {
self.prompts.input_description()
}
fn breaking_change_input(&self) -> Result<BreakingChange, Error> {
self.prompts.input_breaking_change()
}
fn references_input(&self) -> Result<References, Error> {
self.prompts.input_references()
}
fn body_input(&self) -> Result<Body, Error> {
self.prompts.input_body()
}
fn preview_and_confirm(
&self,
commit_type: CommitType,
scope: Scope,
description: Description,
breaking_change: BreakingChange,
body: Body,
references: References,
) -> Result<ConventionalCommit, Error> {
let message = ConventionalCommit::format_preview(
commit_type,
&scope,
&description,
&breaking_change,
&body,
&references,
);
let conventional_commit: ConventionalCommit = match ConventionalCommit::new(
commit_type,
scope.clone(),
description.clone(),
breaking_change,
body,
references,
) {
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
)));
}
};
let confirmed = self.prompts.confirm_apply(&message)?;
if confirmed {
Ok(conventional_commit)
} else {
Err(Error::Cancelled)
}
}
pub async fn new_revision(&self, revset: &str) -> Result<(), Error> {
self.executor.new_revision(revset).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
use crate::jj::mock::MockJjExecutor;
use crate::prompts::mock::MockPrompts;
#[test]
fn workflow_creation() {
let mock = MockJjExecutor::new();
let workflow = CommitWorkflow::new(mock);
assert!(matches!(workflow, CommitWorkflow { .. }));
}
#[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_for_revset("@").await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::NotARepository));
}
#[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_for_revset("@").await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::NotARepository));
}
#[test]
fn type_selection_returns_valid_type() {
let mock_executor = MockJjExecutor::new();
let mock_prompts = MockPrompts::new().with_commit_type(CommitType::Feat);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result = workflow.type_selection();
assert!(result.is_ok());
assert_eq!(result.unwrap(), CommitType::Feat);
}
#[test]
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();
assert!(result.is_ok());
assert_eq!(result.unwrap(), Scope::parse("test").unwrap());
}
#[test]
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();
assert!(result.is_ok());
assert_eq!(result.unwrap(), Description::parse("test").unwrap());
}
#[test]
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 breaking_change = BreakingChange::No;
let body = Body::default();
let references = References::default();
let result = workflow.preview_and_confirm(
commit_type,
scope,
description,
breaking_change,
body,
references,
);
assert!(result.is_ok());
}
#[tokio::test]
async fn workflow_handles_describe_error() {
let mock = MockJjExecutor::new()
.with_is_repo_response(Ok(true))
.with_describe_response(Err(Error::RepositoryLocked));
assert!(mock.is_repository().await.is_ok());
assert!(mock.describe("@", "test").await.is_err());
let working_mock = MockJjExecutor::new();
let workflow = CommitWorkflow::new(working_mock);
assert!(matches!(workflow, CommitWorkflow { .. }));
}
#[test]
fn workflow_implements_debug() {
let mock = MockJjExecutor::new();
let workflow = CommitWorkflow::new(mock);
let debug_output = format!("{:?}", workflow);
assert!(debug_output.contains("CommitWorkflow"));
}
#[tokio::test]
async fn test_complete_workflow_happy_path() {
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("add new feature").unwrap())
.with_breaking_change(BreakingChange::Yes)
.with_references(References::default())
.with_body(Body::default())
.with_confirm(true);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!(result.is_ok());
}
#[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_for_revset("@").await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::Cancelled));
}
#[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_breaking_change(BreakingChange::No)
.with_references(References::default())
.with_body(Body::default())
.with_confirm(false);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::Cancelled));
}
#[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)
.with_scope(Scope::parse("very-long-scope-name").unwrap())
.with_description(Description::parse("a".repeat(45)).unwrap())
.with_breaking_change(BreakingChange::No)
.with_references(References::default())
.with_body(Body::default())
.with_scope(Scope::empty())
.with_description(Description::parse("short description").unwrap())
.with_breaking_change(BreakingChange::No)
.with_references(References::default())
.with_body(Body::default())
.with_confirm(true);
let mock_prompts_handle = mock_prompts.clone();
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!(
result.is_ok(),
"Workflow should succeed after retry, got: {:?}",
result
);
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
);
}
#[tokio::test]
async fn test_workflow_invalid_scope() {
let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
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_for_revset("@").await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::InvalidScope(_)));
}
#[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_for_revset("@").await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::InvalidDescription(_)));
}
#[test]
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);
assert!(matches!(workflow, CommitWorkflow { .. }));
}
#[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_breaking_change(BreakingChange::Yes)
.with_references(References::default())
.with_body(Body::default())
.with_confirm(true);
let workflow = CommitWorkflow::with_prompts(
MockJjExecutor::new().with_is_repo_response(Ok(true)),
mock_prompts,
);
let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!(result.is_ok(), "Failed for commit type: {:?}", commit_type);
}
}
#[tokio::test]
async fn test_various_scope_formats() {
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_breaking_change(BreakingChange::Yes)
.with_references(References::default())
.with_body(Body::default())
.with_confirm(true);
let workflow = CommitWorkflow::with_prompts(
MockJjExecutor::new().with_is_repo_response(Ok(true)),
mock_prompts,
);
{
let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!(result.is_ok());
}
let mock_prompts = MockPrompts::new()
.with_commit_type(CommitType::Feat)
.with_scope(Scope::parse("api").unwrap())
.with_description(Description::parse("test").unwrap())
.with_breaking_change(BreakingChange::No)
.with_references(References::default())
.with_body(Body::default())
.with_confirm(true);
let workflow = CommitWorkflow::with_prompts(
MockJjExecutor::new().with_is_repo_response(Ok(true)),
mock_prompts,
);
{
let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!(result.is_ok());
}
}
#[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 { .. }));
}
#[test]
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,
Body::default(),
References::default(),
);
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,
);
}
#[test]
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,
Body::default(),
References::default(),
);
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,
);
}
#[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_references(References::default())
.with_body(Body::default())
.with_confirm(true);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result: Result<(), Error> = workflow.run_for_revset("@").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],
);
}
#[test]
fn preview_and_confirm_forwards_body() {
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("add feature").unwrap(),
BreakingChange::No,
Body::from("This explains the change."),
References::default(),
);
assert!(result.is_ok(), "expected Ok, got: {:?}", result);
assert!(
result
.unwrap()
.to_string()
.contains("This explains the change."),
"body must appear in the commit message"
);
}
#[test]
fn preview_and_confirm_forwards_body_with_breaking_change() {
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("drop legacy API").unwrap(),
"removes legacy endpoint".into(),
Body::from("The endpoint was deprecated in v2."),
References::default(),
);
assert!(result.is_ok(), "expected Ok, got: {:?}", result);
let message = result.unwrap().to_string();
assert!(
message.contains("The endpoint was deprecated in v2."),
"body must appear in the commit message, got: {message:?}"
);
assert!(
message.contains("BREAKING CHANGE: removes legacy endpoint"),
"breaking change footer must still be present, got: {message:?}"
);
}
#[tokio::test]
async fn full_workflow_describes_commit_with_body() {
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("add feature").unwrap())
.with_breaking_change(BreakingChange::No)
.with_references(References::default())
.with_body(Body::from("This explains the change."))
.with_confirm(true);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result: Result<(), Error> = workflow.run_for_revset("@").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("This explains the change."),
"body must appear in the described commit, got: {:?}",
messages[0]
);
}
#[tokio::test]
async fn full_workflow_with_no_body_succeeds() {
let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
let mock_prompts = MockPrompts::new()
.with_commit_type(CommitType::Fix)
.with_scope(Scope::empty())
.with_description(Description::parse("fix crash").unwrap())
.with_breaking_change(BreakingChange::No)
.with_references(References::default())
.with_body(Body::default())
.with_confirm(true);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!(
result.is_ok(),
"expected workflow to succeed, got: {:?}",
result
);
let messages = workflow.executor.describe_messages();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0], "fix: fix crash");
}
#[tokio::test]
async fn workflow_new_revision_records_revset() {
let mock_executor = MockJjExecutor::new();
let workflow = CommitWorkflow::new(mock_executor);
let result = workflow.new_revision("@").await;
assert!(result.is_ok());
let calls = workflow.executor.new_revision_calls();
assert_eq!(calls, vec!["@"]);
}
#[tokio::test]
async fn workflow_new_revision_propagates_error() {
let mock_executor =
MockJjExecutor::new().with_new_revision_response(Err(Error::RepositoryLocked));
let workflow = CommitWorkflow::new(mock_executor);
let result = workflow.new_revision("@").await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::RepositoryLocked));
}
#[tokio::test]
async fn workflow_describe_then_new_revision() {
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("add feature").unwrap())
.with_breaking_change(BreakingChange::No)
.with_references(References::default())
.with_body(Body::default())
.with_confirm(true);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
workflow.run_for_revset("@").await.expect("describe failed");
workflow
.new_revision("@")
.await
.expect("new_revision failed");
let messages = workflow.executor.describe_messages();
assert_eq!(messages.len(), 1);
assert!(messages[0].contains("feat:"));
let calls = workflow.executor.new_revision_calls();
assert_eq!(calls, vec!["@"]);
}
}