Compare commits
2 Commits
359636b152
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
5b8b035e09
|
|||
|
b135f1ef9e
|
24
.github/workflows/action.yml
vendored
24
.github/workflows/action.yml
vendored
@@ -36,26 +36,23 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
nix develop --no-pure-eval --accept-flake-config --command just audit
|
nix develop --no-pure-eval --accept-flake-config --command just audit
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: |
|
|
||||||
nix develop --no-pure-eval --accept-flake-config --command just lint-report
|
|
||||||
|
|
||||||
- name: Build Linux release binary
|
|
||||||
run: nix build --no-pure-eval --accept-flake-config
|
|
||||||
|
|
||||||
- name: Build Windows release binary
|
|
||||||
run: nix build .#windows --no-pure-eval --accept-flake-config
|
|
||||||
|
|
||||||
- name: Coverage
|
- name: Coverage
|
||||||
run: |
|
run: |
|
||||||
nix develop --no-pure-eval --accept-flake-config --command just coverage-ci
|
nix develop --no-pure-eval --accept-flake-config --command just coverage-ci
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: |
|
||||||
|
nix develop --no-pure-eval --accept-flake-config --command just lint-report
|
||||||
|
|
||||||
- name: Sonar analysis
|
- name: Sonar analysis
|
||||||
uses: SonarSource/sonarqube-scan-action@v6
|
uses: SonarSource/sonarqube-scan-action@v6
|
||||||
env:
|
env:
|
||||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||||
|
|
||||||
|
- name: Build Linux release binary
|
||||||
|
run: nix build --no-pure-eval --accept-flake-config
|
||||||
|
|
||||||
- name: Prepare Linux binary
|
- name: Prepare Linux binary
|
||||||
run: |
|
run: |
|
||||||
mkdir dist-linux
|
mkdir dist-linux
|
||||||
@@ -65,9 +62,12 @@ jobs:
|
|||||||
- name: Upload Linux artifact
|
- name: Upload Linux artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: jj-cz-linux-x86_64
|
name: jj-cz-x86_64-unknown-linux-gnu
|
||||||
path: dist-linux/*
|
path: dist-linux/*
|
||||||
|
|
||||||
|
- name: Build Windows release binary
|
||||||
|
run: nix build .#windows --no-pure-eval --accept-flake-config
|
||||||
|
|
||||||
- name: Prepare Windows binary
|
- name: Prepare Windows binary
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist-windows
|
mkdir -p dist-windows
|
||||||
@@ -77,5 +77,5 @@ jobs:
|
|||||||
- name: Upload Windows artifact
|
- name: Upload Windows artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: jj-cz-windows-x86_64
|
name: jj-cz-x86_64-pc-windows-gnu
|
||||||
path: dist-windows/*
|
path: dist-windows/*
|
||||||
|
|||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1571,6 +1571,7 @@ dependencies = [
|
|||||||
"crossterm",
|
"crossterm",
|
||||||
"dyn-clone",
|
"dyn-clone",
|
||||||
"fuzzy-matcher",
|
"fuzzy-matcher",
|
||||||
|
"tempfile",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"unicode-width",
|
"unicode-width",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ async-trait = "0.1.89"
|
|||||||
etcetera = "0.11.0"
|
etcetera = "0.11.0"
|
||||||
clap = { version = "4.5.57", features = ["derive"] }
|
clap = { version = "4.5.57", features = ["derive"] }
|
||||||
git-conventional = "0.12.9"
|
git-conventional = "0.12.9"
|
||||||
inquire = "0.9.2"
|
inquire = { version = "0.9.2", features = ["editor"] }
|
||||||
jj-lib = "0.39.0"
|
jj-lib = "0.39.0"
|
||||||
lazy-regex = { version = "3.5.1", features = ["lite"] }
|
lazy-regex = { version = "3.5.1", features = ["lite"] }
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
|
|||||||
185
src/commit/types/body.rs
Normal file
185
src/commit/types/body.rs
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||||
|
#[repr(transparent)]
|
||||||
|
pub struct Body(Option<String>);
|
||||||
|
|
||||||
|
impl<T: ToString> From<T> for Body {
|
||||||
|
fn from(value: T) -> Self {
|
||||||
|
let value = value.to_string();
|
||||||
|
let lines: Vec<&str> = value
|
||||||
|
.trim_end()
|
||||||
|
.lines()
|
||||||
|
.map(|line| line.trim_end())
|
||||||
|
.skip_while(|line| line.is_empty())
|
||||||
|
.collect();
|
||||||
|
match lines.join("\n").as_str() {
|
||||||
|
"" => Self::default(),
|
||||||
|
value => Self(Some(value.into())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Body {
|
||||||
|
pub fn format(&self) -> String {
|
||||||
|
match &self.0 {
|
||||||
|
None => String::new(),
|
||||||
|
Some(value) if value.trim().is_empty() => String::new(),
|
||||||
|
Some(body) => format!("\n{body}\n"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Default produces Body(None) — no body
|
||||||
|
#[test]
|
||||||
|
fn default_produces_none() {
|
||||||
|
assert_eq!(Body::default(), Body(None));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty string produces Body(None)
|
||||||
|
#[test]
|
||||||
|
fn from_empty_string_produces_none() {
|
||||||
|
assert_eq!(Body::from(""), Body(None));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whitespace-only string produces Body(None)
|
||||||
|
#[test]
|
||||||
|
fn from_whitespace_only_produces_none() {
|
||||||
|
assert_eq!(Body::from(" "), Body(None));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tabs and newlines only produce Body(None)
|
||||||
|
#[test]
|
||||||
|
fn from_tab_and_newline_only_produces_none() {
|
||||||
|
assert_eq!(Body::from("\t\n "), Body(None));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single newline (typical empty-editor save) produces Body(None)
|
||||||
|
#[test]
|
||||||
|
fn from_single_newline_produces_none() {
|
||||||
|
assert_eq!(Body::from("\n"), Body(None));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-empty string produces Body(Some(...)) with content preserved
|
||||||
|
#[test]
|
||||||
|
fn from_non_empty_string_produces_some() {
|
||||||
|
assert_eq!(
|
||||||
|
Body::from("some body text"),
|
||||||
|
Body(Some("some body text".to_string())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leading and internal whitespace is preserved — users may write
|
||||||
|
/// indented lists, ASCII art, file trees, etc.
|
||||||
|
#[test]
|
||||||
|
fn from_preserves_leading_whitespace() {
|
||||||
|
assert_eq!(
|
||||||
|
Body::from(" content "),
|
||||||
|
Body(Some(" content".to_string())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leading whitespace on individual lines is preserved
|
||||||
|
#[test]
|
||||||
|
fn from_preserves_per_line_leading_whitespace() {
|
||||||
|
let input = "- item one\n - nested item\n - deeply nested";
|
||||||
|
assert_eq!(Body::from(input), Body(Some(input.to_string())),);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trailing newline (typical editor output) is stripped
|
||||||
|
#[test]
|
||||||
|
fn from_trims_trailing_newline() {
|
||||||
|
assert_eq!(
|
||||||
|
Body::from("editor content\n"),
|
||||||
|
Body(Some("editor content".to_string())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leading blank lines (e.g. from editor artefacts after JJ: comment
|
||||||
|
/// stripping) are dropped
|
||||||
|
#[test]
|
||||||
|
fn from_drops_leading_blank_lines() {
|
||||||
|
assert_eq!(Body::from("\n\ncontent"), Body(Some("content".to_string())),);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Windows-style CRLF line endings are normalised to LF
|
||||||
|
#[test]
|
||||||
|
fn from_normalises_crlf_to_lf() {
|
||||||
|
assert_eq!(
|
||||||
|
Body::from("line one\r\nline two"),
|
||||||
|
Body(Some("line one\nline two".to_string())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal newlines are preserved for multi-line bodies
|
||||||
|
#[test]
|
||||||
|
fn from_preserves_internal_newlines() {
|
||||||
|
assert_eq!(
|
||||||
|
Body::from("line one\nline two"),
|
||||||
|
Body(Some("line one\nline two".to_string())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Into<Body> conversion works via `.into()`
|
||||||
|
#[test]
|
||||||
|
fn into_conversion_works() {
|
||||||
|
let body: Body = "content".into();
|
||||||
|
assert_eq!(body, Body(Some("content".to_string())));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clone produces a value equal to the original
|
||||||
|
#[test]
|
||||||
|
fn clone_produces_equal_value() {
|
||||||
|
let body = Body::from("content");
|
||||||
|
assert_eq!(body.clone(), body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two bodies constructed from the same string are equal
|
||||||
|
#[test]
|
||||||
|
fn equality_same_content() {
|
||||||
|
assert_eq!(Body::from("same"), Body::from("same"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bodies with different content are not equal
|
||||||
|
#[test]
|
||||||
|
fn inequality_different_content() {
|
||||||
|
assert_ne!(Body::from("first"), Body::from("second"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// None body is not equal to a body with content
|
||||||
|
#[test]
|
||||||
|
fn inequality_none_vs_some() {
|
||||||
|
assert_ne!(Body::default(), Body::from("content"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debug output is available and mentions Body
|
||||||
|
#[test]
|
||||||
|
fn debug_output_is_available() {
|
||||||
|
let body = Body::from("test");
|
||||||
|
assert!(format!("{:?}", body).contains("Body"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// format() on a None body returns an empty string
|
||||||
|
#[test]
|
||||||
|
fn format_none_returns_empty_string() {
|
||||||
|
assert_eq!(Body::default().format(), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// format() on a Some body returns "\ncontent\n"
|
||||||
|
/// (leading \n creates the blank line after the commit header;
|
||||||
|
/// trailing \n creates the blank line before the footer)
|
||||||
|
#[test]
|
||||||
|
fn format_some_returns_newline_wrapped_content() {
|
||||||
|
let body = Body::from("some body text");
|
||||||
|
assert_eq!(body.format(), "\nsome body text\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// format() preserves internal newlines in multi-line bodies
|
||||||
|
#[test]
|
||||||
|
fn format_some_multiline_preserves_content() {
|
||||||
|
let body = Body::from("line one\nline two");
|
||||||
|
assert_eq!(body.format(), "\nline one\nline two\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use super::{BreakingChange, CommitType, Description, Scope};
|
use super::{Body, BreakingChange, CommitType, Description, Scope};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
/// Errors that can occur when creating a ConventionalCommit
|
/// Errors that can occur when creating a ConventionalCommit
|
||||||
@@ -22,6 +22,7 @@ pub struct ConventionalCommit {
|
|||||||
scope: Scope,
|
scope: Scope,
|
||||||
description: Description,
|
description: Description,
|
||||||
breaking_change: BreakingChange,
|
breaking_change: BreakingChange,
|
||||||
|
body: Body,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConventionalCommit {
|
impl ConventionalCommit {
|
||||||
@@ -42,12 +43,14 @@ impl ConventionalCommit {
|
|||||||
scope: Scope,
|
scope: Scope,
|
||||||
description: Description,
|
description: Description,
|
||||||
breaking_change: BreakingChange,
|
breaking_change: BreakingChange,
|
||||||
|
body: Body,
|
||||||
) -> Result<Self, CommitMessageError> {
|
) -> Result<Self, CommitMessageError> {
|
||||||
let commit = Self {
|
let commit = Self {
|
||||||
commit_type,
|
commit_type,
|
||||||
scope,
|
scope,
|
||||||
description,
|
description,
|
||||||
breaking_change,
|
breaking_change,
|
||||||
|
body,
|
||||||
};
|
};
|
||||||
let len = commit.first_line_len();
|
let len = commit.first_line_len();
|
||||||
if len > Self::FIRST_LINE_MAX_LENGTH {
|
if len > Self::FIRST_LINE_MAX_LENGTH {
|
||||||
@@ -88,6 +91,7 @@ impl ConventionalCommit {
|
|||||||
&self.scope,
|
&self.scope,
|
||||||
&self.description,
|
&self.description,
|
||||||
&self.breaking_change,
|
&self.breaking_change,
|
||||||
|
&self.body,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,33 +105,20 @@ impl ConventionalCommit {
|
|||||||
scope: &Scope,
|
scope: &Scope,
|
||||||
description: &Description,
|
description: &Description,
|
||||||
breaking_change: &BreakingChange,
|
breaking_change: &BreakingChange,
|
||||||
|
body: &Body,
|
||||||
) -> String {
|
) -> String {
|
||||||
let scope = scope.header_segment();
|
let scope = scope.header_segment();
|
||||||
let breaking_change_header = breaking_change.header_segment();
|
let breaking_change_header = breaking_change.header_segment();
|
||||||
let breaking_change_footer = breaking_change.as_footer();
|
let breaking_change_footer = breaking_change.as_footer();
|
||||||
format!(
|
format!(
|
||||||
r#"{commit_type}{scope}{breaking_change_header}: {description}
|
r#"{commit_type}{scope}{breaking_change_header}: {description}
|
||||||
|
{}
|
||||||
{breaking_change_footer}"#,
|
{breaking_change_footer}"#,
|
||||||
|
body.format()
|
||||||
)
|
)
|
||||||
.trim()
|
.trim()
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the commit type
|
|
||||||
pub fn commit_type(&self) -> CommitType {
|
|
||||||
self.commit_type
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a reference to the scope
|
|
||||||
pub fn scope(&self) -> &Scope {
|
|
||||||
&self.scope
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a reference to the description
|
|
||||||
pub fn description(&self) -> &Description {
|
|
||||||
&self.description
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for ConventionalCommit {
|
impl std::fmt::Display for ConventionalCommit {
|
||||||
@@ -157,7 +148,13 @@ mod tests {
|
|||||||
description: Description,
|
description: Description,
|
||||||
breaking_change: BreakingChange,
|
breaking_change: BreakingChange,
|
||||||
) -> ConventionalCommit {
|
) -> ConventionalCommit {
|
||||||
ConventionalCommit::new(commit_type, scope, description, breaking_change)
|
ConventionalCommit::new(
|
||||||
|
commit_type,
|
||||||
|
scope,
|
||||||
|
description,
|
||||||
|
breaking_change,
|
||||||
|
Body::default(),
|
||||||
|
)
|
||||||
.expect("test commit should have valid line length")
|
.expect("test commit should have valid line length")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,9 +167,9 @@ mod tests {
|
|||||||
test_description("add new feature"),
|
test_description("add new feature"),
|
||||||
BreakingChange::No,
|
BreakingChange::No,
|
||||||
);
|
);
|
||||||
assert_eq!(commit.commit_type(), CommitType::Feat);
|
assert_eq!(commit.commit_type, CommitType::Feat);
|
||||||
assert_eq!(commit.scope().as_str(), "cli");
|
assert_eq!(commit.scope.as_str(), "cli");
|
||||||
assert_eq!(commit.description().as_str(), "add new feature");
|
assert_eq!(commit.description.as_str(), "add new feature");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test that ConventionalCommit::new() works with empty scope
|
/// Test that ConventionalCommit::new() works with empty scope
|
||||||
@@ -184,9 +181,9 @@ mod tests {
|
|||||||
test_description("fix critical bug"),
|
test_description("fix critical bug"),
|
||||||
BreakingChange::No,
|
BreakingChange::No,
|
||||||
);
|
);
|
||||||
assert_eq!(commit.commit_type(), CommitType::Fix);
|
assert_eq!(commit.commit_type, CommitType::Fix);
|
||||||
assert!(commit.scope().is_empty());
|
assert!(commit.scope.is_empty());
|
||||||
assert_eq!(commit.description().as_str(), "fix critical bug");
|
assert_eq!(commit.description.as_str(), "fix critical bug");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test that format() produces "type(scope): description" when scope is non-empty
|
/// Test that format() produces "type(scope): description" when scope is non-empty
|
||||||
@@ -405,56 +402,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test commit_type() returns the correct type
|
|
||||||
#[test]
|
|
||||||
fn commit_type_accessor_returns_correct_type() {
|
|
||||||
for commit_type in CommitType::all() {
|
|
||||||
let commit = test_commit(
|
|
||||||
*commit_type,
|
|
||||||
Scope::empty(),
|
|
||||||
test_description("test"),
|
|
||||||
BreakingChange::No,
|
|
||||||
);
|
|
||||||
assert_eq!(commit.commit_type(), *commit_type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test scope() returns reference to scope
|
|
||||||
#[test]
|
|
||||||
fn scope_accessor_returns_reference() {
|
|
||||||
let commit = test_commit(
|
|
||||||
CommitType::Feat,
|
|
||||||
test_scope("auth"),
|
|
||||||
test_description("add feature"),
|
|
||||||
BreakingChange::No,
|
|
||||||
);
|
|
||||||
assert_eq!(commit.scope().as_str(), "auth");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test scope() returns reference to empty scope
|
|
||||||
#[test]
|
|
||||||
fn scope_accessor_returns_empty_scope() {
|
|
||||||
let commit = test_commit(
|
|
||||||
CommitType::Feat,
|
|
||||||
Scope::empty(),
|
|
||||||
test_description("add feature"),
|
|
||||||
BreakingChange::No,
|
|
||||||
);
|
|
||||||
assert!(commit.scope().is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test description() returns reference to description
|
|
||||||
#[test]
|
|
||||||
fn description_accessor_returns_reference() {
|
|
||||||
let commit = test_commit(
|
|
||||||
CommitType::Feat,
|
|
||||||
Scope::empty(),
|
|
||||||
test_description("add new authentication flow"),
|
|
||||||
BreakingChange::No,
|
|
||||||
);
|
|
||||||
assert_eq!(commit.description().as_str(), "add new authentication flow");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test Clone trait
|
/// Test Clone trait
|
||||||
#[test]
|
#[test]
|
||||||
fn conventional_commit_is_cloneable() {
|
fn conventional_commit_is_cloneable() {
|
||||||
@@ -689,6 +636,7 @@ mod tests {
|
|||||||
Scope::parse(&scope_20).unwrap(),
|
Scope::parse(&scope_20).unwrap(),
|
||||||
Description::parse(&desc_44).unwrap(),
|
Description::parse(&desc_44).unwrap(),
|
||||||
BreakingChange::No,
|
BreakingChange::No,
|
||||||
|
Body::default(),
|
||||||
);
|
);
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
let commit = result.unwrap();
|
let commit = result.unwrap();
|
||||||
@@ -718,6 +666,7 @@ mod tests {
|
|||||||
Scope::parse(&scope_30).unwrap(),
|
Scope::parse(&scope_30).unwrap(),
|
||||||
Description::parse(&desc_31).unwrap(),
|
Description::parse(&desc_31).unwrap(),
|
||||||
BreakingChange::No,
|
BreakingChange::No,
|
||||||
|
Body::default(),
|
||||||
);
|
);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -741,6 +690,7 @@ mod tests {
|
|||||||
Scope::parse(&scope_30).unwrap(),
|
Scope::parse(&scope_30).unwrap(),
|
||||||
Description::parse(&desc_40).unwrap(),
|
Description::parse(&desc_40).unwrap(),
|
||||||
BreakingChange::No,
|
BreakingChange::No,
|
||||||
|
Body::default(),
|
||||||
);
|
);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -760,6 +710,7 @@ mod tests {
|
|||||||
Scope::empty(),
|
Scope::empty(),
|
||||||
test_description("quick fix"),
|
test_description("quick fix"),
|
||||||
BreakingChange::No,
|
BreakingChange::No,
|
||||||
|
Body::default(),
|
||||||
);
|
);
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
@@ -772,6 +723,7 @@ mod tests {
|
|||||||
test_scope("cli"),
|
test_scope("cli"),
|
||||||
test_description("add feature"),
|
test_description("add feature"),
|
||||||
BreakingChange::No,
|
BreakingChange::No,
|
||||||
|
Body::default(),
|
||||||
);
|
);
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
@@ -797,6 +749,7 @@ mod tests {
|
|||||||
Scope::empty(),
|
Scope::empty(),
|
||||||
test_description("test"),
|
test_description("test"),
|
||||||
BreakingChange::No,
|
BreakingChange::No,
|
||||||
|
Body::default(),
|
||||||
);
|
);
|
||||||
// Just verify it's a Result by using is_ok()
|
// Just verify it's a Result by using is_ok()
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
@@ -822,7 +775,13 @@ mod tests {
|
|||||||
None => Scope::empty(),
|
None => Scope::empty(),
|
||||||
};
|
};
|
||||||
let desc = Description::parse(*desc_str).unwrap();
|
let desc = Description::parse(*desc_str).unwrap();
|
||||||
let commit = ConventionalCommit::new(*commit_type, scope, desc, BreakingChange::No);
|
let commit = ConventionalCommit::new(
|
||||||
|
*commit_type,
|
||||||
|
scope,
|
||||||
|
desc,
|
||||||
|
BreakingChange::No,
|
||||||
|
Body::default(),
|
||||||
|
);
|
||||||
// new() itself calls git_conventional::Commit::parse internally, so
|
// new() itself calls git_conventional::Commit::parse internally, so
|
||||||
// if this is Ok, SC-002 is satisfied for this case.
|
// if this is Ok, SC-002 is satisfied for this case.
|
||||||
assert!(
|
assert!(
|
||||||
@@ -958,6 +917,7 @@ mod tests {
|
|||||||
Scope::parse(&scope_20).unwrap(),
|
Scope::parse(&scope_20).unwrap(),
|
||||||
Description::parse(&desc_44).unwrap(),
|
Description::parse(&desc_44).unwrap(),
|
||||||
BreakingChange::Yes,
|
BreakingChange::Yes,
|
||||||
|
Body::default(),
|
||||||
);
|
);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -979,6 +939,7 @@ mod tests {
|
|||||||
Scope::empty(),
|
Scope::empty(),
|
||||||
test_description("quick fix"),
|
test_description("quick fix"),
|
||||||
long_note.into(),
|
long_note.into(),
|
||||||
|
Body::default(),
|
||||||
);
|
);
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
@@ -993,10 +954,11 @@ mod tests {
|
|||||||
BreakingChange::No,
|
BreakingChange::No,
|
||||||
);
|
);
|
||||||
let preview = ConventionalCommit::format_preview(
|
let preview = ConventionalCommit::format_preview(
|
||||||
commit.commit_type(),
|
commit.commit_type,
|
||||||
commit.scope(),
|
&commit.scope,
|
||||||
commit.description(),
|
&commit.description,
|
||||||
&BreakingChange::No,
|
&BreakingChange::No,
|
||||||
|
&Body::default(),
|
||||||
);
|
);
|
||||||
assert_eq!(preview, commit.format());
|
assert_eq!(preview, commit.format());
|
||||||
}
|
}
|
||||||
@@ -1009,6 +971,7 @@ mod tests {
|
|||||||
&Scope::empty(),
|
&Scope::empty(),
|
||||||
&test_description("drop legacy API"),
|
&test_description("drop legacy API"),
|
||||||
&"removes legacy endpoint".into(),
|
&"removes legacy endpoint".into(),
|
||||||
|
&Body::default(),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
preview,
|
preview,
|
||||||
@@ -1024,6 +987,7 @@ mod tests {
|
|||||||
&test_scope("api"),
|
&test_scope("api"),
|
||||||
&test_description("drop Node 6"),
|
&test_description("drop Node 6"),
|
||||||
&"Node 6 is no longer supported".into(),
|
&"Node 6 is no longer supported".into(),
|
||||||
|
&Body::default(),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
preview,
|
preview,
|
||||||
@@ -1115,4 +1079,109 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Body tests (these will fail until format_preview() is updated to use body) ---
|
||||||
|
|
||||||
|
/// format() includes the body between the header and an empty footer
|
||||||
|
///
|
||||||
|
/// Case: body = Some, footer = "" → "type: desc\n\ncontent"
|
||||||
|
#[test]
|
||||||
|
fn format_with_body_no_breaking_change() {
|
||||||
|
let commit = ConventionalCommit::new(
|
||||||
|
CommitType::Feat,
|
||||||
|
Scope::empty(),
|
||||||
|
test_description("add feature"),
|
||||||
|
BreakingChange::No,
|
||||||
|
Body::from("This explains the change."),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
commit.format(),
|
||||||
|
"feat: add feature\n\nThis explains the change."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// format() includes the body when a scope is also present
|
||||||
|
#[test]
|
||||||
|
fn format_with_body_and_scope() {
|
||||||
|
let commit = ConventionalCommit::new(
|
||||||
|
CommitType::Fix,
|
||||||
|
test_scope("api"),
|
||||||
|
test_description("handle null response"),
|
||||||
|
BreakingChange::No,
|
||||||
|
Body::from("Null responses were previously unhandled."),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
commit.format(),
|
||||||
|
"fix(api): handle null response\n\nNull responses were previously unhandled."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// format() preserves internal newlines in a multi-paragraph body
|
||||||
|
#[test]
|
||||||
|
fn format_with_multiline_body() {
|
||||||
|
let commit = ConventionalCommit::new(
|
||||||
|
CommitType::Docs,
|
||||||
|
Scope::empty(),
|
||||||
|
test_description("update README"),
|
||||||
|
BreakingChange::No,
|
||||||
|
Body::from("First paragraph.\n\nSecond paragraph."),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
commit.format(),
|
||||||
|
"docs: update README\n\nFirst paragraph.\n\nSecond paragraph."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// format() places the body between the header and the breaking-change footer
|
||||||
|
///
|
||||||
|
/// Case: body = Some, footer = Some → "type: desc\n\nbody\n\nBREAKING CHANGE: note"
|
||||||
|
#[test]
|
||||||
|
fn format_with_body_and_breaking_change_note() {
|
||||||
|
let commit = ConventionalCommit::new(
|
||||||
|
CommitType::Feat,
|
||||||
|
Scope::empty(),
|
||||||
|
test_description("drop legacy API"),
|
||||||
|
"removes legacy endpoint".into(),
|
||||||
|
Body::from("The endpoint was deprecated in v2."),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
commit.format(),
|
||||||
|
"feat!: drop legacy API\n\nThe endpoint was deprecated in v2.\n\nBREAKING CHANGE: removes legacy endpoint"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// format_preview() includes the body in the output
|
||||||
|
#[test]
|
||||||
|
fn format_preview_with_body() {
|
||||||
|
let preview = ConventionalCommit::format_preview(
|
||||||
|
CommitType::Feat,
|
||||||
|
&Scope::empty(),
|
||||||
|
&test_description("add feature"),
|
||||||
|
&BreakingChange::No,
|
||||||
|
&Body::from("This explains the change."),
|
||||||
|
);
|
||||||
|
assert_eq!(preview, "feat: add feature\n\nThis explains the change.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// format_preview() with body and breaking-change note produces the full message
|
||||||
|
///
|
||||||
|
/// Case: body = Some, footer = Some → "type: desc\n\nbody\n\nBREAKING CHANGE: note"
|
||||||
|
#[test]
|
||||||
|
fn format_preview_with_body_and_breaking_change() {
|
||||||
|
let preview = ConventionalCommit::format_preview(
|
||||||
|
CommitType::Fix,
|
||||||
|
&Scope::empty(),
|
||||||
|
&test_description("drop old API"),
|
||||||
|
&"old API removed".into(),
|
||||||
|
&Body::from("Migration guide: see CHANGELOG."),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
preview,
|
||||||
|
"fix!: drop old API\n\nMigration guide: see CHANGELOG.\n\nBREAKING CHANGE: old API removed"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,5 +13,8 @@ pub use scope::{Scope, ScopeError};
|
|||||||
mod description;
|
mod description;
|
||||||
pub use description::{Description, DescriptionError};
|
pub use description::{Description, DescriptionError};
|
||||||
|
|
||||||
|
mod body;
|
||||||
|
pub use body::Body;
|
||||||
|
|
||||||
mod message;
|
mod message;
|
||||||
pub use message::{CommitMessageError, ConventionalCommit};
|
pub use message::{CommitMessageError, ConventionalCommit};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ mod prompts;
|
|||||||
|
|
||||||
pub use crate::{
|
pub use crate::{
|
||||||
commit::types::{
|
commit::types::{
|
||||||
BreakingChange, CommitMessageError, CommitType, ConventionalCommit, Description,
|
Body, BreakingChange, CommitMessageError, CommitType, ConventionalCommit, Description,
|
||||||
DescriptionError, Scope, ScopeError,
|
DescriptionError, Scope, ScopeError,
|
||||||
},
|
},
|
||||||
error::Error,
|
error::Error,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
commit::types::{BreakingChange, CommitType, Description, Scope},
|
commit::types::{Body, BreakingChange, CommitType, Description, Scope},
|
||||||
error::Error,
|
error::Error,
|
||||||
prompts::prompter::Prompter,
|
prompts::prompter::Prompter,
|
||||||
};
|
};
|
||||||
@@ -20,6 +20,7 @@ enum MockResponse {
|
|||||||
Scope(Scope),
|
Scope(Scope),
|
||||||
Description(Description),
|
Description(Description),
|
||||||
BreakingChange(BreakingChange),
|
BreakingChange(BreakingChange),
|
||||||
|
Body(Body),
|
||||||
Confirm(bool),
|
Confirm(bool),
|
||||||
Error(Error),
|
Error(Error),
|
||||||
}
|
}
|
||||||
@@ -80,6 +81,15 @@ impl MockPrompts {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Configure the mock to return a specific body response
|
||||||
|
pub fn with_body(self, body: Body) -> Self {
|
||||||
|
self.responses
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(MockResponse::Body(body));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Configure the mock to return a specific confirmation response
|
/// Configure the mock to return a specific confirmation response
|
||||||
pub fn with_confirm(self, confirm: bool) -> Self {
|
pub fn with_confirm(self, confirm: bool) -> Self {
|
||||||
self.responses
|
self.responses
|
||||||
@@ -130,6 +140,14 @@ impl MockPrompts {
|
|||||||
.contains(&"input_breaking_change".to_string())
|
.contains(&"input_breaking_change".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if input_body was called
|
||||||
|
pub fn was_body_called(&self) -> bool {
|
||||||
|
self.prompts_called
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.contains(&"input_body".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if confirm_apply was called
|
/// Check if confirm_apply was called
|
||||||
pub fn was_confirm_called(&self) -> bool {
|
pub fn was_confirm_called(&self) -> bool {
|
||||||
self.prompts_called
|
self.prompts_called
|
||||||
@@ -197,6 +215,19 @@ impl Prompter for MockPrompts {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn input_body(&self) -> Result<Body, Error> {
|
||||||
|
self.prompts_called
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push("input_body".to_string());
|
||||||
|
|
||||||
|
match self.responses.lock().unwrap().remove(0) {
|
||||||
|
MockResponse::Body(body) => Ok(body),
|
||||||
|
MockResponse::Error(e) => Err(e),
|
||||||
|
_ => panic!("MockPrompts: Expected Body response, got different type"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn confirm_apply(&self, _message: &str) -> Result<bool, Error> {
|
fn confirm_apply(&self, _message: &str) -> Result<bool, Error> {
|
||||||
self.prompts_called
|
self.prompts_called
|
||||||
.lock()
|
.lock()
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use inquire::{Confirm, Text};
|
|||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
commit::types::{BreakingChange, CommitType, Description, Scope},
|
commit::types::{Body, BreakingChange, CommitType, Description, Scope},
|
||||||
error::Error,
|
error::Error,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,6 +30,9 @@ pub trait Prompter: Send + Sync {
|
|||||||
/// Prompt the user for breaking change
|
/// Prompt the user for breaking change
|
||||||
fn input_breaking_change(&self) -> Result<BreakingChange, Error>;
|
fn input_breaking_change(&self) -> Result<BreakingChange, Error>;
|
||||||
|
|
||||||
|
/// Prompt the user to optionally add a free-form body via an external editor
|
||||||
|
fn input_body(&self) -> Result<Body, Error>;
|
||||||
|
|
||||||
/// Prompt the user to confirm applying the commit message
|
/// Prompt the user to confirm applying the commit message
|
||||||
fn confirm_apply(&self, message: &str) -> Result<bool, Error>;
|
fn confirm_apply(&self, message: &str) -> Result<bool, Error>;
|
||||||
|
|
||||||
@@ -176,6 +179,38 @@ impl Prompter for RealPrompts {
|
|||||||
Ok(trimmed.into())
|
Ok(trimmed.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn input_body(&self) -> Result<Body, Error> {
|
||||||
|
use inquire::Editor;
|
||||||
|
|
||||||
|
let wants_body = Confirm::new("Add a body?")
|
||||||
|
.with_default(false)
|
||||||
|
.prompt()
|
||||||
|
.map_err(|_| Error::Cancelled)?;
|
||||||
|
|
||||||
|
if !wants_body {
|
||||||
|
return Ok(Body::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
let template = "\
|
||||||
|
JJ: Body (optional). Markdown is supported.\n\
|
||||||
|
JJ: Wrap prose lines at 72 characters where possible.\n\
|
||||||
|
JJ: Lines starting with \"JJ:\" will be removed.\n";
|
||||||
|
|
||||||
|
let raw = Editor::new("Body:")
|
||||||
|
.with_predefined_text(template)
|
||||||
|
.with_file_extension(".md")
|
||||||
|
.prompt()
|
||||||
|
.map_err(|_| Error::Cancelled)?;
|
||||||
|
|
||||||
|
let stripped: String = raw
|
||||||
|
.lines()
|
||||||
|
.filter(|line| !line.starts_with("JJ:"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
Ok(Body::from(stripped))
|
||||||
|
}
|
||||||
|
|
||||||
fn confirm_apply(&self, message: &str) -> Result<bool, Error> {
|
fn confirm_apply(&self, message: &str) -> Result<bool, Error> {
|
||||||
use inquire::Confirm;
|
use inquire::Confirm;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
commit::types::{
|
commit::types::{
|
||||||
BreakingChange, CommitMessageError, CommitType, ConventionalCommit, Description, Scope,
|
Body, BreakingChange, CommitMessageError, CommitType, ConventionalCommit, Description,
|
||||||
|
Scope,
|
||||||
},
|
},
|
||||||
error::Error,
|
error::Error,
|
||||||
jj::JjExecutor,
|
jj::JjExecutor,
|
||||||
@@ -62,8 +63,9 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
|
|||||||
let scope = self.scope_input().await?;
|
let scope = self.scope_input().await?;
|
||||||
let description = self.description_input().await?;
|
let description = self.description_input().await?;
|
||||||
let breaking_change = self.breaking_change_input().await?;
|
let breaking_change = self.breaking_change_input().await?;
|
||||||
|
let body = self.body_input().await?;
|
||||||
match self
|
match self
|
||||||
.preview_and_confirm(commit_type, scope, description, breaking_change)
|
.preview_and_confirm(commit_type, scope, description, breaking_change, body)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(conventional_commit) => {
|
Ok(conventional_commit) => {
|
||||||
@@ -112,6 +114,11 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
|
|||||||
self.prompts.input_breaking_change()
|
self.prompts.input_breaking_change()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prompt user to optionally add a free-form body via an external editor
|
||||||
|
async fn body_input(&self) -> Result<Body, Error> {
|
||||||
|
self.prompts.input_body()
|
||||||
|
}
|
||||||
|
|
||||||
/// Preview the formatted conventional commit message and get user confirmation
|
/// Preview the formatted conventional commit message and get user confirmation
|
||||||
///
|
///
|
||||||
/// This method also validates that the complete first line
|
/// This method also validates that the complete first line
|
||||||
@@ -122,10 +129,16 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
|
|||||||
scope: Scope,
|
scope: Scope,
|
||||||
description: Description,
|
description: Description,
|
||||||
breaking_change: BreakingChange,
|
breaking_change: BreakingChange,
|
||||||
|
body: Body,
|
||||||
) -> Result<ConventionalCommit, Error> {
|
) -> Result<ConventionalCommit, Error> {
|
||||||
// Format the message for preview
|
// Format the message for preview
|
||||||
let message =
|
let message = ConventionalCommit::format_preview(
|
||||||
ConventionalCommit::format_preview(commit_type, &scope, &description, &breaking_change);
|
commit_type,
|
||||||
|
&scope,
|
||||||
|
&description,
|
||||||
|
&breaking_change,
|
||||||
|
&body,
|
||||||
|
);
|
||||||
|
|
||||||
// Try to build the conventional commit (this validates the 72-char limit)
|
// Try to build the conventional commit (this validates the 72-char limit)
|
||||||
let conventional_commit: ConventionalCommit = match ConventionalCommit::new(
|
let conventional_commit: ConventionalCommit = match ConventionalCommit::new(
|
||||||
@@ -133,6 +146,7 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
|
|||||||
scope.clone(),
|
scope.clone(),
|
||||||
description.clone(),
|
description.clone(),
|
||||||
breaking_change,
|
breaking_change,
|
||||||
|
body,
|
||||||
) {
|
) {
|
||||||
Ok(cc) => cc,
|
Ok(cc) => cc,
|
||||||
Err(CommitMessageError::FirstLineTooLong { actual, max }) => {
|
Err(CommitMessageError::FirstLineTooLong { actual, max }) => {
|
||||||
@@ -258,8 +272,9 @@ mod tests {
|
|||||||
let scope = Scope::empty();
|
let scope = Scope::empty();
|
||||||
let description = Description::parse("test description").unwrap();
|
let description = Description::parse("test description").unwrap();
|
||||||
let breaking_change = BreakingChange::No;
|
let breaking_change = BreakingChange::No;
|
||||||
|
let body = Body::default();
|
||||||
let result = workflow
|
let result = workflow
|
||||||
.preview_and_confirm(commit_type, scope, description, breaking_change)
|
.preview_and_confirm(commit_type, scope, description, breaking_change, body)
|
||||||
.await;
|
.await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
@@ -305,6 +320,7 @@ mod tests {
|
|||||||
.with_scope(Scope::empty())
|
.with_scope(Scope::empty())
|
||||||
.with_description(Description::parse("add new feature").unwrap())
|
.with_description(Description::parse("add new feature").unwrap())
|
||||||
.with_breaking_change(BreakingChange::Yes)
|
.with_breaking_change(BreakingChange::Yes)
|
||||||
|
.with_body(Body::default())
|
||||||
.with_confirm(true);
|
.with_confirm(true);
|
||||||
|
|
||||||
// Create workflow with both mocks
|
// Create workflow with both mocks
|
||||||
@@ -337,6 +353,7 @@ mod tests {
|
|||||||
.with_scope(Scope::parse("api").unwrap())
|
.with_scope(Scope::parse("api").unwrap())
|
||||||
.with_description(Description::parse("fix bug").unwrap())
|
.with_description(Description::parse("fix bug").unwrap())
|
||||||
.with_breaking_change(BreakingChange::No)
|
.with_breaking_change(BreakingChange::No)
|
||||||
|
.with_body(Body::default())
|
||||||
.with_confirm(false); // User cancels at confirmation
|
.with_confirm(false); // User cancels at confirmation
|
||||||
|
|
||||||
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||||
@@ -360,10 +377,12 @@ mod tests {
|
|||||||
.with_scope(Scope::parse("very-long-scope-name").unwrap())
|
.with_scope(Scope::parse("very-long-scope-name").unwrap())
|
||||||
.with_description(Description::parse("a".repeat(45)).unwrap())
|
.with_description(Description::parse("a".repeat(45)).unwrap())
|
||||||
.with_breaking_change(BreakingChange::No)
|
.with_breaking_change(BreakingChange::No)
|
||||||
|
.with_body(Body::default())
|
||||||
// Second iteration: short enough to succeed
|
// Second iteration: short enough to succeed
|
||||||
.with_scope(Scope::empty())
|
.with_scope(Scope::empty())
|
||||||
.with_description(Description::parse("short description").unwrap())
|
.with_description(Description::parse("short description").unwrap())
|
||||||
.with_breaking_change(BreakingChange::No)
|
.with_breaking_change(BreakingChange::No)
|
||||||
|
.with_body(Body::default())
|
||||||
.with_confirm(true);
|
.with_confirm(true);
|
||||||
|
|
||||||
// Clone before moving into workflow so we can inspect emitted messages after
|
// Clone before moving into workflow so we can inspect emitted messages after
|
||||||
@@ -457,6 +476,7 @@ mod tests {
|
|||||||
.with_scope(Scope::empty())
|
.with_scope(Scope::empty())
|
||||||
.with_description(Description::parse("test").unwrap())
|
.with_description(Description::parse("test").unwrap())
|
||||||
.with_breaking_change(BreakingChange::Yes)
|
.with_breaking_change(BreakingChange::Yes)
|
||||||
|
.with_body(Body::default())
|
||||||
.with_confirm(true);
|
.with_confirm(true);
|
||||||
|
|
||||||
let workflow = CommitWorkflow::with_prompts(
|
let workflow = CommitWorkflow::with_prompts(
|
||||||
@@ -479,6 +499,7 @@ mod tests {
|
|||||||
.with_scope(Scope::empty())
|
.with_scope(Scope::empty())
|
||||||
.with_description(Description::parse("test").unwrap())
|
.with_description(Description::parse("test").unwrap())
|
||||||
.with_breaking_change(BreakingChange::Yes)
|
.with_breaking_change(BreakingChange::Yes)
|
||||||
|
.with_body(Body::default())
|
||||||
.with_confirm(true);
|
.with_confirm(true);
|
||||||
|
|
||||||
let workflow = CommitWorkflow::with_prompts(
|
let workflow = CommitWorkflow::with_prompts(
|
||||||
@@ -496,6 +517,7 @@ mod tests {
|
|||||||
.with_scope(Scope::parse("api").unwrap())
|
.with_scope(Scope::parse("api").unwrap())
|
||||||
.with_description(Description::parse("test").unwrap())
|
.with_description(Description::parse("test").unwrap())
|
||||||
.with_breaking_change(BreakingChange::No)
|
.with_breaking_change(BreakingChange::No)
|
||||||
|
.with_body(Body::default())
|
||||||
.with_confirm(true);
|
.with_confirm(true);
|
||||||
|
|
||||||
let workflow = CommitWorkflow::with_prompts(
|
let workflow = CommitWorkflow::with_prompts(
|
||||||
@@ -542,6 +564,7 @@ mod tests {
|
|||||||
Scope::empty(),
|
Scope::empty(),
|
||||||
Description::parse("remove old API").unwrap(),
|
Description::parse("remove old API").unwrap(),
|
||||||
BreakingChange::Yes,
|
BreakingChange::Yes,
|
||||||
|
Body::default(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -570,6 +593,7 @@ mod tests {
|
|||||||
Scope::empty(),
|
Scope::empty(),
|
||||||
Description::parse("drop legacy API").unwrap(),
|
Description::parse("drop legacy API").unwrap(),
|
||||||
breaking_change,
|
breaking_change,
|
||||||
|
Body::default(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -601,6 +625,7 @@ mod tests {
|
|||||||
.with_scope(Scope::empty())
|
.with_scope(Scope::empty())
|
||||||
.with_description(Description::parse("remove old API").unwrap())
|
.with_description(Description::parse("remove old API").unwrap())
|
||||||
.with_breaking_change(BreakingChange::Yes)
|
.with_breaking_change(BreakingChange::Yes)
|
||||||
|
.with_body(Body::default())
|
||||||
.with_confirm(true);
|
.with_confirm(true);
|
||||||
|
|
||||||
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||||
@@ -620,4 +645,131 @@ mod tests {
|
|||||||
messages[0],
|
messages[0],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Body tests ---
|
||||||
|
// preview_and_confirm() tests compile now but will fail until the Body::default()
|
||||||
|
// at line 138 of preview_and_confirm() is replaced with the `body` parameter.
|
||||||
|
// The full_workflow_* tests additionally require MockPrompts::with_body().
|
||||||
|
|
||||||
|
/// preview_and_confirm must forward the body to ConventionalCommit::new()
|
||||||
|
///
|
||||||
|
/// Currently the implementation passes Body::default() instead of the
|
||||||
|
/// received body, so this test will fail until that is fixed.
|
||||||
|
#[tokio::test]
|
||||||
|
async 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."),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// preview_and_confirm must forward the body even when a breaking change is present
|
||||||
|
///
|
||||||
|
/// Expected format: "type!: desc\n\nbody\n\nBREAKING CHANGE: note"
|
||||||
|
#[tokio::test]
|
||||||
|
async 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."),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full run() workflow must collect a body and include it in the
|
||||||
|
/// described commit.
|
||||||
|
///
|
||||||
|
/// Requires MockPrompts::with_body() and run() to call body_input().
|
||||||
|
#[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_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().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]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// run() must still work correctly when the user declines to add a body
|
||||||
|
///
|
||||||
|
/// Requires MockPrompts::with_body() returning Body::default().
|
||||||
|
#[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_body(Body::default())
|
||||||
|
.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);
|
||||||
|
assert_eq!(messages[0], "fix: fix crash");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use assert_fs::TempDir;
|
use assert_fs::TempDir;
|
||||||
#[cfg(feature = "test-utils")]
|
#[cfg(feature = "test-utils")]
|
||||||
use jj_cz::{BreakingChange, CommitType, Description, MockPrompts, Scope};
|
use jj_cz::{Body, BreakingChange, CommitType, Description, MockPrompts, Scope};
|
||||||
use jj_cz::{CommitWorkflow, Error, JjLib};
|
use jj_cz::{CommitWorkflow, Error, JjLib};
|
||||||
#[cfg(feature = "test-utils")]
|
#[cfg(feature = "test-utils")]
|
||||||
use jj_lib::{config::StackedConfig, settings::UserSettings, workspace::Workspace};
|
use jj_lib::{config::StackedConfig, settings::UserSettings, workspace::Workspace};
|
||||||
@@ -28,6 +28,7 @@ async fn test_happy_path_integration() {
|
|||||||
.with_scope(Scope::empty())
|
.with_scope(Scope::empty())
|
||||||
.with_description(Description::parse("add new feature").unwrap())
|
.with_description(Description::parse("add new feature").unwrap())
|
||||||
.with_breaking_change(BreakingChange::No)
|
.with_breaking_change(BreakingChange::No)
|
||||||
|
.with_body(Body::default())
|
||||||
.with_confirm(true);
|
.with_confirm(true);
|
||||||
|
|
||||||
// Create a mock executor that tracks calls
|
// Create a mock executor that tracks calls
|
||||||
@@ -87,6 +88,7 @@ async fn test_cancellation() {
|
|||||||
.with_scope(Scope::empty())
|
.with_scope(Scope::empty())
|
||||||
.with_description(Description::parse("test").unwrap())
|
.with_description(Description::parse("test").unwrap())
|
||||||
.with_breaking_change(BreakingChange::No)
|
.with_breaking_change(BreakingChange::No)
|
||||||
|
.with_body(Body::default())
|
||||||
.with_confirm(true);
|
.with_confirm(true);
|
||||||
let workflow = CommitWorkflow::with_prompts(executor, mock_prompts);
|
let workflow = CommitWorkflow::with_prompts(executor, mock_prompts);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user