diff --git a/.github/workflows/push-release.yaml b/.github/workflows/push-release.yaml index 6f743cb..7deecf6 100644 --- a/.github/workflows/push-release.yaml +++ b/.github/workflows/push-release.yaml @@ -31,16 +31,12 @@ jobs: set -e ./mvnw --no-transfer-progress --batch-mode install -Dgpg.skip CURRENT_VERSION=$(./mvnw --no-transfer-progress --batch-mode help:evaluate -Dexpression=project.version -q -DforceStdout) - ./mvnw --no-transfer-progress --batch-mode io.github.bsels:semantic-version-maven-plugin:$CURRENT_VERSION:update + ./mvnw --no-transfer-progress --batch-mode io.github.bsels:semantic-version-maven-plugin:$CURRENT_VERSION:update \ + -Dversioning.update.scripts=./update-readme.sh -Dversioning.git=COMMIT VERSION=$(./mvnw --no-transfer-progress --batch-mode help:evaluate -Dexpression=project.version -q -DforceStdout) echo "version=$VERSION" >> $GITHUB_OUTPUT - - name: Update version in README + - name: Create tag and push run: | - sed -i 's/[0-9]\+[.][0-9]\+[.][0-9]\+<\/version>/${{ steps.bumpVersion.outputs.version }}<\/version>/g' README.md - - name: Commit changes - run: | - git add .versioning CHANGELOG.md pom.xml - git commit -am "Released ${{ steps.bumpVersion.outputs.version }} [skip ci]" git tag "v${{ steps.bumpVersion.outputs.version }}" git push git push origin tag "v${{ steps.bumpVersion.outputs.version }}" diff --git a/.github/workflows/release-build.yaml b/.github/workflows/release-build.yaml index 91ea02c..14e2358 100644 --- a/.github/workflows/release-build.yaml +++ b/.github/workflows/release-build.yaml @@ -9,7 +9,7 @@ on: required: true type: string permissions: - contents: read + contents: write jobs: build: name: Build @@ -43,5 +43,13 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SERVER_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} SERVER_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_KEY_PASSWORD }} run: | - ./mvnw --batch-mode --no-transfer-progress clean deploy -Dgpg.passphrase=${{ secrets.GPG_KEY_PASSWORD }} + ./mvnw --batch-mode --no-transfer-progress clean deploy + - name: Zip coverage report + working-directory: ./target/site/jacoco + run: | + zip -r9 ../../../coverage-report.zip * + - name: Attach coverage report to release + run: | + gh release upload ${{ github.event.release.tag_name }} coverage-report.zip diff --git a/.versioning/versioning-20260122190538.md b/.versioning/versioning-20260122190538.md new file mode 100644 index 0000000..84f32ff --- /dev/null +++ b/.versioning/versioning-20260122190538.md @@ -0,0 +1,9 @@ +--- +io.github.bsels:semantic-version-maven-plugin: "PATCH" +--- + +Bumped dependencies: + +- Jackson from 2.20.1 to 2.21.0 +- JUnit from 6.0.1 to 6.0.2 +- Central publishing maven plugin from 0.9.0 to 0.10.0 diff --git a/.versioning/versioning-20260122190648.md b/.versioning/versioning-20260122190648.md new file mode 100644 index 0000000..482fc71 --- /dev/null +++ b/.versioning/versioning-20260122190648.md @@ -0,0 +1,5 @@ +--- +io.github.bsels:semantic-version-maven-plugin: "MINOR" +--- + +Added support for git for automated stashing or committing from files. diff --git a/.versioning/versioning-20260124102122.md b/.versioning/versioning-20260124102122.md new file mode 100644 index 0000000..a4e8a4a --- /dev/null +++ b/.versioning/versioning-20260124102122.md @@ -0,0 +1,5 @@ +--- +io.github.bsels:semantic-version-maven-plugin: "MINOR" +--- + +Support additional script execution during version bump for more customization diff --git a/.versioning/versioning-20260124134430.md b/.versioning/versioning-20260124134430.md new file mode 100644 index 0000000..310e0a4 --- /dev/null +++ b/.versioning/versioning-20260124134430.md @@ -0,0 +1,5 @@ +--- +io.github.bsels:semantic-version-maven-plugin: "MINOR" +--- + +Make the version header configurable with placeholders for the version and the date and allow custom date formats. diff --git a/.versioning/versioning-20260124151654.md b/.versioning/versioning-20260124151654.md new file mode 100644 index 0000000..0652fe0 --- /dev/null +++ b/.versioning/versioning-20260124151654.md @@ -0,0 +1,5 @@ +--- +io.github.bsels:semantic-version-maven-plugin: "MINOR" +--- + +Added mode to only use artifact ID as identifier in the file based versioning. diff --git a/README.md b/README.md index 1633ee3..30c9730 100644 --- a/README.md +++ b/README.md @@ -68,12 +68,15 @@ bump types, and allows you to write changelog entries either inline or via an ex #### Configuration Properties -| Property | Type | Default | Description | -|------------------------|-----------|-------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `versioning.modus` | `Modus` | `PROJECT_VERSION` | Versioning strategy:
• `PROJECT_VERSION`: All projects in multi-module builds
• `REVISION_PROPERTY`: Only current project using the `revision` property
• `PROJECT_VERSION_ONLY_LEAFS`: Only leaf projects (no modules) | -| `versioning.directory` | `Path` | `.versioning` | Directory for storing version markdown files | -| `versioning.dryRun` | `boolean` | `false` | Preview changes without writing files | -| `versioning.backup` | `boolean` | `false` | Create backup of files before modification | +| Property | Type | Default | Description | +|------------------------------------|----------------------|-------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `versioning.modus` | `Modus` | `PROJECT_VERSION` | Versioning strategy:
• `PROJECT_VERSION`: All projects in multi-module builds
• `REVISION_PROPERTY`: Only current project using the `revision` property
• `PROJECT_VERSION_ONLY_LEAFS`: Only leaf projects (no modules) | +| `versioning.identifier` | `ArtifactIdentifier` | `GROUP_ID_AND_ARTIFACT_ID` | Artifact key format in version markdown files:
• `GROUP_ID_AND_ARTIFACT_ID`: use `groupId:artifactId` keys (default)
• `ONLY_ARTIFACT_ID`: use artifactId only when all modules share the same groupId | +| `versioning.directory` | `Path` | `.versioning` | Directory for storing version markdown files | +| `versioning.dryRun` | `boolean` | `false` | Preview changes without writing files | +| `versioning.backup` | `boolean` | `false` | Create backup of files before modification | +| `versioning.commit.message.create` | `String` | `Created version Markdown file for {numberOfProjects} project(s)` | Commit message template for version markdown file creation. Use `{numberOfProjects}` placeholder for project count | +| `versioning.git` | `Git` | `NO_GIT` | Defines the git operation mode:
• `NO_GIT`: no git operations will be performed
• `STASH`: added changed files to the git stash
• `COMMIT`: commit all changed files with the configured commit message | #### Example Usage @@ -118,13 +121,18 @@ versions, updates dependencies in multi-module projects, and merges changelog en #### Configuration Properties -| Property | Type | Default | Description | -|------------------------|---------------|-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `versioning.bump` | `VersionBump` | `FILE_BASED` | Version bump strategy:
• `FILE_BASED`: Use version markdown files from `.versioning` directory
• `MAJOR`: Apply MAJOR version bump to all projects
• `MINOR`: Apply MINOR version bump to all projects
• `PATCH`: Apply PATCH version bump to all projects | -| `versioning.modus` | `Modus` | `PROJECT_VERSION` | Versioning strategy:
• `PROJECT_VERSION`: All projects in multi-module builds
• `REVISION_PROPERTY`: Only current project using the `revision` property
• `PROJECT_VERSION_ONLY_LEAFS`: Only leaf projects (no modules) | -| `versioning.directory` | `Path` | `.versioning` | Directory containing version markdown files | -| `versioning.dryRun` | `boolean` | `false` | Preview changes without writing files | -| `versioning.backup` | `boolean` | `false` | Create backup of POM and CHANGELOG files before modification | +| Property | Type | Default | Description | +|------------------------------------|----------------------|-----------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `versioning.bump` | `VersionBump` | `FILE_BASED` | Version bump strategy:
• `FILE_BASED`: Use version markdown files from `.versioning` directory
• `MAJOR`: Apply MAJOR version bump to all projects
• `MINOR`: Apply MINOR version bump to all projects
• `PATCH`: Apply PATCH version bump to all projects | +| `versioning.modus` | `Modus` | `PROJECT_VERSION` | Versioning strategy:
• `PROJECT_VERSION`: All projects in multi-module builds
• `REVISION_PROPERTY`: Only current project using the `revision` property
• `PROJECT_VERSION_ONLY_LEAFS`: Only leaf projects (no modules) | +| `versioning.identifier` | `ArtifactIdentifier` | `GROUP_ID_AND_ARTIFACT_ID` | Artifact key format in version markdown files:
• `GROUP_ID_AND_ARTIFACT_ID`: use `groupId:artifactId` keys (default)
• `ONLY_ARTIFACT_ID`: use artifactId only when all modules share the same groupId | +| `versioning.directory` | `Path` | `.versioning` | Directory containing version markdown files | +| `versioning.dryRun` | `boolean` | `false` | Preview changes without writing files | +| `versioning.backup` | `boolean` | `false` | Create backup of POM and CHANGELOG files before modification | +| `versioning.commit.message.update` | `String` | `Updated {numberOfProjects} project version(s) [skip ci]` | Commit message template for version updates. Use `{numberOfProjects}` placeholder for project count | +| `versioning.update.scripts` | `String` | `-` | Script paths to execute per updated module, separated by the OS path separator. Each script runs in the module directory with `CURRENT_VERSION`, `NEW_VERSION`, `DRY_RUN` (`true` or `false`), `GIT_STASH` (`true` or `false`), `EXECUTION_DATE` (YYYY-MM-DD) environment variables. | +| `versioning.version.header` | `String` | `{version} - {date#YYYY-MM-DD}` | Header format for version markdown files. Supports `{version}` and `{date#YYYY-MM-DD}` placeholders; date uses a `DateTimeFormatter` pattern. | +| `versioning.git` | `Git` | `NO_GIT` | Defines the git operation mode:
• `NO_GIT`: no git operations will be performed
• `STASH`: added changed files to the git stash
• `COMMIT`: commit all changed files with the configured commit message | #### Example Usage @@ -176,6 +184,13 @@ mvn io.github.bsels:semantic-version-maven-plugin:update \ -Dversioning.directory=.versions ``` +**Custom version file header**: + +```bash +mvn io.github.bsels:semantic-version-maven-plugin:update \ + -Dversioning.version.header='Release {version} ({date#YYYY-MM-DD})' +``` + **Multi-module project with revision property**: ```bash @@ -189,18 +204,29 @@ mvn io.github.bsels:semantic-version-maven-plugin:update \ These properties apply to both `create` and `update` goals: -| Property | Type | Default | Description | -|------------------------|-----------|-------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `versioning.modus` | `Modus` | `PROJECT_VERSION` | Defines versioning strategy for project structure:
• `PROJECT_VERSION`: Process all projects in topological order
• `REVISION_PROPERTY`: Process only the current project using the `revision` property
• `PROJECT_VERSION_ONLY_LEAFS`: Process only leaf projects (no child modules) | -| `versioning.directory` | `Path` | `.versioning` | Directory path for version markdown files (absolute or relative to project root) | -| `versioning.dryRun` | `boolean` | `false` | When `true`, performs all operations without writing files (logs output instead) | -| `versioning.backup` | `boolean` | `false` | When `true`, creates `.bak` backup files before modifying POM and CHANGELOG files | +| Property | Type | Default | Description | +|-------------------------|----------------------|----------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `versioning.modus` | `Modus` | `PROJECT_VERSION` | Defines versioning strategy for project structure:
• `PROJECT_VERSION`: Process all projects in topological order
• `REVISION_PROPERTY`: Process only the current project using the `revision` property
• `PROJECT_VERSION_ONLY_LEAFS`: Process only leaf projects (no child modules) | +| `versioning.identifier` | `ArtifactIdentifier` | `GROUP_ID_AND_ARTIFACT_ID` | Artifact key format in version markdown files:
• `GROUP_ID_AND_ARTIFACT_ID`: use `groupId:artifactId` keys (default)
• `ONLY_ARTIFACT_ID`: use artifactId only when all modules share the same groupId | +| `versioning.directory` | `Path` | `.versioning` | Directory path for version markdown files (absolute or relative to project root) | +| `versioning.dryRun` | `boolean` | `false` | When `true`, performs all operations without writing files (logs output instead) | +| `versioning.backup` | `boolean` | `false` | When `true`, creates `.bak` backup files before modifying POM and CHANGELOG files | +| `versioning.git` | `Git` | `NO_GIT` | Defines the git operation mode:
• `NO_GIT`: no git operations will be performed
• `STASH`: added changed files to the git stash
• `COMMIT`: commit all changed files with the configured commit message | + +### create-Specific Properties + +| Property | Type | Default | Description | +|------------------------------------|----------|-------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------| +| `versioning.commit.message.create` | `String` | `Created version Markdown file for {numberOfProjects} project(s)` | Commit message template used when creating version markdown files. The `{numberOfProjects}` placeholder is replaced with the actual count | ### update-Specific Properties -| Property | Type | Default | Description | -|-------------------|---------------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `versioning.bump` | `VersionBump` | `FILE_BASED` | Determines version increment strategy:
• `FILE_BASED`: Read version bumps from markdown files in `.versioning` directory
• `MAJOR`: Force MAJOR version increment (X.0.0) for all projects
• `MINOR`: Force MINOR version increment (0.X.0) for all projects
• `PATCH`: Force PATCH version increment (0.0.X) for all projects | +| Property | Type | Default | Description | +|------------------------------------|---------------|-----------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `versioning.bump` | `VersionBump` | `FILE_BASED` | Determines version increment strategy:
• `FILE_BASED`: Read version bumps from markdown files in `.versioning` directory
• `MAJOR`: Force MAJOR version increment (X.0.0) for all projects
• `MINOR`: Force MINOR version increment (0.X.0) for all projects
• `PATCH`: Force PATCH version increment (0.0.X) for all projects | +| `versioning.commit.message.update` | `String` | `Updated {numberOfProjects} project version(s) [skip ci]` | Commit message template used when updating project versions. The `{numberOfProjects}` placeholder is replaced with the actual count | +| `versioning.update.scripts` | `String` | `-` | Script paths to execute per updated module, separated by the OS path separator. Each script runs in the module directory with `CURRENT_VERSION`, `NEW_VERSION`, `DRY_RUN` (`true` or `false`), `GIT_STASH` (`true` or `false`), `EXECUTION_DATE` (YYYY-MM-DD) environment variables. | +| `versioning.version.header` | `String` | `{version} - {date#YYYY-MM-DD}` | Header format for version markdown files. Supports `{version}` and `{date#YYYY-MM-DD}` placeholders; date uses a `DateTimeFormatter` pattern. | ## Examples @@ -273,4 +299,3 @@ Configure the plugin directly in `pom.xml`: ## License This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. - diff --git a/pom.xml b/pom.xml index 6e498e7..a3dcbe7 100644 --- a/pom.xml +++ b/pom.xml @@ -40,6 +40,7 @@ 17 + 0.8.14 3.9.12 3.6.2 3.9.0 @@ -49,13 +50,13 @@ 3.2.8 3.12.0 3.4.0 - 0.9.0 + 0.10.0 3.27.6 0.27.0 - 2.20.1 - 6.0.1 + 2.21.0 + 6.0.2 3.15.2 5.21.0 @@ -69,6 +70,30 @@ + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + + prepare-agent + + + + default-report + prepare-package + + report + + + + HTML + + + + + org.apache.maven.plugins maven-enforcer-plugin @@ -119,7 +144,7 @@ maven-surefire-plugin ${maven.surefire.plugin.version} - -javaagent:${org.mockito:mockito-core:jar} + @{argLine} -javaagent:${org.mockito:mockito-core:jar} diff --git a/src/main/java/io/github/bsels/semantic/version/BaseMojo.java b/src/main/java/io/github/bsels/semantic/version/BaseMojo.java index 05cc2e5..e6115c3 100644 --- a/src/main/java/io/github/bsels/semantic/version/BaseMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/BaseMojo.java @@ -4,8 +4,11 @@ import io.github.bsels.semantic.version.models.MavenArtifact; import io.github.bsels.semantic.version.models.SemanticVersionBump; import io.github.bsels.semantic.version.models.VersionMarkdown; +import io.github.bsels.semantic.version.parameters.ArtifactIdentifier; +import io.github.bsels.semantic.version.parameters.Git; import io.github.bsels.semantic.version.parameters.Modus; import io.github.bsels.semantic.version.utils.MarkdownUtils; +import io.github.bsels.semantic.version.utils.ProcessUtils; import io.github.bsels.semantic.version.utils.Utils; import org.apache.maven.execution.MavenSession; import org.apache.maven.plugin.AbstractMojo; @@ -123,13 +126,30 @@ public abstract sealed class BaseMojo extends AbstractMojo permits CreateVersion @Parameter(property = "versioning.directory", required = true, defaultValue = ".versioning") protected Path versionDirectory = Path.of(".versioning"); + /// Represents the Git version control system configuration for the Maven plugin. + /// Used to manage versioning-related operations specific to Git. + /// + /// By default, the value is set to [Git#NO_GIT], + /// indicating that no Git-specific actions will be performed unless explicitly configured. + /// + /// This field can be overridden by specifying the Maven property `versioning.git`. + @Parameter(property = "versioning.git", required = true, defaultValue = "NO_GIT") + protected Git git = Git.NO_GIT; + /// Indicates whether the original POM file and CHANGELOG file should be backed up before modifying its content. /// /// This parameter is configurable via the Maven property `versioning.backup`. /// When set to `true`, a backup of the POM/CHANGELOG file will be created before any updates are applied. /// The default value for this parameter is `false`, meaning no backup will be created unless explicitly specified. - @Parameter(property = "versioning.backup", defaultValue = "false") - boolean backupFiles = false; + @Parameter(property = "versioning.backup", required = true, defaultValue = "false") + protected boolean backupFiles = false; + + /// Specifies the mode of artifact identification within a repository or dependency context. + /// This parameter is configurable via the Maven property `versioning.identifier`. + /// The default value is [ArtifactIdentifier#GROUP_ID_AND_ARTIFACT_ID], + /// which includes both the group ID and artifact ID. + @Parameter(property = "versioning.identifier", required = true, defaultValue = "GROUP_ID_AND_ARTIFACT_ID") + protected ArtifactIdentifier identifier = ArtifactIdentifier.GROUP_ID_AND_ARTIFACT_ID; /// Default constructor for the BaseMojo class. /// Initializes the instance by invoking the superclass constructor. @@ -210,7 +230,12 @@ protected final List getVersionMarkdowns() throws MojoExecution .toList(); List parsedMarkdowns = new ArrayList<>(); for (Path markdownFile : markdownFiles) { - parsedMarkdowns.add(MarkdownUtils.readVersionMarkdown(log, markdownFile)); + parsedMarkdowns.add(MarkdownUtils.readVersionMarkdown( + log, + markdownFile, + identifier, + session.getCurrentProject().getGroupId() + )); } versionMarkdowns = List.copyOf(parsedMarkdowns); } catch (IOException e) { @@ -333,6 +358,7 @@ protected void writeMarkdownFile(Node markdownNode, Path markdownFile) } else { MarkdownUtils.writeMarkdownFile(markdownFile, markdownNode, backupFiles); } + stashFiles(List.of(markdownFile)); } /// Simulates writing to a file by using a [StringWriter]. @@ -354,6 +380,29 @@ protected void dryRunWriteFile(MojoThrowingConsumer consumer, Path } } + /// Stashes the provided list of file paths using Git if stashing is enabled and not in dry-run mode. + /// + /// @param files the list of file paths to be stashed + /// @throws MojoExecutionException if an error occurs during the stashing process + protected void stashFiles(List files) throws MojoExecutionException { + if (!dryRun && git.isStash()) { + ProcessUtils.gitStashFiles(files); + } + } + + /// Commits changes to a Git repository if specific conditions are met. + /// The commit operation will only be performed when: + /// - Git commit mode is enabled ([Git#isCommit] returns true) + /// - Dry-run mode is disabled (dryRun is false) + /// + /// @param message The commit message to use for the commit operation. + /// @throws MojoExecutionException If the commit operation fails. + protected void commit(String message) throws MojoExecutionException { + if (!dryRun && git.isCommit()) { + ProcessUtils.gitCommit(message); + } + } + /// Functional interface that represents an operation that accepts a single input /// and can throw [MojoExecutionException] and [MojoFailureException]. /// diff --git a/src/main/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojo.java b/src/main/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojo.java index c7742b5..5d12d85 100644 --- a/src/main/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojo.java @@ -1,6 +1,7 @@ package io.github.bsels.semantic.version; import io.github.bsels.semantic.version.models.MavenArtifact; +import io.github.bsels.semantic.version.models.PlaceHolderWithType; import io.github.bsels.semantic.version.models.SemanticVersionBump; import io.github.bsels.semantic.version.utils.MarkdownUtils; import io.github.bsels.semantic.version.utils.ProcessUtils; @@ -13,6 +14,7 @@ import org.apache.maven.plugins.annotations.Execute; import org.apache.maven.plugins.annotations.LifecyclePhase; import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; import org.apache.maven.plugins.annotations.ResolutionScope; import org.commonmark.node.Node; @@ -56,6 +58,24 @@ public final class CreateVersionMarkdownMojo extends BaseMojo { SemanticVersionBump.PATCH, SemanticVersionBump.MINOR, SemanticVersionBump.MAJOR ); + /// Represents the commit message for creating a version Markdown file. + /// This variable is essential for customizing the commit message applied when creating a version Markdown file. + /// + /// The placeholder `"{numberOfProjects}"` is used to dynamically insert the number of projects for which + /// the version file was created into the message. + /// + /// Attributes: + /// - property: Specifies the configuration property key to override this value. + /// - required: Signifies that this parameter is mandatory. + /// - defaultValue: If not explicitly specified, + /// defaults to `"Created version Markdown file for {numberOfProjects} project(s)"`. + @Parameter( + property = "versioning.commit.message.create", + required = true, + defaultValue = "Created version Markdown file for {numberOfProjects} project(s)" + ) + String commitMessage = "Created version Markdown file for {numberOfProjects} project(s)"; + /// Default constructor for the CreateVersionMarkdownMojo class. /// Invokes the superclass constructor to initialize the instance. /// This constructor is typically used by the Maven framework during the build lifecycle. @@ -79,6 +99,11 @@ public CreateVersionMarkdownMojo() { /// @throws MojoFailureException if the operation to process or create the version Markdown file fails. @Override protected void internalExecute() throws MojoExecutionException, MojoFailureException { + commitMessage = Utils.prepareFormatString( + commitMessage, + List.of(new PlaceHolderWithType("numberOfProjects", "d")) + ); + Log log = getLog(); List projects = getProjectsInScope() .map(mavenProject -> new MavenArtifact(mavenProject.getGroupId(), mavenProject.getArtifactId())) @@ -93,7 +118,7 @@ protected void internalExecute() throws MojoExecutionException, MojoFailureExcep return; } - YamlFrontMatterBlock versionBumpHeader = MarkdownUtils.createVersionBumpsHeader(log, selectedProjects); + YamlFrontMatterBlock versionBumpHeader = MarkdownUtils.createVersionBumpsHeader(log, selectedProjects, identifier); Node inputMarkdown = createChangelogEntry(); inputMarkdown.prependChild(versionBumpHeader); @@ -101,6 +126,7 @@ protected void internalExecute() throws MojoExecutionException, MojoFailureExcep Utils.createDirectoryIfNotExists(versioningFolder); Path versioningFile = Utils.resolveVersioningFile(versioningFolder); writeMarkdownFile(inputMarkdown, versioningFile); + commit(commitMessage.formatted(selectedProjects.size())); } /// Creates a changelog entry by either taking user input directly or by leveraging an external editor. diff --git a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java index 0f448cd..db338ac 100644 --- a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java @@ -2,12 +2,14 @@ import io.github.bsels.semantic.version.models.MarkdownMapping; import io.github.bsels.semantic.version.models.MavenArtifact; +import io.github.bsels.semantic.version.models.PlaceHolderWithType; import io.github.bsels.semantic.version.models.SemanticVersionBump; import io.github.bsels.semantic.version.models.VersionChange; import io.github.bsels.semantic.version.models.VersionMarkdown; import io.github.bsels.semantic.version.parameters.VersionBump; import io.github.bsels.semantic.version.utils.MarkdownUtils; import io.github.bsels.semantic.version.utils.POMUtils; +import io.github.bsels.semantic.version.utils.ProcessUtils; import io.github.bsels.semantic.version.utils.Utils; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; @@ -21,9 +23,12 @@ import org.w3c.dom.Document; import org.w3c.dom.Node; +import java.io.File; import java.nio.file.Path; +import java.time.format.DateTimeFormatter; import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; @@ -73,6 +78,63 @@ public final class UpdatePomMojo extends BaseMojo { @Parameter(property = "versioning.bump", required = true, defaultValue = "FILE_BASED") VersionBump versionBump = VersionBump.FILE_BASED; + /// Represents the commit message template used during version updates. + /// This variable is essential for customizing the commit message applied when updating project versions. + /// + /// The placeholder `"{numberOfProjects}"` is used to dynamically insert the number of project versions updated into + /// the message. + /// + /// Attributes: + /// - property: Specifies the configuration property key to override this value. + /// - required: Signifies that this parameter is mandatory. + /// - defaultValue: If not explicitly specified, + /// defaults to `"Updated {numberOfProjects} project version(s)[skip ci]"`. + @Parameter( + property = "versioning.commit.message.update", + required = true, + defaultValue = "Updated {numberOfProjects} project version(s) [skip ci]" + ) + String commitMessage = "Updated {numberOfProjects} project version(s) [skip ci]"; + + /// Specifies the scripts or paths to the scripts used in versioning updates. + /// These scripts can be used to manage or modify version-related data or configuration during the build process in + /// the working directory of each processed module. + /// The scripts will be executed in the order they are specified. + /// + /// The scripts will be called with the following environment variables: + /// - `CURRENT_VERSION`: The current version of the project. + /// - `NEW_VERSION`: The new version of the project after the update. + /// - `DRY_RUN`: A flag indicating whether the script is being executed in dry-run mode (true) or not (false). + /// - `GIT_STASH`: A flag indicating whether the script should stash the files or not (true) or not (false). + /// - `EXECUTION_DATE`: The date and time when the script was executed formatted as ISO 8601: `YYYY-MM-DD`. + /// + /// The scripts should be separated by the OS file path separator + /// + /// This parameter is optional. + @Parameter(property = "versioning.update.scripts", required = false) + String scripts; + + /// Specifies the header format for the version file. + /// The header is used to define the versioning structure within the file, + /// including placeholders that can be dynamically replaced. + /// + /// The default format includes the version number and the current date. + /// Placeholders like `{version}` and `{date#YYYY-MM-DD}` are used for injecting the version + /// and date details respectively. + /// + /// - `{version}`: Represents the version of the application or component. + /// - `{date#YYYY-MM-DD}`: Represents the current date in the specified format + /// (the date format is processed by the [DateTimeFormatter]). + /// + /// This property is mandatory and must be defined for versioning tasks. + @Parameter(property = "versioning.version.header", required = true, defaultValue = "{version} - {date#YYYY-MM-DD}") + String versionHeader = "{version} - {date#YYYY-MM-DD}"; + + /// A list that holds file system paths pointing to script files. + /// Will be derived on execution from the `scripts` parameter. + /// Each path represented by this list is of type [Path], allowing interaction with the file system. + private List scriptPaths = List.of(); + /// Default constructor for the UpdatePomMojo class. /// /// Initializes an instance of the UpdatePomMojo class by invoking the superclass constructor. @@ -93,7 +155,7 @@ public UpdatePomMojo() { /// /// This method performs the following steps: /// 1. Retrieves the logger instance for logging operations. - /// 2. Fetches and processes markdown version information. + /// 2. Fetches and processes Markdown version information. /// 3. Validates the provided Markdown mappings to ensure correctness. /// 4. Collects Maven projects that are within the scope for processing. /// 5. Based on the number of scoped projects: @@ -106,6 +168,18 @@ public UpdatePomMojo() { /// @throws MojoFailureException if a failure condition specific to the plugin occurs. This indicates a detected issue that halts further execution. @Override public void internalExecute() throws MojoExecutionException, MojoFailureException { + commitMessage = Utils.prepareFormatString( + commitMessage, + List.of(new PlaceHolderWithType("numberOfProjects", "d")) + ); + scriptPaths = Optional.ofNullable(scripts) + .map(s -> s.split(File.pathSeparator)) + .stream() + .flatMap(Arrays::stream) + .map(Path::of) + .map(Path::toAbsolutePath) + .toList(); + Log log = getLog(); List versionMarkdowns = getVersionMarkdowns(); MarkdownMapping mapping = getMarkdownMapping(versionMarkdowns); @@ -114,37 +188,38 @@ public void internalExecute() throws MojoExecutionException, MojoFailureExceptio List projectsInScope = getProjectsInScope() .collect(Utils.asImmutableList()); - boolean hasChanges; + int changedProjects; if (projectsInScope.isEmpty()) { log.warn("No projects found in scope"); - hasChanges = false; + changedProjects = 0; } else if (projectsInScope.size() == 1) { log.info("Single project in scope"); - hasChanges = handleSingleProject(mapping, projectsInScope.get(0)); + changedProjects = handleSingleProject(mapping, projectsInScope.get(0)); } else { log.info("Multiple projects in scope"); - hasChanges = handleMultiProjects(mapping, projectsInScope); + changedProjects = handleMultiProjects(mapping, projectsInScope); } - if (!dryRun && hasChanges && VersionBump.FILE_BASED.equals(versionBump)) { - Utils.deleteFilesIfExists( - versionMarkdowns.stream() - .map(VersionMarkdown::path) - .filter(Objects::nonNull) - .toList() - ); + if (!dryRun && changedProjects > 0 && VersionBump.FILE_BASED.equals(versionBump)) { + List paths = versionMarkdowns.stream() + .map(VersionMarkdown::path) + .filter(Objects::nonNull) + .toList(); + Utils.deleteFilesIfExists(paths); + stashFiles(paths); } + commit(commitMessage.formatted(changedProjects)); } /// Handles the processing of a single Maven project by determining the semantic version bump, /// updating the project's version, and synchronizing the changes with a Markdown file. /// - /// @param markdownMapping the mapping that contains the version bump map and markdown file details + /// @param markdownMapping the mapping that contains the version bump map and Markdown file details /// @param project the Maven project to be processed - /// @return `true` if a version update was performed, `false` otherwise. + /// @return `1` if a version update was performed, `0` otherwise. /// @throws MojoExecutionException if an error occurs during processing the project's POM file /// @throws MojoFailureException if a failure occurs due to semantic version bump or other operations - private boolean handleSingleProject(MarkdownMapping markdownMapping, MavenProject project) + private int handleSingleProject(MarkdownMapping markdownMapping, MavenProject project) throws MojoExecutionException, MojoFailureException { Path pom = project.getFile() .toPath(); @@ -160,18 +235,19 @@ private boolean handleSingleProject(MarkdownMapping markdownMapping, MavenProjec writeUpdatedPom(document, pom); updateMarkdownFile(markdownMapping, artifact, pom, newVersion); + executeScripts(pom.getParent(), version.get()); } - return version.isPresent(); + return version.isPresent() ? 1 : 0; } /// Handles multiple Maven projects by processing their POM files, dependencies, and versions, updating the projects as necessary. /// /// @param markdownMapping an instance of [MarkdownMapping] that contains mapping details for Markdown processing. /// @param projects a list of [MavenProject] objects, representing the Maven projects to be processed. - /// @return `true` if any projects were updated, `false` otherwise. + /// @return the number of projects that were updated based on the version changes. /// @throws MojoExecutionException if there's an execution error while handling the projects. /// @throws MojoFailureException if a failure is encountered during the processing of the projects. - private boolean handleMultiProjects(MarkdownMapping markdownMapping, List projects) + private int handleMultiProjects(MarkdownMapping markdownMapping, List projects) throws MojoExecutionException, MojoFailureException { Log log = getLog(); Map documents = readAllPoms(projects); @@ -204,7 +280,28 @@ private boolean handleMultiProjects(MarkdownMapping markdownMapping, List POMUtils.updateVersionNodeIfOldVersionMatches(change, node)); @@ -251,7 +349,7 @@ private void handleDependencyMavenProjects( } /// Processes the Markdown versions for the provided Maven artifacts and updates the required dependencies, - /// markdown files, and version nodes as needed. + /// Markdown files, and version nodes as needed. /// /// @param markdownMapping the mapping containing information about the Markdown files and version bump rules /// @param reactorArtifacts the set of Maven artifacts that are part of the current reactor build @@ -259,7 +357,7 @@ private void handleDependencyMavenProjects( /// @param dependencyToProjectArtifacts a mapping of Maven artifacts to lists of dependent project artifacts /// @param updatableDependencies a mapping of Maven artifacts to lists of dependencies in the form of XML nodes that can be updated in the POM files /// @return an object containing the set of updated artifacts and the queue of artifacts to be updated - /// @throws MojoExecutionException if there is an error during version processing or markdown update + /// @throws MojoExecutionException if there is an error during version processing or Markdown update /// @throws MojoFailureException if any Mojo-related failure occurs during execution private UpdatedAndToUpdateArtifacts processMarkdownVersions( MarkdownMapping markdownMapping, @@ -287,6 +385,7 @@ private UpdatedAndToUpdateArtifacts processMarkdownVersions( updatableDependencies.getOrDefault(artifact, List.of()) .forEach(node -> POMUtils.updateVersionNodeIfOldVersionMatches(change, node)); + executeScripts(mavenProjectAndDocument.pomFile().getParent(), change); } } return new UpdatedAndToUpdateArtifacts(updatedArtifacts, toBeUpdated); @@ -429,9 +528,10 @@ private void writeUpdatedPom(Document document, Path pom) throws MojoExecutionEx } else { POMUtils.writePom(document, pom, backupFiles); } + stashFiles(List.of(pom)); } - /// Updates the Markdown file by reading the current changelog, merging version-specific markdown changes, + /// Updates the Markdown file by reading the current changelog, merging version-specific Markdown changes, /// and writing the updated changelog to the file system. /// /// @param markdownMapping the mapping between Maven artifacts and their associated Markdown changes @@ -454,6 +554,7 @@ private void updateMarkdownFile( MarkdownUtils.mergeVersionMarkdownsInChangelog( changelog, newVersion, + versionHeader, markdownMapping.markdownMap() .getOrDefault( projectArtifact, diff --git a/src/main/java/io/github/bsels/semantic/version/models/PlaceHolderWithType.java b/src/main/java/io/github/bsels/semantic/version/models/PlaceHolderWithType.java new file mode 100644 index 0000000..2c8ab5b --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/models/PlaceHolderWithType.java @@ -0,0 +1,24 @@ +package io.github.bsels.semantic.version.models; + +import java.util.Objects; + +/// Represents a placeholder with an associated formatType. +/// +/// This record encapsulates a string placeholder and its corresponding formatType, +/// ensuring that both values are non-null during initialization. +/// +/// @param placeholder the placeholder string; must not be null +/// @param formatType the formatType string associated with the placeholder; must not be null +public record PlaceHolderWithType(String placeholder, String formatType) { + + /// Constructs a new instance of the `PlaceHolderWithType` record. + /// Validates that both the placeholder and formatType values are non-null during initialization. + /// + /// @param placeholder the placeholder string; must not be null + /// @param formatType the formatType string; must not be null + /// @throws NullPointerException if `placeholder` or `formatType` is null + public PlaceHolderWithType { + Objects.requireNonNull(placeholder, "`placeholder` cannot be null"); + Objects.requireNonNull(formatType, "`formatType` cannot be null"); + } +} diff --git a/src/main/java/io/github/bsels/semantic/version/parameters/ArtifactIdentifier.java b/src/main/java/io/github/bsels/semantic/version/parameters/ArtifactIdentifier.java new file mode 100644 index 0000000..72258c1 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/parameters/ArtifactIdentifier.java @@ -0,0 +1,14 @@ +package io.github.bsels.semantic.version.parameters; + +/// Enum representing different modes of artifact identification within a repository or dependency context. +/// This is typically used to determine how an artifact should be uniquely identified, +/// either by combining the group ID with the artifact ID or by using only the artifact ID. +public enum ArtifactIdentifier { + /// Represents an identifier mode that includes both the group ID and the artifact ID. + /// This mode is typically used when it is necessary to fully qualify an artifact within a repository + /// or dependency context to ensure unique identification. + GROUP_ID_AND_ARTIFACT_ID, + /// Represents an identifier mode that only includes the artifact ID. + /// This mode is used when the group ID is not required (all modules share the same group ID). + ONLY_ARTIFACT_ID +} diff --git a/src/main/java/io/github/bsels/semantic/version/parameters/Git.java b/src/main/java/io/github/bsels/semantic/version/parameters/Git.java new file mode 100644 index 0000000..39b4fed --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/parameters/Git.java @@ -0,0 +1,38 @@ +package io.github.bsels.semantic.version.parameters; + +/// Enum representing different states or contexts related to Git functionality. +/// This can include representing the absence of Git integration, +/// a stash state, or an active commit state within a Git repository. +public enum Git { + /// Represents a state where no Git-related context or repository information is associated. + /// This enum value can be used to indicate an absence of Git functionality + /// or integration within the current context. + NO_GIT, + /// Represents a stash state in a Git repository. + /// This enum value is used to indicate that the current instance corresponds to a stash state. + STASH, + /// Represents the state of an active commit in a Git repository. + /// This enum value indicates that the current instance is in a commit state, + /// which can be checked using the [#isCommit()] method. + COMMIT; + + /// Determines if the current instance represents a stash state. + /// + /// @return `true` if the current instance is either [#STASH] or [#COMMIT], otherwise `false`. + public boolean isStash() { + return switch (this) { + case COMMIT, STASH -> true; + case NO_GIT -> false; + }; + } + + /// Determines if the current instance represents a commit state. + /// + /// @return `true` if the current instance is [#COMMIT], otherwise `false`. + public boolean isCommit() { + return switch (this) { + case COMMIT -> true; + case STASH, NO_GIT -> false; + }; + } +} diff --git a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java index 6d6f3cf..7196c63 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.databind.type.MapType; import com.fasterxml.jackson.databind.type.TypeFactory; import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; @@ -9,6 +10,10 @@ import io.github.bsels.semantic.version.models.MavenArtifact; import io.github.bsels.semantic.version.models.SemanticVersionBump; import io.github.bsels.semantic.version.models.VersionMarkdown; +import io.github.bsels.semantic.version.parameters.ArtifactIdentifier; +import io.github.bsels.semantic.version.utils.mapper.MavenArtifactArtifactOnlyDeserializer; +import io.github.bsels.semantic.version.utils.mapper.MavenArtifactArtifactOnlyKeyDeserializer; +import io.github.bsels.semantic.version.utils.mapper.MavenArtifactArtifactOnlySerializer; import io.github.bsels.semantic.version.utils.yaml.front.block.MarkdownYamFrontMatterBlockRendererFactory; import io.github.bsels.semantic.version.utils.yaml.front.block.YamlFrontMatterBlock; import io.github.bsels.semantic.version.utils.yaml.front.block.YamlFrontMatterExtension; @@ -64,6 +69,22 @@ public final class MarkdownUtils { private static final ObjectMapper YAML_MAPPER = new YAMLMapper() .configure(YAMLGenerator.Feature.WRITE_DOC_START_MARKER, false); + /// A pre-configured Jackson [ObjectMapper] instance for processing YAML data. + /// This [ObjectMapper] is designed specifically for serializing [MavenArtifact] objects + /// using the [MavenArtifactArtifactOnlySerializer]. + /// It is a static and final instance, + /// ensuring a consistent configuration that only serializes the artifact-level details of [MavenArtifact] objects. + /// + /// The [ObjectMapper] is initialized as a copy of the default YAML_MAPPER and extended + /// with a custom SimpleModule that registers the [MavenArtifactArtifactOnlySerializer]. + /// This configuration modifies the serialization behavior to focus exclusively on artifact-specific properties. + private static final ObjectMapper YAML_MAPPER_ARTIFACT_ONLY_SERIALIZER = YAML_MAPPER.copy() + .registerModule( + new SimpleModule() + .addKeySerializer(MavenArtifact.class, new MavenArtifactArtifactOnlySerializer()) + .addSerializer(MavenArtifact.class, new MavenArtifactArtifactOnlySerializer()) + ); + /// A statically defined parser built for processing CommonMark-based Markdown with certain custom configurations. /// This parser is configured to: /// - Utilize the [YamlFrontMatterExtension], which adds support for recognizing and processing YAML front matter @@ -117,10 +138,13 @@ private MarkdownUtils() { /// @return a [VersionMarkdown] object containing the parsed Markdown content and the extracted Maven artifact to semantic version bump mappings /// @throws NullPointerException if `log` or `markdownFile` is null /// @throws MojoExecutionException if an error occurs while reading the file, parsing the YAML front matter, or the Markdown does not contain the expected YAML front matter block - public static VersionMarkdown readVersionMarkdown(Log log, Path markdownFile) - throws NullPointerException, MojoExecutionException { + public static VersionMarkdown readVersionMarkdown( + Log log, Path markdownFile, ArtifactIdentifier identifier, String groupId + ) throws NullPointerException, MojoExecutionException { Objects.requireNonNull(log, "`log` must not be null"); Objects.requireNonNull(markdownFile, "`markdownFile` must not be null"); + Objects.requireNonNull(identifier, "`identifier` must not be null"); + Objects.requireNonNull(groupId, "`groupId` must not be null"); Node document = readMarkdown(log, markdownFile); if (!(document.getFirstChild() instanceof YamlFrontMatterBlock yamlFrontMatterBlock)) { @@ -132,7 +156,8 @@ public static VersionMarkdown readVersionMarkdown(Log log, Path markdownFile) Map bumps; try { log.debug("YAML front matter:\n%s".formatted(yaml.indent(4).stripTrailing())); - bumps = YAML_MAPPER.readValue(yaml, MAVEN_ARTIFACT_BUMP_MAP_TYPE); + bumps = prepareObjectMapperDeserialization(identifier, groupId) + .readValue(yaml, MAVEN_ARTIFACT_BUMP_MAP_TYPE); } catch (JsonProcessingException e) { throw new MojoExecutionException( "YAML front matter does not contain valid maven artifacts and semantic version bump", e @@ -192,6 +217,7 @@ public static Node parseMarkdown(String markdown) throws NullPointerException { /// /// @param changelog the root Node of the changelog Markdown structure to be updated; must not be null /// @param version the version string to be added to the changelog; must not be null + /// @param headerLine the header line format to be used for the new version heading; must not be null /// @param headerToNodes a mapping of SemanticVersionBump types to their associated Markdown nodes; must not be null /// @throws NullPointerException if any of the parameters `changelog`, `version`, or `headerToNodes` is null /// @throws IllegalArgumentException if the changelog is not a document or does not start with a single H1 heading titled "Changelog" @@ -199,10 +225,12 @@ public static Node parseMarkdown(String markdown) throws NullPointerException { public static void mergeVersionMarkdownsInChangelog( Node changelog, String version, + String headerLine, Map> headerToNodes ) throws NullPointerException, IllegalArgumentException { Objects.requireNonNull(changelog, "`changelog` must not be null"); Objects.requireNonNull(version, "`version` must not be null"); + Objects.requireNonNull(headerLine, "`headerFormatLine` must not be null"); Objects.requireNonNull(headerToNodes, "`headerToNodes` must not be null"); if (!(changelog instanceof Document document)) { @@ -217,7 +245,7 @@ public static void mergeVersionMarkdownsInChangelog( Heading newVersionHeading = new Heading(); newVersionHeading.setLevel(2); - newVersionHeading.appendChild(new Text("%s - %s".formatted(version, LocalDate.now()))); + newVersionHeading.appendChild(new Text(Utils.formatHeaderLine(headerLine, version, LocalDate.now()))); heading.insertAfter(newVersionHeading); Comparator>> comparator = Map.Entry.comparingByKey(); @@ -308,13 +336,15 @@ public static VersionMarkdown createSimpleVersionBumpDocument(MavenArtifact mave /// @throws NullPointerException if the provided map and log is null. /// @throws MojoExecutionException if an error occurs while constructing the YAML representation. public static YamlFrontMatterBlock createVersionBumpsHeader( - Log log, Map bumps + Log log, Map bumps, ArtifactIdentifier identifier ) throws NullPointerException, MojoExecutionException { Objects.requireNonNull(log, "`log` must not be null"); Objects.requireNonNull(bumps, "`bumps` must not be null"); + Objects.requireNonNull(identifier, "`identifier` must not be null"); String yaml; try { - yaml = YAML_MAPPER.writeValueAsString(bumps); + yaml = prepareObjectMapperSerialization(identifier) + .writeValueAsString(bumps).stripTrailing(); log.debug("Version bumps YAML:\n%s\n".formatted(yaml.indent(4).stripTrailing())); } catch (JsonProcessingException e) { throw new MojoExecutionException("Unable to construct version bump YAML", e); @@ -322,6 +352,37 @@ public static YamlFrontMatterBlock createVersionBumpsHeader( return new YamlFrontMatterBlock(yaml); } + /// Prepares and configures an [ObjectMapper] for deserializing YAML data based on the given identifier type. + /// + /// @param identifier The artifact identifier type used to determine the [ObjectMapper] configuration. It can be either [ArtifactIdentifier#GROUP_ID_AND_ARTIFACT_ID] or [ArtifactIdentifier#ONLY_ARTIFACT_ID]. + /// @param groupId The group ID to be used in the deserialization process when the identifier is [ArtifactIdentifier#ONLY_ARTIFACT_ID]. + /// @return A configured [ObjectMapper] instance for deserializing YAML data. + private static ObjectMapper prepareObjectMapperDeserialization(ArtifactIdentifier identifier, String groupId) { + return switch (identifier) { + case GROUP_ID_AND_ARTIFACT_ID -> YAML_MAPPER; + case ONLY_ARTIFACT_ID -> YAML_MAPPER.copy() + .registerModule( + new SimpleModule() + .addKeyDeserializer( + MavenArtifact.class, + new MavenArtifactArtifactOnlyKeyDeserializer(groupId) + ) + ); + }; + } + + /// Prepares and returns an [ObjectMapper] instance configured for serialization based on the provided artifact + /// identifier. + /// + /// @param identifier the artifact identifier that determines the specific [ObjectMapper] configuration to be used. It can either be [ArtifactIdentifier#GROUP_ID_AND_ARTIFACT_ID] or [ArtifactIdentifier#ONLY_ARTIFACT_ID]. + /// @return an [ObjectMapper] instance configured for the specified artifact identifier. + private static ObjectMapper prepareObjectMapperSerialization(ArtifactIdentifier identifier) { + return switch (identifier) { + case GROUP_ID_AND_ARTIFACT_ID -> YAML_MAPPER; + case ONLY_ARTIFACT_ID -> YAML_MAPPER_ARTIFACT_ONLY_SERIALIZER; + }; + } + /// Merges two [Node] instances by inserting the second node after the first node and returning the second node. /// /// @return a [BinaryOperator] that takes two [Node] instances, inserts the second node after the first, and returns the second node diff --git a/src/main/java/io/github/bsels/semantic/version/utils/ProcessUtils.java b/src/main/java/io/github/bsels/semantic/version/utils/ProcessUtils.java index 4027c84..340f6c8 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/ProcessUtils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/ProcessUtils.java @@ -1,12 +1,17 @@ package io.github.bsels.semantic.version.utils; +import io.github.bsels.semantic.version.models.VersionChange; import org.apache.maven.plugin.MojoExecutionException; import java.io.IOException; import java.nio.file.Path; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.function.Predicate; +import java.util.stream.Stream; /// Utility class providing methods for handling processes and editors. /// This class is not intended to be instantiated. @@ -53,6 +58,101 @@ public static String getDefaultEditor() { .orElseGet(ProcessUtils::fallbackOsEditor); } + /// Stages the specified files for a Git stash operation. + /// This method ensures the given list of files is non-null, non-empty, and contains no null elements. + /// It executes a Git command to add the provided files to staging. + /// + /// @param files the list of file paths to be stashed; must not be null, empty, or contain null elements + /// @throws NullPointerException if the `files` list or any element within the list is null + /// @throws IllegalArgumentException if the `files` list is empty + /// @throws MojoExecutionException if the Git command execution fails + public static void gitStashFiles(List files) + throws IllegalArgumentException, NullPointerException, MojoExecutionException { + Objects.requireNonNull(files, "`files` must not be null"); + files.forEach(file -> Objects.requireNonNull(file, "`file` in `files` must not be null")); + if (files.isEmpty()) { + throw new IllegalArgumentException("`files` must not be empty"); + } + List command = Stream.concat( + Stream.of("git", "add"), + files.stream().map(Path::toString) + ).toList(); + executeGitCommand(command, "Unable to add files to Git stash"); + } + + /// Commits staged changes in a Git repository with the given commit message. + /// This method constructs a Git commit command using the provided message and executes it as a system process. + /// + /// @param message the commit message to be used for the Git commit; must not be null + /// @throws NullPointerException if the `message` is null + /// @throws MojoExecutionException if the Git command execution fails + public static void gitCommit(String message) throws NullPointerException, MojoExecutionException { + Objects.requireNonNull(message, "`message` must not be null"); + executeGitCommand( + List.of("git", "commit", "-m", message), + "Unable to commit changes" + ); + } + + /// Executes the given script within the context of a specified project directory and applies version-related + /// environment variables. + /// Optionally, the execution can be a dry run or include Git stash behavior. + /// + /// @param script the path to the script to be executed; must not be null + /// @param projectPath the path to the project directory in which the script is executed; must not be null + /// @param versionChange an instance of [VersionChange] representing the old and new version values; must not be null + /// @param dryRun a boolean flag indicating whether the operation should simulate changes without applying them + /// @param stash a boolean flag indicating whether Git stash behavior should be applied during execution + /// @throws NullPointerException if any of the `script`, `projectPath`, or `versionChange` arguments are null + /// @throws MojoExecutionException if an I/O or interruption error occurs during script execution, or if the process exits with a non-zero status code + public static void executeScripts( + Path script, Path projectPath, VersionChange versionChange, boolean dryRun, boolean stash + ) throws NullPointerException, MojoExecutionException { + Objects.requireNonNull(script, "`script` must not be null"); + Objects.requireNonNull(projectPath, "`projectPath` must not be null"); + Objects.requireNonNull(versionChange, "`versionChange` must not be null"); + + try { + ProcessBuilder processBuilder = new ProcessBuilder(script.toString()); + Map environment = processBuilder.environment(); + environment.put("CURRENT_VERSION", versionChange.oldVersion()); + environment.put("NEW_VERSION", versionChange.newVersion()); + environment.put("DRY_RUN", Boolean.toString(dryRun)); + environment.put("GIT_STASH", Boolean.toString(stash)); + environment.put("EXECUTION_DATE", LocalDate.now().toString()); + Process process = processBuilder.directory(projectPath.toFile()) + .inheritIO() + .start(); + if (process.waitFor() != 0) { + throw new MojoExecutionException("Script execution failed."); + } + } catch (IOException | InterruptedException e) { + throw new MojoExecutionException("Script execution failed.", e); + } + } + + /// Executes the specified Git command as a system process and monitors its exit status. + /// This method blocks until the process completes + /// and throws an exception if the process exits with a non-zero status code. + /// + /// @param command the list of strings representing the Git command and its arguments; must not be null + /// @param processExitNonZero the error message to throw if the process exits with a non-zero status code; must not be null + /// @throws MojoExecutionException if an I/O error, process interruption, or non-zero exit status occurs + private static void executeGitCommand(List command, String processExitNonZero) + throws MojoExecutionException { + try { + Process process = new ProcessBuilder() + .command(command) + .inheritIO() + .start(); + if (process.waitFor() != 0) { + throw new MojoExecutionException(processExitNonZero); + } + } catch (IOException | InterruptedException e) { + throw new MojoExecutionException(processExitNonZero, e); + } + } + /// Determines the fallback text editor based on the operating system. /// If the operating system is identified as Windows, the method returns "notepad". /// Otherwise, it returns "vi" as a default editor for non-Windows systems. diff --git a/src/main/java/io/github/bsels/semantic/version/utils/Utils.java b/src/main/java/io/github/bsels/semantic/version/utils/Utils.java index 4b348e9..bce271f 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/Utils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/Utils.java @@ -1,5 +1,6 @@ package io.github.bsels.semantic.version.utils; +import io.github.bsels.semantic.version.models.PlaceHolderWithType; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.project.MavenProject; @@ -7,9 +8,11 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Collection; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -18,6 +21,8 @@ import java.util.function.BinaryOperator; import java.util.function.Function; import java.util.function.Predicate; +import java.util.regex.MatchResult; +import java.util.regex.Pattern; import java.util.stream.Collector; import java.util.stream.Collectors; @@ -41,6 +46,20 @@ public final class Utils { /// The formatter is thread-safe and can be used in concurrent environments. public static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + /// A regular expression pattern used to extract and match specific placeholders from a string. + /// + /// The placeholders supported by this pattern are: + /// - `{date}`: A basic date placeholder. + /// - `{date#}`: A date placeholder that specifies a date formatting pattern + /// within angled brackets following a '#' character. + /// - `version`: A placeholder representing a version value. + /// + /// This pattern is mainly used for parsing or identifying templated strings that include + /// dynamically replaceable placeholders for date and version values. + private static final Pattern PLACEHOLDER_FORMAT_EXTRACTOR = Pattern.compile("\\{(date(#([^{}]*))?|version)}"); + + private static final Map CACHED_DATE_FORMATTERS = new HashMap<>(); + /// Utility class containing static constants and methods for various common operations. /// This class is not designed to be instantiated. private Utils() { @@ -159,6 +178,67 @@ public static Path resolveVersioningFile(Path folder) throws NullPointerExceptio return folder.resolve("versioning-%s.md".formatted(DATE_TIME_FORMATTER.format(LocalDateTime.now()))); } + /// Prepares a format string by replacing placeholders defined in the given list of keys with formatted substitution + /// placeholders using their formatType and position. + /// + /// @param formatString the string containing placeholders to be replaced. Must not be null. + /// @param keys a list of `PlaceHolderWithType` objects representing placeholders and their types. Each item in the list must not be null, and the list itself must also not be null. + /// @return a string with all placeholders replaced by formatted substitution placeholders. + /// @throws NullPointerException if `formatString`, `keys`, or any element in `keys` is null. + public static String prepareFormatString(String formatString, List keys) + throws NullPointerException { + Objects.requireNonNull(formatString, "`formatString` must not be null"); + Objects.requireNonNull(keys, "`keys` must not be null"); + keys.forEach(key -> Objects.requireNonNull(key, "All keys must not be null")); + for (int i = 0; i < keys.size(); i++) { + PlaceHolderWithType currentKey = keys.get(i); + formatString = formatString.replace( + "{" + currentKey.placeholder() + "}", + "%" + (i + 1) + "$" + currentKey.formatType() + ); + } + return formatString; + } + + /// Formats the provided header line by replacing placeholders with the corresponding values. + /// Placeholders include `{version}` for the version string + /// and `{date}` or a custom date pattern for the date component. + /// + /// @param headerLine the header line string containing placeholders to be replaced; must not be null + /// @param version the version string to replace the `{version}` placeholder; must not be null + /// @param date the date object to replace the `{date}` or custom date pattern placeholders; must not be null + /// @return a new string where placeholders in the header line are replaced with specified values + /// @throws NullPointerException if any of the provided arguments are null + public static String formatHeaderLine(String headerLine, String version, LocalDate date) + throws NullPointerException { + Objects.requireNonNull(headerLine, "`headerLine` must not be null"); + Objects.requireNonNull(version, "`version` must not be null"); + Objects.requireNonNull(date, "`date` must not be null"); + return PLACEHOLDER_FORMAT_EXTRACTOR.matcher(headerLine) + .replaceAll(match -> replaceVersionOrDateOnMatch(version, date, match)); + } + + /// Replaces a placeholder in a matched pattern with either a version string or a formatted date, + /// based on the placeholder's content. + /// + /// @param version the version string to replace the `{version}` placeholder + /// @param date the date to replace date-related placeholders + /// @param match the result of a regex match containing the placeholder to be replaced + /// @return the replacement string for the matched placeholder, either the version or the formatted date + private static String replaceVersionOrDateOnMatch(String version, LocalDate date, MatchResult match) { + String placeholder = match.group(0); + if ("{version}".equals(placeholder)) { + return version; + } + final DateTimeFormatter formatter; + if ("{date}".equals(placeholder)) { + formatter = DateTimeFormatter.ISO_LOCAL_DATE; + } else { + formatter = CACHED_DATE_FORMATTERS.computeIfAbsent(match.group(3), DateTimeFormatter::ofPattern); + } + return date.format(formatter); + } + /// Returns a predicate that always evaluates to `true`. /// /// @param the type of the input to the predicate diff --git a/src/main/java/io/github/bsels/semantic/version/utils/mapper/MavenArtifactArtifactOnlyDeserializer.java b/src/main/java/io/github/bsels/semantic/version/utils/mapper/MavenArtifactArtifactOnlyDeserializer.java new file mode 100644 index 0000000..e6def96 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/utils/mapper/MavenArtifactArtifactOnlyDeserializer.java @@ -0,0 +1,61 @@ +package io.github.bsels.semantic.version.utils.mapper; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import io.github.bsels.semantic.version.models.MavenArtifact; + +import java.io.IOException; +import java.util.Objects; + +/// A custom deserializer for creating instances of `MavenArtifact` using only the artifact ID +/// from JSON input combined with a predefined group ID. +/// +/// This class extends the `JsonDeserializer` class to facilitate deserialization of JSON input +/// into `MavenArtifact` objects with a known group ID and a dynamically parsed artifact ID. +/// It is primarily intended for scenarios where the group ID is constant and only the artifact ID +/// is variable within the JSON data. +/// +/// The deserialization process extracts the artifact ID from the JSON input +/// and constructs the `MavenArtifact` by pairing it with the predefined group ID. +/// +/// Thread Safety: +/// Instances of this deserializer are thread-safe, as the `groupId` is immutable +/// and the deserialization process does not rely on any shared mutable state. +public class MavenArtifactArtifactOnlyDeserializer extends JsonDeserializer { + /// Represents the predefined group ID associated with a Maven artifact. + /// + /// This field is used to specify the group ID that will be combined with an artifact ID + /// during the deserialization process to construct a `MavenArtifact` object. + /// It is a constant value set during the instantiation of the deserializer + /// and remains immutable throughout the lifecycle of the deserializer. + /// + /// Thread Safety: This field is declared as `final` and is therefore thread-safe. + private final String groupId; + + /// Constructs a `MavenArtifactArtifactOnlyDeserializer` with the specified group ID. + /// + /// This deserializer is used to deserialize a JSON input into a `MavenArtifact` + /// by combining the predefined group ID with the artifact ID parsed from the JSON. + /// + /// @param groupId the predefined group ID to be associated with the Maven artifact; must not be null + /// @throws NullPointerException if `groupId` is null + public MavenArtifactArtifactOnlyDeserializer(String groupId) throws NullPointerException { + super(); + this.groupId = Objects.requireNonNull(groupId, "`groupId` must not be null"); + } + + /// Deserializes a JSON string into a `MavenArtifact` instance. + /// The deserialization process reads the artifact ID of the Maven artifact from the JSON input + /// and combines it with the predefined group ID to construct a `MavenArtifact` object. + /// + /// @param p the `JsonParser` used to parse the JSON input; must not be null + /// @param ctxt the `DeserializationContext` that can be used to access information about the deserialization process; must not be null + /// @return a new `MavenArtifact` instance constructed with the predefined group ID and the artifact ID obtained from the JSON input + /// @throws IOException if an I/O error occurs during parsing + @Override + public MavenArtifact deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + String artifactId = p.readValueAs(String.class); + return new MavenArtifact(groupId, artifactId); + } +} diff --git a/src/main/java/io/github/bsels/semantic/version/utils/mapper/MavenArtifactArtifactOnlyKeyDeserializer.java b/src/main/java/io/github/bsels/semantic/version/utils/mapper/MavenArtifactArtifactOnlyKeyDeserializer.java new file mode 100644 index 0000000..c24715e --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/utils/mapper/MavenArtifactArtifactOnlyKeyDeserializer.java @@ -0,0 +1,73 @@ +package io.github.bsels.semantic.version.utils.mapper; + +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.KeyDeserializer; +import io.github.bsels.semantic.version.models.MavenArtifact; + +import java.util.Objects; + +/// A custom deserializer for YAML keys that only specify the artifact ID of a Maven artifact. +/// +/// This deserializer maps YAML keys to [MavenArtifact] instances by combining a predefined group ID +/// with the artifact ID extracted from the YAML key. +/// The deserialized object represents a Maven artifact with both the group ID and artifact ID set. +/// +/// Thread Safety: This class is immutable and thread-safe as it uses a final group ID +/// and does not retain the mutable state. +/// +/// Responsibilities: +/// - Extract the artifact ID from the provided YAML key. +/// - Combine the artifact ID with the group ID supplied during construction to create a Maven artifact object. +/// +/// This class extends the [KeyDeserializer] from Jackson's data-binding framework. +/// +/// Constructor Parameters: +/// - `groupId`: The predefined group ID to be paired with the artifact ID from the YAML key. +/// This parameter must not be null. +/// +/// Method Details: +/// - [#deserializeKey(String, DeserializationContext)]: Combines the predefined group ID with the artifact ID +/// (extracted from the YAML key) to create a new [MavenArtifact] instance. +/// +/// Usage Context: +/// This deserializer is intended for use in scenarios where the YAML key contains only the artifact ID, +/// and a fixed group ID must be applied to construct the complete Maven artifact representation. +public final class MavenArtifactArtifactOnlyKeyDeserializer extends KeyDeserializer { + + /// The predefined group ID used to pair with the artifact ID extracted from the YAML key. + /// + /// Responsibilities: + /// - This constant represents the fixed group ID combined with the artifact ID to construct a [MavenArtifact] + /// instance during key deserialization. + /// + /// Constraints: + /// - This value is immutable and must not be null. It is initialized at construction time. + /// + /// Thread Safety: + /// - This field is `final` and thus guarantees immutability, ensuring thread-safe usage. + private final String groupId; + + /// Constructs a new instance of `MavenArtifactArtifactOnlyKeyDeserializer` with the specified group ID. + /// + /// This deserializer is used to handle deserialization of YAML keys into `MavenArtifact` objects by combining + /// a predefined group ID with an artifact ID. + /// The group ID is immutable and specified at construction time. + /// + /// @param groupId the predefined group ID to associate with the artifacts; must not be null + /// @throws NullPointerException if `groupId` is null + public MavenArtifactArtifactOnlyKeyDeserializer(String groupId) throws NullPointerException { + this.groupId = Objects.requireNonNull(groupId, "`groupId` must not be null"); + } + + /// Deserializes a YAML key into a [MavenArtifact] object by combining a predefined group ID + /// with the provided artifact ID. + /// + /// @param key the YAML key representing the artifact ID; must not be null + /// @param ctxt the Jackson deserialization context providing additional configuration information; may be null + /// @return a new [MavenArtifact] instance constructed by combining the predefined group ID with the provided artifact ID + /// @throws NullPointerException if the key is null + @Override + public MavenArtifact deserializeKey(String key, DeserializationContext ctxt) { + return new MavenArtifact(groupId, key); + } +} diff --git a/src/main/java/io/github/bsels/semantic/version/utils/mapper/MavenArtifactArtifactOnlySerializer.java b/src/main/java/io/github/bsels/semantic/version/utils/mapper/MavenArtifactArtifactOnlySerializer.java new file mode 100644 index 0000000..e78d282 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/utils/mapper/MavenArtifactArtifactOnlySerializer.java @@ -0,0 +1,56 @@ +package io.github.bsels.semantic.version.utils.mapper; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import io.github.bsels.semantic.version.models.MavenArtifact; + +import java.io.IOException; + +/// A custom serializer for the [MavenArtifact] class that serializes only the artifact ID of the Maven artifact. +/// +/// This serializer is designed to selectively output the `artifactId` component of a [MavenArtifact]. +/// The [#serialize(MavenArtifact, JsonGenerator, SerializerProvider)] method leverages the [MavenArtifact#artifactId()] +/// method to retrieve the artifact ID and write it as a JSON string. +/// +/// The purpose of this serializer is to provide a minimalist representation of Maven artifacts in JSON format, +/// omitting other properties such as the group ID. +/// +/// This class extends `JsonSerializer` and is intended to be used with Jackson's object mapping +/// framework. +/// +/// Thread Safety: This class is stateless and thread-safe. +public final class MavenArtifactArtifactOnlySerializer extends JsonSerializer { + + /// Constructs a new instance of `MavenArtifactArtifactOnlySerializer`. + /// + /// This is a custom serializer for the [MavenArtifact] class used to serialize only the `artifactId` property + /// of a [MavenArtifact] instance. + /// The serializer is stateless and does not require any initialization. + /// + /// This constructor does not perform any additional setup or configuration, + /// as the serializer is designed for minimalist representation of [MavenArtifact] objects, + /// focusing solely on the `artifactId`. + public MavenArtifactArtifactOnlySerializer() { + super(); + } + + /// Serializes a [MavenArtifact] instance by writing its artifact ID as a JSON string. + /// + /// @param mavenArtifact the [MavenArtifact] instance to serialize; must not be null + /// @param jsonGenerator the [JsonGenerator] used to write JSON content; must not be null + /// @param serializerProvider the [SerializerProvider] that can be used to get serializers for serializing other types of objects if necessary; must not be null + /// @throws IOException if an I/O error occurs during JSON generation + @Override + public void serialize( + MavenArtifact mavenArtifact, + JsonGenerator jsonGenerator, + SerializerProvider serializerProvider + ) throws IOException { + if (jsonGenerator.getOutputContext().hasCurrentName()) { + jsonGenerator.writeString(mavenArtifact.artifactId()); + } else { + jsonGenerator.writeFieldName(mavenArtifact.artifactId()); + } + } +} diff --git a/src/main/java/io/github/bsels/semantic/version/utils/mapper/package-info.java b/src/main/java/io/github/bsels/semantic/version/utils/mapper/package-info.java new file mode 100644 index 0000000..dc01e84 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/utils/mapper/package-info.java @@ -0,0 +1,2 @@ +/// Custom serialization and deserialization utilities for the Semantic Versioning plugin. +package io.github.bsels.semantic.version.utils.mapper; \ No newline at end of file diff --git a/src/test/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojoTest.java b/src/test/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojoTest.java index 03d1f22..1352443 100644 --- a/src/test/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojoTest.java +++ b/src/test/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojoTest.java @@ -1,6 +1,8 @@ package io.github.bsels.semantic.version; import io.github.bsels.semantic.version.models.SemanticVersionBump; +import io.github.bsels.semantic.version.parameters.ArtifactIdentifier; +import io.github.bsels.semantic.version.parameters.Git; import io.github.bsels.semantic.version.parameters.Modus; import io.github.bsels.semantic.version.test.utils.ReadMockedMavenSession; import io.github.bsels.semantic.version.test.utils.TestLog; @@ -31,8 +33,10 @@ import java.nio.file.OpenOption; import java.nio.file.Path; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Scanner; @@ -55,6 +59,7 @@ public class CreateVersionMarkdownMojoTest extends AbstractBaseMojoTest { private TestLog testLog; private Map mockedOutputFiles; private Set mockedCreatedDirectories; + private List> mockedExecutedProcesses; private MockedStatic filesMockedStatic; private MockedStatic localDateTimeMockedStatic; private MockedConstruction mockedProcessBuilderConstruction; @@ -68,6 +73,7 @@ public void setUp() { mockedOutputFiles = new HashMap<>(); mockedCreatedDirectories = new HashSet<>(); + mockedExecutedProcesses = new ArrayList<>(); filesMockedStatic = Mockito.mockStatic(Files.class, Mockito.CALLS_REAL_METHODS); filesMockedStatic.when(() -> Files.newBufferedWriter(Mockito.any(), Mockito.any(), Mockito.any(OpenOption[].class))) @@ -96,6 +102,17 @@ public void setUp() { .thenReturn(DATE_TIME); mockedProcessBuilderConstruction = Mockito.mockConstruction(ProcessBuilder.class, (mock, context) -> { + if (!context.arguments().isEmpty()) { + List command = List.of((String[]) context.arguments().get(0)); + if (!command.isEmpty()) { + mockedExecutedProcesses.add(command); + } + } + Mockito.when(mock.command(Mockito.anyList())) + .thenAnswer(invocation -> { + mockedExecutedProcesses.add(invocation.getArgument(0)); + return mock; + }); Mockito.when(mock.inheritIO()).thenReturn(mock); Mockito.when(mock.start()).thenReturn(processMock); }); @@ -142,6 +159,8 @@ void noExecutionOnSubProjectIfDisabled_SkipExecution() { assertThat(mockedOutputFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @ParameterizedTest @@ -211,6 +230,8 @@ void noProjectsSelected_LogWarning() { assertThat(mockedOutputFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @ParameterizedTest @@ -273,17 +294,20 @@ Please type the changelog entry here (enter empty line to open external editor, .isEqualTo(""" --- org.example.itests.multi:parent: "%s" - --- Testing """.formatted(bump)) ); + assertThat(mockedExecutedProcesses) + .isEmpty(); } - @Test - void selectMultipleProjects_Valid() { + @ParameterizedTest + @EnumSource(value = Git.class) + void selectMultipleProjects_Valid(Git gitMode) { classUnderTest.dryRun = false; + classUnderTest.git = gitMode; try (MockedConstruction ignored = Mockito.mockConstruction( Scanner.class, (mock, context) -> { @@ -362,6 +386,22 @@ Please type the changelog entry here (enter empty line to open external editor, .contains("---\n") .contains("Testing") ); + + if (Git.NO_GIT == gitMode) { + assertThat(mockedExecutedProcesses) + .isEmpty(); + } else if (Git.STASH == gitMode) { + assertThat(mockedExecutedProcesses) + .hasSize(1) + .containsExactly(List.of("git", "add", getVersioningMarkdown().toString())); + } else { + assertThat(mockedExecutedProcesses) + .hasSize(2) + .containsExactly( + List.of("git", "add", getVersioningMarkdown().toString()), + List.of("git", "commit", "-m", "Created version Markdown file for 3 project(s)") + ); + } } private Path getVersioningMarkdown() { @@ -414,7 +454,64 @@ void dryRunInlineEditor_Valid(SemanticVersionBump bump) { Dry-run: new markdown file at %s: --- org.example.itests.single:project: "%s" + --- + Testing + """.formatted(getSingleVersioningMarkdown(), bump)) + ); + + assertThat(outputStream.toString()) + .isEqualTo(""" + Project org.example.itests.single:project + Select semantic version bump:\s + 1: PATCH + 2: MINOR + 3: MAJOR + Enter semantic version name or number: \ + Version bumps: 'org.example.itests.single:project': %S + Please type the changelog entry here (enter empty line to open external editor, \ + two empty lines after your input to end): + """.formatted(bump)); + + assertThat(mockedOutputFiles) + .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); + } + + @ParameterizedTest + @EnumSource(value = SemanticVersionBump.class, names = {"MAJOR", "MINOR", "PATCH"}) + void dryRunInlineEditor_ArtifactOnlyIdentifier_Valid(SemanticVersionBump bump) { + classUnderTest.dryRun = true; + classUnderTest.identifier = ArtifactIdentifier.ONLY_ARTIFACT_ID; + + try (MockedConstruction ignored = Mockito.mockConstruction( + Scanner.class, (mock, context) -> { + Mockito.when(mock.hasNextLine()).thenReturn(true, false); + if (context.getCount() == 1) { + Mockito.when(mock.nextLine()).thenReturn(bump.name()); + } else { + Mockito.when(mock.nextLine()).thenReturn("Testing"); + } + } + )) { + assertThatNoException() + .isThrownBy(classUnderTest::execute); + } + + assertThat(testLog.getLogRecords()) + .isNotEmpty() + .hasSize(3) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.single:project:1.0.0"), + validateLogRecordDebug(""" + Version bumps YAML: + project: "%s" + """.formatted(bump)), + validateLogRecordInfo(""" + Dry-run: new markdown file at %s: + --- + project: "%s" --- Testing @@ -436,6 +533,8 @@ Please type the changelog entry here (enter empty line to open external editor, assertThat(mockedOutputFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @Test @@ -483,6 +582,11 @@ Please type the changelog entry here (enter empty line to open external editor, assertThat(mockedOutputFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .hasSize(1) + .containsExactly( + List.of("vi", TEMP_FILE.toString()) + ); } @ParameterizedTest @@ -538,12 +642,53 @@ Please type the changelog entry here (enter empty line to open external editor, .isEqualTo(""" --- org.example.itests.single:project: "%s" - --- Testing external """.formatted(bump)) ); + assertThat(mockedExecutedProcesses) + .hasSize(1) + .containsExactly( + List.of("vi", TEMP_FILE.toString()) + ); + } + + @ParameterizedTest + @EnumSource(Git.class) + void inlineEditorGitIntegration_Valid(Git gitMode) { + classUnderTest.dryRun = false; + classUnderTest.git = gitMode; + + try (MockedConstruction ignored = Mockito.mockConstruction( + Scanner.class, (mock, context) -> { + Mockito.when(mock.hasNextLine()).thenReturn(true, false); + if (context.getCount() == 1) { + Mockito.when(mock.nextLine()).thenReturn(SemanticVersionBump.MAJOR.name()); + } else { + Mockito.when(mock.nextLine()).thenReturn("Testing"); + } + } + )) { + assertThatNoException() + .isThrownBy(classUnderTest::execute); + } + + if (Git.NO_GIT == gitMode) { + assertThat(mockedExecutedProcesses) + .isEmpty(); + } else if (Git.STASH == gitMode) { + assertThat(mockedExecutedProcesses) + .hasSize(1) + .containsExactly(List.of("git", "add", getSingleVersioningMarkdown().toString())); + } else { + assertThat(mockedExecutedProcesses) + .hasSize(2) + .containsExactly( + List.of("git", "add", getSingleVersioningMarkdown().toString()), + List.of("git", "commit", "-m", "Created version Markdown file for 1 project(s)") + ); + } } private Path getSingleVersioningMarkdown() { diff --git a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java index a5a4613..64cf03d 100644 --- a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java +++ b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java @@ -1,6 +1,8 @@ package io.github.bsels.semantic.version; import io.github.bsels.semantic.version.models.SemanticVersionBump; +import io.github.bsels.semantic.version.parameters.ArtifactIdentifier; +import io.github.bsels.semantic.version.parameters.Git; import io.github.bsels.semantic.version.parameters.Modus; import io.github.bsels.semantic.version.parameters.VersionBump; import io.github.bsels.semantic.version.test.utils.ReadMockedMavenSession; @@ -15,12 +17,14 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.Mock; import org.mockito.MockedConstruction; import org.mockito.MockedStatic; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import java.io.BufferedWriter; +import java.io.File; import java.io.IOException; import java.io.StringWriter; import java.nio.file.CopyOption; @@ -42,14 +46,17 @@ @ExtendWith(MockitoExtension.class) public class UpdatePomMojoTest extends AbstractBaseMojoTest { private static final LocalDate DATE = LocalDate.of(2025, 1, 1); + @Mock + Process processMock; private UpdatePomMojo classUnderTest; private TestLog testLog; private Map mockedOutputFiles; private List mockedCopiedFiles; private List mockedDeletedFiles; - + private List> mockedExecutedProcesses; private MockedStatic filesMockedStatic; private MockedStatic localDateMockedStatic; + private MockedConstruction mockedProcessBuilderConstruction; @BeforeEach void setUp() { @@ -59,6 +66,7 @@ void setUp() { mockedOutputFiles = new HashMap<>(); mockedCopiedFiles = new ArrayList<>(); mockedDeletedFiles = new ArrayList<>(); + mockedExecutedProcesses = new ArrayList<>(); filesMockedStatic = Mockito.mockStatic(Files.class, Mockito.CALLS_REAL_METHODS); filesMockedStatic.when(() -> Files.newBufferedWriter(Mockito.any(), Mockito.any(), Mockito.any(OpenOption[].class))) @@ -85,12 +93,32 @@ void setUp() { localDateMockedStatic = Mockito.mockStatic(LocalDate.class); localDateMockedStatic.when(LocalDate::now) .thenReturn(DATE); + + mockedProcessBuilderConstruction = Mockito.mockConstruction(ProcessBuilder.class, (mock, context) -> { + if (!context.arguments().isEmpty()) { + List command = List.of((String[]) context.arguments().get(0)); + if (!command.isEmpty()) { + mockedExecutedProcesses.add(command); + } + } + Map environment = new HashMap<>(); + Mockito.when(mock.command(Mockito.anyList())) + .thenAnswer(invocation -> { + mockedExecutedProcesses.add(invocation.getArgument(0)); + return mock; + }); + Mockito.when(mock.environment()).thenReturn(environment); + Mockito.when(mock.directory(Mockito.any())).thenReturn(mock); + Mockito.when(mock.inheritIO()).thenReturn(mock); + Mockito.when(mock.start()).thenReturn(processMock); + }); } @AfterEach void tearDown() { filesMockedStatic.close(); localDateMockedStatic.close(); + mockedProcessBuilderConstruction.close(); } @Test @@ -121,6 +149,8 @@ void noExecutionOnSubProjectIfDisabled_SkipExecution() { .isEmpty(); assertThat(mockedDeletedFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @ParameterizedTest @@ -154,6 +184,8 @@ void noProjectsInScope_LogsWarning(Modus modus) { .isEmpty(); assertThat(mockedDeletedFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @Nested @@ -265,6 +297,8 @@ void fixedVersionBump_Valid(VersionBump versionBump) { .isEmpty(); assertThat(mockedDeletedFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @Test @@ -310,6 +344,8 @@ void noSemanticVersionBumpFileBased_NothingChanged() { .isEmpty(); assertThat(mockedDeletedFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @Test @@ -477,11 +513,185 @@ void singleFileBased_Valid() { .isNotEmpty() .hasSize(1) .containsExactly(getResourcesPath("versioning", "leaves", "single", "versioning.md")); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @Test - void multiFileBased_Valid() { + void singleFileBased_ArtifactOnlyIdentifier_Valid() { classUnderTest.versionBump = VersionBump.FILE_BASED; + classUnderTest.identifier = ArtifactIdentifier.ONLY_ARTIFACT_ID; + classUnderTest.versionDirectory = getResourcesPath("versioning", "leaves", "single-artifact-only"); + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + assertThat(testLog.getLogRecords()) + .hasSize(21) + .satisfiesExactlyInAnyOrder( + validateLogRecordInfo("Execution for project: org.example.itests.leaves:root:5.0.0-root"), + validateLogRecordInfo("Read 7 lines from %s".formatted( + getResourcesPath("versioning", "leaves", "single-artifact-only", "versioning.md") + )), + validateLogRecordDebug(""" + YAML front matter: + child-1: patch + child-2: minor + child-3: major\ + """), + validateLogRecordDebug(""" + Maven artifacts and semantic version bumps: + {org.example.itests.leaves:child-2=MINOR, org.example.itests.leaves:child-1=PATCH, \ + org.example.itests.leaves:child-3=MAJOR}\ + """), + validateLogRecordInfo("Multiple projects in scope"), + validateLogRecordInfo("Found 3 projects in scope"), + validateLogRecordInfo("Updating version with a PATCH semantic version"), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("leaves", "child-1", "CHANGELOG.md") + )), + validateLogRecordDebug("Original changelog"), + validateLogRecordDebug("Updated changelog"), + validateLogRecordInfo("Updating version with a MINOR semantic version"), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("leaves", "intermediate", "child-2", "CHANGELOG.md") + )), + validateLogRecordDebug("Original changelog"), + validateLogRecordDebug("Updated changelog"), + validateLogRecordInfo("Updating version with a MAJOR semantic version"), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("leaves", "intermediate", "child-3", "CHANGELOG.md") + )), + validateLogRecordDebug("Original changelog"), + validateLogRecordDebug("Updated changelog"), + validateLogRecordDebug("Updating project org.example.itests.leaves:child-1"), + validateLogRecordDebug("Updating project org.example.itests.leaves:child-2"), + validateLogRecordDebug("Updating project org.example.itests.leaves:child-3") + ); + + assertThat(mockedOutputFiles) + .hasSize(6) + .hasEntrySatisfying( + getResourcesPath("leaves", "child-1", "pom.xml"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + + + 4.0.0 + org.example.itests.leaves + child-1 + 5.0.1-child-1 + + """ + ) + ) + .hasEntrySatisfying( + getResourcesPath("leaves", "intermediate", "child-2", "pom.xml"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + + + 4.0.0 + org.example.itests.leaves + child-2 + 5.1.0-child-2 + + """ + ) + ) + .hasEntrySatisfying( + getResourcesPath("leaves", "intermediate", "child-3", "pom.xml"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + + + 4.0.0 + org.example.itests.leaves + child-3 + 6.0.0-child-3 + + """ + ) + ) + .hasEntrySatisfying( + getResourcesPath("leaves", "child-1", "CHANGELOG.md"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + # Changelog + + ## 5.0.1-child-1 - 2025-01-01 + + ### Patch + + Different versions bump in different modules. + + ## 5.0.0-child-1 - 2026-01-01 + + Initial child 1 release. + """ + ) + ) + .hasEntrySatisfying( + getResourcesPath("leaves", "intermediate", "child-2", "CHANGELOG.md"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + # Changelog + + ## 5.1.0-child-2 - 2025-01-01 + + ### Minor + + Different versions bump in different modules. + + ## 5.0.0-child-2 - 2026-01-01 + + Initial child 2 release. + """ + ) + ) + .hasEntrySatisfying( + getResourcesPath("leaves", "intermediate", "child-3", "CHANGELOG.md"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + # Changelog + + ## 6.0.0-child-3 - 2025-01-01 + + ### Major + + Different versions bump in different modules. + + ## 5.0.0-child-3 - 2026-01-01 + + Initial child 3 release. + """ + ) + ); + assertThat(mockedCopiedFiles) + .isEmpty(); + + assertThat(mockedDeletedFiles) + .isNotEmpty() + .hasSize(1) + .containsExactly(getResourcesPath("versioning", "leaves", "single-artifact-only", "versioning.md")); + assertThat(mockedExecutedProcesses) + .isEmpty(); + } + + @ParameterizedTest + @EnumSource(Git.class) + void multiFileBased_Valid(Git gitMode) { + classUnderTest.versionBump = VersionBump.FILE_BASED; + classUnderTest.git = gitMode; classUnderTest.versionDirectory = getResourcesPath("versioning", "leaves", "multi"); assertThatNoException() @@ -667,6 +877,55 @@ void multiFileBased_Valid() { getResourcesPath("versioning", "leaves", "multi", "child-2.md"), getResourcesPath("versioning", "leaves", "multi", "child-3.md") ); + if (Git.NO_GIT == gitMode) { + assertThat(mockedExecutedProcesses) + .isEmpty(); + } else if (Git.STASH == gitMode) { + assertThat(mockedExecutedProcesses) + .hasSize(7) + .contains( + List.of("git", "add", getResourcesPath("leaves", "child-1", "pom.xml").toString()), + List.of("git", "add", getResourcesPath("leaves", "child-1", "CHANGELOG.md").toString()), + List.of("git", "add", getResourcesPath("leaves", "intermediate", "child-2", "pom.xml").toString()), + List.of("git", "add", getResourcesPath("leaves", "intermediate", "child-2", "CHANGELOG.md").toString()), + List.of("git", "add", getResourcesPath("leaves", "intermediate", "child-3", "pom.xml").toString()), + List.of("git", "add", getResourcesPath("leaves", "intermediate", "child-3", "CHANGELOG.md").toString()) + ) + .anySatisfy( + command -> assertThat(command) + .startsWith("git", "add") + .containsExactlyInAnyOrder( + "git", + "add", + getResourcesPath("versioning", "leaves", "multi", "child-1.md").toString(), + getResourcesPath("versioning", "leaves", "multi", "child-2.md").toString(), + getResourcesPath("versioning", "leaves", "multi", "child-3.md").toString() + ) + ); + } else { + assertThat(mockedExecutedProcesses) + .hasSize(8) + .contains( + List.of("git", "add", getResourcesPath("leaves", "child-1", "pom.xml").toString()), + List.of("git", "add", getResourcesPath("leaves", "child-1", "CHANGELOG.md").toString()), + List.of("git", "add", getResourcesPath("leaves", "intermediate", "child-2", "pom.xml").toString()), + List.of("git", "add", getResourcesPath("leaves", "intermediate", "child-2", "CHANGELOG.md").toString()), + List.of("git", "add", getResourcesPath("leaves", "intermediate", "child-3", "pom.xml").toString()), + List.of("git", "add", getResourcesPath("leaves", "intermediate", "child-3", "CHANGELOG.md").toString()), + List.of("git", "commit", "-m", "Updated 3 project version(s) [skip ci]") + ) + .anySatisfy( + command -> assertThat(command) + .startsWith("git", "add") + .containsExactlyInAnyOrder( + "git", + "add", + getResourcesPath("versioning", "leaves", "multi", "child-1.md").toString(), + getResourcesPath("versioning", "leaves", "multi", "child-2.md").toString(), + getResourcesPath("versioning", "leaves", "multi", "child-3.md").toString() + ) + ); + } } } @@ -909,6 +1168,8 @@ void handleDependencyCorrect_NoErrors( .isNotEmpty() .hasSize(1) .containsExactly(getResourcesPath("versioning", "multi", dependency, "versioning.md")); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @Test @@ -962,6 +1223,8 @@ void independentProject_NoDependencyUpdates() { .isNotEmpty() .hasSize(1) .containsExactly(getResourcesPath("versioning", "multi", "excluded", "versioning.md")); + assertThat(mockedExecutedProcesses) + .isEmpty(); } } @@ -1108,6 +1371,8 @@ void handleMultiRecursiveProjectCorrect_NoErrors() { .isNotEmpty() .hasSize(1) .containsExactly(getResourcesPath("versioning", "multi-recursive", "versioning.md")); + assertThat(mockedExecutedProcesses) + .isEmpty(); } } @@ -1203,6 +1468,8 @@ void fixedVersionBump_Valid(VersionBump versionBump) { .isEmpty(); assertThat(mockedDeletedFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @ParameterizedTest @@ -1307,6 +1574,8 @@ void fixedVersionBumpWithBackup_Valid(VersionBump versionBump) { ); assertThat(mockedDeletedFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @ParameterizedTest @@ -1385,6 +1654,8 @@ void fixedVersionBumpDryRun_Valid(VersionBump versionBump) { .isEmpty(); assertThat(mockedDeletedFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @ParameterizedTest @@ -1430,6 +1701,8 @@ void dryRunStringWriteCloseFailure_ThrowMojoExecutionException(VersionBump versi .isEmpty(); assertThat(mockedDeletedFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @Test @@ -1456,6 +1729,8 @@ void filedBasedWalkFailed_ThrowMojoExecutionException() { .isEmpty(); assertThat(mockedDeletedFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @Test @@ -1494,6 +1769,8 @@ void unknownProjectFileBased_ThrowMojoFailureException() { .isEmpty(); assertThat(mockedDeletedFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @Test @@ -1533,6 +1810,8 @@ void noSemanticVersionBumpFileBased_NothingChanged() { .isEmpty(); assertThat(mockedDeletedFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @ParameterizedTest @@ -1630,6 +1909,8 @@ void singleSemanticVersionBumFile_Valid(String folder, String title, String expe .containsExactlyInAnyOrder( getResourcesPath("versioning", "revision", "multi", folder, "versioning.md") ); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @Test @@ -1768,6 +2049,8 @@ void multipleSemanticVersionBumpFiles_Valid() { getResourcesPath("versioning", "revision", "multi", "multiple", "patch.md"), getResourcesPath("versioning", "revision", "multi", "multiple", "none.md") ); + assertThat(mockedExecutedProcesses) + .isEmpty(); } } @@ -1858,6 +2141,8 @@ void fixedVersionBump_Valid(VersionBump versionBump) { .isEmpty(); assertThat(mockedDeletedFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @ParameterizedTest @@ -1957,6 +2242,8 @@ void fixedVersionBumpWithBackup_Valid(VersionBump versionBump) { ); assertThat(mockedDeletedFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @ParameterizedTest @@ -2028,6 +2315,8 @@ void fixedVersionBumpDryRun_Valid(VersionBump versionBump) { .isEmpty(); assertThat(mockedDeletedFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @ParameterizedTest @@ -2071,6 +2360,8 @@ void dryRunStringWriteCloseFailure_ThrowMojoExecutionException(VersionBump versi .isEmpty(); assertThat(mockedDeletedFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @Test @@ -2097,6 +2388,8 @@ void filedBasedWalkFailed_ThrowMojoExecutionException() { .isEmpty(); assertThat(mockedDeletedFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @Test @@ -2135,6 +2428,8 @@ void unknownProjectFileBased_ThrowMojoFailureException() { .isEmpty(); assertThat(mockedDeletedFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @Test @@ -2174,6 +2469,8 @@ void noSemanticVersionBumpFileBased_NothingChanged() { .isEmpty(); assertThat(mockedDeletedFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @ParameterizedTest @@ -2266,6 +2563,8 @@ void singleSemanticVersionBumFile_Valid(String folder, String title, String expe .containsExactlyInAnyOrder( getResourcesPath("versioning", "revision", "single", folder, "versioning.md") ); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @Test @@ -2399,6 +2698,8 @@ void multipleSemanticVersionBumpFiles_Valid() { getResourcesPath("versioning", "revision", "single", "multiple", "patch.md"), getResourcesPath("versioning", "revision", "single", "multiple", "none.md") ); + assertThat(mockedExecutedProcesses) + .isEmpty(); } } @@ -2485,6 +2786,8 @@ void fixedVersionBump_Valid(VersionBump versionBump) { .isEmpty(); assertThat(mockedDeletedFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @ParameterizedTest @@ -2580,6 +2883,8 @@ void fixedVersionBumpWithBackup_Valid(VersionBump versionBump) { ); assertThat(mockedDeletedFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @ParameterizedTest @@ -2647,6 +2952,8 @@ void fixedVersionBumpDryRun_Valid(VersionBump versionBump) { .isEmpty(); assertThat(mockedDeletedFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @ParameterizedTest @@ -2690,6 +2997,8 @@ void dryRunStringWriteCloseFailure_ThrowMojoExecutionException(VersionBump versi .isEmpty(); assertThat(mockedDeletedFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @Test @@ -2716,6 +3025,8 @@ void filedBasedWalkFailed_ThrowMojoExecutionException() { .isEmpty(); assertThat(mockedDeletedFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @Test @@ -2754,6 +3065,8 @@ void unknownProjectFileBased_ThrowMojoFailureException() { .isEmpty(); assertThat(mockedDeletedFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @Test @@ -2793,6 +3106,8 @@ void noSemanticVersionBumpFileBased_NothingChanged() { .isEmpty(); assertThat(mockedDeletedFiles) .isEmpty(); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @ParameterizedTest @@ -2881,6 +3196,8 @@ void singleSemanticVersionBumFile_Valid(String folder, String title, String expe .containsExactlyInAnyOrder( getResourcesPath("versioning", "single", folder, "versioning.md") ); + assertThat(mockedExecutedProcesses) + .isEmpty(); } @Test @@ -3010,6 +3327,62 @@ void multipleSemanticVersionBumpFiles_Valid() { getResourcesPath("versioning", "single", "multiple", "patch.md"), getResourcesPath("versioning", "single", "multiple", "none.md") ); + assertThat(mockedExecutedProcesses) + .isEmpty(); + } + } + + @Nested + class ExecuteScriptsFlowTest { + + @Test + void singleProject_ExecutesScriptsForUpdate() { + classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession( + getResourcesPath("single"), + Path.of(".") + ); + classUnderTest.modus = Modus.PROJECT_VERSION; + classUnderTest.versionBump = VersionBump.FILE_BASED; + classUnderTest.versionDirectory = getResourcesPath("versioning", "single", "patch"); + classUnderTest.scripts = String.join(File.pathSeparator, "script-a.sh", "script-b.sh"); + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + assertThat(mockedExecutedProcesses) + .hasSize(2); + assertThat(countScriptExecutions(Path.of("script-a.sh").toAbsolutePath().toString())) + .isEqualTo(1); + assertThat(countScriptExecutions(Path.of("script-b.sh").toAbsolutePath().toString())) + .isEqualTo(1); + } + + @Test + void multiProject_ExecutesScriptsForEachUpdatedProject() { + classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession( + getResourcesPath("leaves"), + Path.of(".") + ); + classUnderTest.modus = Modus.PROJECT_VERSION_ONLY_LEAFS; + classUnderTest.versionBump = VersionBump.FILE_BASED; + classUnderTest.versionDirectory = getResourcesPath("versioning", "leaves", "single"); + classUnderTest.scripts = String.join(File.pathSeparator, "script-a.sh", "script-b.sh"); + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + assertThat(mockedExecutedProcesses) + .hasSize(6); + assertThat(countScriptExecutions(Path.of("script-a.sh").toAbsolutePath().toString())) + .isEqualTo(3); + assertThat(countScriptExecutions(Path.of("script-b.sh").toAbsolutePath().toString())) + .isEqualTo(3); + } + + private long countScriptExecutions(String script) { + return mockedExecutedProcesses.stream() + .filter(command -> command.equals(List.of(script))) + .count(); } } } diff --git a/src/test/java/io/github/bsels/semantic/version/parameters/GitTest.java b/src/test/java/io/github/bsels/semantic/version/parameters/GitTest.java new file mode 100644 index 0000000..29ca840 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/parameters/GitTest.java @@ -0,0 +1,46 @@ +package io.github.bsels.semantic.version.parameters; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; + +import static org.assertj.core.api.Assertions.assertThat; + +public class GitTest { + + @Test + void numberOfEnumElements_Return3() { + assertThat(Git.values()) + .hasSize(3) + .extracting(Git::name) + .containsExactlyInAnyOrder("NO_GIT", "COMMIT", "STASH"); + } + + @ParameterizedTest + @EnumSource(Git.class) + void toString_ReturnsCorrectValue(Git git) { + assertThat(git.toString()) + .isEqualTo(git.name()); + } + + @ParameterizedTest + @EnumSource(Git.class) + void valueOf_ReturnCorrectValue(Git git) { + assertThat(Git.valueOf(git.toString())) + .isEqualTo(git); + } + + @ParameterizedTest + @CsvSource({ + "NO_GIT,false,false", + "STASH,true,false", + "COMMIT,true,true" + }) + void mode_ExpectedValue(Git git, boolean isStash, boolean isCommit) { + assertThat(git.isStash()) + .isEqualTo(isStash); + assertThat(git.isCommit()) + .isEqualTo(isCommit); + } +} diff --git a/src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java b/src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java index a05a720..8adea9c 100644 --- a/src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java +++ b/src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java @@ -5,6 +5,7 @@ import io.github.bsels.semantic.version.models.MavenArtifact; import io.github.bsels.semantic.version.models.SemanticVersionBump; import io.github.bsels.semantic.version.models.VersionMarkdown; +import io.github.bsels.semantic.version.parameters.ArtifactIdentifier; import io.github.bsels.semantic.version.test.utils.TestLog; import io.github.bsels.semantic.version.utils.yaml.front.block.YamlFrontMatterBlock; import org.apache.maven.plugin.MojoExecutionException; @@ -48,10 +49,13 @@ public class MarkdownUtilsTest { private static final String ARTIFACT_ID = "artifactId"; private static final String GROUP_ID = "groupId"; + private static final ArtifactIdentifier IDENTIFIER = ArtifactIdentifier.GROUP_ID_AND_ARTIFACT_ID; + private static final ArtifactIdentifier ARTIFACT_ONLY_IDENTIFIER = ArtifactIdentifier.ONLY_ARTIFACT_ID; private static final MavenArtifact MAVEN_ARTIFACT = new MavenArtifact(GROUP_ID, ARTIFACT_ID); private static final Path CHANGELOG_PATH = Path.of("project/CHANGELOG.md"); private static final Path CHANGELOG_BACKUP_PATH = Path.of("project/CHANGELOG.md.backup"); private static final String VERSION = "1.0.0"; + private static final String HEADER_LINE = "{version} - {date}"; private static final LocalDate DATE = LocalDate.of(2025, 1, 1); private static final String CHANGE_LINE = "Version bumped with a %s semantic version at index %d"; @@ -296,6 +300,7 @@ void nullChangelog_ThrowsNullPointerException() { assertThatThrownBy(() -> MarkdownUtils.mergeVersionMarkdownsInChangelog( null, VERSION, + HEADER_LINE, Map.ofEntries(createDummyVersionMarkdown(SemanticVersionBump.PATCH, 1)) )) .isInstanceOf(NullPointerException.class) @@ -307,17 +312,31 @@ void nullVersion_ThrowsNullPointerException() { assertThatThrownBy(() -> MarkdownUtils.mergeVersionMarkdownsInChangelog( createDummyChangelogDocument(), null, + HEADER_LINE, Map.ofEntries(createDummyVersionMarkdown(SemanticVersionBump.PATCH, 1)) )) .isInstanceOf(NullPointerException.class) .hasMessage("`version` must not be null"); } + @Test + void nullHeaderLine_ThrowsNullPointerException() { + assertThatThrownBy(() -> MarkdownUtils.mergeVersionMarkdownsInChangelog( + createDummyChangelogDocument(), + VERSION, + null, + Map.ofEntries(createDummyVersionMarkdown(SemanticVersionBump.PATCH, 1)) + )) + .isInstanceOf(NullPointerException.class) + .hasMessage("`headerFormatLine` must not be null"); + } + @Test void nullHeaderToNodes_ThrowsNullPointerException() { assertThatThrownBy(() -> MarkdownUtils.mergeVersionMarkdownsInChangelog( createDummyChangelogDocument(), VERSION, + HEADER_LINE, null )) .isInstanceOf(NullPointerException.class) @@ -329,6 +348,7 @@ void changelogIsNotDocument_ThrowsIllegalArgumentException() { assertThatThrownBy(() -> MarkdownUtils.mergeVersionMarkdownsInChangelog( new Paragraph(), VERSION, + HEADER_LINE, Map.ofEntries(createDummyVersionMarkdown(SemanticVersionBump.PATCH, 1)) )) .isInstanceOf(IllegalArgumentException.class) @@ -344,6 +364,7 @@ void changelogStartWithParagraph_ThrowsIllegalArgumentException() { assertThatThrownBy(() -> MarkdownUtils.mergeVersionMarkdownsInChangelog( document, VERSION, + HEADER_LINE, Map.ofEntries(createDummyVersionMarkdown(SemanticVersionBump.PATCH, 1)) )) .isInstanceOf(IllegalArgumentException.class) @@ -360,6 +381,7 @@ void changelogStartWithLevel2Heading_ThrowsIllegalArgumentException() { assertThatThrownBy(() -> MarkdownUtils.mergeVersionMarkdownsInChangelog( document, VERSION, + HEADER_LINE, Map.ofEntries(createDummyVersionMarkdown(SemanticVersionBump.PATCH, 1)) )) .isInstanceOf(IllegalArgumentException.class) @@ -376,6 +398,7 @@ void changelogStartWithLevel1HeadingNoText_ThrowsIllegalArgumentException() { assertThatThrownBy(() -> MarkdownUtils.mergeVersionMarkdownsInChangelog( document, VERSION, + HEADER_LINE, Map.ofEntries(createDummyVersionMarkdown(SemanticVersionBump.PATCH, 1)) )) .isInstanceOf(IllegalArgumentException.class) @@ -391,6 +414,7 @@ void changelogStartWithLevel1HeadingNotChangelogText_ThrowsIllegalArgumentExcept assertThatThrownBy(() -> MarkdownUtils.mergeVersionMarkdownsInChangelog( document, VERSION, + HEADER_LINE, Map.ofEntries(createDummyVersionMarkdown(SemanticVersionBump.PATCH, 1)) )) .isInstanceOf(IllegalArgumentException.class) @@ -410,6 +434,7 @@ void programmaticError_ThrowAssertionError() { assertThatThrownBy(() -> MarkdownUtils.mergeVersionMarkdownsInChangelog( document, VERSION, + HEADER_LINE, Map.of() )) .isInstanceOf(AssertionError.class) @@ -427,6 +452,7 @@ void nodeToAddIsNotADocument_ThrowsIllegalArgumentException() { assertThatThrownBy(() -> MarkdownUtils.mergeVersionMarkdownsInChangelog( changelogDocument, VERSION, + HEADER_LINE, Map.of(SemanticVersionBump.PATCH, List.of(new Paragraph()))) ) .isInstanceOf(IllegalArgumentException.class) @@ -446,6 +472,7 @@ void noNodes_OnlyIncludeVersionHeader() { .isThrownBy(() -> MarkdownUtils.mergeVersionMarkdownsInChangelog( changelogDocument, VERSION, + HEADER_LINE, Map.of()) ); } @@ -474,6 +501,7 @@ void multipleNodes_IncludeVersionHeaderForEachNode() { .isThrownBy(() -> MarkdownUtils.mergeVersionMarkdownsInChangelog( changelogDocument, VERSION, + HEADER_LINE, Map.ofEntries( createDummyVersionMarkdown(SemanticVersionBump.NONE, 1), createDummyVersionMarkdown(SemanticVersionBump.PATCH, 2), @@ -613,7 +641,7 @@ class ReadVersionMarkdownTest { @Test void nullLog_ThrowsNullPointerException() { - assertThatThrownBy(() -> MarkdownUtils.readVersionMarkdown(null, CHANGELOG_PATH)) + assertThatThrownBy(() -> MarkdownUtils.readVersionMarkdown(null, CHANGELOG_PATH, IDENTIFIER, GROUP_ID)) .isInstanceOf(NullPointerException.class) .hasMessage("`log` must not be null"); } @@ -621,7 +649,7 @@ void nullLog_ThrowsNullPointerException() { @Test void nullMarkdownFile_ThrowsNullPointerException() { TestLog log = new TestLog(TestLog.LogLevel.DEBUG); - assertThatThrownBy(() -> MarkdownUtils.readVersionMarkdown(log, null)) + assertThatThrownBy(() -> MarkdownUtils.readVersionMarkdown(log, null, IDENTIFIER, GROUP_ID)) .isInstanceOf(NullPointerException.class) .hasMessage("`markdownFile` must not be null"); } @@ -645,7 +673,7 @@ void hasNoFrontBlock_ThrowsMojoExecutionException() { filesMockedStatic.when(() -> Files.lines(CHANGELOG_PATH, StandardCharsets.UTF_8)) .thenReturn(markdown.lines()); - assertThatThrownBy(() -> MarkdownUtils.readVersionMarkdown(log, CHANGELOG_PATH)) + assertThatThrownBy(() -> MarkdownUtils.readVersionMarkdown(log, CHANGELOG_PATH, IDENTIFIER, GROUP_ID)) .isInstanceOf(MojoExecutionException.class) .hasMessage("YAML front matter block not found in '%s' file".formatted(CHANGELOG_PATH)); } @@ -681,7 +709,7 @@ void hasNoVersionBumpFrontBlock_ThrowsMojoExecutionException() { filesMockedStatic.when(() -> Files.lines(CHANGELOG_PATH, StandardCharsets.UTF_8)) .thenReturn(markdown.lines()); - assertThatThrownBy(() -> MarkdownUtils.readVersionMarkdown(log, CHANGELOG_PATH)) + assertThatThrownBy(() -> MarkdownUtils.readVersionMarkdown(log, CHANGELOG_PATH, IDENTIFIER, GROUP_ID)) .isInstanceOf(MojoExecutionException.class) .hasMessage("YAML front matter does not contain valid maven artifacts and semantic version bump") .hasRootCauseInstanceOf(JsonProcessingException.class) @@ -739,7 +767,7 @@ void happyFlow_ValidObject() throws MojoExecutionException { filesMockedStatic.when(() -> Files.lines(CHANGELOG_PATH, StandardCharsets.UTF_8)) .thenReturn(markdown.lines()); - versionMarkdown = MarkdownUtils.readVersionMarkdown(log, CHANGELOG_PATH); + versionMarkdown = MarkdownUtils.readVersionMarkdown(log, CHANGELOG_PATH, IDENTIFIER, GROUP_ID); } assertThat(versionMarkdown) @@ -790,6 +818,87 @@ void happyFlow_ValidObject() throws MojoExecutionException { .hasFieldOrPropertyWithValue("throwable", Optional.empty()) ); } + + @Test + void happyFlow_ValidObject_OnlyArtifactId() throws MojoExecutionException { + TestLog log = new TestLog(TestLog.LogLevel.NONE); + String markdown = """ + --- + none: None + patch: patch + minor: MINOR + major: MAJOR + --- + + # Header 1 + + Header 1 paragraph. + """; + VersionMarkdown versionMarkdown; + try (MockedStatic filesMockedStatic = Mockito.mockStatic(Files.class)) { + filesMockedStatic.when(() -> Files.exists(CHANGELOG_PATH)) + .thenReturn(true); + filesMockedStatic.when(() -> Files.lines(CHANGELOG_PATH, StandardCharsets.UTF_8)) + .thenReturn(markdown.lines()); + + versionMarkdown = MarkdownUtils.readVersionMarkdown( + log, + CHANGELOG_PATH, + ARTIFACT_ONLY_IDENTIFIER, + GROUP_ID + ); + } + + assertThat(versionMarkdown) + .satisfies( + data -> assertThat(data.bumps()) + .hasSize(4) + .containsEntry( + new MavenArtifact(GROUP_ID, "none"), + SemanticVersionBump.NONE + ) + .containsEntry( + new MavenArtifact(GROUP_ID, "patch"), + SemanticVersionBump.PATCH + ) + .containsEntry( + new MavenArtifact(GROUP_ID, "minor"), + SemanticVersionBump.MINOR + ) + .containsEntry( + new MavenArtifact(GROUP_ID, "major"), + SemanticVersionBump.MAJOR + ) + ); + + assertThat(log.getLogRecords()) + .hasSize(3) + .satisfiesExactly( + line -> assertThat(line) + .hasFieldOrPropertyWithValue("level", TestLog.LogLevel.INFO) + .hasFieldOrPropertyWithValue("message", Optional.of("Read 10 lines from %s".formatted(CHANGELOG_PATH))) + .hasFieldOrPropertyWithValue("throwable", Optional.empty()), + line -> assertThat(line) + .hasFieldOrPropertyWithValue("level", TestLog.LogLevel.DEBUG) + .hasFieldOrPropertyWithValue("message", Optional.of(""" + YAML front matter: + none: None + patch: patch + minor: MINOR + major: MAJOR\ + """)) + .hasFieldOrPropertyWithValue("throwable", Optional.empty()), + line -> assertThat(line) + .hasFieldOrPropertyWithValue("level", TestLog.LogLevel.DEBUG) + .satisfies(record -> assertThat(record.message().orElseThrow()) + .contains("Maven artifacts and semantic version bumps:") + .contains("groupId:none=NONE") + .contains("groupId:patch=PATCH") + .contains("groupId:minor=MINOR") + .contains("groupId:major=MAJOR")) + .hasFieldOrPropertyWithValue("throwable", Optional.empty()) + ); + } } @Nested @@ -797,14 +906,18 @@ class CreateVersionBumpHeaderTest { @Test void logIsNull_ThrowNullPointerException() { - assertThatThrownBy(() -> MarkdownUtils.createVersionBumpsHeader(null, Map.of())) + assertThatThrownBy(() -> MarkdownUtils.createVersionBumpsHeader(null, Map.of(), IDENTIFIER)) .isInstanceOf(NullPointerException.class) .hasMessage("`log` must not be null"); } @Test void bumpsIsNull_ThrowNullPointerException() { - assertThatThrownBy(() -> MarkdownUtils.createVersionBumpsHeader(new TestLog(TestLog.LogLevel.DEBUG), null)) + assertThatThrownBy(() -> MarkdownUtils.createVersionBumpsHeader( + new TestLog(TestLog.LogLevel.DEBUG), + null, + IDENTIFIER + )) .isInstanceOf(NullPointerException.class) .hasMessage("`bumps` must not be null"); } @@ -814,7 +927,7 @@ void nullKey_ThrowMojoExecutionException() { TestLog log = new TestLog(TestLog.LogLevel.DEBUG); Map bumps = new HashMap<>(); bumps.put(null, SemanticVersionBump.PATCH); - assertThatThrownBy(() -> MarkdownUtils.createVersionBumpsHeader(log, bumps)) + assertThatThrownBy(() -> MarkdownUtils.createVersionBumpsHeader(log, bumps, IDENTIFIER)) .isInstanceOf(MojoExecutionException.class) .hasMessage("Unable to construct version bump YAML") .hasRootCauseInstanceOf(JsonMappingException.class) @@ -831,11 +944,11 @@ void singleEntry_Valid(SemanticVersionBump bump) throws MojoExecutionException { Map bumps = Map.of( new MavenArtifact("group", "artifact"), bump ); - YamlFrontMatterBlock block = MarkdownUtils.createVersionBumpsHeader(log, bumps); + YamlFrontMatterBlock block = MarkdownUtils.createVersionBumpsHeader(log, bumps, IDENTIFIER); assertThat(block) .isNotNull() .hasFieldOrPropertyWithValue("yaml", """ - group:artifact: "%s" + group:artifact: "%s"\ """.formatted(bump)); assertThat(log.getLogRecords()) @@ -850,6 +963,35 @@ void singleEntry_Valid(SemanticVersionBump bump) throws MojoExecutionException { ); } + @Test + void singleEntry_OnlyArtifactId_Valid() throws MojoExecutionException { + TestLog log = new TestLog(TestLog.LogLevel.NONE); + Map bumps = Map.of( + new MavenArtifact(GROUP_ID, ARTIFACT_ID), SemanticVersionBump.MINOR + ); + YamlFrontMatterBlock block = MarkdownUtils.createVersionBumpsHeader( + log, + bumps, + ARTIFACT_ONLY_IDENTIFIER + ); + assertThat(block) + .isNotNull() + .hasFieldOrPropertyWithValue("yaml", """ + artifactId: "MINOR"\ + """); + + assertThat(log.getLogRecords()) + .isNotEmpty() + .hasSize(1) + .satisfiesExactly( + line -> assertThat(line) + .returns(""" + Version bumps YAML: + artifactId: "MINOR" + """, l -> l.message().orElseThrow()) + ); + } + @Test void multipleEntries_Valid() throws MojoExecutionException { TestLog log = new TestLog(TestLog.LogLevel.NONE); @@ -858,7 +1000,7 @@ void multipleEntries_Valid() throws MojoExecutionException { new MavenArtifact("group-2", "minor"), SemanticVersionBump.MINOR, new MavenArtifact("group-3", "patch"), SemanticVersionBump.PATCH ); - YamlFrontMatterBlock block = MarkdownUtils.createVersionBumpsHeader(log, bumps); + YamlFrontMatterBlock block = MarkdownUtils.createVersionBumpsHeader(log, bumps, IDENTIFIER); assertThat(block) .isNotNull() .extracting(YamlFrontMatterBlock::getYaml) diff --git a/src/test/java/io/github/bsels/semantic/version/utils/ProcessUtilsTest.java b/src/test/java/io/github/bsels/semantic/version/utils/ProcessUtilsTest.java index a59d650..df63854 100644 --- a/src/test/java/io/github/bsels/semantic/version/utils/ProcessUtilsTest.java +++ b/src/test/java/io/github/bsels/semantic/version/utils/ProcessUtilsTest.java @@ -1,5 +1,6 @@ package io.github.bsels.semantic.version.utils; +import io.github.bsels.semantic.version.models.VersionChange; import org.apache.maven.plugin.MojoExecutionException; import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.AfterEach; @@ -14,8 +15,15 @@ import java.io.IOException; import java.nio.file.Path; +import java.time.LocalDate; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; import static org.assertj.core.api.Assertions.assertThatThrownBy; @ExtendWith(MockitoExtension.class) @@ -355,4 +363,397 @@ private void validateProcessArguments(MockedConstruction.Context context, String .containsExactly(editor, file.toString()); } } + + @Nested + class ExecuteScriptsTest { + + @Test + void nullScript_ThrowsNullPointerException() { + assertThatThrownBy(() -> ProcessUtils.executeScripts( + null, Path.of("."), new VersionChange("1.0.0", "1.1.0"), false, false)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`script` must not be null"); + } + + @Test + void nullProjectPath_ThrowsNullPointerException() { + assertThatThrownBy(() -> ProcessUtils.executeScripts( + Path.of("script.sh"), null, new VersionChange("1.0.0", "1.1.0"), false, false)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`projectPath` must not be null"); + } + + @Test + void nullVersionChange_ThrowsNullPointerException() { + assertThatThrownBy(() -> ProcessUtils.executeScripts( + Path.of("script.sh"), Path.of("."), null, false, false)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`versionChange` must not be null"); + } + + @Test + void processZeroExit_SetsEnvironmentAndUsesProjectDirectory() throws Exception { + Path script = Path.of("script.sh"); + Path projectPath = Path.of("/tmp/project"); + VersionChange versionChange = new VersionChange("1.2.3", "2.0.0"); + Map environment = new HashMap<>(); + LocalDate before = LocalDate.now(); + + try (MockedConstruction mockedBuilder = Mockito.mockConstruction( + ProcessBuilder.class, (mock, context) -> { + validateScriptArguments(context, script); + Mockito.when(mock.environment()).thenReturn(environment); + Mockito.when(mock.directory(Mockito.any())).thenReturn(mock); + Mockito.when(mock.inheritIO()).thenReturn(mock); + Mockito.when(mock.start()).thenReturn(process); + } + )) { + Mockito.when(process.waitFor()).thenReturn(0); + + assertThatNoException().isThrownBy(() -> ProcessUtils.executeScripts( + script, projectPath, versionChange, true, false)); + + assertThat(mockedBuilder.constructed()).hasSize(1); + ProcessBuilder builder = mockedBuilder.constructed().get(0); + Mockito.verify(builder).directory(projectPath.toFile()); + Mockito.verify(builder).inheritIO(); + Mockito.verify(builder).start(); + } + + LocalDate after = LocalDate.now(); + assertThat(environment) + .containsEntry("CURRENT_VERSION", "1.2.3") + .containsEntry("NEW_VERSION", "2.0.0") + .containsEntry("DRY_RUN", "true") + .containsEntry("GIT_STASH", "false"); + assertThat(environment.get("EXECUTION_DATE")) + .isIn(before.toString(), after.toString()); + } + + @Test + void processNonZeroExit_ThrowsMojoExecutionException() throws InterruptedException { + Path script = Path.of("script.sh"); + Path projectPath = Path.of("/tmp/project"); + VersionChange versionChange = new VersionChange("1.2.3", "2.0.0"); + Map environment = new HashMap<>(); + + try (MockedConstruction ignored = Mockito.mockConstruction( + ProcessBuilder.class, (mock, context) -> { + validateScriptArguments(context, script); + Mockito.when(mock.environment()).thenReturn(environment); + Mockito.when(mock.directory(Mockito.any())).thenReturn(mock); + Mockito.when(mock.inheritIO()).thenReturn(mock); + Mockito.when(mock.start()).thenReturn(process); + } + )) { + Mockito.when(process.waitFor()).thenReturn(1); + + assertThatThrownBy(() -> ProcessUtils.executeScripts( + script, projectPath, versionChange, false, true)) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Script execution failed."); + } + } + + @Test + void processStartThrowsIOException_ThrowsMojoExecutionException() { + Path script = Path.of("script.sh"); + Path projectPath = Path.of("/tmp/project"); + VersionChange versionChange = new VersionChange("1.2.3", "2.0.0"); + Map environment = new HashMap<>(); + IOException ioException = new IOException("Start failed"); + + try (MockedConstruction ignored = Mockito.mockConstruction( + ProcessBuilder.class, (mock, context) -> { + validateScriptArguments(context, script); + Mockito.when(mock.environment()).thenReturn(environment); + Mockito.when(mock.directory(Mockito.any())).thenReturn(mock); + Mockito.when(mock.inheritIO()).thenReturn(mock); + Mockito.when(mock.start()).thenThrow(ioException); + } + )) { + assertThatThrownBy(() -> ProcessUtils.executeScripts( + script, projectPath, versionChange, false, false)) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Script execution failed.") + .hasCause(ioException); + } + } + + @Test + void processWaitForThrowsInterruptedException_ThrowsMojoExecutionException() throws Exception { + Path script = Path.of("script.sh"); + Path projectPath = Path.of("/tmp/project"); + VersionChange versionChange = new VersionChange("1.2.3", "2.0.0"); + Map environment = new HashMap<>(); + InterruptedException interruptedException = new InterruptedException("Interrupted"); + + try (MockedConstruction ignored = Mockito.mockConstruction( + ProcessBuilder.class, (mock, context) -> { + validateScriptArguments(context, script); + Mockito.when(mock.environment()).thenReturn(environment); + Mockito.when(mock.directory(Mockito.any())).thenReturn(mock); + Mockito.when(mock.inheritIO()).thenReturn(mock); + Mockito.when(mock.start()).thenReturn(process); + } + )) { + Mockito.when(process.waitFor()).thenThrow(interruptedException); + + assertThatThrownBy(() -> ProcessUtils.executeScripts( + script, projectPath, versionChange, false, false)) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Script execution failed.") + .hasCause(interruptedException); + } + } + + private void validateScriptArguments(MockedConstruction.Context context, Path script) { + assertThat(context.arguments()) + .isNotNull() + .isNotEmpty() + .hasSize(1) + .first() + .asInstanceOf(InstanceOfAssertFactories.array(String[].class)) + .containsExactly(script.toString()); + } + } + + @Nested + class GitCommitTest { + + @Test + void nullMessage_ThrowNullPointerException() { + assertThatThrownBy(() -> ProcessUtils.gitCommit(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`message` must not be null"); + } + + @Test + void processCreationFailed_ThrowsMojoExecutionException() { + AtomicReference> command = new AtomicReference<>(); + try (MockedConstruction ignored = Mockito.mockConstruction( + ProcessBuilder.class, (mock, context) -> { + Mockito.when(mock.command(Mockito.anyList())) + .thenAnswer(invocation -> { + command.set(invocation.getArgument(0)); + return mock; + }); + Mockito.when(mock.inheritIO()).thenReturn(mock); + Mockito.when(mock.start()) + .thenThrow(IOException.class); + } + )) { + assertThatThrownBy(() -> ProcessUtils.gitCommit("Test commit")) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Unable to commit changes") + .hasRootCauseInstanceOf(IOException.class); + } + + assertThat(command.get()) + .containsExactly("git", "commit", "-m", "Test commit"); + } + + @Test + void processInterrupted_ThrowsMojoExecutionException() { + AtomicReference> command = new AtomicReference<>(); + try (MockedConstruction ignored = Mockito.mockConstruction( + ProcessBuilder.class, (mock, context) -> { + Mockito.when(mock.command(Mockito.anyList())) + .thenAnswer(invocation -> { + command.set(invocation.getArgument(0)); + return mock; + }); + Mockito.when(mock.inheritIO()).thenReturn(mock); + Mockito.when(mock.start()).thenReturn(process); + Mockito.when(process.waitFor()) + .thenThrow(InterruptedException.class); + } + )) { + assertThatThrownBy(() -> ProcessUtils.gitCommit("Test commit 2")) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Unable to commit changes") + .hasRootCauseInstanceOf(InterruptedException.class); + } + + assertThat(command.get()) + .containsExactly("git", "commit", "-m", "Test commit 2"); + } + + @Test + void processNonZeroExit_ThrowsMojoExecutionException() { + AtomicReference> command = new AtomicReference<>(); + try (MockedConstruction ignored = Mockito.mockConstruction( + ProcessBuilder.class, (mock, context) -> { + Mockito.when(mock.command(Mockito.anyList())) + .thenAnswer(invocation -> { + command.set(invocation.getArgument(0)); + return mock; + }); + Mockito.when(mock.inheritIO()).thenReturn(mock); + Mockito.when(mock.start()).thenReturn(process); + Mockito.when(process.waitFor()) + .thenReturn(1); + } + )) { + assertThatThrownBy(() -> ProcessUtils.gitCommit("Test commit 3")) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Unable to commit changes"); + } + + assertThat(command.get()) + .containsExactly("git", "commit", "-m", "Test commit 3"); + } + + @Test + void processZeroExit_Success() { + AtomicReference> command = new AtomicReference<>(); + try (MockedConstruction ignored = Mockito.mockConstruction( + ProcessBuilder.class, (mock, context) -> { + Mockito.when(mock.command(Mockito.anyList())) + .thenAnswer(invocation -> { + command.set(invocation.getArgument(0)); + return mock; + }); + Mockito.when(mock.inheritIO()).thenReturn(mock); + Mockito.when(mock.start()).thenReturn(process); + Mockito.when(process.waitFor()) + .thenReturn(0); + } + )) { + assertThatNoException() + .isThrownBy(() -> ProcessUtils.gitCommit("Test commit 4")); + } + + assertThat(command.get()) + .containsExactly("git", "commit", "-m", "Test commit 4"); + } + } + + @Nested + class GitStashFilesTest { + private static final Path TEST_POM = Path.of("pom.xml"); + private static final Path TEST_CHANGELOG = Path.of("CHANGELOG.md"); + + @Test + void nullFiles_ThrowsNullPointerException() { + assertThatThrownBy(() -> ProcessUtils.gitStashFiles(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`files` must not be null"); + } + + @Test + void emptyFiles_ThrowsIllegalArgumentException() { + assertThatThrownBy(() -> ProcessUtils.gitStashFiles(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("`files` must not be empty"); + } + + @Test + void nullFileInFiles_ThrowsNullPointerException() { + assertThatThrownBy(() -> ProcessUtils.gitStashFiles(Collections.singletonList(null))) + .isInstanceOf(NullPointerException.class) + .hasMessage("`file` in `files` must not be null"); + } + + @Test + void processCreationFailed_ThrowsMojoExecutionException() { + AtomicReference> command = new AtomicReference<>(); + try (MockedConstruction ignored = Mockito.mockConstruction( + ProcessBuilder.class, (mock, context) -> { + Mockito.when(mock.command(Mockito.anyList())) + .thenAnswer(invocation -> { + command.set(invocation.getArgument(0)); + return mock; + }); + Mockito.when(mock.inheritIO()).thenReturn(mock); + Mockito.when(mock.start()) + .thenThrow(IOException.class); + } + )) { + assertThatThrownBy(() -> ProcessUtils.gitStashFiles(List.of(TEST_POM))) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Unable to add files to Git stash") + .hasRootCauseInstanceOf(IOException.class); + } + + assertThat(command.get()) + .containsExactly("git", "add", TEST_POM.toString()); + } + + @Test + void processInterrupted_ThrowsMojoExecutionException() { + AtomicReference> command = new AtomicReference<>(); + try (MockedConstruction ignored = Mockito.mockConstruction( + ProcessBuilder.class, (mock, context) -> { + Mockito.when(mock.command(Mockito.anyList())) + .thenAnswer(invocation -> { + command.set(invocation.getArgument(0)); + return mock; + }); + Mockito.when(mock.inheritIO()).thenReturn(mock); + Mockito.when(mock.start()).thenReturn(process); + + Mockito.when(process.waitFor()) + .thenThrow(InterruptedException.class); + } + )) { + assertThatThrownBy(() -> ProcessUtils.gitStashFiles(List.of(TEST_CHANGELOG))) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Unable to add files to Git stash") + .hasRootCauseInstanceOf(InterruptedException.class); + } + + assertThat(command.get()) + .containsExactly("git", "add", TEST_CHANGELOG.toString()); + } + + @Test + void processNonZeroExit_ThrowsMojoExecutionException() { + AtomicReference> command = new AtomicReference<>(); + try (MockedConstruction ignored = Mockito.mockConstruction( + ProcessBuilder.class, (mock, context) -> { + Mockito.when(mock.command(Mockito.anyList())) + .thenAnswer(invocation -> { + command.set(invocation.getArgument(0)); + return mock; + }); + Mockito.when(mock.inheritIO()).thenReturn(mock); + Mockito.when(mock.start()).thenReturn(process); + Mockito.when(process.waitFor()) + .thenReturn(1); + } + )) { + assertThatThrownBy(() -> ProcessUtils.gitStashFiles(List.of(TEST_POM, TEST_CHANGELOG))) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Unable to add files to Git stash"); + } + + assertThat(command.get()) + .containsExactly("git", "add", TEST_POM.toString(), TEST_CHANGELOG.toString()); + } + + @Test + void processZeroExit_Success() { + AtomicReference> command = new AtomicReference<>(); + try (MockedConstruction ignored = Mockito.mockConstruction( + ProcessBuilder.class, (mock, context) -> { + Mockito.when(mock.command(Mockito.anyList())) + .thenAnswer(invocation -> { + command.set(invocation.getArgument(0)); + return mock; + }); + Mockito.when(mock.inheritIO()).thenReturn(mock); + Mockito.when(mock.start()).thenReturn(process); + Mockito.when(process.waitFor()) + .thenReturn(0); + } + )) { + assertThatNoException() + .isThrownBy(() -> ProcessUtils.gitStashFiles(List.of(TEST_CHANGELOG, TEST_POM))); + } + + assertThat(command.get()) + .containsExactly("git", "add", TEST_CHANGELOG.toString(), TEST_POM.toString()); + } + } } diff --git a/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java b/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java index 4cc0d74..6da538e 100644 --- a/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java +++ b/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java @@ -1,5 +1,6 @@ package io.github.bsels.semantic.version.utils; +import io.github.bsels.semantic.version.models.PlaceHolderWithType; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.project.MavenProject; import org.junit.jupiter.api.Nested; @@ -20,7 +21,9 @@ import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.time.DayOfWeek; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -527,4 +530,212 @@ void resolveNewVersioningFile_ValidPath() { } } } + + @Nested + class PrepareFormatStringTest { + + @Test + void nullFormatString_ThrowsNullPointerException() { + assertThatThrownBy(() -> Utils.prepareFormatString(null, List.of())) + .isInstanceOf(NullPointerException.class) + .hasMessage("`formatString` must not be null"); + } + + @Test + void nullKeys_ThrowsNullPointerException() { + assertThatThrownBy(() -> Utils.prepareFormatString("test", null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`keys` must not be null"); + } + + @Test + void nullKeyInList_ThrowsNullPointerException() { + List keys = Arrays.asList( + new PlaceHolderWithType("version", "s"), + null, + new PlaceHolderWithType("date", "s") + ); + assertThatThrownBy(() -> Utils.prepareFormatString("test", keys)) + .isInstanceOf(NullPointerException.class) + .hasMessage("All keys must not be null"); + } + + @Test + void emptyKeysAndEmptyString_ReturnsEmptyString() { + String result = Utils.prepareFormatString("", List.of()); + assertThat(result).isEmpty(); + } + + @Test + void emptyKeys_ReturnsOriginalString() { + String formatString = "No placeholders here"; + String result = Utils.prepareFormatString(formatString, List.of()); + assertThat(result).isEqualTo(formatString); + } + + @Test + void singlePlaceholder_CorrectlyReplaced() { + String formatString = "Version: {version}"; + List keys = List.of( + new PlaceHolderWithType("version", "s") + ); + String result = Utils.prepareFormatString(formatString, keys); + assertThat(result).isEqualTo("Version: %1$s"); + } + + @Test + void multiplePlaceholders_CorrectlyReplacedInOrder() { + String formatString = "Version {version} released on {date} by {author}"; + List keys = List.of( + new PlaceHolderWithType("version", "s"), + new PlaceHolderWithType("date", "s"), + new PlaceHolderWithType("author", "s") + ); + String result = Utils.prepareFormatString(formatString, keys); + assertThat(result).isEqualTo("Version %1$s released on %2$s by %3$s"); + } + + @Test + void differentFormatTypes_CorrectlyApplied() { + String formatString = "Count: {count}, Name: {name}, Value: {value}"; + List keys = List.of( + new PlaceHolderWithType("count", "d"), + new PlaceHolderWithType("name", "s"), + new PlaceHolderWithType("value", "f") + ); + String result = Utils.prepareFormatString(formatString, keys); + assertThat(result).isEqualTo("Count: %1$d, Name: %2$s, Value: %3$f"); + } + + @Test + void samePlaceholderMultipleTimes_AllInstancesReplaced() { + String formatString = "{version} is the latest version. Update to {version} now!"; + List keys = List.of( + new PlaceHolderWithType("version", "s") + ); + String result = Utils.prepareFormatString(formatString, keys); + assertThat(result).isEqualTo("%1$s is the latest version. Update to %1$s now!"); + } + + @Test + void placeholderNotInString_StringUnchanged() { + String formatString = "No version here"; + List keys = List.of( + new PlaceHolderWithType("version", "s"), + new PlaceHolderWithType("date", "s") + ); + String result = Utils.prepareFormatString(formatString, keys); + assertThat(result).isEqualTo("No version here"); + } + + @Test + void partialPlaceholderMatch_OnlyFullMatchReplaced() { + String formatString = "{version} and {versioning} are different"; + List keys = List.of( + new PlaceHolderWithType("version", "s") + ); + String result = Utils.prepareFormatString(formatString, keys); + assertThat(result).isEqualTo("%1$s and {versioning} are different"); + } + + @Test + void complexFormatString_CorrectlyProcessed() { + String formatString = "Released version {version} on {date} with {count} features"; + List keys = List.of( + new PlaceHolderWithType("version", "s"), + new PlaceHolderWithType("date", "tF"), + new PlaceHolderWithType("count", "d") + ); + String result = Utils.prepareFormatString(formatString, keys); + assertThat(result).isEqualTo("Released version %1$s on %2$tF with %3$d features"); + } + + @Test + void placeholdersWithSpecialCharacters_CorrectlyReplaced() { + String formatString = "Value: {some-value}, Another: {another_value}"; + List keys = List.of( + new PlaceHolderWithType("some-value", "s"), + new PlaceHolderWithType("another_value", "d") + ); + String result = Utils.prepareFormatString(formatString, keys); + assertThat(result).isEqualTo("Value: %1$s, Another: %2$d"); + } + } + + @Nested + class FormatHeaderLineTest { + + @Test + void nullHeaderLine_ThrowsNullPointerException() { + assertThatThrownBy(() -> Utils.formatHeaderLine(null, "1.0.0", LocalDate.now())) + .isInstanceOf(NullPointerException.class) + .hasMessage("`headerLine` must not be null"); + } + + @Test + void nullVersion_ThrowsNullPointerException() { + assertThatThrownBy(() -> Utils.formatHeaderLine("## {version}", null, LocalDate.now())) + .isInstanceOf(NullPointerException.class) + .hasMessage("`version` must not be null"); + } + + @Test + void nullDate_ThrowsNullPointerException() { + assertThatThrownBy(() -> Utils.formatHeaderLine("## {date}", "1.0.0", null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`date` must not be null"); + } + + @Test + void defaultDateAndVersionPlaceholders_Replaced() { + LocalDate date = LocalDate.of(2024, 2, 3); + String result = Utils.formatHeaderLine("## {version} - {date}", "1.2.3", date); + + assertThat(result).isEqualTo("## 1.2.3 - 2024-02-03"); + } + + @Test + void customDatePattern_Replaced() { + LocalDate date = LocalDate.of(2024, 2, 3); + String result = Utils.formatHeaderLine("Released {date#yyyy/MM/dd}", "1.2.3", date); + + assertThat(result).isEqualTo("Released 2024/02/03"); + } + + @Test + void multiplePlaceholders_AllReplaced() { + LocalDate date = LocalDate.of(2024, 2, 3); + String result = Utils.formatHeaderLine( + "v{version} ({date}) -> {version}", + "2.0.0", + date + ); + + assertThat(result).isEqualTo("v2.0.0 (2024-02-03) -> 2.0.0"); + } + + @Test + void multipleDateFormats_AllReplaced() { + LocalDate date = LocalDate.of(2024, 2, 3); + String result = Utils.formatHeaderLine( + "Released {date#yyyy/MM/dd} (ISO {date}) [stamp {date#yyyyMMdd}]", + "2.0.0", + date + ); + + assertThat(result).isEqualTo("Released 2024/02/03 (ISO 2024-02-03) [stamp 20240203]"); + } + + @Test + void unknownPlaceholder_Preserved() { + LocalDate date = LocalDate.of(2024, 2, 3); + String result = Utils.formatHeaderLine( + "Release {unknown} on {date}", + "1.0.0", + date + ); + + assertThat(result).isEqualTo("Release {unknown} on 2024-02-03"); + } + } } diff --git a/src/test/java/io/github/bsels/semantic/version/utils/mapper/MavenArtifactArtifactOnlyDeserializerTest.java b/src/test/java/io/github/bsels/semantic/version/utils/mapper/MavenArtifactArtifactOnlyDeserializerTest.java new file mode 100644 index 0000000..de46564 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/utils/mapper/MavenArtifactArtifactOnlyDeserializerTest.java @@ -0,0 +1,36 @@ +package io.github.bsels.semantic.version.utils.mapper; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import io.github.bsels.semantic.version.models.MavenArtifact; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class MavenArtifactArtifactOnlyDeserializerTest { + private static final String GROUP_ID = "groupId"; + private static final String ARTIFACT_ID = "artifactId"; + + @Test + void nullGroupId_ThrowsNullPointerException() { + assertThatThrownBy(() -> new MavenArtifactArtifactOnlyDeserializer(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`groupId` must not be null"); + } + + @Test + void deserializeValue_ReturnsMavenArtifact() throws Exception { + ObjectMapper mapper = new ObjectMapper() + .registerModule(new SimpleModule() + .addDeserializer(MavenArtifact.class, new MavenArtifactArtifactOnlyDeserializer(GROUP_ID)) + ); + + MavenArtifact artifact = mapper.readValue("\"" + ARTIFACT_ID + "\"", MavenArtifact.class); + + assertThat(artifact) + .isNotNull() + .hasFieldOrPropertyWithValue("groupId", GROUP_ID) + .hasFieldOrPropertyWithValue("artifactId", ARTIFACT_ID); + } +} diff --git a/src/test/java/io/github/bsels/semantic/version/utils/mapper/MavenArtifactArtifactOnlyKeyDeserializerTest.java b/src/test/java/io/github/bsels/semantic/version/utils/mapper/MavenArtifactArtifactOnlyKeyDeserializerTest.java new file mode 100644 index 0000000..43edd06 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/utils/mapper/MavenArtifactArtifactOnlyKeyDeserializerTest.java @@ -0,0 +1,45 @@ +package io.github.bsels.semantic.version.utils.mapper; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import io.github.bsels.semantic.version.models.MavenArtifact; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class MavenArtifactArtifactOnlyKeyDeserializerTest { + private static final String GROUP_ID = "groupId"; + private static final String ARTIFACT_ID = "artifactId"; + + @Test + void nullGroupId_ThrowsNullPointerException() { + assertThatThrownBy(() -> new MavenArtifactArtifactOnlyKeyDeserializer(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`groupId` must not be null"); + } + + @Test + void deserializeKey_ReturnsMavenArtifact() throws Exception { + ObjectMapper mapper = new ObjectMapper() + .registerModule(new SimpleModule() + .addKeyDeserializer( + MavenArtifact.class, + new MavenArtifactArtifactOnlyKeyDeserializer(GROUP_ID) + ) + ); + + Map artifacts = mapper.readValue( + "{\"" + ARTIFACT_ID + "\":\"value\"}", + new TypeReference<>() { + } + ); + + assertThat(artifacts) + .hasSize(1) + .containsEntry(new MavenArtifact(GROUP_ID, ARTIFACT_ID), "value"); + } +} diff --git a/src/test/java/io/github/bsels/semantic/version/utils/mapper/MavenArtifactArtifactOnlySerializerTest.java b/src/test/java/io/github/bsels/semantic/version/utils/mapper/MavenArtifactArtifactOnlySerializerTest.java new file mode 100644 index 0000000..2f2c95f --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/utils/mapper/MavenArtifactArtifactOnlySerializerTest.java @@ -0,0 +1,41 @@ +package io.github.bsels.semantic.version.utils.mapper; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import io.github.bsels.semantic.version.models.MavenArtifact; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +public class MavenArtifactArtifactOnlySerializerTest { + private static final String GROUP_ID = "groupId"; + private static final String ARTIFACT_ID = "artifactId"; + + @Test + void serializeValue_WritesArtifactIdString() throws Exception { + ObjectMapper mapper = new ObjectMapper() + .registerModule(new SimpleModule() + .addSerializer(MavenArtifact.class, new MavenArtifactArtifactOnlySerializer()) + ); + + String json = mapper.writeValueAsString(Map.of("artifact", new MavenArtifact(GROUP_ID, ARTIFACT_ID))); + + assertThat(json) + .isEqualTo("{\"artifact\":\"" + ARTIFACT_ID + "\"}"); + } + + @Test + void serializeKey_WritesArtifactIdFieldName() throws Exception { + ObjectMapper mapper = new ObjectMapper() + .registerModule(new SimpleModule() + .addKeySerializer(MavenArtifact.class, new MavenArtifactArtifactOnlySerializer()) + ); + + String json = mapper.writeValueAsString(Map.of(new MavenArtifact(GROUP_ID, ARTIFACT_ID), "value")); + + assertThat(json) + .isEqualTo("{\"" + ARTIFACT_ID + "\":\"value\"}"); + } +} diff --git a/src/test/resources/itests/versioning/leaves/single-artifact-only/versioning.md b/src/test/resources/itests/versioning/leaves/single-artifact-only/versioning.md new file mode 100644 index 0000000..5f1ec66 --- /dev/null +++ b/src/test/resources/itests/versioning/leaves/single-artifact-only/versioning.md @@ -0,0 +1,7 @@ +--- +child-1: patch +child-2: minor +child-3: major +--- + +Different versions bump in different modules. diff --git a/update-readme.sh b/update-readme.sh new file mode 100755 index 0000000..466741b --- /dev/null +++ b/update-readme.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +if [ "$DRY_RUN" = "true" ] +then + sed "s/[0-9]\+[.][0-9]\+[.][0-9]\+<\/version>/$NEW_VERSION<\/version>/g" README.md +else + sed -i "s/[0-9]\+[.][0-9]\+[.][0-9]\+<\/version>/$NEW_VERSION<\/version>/g" README.md + if [ "$GIT_STASH" = "true" ] + then + git add README.md + fi +fi