diff --git a/.github/ISSUE_TEMPLATE/vulnerability.md b/.github/ISSUE_TEMPLATE/vulnerability.md new file mode 100644 index 0000000..8660187 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/vulnerability.md @@ -0,0 +1,17 @@ +--- +name: Vulnerability report +about: Report a vulnerability +title: '' +labels: bug +assignees: '' + +--- + +**Describe the vulnerability** +In which part of the project is it? +Which dependency is it? +What is the impact? + +**Suggested fix** +Describe the potential fix. +To which version should the impacted dependency be upgraded? diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 922c550..a875dcc 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,8 +1,7 @@ # PR Checklist - [ ] Added tests for the changes - [ ] Updated docs (root README.md, if necessary) -- [ ] PR title starts with a semantic version bump: (patch, minor or major) - - Example: `patch: Update dependencies` +- [ ] Contains at least one versioning Markdown in `.versioning` # Problem or reason What problem does this PR solve? diff --git a/.github/workflows/push-release.yaml b/.github/workflows/push-release.yaml index 420d3fe..6f743cb 100644 --- a/.github/workflows/push-release.yaml +++ b/.github/workflows/push-release.yaml @@ -25,51 +25,29 @@ jobs: run: | git config user.name github-actions[bot] git config user.email 41898282+github-actions[bot]@users.noreply.github.com - - name: Get current project version - id: projectVersion - run: | - echo "version=$(./mvnw help:evaluate --no-transfer-progress -Dexpression=project.version -q -DforceStdout)" >> $GITHUB_OUTPUT - - name: Bump version + - name: Build project and run versioning id: bumpVersion - uses: actions/github-script@v8 - env: - COMMIT_MESSAGE: ${{ github.event.head_commit.message }} - CURRENT_VERSION: ${{ steps.projectVersion.outputs.version }} - with: - result-encoding: string - script: | - const commitMessage = process.env.COMMIT_MESSAGE.trim().toLowerCase(); - const [major, minor, patch] = process.env.CURRENT_VERSION.trim().split('.'); - if (commitMessage.startsWith('major')) { - return `${parseInt(major) + 1}.0.0`; - } - if (commitMessage.startsWith('minor')) { - return `${major}.${parseInt(minor) + 1}.0`; - } - if (commitMessage.startsWith('patch')) { - return `${major}.${minor}.${parseInt(patch) + 1}`; - } - return process.env.CURRENT_VERSION; - - name: Set new project version - if: ${{ steps.bumpVersion.outputs.result != steps.projectVersion.outputs.version }} run: | - ./mvnw versions:set --no-transfer-progress -DnewVersion=${{ steps.bumpVersion.outputs.result }} + 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 + VERSION=$(./mvnw --no-transfer-progress --batch-mode help:evaluate -Dexpression=project.version -q -DforceStdout) + echo "version=$VERSION" >> $GITHUB_OUTPUT - name: Update version in README - if: ${{ steps.bumpVersion.outputs.result != steps.projectVersion.outputs.version }} run: | - sed -i 's/[0-9]\+[.][0-9]\+[.][0-9]\+<\/version>/${{ steps.bumpVersion.outputs.result }}<\/version>/g' README.md + sed -i 's/[0-9]\+[.][0-9]\+[.][0-9]\+<\/version>/${{ steps.bumpVersion.outputs.version }}<\/version>/g' README.md - name: Commit changes - if: ${{ steps.bumpVersion.outputs.result != steps.projectVersion.outputs.version }} run: | - git commit -am "Released ${{ steps.bumpVersion.outputs.result }} [skip ci]" - git tag "v${{ steps.bumpVersion.outputs.result }}" + 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.result }}" + git push origin tag "v${{ steps.bumpVersion.outputs.version }}" - name: Create release - if: ${{ steps.bumpVersion.outputs.result != steps.projectVersion.outputs.version }} env: GH_TOKEN: ${{ secrets.RELEASE_PAT_TOKEN }} - tag: ${{ format('v{0}', steps.bumpVersion.outputs.result) }} + tag: ${{ format('v{0}', steps.bumpVersion.outputs.version) }} run: | gh release create "$tag" \ --repo="$GITHUB_REPOSITORY" \ diff --git a/.versioning/20250104-132200.md b/.versioning/20250104-132200.md new file mode 100644 index 0000000..73d38b8 --- /dev/null +++ b/.versioning/20250104-132200.md @@ -0,0 +1,5 @@ +--- +'io.github.bsels:semantic-version-maven-plugin': major +--- + +Initial version of the **semantic-version-maven-plugin**. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..825c32f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/README.md b/README.md index 7a6b27c..2d0db25 100644 --- a/README.md +++ b/README.md @@ -6,4 +6,271 @@ [![Push create release](https://github.com/bsels/semantic-version-maven-plugin/actions/workflows/push-release.yaml/badge.svg)](https://github.com/bsels/semantic-version-maven-plugin/actions/workflows/push-release.yaml) [![Release Build](https://github.com/bsels/semantic-version-maven-plugin/actions/workflows/release-build.yaml/badge.svg?event=release)](https://github.com/bsels/semantic-version-maven-plugin/actions/workflows/release-build.yaml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -![Java Version 17](https://img.shields.io/badge/Java_Version-17-purple?logo=data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjIwMHB4IiB3aWR0aD0iMjAwcHgiIHZlcnNpb249IjEuMSIgaWQ9IkNhcGFfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmlld0JveD0iMCAwIDUwMi42MzIgNTAyLjYzMiIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIgZmlsbD0iIzAwMDAwMCI+PGcgaWQ9IlNWR1JlcG9fYmdDYXJyaWVyIiBzdHJva2Utd2hpdGVpZHRoPSIwIj48L2c+PGcgaWQ9IlNWR1JlcG9fdHJhY2VyQ2FycmllciIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48L2c+PGcgaWQ9IlNWR1JlcG9faWNvbkNhcnJpZXIiPiA8Zz4gPGc+IDxwYXRoIHN0eWxlPSJmaWxsOndoaXRlOyIgZD0iTTI0MC44NjQsMjY5Ljg5NGMwLDAtMjguMDItNTMuOTkyLTI2Ljk4NS05My40NDVjMC43NTUtMjguMTkzLDY0LjMyNC01Ni4wNjIsODkuMjgxLTk2LjUyOSBDMzI4LjA3NCwzOS40MzEsMzAwLjA1NCwwLDMwMC4wNTQsMHM2LjIzNCwyOS4wNzctMTAuMzc2LDU5LjE0N2MtMTYuNjA5LDMwLjExMy03Ny45MTQsNDcuNzc5LTEwMS43NDksOTkuNjc5IFMyNDAuODY0LDI2OS44OTQsMjQwLjg2NCwyNjkuODk0eiI+PC9wYXRoPiA8cGF0aCBzdHlsZT0iZmlsbDp3aGl0ZTsiIGQ9Ik0zNDUuNzQxLDEwNS44NjljMCwwLTk1LjQ5NCwzNi4zNDctOTUuNDk0LDc3Ljg0OWMwLDQxLjU0NSwyNS45MjgsNTUuMDI3LDMwLjExMyw2OC41MDkgYzQuMTQyLDEzLjUyNS03LjI2OSwzNi4zNDctNy4yNjksMzYuMzQ3czM3LjM2MS0yNS45NSwzMS4xMDUtNTYuMDYyYy02LjIzNC0zMC4xMTMtMzUuMjktMzkuNDc1LTE4LjY1OS02OS41NDQgQzI5Ni42NDYsMTQyLjc5OSwzNDUuNzQxLDEwNS44NjksMzQ1Ljc0MSwxMDUuODY5eiI+PC9wYXRoPiA8cGF0aCBzdHlsZT0iZmlsbDp3aGl0ZTsiIGQ9Ik0yMzAuNTEsMzI0Ljc0OGM4OC4yNDYtMy4xNDksMTIwLjQzLTMwLjk5NywxMjAuNDMtMzAuOTk3IGMtNTcuMDc2LDE1LjU1My0yMDguNjU0LDE0LjUzOS0yMDkuNzExLDMuMTI4Yy0xLjAxNC0xMS40MTEsNDYuNzAxLTIwLjc3Myw0Ni43MDEtMjAuNzczcy03NC43MjEsMC04MC45NTUsMTguNjggQzEwMC43NCwzMTMuNDY3LDE0Mi4zMjgsMzI3LjgzMywyMzAuNTEsMzI0Ljc0OHoiPjwvcGF0aD4gPHBhdGggc3R5bGU9ImZpbGw6d2hpdGU7IiBkPSJNMzU4LjE4NywzNjguNDk0YzAsMCw4Ni4zNjktMTguNDIxLDc3LjgyNy02NS4zMzhjLTEwLjM1NC01Ny4xMTktNzAuNTgtMjQuOTM2LTcwLjU4LTI0LjkzNiBzNDIuNjAyLDAsNDYuNzIyLDI1LjkyOEM0MTYuMzIsMzMwLjA5OCwzNTguMTg3LDM2OC40OTQsMzU4LjE4NywzNjguNDk0eiI+PC9wYXRoPiA8cGF0aCBzdHlsZT0iZmlsbDp3aGl0ZTsiIGQ9Ik0zMTUuNjI4LDM0My42MDFjMCwwLTIxLjc2NSw1LjcxNi01NC4wMTMsOS4zNGMtNDMuMjI4LDQuODUzLTk1LjQ5NCwxLjAxNC05OS42NTctNi4yNTYgYy00LjA5OC03LjI2OSw3LjI2OS0xMS40MTEsNy4yNjktMTEuNDExYy01MS45MjEsMTIuNDY4LTIzLjUxMiwzNC4yMzMsMzcuMzM5LDM4LjQxOGM1Mi4xNTgsMy41NTksMTI5Ljc5MS0xNS41NzQsMTI5Ljc5MS0xNS41NzQgTDMxNS42MjgsMzQzLjYwMXoiPjwvcGF0aD4gPHBhdGggc3R5bGU9ImZpbGw6d2hpdGU7IiBkPSJNMTgxLjczOCwzODguOTQzYzAsMC0yMy41NTUsMC42NjktMjQuOTM2LDEzLjEzN2MtMS4zNTksMTIuMzgyLDE0LjQ5NiwyMy41MTIsNzIuNjUsMjYuOTY0IGM1OC4xMzMsMy40NTEsOTguOTg4LTE1Ljg5OCw5OC45ODgtMTUuODk4bC0yNi4yOTUtMTUuOTYyYzAsMC0xNi42MzEsMy40OTQtNDIuMjM2LDYuOTQ2IGMtMjUuNjI2LDMuNDczLTc4LjE3My0yLjc4My04MC4yNDMtNy41OTNDMTc3LjU1MywzOTEuNjgyLDE4MS43MzgsMzg4Ljk0MywxODEuNzM4LDM4OC45NDN6Ij48L3BhdGg+IDxwYXRoIHN0eWxlPSJmaWxsOndoaXRlOyIgZD0iTTQwNy45OTQsNDQ1LjAwNWM4Ljk5NS05LjcwNy0yLjc4My0xNy4zMjEtMi43ODMtMTcuMzIxczQuMTQyLDQuODUzLTEuMzM3LDEwLjM3NiBjLTUuNTQ0LDUuNTIyLTU2LjA4NCwxOS4zNDktMTM3LjA2MSwyMy41MTJjLTgwLjk1NSw0LjE2My0xNjguODU2LTcuNjE1LTE3MS42MzktMTcuOTkgYy0yLjY5Ni0xMC4zNzYsNDUuMDE4LTE4LjY1OSw0NS4wMTgtMTguNjU5Yy01LjUyMiwwLjY5LTcxLjk2LDIuMDcxLTc0LjA3NCwyMC4wODJjLTIuMDcxLDE3Ljk2OCwyOS4wNTYsMzIuNTA3LDE1My42NywzMi41MDcgQzM0NC4zMzksNDc3LjQ5MSwzOTkuMDQyLDQ1NC42NDcsNDA3Ljk5NCw0NDUuMDA1eiI+PC9wYXRoPiA8cGF0aCBzdHlsZT0iZmlsbDp3aGl0ZTsiIGQ9Ik0zNTkuNTY4LDQ4NS44MTdjLTU0LjY4MiwxMS4wNDQtMjIwLjczNCw0LjA3Ny0yMjAuNzM0LDQuMDc3czEwNy45MTksMjUuNjI2LDIzMS4xMDksNC4xODUgYzU4Ljg4OC0xMC4yNjgsNjIuMzE4LTM4Ljc2Myw2Mi4zMTgtMzguNzYzUzQxNC4yNSw0NzQuNzA4LDM1OS41NjgsNDg1LjgxN3oiPjwvcGF0aD4gPC9nPiA8Zz4gPC9nPiA8Zz4gPC9nPiA8Zz4gPC9nPiA8Zz4gPC9nPiA8Zz4gPC9nPiA8Zz4gPC9nPiA8Zz4gPC9nPiA8Zz4gPC9nPiA8Zz4gPC9nPiA8Zz4gPC9nPiA8Zz4gPC9nPiA8Zz4gPC9nPiA8Zz4gPC9nPiA8Zz4gPC9nPiA8Zz4gPC9nPiA8L2c+IDwvZz48L3N2Zz4=) +![Java Version 17](https://img.shields.io/badge/Java_Version-17-purple?logo=data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjIwMHB4IiB3aWR0aD0iMjAwcHgiIHZlcnNpb249IjEuMSIgaWQ9IkNhcGFfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmlld0JveD0iMCAwIDUwMi42MzIgNTAyLjYzMiIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIgZmlsbD0iIzAwMDAwMCI+PGcgaWQ9IlNWR1JlcG9fYmdDYXJyaWVyIiBzdHJva2Utd2hpdGVpZHRoPSIwIj48L2c+PGcgaWQ9IlNWR1JlcG9fdHJhY2VyQ2FycmllciIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48L2c+PGcgaWQ9IlNWR1JlcG9faWNvbkNhcnJpZXIiPiA8Zz4gPGc+IDxwYXRoIHN0eWxlPSJmaWxsOndoaXRlOyIgZD0iTTI0MC44NjQsMjY5Ljg5NGMwLDAtMjguMDItNTMuOTkyLTI2Ljk4NS05My40NDVjMC43NTUtMjguMTkzLDY0LjMyNC01Ni4wNjIsODkuMjgxLTk2LjUyOSBDMzI4LjA3NCwzOS40MzEsMzAwLjA1NCwwLDMwMC4wNTQsMHM2LjIzNCwyOS4wNzctMTAuMzc2LDU5LjE0N2MtMTYuNjA5LDMwLjExMy03Ny45MTQsNDcuNzc5LTEwMS43NDksOTkuNjc5IFMyNDAuODY0LDI2OS44OTQsMjQwLjg2NCwyNjkuODk0eiI+PC9wYXRoPiA8cGF0aCBzdHlsZT0iZmlsbDp3aGl0ZTsiIGQ9Ik0zNDUuNzQxLDEwNS44NjljMCwwLTk1LjQ5NCwzNi4zNDctOTUuNDk0LDc3Ljg0OWMwLDQxLjU0NSwyNS45MjgsNTUuMDI3LDMwLjExMyw2OC41MDkgYzQuMTQyLDEzLjUyNS03LjI2OSwzNi4zNDctNy4yNjksMzYuMzQ3czM3LjM2MS0yNS45NSwzMS4xMDUtNTYuMDYyYy02LjIzNC0zMC4xMTMtMzUuMjktMzkuNDc1LTE4LjY1OS02OS41NDQgQzI5Ni42NDYsMTQyLjc5OSwzNDUuNzQxLDEwNS44NjksMzQ1Ljc0MSwxMDUuODY5eiI+PC9wYXRoPiA8cGF0aCBzdHlsZT0iZmlsbDp3aGl0ZTsiIGQ9Ik0yMzAuNTEsMzI0Ljc0OGM4OC4yNDYtMy4xNDksMTIwLjQzLTMwLjk5NywxMjAuNDMtMzAuOTk3IGMtNTcuMDc2LDE1LjU1My0yMDguNjU0LDE0LjUzOS0yMDkuNzExLDMuMTI4Yy0xLjAxNC0xMS40MTEsNDYuNzAxLTIwLjc3Myw0Ni43MDEtMjAuNzczcy03NC43MjEsMC04MC45NTUsMTguNjggQzEwMC43NCwzMTMuNDY3LDE0Mi4zMjgsMzI3LjgzMywyMzAuNTEsMzI0Ljc0OHoiPjwvcGF0aD4gPHBhdGggc3R5bGU9ImZpbGw6d2hpdGU7IiBkPSJNMzU4LjE4NywzNjguNDk0YzAsMCw4Ni4zNjktMTguNDIxLDc3LjgyNy02NS4zMzhjLTEwLjM1NC01Ny4xMTktNzAuNTgtMjQuOTM2LTcwLjU4LTI0LjkzNiBzNDIuNjAyLDAsNDYuNzIyLDI1LjkyOEM0MTYuMzIsMzMwLjA5OCwzNTguMTg3LDM2OC40OTQsMzU4LjE4NywzNjguNDk0eiI+PC9wYXRoPiA8cGF0aCBzdHlsZT0iZmlsbDp3aGl0ZTsiIGQ9Ik0zMTUuNjI4LDM0My42MDFjMCwwLTIxLjc2NSw1LjcxNi01NC4wMTMsOS4zNGMtNDMuMjI4LDQuODUzLTk1LjQ5NCwxLjAxNC05OS42NTctNi4yNTYgYy00LjA5OC03LjI2OSw3LjI2OS0xMS40MTEsNy4yNjktMTEuNDExYy01MS45MjEsMTIuNDY4LTIzLjUxMiwzNC4yMzMsMzcuMzM5LDM4LjQxOGM1Mi4xNTgsMy41NTksMTI5Ljc5MS0xNS41NzQsMTI5Ljc5MS0xNS41NzQgTDMxNS42MjgsMzQzLjYwMXoiPjwvcGF0aD4gPHBhdGggc3R5bGU9ImZpbGw6d2hpdGU7IiBkPSJNMTgxLjczOCwzODguOTQzYzAsMC0yMy41NTUsMC42NjktMjQuOTM2LDEzLjEzN2MtMS4zNTksMTIuMzgyLDE0LjQ5NiwyMy41MTIsNzIuNjUsMjYuOTY0IGM1OC4xMzMsMy40NTEsOTguOTg4LTE1Ljg5OCw5OC45ODgtMTUuODk4bC0yNi4yOTUtMTUuOTYyYzAsMC0xNi42MzEsMy40OTQtNDIuMjM2LDYuOTQ2IGMtMjUuNjI2LDMuNDczLTc4LjE3My0yLjc4My04MC4yNDMtNy41OTNDMTc3LjU1MywzOTEuNjgyLDE4MS43MzgsMzg4Ljk0MywxODEuNzM4LDM4OC45NDN6Ij48L3BhdGg+IDxwYXRoIHN0eWxlPSJmaWxsOndoaXRlOyIgZD0iTTQwNy45OTQsNDQ1LjAwNWM4Ljk5NS05LjcwNy0yLjc4My0xNy4zMjEtMi43ODMtMTcuMzIxczQuMTQyLDQuODUzLTEuMzM3LDEwLjM3NiBjLTUuNTQ0LDUuNTIyLTU2LjA4NCwxOS4zNDktMTM3LjA2MSwyMy41MTJjLTgwLjk1NSw0LjE2My0xNjguODU2LTcuNjE1LTE3MS42MzktMTcuOTkgYy0yLjY5Ni0xMC4zNzYsNDUuMDE4LTE4LjY1OSw0NS4wMTgtMTguNjU5Yy01LjUyMiwwLjY5LTcxLjk2LDIuMDcxLTc0LjA3NCwyMC4wODJjLTIuMDcxLDE3Ljk2OCwyOS4wNTYsMzIuNTA3LDE1My42NywzMi41MDcgQzM0NC4zMzksNDc3LjQ5MSwzOTkuMDQyLDQ1NC42NDcsNDA3Ljk5NCw0NDUuMDA1eiI+PC9wYXRoPiA8cGF0aCBzdHlsZT0iZmlsbDp3aGl0ZTsiIGQ9Ik0zNTkuNTY4LDQ4NS44MTdjLTU0LjY4MiwxMS4wNDQtMjIwLjczNCw0LjA3Ny0yMjAuNzM0LDQuMDc3czEwNy45MTksMjUuNjI2LDIzMS4xMDksNC4xODUgYzU4Ljg4OC0xMC4yNjgsNjIuMzE4LTM4Ljc2Myw2Mi4zMTgtMzguNzYzUzQxNC4yNSw0NzQuNzA4LDM1OS41NjgsNDg1LjgxN3oiPjwvcGF0aD4gPC9nPiA8Zz4gPC9nPiA8Zz4gPC9nPiA8Zz4gPC9nPiA8Zz4gPC9nPiA8Zz4gPC9nPiA8Zz4gPC9nPiA8Zz4gPC9nPiA8Zz4gPC9nPiA8Zz4gPC9nPiA8Zz4gPC9nPiA8Zz4gPC9nPiA8Zz4gPC9nPiA + + + io.github.bsels + semantic-version-maven-plugin + 0.0.1 + + + +``` + +## Goals + +### create + +**Full name**: `io.github.bsels:semantic-version-maven-plugin:create` + +**Description**: Creates a version markdown file that specifies which projects should receive which type of semantic +version bump (PATCH, MINOR, or MAJOR). The goal provides an interactive interface to select projects and their version +bump types, and allows you to write changelog entries either inline or via an external editor. + +**Phase**: Not bound to any lifecycle phase (standalone goal) + +#### 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 | + +#### Example Usage + +**Basic usage** (interactive mode): + +```bash +mvn io.github.bsels:semantic-version-maven-plugin:create +``` + +**With custom versioning directory**: + +```bash +mvn io.github.bsels:semantic-version-maven-plugin:create \ + -Dversioning.directory=.versions +``` + +**Dry-run to preview**: + +```bash +mvn io.github.bsels:semantic-version-maven-plugin:create \ + -Dversioning.dryRun=true +``` + +**Multi-module project (leaf projects only)**: + +```bash +mvn io.github.bsels:semantic-version-maven-plugin:create \ + -Dversioning.modus=PROJECT_VERSION_ONLY_LEAFS +``` + +--- + +### update + +**Full name**: `io.github.bsels:semantic-version-maven-plugin:update` + +**Description**: Updates POM file versions and CHANGELOG.md files based on version markdown files created by the +`create` goal. The goal reads version bump specifications from markdown files, applies semantic versioning to project +versions, updates dependencies in multi-module projects, and merges changelog entries into CHANGELOG.md files. + +**Phase**: Not bound to any lifecycle phase (standalone goal) + +#### 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 | + +#### Example Usage + +**Basic usage** (file-based versioning): + +```bash +mvn io.github.bsels:semantic-version-maven-plugin:update +``` + +**Force MAJOR version bump** (override version files): + +```bash +mvn io.github.bsels:semantic-version-maven-plugin:update \ + -Dversioning.bump=MAJOR +``` + +**Force MINOR version bump**: + +```bash +mvn io.github.bsels:semantic-version-maven-plugin:update \ + -Dversioning.bump=MINOR +``` + +**Force PATCH version bump**: + +```bash +mvn io.github.bsels:semantic-version-maven-plugin:update \ + -Dversioning.bump=PATCH +``` + +**Dry-run to preview changes**: + +```bash +mvn io.github.bsels:semantic-version-maven-plugin:update \ + -Dversioning.dryRun=true +``` + +**With backup files**: + +```bash +mvn io.github.bsels:semantic-version-maven-plugin:update \ + -Dversioning.backup=true +``` + +**Custom versioning directory**: + +```bash +mvn io.github.bsels:semantic-version-maven-plugin:update \ + -Dversioning.directory=.versions +``` + +**Multi-module project with revision property**: + +```bash +mvn io.github.bsels:semantic-version-maven-plugin:update \ + -Dversioning.modus=REVISION_PROPERTY +``` + +## Configuration Properties + +### Common Properties + +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 | + +### 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 | + +## Examples + +### Example 1: Single Project Workflow + +1. **Create version specification**: + ```bash + mvn io.github.bsels:semantic-version-maven-plugin:create + ``` + - Select MINOR version bump + - Enter changelog: "Added new user authentication feature" + +2. **Preview changes**: + ```bash + mvn io.github.bsels:semantic-version-maven-plugin:update -Dversioning.dryRun=true + ``` + +3. **Apply version update**: + ```bash + mvn io.github.bsels:semantic-version-maven-plugin:update + ``` + +### Example 2: Multi-Module Project Workflow + +1. **Create version specifications for multiple modules**: + ```bash + mvn io.github.bsels:semantic-version-maven-plugin:create + ``` + - Select `module-api` → MAJOR (breaking changes) + - Select `module-core` → MINOR (new features) + - Enter changelog for each module + +2. **Update with backups**: + ```bash + mvn io.github.bsels:semantic-version-maven-plugin:update -Dversioning.backup=true + ``` + +### Example 3: Emergency Patch Release + +Skip version file creation and force PATCH bump: + +```bash +mvn io.github.bsels:semantic-version-maven-plugin:update -Dversioning.bump=PATCH +``` + +### Example 4: POM Configuration + +Configure the plugin directly in `pom.xml`: + +```xml + + + + + io.github.bsels + semantic-version-maven-plugin + 0.0.1 + + PROJECT_VERSION + .versioning + false + true + FILE_BASED + + + + +``` + +## License + +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. + diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..531f644 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,9 @@ +# Security policy + +## Supported versions + +The latest version of the project is currently supported with security updates. + +## Reporting a vulnerability + +You can report a vulnerability by creating an issue. diff --git a/mvnw b/mvnw old mode 100644 new mode 100755 diff --git a/pom.xml b/pom.xml index 8ee9bf7..1d1a692 100644 --- a/pom.xml +++ b/pom.xml @@ -6,10 +6,14 @@ io.github.bsels semantic-version-maven-plugin - 0.0.1-SNAPSHOT + 0.0.1 maven-plugin ${project.groupId}:${project.artifactId} - TODO + + A Maven plugin that automates semantic versioning with markdown‑based changelog management. + Provides two standalone goals: create generates interactive version spec files; update applies those specs, + bumps project versions, and merges CHANGELOG entries. + https://github.com/bsels/semantic-version-maven-plugin @@ -51,9 +55,11 @@ 3.27.6 + 0.27.0 + 2.20.1 6.0.1 - 5.21.0 3.15.2 + 5.21.0 UTF-8 @@ -81,7 +87,7 @@ ${maven.plugin.api.version} - >=25.0.0 + >=17.0.0 @@ -205,6 +211,21 @@ maven-plugin-annotations ${maven.plugin.version} + + org.commonmark + commonmark + ${commonmark.version} + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + ${jackson.version} + diff --git a/src/main/java/io/github/bsels/semantic/version/BaseMojo.java b/src/main/java/io/github/bsels/semantic/version/BaseMojo.java new file mode 100644 index 0000000..05cc2e5 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/BaseMojo.java @@ -0,0 +1,371 @@ +package io.github.bsels.semantic.version; + +import io.github.bsels.semantic.version.models.MarkdownMapping; +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.Modus; +import io.github.bsels.semantic.version.utils.MarkdownUtils; +import io.github.bsels.semantic.version.utils.Utils; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugin.logging.Log; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.project.MavenProject; +import org.commonmark.node.Node; + +import java.io.IOException; +import java.io.StringWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/// Base class for Maven plugin goals, providing foundational functionality for Mojo execution. +/// This class extends [AbstractMojo] and serves as the base for plugins managing versioning +/// or other project configurations in Maven builds. +/// Subclasses must implement the abstract `internalExecute` method to define specific behaviors. +/// +/// This class handles: +/// - Resolving the base directory of the project. +/// - Configurable execution modes for versioning. +/// - Determining whether the plugin executes for subprojects in multi-module builds. +/// - Accessing the current Maven session and project structure. +/// - Delegating core plugin functionality to subclasses. +/// +/// Permissions: +/// - This sealed class can only be extended by specific classes stated in its `permits` clause. +/// +/// Parameters: +/// - `baseDirectory`: Resolved to the project's `basedir` property. +/// - `modus`: Execution strategy for versioning (e.g., single or multi-module). +/// - `session`: Current Maven session instance. +/// - `executeForSubproject`: Determines whether the plugin should apply logic to subprojects. +/// - `dryRun`: Indicates whether the plugin should execute in dry-run mode. +/// +/// The `execute()` method handles plugin execution flow by determining the project type +/// and invoking the `internalExecute()` method of subclasses where necessary. +/// +/// Any issues encountered during plugin execution may result in a [MojoExecutionException] +/// or a [MojoFailureException] being thrown. +public abstract sealed class BaseMojo extends AbstractMojo permits CreateVersionMarkdownMojo, UpdatePomMojo { + + /// A constant string representing the filename of the changelog file, "CHANGELOG.md". + /// + /// This file typically contains information about the changes, updates, and version history for a project. + /// It can be used or referenced in Maven plugin implementations to locate + /// or process the changelog file content during build processes. + public static final String CHANGELOG_MD = "CHANGELOG.md"; + + /// Represents the mode in which project versioning is handled within the Maven plugin. + /// This parameter is used to define the strategy for managing version numbers across single or multi-module projects. + /// + /// Configuration: + /// - `property`: "versioning.modus", allows external configuration via Maven plugin properties. + /// - `required`: This parameter is mandatory and must be explicitly defined during plugin execution. + /// - `defaultValue`: Defaults to `PROJECT_VERSION` mode, where versioning is executed based on the project version. + /// + /// Supported Modes: + /// - [Modus#PROJECT_VERSION]: Handles versioning for projects using the project version property. + /// - [Modus#REVISION_PROPERTY]: Handles versioning for projects using the revision property. + /// - [Modus#PROJECT_VERSION_ONLY_LEAFS]: Handles versioning for projects using the project version property, + /// but only for leaf projects in a multi-module setup. + @Parameter(property = "versioning.modus", required = true, defaultValue = "PROJECT_VERSION") + protected Modus modus = Modus.PROJECT_VERSION; + + /// Represents the current Maven session during the execution of the plugin. + /// Provides access to details such as the projects being built, current settings, + /// system properties, and organizational workflow defined in the Maven runtime. + /// + /// This parameter is injected by Maven and is critical for accessing and manipulating + /// the build lifecycle, including resolving the state of the project or session-specific + /// configurations. + /// + /// Configuration: + /// - `readonly`: Ensures the session remains immutable during plugin execution. + /// - `required`: The session parameter is mandatory for the plugin to function. + /// - `defaultValue`: Defaults to Maven's `${session}`, representing the active Maven session. + @Parameter(defaultValue = "${session}", required = true, readonly = true) + protected MavenSession session; + + /// Indicates whether the plugin should execute in dry-run mode. + /// When set to `true`, the plugin performs all operations and logs outputs + /// without making actual changes to files or the project configuration. + /// This is useful for testing and verifying the plugin's behavior before applying changes. + /// + /// Configuration: + /// - `property`: Maps to the Maven plugin property `versioning.dryRun`. + /// - `defaultValue`: Defaults to `false`, meaning dry-run mode is disabled by default. + @Parameter(property = "versioning.dryRun", defaultValue = "false") + protected boolean dryRun = false; + + /// Represents the directory used for storing versioning-related files during the Maven plugin execution. + /// + /// This field is a configuration parameter for the plugin, + /// allowing users to specify a custom directory in the version-specific Markdown files resides. + /// By default, it points to the `.versioning` directory relative to the root project directory. + /// + /// Key Characteristics: + /// - Defined as a `Path` object to represent the directory in a file system-agnostic manner. + /// - Configurable via the Maven property `versioning.directory`. + /// - Marked as a required field, meaning the plugin execution will fail if it is not set or cannot be resolved. + /// - Defaults to the `.versioning` directory, unless explicitly overridden. + /// + /// This field is commonly used by methods or processes within the containing class to locate + /// and operate on files related to versioning functionality. + @Parameter(property = "versioning.directory", required = true, defaultValue = ".versioning") + protected Path versionDirectory = Path.of(".versioning"); + + /// 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; + + /// Default constructor for the BaseMojo class. + /// Initializes the instance by invoking the superclass constructor. + /// Maven framework typically uses this constructor during the build process. + protected BaseMojo() { + super(); + } + + /// Executes the Mojo. + /// This method is the main entry point for the Maven plugin execution. + /// It handles the execution logic related to ensuring the plugin is executed for the correct Maven project in a + /// multi-module project scenario and delegates the core functionality to the [#internalExecute()] method + /// for further implementation by subclasses. + /// + /// The execution process includes: + /// - Determining whether the current Maven project is the root project or a subproject. + /// - Skipping execution for subprojects unless explicitly allowed by the `executeForSubproject` field. + /// - Logging appropriate messages regarding execution status. + /// - Delegating the plugin-specific functionality to the `internalExecute` method. + /// + /// @throws MojoExecutionException if there is an issue during the execution causing it to fail irrecoverably. + /// @throws MojoFailureException if the execution fails due to a known configuration or logic failure. + public final void execute() throws MojoExecutionException, MojoFailureException { + Log log = getLog(); + MavenProject rootProject = session.getTopLevelProject(); + MavenProject currentProject = session.getCurrentProject(); + if (!rootProject.equals(currentProject)) { + log.info("Skipping execution for subproject %s:%s:%s".formatted( + currentProject.getGroupId(), + currentProject.getArtifactId(), + currentProject.getVersion() + )); + return; + } + + log.info("Execution for project: %s:%s:%s".formatted( + currentProject.getGroupId(), + currentProject.getArtifactId(), + currentProject.getVersion() + )); + + internalExecute(); + } + + /// Executes the core functionality of the Maven plugin. + /// This method is intended to be implemented by subclasses to define the specific behavior of the plugin. + /// + /// The method is called internally by the `execute()` method of the containing class, + /// after performing necessary checks and setup steps related to the Maven project context. + /// + /// Subclasses should override this method to provide the actual logic for the plugin operation. + /// + /// @throws MojoExecutionException if an unexpected error occurs during the execution, causing it to fail irrecoverably. + /// @throws MojoFailureException if the execution fails due to a recoverable or known issue, such as an invalid configuration. + protected abstract void internalExecute() throws MojoExecutionException, MojoFailureException; + + /// Reads all Markdown files from the `.versioning` directory within the execution root directory, + /// parses their content, and converts them into a list of [VersionMarkdown] objects. + /// + /// The method recursively iterates through the `.versioning` directory, filtering for files with a `.md` extension, + /// and processes each Markdown file using the [MarkdownUtils#readVersionMarkdown] method. + /// The parsed results are returned as immutable instances of [VersionMarkdown]. + /// + /// @return a [List] of [VersionMarkdown] objects representing the parsed Markdown content and versioning metadata + /// @throws MojoExecutionException if an I/O error occurs while accessing the `.versioning` directory or its contents, or if there is an error in parsing the Markdown files + protected final List getVersionMarkdowns() throws MojoExecutionException { + Log log = getLog(); + Path versioningFolder = getVersioningFolder(); + if (!Files.exists(versioningFolder)) { + log.warn("No versioning files found in %s as folder does not exists".formatted(versioningFolder)); + return List.of(); + } + + List versionMarkdowns; + try (Stream markdownFileStream = Files.walk(versioningFolder, 1)) { + List markdownFiles = markdownFileStream.filter(Files::isRegularFile) + .filter(path -> path.toString().toLowerCase().endsWith(".md")) + .toList(); + List parsedMarkdowns = new ArrayList<>(); + for (Path markdownFile : markdownFiles) { + parsedMarkdowns.add(MarkdownUtils.readVersionMarkdown(log, markdownFile)); + } + versionMarkdowns = List.copyOf(parsedMarkdowns); + } catch (IOException e) { + throw new MojoExecutionException("Unable to read versioning folder", e); + } + return versionMarkdowns; + } + + /// Determines and retrieves the path to the versioning folder used for storing version-related files. + /// If the `versionDirectory` field is configured as an absolute path, it is returned as-is. + /// Otherwise, a relative path is resolved against the current Maven execution's root directory. + /// + /// @return the path to the versioning folder as a [Path] object + protected Path getVersioningFolder() { + Path versioningFolder; + if (versionDirectory.isAbsolute()) { + versioningFolder = versionDirectory; + } else { + versioningFolder = Path.of(session.getExecutionRootDirectory()).resolve(versionDirectory); + } + return versioningFolder; + } + + /// Creates a MarkdownMapping instance based on a list of [VersionMarkdown] objects. + /// + /// This method processes a list of [VersionMarkdown] entries to generate a mapping + /// between Maven artifacts and their respective semantic version bumps. + /// + /// @param versionMarkdowns the list of [VersionMarkdown] objects representing version updates; must not be null + /// @return a MarkdownMapping instance encapsulating the calculated semantic version bumps and an empty Markdown map + protected MarkdownMapping getMarkdownMapping(List versionMarkdowns) { + Map versionBumpMap = versionMarkdowns.stream() + .map(VersionMarkdown::bumps) + .map(Map::entrySet) + .flatMap(Set::stream) + .collect(Utils.groupingByImmutable( + Map.Entry::getKey, + Collectors.reducing(SemanticVersionBump.NONE, Map.Entry::getValue, SemanticVersionBump::max) + )); + Map> markdownMap = versionMarkdowns.stream() + .>mapMulti((item, consumer) -> { + for (MavenArtifact artifact : item.bumps().keySet()) { + consumer.accept(Map.entry(artifact, item)); + } + }) + .collect(Utils.groupingByImmutable( + Map.Entry::getKey, + Collectors.mapping(Map.Entry::getValue, Utils.asImmutableList()) + )); + return new MarkdownMapping(versionBumpMap, markdownMap); + } + + /// Validates that the artifacts defined in the MarkdownMapping are present within the scope of the Maven project + /// execution. + /// + /// This method compares the artifacts in the provided MarkdownMapping against the artifacts derived from the Maven + /// projects currently in scope. + /// If any artifacts in the MarkdownMapping are not present in the project scope, + /// a [MojoFailureException] is thrown. + /// + /// @param markdownMapping the MarkdownMapping object containing the artifacts and their corresponding semantic version bumps; must not be null + /// @throws MojoFailureException if any artifacts in the MarkdownMapping are not part of the current Maven project scope + protected void validateMarkdowns(MarkdownMapping markdownMapping) throws MojoFailureException { + Set artifactsInMarkdown = markdownMapping.versionBumpMap().keySet(); + Stream projectsInScope = getProjectsInScope(); + Set artifacts = projectsInScope.map(project -> new MavenArtifact(project.getGroupId(), project.getArtifactId())) + .collect(Utils.asImmutableSet()); + + if (!artifacts.containsAll(artifactsInMarkdown)) { + String unknownArtifacts = artifactsInMarkdown.stream() + .filter(Predicate.not(artifacts::contains)) + .map(MavenArtifact::toString) + .collect(Collectors.joining(", ")); + + throw new MojoFailureException( + "The following artifacts in the Markdown files are not present in the project scope: %s".formatted( + unknownArtifacts + ) + ); + } + } + + /// Retrieves a stream of Maven projects that are within the current execution scope. + /// The scope varies based on the value of the field `modus`: + /// - [Modus#PROJECT_VERSION]: Returns all projects in the session, sorted topologically. + /// - [Modus#REVISION_PROPERTY]: Returns only the current project in the session. + /// - [Modus#PROJECT_VERSION_ONLY_LEAFS]: Returns only leaf projects in the session, sorted topologically. + /// + /// @return a [Stream] of [MavenProject] objects representing the projects within the defined execution scope + protected Stream getProjectsInScope() { + return switch (modus) { + case PROJECT_VERSION -> session.getResult() + .getTopologicallySortedProjects() + .stream(); + case REVISION_PROPERTY -> Stream.of(session.getCurrentProject()); + case PROJECT_VERSION_ONLY_LEAFS -> session.getResult() + .getTopologicallySortedProjects() + .stream() + .filter(Utils.mavenProjectHasNoModules()); + }; + } + + /// Writes a changelog to a Markdown file. + /// If the dry-run mode is enabled, the method simulates the writing operation + /// and logs the result instead of physically creating or modifying the file. + /// Otherwise, it directly writes to the specified Markdown file, + /// potentially backing up the previous file if required. + /// + /// @param markdownNode the [Node] representing the changelog content to write; must not be null + /// @param markdownFile the [Path] representing the target Markdown file; must not be null + /// @throws MojoExecutionException if an unexpected error occurs during execution, such as an I/O issue + /// @throws MojoFailureException if the writing operation fails due to known issues or invalid configuration + protected void writeMarkdownFile(Node markdownNode, Path markdownFile) + throws MojoExecutionException, MojoFailureException { + if (dryRun) { + dryRunWriteFile( + writer -> MarkdownUtils.writeMarkdown(writer, markdownNode), + markdownFile, "Dry-run: new markdown file at %s:%n%s" + ); + } else { + MarkdownUtils.writeMarkdownFile(markdownFile, markdownNode, backupFiles); + } + } + + /// Simulates writing to a file by using a [StringWriter]. + /// The provided consumer is responsible for writing content to the [StringWriter]. + /// Logs the specified logLine upon successful completion. + /// + /// @param consumer the functional interface used to write content to the [StringWriter] + /// @param file the file path representing the target file for writing (used for logging) + /// @param logLine the log message that will be logged, formatted with the file and written content + /// @throws MojoExecutionException if an I/O error occurs while attempting to write + /// @throws MojoFailureException if any Mojo-related failure occurs during execution + protected void dryRunWriteFile(MojoThrowingConsumer consumer, Path file, String logLine) + throws MojoExecutionException, MojoFailureException { + try (StringWriter writer = new StringWriter()) { + consumer.accept(writer); + getLog().info(logLine.formatted(file, writer)); + } catch (IOException e) { + throw new MojoExecutionException("Unable to open output stream for writing", e); + } + } + + /// Functional interface that represents an operation that accepts a single input + /// and can throw [MojoExecutionException] and [MojoFailureException]. + /// + /// @param the type of the input to the operation + protected interface MojoThrowingConsumer { + + /// Performs the given operation on the specified input. + /// + /// @param t the input parameter on which the operation will be performed + /// @throws MojoExecutionException if an error occurs during execution + /// @throws MojoFailureException if the operation fails + void accept(T t) throws MojoExecutionException, 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 new file mode 100644 index 0000000..c7742b5 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojo.java @@ -0,0 +1,194 @@ +package io.github.bsels.semantic.version; + +import io.github.bsels.semantic.version.models.MavenArtifact; +import io.github.bsels.semantic.version.models.SemanticVersionBump; +import io.github.bsels.semantic.version.utils.MarkdownUtils; +import io.github.bsels.semantic.version.utils.ProcessUtils; +import io.github.bsels.semantic.version.utils.TerminalHelper; +import io.github.bsels.semantic.version.utils.Utils; +import io.github.bsels.semantic.version.utils.yaml.front.block.YamlFrontMatterBlock; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugin.logging.Log; +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.ResolutionScope; +import org.commonmark.node.Node; + +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +/// Mojo for creating a version Markdown file based on semantic versioning. +/// +/// This class is an implementation of a Maven plugin goal that facilitates the creation of a semantic +/// version Markdown file for documenting version changes in projects within a specified scope. +/// It provides functionality to select semantic version bumps, manage changelog entries, +/// and generate Markdown content according to the determined versioning structure. +/// +/// The Mojo operates in the following steps: +/// 1. Collects the list of projects within a scope defined by the Maven build lifecycle. +/// 2. Allows the user to define semantic version bumps for each project. +/// 3. Creates and updates a version Markdown file with the required versioning details. +/// +/// This goal ensures consistency in semantic versioning practices across multi-module Maven projects. +@Mojo(name = "create", aggregator = true, requiresDependencyResolution = ResolutionScope.NONE) +@Execute(phase = LifecyclePhase.NONE) +public final class CreateVersionMarkdownMojo extends BaseMojo { + /// A static list containing the semantic version bump types in ascending order of significance: + /// PATCH, MINOR, and MAJOR. + /// + /// This list defines the standard sequence of semantic version increments allowed in the application. + /// It is used to determine the type of version bump that can be considered + /// or applied during semantic versioning operations. + /// + /// - PATCH: Represents the smallest increment, typically for backward-compatible bug fixes. + /// - MINOR: Represents an intermediate increment, typically for adding backward-compatible functionality. + /// - MAJOR: Represents the largest increment, typically involving breaking changes. + /// + /// Being immutable and final, this list ensures a consistent + /// and predefined order for semantic version bump evaluations or operations across the application. + private static final List SEMANTIC_VERSION_BUMPS = List.of( + SemanticVersionBump.PATCH, SemanticVersionBump.MINOR, SemanticVersionBump.MAJOR + ); + + /// 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. + public CreateVersionMarkdownMojo() { + super(); + } + + /// Executes the primary logic of the CreateVersionMarkdownMojo goal. + /// This method is triggered during the Maven build lifecycle and is responsible for creating a version Markdown + /// entry based on the semantic versioning bumps determined for projects within the specified scope. + /// + /// The method performs the following actions: + /// 1. Retrieves the list of projects in scope and validates its existence. + /// 2. Determines and selects the semantic version bumps for these projects. + /// 3. Constructs a version bump header in YAML front matter format to append to the changelog entry. + /// 4. Creates the changelog entry and prepends the version bump header. + /// 5. Validates the existence of the versioning folder, creating it if necessary. + /// 6. Resolves the target versioning file path and writes the updated Markdown content to it. + /// + /// @throws MojoExecutionException if an error occurs during execution, such as issues creating directories or writing the versioning file. + /// @throws MojoFailureException if the operation to process or create the version Markdown file fails. + @Override + protected void internalExecute() throws MojoExecutionException, MojoFailureException { + Log log = getLog(); + List projects = getProjectsInScope() + .map(mavenProject -> new MavenArtifact(mavenProject.getGroupId(), mavenProject.getArtifactId())) + .toList(); + if (projects.isEmpty()) { + log.warn("No projects found in scope"); + return; + } + Map selectedProjects = determineVersionBumps(projects); + if (selectedProjects.isEmpty()) { + log.warn("No projects selected"); + return; + } + + YamlFrontMatterBlock versionBumpHeader = MarkdownUtils.createVersionBumpsHeader(log, selectedProjects); + Node inputMarkdown = createChangelogEntry(); + inputMarkdown.prependChild(versionBumpHeader); + + Path versioningFolder = getVersioningFolder(); + Utils.createDirectoryIfNotExists(versioningFolder); + Path versioningFile = Utils.resolveVersioningFile(versioningFolder); + writeMarkdownFile(inputMarkdown, versioningFile); + } + + /// Creates a changelog entry by either taking user input directly or by leveraging an external editor. + /// This method prompts the user to enter multiline input for the changelog entry, where two consecutive empty lines + /// terminate the input. + /// If the user enters an empty line initially, + /// the method invokes an external editor to create the changelog content. + /// + /// @return a [Node] representing the parsed Markdown content of the changelog entry. + /// @throws MojoExecutionException if an error occurs during the execution of the changelog entry creation. + /// @throws MojoFailureException if the operation to create or process the changelog fails. + private Node createChangelogEntry() throws MojoExecutionException, MojoFailureException { + Optional input = TerminalHelper.readMultiLineInput( + """ + Please type the changelog entry here (enter empty line to open external editor, \ + two empty lines after your input to end):\ + """ + ); + Node inputMarkdown; + if (input.isPresent()) { + inputMarkdown = MarkdownUtils.parseMarkdown(input.get()); + } else { + inputMarkdown = createVersionMarkdownInExternalEditor(); + } + return inputMarkdown; + } + + /// Determines the semantic version bumps for a list of Maven artifacts based on user selection. + /// This method allows the user to define semantic version bumps for one or multiple projects from the provided list + /// of Maven artifacts. + /// If no projects are selected during the process, the method returns null. + /// + /// @param projects a list of [MavenArtifact] objects representing the projects for which version bumps will be determined; must not be null + /// @return a map where the keys are [MavenArtifact] objects and the values are the corresponding [SemanticVersionBump] selected by the user, or null if no projects are selected + private Map determineVersionBumps(List projects) { + Map selectedProjects = new HashMap<>(projects.size()); + if (projects.size() == 1) { + MavenArtifact mavenArtifact = projects.get(0); + System.out.printf("Project %s%n", mavenArtifact); + SemanticVersionBump versionBump = TerminalHelper.singleChoice( + "Select semantic version bump: ", "semantic version", SEMANTIC_VERSION_BUMPS + ); + selectedProjects.put(mavenArtifact, versionBump); + } else { + List projectSelections = TerminalHelper.multiChoice("Select projects:", "project", projects); + if (projectSelections.isEmpty()) { + getLog().debug("No projects selected"); + return Map.of(); + } + System.out.printf("Selected projects: %s%n", projectSelections.stream().map(MavenArtifact::toString).collect(Collectors.joining(", "))); + for (MavenArtifact mavenArtifact : projectSelections) { + SemanticVersionBump versionBump = TerminalHelper.singleChoice( + "Select semantic version bump for %s: ".formatted(mavenArtifact), + "semantic version", + SEMANTIC_VERSION_BUMPS + ); + selectedProjects.put(mavenArtifact, versionBump); + } + } + System.out.printf( + "Version bumps: %s%n", + selectedProjects.entrySet() + .stream() + .sorted(Map.Entry.comparingByKey()) + .map(entry -> "'%s': %s".formatted(entry.getKey(), entry.getValue())) + .collect(Collectors.joining(", ")) + ); + return Map.copyOf(selectedProjects); + } + + /// Creates a Markdown file in an external editor, processes and returns its content as a [Node] object. + /// This method creates a temporary Markdown file, opens it in an external editor for editing, + /// and subsequently reads its content into a [Node] representation. + /// The temporary file is deleted after the operation, regardless of its success or failure. + /// + /// @return A [Node] object representing the content of the created and processed Markdown file. + /// @throws MojoExecutionException If there is an issue during the creation or reading process. + /// @throws MojoFailureException If the operation fails to create or edit a Markdown file successfully. + private Node createVersionMarkdownInExternalEditor() throws MojoExecutionException, MojoFailureException { + Path temporaryMarkdownFile = Utils.createTemporaryMarkdownFile(); + try { + boolean valid = ProcessUtils.executeEditor(temporaryMarkdownFile); + if (!valid) { + throw new MojoFailureException("Unable to create a new Markdown file in external editor."); + } + return MarkdownUtils.readMarkdown(getLog(), temporaryMarkdownFile); + } finally { + Utils.deleteFileIfExists(temporaryMarkdownFile); + } + } +} diff --git a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java new file mode 100644 index 0000000..0f448cd --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java @@ -0,0 +1,547 @@ +package io.github.bsels.semantic.version; + +import io.github.bsels.semantic.version.models.MarkdownMapping; +import io.github.bsels.semantic.version.models.MavenArtifact; +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.Utils; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugin.logging.Log; +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.apache.maven.project.MavenProject; +import org.w3c.dom.Document; +import org.w3c.dom.Node; + +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Queue; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import static io.github.bsels.semantic.version.utils.MarkdownUtils.readMarkdown; + +/// The UpdatePomMojo class provides functionality for updating Maven project POM files during a build process. +/// It integrates into the Maven lifecycle as a Mojo and enables version updates, dependency management, +/// and synchronization with supporting Markdown files. +/// +/// This class supports the following key functionalities: +/// - Applies semantic versioning to update project versions. +/// - Processes dependencies and updates related files accordingly. +/// - Handles single or multiple Maven projects. +/// - Provides backup capabilities to safeguard original POM files. +/// - Offers dry-run functionality to preview changes without modifying files. +@Mojo(name = "update", aggregator = true, requiresDependencyResolution = ResolutionScope.NONE) +@Execute(phase = LifecyclePhase.NONE) +public final class UpdatePomMojo extends BaseMojo { + + /// Represents the strategy or mechanism for handling version increments or updates during the execution + /// of the Maven plugin. This parameter defines how the versioning process is managed in the project, whether + /// it's based on semantic versioning principles or custom file-based mechanisms. + /// + /// Configuration: + /// - `property`: "versioning.bump", allows external configuration via Maven plugin properties. + /// - `required`: This parameter is mandatory and must be explicitly defined during plugin execution. + /// - `defaultValue`: Defaults to `FILE_BASED`, where version determination relies on file-based mechanisms. + /// + /// Supported Strategies: + /// - [VersionBump#FILE_BASED]: Determines version information or increments based on file-based mechanisms, + /// such as reading specific configuration or version files. + /// - [VersionBump#MAJOR]: Represents an increment to the major version component, + /// used for changes that break backward compatibility. + /// - [VersionBump#MINOR]: Represents an increment to the minor version component, + /// used for adding new backward-compatible features. + /// - [VersionBump#PATCH]: Represents an increment to the patch version component, + /// used for backward-compatible bug fixes. + @Parameter(property = "versioning.bump", required = true, defaultValue = "FILE_BASED") + VersionBump versionBump = VersionBump.FILE_BASED; + + /// Default constructor for the UpdatePomMojo class. + /// + /// Initializes an instance of the UpdatePomMojo class by invoking the superclass constructor. + /// This class is responsible for handling version updates in Maven POM files during the build process. + /// + /// Key Responsibilities of UpdatePomMojo: + /// - Determines the type of semantic version bump to apply. + /// - Updates Maven POM version information based on the configured parameters. + /// - Supports dry-run for reviewing changes without making actual file updates. + /// - Provides backup functionality to preserve the original POM file before modifications. + /// + /// Intended to be used within the Maven build lifecycle by plugins requiring POM version updates. + public UpdatePomMojo() { + super(); + } + + /// Executes the core logic of the Mojo. + /// + /// This method performs the following steps: + /// 1. Retrieves the logger instance for logging operations. + /// 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: + /// - Logs a message if no projects are found. + /// - Handles processing for a single project if only one is found. + /// - Handles processing for multiple projects if more than one is found. + /// 6. If any project is updated based on the files, the version Markdown files are deleted. + /// + /// @throws MojoExecutionException if an unexpected problem occurs during execution. This is typically a critical error that causes the Mojo to fail. + /// @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 { + Log log = getLog(); + List versionMarkdowns = getVersionMarkdowns(); + MarkdownMapping mapping = getMarkdownMapping(versionMarkdowns); + validateMarkdowns(mapping); + + List projectsInScope = getProjectsInScope() + .collect(Utils.asImmutableList()); + + boolean hasChanges; + if (projectsInScope.isEmpty()) { + log.warn("No projects found in scope"); + hasChanges = false; + } else if (projectsInScope.size() == 1) { + log.info("Single project in scope"); + hasChanges = handleSingleProject(mapping, projectsInScope.get(0)); + } else { + log.info("Multiple projects in scope"); + hasChanges = handleMultiProjects(mapping, projectsInScope); + } + + if (!dryRun && hasChanges && VersionBump.FILE_BASED.equals(versionBump)) { + Utils.deleteFilesIfExists( + versionMarkdowns.stream() + .map(VersionMarkdown::path) + .filter(Objects::nonNull) + .toList() + ); + } + } + + /// 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 project the Maven project to be processed + /// @return `true` if a version update was performed, `false` 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) + throws MojoExecutionException, MojoFailureException { + Path pom = project.getFile() + .toPath(); + MavenArtifact artifact = new MavenArtifact(project.getGroupId(), project.getArtifactId()); + + Document document = POMUtils.readPom(pom); + + SemanticVersionBump semanticVersionBump = getSemanticVersionBump(artifact, markdownMapping.versionBumpMap()); + Optional version = updateProjectVersion(semanticVersionBump, document); + if (version.isPresent()) { + String newVersion = version.get() + .newVersion(); + + writeUpdatedPom(document, pom); + updateMarkdownFile(markdownMapping, artifact, pom, newVersion); + } + return version.isPresent(); + } + + /// 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. + /// @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) + throws MojoExecutionException, MojoFailureException { + Log log = getLog(); + Map documents = readAllPoms(projects); + Set reactorArtifacts = documents.keySet(); + log.info("Found %d projects in scope".formatted(documents.size())); + + Map> updatableDependencies = mergeUpdatableDependencies( + documents.values(), + reactorArtifacts + ); + Map> dependencyToProjectArtifacts = createDependencyToProjectArtifactMapping( + documents.values(), + reactorArtifacts + ); + + UpdatedAndToUpdateArtifacts result = processMarkdownVersions( + markdownMapping, + reactorArtifacts, + documents, + dependencyToProjectArtifacts, + updatableDependencies + ); + + handleDependencyMavenProjects( + markdownMapping, + result, + documents, + dependencyToProjectArtifacts, + updatableDependencies + ); + + writeUpdatedProjects(result.updatedArtifacts(), documents); + return !result.updatedArtifacts().isEmpty(); + } + + /// Handles Maven projects and their dependencies to update versions and related metadata. + /// This method processes dependencies and updates the project versions accordingly, + /// ensuring that affected dependencies and documentation are updated. + /// + /// @param markdownMapping the mapping of Markdown files for recording version changes + /// @param result the object containing artifacts to be updated and those already updated + /// @param documents a mapping of Maven artifacts to their associated project and document representation + /// @param dependencyToProjectArtifacts a mapping of Maven artifacts to the list of project artifacts depending on them + /// @param updatableDependencies a mapping of Maven artifacts to their corresponding updatable dependency nodes in POM files + /// @throws MojoExecutionException if an error occurs during the execution of the Maven plugin + /// @throws MojoFailureException if any Mojo-related failure occurs during execution + private void handleDependencyMavenProjects( + MarkdownMapping markdownMapping, + UpdatedAndToUpdateArtifacts result, + Map documents, + Map> dependencyToProjectArtifacts, + Map> updatableDependencies + ) throws MojoExecutionException, MojoFailureException { + Set updatedArtifacts = result.updatedArtifacts(); + Queue toBeUpdated = result.toBeUpdated(); + while (!toBeUpdated.isEmpty()) { + MavenArtifact artifact = toBeUpdated.poll(); + toBeUpdated.remove(artifact); + updatedArtifacts.add(artifact); + + MavenProjectAndDocument mavenProjectAndDocument = documents.get(artifact); + VersionChange change = updateProjectVersion( + SemanticVersionBump.PATCH, + mavenProjectAndDocument.document() + ).orElseThrow(); + + dependencyToProjectArtifacts.getOrDefault(artifact, List.of()) + .stream() + .filter(Predicate.not(updatedArtifacts::contains)) + .forEach(toBeUpdated::offer); + + updateMarkdownFile(markdownMapping, artifact, mavenProjectAndDocument.pomFile(), change.newVersion()); + + updatableDependencies.getOrDefault(artifact, List.of()) + .forEach(node -> POMUtils.updateVersionNodeIfOldVersionMatches(change, node)); + } + } + + /// Processes the Markdown versions for the provided Maven artifacts and updates the required dependencies, + /// 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 + /// @param documents a mapping of Maven artifacts to their corresponding Maven project and document + /// @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 MojoFailureException if any Mojo-related failure occurs during execution + private UpdatedAndToUpdateArtifacts processMarkdownVersions( + MarkdownMapping markdownMapping, + Set reactorArtifacts, + Map documents, + Map> dependencyToProjectArtifacts, + Map> updatableDependencies + ) throws MojoExecutionException, MojoFailureException { + Set updatedArtifacts = new HashSet<>(); + Queue toBeUpdated = new ArrayDeque<>(reactorArtifacts.size()); + for (MavenArtifact artifact : reactorArtifacts) { + SemanticVersionBump bump = getSemanticVersionBump(artifact, markdownMapping.versionBumpMap()); + MavenProjectAndDocument mavenProjectAndDocument = documents.get(artifact); + Optional versionChange = updateProjectVersion(bump, mavenProjectAndDocument.document()); + if (versionChange.isPresent()) { + VersionChange change = versionChange.get(); + updatedArtifacts.add(artifact); + + dependencyToProjectArtifacts.getOrDefault(artifact, List.of()) + .stream() + .filter(Predicate.not(updatedArtifacts::contains)) + .forEach(toBeUpdated::offer); + + updateMarkdownFile(markdownMapping, artifact, mavenProjectAndDocument.pomFile(), change.newVersion()); + + updatableDependencies.getOrDefault(artifact, List.of()) + .forEach(node -> POMUtils.updateVersionNodeIfOldVersionMatches(change, node)); + } + } + return new UpdatedAndToUpdateArtifacts(updatedArtifacts, toBeUpdated); + } + + /// Updates the Maven projects based on the provided set of updated artifacts and their associated + /// Maven project documents. + /// + /// @param updatedArtifacts a set of Maven artifacts that have been updated and need their projects to be modified + /// @param documents a map that associates Maven artifacts with their corresponding Maven project and document details + /// @throws MojoExecutionException if an error occurs during the project update process + /// @throws MojoFailureException if the update process fails due to a misconfiguration or other failure + private void writeUpdatedProjects( + Set updatedArtifacts, + Map documents + ) throws MojoExecutionException, MojoFailureException { + Log log = getLog(); + for (MavenArtifact artifact : updatedArtifacts) { + log.debug("Updating project %s".formatted(artifact)); + MavenProjectAndDocument mavenProjectAndDocument = documents.get(artifact); + Path pomFile = mavenProjectAndDocument.pomFile(); + writeUpdatedPom(mavenProjectAndDocument.document(), pomFile); + } + } + + /// Reads and processes the POM files for a list of Maven projects + /// and returns a mapping of Maven artifacts to their corresponding project and document representations. + /// + /// @param projects the list of Maven projects whose POMs need to be read + /// @return an immutable map where the key is the Maven artifact representing a project and the value is its associated Maven project and document representation + /// @throws MojoExecutionException if an error occurs while executing the Mojo + /// @throws MojoFailureException if the Mojo fails due to an expected problem + private Map readAllPoms(List projects) + throws MojoExecutionException, MojoFailureException { + Map documents = new HashMap<>(); + for (MavenProject project : projects) { + MavenArtifact mavenArtifact = new MavenArtifact(project.getGroupId(), project.getArtifactId()); + Path pomFile = project.getFile().toPath(); + MavenProjectAndDocument projectAndDocument = new MavenProjectAndDocument( + mavenArtifact, + pomFile, + POMUtils.readPom(pomFile) + ); + documents.put(mavenArtifact, projectAndDocument); + } + return Map.copyOf(documents); + } + + /// Updates the project version based on the specified semantic version bump and document. + /// If no version update is required, an empty [Optional] is returned. + /// + /// @param semanticVersionBump the type of semantic version change to apply (e.g., major, minor, patch) + /// @param document the XML document representing the project's POM file + /// @return an [Optional] containing a [VersionChange] object representing the original and updated version, or an empty [Optional] if no update was performed + /// @throws MojoExecutionException if an error occurs while updating the version + private Optional updateProjectVersion( + SemanticVersionBump semanticVersionBump, + Document document + ) throws MojoExecutionException { + Log log = getLog(); + Node versionNode = POMUtils.getProjectVersionNode(document, modus); + String originalVersion = versionNode.getTextContent(); + + log.info("Updating version with a %s semantic version".formatted(semanticVersionBump)); + if (SemanticVersionBump.NONE.equals(semanticVersionBump)) { + log.info("No version update required"); + return Optional.empty(); + } + POMUtils.updateVersion(versionNode, semanticVersionBump); + return Optional.of(new VersionChange(originalVersion, versionNode.getTextContent())); + } + + /// Creates a mapping between dependency artifacts and project artifacts based on the provided + /// Maven project documents and reactor artifacts. + /// The method identifies dependencies in the projects that match artifacts in the reactor and associates + /// them with their corresponding project artifacts. + /// + /// @param documents a collection of [MavenProjectAndDocument] representing the Maven projects and their associated model documents. + /// @param reactorArtifacts a set of [MavenArtifact] objects representing the artifacts present in the reactor. + /// @return a map where keys are dependency artifacts (from the reactor) and values are lists of project artifacts they are associated with. + private Map> createDependencyToProjectArtifactMapping( + Collection documents, + Set reactorArtifacts + ) { + return documents.stream() + .flatMap( + projectAndDocument -> POMUtils.getMavenArtifacts(projectAndDocument.document()) + .keySet() + .stream() + .filter(reactorArtifacts::contains) + .map(artifact -> Map.entry(artifact, projectAndDocument.artifact())) + ) + .collect(Utils.groupingByImmutable( + Map.Entry::getKey, + Collectors.mapping(Map.Entry::getValue, Utils.asImmutableList()) + )); + } + + /// Merges updatable dependencies from a list of Maven project documents and a set of reactor artifacts. + /// Filters and groups Maven artifacts and associated information based on the given reactor artifacts. + /// + /// @param documents a collection of [MavenProjectAndDocument] objects representing the Maven projects and their associated documents + /// @param reactorArtifacts a set of [MavenArtifact] objects representing the reactor build artifacts to be processed + /// @return a map where keys are [MavenArtifact] objects and values are immutable lists of dependency nodes associated with those artifacts + private Map> mergeUpdatableDependencies( + Collection documents, + Set reactorArtifacts + ) { + return documents.stream() + .map(MavenProjectAndDocument::document) + .map(POMUtils::getMavenArtifacts) + .map(Map::entrySet) + .flatMap(Set::stream) + .filter(entry -> reactorArtifacts.contains(entry.getKey())) + .collect(Utils.groupingByImmutable( + Map.Entry::getKey, + Collectors.mapping( + Map.Entry::getValue, + Utils.asImmutableList(Collectors.reducing( + new ArrayList<>(), + Utils.consumerToOperator(List::addAll) + )) + ) + )); + } + + /// Writes the updated Maven POM file. This method either writes the updated POM to the specified path or performs a dry-run + /// where the updated POM content is logged for review without making any file changes. + /// + /// If dry-run mode is enabled, the new POM content is created as a string and logged. Otherwise, the updated POM is + /// written to the provided file path, with an option to back up the original file before overwriting. + /// + /// @param document the XML Document representation of the Maven POM file to be updated + /// @param pom the path to the POM file where the updated content will be written + /// @throws MojoExecutionException if an I/O error occurs while writing the updated POM or processing the dry-run + /// @throws MojoFailureException if the operation fails due to an XML parsing or writing error + private void writeUpdatedPom(Document document, Path pom) throws MojoExecutionException, MojoFailureException { + if (dryRun) { + dryRunWriteFile(writer -> POMUtils.writePom(document, writer), pom, "Dry-run: new pom at %s:%n%s"); + } else { + POMUtils.writePom(document, pom, backupFiles); + } + } + + /// 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 + /// @param projectArtifact the Maven artifact representing the project for which the Markdown file is being updated + /// @param pom the path to the pom.xml file, used as a reference to locate the Markdown file + /// @param newVersion the version information to be updated in the Markdown file + /// @throws MojoExecutionException if an error occurs during the update process + /// @throws MojoFailureException if any Mojo-related failure occurs during execution + private void updateMarkdownFile( + MarkdownMapping markdownMapping, + MavenArtifact projectArtifact, + Path pom, + String newVersion + ) throws MojoExecutionException, MojoFailureException { + Log log = getLog(); + Path changelogFile = pom.getParent().resolve(CHANGELOG_MD); + org.commonmark.node.Node changelog = readMarkdown(log, changelogFile); + log.debug("Original changelog"); + MarkdownUtils.printMarkdown(log, changelog, 0); + MarkdownUtils.mergeVersionMarkdownsInChangelog( + changelog, + newVersion, + markdownMapping.markdownMap() + .getOrDefault( + projectArtifact, + List.of(MarkdownUtils.createSimpleVersionBumpDocument(projectArtifact)) + ) + .stream() + .collect(Utils.groupingByImmutable( + entry -> entry.bumps().get(projectArtifact), + Collectors.mapping(VersionMarkdown::content, Utils.asImmutableList()) + )) + ); + log.debug("Updated changelog"); + MarkdownUtils.printMarkdown(log, changelog, 0); + + writeUpdatedChangelog(changelog, changelogFile); + } + + /// Writes the updated changelog to the specified changelog file. + /// If the dry-run mode is enabled, the updated changelog is logged instead of being written to the file. + /// Otherwise, the changelog is saved to the specified path, with an optional backup of the existing file. + /// + /// @param changelog the commonmark node representing the updated changelog content to be written + /// @param changelogFile the path to the file where the updated changelog should be saved + /// @throws MojoExecutionException if an I/O error occurs during writing the changelog + /// @throws MojoFailureException if any Mojo-related failure occurs during execution + private void writeUpdatedChangelog(org.commonmark.node.Node changelog, Path changelogFile) + throws MojoExecutionException, MojoFailureException { + writeMarkdownFile(changelog, changelogFile); + } + + /// Determines the semantic version bump for a given Maven artifact based on the provided map of version bumps + /// and the current version bump configuration. + /// + /// @param artifact the Maven artifact for which the semantic version bump is to be determined + /// @param bumps a map containing Maven artifacts as keys and their corresponding semantic version bumping as values + /// @return the semantic version bump that should be applied to the given artifact + private SemanticVersionBump getSemanticVersionBump( + MavenArtifact artifact, + Map bumps + ) { + return switch (versionBump) { + case FILE_BASED -> bumps.getOrDefault(artifact, SemanticVersionBump.NONE); + case MAJOR -> SemanticVersionBump.MAJOR; + case MINOR -> SemanticVersionBump.MINOR; + case PATCH -> SemanticVersionBump.PATCH; + }; + } + + /// Represents a combination of a Maven project artifact, its associated POM file path, + /// and the XML document of the POM file's contents. + /// + /// This class is designed as a record to provide an immutable data container for + /// conveniently managing and accessing Maven project-related information. + /// + /// @param artifact the Maven artifact associated with the project; must not be null + /// @param pomFile the path to the POM file for the project; must not be null + /// @param document the XML document representing the POM file's contents; must not be null + private record MavenProjectAndDocument(MavenArtifact artifact, Path pomFile, Document document) { + + /// Constructs a new instance of the MavenProjectAndDocument record. + /// + /// @param artifact the Maven artifact associated with the project; must not be null + /// @param pomFile the path to the POM file for the project; must not be null + /// @param document the XML document representing the POM file's contents; must not be null + /// @throws NullPointerException if any of the provided parameters are null + private MavenProjectAndDocument { + Objects.requireNonNull(artifact, "`artifact` must not be null"); + Objects.requireNonNull(pomFile, "`pomFile` must not be null"); + Objects.requireNonNull(document, "`document` must not be null"); + } + } + + /// Represents a data structure that holds a set of updated Maven artifacts + /// and a queue of Maven artifacts to be updated. + /// + /// This class is immutable and ensures non-null constraints on the provided parameters. + /// + /// @param updatedArtifacts a set of [MavenArtifact] instances that represent the artifacts already updated + /// @param toBeUpdated a queue of [MavenArtifact] instances representing the artifacts yet to be updated + private record UpdatedAndToUpdateArtifacts(Set updatedArtifacts, Queue toBeUpdated) { + + /// Constructs an instance of UpdatedAndToUpdateArtifacts, ensuring the provided parameters are not null. + /// + /// @param updatedArtifacts a set of [MavenArtifact] objects that have been updated; must not be null + /// @param toBeUpdated a queue of [MavenArtifact] objects that are yet to be updated; must not be null + private UpdatedAndToUpdateArtifacts { + Objects.requireNonNull(updatedArtifacts, "`updatedArtifacts` must not be null"); + Objects.requireNonNull(toBeUpdated, "`toBeUpdated` must not be null"); + } + } +} diff --git a/src/main/java/io/github/bsels/semantic/version/models/MarkdownMapping.java b/src/main/java/io/github/bsels/semantic/version/models/MarkdownMapping.java new file mode 100644 index 0000000..215afce --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/models/MarkdownMapping.java @@ -0,0 +1,31 @@ +package io.github.bsels.semantic.version.models; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/// Represents a mapping of Maven artifacts to their corresponding semantic version bumps and version-specific markdown entries. +/// It encapsulates two main mappings: +/// - A mapping of MavenArtifact instances to their associated SemanticVersionBump values. +/// - A mapping of MavenArtifact instances to a list of VersionMarkdown entries. +/// +/// This record ensures immutability and validates input data during construction. +/// +/// @param versionBumpMap a map associating MavenArtifact instances with their corresponding SemanticVersionBump values; must not be null +/// @param markdownMap a map associating MavenArtifact instances with a list of VersionMarkdown entries; must not be null +public record MarkdownMapping( + Map versionBumpMap, + Map> markdownMap +) { + + /// Constructs a new instance of the MarkdownMapping record. + /// Validates and creates immutable copies of the provided maps to ensure integrity and immutability. + /// + /// @param versionBumpMap a map associating MavenArtifact instances with their corresponding SemanticVersionBump values; must not be null + /// @param markdownMap a map associating MavenArtifact instances with a list of VersionMarkdown entries; must not be null + /// @throws NullPointerException if `versionBumpMap` or `markdownMap` is null + public MarkdownMapping { + versionBumpMap = Map.copyOf(Objects.requireNonNull(versionBumpMap, "`versionBumpMap` must not be null")); + markdownMap = Map.copyOf(Objects.requireNonNull(markdownMap, "`markdownMap` must not be null")); + } +} diff --git a/src/main/java/io/github/bsels/semantic/version/models/MavenArtifact.java b/src/main/java/io/github/bsels/semantic/version/models/MavenArtifact.java new file mode 100644 index 0000000..372b0a1 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/models/MavenArtifact.java @@ -0,0 +1,80 @@ +package io.github.bsels.semantic.version.models; + +import com.fasterxml.jackson.annotation.JsonCreator; + +import java.util.Comparator; +import java.util.Objects; + +/// Represents a Maven artifact consisting of a group ID and an artifact ID. +/// +/// Maven artifacts are uniquely identified by their group ID and artifact ID within a specific repository or context. +/// The group ID typically represents the organization or project that produces the artifact, +/// while the artifact ID identifies the specific library, tool, or application. +/// +/// Instances of this record validate that both the group ID and artifact ID are non-null during construction. +/// +/// @param groupId the group ID of the Maven artifact; must not be null +/// @param artifactId the artifact ID of the Maven artifact; must not be null +public record MavenArtifact(String groupId, String artifactId) + implements Comparable { + /// A comparator used to define the natural ordering of `MavenArtifact` instances. + /// This comparator first compares Maven artifacts by their `groupId` and, if they are equal, + /// it proceeds to compare them by their `artifactId`. + /// + /// The comparison ensures a consistent and logical sorting order for `MavenArtifact` objects + /// based on their group and artifact identifiers. + public static final Comparator COMPARATOR = Comparator.comparing(MavenArtifact::groupId) + .thenComparing(MavenArtifact::artifactId); + + /// Constructs a new instance of `MavenArtifact` with the specified group ID and artifact ID. + /// Validates that neither the group ID nor the artifact ID are null. + /// + /// @param groupId the group ID of the artifact must not be null + /// @param artifactId the artifact ID must not be null + /// @throws NullPointerException if `groupId` or `artifactId` is null + public MavenArtifact { + Objects.requireNonNull(groupId, "`groupId` must not be null"); + Objects.requireNonNull(artifactId, "`artifactId` must not be null"); + } + + /// Creates a new `MavenArtifact` instance by parsing a string in the format `:`. + /// + /// The input string is expected to contain exactly one colon separating the group ID and the artifact ID. + /// + /// @param colonSeparatedString the string representing the Maven artifact in the format `:` + /// @return a new `MavenArtifact` instance constructed using the parsed group ID and artifact ID + /// @throws IllegalArgumentException if the input string does not conform to the expected format + /// @throws NullPointerException if the `colonSeparatedString` parameter is null + @JsonCreator + public static MavenArtifact of(String colonSeparatedString) { + String[] parts = Objects.requireNonNull(colonSeparatedString, "`colonSeparatedString` must not be null") + .split(":"); + if (parts.length != 2) { + throw new IllegalArgumentException( + "Invalid Maven artifact format: %s, expected :".formatted( + colonSeparatedString + ) + ); + } + return new MavenArtifact(parts[0], parts[1]); + } + + /// Returns a string representation of the Maven artifact in the format "groupId:artifactId". + /// + /// @return a string representation of the Maven artifact, combining the group ID and artifact ID separated by a colon + @Override + public String toString() { + return "%s:%s".formatted(groupId, artifactId); + } + + /// Compares this MavenArtifact instance with the specified MavenArtifact for order. + /// The comparison is based on the string representations of the MavenArtifact instances, + /// which are formatted as "groupId:artifactId". + /// + /// @param other the MavenArtifact to be compared with this instance + /// @return a negative integer, zero, or a positive integer as the string representation of this MavenArtifact is less than, equal to, or greater than the string representation of the specified MavenArtifact + @Override + public int compareTo(MavenArtifact other) { + return COMPARATOR.compare(this, other); + } +} diff --git a/src/main/java/io/github/bsels/semantic/version/models/SemanticVersion.java b/src/main/java/io/github/bsels/semantic/version/models/SemanticVersion.java new file mode 100644 index 0000000..c47acec --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/models/SemanticVersion.java @@ -0,0 +1,154 @@ +package io.github.bsels.semantic.version.models; + +import java.util.Objects; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/// Represents a semantic version consisting of a major, minor, and patch version, along with an optional suffix. +/// Semantic versioning is a versioning scheme that follows the format `major.minor.patch-suffix`. +/// The major, minor, and patch components are mandatory and must be non-negative integers. +/// The optional suffix, if present, begins with a dash and consists of alphanumeric characters, dots, or dashes. +/// +/// @param major the major version number must be a non-negative integer +/// @param minor the minor version number must be a non-negative integer +/// @param patch the patch version number must be a non-negative integer +/// @param suffix an optional suffix for the version must start with a dash and may contain alphanumeric characters, dashes, or dots +public record SemanticVersion(int major, int minor, int patch, Optional suffix) { + /// A compiled regular expression pattern representing the syntax for a semantic version string. + /// The semantic version format includes: + /// - A mandatory major version component (non-negative integer). + /// - A mandatory minor version component (non-negative integer). + /// - A mandatory patch version component (non-negative integer). + /// - An optional suffix component, starting with a dash and consisting of alphanumeric characters, dots, or dashes. + /// + /// The full version string must adhere to the following format: + /// `major.minor.patch-suffix`, where the suffix is optional. + /// + /// This pattern ensures that the input string strictly follows the semantic versioning rules. + public static final Pattern REGEX = Pattern.compile("^(\\d+)\\.(\\d+)\\.(\\d+)(-[a-zA-Z0-9-.]+)?$"); + /// A regular expression pattern designed to validate the format of suffixes in semantic versions. + /// + /// The suffix must: + /// - Start with a dash (`-`) + /// - Contain only alphanumeric characters, dashes (`-`), or dots (`.`) + /// + /// This pattern is primarily used to ensure proper validation of optional suffix components in semantic versioning. + /// + /// Example of valid suffixes: + /// - `-alpha` + /// - `-1.0.0` + /// - `-beta.2` + /// + /// Example of invalid suffixes: + /// - `_alpha` (does not start with a dash) + /// - `alpha` (does not start with a dash) + public static final String SUFFIX_REGEX_PATTERN = "^-[a-zA-Z0-9-.]+$"; + + /// Constructs a new instance of SemanticVersion with the specified major, minor, patch, + /// and optional suffix components. + /// Validates that the major, minor, and patch numbers are non-negative and that the optional suffix, + /// if present, adheres to the required format. + /// + /// @param major the major version number must be a non-negative integer + /// @param minor the minor version number must be a non-negative integer + /// @param patch the patch version number must be a non-negative integer + /// @param suffix an optional suffix for the version must start with a dash and may contain alphanumeric characters, dashes, or dots + /// @throws IllegalArgumentException if any of the version numbers are negative, or the suffix does not match the required format + public SemanticVersion { + if (major < 0 || minor < 0 || patch < 0) { + throw new IllegalArgumentException("Version parts must be non-negative"); + } + suffix = Objects.requireNonNullElseGet(suffix, Optional::empty) + .filter(Predicate.not(String::isEmpty)); + suffix.ifPresent(SemanticVersion::validateSuffix); + } + + /// Parses a semantic version string and creates a `SemanticVersion` instance. + /// The version string must comply with the semantic versioning format: + /// `major.minor.patch` or `major.minor.patch-suffix`. + /// Major, minor, and patch must be non-negative integers. + /// The optional suffix must start with a dash and may contain alphanumeric characters, dashes, or dots. + /// + /// @param version the semantic version string to parse + /// @return a new `SemanticVersion` instance representing the parsed version + /// @throws IllegalArgumentException if the version string is blank, does not match the semantic versioning format, or contains invalid components + /// @throws NullPointerException if the version string is null + public static SemanticVersion of(String version) throws IllegalArgumentException, NullPointerException { + version = Objects.requireNonNull(version, "`version` must not be null").strip(); + Matcher matches = REGEX.matcher(version); + if (!matches.matches()) { + throw new IllegalArgumentException("Invalid semantic version format: %s, should match the regex %s".formatted(version, REGEX.pattern())); + } + return new SemanticVersion( + Integer.parseInt(matches.group(1)), + Integer.parseInt(matches.group(2)), + Integer.parseInt(matches.group(3)), + Optional.ofNullable(matches.group(4)) + .filter(Predicate.not(String::isEmpty)) + ); + } + + /// Validates that the provided suffix matches the expected format. + /// The suffix must be alphanumeric, may contain dashes or dots, and cannot begin with a dash. + /// + /// @param suffix the suffix string to validate; must be in the correct format as defined by the [#SUFFIX_REGEX_PATTERN] + /// @throws IllegalArgumentException if the suffix does not match the required format + private static void validateSuffix(String suffix) throws IllegalArgumentException { + if (!suffix.matches(SUFFIX_REGEX_PATTERN)) { + throw new IllegalArgumentException("Suffix must be alphanumeric, dash, or dot, and should not start with a dash"); + } + } + + /// Returns a string representation of the semantic version in the format "major.minor.patch-suffix", + /// where the suffix is optional. + /// + /// @return the semantic version as a string in the format "major.minor.patch" or "major.minor.patch-suffix" if a suffix is present. + @Override + public String toString() { + return "%d.%d.%d%s".formatted(major, minor, patch, suffix.orElse("")); + } + + /// Increments the semantic version based on the specified type of version bump. + /// The type of bump determines which component of the version (major, minor, or patch) is incremented. + /// If the bump type is NONE, the version remains unchanged. + /// + /// @param bump the type of version increment to apply (MAJOR, MINOR, PATCH, or NONE) + /// @return a new `SemanticVersion` instance with the incremented version, or the same instance if no change is required + /// @throws NullPointerException the `bump` parameter is null + public SemanticVersion bump(SemanticVersionBump bump) throws NullPointerException { + return switch (Objects.requireNonNull(bump, "`bump` must not be null")) { + case MAJOR -> new SemanticVersion(major + 1, 0, 0, suffix); + case MINOR -> new SemanticVersion(major, minor + 1, 0, suffix); + case PATCH -> new SemanticVersion(major, minor, patch + 1, suffix); + case NONE -> this; + }; + } + + /// Removes the optional suffix from the semantic version, if present, and returns a new `SemanticVersion` + /// instance that consists only of the major, minor, and patch components. + /// + /// @return a new `SemanticVersion` instance without the suffix. + public SemanticVersion stripSuffix() { + if (suffix.isPresent()) { + return new SemanticVersion(major, minor, patch, Optional.empty()); + } + return this; + } + + /// Returns a new `SemanticVersion` instance with the specified suffix. + /// The suffix may contain additional information about the version, such as build metadata or pre-release identifiers. + /// + /// @param suffix the suffix to associate with the version must not be null + /// @return a new `SemanticVersion` instance with the specified suffix, or the same instance if the suffix is already present. + /// @throws NullPointerException if the `suffix` parameter is null + public SemanticVersion withSuffix(String suffix) throws NullPointerException { + Objects.requireNonNull(suffix, "`suffix` must not be null"); + validateSuffix(suffix); + if (this.suffix.filter(suffix::equals).isPresent()) { + return this; + } + return new SemanticVersion(major, minor, patch, Optional.of(suffix)); + } +} diff --git a/src/main/java/io/github/bsels/semantic/version/models/SemanticVersionBump.java b/src/main/java/io/github/bsels/semantic/version/models/SemanticVersionBump.java new file mode 100644 index 0000000..b8c57e9 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/models/SemanticVersionBump.java @@ -0,0 +1,76 @@ +package io.github.bsels.semantic.version.models; + +import com.fasterxml.jackson.annotation.JsonCreator; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; +import java.util.Objects; + +/// Represents the type of version increment in the context of semantic versioning. +/// Semantic versioning consists of major, minor, and patch version components, along with an optional suffix. +/// This enum is used to denote which part of a version should be incremented or whether no increment is to occur. +/// +/// The enum values are defined as: +/// - NONE: Indicates that no version increment is to occur. +/// - PATCH: Indicates a patch version increment, which may introduce backward-compatible bug fixes. +/// - MINOR: Indicates a minor version increment, which may add functionality in a backward-compatible manner. +/// - MAJOR: Indicates a major version increment, which may introduce breaking changes. +public enum SemanticVersionBump { + /// Indicates that no version increment is to occur. + /// This value is used in the context of semantic versioning when the version should remain unchanged. + NONE, + /// Indicates a patch version increment in the context of semantic versioning. + /// A patch increment is typically used to introduce backward-compatible bug fixes. + PATCH, + /// Indicates a minor version increment in the context of semantic versioning. + /// A minor increment is typically used to add new functionality in a backward-compatible manner. + MINOR, + /// Indicates a major version increment in the context of semantic versioning. + /// A major increment typically introduces breaking changes, making backward compatibility + /// with earlier versions unlikely. + MAJOR; + + /// Converts a string representation of a semantic version bump to its corresponding enum value. + /// + /// The input string is case-insensitive and will be converted to uppercase to match the enum names. + /// + /// @param value the string representation of the semantic version bump, such as "MAJOR", "MINOR", "PATCH", or "NONE" + /// @return the corresponding `SemanticVersionBump` enum value + /// @throws IllegalArgumentException if the input value does not match any of the valid enum names + @JsonCreator + public static SemanticVersionBump fromString(String value) throws IllegalArgumentException { + if (value == null) { + return NONE; + } + return valueOf(value.toUpperCase()); + } + + /// Determines the maximum semantic version bump from an array of [SemanticVersionBump] values. + /// The bumps are compared based on their natural order, and the highest value is returned. + /// If the array is empty, [#NONE] is returned. + /// + /// @param bumps the array of [SemanticVersionBump] values to evaluate + /// @return the maximum semantic version bump in the array, or [#NONE] if the array is empty + /// @throws NullPointerException if the `bumps` parameter is null + /// @see #max(Collection) + public static SemanticVersionBump max(SemanticVersionBump... bumps) throws NullPointerException { + Objects.requireNonNull(bumps, "`bumps` must not be null"); + return max(Arrays.asList(bumps)); + } + + /// Determines the maximum semantic version bump from a collection of [SemanticVersionBump] values. + /// The bumps are compared based on their natural order, and the highest value is returned. + /// If the collection is empty or only has null pointers, [#NONE] is returned. + /// + /// @param bumps the collection of [SemanticVersionBump] values to evaluate + /// @return the maximum semantic version bump in the collection, or [#NONE] if the collection is empty + /// @throws NullPointerException if the `bumps` parameter is null + public static SemanticVersionBump max(Collection bumps) throws NullPointerException { + Objects.requireNonNull(bumps, "`bumps` must not be null"); + return bumps.stream() + .filter(Objects::nonNull) + .max(Comparator.naturalOrder()) + .orElse(NONE); + } +} diff --git a/src/main/java/io/github/bsels/semantic/version/models/VersionChange.java b/src/main/java/io/github/bsels/semantic/version/models/VersionChange.java new file mode 100644 index 0000000..b16d606 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/models/VersionChange.java @@ -0,0 +1,26 @@ +package io.github.bsels.semantic.version.models; + +import java.util.Objects; + +/// Represents a change or transition between two versions. +/// This class is a record that holds information about the old version +/// and the new version during a version update or transition process. +/// +/// Objects of this record are immutable and encapsulate both the old +/// and the new version as non-null values. +/// +/// @param oldVersion the previous version, representing the initial state before the update. Must not be null. +/// @param newVersion the updated version, representing the final state after the change. Must not be null. +public record VersionChange(String oldVersion, String newVersion) { + + /// Constructs an instance of VersionChange to represent a transition from one version to another. + /// Both the old version and the new version must be non-null. + /// + /// @param oldVersion the previous version. Must not be null. + /// @param newVersion the new version. Must not be null. + /// @throws NullPointerException if either oldVersion or newVersion is null. + public VersionChange { + Objects.requireNonNull(oldVersion, "`oldVersion` must not be null"); + Objects.requireNonNull(newVersion, "`newVersion` must not be null"); + } +} diff --git a/src/main/java/io/github/bsels/semantic/version/models/VersionMarkdown.java b/src/main/java/io/github/bsels/semantic/version/models/VersionMarkdown.java new file mode 100644 index 0000000..64c4e6c --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/models/VersionMarkdown.java @@ -0,0 +1,44 @@ +package io.github.bsels.semantic.version.models; + +import org.commonmark.node.Node; + +import java.nio.file.Path; +import java.util.Map; +import java.util.Objects; + +/// Represents parsed Markdown content for a version alongside mappings of Maven artifacts +/// and their corresponding semantic version bumps. +/// +/// This record is used to encapsulate structured content and versioning information +/// for Maven artifacts in the context of semantic versioning. +/// The Markdown content is represented as a hierarchical structure of nodes, and the version bumps are specified +/// as a mapping between Maven artifacts and their respective semantic version increments. +/// +/// The record guarantees that the content and the map of bumps are always non-null +/// and enforces that the map of bumps cannot be empty. +/// +/// @param path the path to the Markdown file containing the version information +/// @param content the root node of the Markdown content representing the parsed structure must not be null +/// @param bumps the mapping of Maven artifacts to their respective semantic version bumps; must not be null or empty +public record VersionMarkdown( + Path path, + Node content, + Map bumps +) { + + /// Constructs an instance of the VersionMarkdown record. + /// Validates the provided content and bumps map to ensure they are non-null and meet required constraints. + /// + /// @param path the path to the Markdown file containing the version information; can be null for in-memory files + /// @param content the root node representing the content; must not be null + /// @param bumps a map of Maven artifacts to their corresponding semantic version bumps; must not be null or empty + /// @throws NullPointerException if content or bumps is null + /// @throws IllegalArgumentException if bumps is empty + public VersionMarkdown { + Objects.requireNonNull(content, "`content` must not be null"); + bumps = Map.copyOf(Objects.requireNonNull(bumps, "`bumps` must not be null")); + if (bumps.isEmpty()) { + throw new IllegalArgumentException("`bumps` must not be empty"); + } + } +} diff --git a/src/main/java/io/github/bsels/semantic/version/package-info.java b/src/main/java/io/github/bsels/semantic/version/package-info.java new file mode 100644 index 0000000..d362848 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/package-info.java @@ -0,0 +1,2 @@ +/// This package contains all the Mojos of the semantic version Maven plugin +package io.github.bsels.semantic.version; \ No newline at end of file diff --git a/src/main/java/io/github/bsels/semantic/version/parameters/Modus.java b/src/main/java/io/github/bsels/semantic/version/parameters/Modus.java new file mode 100644 index 0000000..0ce1911 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/parameters/Modus.java @@ -0,0 +1,15 @@ +package io.github.bsels.semantic.version.parameters; + +/// Enum representing different modes of handling project versions. +public enum Modus { + /// Represents the mode for handling single or multi-project versions using each project's version property'. + /// The project version will be defined on each project individually. + PROJECT_VERSION, + /// Represents the mode for handling single or multi-project versions using the revision property. + /// The revision property is defined on the root project. + REVISION_PROPERTY, + /// Represents the mode for handling single or multi-project versions using each project's version property', + /// but only for leaf projects in a multi-module setup; non-leaf projects will be skipped. + /// The project version will be defined on each project individually. + PROJECT_VERSION_ONLY_LEAFS +} diff --git a/src/main/java/io/github/bsels/semantic/version/parameters/VersionBump.java b/src/main/java/io/github/bsels/semantic/version/parameters/VersionBump.java new file mode 100644 index 0000000..a9e7765 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/parameters/VersionBump.java @@ -0,0 +1,18 @@ +package io.github.bsels.semantic.version.parameters; + +/// Enum representing the different types of version increments or handling approaches. +/// This can be based on semantic versioning principles or other version determination mechanisms. +public enum VersionBump { + /// `FILE_BASED` represents a mode where version determination or handling is dependent on file-based mechanisms. + /// This could involve reading specific files or configurations to infer or decide version-related changes. + FILE_BASED, + /// Represents a version increment of the major component in semantic versioning. + /// A major increment is typically used for changes that are not backward-compatible. + MAJOR, + /// Represents a version increment of the minor component in semantic versioning. + /// A minor increment is typically used for adding new backward-compatible features. + MINOR, + /// Represents a version increment of the patch component in semantic versioning. + /// A patch increment is typically used for backwards-compatible bug fixes. + PATCH +} diff --git a/src/main/java/io/github/bsels/semantic/version/parameters/package-info.java b/src/main/java/io/github/bsels/semantic/version/parameters/package-info.java new file mode 100644 index 0000000..ee71e13 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/parameters/package-info.java @@ -0,0 +1,2 @@ +/// This package contains the necessary classes for the plugin parameters +package io.github.bsels.semantic.version.parameters; \ No newline at end of file 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 new file mode 100644 index 0000000..6d6f3cf --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java @@ -0,0 +1,388 @@ +package io.github.bsels.semantic.version.utils; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.type.MapType; +import com.fasterxml.jackson.databind.type.TypeFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; +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.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; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.logging.Log; +import org.commonmark.node.Document; +import org.commonmark.node.Heading; +import org.commonmark.node.Node; +import org.commonmark.node.Paragraph; +import org.commonmark.node.Text; +import org.commonmark.parser.IncludeSourceSpans; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.Renderer; +import org.commonmark.renderer.markdown.MarkdownRenderer; + +import java.io.IOException; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.LocalDate; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.BinaryOperator; +import java.util.stream.Stream; + +/// Utility class for handling operations related to Markdown processing. +/// +/// This class provides static methods for parsing, rendering, merging, +/// and writing structured Markdown content and YAML front matter. +/// It is not intended to be instantiated. +public final class MarkdownUtils { + + /// A constant map type representing a mapping between [MavenArtifact] objects and [SemanticVersionBump] values. + /// + /// This map type is constructed using Jackson's [TypeFactory] for type-safe operations + /// on a HashMap that maps [MavenArtifact] as keys to [SemanticVersionBump] as values. + /// It is intended to provide a standardized type structure for operations where Maven artifacts are associated with + /// their corresponding semantic version bump types. + /// + /// This constant is defined as a static field within the utility class, + /// ensuring it cannot be modified during runtime and is globally accessible. + private static final MapType MAVEN_ARTIFACT_BUMP_MAP_TYPE = TypeFactory.defaultInstance() + .constructMapType(HashMap.class, MavenArtifact.class, SemanticVersionBump.class); + + /// A static and final [ObjectMapper] instance configured as a [YAMLMapper]. + /// This variable is intended for parsing and generating YAML content. + /// It provides a convenient singleton for YAML operations within the context of the MarkdownUtils utility class. + private static final ObjectMapper YAML_MAPPER = new YAMLMapper() + .configure(YAMLGenerator.Feature.WRITE_DOC_START_MARKER, false); + + /// 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 + /// metadata in Markdown documents. + /// - Include source spans to represent the start and end positions of both block and inline elements in the + /// original text, enabled by setting the [IncludeSourceSpans] mode to [IncludeSourceSpans#BLOCKS_AND_INLINES]. + /// + /// The parser is immutable and thread-safe, making it suitable for concurrent use across multiple threads. + private static final Parser PARSER = Parser.builder() + .extensions(List.of(YamlFrontMatterExtension.create())) + .includeSourceSpans(IncludeSourceSpans.BLOCKS_AND_INLINES) + .build(); + + /// A static, pre-configured instance of the [Renderer] used to process and render Markdown content within + /// the [MarkdownUtils] utility class. + /// + /// The [#MARKDOWN_RENDERER] is initialized using the [MarkdownRenderer#builder()] method to create a builder for + /// fine-grained control over the rendering configuration and finalizes the building process via `build()`. + /// + /// This instance serves as the primary renderer for various Markdown processing tasks in the utility methods + /// provided by the [MarkdownUtils] class. + /// + /// The renderer handles the task of generating structured output for Markdown nodes. + /// + /// This is a singleton-like static constant to ensure consistent rendering behavior throughout the invocation + /// of Markdown processing methods. + private static final Renderer MARKDOWN_RENDERER = MarkdownRenderer.builder() + .nodeRendererFactory(MarkdownYamFrontMatterBlockRendererFactory.getInstance()) + .build(); + + /// Represents the title "Changelog" used as the top-level heading in Markdown changelogs processed by + /// the utility methods of the `MarkdownUtils` class. + /// + /// This constant is used as a reference to ensure that the changelog Markdown structure + /// adheres to the expected format, where the main heading for the document is a single H1 titled + private static final String CHANGELOG = "Changelog"; + + /// Utility class for handling operations related to Markdown processing. + /// This class contains static methods and is not intended to be instantiated. + private MarkdownUtils() { + // No instance needed + } + + /// Parses a Markdown file to extract its contents and associated YAML front matter, + /// which specifies mappings of Maven artifacts to their corresponding semantic version bumps. + /// The parsed Markdown content is stored as a hierarchical structure of nodes, + /// and the versioning information is extracted from the YAML front matter block. + /// + /// @param log the logger used to log informational and debug messages during the parsing process; must not be null + /// @param markdownFile the path to the Markdown file to be read and parsed; must not be null + /// @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 { + Objects.requireNonNull(log, "`log` must not be null"); + Objects.requireNonNull(markdownFile, "`markdownFile` must not be null"); + Node document = readMarkdown(log, markdownFile); + + if (!(document.getFirstChild() instanceof YamlFrontMatterBlock yamlFrontMatterBlock)) { + throw new MojoExecutionException("YAML front matter block not found in '%s' file".formatted(markdownFile)); + } + String yaml = yamlFrontMatterBlock.getYaml(); + yamlFrontMatterBlock.unlink(); + + 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); + } catch (JsonProcessingException e) { + throw new MojoExecutionException( + "YAML front matter does not contain valid maven artifacts and semantic version bump", e + ); + } + log.debug("Maven artifacts and semantic version bumps:\n%s".formatted(bumps)); + printMarkdown(log, document, 0); + return new VersionMarkdown(markdownFile, document, bumps); + } + + /// Reads and parses a Markdown file, returning its content as a structured Node object. + /// The method logs the number of lines read from the file for informational purposes. + /// + /// @param log the logger used to log informational messages during the parsing process; must not be null + /// @param markdownFile the path to the Markdown file to be read and parsed; must not be null + /// @return a Node object representing the parsed structure of the Markdown content + /// @throws NullPointerException if log or markdownFile is null + /// @throws MojoExecutionException if an error occurs while reading the file or parsing its content + public static Node readMarkdown(Log log, Path markdownFile) throws MojoExecutionException { + Objects.requireNonNull(log, "`log` must not be null"); + Objects.requireNonNull(markdownFile, "`markdownFile` must not be null"); + if (!Files.exists(markdownFile)) { + log.info("No changelog file found at '%s', creating an empty CHANGELOG internally".formatted(markdownFile)); + Document document = new Document(); + Heading heading = new Heading(); + heading.setLevel(1); + heading.appendChild(new Text(CHANGELOG)); + document.appendChild(heading); + return document; + } + try (Stream lineStream = Files.lines(markdownFile, StandardCharsets.UTF_8)) { + List lines = lineStream.toList(); + log.info("Read %d lines from %s".formatted(lines.size(), markdownFile)); + return parseMarkdown(String.join("\n", lines)); + } catch (IOException e) { + throw new MojoExecutionException("Unable to read '%s' file".formatted(markdownFile), e); + } + } + + /// Parses the given Markdown text and returns a Node representing the structured content of the Markdown document. + /// + /// @param markdown the Markdown text to parse, provided as a String + /// @return a Node object representing the parsed structure of the Markdown document + /// @throws NullPointerException if the `markdown` parameter is null + public static Node parseMarkdown(String markdown) throws NullPointerException { + Objects.requireNonNull(markdown, "`markdown` must not be null"); + return PARSER.parse(markdown); + } + + /// Merges version-specific Markdown content into a changelog Node structure. + /// + /// This method updates the provided changelog Node by inserting a new heading for the specified version + /// at the appropriate position. + /// The content associated with the version is then added under this heading, + /// grouped by semantic version bump types (e.g., MAJOR, MINOR, PATCH). + /// The changelog must begin with a single H1 heading titled "Changelog". + /// + /// @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 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" + /// @throws IllegalArgumentException if any of the nodes in the map entries node lists is not a document + public static void mergeVersionMarkdownsInChangelog( + Node changelog, + String version, + Map> headerToNodes + ) throws NullPointerException, IllegalArgumentException { + Objects.requireNonNull(changelog, "`changelog` must not be null"); + Objects.requireNonNull(version, "`version` must not be null"); + Objects.requireNonNull(headerToNodes, "`headerToNodes` must not be null"); + + if (!(changelog instanceof Document document)) { + throw new IllegalArgumentException("`changelog` must be a Document"); + } + if (!(document.getFirstChild() instanceof Heading heading && + heading.getLevel() == 1 && + heading.getFirstChild() instanceof Text text && CHANGELOG.equals(text.getLiteral()))) { + throw new IllegalArgumentException("Changelog must start with a single H1 heading with the text 'Changelog'"); + } + Node nextChild = heading.getNext(); + + Heading newVersionHeading = new Heading(); + newVersionHeading.setLevel(2); + newVersionHeading.appendChild(new Text("%s - %s".formatted(version, LocalDate.now()))); + heading.insertAfter(newVersionHeading); + + Comparator>> comparator = Map.Entry.comparingByKey(); + Node current = headerToNodes.entrySet() + .stream() + .sorted(comparator.reversed()) + .reduce(newVersionHeading, MarkdownUtils::copyVersionMarkdownToChangeset, mergeNodes()); + + assert current.getNext() == nextChild : "Incorrectly inserted nodes into changelog"; + } + + /// Writes a Markdown document to a specified file. Optionally creates a backup of the existing file + /// before overwriting it. + /// + /// @param markdownFile the path to the Markdown file where the document will be written; must not be null + /// @param document the node representing the structured Markdown content to be written; must not be null + /// @param backupOld a boolean indicating whether to create a backup of the existing file before writing + /// @throws NullPointerException if `markdownFile` or `document` is null + /// @throws MojoExecutionException if an error occurs while creating the backup or writing to the file + public static void writeMarkdownFile(Path markdownFile, Node document, boolean backupOld) + throws MojoExecutionException, NullPointerException { + Objects.requireNonNull(markdownFile, "`markdownFile` must not be null"); + Objects.requireNonNull(document, "`document` must not be null"); + if (backupOld) { + Utils.backupFile(markdownFile); + } + try (Writer writer = Files.newBufferedWriter(markdownFile, StandardCharsets.UTF_8, + StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) { + writeMarkdown(writer, document); + } catch (IOException e) { + throw new MojoExecutionException("Unable to write %s".formatted(markdownFile), e); + } + } + + /// Writes the rendered Markdown content of the given document node to the specified output writer. + /// This operation uses a pre-configured Markdown renderer to transform the structured document node into + /// Markdown format before writing it to the output. + /// + /// @param output the writer to which the rendered Markdown content will be written; must not be null + /// @param document the node representing the structured Markdown content to be rendered; must not be null + /// @throws NullPointerException if `output` or `document` is null + public static void writeMarkdown(Writer output, Node document) throws NullPointerException { + Objects.requireNonNull(output, "`output` must not be null"); + Objects.requireNonNull(document, "`document` must not be null"); + MARKDOWN_RENDERER.render(document, output); + } + + /// Recursively logs the structure of a Markdown document starting from the given node. + /// Each node in the document is logged at a specific indentation level to visually + /// represent the hierarchy of the Markdown content. + /// + /// @param log the logger used for logging the node details; must not be null + /// @param node the current node in the Markdown structure to be logged; can be null + /// @param level the indentation level, used to format logged output to represent hierarchy + public static void printMarkdown(Log log, Node node, int level) { + if (!log.isDebugEnabled()) { + return; + } + if (node == null) { + return; + } + log.debug(node.toString().indent(level).stripTrailing()); + printMarkdown(log, node.getFirstChild(), level + 2); + printMarkdown(log, node.getNext(), level); + } + + /// Creates a simple version bump document that indicates a project version has been bumped as a result + /// of dependency changes. + /// + /// @param mavenArtifact the Maven artifact associated with the version bump; must not be null + /// @return a [VersionMarkdown] object containing the generated document and a mapping of the Maven artifact to a PATCH semantic version bump + /// @throws NullPointerException if the `mavenArtifact` parameter is null + public static VersionMarkdown createSimpleVersionBumpDocument(MavenArtifact mavenArtifact) + throws NullPointerException { + Objects.requireNonNull(mavenArtifact, "`mavenArtifact` must not be null"); + Document document = new Document(); + Paragraph paragraph = new Paragraph(); + paragraph.appendChild(new Text("Project version bumped as result of dependency bumps")); + document.appendChild(paragraph); + return new VersionMarkdown(null, document, Map.of(mavenArtifact, SemanticVersionBump.NONE)); + } + + /// Creates a YAML front matter block containing version bump information for Maven artifacts. + /// + /// @param log the logger used for logging the YAML representation; must not be null + /// @param bumps a map where each key is a Maven artifact and the value is its corresponding semantic version bump. Must not be null. + /// @return a [YamlFrontMatterBlock] containing the YAML representation of the version bump information. + /// @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 + ) throws NullPointerException, MojoExecutionException { + Objects.requireNonNull(log, "`log` must not be null"); + Objects.requireNonNull(bumps, "`bumps` must not be null"); + String yaml; + try { + yaml = YAML_MAPPER.writeValueAsString(bumps); + 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); + } + return new YamlFrontMatterBlock(yaml); + } + + /// 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 + private static BinaryOperator mergeNodes() { + return (a, b) -> { + a.insertAfter(b); + return b; + }; + } + + /// Copies version-specific Markdown content to a changelog changeset by creating a new heading + /// for the semantic version bump type and appending associated nodes under that heading. + /// + /// @param current the current Node in the Markdown structure to which the bump type heading and its associated nodes will be inserted; must not be null + /// @param entry a Map.Entry containing a SemanticVersionBump key representing the bump type (e.g., MAJOR, MINOR, PATCH, NONE) and a List of Nodes associated with that bump type; must not be null + /// @return the last Node inserted into the Markdown structure, representing the merged result of the operation + /// @throws IllegalArgumentException if any of the nodes in the entry node list is not a document + private static Node copyVersionMarkdownToChangeset(Node current, Map.Entry> entry) + throws IllegalArgumentException { + Heading bumpTypeHeading = new Heading(); + bumpTypeHeading.setLevel(3); + bumpTypeHeading.appendChild(new Text(switch (entry.getKey()) { + case MAJOR -> "Major"; + case MINOR -> "Minor"; + case PATCH -> "Patch"; + case NONE -> "Other"; + })); + current.insertAfter(bumpTypeHeading); + return entry.getValue() + .stream() + .map(MarkdownUtils::cloneNode) + .reduce(bumpTypeHeading, MarkdownUtils::insertNodeChilds, mergeNodes()); + } + + /// Inserts all child nodes of the given node into the current node sequentially. + /// Each child node of the provided node is inserted after the current node, one at a time, + /// and the method updates the current node reference to the last inserted child node. + /// + /// @param currentLambda the node after which the child nodes will be inserted; must not be null + /// @param node the node whose children are to be inserted; must not be null + /// @return the last child node that was inserted after the current node + private static Node insertNodeChilds(Node currentLambda, Node node) throws IllegalArgumentException { + BinaryOperator binaryOperator = mergeNodes(); + Node nextChild = node.getFirstChild(); + while (nextChild != null) { + Node nextSibling = nextChild.getNext(); + currentLambda = binaryOperator.apply(currentLambda, nextChild); + nextChild = nextSibling; + } + return currentLambda; + } + + /// Creates a deep copy of the given node by parsing its rendered Markdown representation. + /// + /// @param node the [Node] object to be cloned. Must not be null. + /// @return a new [Node] object that represents a deep copy of the input node. + /// @throws IllegalArgumentException if the `node` parameter is not a document + private static Node cloneNode(Node node) { + if (!(node instanceof Document document)) { + throw new IllegalArgumentException("Node must be a Document"); + } + return PARSER.parse(MARKDOWN_RENDERER.render(document)); + } +} diff --git a/src/main/java/io/github/bsels/semantic/version/utils/POMUtils.java b/src/main/java/io/github/bsels/semantic/version/utils/POMUtils.java new file mode 100644 index 0000000..66b9be1 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/utils/POMUtils.java @@ -0,0 +1,487 @@ +package io.github.bsels.semantic.version.utils; + +import io.github.bsels.semantic.version.models.MavenArtifact; +import io.github.bsels.semantic.version.models.SemanticVersion; +import io.github.bsels.semantic.version.models.SemanticVersionBump; +import io.github.bsels.semantic.version.models.VersionChange; +import io.github.bsels.semantic.version.parameters.Modus; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.w3c.dom.Document; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Result; +import javax.xml.transform.Source; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerConfigurationException; +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import java.io.IOException; +import java.io.InputStream; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +/// Utility class for handling various operations related to Project Object Model (POM) files, such as reading, writing, +/// version updates, and backups. +/// Provides methods for XML parsing and manipulation with the goal of managing POM files effectively +/// in a Maven project context. +/// +/// This class is not intended to be instantiated, and all methods are designed to be used in a static context. +public final class POMUtils { + /// Represents the artifact identifier of a Maven project, commonly referred to as "artifactId". + /// This field holds a string value corresponding to the unique name that distinguishes a particular artifact + /// within a Maven group. + /// It is critical in identifying the artifact during resolution and publishing phases when working + /// with Maven repositories. + public static final String ARTIFACT_ID = "artifactId"; + /// Represents the constant identifier for a build configuration or process in a system. + /// This variable typically signifies a specific context or property related to a build operation. + /// It is a constant value set to "build". + public static final String BUILD = "build"; + /// A constant string that represents the key or identifier used for referencing + /// dependencies in a specific context, such as configuration files or dependency management systems. + /// This variable is intended to be immutable and globally accessible. + public static final String DEPENDENCIES = "dependencies"; + /// A constant string representing the term "dependency". + /// This variable may be used to denote a dependency within a system, configuration, or software component. + public static final String DEPENDENCY = "dependency"; + /// Represents the identifier for a Maven project group within the Project Object Model (POM). + /// The group ID serves as a unique namespace for the project, typically following a reverse-domain + /// naming convention. + /// It is a fundamental element used to identify Maven artifacts in a repository. + public static final String GROUP_ID = "groupId"; + /// A constant string representing the key or identifier for a plugin. + /// This variable is typically used to denote the context or type of plugin in a system where plugins are managed + /// or used. + public static final String PLUGIN = "plugin"; + /// A constant representing the key or identifier for plugins. + /// Typically used to denote a group, category, or configuration related to plugins in the application. + public static final String PLUGINS = "plugins"; + /// A constant representing the string literal "project". + /// This variable is often used as an identifier, key, or label to refer to project-related contexts, + /// configurations, or data. + public static final String PROJECT = "project"; + /// Represents the name of the version field or property within a POM (Project Object Model) file. + /// This constant serves as a key used for identifying and interacting with the version-related elements + /// or properties within Maven-based projects. + public static final String VERSION = "version"; + + /// A constant list of directory names representing the path segments typically used to locate build-related plugins + /// within a project structure. + /// + /// This list contains predefined values that are commonly used in typical build systems + /// or configurations to point to the directory where plugins are stored, + /// ensuring consistency and reuse across the application. + private static final List BUILD_PLUGINS_PATH = List.of(PROJECT, BUILD, PLUGINS, PLUGIN); + /// A constant list that represents the structured path to the plugins section under plugin management in + /// a build configuration. + /// The elements in the list represent successive levels of hierarchy needed to navigate to the "plugins" + /// node within the build configuration structure. + private static final List BUILD_PLUGIN_MANAGEMENT_PLUGINS_PATH = List.of(PROJECT, BUILD, "pluginManagement", PLUGINS, PLUGIN); + /// A constant that represents the path segments used to locate dependency management dependencies within + /// a project's configuration structure. + /// It consists of a fixed list containing elements that define the hierarchical path: "project", + /// "dependencyManagement", and "dependencies". + /// + /// This variable is used to navigate or reference the dependency management section + /// of a project's configuration file or data structure. + private static final List DEPENDENCY_MANAGEMENT_DEPENDENCIES_PATH = List.of(PROJECT, "dependencyManagement", DEPENDENCIES, DEPENDENCY); + /// A constant list containing the paths for project dependencies. + /// This list is intended to hold predefined directory paths or identifiers + /// used within the application to reference dependency-related resources. + private static final List DEPENDENCIES_PATH = List.of(PROJECT, DEPENDENCIES, DEPENDENCY); + /// A constant list representing the parent path components. + /// This list includes predefined elements such as the project identifier and the "parent" string. + /// Used to define or identify the hierarchical structure of a parent directory or entity. + private static final List PARENT_PATH = List.of(PROJECT, "parent"); + /// A constant list of strings representing the XML traversal path to locate the "revision" property + /// within a Maven POM file. + /// This path defines the sequential hierarchy of nodes that need to be traversed in the XML document, + /// starting with the "project" node, followed by the "properties" node, and finally the "revision" node. + /// + /// This is primarily used in scenarios where the "revision" property value needs to be accessed or + /// modified programmatically within the POM file. + /// It serves as a predefined navigation path, ensuring a consistent and + /// error-free location of the "revision" property across operations. + private static final List REVISION_PROPERTY_PATH = List.of(PROJECT, "properties", "revision"); + /// Defines the path to locate the project version element within a POM (Project Object Model) file. + /// The path is expressed as a list of strings, where each string represents a hierarchical element + /// from the root of the XML document to the target "version" node. + /// + /// This path is primarily used by methods that traverse or manipulate the XML document structure + /// to locate and update the version information in a Maven project. + private static final List VERSION_PROPERTY_PATH = List.of(PROJECT, VERSION); + + /// Represents a set of required fields for a Maven artifact. + /// + /// This constant defines the essential attributes that must be present + /// in a Maven artifact's metadata: "groupId", "artifactId", and "version". + /// These fields are critical for uniquely identifying and resolving a Maven artifact + /// in a repository or during the build process. + private static final Set REQUIRED_MAVEN_ARTIFACT_FIELDS = Set.of(GROUP_ID, ARTIFACT_ID, VERSION); + + /// A static and lazily initialized instance of [DocumentBuilder] used for XML parsing operations. + /// This field serves as a shared resource across methods in the class, preventing the need to + /// repeatedly create new [DocumentBuilder] instances. + /// The instance is configured with specific settings, such as namespace awareness, + /// ignoring of whitespace, and inclusion of comments in parsed documents. + /// + /// This variable is intended to facilitate efficient and consistent XML document parsing in the context + /// of handling Project Object Model (POM) files. + /// + /// The initialization and configuration of this [DocumentBuilder] instance are managed by + /// the [#getOrCreateDocumentBuilder] method. + /// Access to this field should be done only through that method to ensure proper initialization and error handling. + private static DocumentBuilder documentBuilder = null; + /// A static instance of the [Transformer] class used for XML transformation tasks within the utility. + /// The `transformer` is lazily initialized when required to perform operations such as + /// writing and formatting XML documents. + /// It is configured to work with XML-related tasks in the context of processing POM (Project Object Model) files. + /// + /// This variable is managed internally to ensure a single instance is reused, avoiding + /// repetitive creation and enhancing performance during XML transformations. + /// If creation of the [Transformer] instance fails, + /// it throws an exception managed by the utility's methods leveraging this variable. + /// + /// The `transformer` is shared across various operations in this utility class, + /// ensuring consistency in XML transformation behavior. + /// + /// The initialization and configuration of this [Transformer] instance are managed by + /// the [#getOrCreateTransformer] method. + /// Access to this field should be done only through that method to ensure proper initialization and error handling. + private static Transformer transformer = null; + + /// Utility class for handling various operations related to Project Object Model (POM) files, + /// such as reading, writing, version updates, and backups. + /// Provides methods for XML parsing and manipulation with the goal of managing POM files effectively + /// in a Maven project context. + /// + /// This class is not intended to be instantiated, and all methods are designed to be used in a static context. + private POMUtils() { + // No instance needed + } + + /// Retrieves the project version node from the provided XML document, based on the specified mode. + /// The mode determines the traversal path used to locate the version node within the document. + /// + /// @param document the XML document from which to retrieve the project version node; must not be null + /// @param modus the mode that specifies the traversal logic for locating the version node; must not be null + /// @return the XML node representing the project version + /// @throws NullPointerException if the document or modus argument is null + /// @throws MojoExecutionException if the project version node cannot be located in the document + public static Node getProjectVersionNode(Document document, Modus modus) + throws NullPointerException, MojoExecutionException { + Objects.requireNonNull(document, "`document` must not be null"); + Objects.requireNonNull(modus, "`modus` must not be null"); + List versionPropertyPath = switch (modus) { + case REVISION_PROPERTY -> REVISION_PROPERTY_PATH; + case PROJECT_VERSION, PROJECT_VERSION_ONLY_LEAFS -> VERSION_PROPERTY_PATH; + }; + try { + return walk(document, versionPropertyPath, 0); + } catch (IllegalStateException e) { + throw new MojoExecutionException("Unable to find project version on the path: %s".formatted( + String.join("->", versionPropertyPath) + ), e); + } + } + + /// Parses the specified POM (Project Object Model) file and returns it as an XML Document object. + /// This method attempts to read and parse the provided file, constructing a Document representation + /// of the XML content. + /// + /// @param pomFile the path to the POM file to be read; must not be null + /// @return the parsed XML Document representing the contents of the POM file + /// @throws NullPointerException if the provided pomFile is null + /// @throws MojoExecutionException if an error occurs while reading or parsing the POM file + /// @throws MojoFailureException if the DocumentBuilder cannot be initialized + public static Document readPom(Path pomFile) + throws NullPointerException, MojoExecutionException, MojoFailureException { + Objects.requireNonNull(pomFile, "`pomFile` must not be null"); + DocumentBuilder documentBuilder = getOrCreateDocumentBuilder(); + try (InputStream inputStream = Files.newInputStream(pomFile)) { + return documentBuilder.parse(inputStream); + } catch (IOException | SAXException e) { + throw new MojoExecutionException("Unable to read '%s' file".formatted(pomFile), e); + } + } + + /// Writes the given XML Document to the specified POM file. + /// Optionally, a backup of the old POM file can be created before writing the new document. + /// The method ensures the POM file is written using UTF-8 encoding and handles any necessary transformations + /// or I/O operations. + /// + /// @param document the XML Document to be written; must not be null + /// @param pomFile the path to the POM file where the document will be written; must not be null + /// @param backupOld a boolean indicating whether to create a backup of the old POM file before writing + /// @throws NullPointerException if the document or pomFile argument is null + /// @throws MojoExecutionException if an error occurs during the writing or backup operation + /// @throws MojoFailureException if the required XML Transformer cannot be created + public static void writePom(Document document, Path pomFile, boolean backupOld) + throws NullPointerException, MojoExecutionException, MojoFailureException { + Objects.requireNonNull(document, "`document` must not be null"); + Objects.requireNonNull(pomFile, "`pomFile` must not be null"); + if (backupOld) { + Utils.backupFile(pomFile); + } + try (Writer writer = Files.newBufferedWriter(pomFile, StandardCharsets.UTF_8, + StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) { + writePom(document, writer); + } catch (IOException e) { + throw new MojoExecutionException("Unable to write to %s".formatted(pomFile), e); + } + } + + /// Writes the given XML Document to the specified writer. + /// The method transforms the provided XML document into a stream format and writes it using the given writer. + /// If an error occurs during the transformation or writing process, it throws an appropriate exception. + /// + /// @param document the XML Document to be written; must not be null + /// @param writer the writer to which the XML Document will be written; must not be null + /// @throws NullPointerException if the document or writer argument is null + /// @throws MojoExecutionException if an error occurs during the transformation process + /// @throws MojoFailureException if the XML Transformer cannot be created or fails to execute + public static void writePom(Document document, Writer writer) + throws NullPointerException, MojoExecutionException, MojoFailureException { + Objects.requireNonNull(document, "`document` must not be null"); + Objects.requireNonNull(writer, "`writer` must not be null"); + try { + writer.write("\n"); + Source source = new DOMSource(document); + Result result = new StreamResult(writer); + getOrCreateTransformer() + .transform(source, result); + } catch (IOException | TransformerException e) { + throw new MojoExecutionException("Unable to write XML document", e); + } + } + + /// Updates the version value of the given XML node based on the specified semantic version bump type. + /// The method retrieves the current semantic version from the node, increments the version according + /// to the provided bump type, and updates the node with the new version value. + /// + /// @param nodeElement the XML node whose version value is to be updated; must not be null + /// @param bump the type of semantic version increment to be applied; must not be null + /// @throws NullPointerException if either nodeElement or bump is null + /// @throws IllegalArgumentException if the content of nodeElement cannot be parsed into a valid semantic version + public static void updateVersion(Node nodeElement, SemanticVersionBump bump) + throws NullPointerException, IllegalArgumentException { + Objects.requireNonNull(nodeElement, "`nodeElement` must not be null"); + Objects.requireNonNull(bump, "`bump` must not be null"); + + SemanticVersion version = SemanticVersion.of(nodeElement.getTextContent()); + SemanticVersion updatedVersion = version.bump(bump); + nodeElement.setTextContent(updatedVersion.toString()); + } + + /// Extracts Maven artifacts and their corresponding nodes from the given XML document. + /// This method processes dependency and plugin-related paths in the document to identify Maven artifacts + /// and their associated XML nodes. + /// + /// @param document the XML document representing a Maven POM file + /// @return a map where the keys are MavenArtifact objects representing the artifacts and the values are lists of XML nodes associated with those artifacts + /// @throws NullPointerException if the `document` argument is null + public static Map> getMavenArtifacts(Document document) throws NullPointerException { + Objects.requireNonNull(document, "`document` must not be null"); + Stream dependencyNodes = Stream.concat( + walkStream(document, DEPENDENCIES_PATH, 0), + walkStream(document, DEPENDENCY_MANAGEMENT_DEPENDENCIES_PATH, 0) + ); + Stream pluginNodes = Stream.concat( + walkStream(document, BUILD_PLUGINS_PATH, 0), + walkStream(document, BUILD_PLUGIN_MANAGEMENT_PLUGINS_PATH, 0) + ); + Stream allNodes = Stream.concat(dependencyNodes, pluginNodes); + return Stream.concat(allNodes, walkStream(document, PARENT_PATH, 0)) + .map(POMUtils::handleArtifactNode) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Utils.groupingByImmutable( + Map.Entry::getKey, + Collectors.mapping(Map.Entry::getValue, Utils.asImmutableList()) + )); + } + + /// Updates the text content of the specified node with a new version + /// if the current text content matches the old version specified in the version change. + /// + /// @param versionChange the version change object containing the old and new version values + /// @param node the node whose text content is to be updated + /// @throws NullPointerException if `versionChange` or `node` is null + public static void updateVersionNodeIfOldVersionMatches(VersionChange versionChange, Node node) + throws NullPointerException { + Objects.requireNonNull(versionChange, "`versionChange` must not be null"); + Objects.requireNonNull(node, "`node` must not be null"); + String version = node.getTextContent(); + if (versionChange.oldVersion().equals(version)) { + node.setTextContent(versionChange.newVersion()); + } + } + + /// Processes a given XML [Node] to extract Maven artifact details such as groupId, artifactId, and version, + /// validates the semantic version, + /// and returns an optional mapping of MavenArtifact to its corresponding version [Node]. + /// + /// @param element the XML Node to be processed, typically representing an artifact element in a Maven POM-like structure + /// @return an [Optional] containing a [Map.Entry] where the key is a [MavenArtifact] object and the value is the version [Node], or an empty [Optional] if the required fields are missing or the version is invalid + private static Optional> handleArtifactNode(Node element) { + NodeList childNodes = element.getChildNodes(); + Map tagContent = IntStream.range(0, childNodes.getLength()) + .mapToObj(childNodes::item) + .filter(node -> REQUIRED_MAVEN_ARTIFACT_FIELDS.contains(node.getNodeName())) + .collect(Collectors.toMap(Node::getNodeName, Function.identity())); + + if (!tagContent.keySet().containsAll(REQUIRED_MAVEN_ARTIFACT_FIELDS)) { + return Optional.empty(); + } + Node version = tagContent.get(VERSION); + try { + SemanticVersion.of(version.getTextContent()); + } catch (IllegalArgumentException ignored) { + return Optional.empty(); + } + String groupId = tagContent.get(GROUP_ID).getTextContent(); + String artifactId = tagContent.get(ARTIFACT_ID).getTextContent(); + return Optional.of(Map.entry(new MavenArtifact(groupId, artifactId), version)); + } + + /// Traverses the XML document tree starting from the given parent node, following the specified path, + /// and returns the child node at the end of the path. + /// If no child node matching the path is found, throws an exception. + /// + /// @param parent the starting node of the tree traversal + /// @param path the list of node names defining the path to traverse + /// @param currentElementIndex the current index in the path being processed + /// @return the `Node` at the end of the specified path + /// @throws IllegalStateException if a node in the path cannot be found or traversed + private static Node walk(Node parent, List path, int currentElementIndex) throws IllegalStateException { + if (currentElementIndex == path.size()) { + return parent; + } + StreamWalk result = getStreamWalk(parent, path, currentElementIndex); + return result.nodeStream() + .findFirst() + .map(child -> walk(child, path, currentElementIndex + 1)) + .orElseThrow(() -> new IllegalStateException( + "Unable to find element '%s' in '%s'".formatted(result.currentElementName(), parent.getNodeName()) + )); + } + + /// Recursively traverses a hierarchical structure of nodes based on a specified path and returns a stream of + /// matching nodes. + /// + /// @param parent the starting parent node to traverse from + /// @param path a list of strings representing the path to navigate through the hierarchy + /// @param currentElementIndex the current index in the path being processed + /// @return a stream of nodes that match the specified path + private static Stream walkStream(Node parent, List path, int currentElementIndex) { + if (currentElementIndex == path.size()) { + return Stream.of(parent); + } + StreamWalk result = getStreamWalk(parent, path, currentElementIndex); + return result.nodeStream() + .flatMap(child -> walkStream(child, path, currentElementIndex + 1)); + } + + /// Retrieves a [StreamWalk] object by filtering child nodes of the parent node based on the current element name + /// from the specified path. + /// + /// @param parent the parent Node from which child nodes are retrieved + /// @param path a [List] of Strings representing the path to traverse + /// @param currentElementIndex the index of the current element in the path + /// @return a [StreamWalk] object containing the current element name and a stream of matching child nodes + private static StreamWalk getStreamWalk(Node parent, List path, int currentElementIndex) { + String currentElementName = path.get(currentElementIndex); + NodeList childNodes = parent.getChildNodes(); + Stream nodeStream = IntStream.range(0, childNodes.getLength()) + .mapToObj(childNodes::item) + .filter(child -> currentElementName.equals(child.getNodeName())); + return new StreamWalk(currentElementName, nodeStream); + } + + /// Retrieves an existing instance of `DocumentBuilder` or creates a new one if it does not already exist. + /// Configures the `DocumentBuilderFactory` to enable namespace awareness, + /// to disallow ignoring of element content whitespace, and to include comments in the parsed documents. + /// If an error occurs during the configuration or creation of the `DocumentBuilder`, + /// a `MojoFailureException` is thrown. + /// + /// @return the `DocumentBuilder` instance, either existing or newly created + /// @throws MojoFailureException if the creation of a new `DocumentBuilder` fails due to a configuration issue + private static DocumentBuilder getOrCreateDocumentBuilder() throws MojoFailureException { + if (documentBuilder != null) { + return documentBuilder; + } + DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); + documentBuilderFactory.setNamespaceAware(true); + documentBuilderFactory.setIgnoringElementContentWhitespace(false); + documentBuilderFactory.setIgnoringComments(false); + try { + documentBuilder = documentBuilderFactory.newDocumentBuilder(); + return documentBuilder; + } catch (ParserConfigurationException e) { + throw new MojoFailureException("Unable to construct XML document builder", e); + } + } + + /// Retrieves the existing instance of `Transformer` or creates a new one if it does not already exist. + /// This method uses a `TransformerFactory` to create a new `Transformer` instance when needed. + /// If an error occurs during the creation of the `Transformer`, a `MojoFailureException` is thrown. + /// + /// @return the `Transformer` instance, either existing or newly created + /// @throws MojoFailureException if the creation of a new `Transformer` fails due to a configuration issue + private static Transformer getOrCreateTransformer() throws MojoFailureException { + if (transformer != null) { + return transformer; + } + try { + transformer = TransformerFactory.newInstance() + .newTransformer(); + transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes"); + return transformer; + } catch (TransformerConfigurationException e) { + throw new MojoFailureException("Unable to construct XML transformer", e); + } + } + + /// A record representing a traversal context within a stream of nodes. + /// Instances of this class encapsulate the name of the current element and its associated stream of nodes. + /// + /// This record is immutable and designed to ensure non-null safety for its parameters. + /// It is primarily intended for use in operations involving structured node traversals. + /// + /// @param currentElementName the name of the current element; must not be null + /// @param nodeStream the stream of nodes associated with the element; must not be null + private record StreamWalk(String currentElementName, Stream nodeStream) { + + /// Constructs a new instance of StreamWalk. + /// Ensures that the provided parameters are non-null. + /// + /// @param currentElementName the name of the current element; must not be null + /// @param nodeStream the stream of nodes associated with the element; must not be null + /// @throws NullPointerException if either currentElementName or nodeStream is null + private StreamWalk { + Objects.requireNonNull(currentElementName, "`currentElementName` must not be null"); + Objects.requireNonNull(nodeStream, "`nodeStream` must not be null"); + } + } +} 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 new file mode 100644 index 0000000..4027c84 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/utils/ProcessUtils.java @@ -0,0 +1,65 @@ +package io.github.bsels.semantic.version.utils; + +import org.apache.maven.plugin.MojoExecutionException; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Predicate; + +/// Utility class providing methods for handling processes and editors. +/// This class is not intended to be instantiated. +public final class ProcessUtils { + + /// Utility class providing methods for handling processes and editors. + /// This class is not intended to be instantiated. + private ProcessUtils() { + // No instance needed + } + + /// Executes the default system editor to open a given file. + /// The editor is determined to use system properties or a fallback mechanism. + /// This method blocks until the editor process completes. + /// + /// @param file the path to the file that should be opened in the editor + /// @return true if the editor process exits with a status code of 0, false otherwise + /// @throws NullPointerException if the `file` argument is null + /// @throws MojoExecutionException if an I/O or interruption error occurs while executing the editor + public static boolean executeEditor(Path file) throws NullPointerException, MojoExecutionException { + Objects.requireNonNull(file, "`file` must not be null"); + try { + Process process = new ProcessBuilder(getDefaultEditor(), file.toString()) + .inheritIO() + .start(); + return process.waitFor() == 0; + } catch (IOException | InterruptedException e) { + throw new MojoExecutionException("Unable to execute editor", e); + } + } + + /// Retrieves the default editor based on system properties or a fallback mechanism. + /// The method checks the "VISUAL" system property first, followed by the "EDITOR" system property, + /// and uses a fallback editor determined by the operating system if neither property is set. + /// + /// @return The name of the default editor as a String, or the operating-system-specific fallback editor ("notepad" for Windows or "vi" for other systems) if no editor is explicitly specified. + public static String getDefaultEditor() { + return Optional.ofNullable(System.getProperty("VISUAL")) + .map(String::strip) + .filter(Predicate.not(String::isBlank)) + .or(() -> Optional.ofNullable(System.getProperty("EDITOR"))) + .map(String::strip) + .filter(Predicate.not(String::isBlank)) + .orElseGet(ProcessUtils::fallbackOsEditor); + } + + /// 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. + /// + /// @return The default fallback text editor name based on the operating system. + private static String fallbackOsEditor() { + String os = System.getProperty("os.name").toLowerCase(); + return os.contains("win") ? "notepad" : "vi"; + } +} diff --git a/src/main/java/io/github/bsels/semantic/version/utils/TerminalHelper.java b/src/main/java/io/github/bsels/semantic/version/utils/TerminalHelper.java new file mode 100644 index 0000000..80986eb --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/utils/TerminalHelper.java @@ -0,0 +1,216 @@ +package io.github.bsels.semantic.version.utils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Scanner; +import java.util.regex.Pattern; + +/// A utility class providing methods for interacting with terminal input, +/// including functionality for reading multi-line inputs, +/// handling single-choice and multi-choice selections, and validating input data. +/// +/// The class is designed to simplify and standardize terminal operations for applications that require user input via +/// a console. +/// It supports flexible input parsing using defined patterns and ensures that the input is processed consistently +/// across use cases. +/// +/// All methods in this class are static, and instantiation of the class is not allowed. +public final class TerminalHelper { + /// A compiled regular expression pattern used to identify separators in multi-choice input strings. + /// The separators can include commas (,), semicolons (;), or spaces. + /// + /// This pattern is used to split user input into distinct components, + /// usually when multiple choices are entered in a single line separated by the defined delimiters. + /// It ensures consistent parsing of input strings for multi-choice selection functionality in the application. + private static final Pattern MULTI_CHOICE_SEPARATOR = Pattern.compile("[,; ]"); + + /// A private constructor to prevent instantiation of the `TerminalHelper` class. + /// + /// The `TerminalHelper` class is designed to provide static utility methods for terminal input management, + /// including functionality for reading multi-line input, single-choice selection, multi-choice selection, + /// and related operations. + /// Since all functionality is provided through static methods, there is no need to create instances of this class. + private TerminalHelper() { + // No instance needed + } + + /// Reads multi-line input from the user via the console. + /// The method prompts the user with a message, + /// then reads lines of text input until it encounters two consecutive blank lines, + /// treating this as the end of input. + /// The input is returned as a single string containing all the lines, separated by new line characters. + /// If no meaningful input is provided (the first line is blank), the method returns an empty [Optional]. + /// + /// @param prompt the message to display to the user before starting input; must not be null + /// @return an [Optional] containing the concatenated multi-line input if provided, or an empty [Optional] if the input was blank + /// @throws NullPointerException if the `prompt` parameter is null + public static Optional readMultiLineInput(String prompt) throws NullPointerException { + Objects.requireNonNull(prompt, "`prompt` must not be null"); + System.out.println(prompt); + Scanner scanner = new Scanner(System.in); + StringBuilder builder = new StringBuilder(); + String line = getLineOrEmpty(scanner); + if (line.isBlank()) { + return Optional.empty(); + } + builder.append(line).append("\n"); + boolean lastLineEmpty = line.isBlank(); + line = getLineOrEmpty(scanner); + while (!line.isBlank() || !lastLineEmpty) { + builder.append(line).append("\n"); + lastLineEmpty = line.isBlank(); + line = getLineOrEmpty(scanner); + } + return Optional.of(builder.toString().stripTrailing()); + } + + /// Displays a list of choices to the user and allows selection of a single option + /// by entering its corresponding number or name (for enum values). + /// + /// @param the type of items in the choice list + /// @param choiceHeader a header message displayed above the list of choices; must not be null + /// @param promptObject a string describing the individual choice objects, used in the prompt message; must not be null + /// @param choices a list of selectable options; must not be null or empty, and each item in the list must not be null + /// @return the selected item from the choices, based on the user's input + /// @throws NullPointerException if `choiceHeader`, `promptObject`, `choices`, or any element in the `choices` list is null + /// @throws IllegalArgumentException if the `choices` list is empty + public static T singleChoice(String choiceHeader, String promptObject, List choices) + throws NullPointerException, IllegalArgumentException { + validateChoiceMethodHeader(choiceHeader, promptObject, choices); + boolean isEnum = Enum.class.isAssignableFrom(choices.get(0).getClass()); + Scanner scanner = new Scanner(System.in); + Optional item = Optional.empty(); + while (item.isEmpty()) { + System.out.println(choiceHeader); + for (int i = 0; i < choices.size(); i++) { + System.out.printf(" %d: %s%n", i + 1, choices.get(i)); + } + if (isEnum) { + System.out.printf("Enter %s name or number: ", promptObject); + } else { + System.out.printf("Enter %s number: ", promptObject); + } + String line = scanner.nextLine(); + item = parseIndexOrEnum(line, choices); + } + return item.get(); + } + + /// Displays a list of choices to the user and allows them to select multiple options by entering their corresponding numbers. + /// The user's choices are captured based on the provided prompt and returned as a list. + /// + /// @param the type of items in the choice list + /// @param choiceHeader a header message displayed above the list of choices; must not be null + /// @param promptObject a string describing the individual choice objects, used in the prompt message; must not be null + /// @param choices a list of selectable options; must not be null or empty + /// @return a list of selected items from the choices, based on the user's input + /// @throws NullPointerException if `choiceHeader`, `promptObject`, `choices`, or any element in the `choices` list is null + /// @throws IllegalArgumentException if the `choices` list is empty + public static List multiChoice(String choiceHeader, String promptObject, List choices) + throws NullPointerException, IllegalArgumentException { + validateChoiceMethodHeader(choiceHeader, promptObject, choices); + boolean isEnum = Enum.class.isAssignableFrom(choices.get(0).getClass()); + Scanner scanner = new Scanner(System.in); + List selectedChoices = null; + while (selectedChoices == null) { + System.out.println(choiceHeader); + for (int i = 0; i < choices.size(); i++) { + System.out.printf(" %d: %s%n", i + 1, choices.get(i)); + } + if (isEnum) { + System.out.printf("Enter %s names or number separated by spaces, commas or semicolons: ", promptObject); + } else { + System.out.printf("Enter %s numbers separated by spaces, commas or semicolons: ", promptObject); + } + if (!scanner.hasNextLine()) { + selectedChoices = List.of(); + break; + } + String line = scanner.nextLine(); + if (!line.isBlank()) { + selectedChoices = getSelection(promptObject, choices, line); + } + } + return selectedChoices; + } + + /// Retrieves the next line of input from the provided [Scanner] or returns an empty string if no line is available. + /// + /// @param scanner the [Scanner] from which to read the input; must not be null + /// @return the next line of input as a String if available, or an empty String if no line exists + private static String getLineOrEmpty(Scanner scanner) { + return scanner.hasNextLine() ? scanner.nextLine() : ""; + } + + /// Parses a provided input string to determine a selection from a list of available choices. + /// The input string may contain multiple tokens separated by a predefined separator, + /// with each token being either an index or an enum name. + /// Valid selections are added to the result list, + /// while invalid tokens cause the method to print an error message and return null. + /// + /// @param the type of items in the list of choices + /// @param promptObject a string describing the type of objects being selected, used for error messages; must not be null + /// @param choices a list of selectable options; must not be null or empty + /// @param line a string containing the user's input, with tokens separated by the defined separator; must not be null + /// @return a list of selected items from the choices, or null if any token is invalid + private static List getSelection(String promptObject, List choices, String line) { + List currentSelection = new ArrayList<>(choices.size()); + for (String choice : MULTI_CHOICE_SEPARATOR.split(line)) { + if (choice.isBlank()) { + continue; + } + Optional item = parseIndexOrEnum(choice, choices); + if (item.isPresent()) { + currentSelection.add(item.get()); + } else { + System.out.printf("Invalid %s: %s%n", promptObject, choice); + return null; + } + } + return currentSelection; + } + + /// Validates the parameters for choice-related methods to ensure all required inputs are provided and not null. + /// + /// @param the type of items in the choice list + /// @param choiceHeader a header message displayed above the list of choices; must not be null + /// @param promptObject a string describing the individual choice objects, used in the prompt message; must not be null + /// @param choices a list of selectable options; each item in the list must not be null + /// @throws NullPointerException if `choiceHeader`, `promptObject`, `choices`, or any element in the `choices` list is null + /// @throws IllegalArgumentException if `choices` is empty + private static void validateChoiceMethodHeader(String choiceHeader, String promptObject, List choices) + throws NullPointerException, IllegalArgumentException { + Objects.requireNonNull(choiceHeader, "`choiceHeader` must not be null"); + Objects.requireNonNull(promptObject, "`promptObject` must not be null"); + Objects.requireNonNull(choices, "`choices` must not be null"); + if (choices.isEmpty()) { + throw new IllegalArgumentException("No choices provided"); + } + for (T choice : choices) { + Objects.requireNonNull(choice, "All choices must not be null"); + } + } + + /// Parses a given string value to determine if it corresponds to an index or matches an enum name + /// in a provided list of choices. + /// If the value is a valid index, retrieves the corresponding item. + /// If the value matches the name of an enum (ignoring case), retrieves the matched enum. + /// + /// @param the type of items in the choice list; can include enums or other types + /// @param value the string input to be parsed; must not be null + /// @param choices a list of selectable options; must not be null or empty + /// @return an [Optional] containing the matched item from the choices, or an empty [Optional] if no matching item is found or the input is invalid + private static Optional parseIndexOrEnum(String value, List choices) { + String stripped = value.strip(); + try { + return Optional.of(choices.get(Integer.parseInt(stripped) - 1)); + } catch (NumberFormatException | IndexOutOfBoundsException ignored) { + return choices.stream() + .filter(Enum.class::isInstance) + .filter(item -> ((Enum) item).name().equalsIgnoreCase(stripped)) + .findFirst(); + } + } +} 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 new file mode 100644 index 0000000..4b348e9 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/utils/Utils.java @@ -0,0 +1,245 @@ +package io.github.bsels.semantic.version.utils; + +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.project.MavenProject; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.BinaryOperator; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collector; +import java.util.stream.Collectors; + +/// A utility class containing static constants and methods for various common operations. +/// This class is final and not intended to be instantiated. +public final class Utils { + /// A constant string used as a suffix to represent backup files. + /// Typically appended to filenames to indicate the file is a backup copy. + public static final String BACKUP_SUFFIX = ".backup"; + /// A [DateTimeFormatter] instance used to format or parse date-time values according to the pattern + /// `yyyyMMddHHmmss`. + /// This formatter ensures that date-time values are represented in a compact string format with the following + /// components: + /// - Year: 4 digits + /// - Month: 2 digits + /// - Day: 2 digits + /// - Hour: 2 digits (24-hour clock) + /// - Minute: 2 digits + /// - Second: 2 digits + /// + /// The formatter is thread-safe and can be used in concurrent environments. + public static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + /// Utility class containing static constants and methods for various common operations. + /// This class is not designed to be instantiated. + private Utils() { + // No instance needed + } + + /// Creates a backup of the specified file. + /// The method copies the given file to a backup location in the same directory, + /// replacing existing backups if necessary. + /// + /// @param file the path to the file to be backed up; must not be null + /// @throws MojoExecutionException if an I/O error occurs during the backup operation + /// @throws NullPointerException if the `file` argument is null + public static void backupFile(Path file) throws NullPointerException, MojoExecutionException { + Objects.requireNonNull(file, "`file` must not be null"); + String fileName = file.getFileName().toString(); + Path backupPom = file.getParent() + .resolve(fileName + BACKUP_SUFFIX); + if (!Files.exists(file)) { + return; + } + try { + Files.copy( + file, + backupPom, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.COPY_ATTRIBUTES, + StandardCopyOption.REPLACE_EXISTING + ); + } catch (IOException e) { + throw new MojoExecutionException("Failed to backup %s to %s".formatted(file, backupPom), e); + } + } + + /// Deletes the specified files if they exist. + /// + /// This method iterates over the collection of file paths, attempting to delete each file + /// at the given path. + /// If a file does not exist, no action is taken for that file. + /// If an I/O error occurs during the deletion process, a [MojoExecutionException] is thrown. + /// The collection of paths must not be null. + /// + /// @param paths the collection of file paths to be deleted; must not be null + /// @throws NullPointerException if the `paths` collection is null + /// @throws MojoExecutionException if an I/O error occurs during the deletion process + public static void deleteFilesIfExists(Collection paths) throws NullPointerException, MojoExecutionException { + Objects.requireNonNull(paths, "`paths` must not be null"); + for (Path path : paths) { + deleteFileIfExists(path); + } + } + + /// Deletes the specified file if it exists. + /// + /// This method attempts to delete the file at the given path. + /// If the file does not exist, no action is taken. + /// If an I/O error occurs during the deletion process, a [MojoExecutionException] is thrown. + /// The path parameter cannot be null. + /// + /// @param path the path to the file to be deleted; must not be null + /// @throws NullPointerException if the `path` argument is null + /// @throws MojoExecutionException if an I/O error occurs during the deletion process + public static void deleteFileIfExists(Path path) throws NullPointerException, MojoExecutionException { + Objects.requireNonNull(path, "`path` must not be null"); + try { + Files.deleteIfExists(path); + } catch (IOException e) { + throw new MojoExecutionException("Failed to delete file", e); + } + } + + /// Creates a temporary Markdown file with a predefined prefix and suffix. + /// + /// The file is created in the default temporary-file directory, using the prefix "versioning-" and the suffix ".md". + /// If the operation fails, a [MojoExecutionException] is thrown. + /// + /// @return the path to the created temporary Markdown file + /// @throws MojoExecutionException if an I/O error occurs during the file creation process + public static Path createTemporaryMarkdownFile() throws MojoExecutionException { + try { + return Files.createTempFile("versioning-", ".md"); + } catch (IOException e) { + throw new MojoExecutionException("Failed to create temporary file", e); + } + } + + /// Creates a directory at the specified path if it does not already exist. + /// + /// This method ensures that the directory structure for the given path is created, + /// including any necessary but nonexistent parent directories. + /// If the directory cannot be created due to an I/O error, a [MojoExecutionException] will be thrown. + /// + /// @param path the path of the directory to create; must not be null + /// @throws NullPointerException if the `path` parameter is null + /// @throws MojoExecutionException if an I/O error occurs while attempting to create the directory + public static void createDirectoryIfNotExists(Path path) throws NullPointerException, MojoExecutionException { + Objects.requireNonNull(path, "`path` must not be null"); + if (!Files.exists(path)) { + try { + Files.createDirectories(path); + } catch (IOException e) { + throw new MojoExecutionException("Failed to create directory", e); + } + } + } + + /// Resolves and returns the path to a versioning file within the specified folder. + /// The file is named using the pattern "versioning-.md", + /// where is formatted according to the predefined date-time formatter. + /// + /// @param folder the base folder where the versioning file will be resolved; must not be null + /// @return the resolved path to the versioning file + /// @throws NullPointerException if the `folder` parameter is null + public static Path resolveVersioningFile(Path folder) throws NullPointerException { + Objects.requireNonNull(folder, "`folder` must not be null"); + return folder.resolve("versioning-%s.md".formatted(DATE_TIME_FORMATTER.format(LocalDateTime.now()))); + } + + /// Returns a predicate that always evaluates to `true`. + /// + /// @param the type of the input to the predicate + /// @return a predicate that evaluates to `true` for any input + public static Predicate alwaysTrue() { + return ignored -> true; + } + + /// Returns a predicate that evaluates to true if the given Maven project has no modules. + /// + /// @return a predicate that checks whether a Maven project has no modules + public static Predicate mavenProjectHasNoModules() { + return project -> project.getModules().isEmpty(); + } + + /// Converts a [BiConsumer] accumulator into a [BinaryOperator]. + /// The resulting operator applies the given accumulator on two arguments + /// and returns the first argument as the result. + /// + /// @param the type of the input and output of the operation + /// @param accumulator a [BiConsumer] that performs a combination operation on two inputs + /// @return a [BinaryOperator] that combines two inputs using the provided accumulator + public static BinaryOperator consumerToOperator(BiConsumer accumulator) { + Objects.requireNonNull(accumulator, "`accumulator` must not be null"); + return (a, b) -> { + accumulator.accept(a, b); + return a; + }; + } + + /// Returns a [Collector] that groups input elements by a classifier function, applies a downstream + /// [Collector] to the values for each key, and produces an immutable [Map]. + /// + /// @param the type of the input elements + /// @param the type of the keys + /// @param the intermediate accumulation type of the downstream [Collector] + /// @param the result type of the downstream reduction + /// @param classifier a function to classify input elements + /// @param downstream a collector to reduce the values associated with a given key + /// @return a [Collector] that groups elements by a classification function and produces an immutable [Map] + public static Collector> groupingByImmutable( + Function classifier, + Collector downstream + ) { + return Collectors.collectingAndThen( + Collectors.groupingBy(classifier, downstream), + Map::copyOf + ); + } + + /// Returns a collector that wraps the given downstream collector and produces an immutable list as its + /// final result. + /// The resulting collector applies the downstream collection and then creates an immutable view over + /// the resulting list. + /// + /// @param the type of input elements to the collector + /// @param the type of elements in the resulting list + /// @param downstream the downstream collector to accumulate elements + /// @return a collector that produces an immutable list as the final result + /// @see #asImmutableList + public static Collector> asImmutableList(Collector> downstream) { + return Collectors.collectingAndThen(downstream, List::copyOf); + } + + /// Returns a collector that accumulates elements into a list and produces an immutable copy of that list as + /// the final result. + /// + /// @param the type of input elements to the collector + /// @return a collector that produces an immutable list of the collected elements + /// @see #asImmutableList(Collector) + public static Collector> asImmutableList() { + return asImmutableList(Collectors.toList()); + } + + /// Returns a collector that accumulates elements into a set and produces an immutable copy of that set as the final + /// result. + /// The resulting set is unmodifiable and guarantees immutability. + /// + /// @param the type of input elements to the collector + /// @return a collector that produces an immutable set of the collected elements + public static Collector> asImmutableSet() { + return Collectors.collectingAndThen(Collectors.toSet(), Set::copyOf); + } +} diff --git a/src/main/java/io/github/bsels/semantic/version/utils/package-info.java b/src/main/java/io/github/bsels/semantic/version/utils/package-info.java new file mode 100644 index 0000000..0c356b0 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/utils/package-info.java @@ -0,0 +1,2 @@ +/// This package contains the utils class for processing POM files, Markdown files, Terminal interaction, and other utility functions. +package io.github.bsels.semantic.version.utils; \ No newline at end of file diff --git a/src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/MarkdownYamFrontMatterBlockRenderer.java b/src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/MarkdownYamFrontMatterBlockRenderer.java new file mode 100644 index 0000000..de9afa7 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/MarkdownYamFrontMatterBlockRenderer.java @@ -0,0 +1,65 @@ +package io.github.bsels.semantic.version.utils.yaml.front.block; + +import org.commonmark.node.Node; +import org.commonmark.renderer.NodeRenderer; +import org.commonmark.renderer.markdown.MarkdownNodeRendererContext; +import org.commonmark.renderer.markdown.MarkdownWriter; + +import java.util.Objects; +import java.util.Set; + +/// The [MarkdownYamFrontMatterBlockRenderer] class is responsible for rendering YAML front matter blocks +/// in Markdown documents by implementing the [NodeRenderer] interface. +/// +/// This renderer processes nodes of type [YamlFrontMatterBlock], +/// writing their YAML content delimited by the standard front matter markers (`---`). +/// It integrates with the Markdown rendering context to ensure seamless output of structured Markdown content. +/// +/// The renderer relies on a [MarkdownWriter] instance, obtained from the provided [MarkdownNodeRendererContext], +/// to handle raw writes and formatted line outputs during the rendering process. +public class MarkdownYamFrontMatterBlockRenderer implements NodeRenderer { + /// A [MarkdownWriter] instance used to facilitate writing Markdown content during the rendering process. + /// This writer is responsible for outputting structured Markdown text, + /// including custom blocks such as YAML front matter. + /// This variable is initialized using the [MarkdownNodeRendererContext] provided in the constructor, + /// ensuring consistent access to the writer throughout the rendering lifecycle. + /// It is expected that the writer is not null and supports the raw writing + /// and line handling required for processing nodes like [YamlFrontMatterBlock]. + private final MarkdownWriter writer; + + /// Constructs a new instance of the MarkdownYamFrontMatterBlockRenderer class. + /// This renderer is responsible for processing and rendering YAML front matter blocks + /// in Markdown documents, using the provided context. + /// + /// @param context the rendering context used to facilitate writing and node processing; must not be null + /// @throws NullPointerException if the provided context is null + public MarkdownYamFrontMatterBlockRenderer(MarkdownNodeRendererContext context) throws NullPointerException { + Objects.requireNonNull(context, "`context` must not be null"); + this.writer = context.getWriter(); + } + + /// Returns the set of `Node` types that this renderer can process. + /// + /// @return a set containing the class type `YamlFrontMatterBlock`, which represents the custom block for YAML front matter in Markdown documents + @Override + public Set> getNodeTypes() { + return Set.of(YamlFrontMatterBlock.class); + } + + /// Renders the specified [Node] by processing it as a [YamlFrontMatterBlock], if applicable. + /// Outputs the YAML content encapsulated within the block, delimited by front matter markers (`---`). + /// + /// @param node the node to be rendered; must be an instance of [YamlFrontMatterBlock]. If the node is not of this type, the method performs no action. + @Override + public void render(Node node) { + if (node instanceof YamlFrontMatterBlock yamlFrontMatterBlock) { + writer.raw("---"); + writer.line(); + writer.raw(yamlFrontMatterBlock.getYaml()); + writer.line(); + writer.raw("---"); + writer.line(); + writer.line(); + } + } +} diff --git a/src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/MarkdownYamFrontMatterBlockRendererFactory.java b/src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/MarkdownYamFrontMatterBlockRendererFactory.java new file mode 100644 index 0000000..2054f7f --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/MarkdownYamFrontMatterBlockRendererFactory.java @@ -0,0 +1,82 @@ +package io.github.bsels.semantic.version.utils.yaml.front.block; + +import org.commonmark.renderer.NodeRenderer; +import org.commonmark.renderer.markdown.MarkdownNodeRendererContext; +import org.commonmark.renderer.markdown.MarkdownNodeRendererFactory; + +import java.util.Objects; +import java.util.Set; + +/// A singleton factory class for creating [NodeRenderer] instances that handle rendering of YAML front matter blocks +/// in Markdown documents. +/// This factory is part of the CommonMark extension for processing YAML front matter. +/// +/// The [MarkdownYamFrontMatterBlockRendererFactory] follows a singleton design, +/// ensuring only one instance is available throughout the application. +/// It provides mechanisms for creating a renderer specific to processing nodes of YAML front matter blocks, +/// as well as retrieving special characters used by the implementation. +/// +/// YAML front matter is a structured block of metadata typically delimited by `---` markers +/// and placed at the beginning of Markdown documents. +/// This factory produces renderers, such as [MarkdownYamFrontMatterBlockRenderer], +/// to handle the production of output for such front matter in accordance with Markdown rendering context requirements. +/// +/// The factory interacts with the [MarkdownNodeRendererContext] for rendering configuration, +/// ensuring seamless integration with the Markdown framework during rendering operations. +/// +/// ## Responsibilities +/// +/// - Provide singleton access to the factory instance via [#getInstance()]. +/// - Create YAML front matter renderers via [#create(MarkdownNodeRendererContext)]. +/// - Provide the set of special characters supported by the implementation via [#getSpecialCharacters()]. +public class MarkdownYamFrontMatterBlockRendererFactory implements MarkdownNodeRendererFactory { + /// A singleton instance of [MarkdownYamFrontMatterBlockRendererFactory]. + /// + /// This instance is used to provide a centralized, + /// shared instance of the [MarkdownYamFrontMatterBlockRendererFactory] class, + /// adhering to the singleton design pattern. + /// It ensures that only one instance of the factory exists throughout the application, + /// which is used for creating node renderers capable of handling YAML front matter blocks in Markdown documents. + private static final MarkdownYamFrontMatterBlockRendererFactory INSTANCE = new MarkdownYamFrontMatterBlockRendererFactory(); + + /// Private constructor for the [MarkdownYamFrontMatterBlockRendererFactory] class. + /// + /// This constructor implements a singleton pattern to ensure that only a single instance of the factory can exist. + /// It is responsible for the instantiation of the singleton instance + /// and prevents external instantiation of the factory. + private MarkdownYamFrontMatterBlockRendererFactory() { + super(); + } + + /// Provides access to the singleton instance of [MarkdownYamFrontMatterBlockRendererFactory]. + /// This factory is responsible for creating node renderers specific to YAML front matter blocks + /// in Markdown documents. + /// + /// @return the singleton instance of [MarkdownYamFrontMatterBlockRendererFactory] + public static MarkdownYamFrontMatterBlockRendererFactory getInstance() { + return INSTANCE; + } + + /// Creates a new instance of [NodeRenderer] to handle YAML front matter block rendering in Markdown documents. + /// + /// @param context the rendering context used to facilitate node processing and writing; must not be null + /// @return a [NodeRenderer] responsible for rendering YAML front matter blocks + /// @throws NullPointerException if the provided context is null + @Override + public NodeRenderer create(MarkdownNodeRendererContext context) throws NullPointerException { + Objects.requireNonNull(context, "`context` must not be null"); + return new MarkdownYamFrontMatterBlockRenderer(context); + } + + /// Retrieves the set of special characters used or supported by the implementation. + /// + /// This method is typically overridden to provide a collection of characters considered as "special" during + /// the processing or rendering of a particular content type, such as Markdown or YAML. + /// In this implementation, an empty set is returned to indicate the absence of any special characters. + /// + /// @return a set of characters representing the special characters; an empty set if none are defined + @Override + public Set getSpecialCharacters() { + return Set.of(); + } +} diff --git a/src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/YamlFrontMatterBlock.java b/src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/YamlFrontMatterBlock.java new file mode 100644 index 0000000..63a779d --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/YamlFrontMatterBlock.java @@ -0,0 +1,45 @@ +package io.github.bsels.semantic.version.utils.yaml.front.block; + +import org.commonmark.node.CustomBlock; + +import java.util.Objects; + +/// Represents a custom block in a document that encapsulates YAML front matter. +/// This class is used to manage and store the YAML content extracted from a block within a parsed document. +/// It serves as a specialized block type extending the [CustomBlock] class, allowing integration with +/// CommonMark parsers and extensions. +/// +/// This block is typically used in Markdown parsing contexts where YAML front matter +/// is specified at the beginning of a document, delimited by specific markers (e.g., "---"). +/// The YAML content is stored as a single string, which can be retrieved using the provided accessor methods. +public class YamlFrontMatterBlock extends CustomBlock { + /// Represents the YAML content extracted or associated with a specific block of text within a document. + /// This variable is expected to hold the serialized YAML string content and is managed as part of a block's lifecycle. + private String yaml; + + /// Constructs a new instance of the YamlFrontMatterBlock class with the specified YAML content. + /// + /// @param yaml the YAML string content to be associated with this block; must not be null + /// @throws NullPointerException if the provided YAML parameter is null + public YamlFrontMatterBlock(String yaml) throws NullPointerException { + this.yaml = Objects.requireNonNull(yaml, "`yaml` must not be null"); + } + + /// Retrieves the YAML content of this block. + /// + /// @return the YAML string associated with this block, or null if not set + public String getYaml() { + return yaml; + } + + /// Sets the YAML content for this block. + /// + /// Updates the YAML front matter content associated with this block. + /// The input string must not be null. + /// + /// @param yaml the YAML string content to be set; must not be null + /// @throws NullPointerException if the provided YAML parameter is null + public void setYaml(String yaml) throws NullPointerException { + this.yaml = Objects.requireNonNull(yaml, "`yaml` must not be null"); + } +} diff --git a/src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/YamlFrontMatterBlockParser.java b/src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/YamlFrontMatterBlockParser.java new file mode 100644 index 0000000..424c6d9 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/YamlFrontMatterBlockParser.java @@ -0,0 +1,147 @@ +package io.github.bsels.semantic.version.utils.yaml.front.block; + +import org.commonmark.node.Block; +import org.commonmark.node.Document; +import org.commonmark.parser.block.AbstractBlockParser; +import org.commonmark.parser.block.AbstractBlockParserFactory; +import org.commonmark.parser.block.BlockContinue; +import org.commonmark.parser.block.BlockParser; +import org.commonmark.parser.block.BlockStart; +import org.commonmark.parser.block.MatchedBlockParser; +import org.commonmark.parser.block.ParserState; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +/// A parser for YAML front matter blocks in Markdown documents. +/// +/// This class is responsible for detecting, parsing, and processing YAML front matter blocks, +/// typically found at the beginning of Markdown documents. +/// YAML front matter is delimited by lines consisting solely of three hyphens (`---`). +/// The parsed content is stored line by line and used to create a [YamlFrontMatterBlock] +/// representing the structured metadata. +public class YamlFrontMatterBlockParser extends AbstractBlockParser { + /// A compiled regular expression pattern used to identify the delimiters of a YAML front matter block. + /// + /// This pattern is designed to match lines consisting solely of three hyphens (`---`), + /// and it serves as an indicator for the start or end of a YAML front matter section in Markdown documents. + /// YAML front matter is typically used to provide metadata at the beginning of a document and is enclosed + /// between these delimiter lines. + /// + /// The pattern plays a critical role in parsing Markdown content by differentiating between YAML + /// front matter and the rest of the document. + /// It is used in conjunction with the [YamlFrontMatterBlockParser] class to identify + /// and process the YAML front matter block correctly. + private static final Pattern YAML_FRONT_MATTER_PATTERN = Pattern.compile("^---$"); + + /// A list of strings representing the content of the YAML front matter block. + /// + /// This variable is used to store the individual lines of the YAML front matter as they are parsed. + /// Each line corresponds to a line of text within the YAML block and is added to this list during + /// the parsing process. + /// + /// The content of this list is used to construct a [YamlFrontMatterBlock] which encapsulates + /// the YAML front matter block in a document. + /// + /// This list is immutable and is initialized as an empty list upon the construction of the + /// [YamlFrontMatterBlockParser] object. + private final List lines; + + /// Represents the current YAML front matter block being parsed. + /// + /// This variable is used to store an instance of the [YamlFrontMatterBlock], + /// which encapsulates the parsed YAML front matter content from a Markdown document. + /// It is initialized during the parsing process and holds the serialized YAML content + /// once the parsing of a YAML block is complete. + /// + /// As an immutable and final field, this variable ensures the integrity of + /// the YAML block throughout its lifecycle within the parser. + private final YamlFrontMatterBlock block; + + /// Constructs a new instance of the [YamlFrontMatterBlockParser] class. + /// + /// This parser is responsible for handling YAML front matter blocks in Markdown documents. + /// It reads the lines representing the YAML front matter and stores them for further processing. + /// The parsed content is eventually converted into a custom block [YamlFrontMatterBlock] representing + /// the YAML front matter. + /// + /// This constructor initializes an empty list to store the lines of YAML front matter content as they are + /// encountered during parsing. + public YamlFrontMatterBlockParser() { + lines = new ArrayList<>(); + block = new YamlFrontMatterBlock(""); + } + + /// Returns a [Block] object representing the YAML front matter block. + /// The block is constructed using the concatenated lines of YAML front matter content. + /// + /// @return a [YamlFrontMatterBlock] containing the serialized YAML front matter content + @Override + public Block getBlock() { + return block; + } + + /// Attempts to continue parsing a block of text according to the current parser state. + /// If the current line matches the YAML front matter pattern, parsing is concluded for the block. + /// Otherwise, the line is added to the block content, and parsing continues. + /// + /// @param parserState the current state of the parser, including the line being processed + /// @return a [BlockContinue] object indicating whether parsing should continue, finish, or continue at a specific index + @Override + public BlockContinue tryContinue(ParserState parserState) { + CharSequence line = parserState.getLine().getContent(); + if (YAML_FRONT_MATTER_PATTERN.matcher(line).matches()) { + block.setYaml(String.join("\n", lines)); + return BlockContinue.finished(); + } + lines.add(line.toString()); + return BlockContinue.atIndex(parserState.getIndex()); + } + + /// A factory class for creating instances of [YamlFrontMatterBlockParser]. + /// + /// This class is responsible for detecting and initializing block parsers for YAML front matter blocks in Markdown + /// documents. + /// It extends [AbstractBlockParserFactory] to integrate with the CommonMark parser framework. + /// + /// The factory checks for the presence of YAML front matter delimiters (e.g., "---") at the start of a document + /// and creates a new instance of [YamlFrontMatterBlockParser] to handle the parsing of the block. + /// The YAML front matter block represents structured metadata typically found at the beginning + /// of Markdown documents. + public static class Factory extends AbstractBlockParserFactory { + + /// Constructs a new instance of the Factory class. + /// + /// The Factory class serves as a custom block parser factory for parsing YAML front matter blocks + /// in Markdown documents. + /// It extends the AbstractBlockParserFactory to provide the logic for detecting and initializing + /// a new block parser of type [YamlFrontMatterBlockParser]. + /// + /// By default, this constructor initializes the base AbstractBlockParserFactory without requiring + /// additional parameters or custom configuration. + public Factory() { + super(); + } + + /// Attempts to start a new block parser for a YAML front matter block. + /// This method checks whether the current line in the parser state matches a YAML front matter delimiter + /// (e.g., "---") and whether it is valid to start a YAML front matter block at this position. + /// If successful, it initializes a new [YamlFrontMatterBlockParser]. + /// + /// @param state the current parser state containing the line content and position + /// @param matchedBlockParser the parser for the currently matched block, used to determine the parent block and its structural context + /// @return a `BlockStart` either containing a new `YamlFrontMatterBlockParser` and its start index, or `BlockStart.none()` if the conditions to start a YAML front matter block are not met + @Override + public BlockStart tryStart(ParserState state, MatchedBlockParser matchedBlockParser) { + CharSequence line = state.getLine().getContent(); + BlockParser parentParser = matchedBlockParser.getMatchedBlockParser(); + if (parentParser.getBlock() instanceof Document document && + document.getFirstChild() == null && + YAML_FRONT_MATTER_PATTERN.matcher(line).matches()) { + return BlockStart.of(new YamlFrontMatterBlockParser()).atIndex(state.getIndex()); + } + return BlockStart.none(); + } + } +} diff --git a/src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/YamlFrontMatterExtension.java b/src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/YamlFrontMatterExtension.java new file mode 100644 index 0000000..96f8848 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/YamlFrontMatterExtension.java @@ -0,0 +1,47 @@ +package io.github.bsels.semantic.version.utils.yaml.front.block; + +import org.commonmark.parser.Parser; + +/// The [YamlFrontMatterExtension] class is an implementation of the [Parser.ParserExtension] interface +/// designed to add support for parsing YAML front matter in CommonMark-based Markdown documents. +/// +/// YAML front matter is commonly used to define metadata for a document and is usually located at the +/// very beginning of the file, enclosed in a pair of delimiter lines (e.g., "---"). +/// This extension integrates with the parser framework by adding the necessary support for identifying and processing +/// such YAML front matter blocks. +public class YamlFrontMatterExtension implements Parser.ParserExtension { + + /// Constructs a new instance of the [YamlFrontMatterExtension] class. + /// + /// This extension is designed to provide support for parsing YAML front matter in CommonMark-based Markdown + /// documents. + /// It integrates with the parser framework by adding a custom block parser that identifies and processes + /// YAML front matter blocks at the beginning of documents. + public YamlFrontMatterExtension() { + super(); + } + + /// Extends the provided [Parser.Builder] to incorporate support for YAML front matter parsing. + /// + /// This method adds a custom block parser factory to the builder, + /// enabling the parsing and processing of YAML front matter blocks in Markdown documents. + /// YAML front matter blocks are typically used to define metadata at the beginning of a document. + /// + /// @param parserBuilder the builder object used to configure the parser; this method adds a custom block parser factory to handle YAML front matter blocks + @Override + public void extend(Parser.Builder parserBuilder) { + parserBuilder.customBlockParserFactory(new YamlFrontMatterBlockParser.Factory()); + } + + /// Creates and returns a new instance of the [YamlFrontMatterExtension] class. + /// + /// This method provides a convenient way to get a new instance of the extension, + /// which integrates YAML front matter parsing capabilities with a CommonMark-based Markdown parser. + /// The returned extension can be used to configure a parser for processing documents containing + /// YAML front matter metadata. + /// + /// @return a new instance of the YamlFrontMatterExtension class + public static YamlFrontMatterExtension create() { + return new YamlFrontMatterExtension(); + } +} diff --git a/src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/package-info.java b/src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/package-info.java new file mode 100644 index 0000000..10637ba --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/package-info.java @@ -0,0 +1,2 @@ +/// This package contains the YAML Front Matter Block Parser and Renderer for the extension with CommonMark. +package io.github.bsels.semantic.version.utils.yaml.front.block; \ No newline at end of file diff --git a/src/test/java/io/github/bsels/semantic/version/AbstractBaseMojoTest.java b/src/test/java/io/github/bsels/semantic/version/AbstractBaseMojoTest.java new file mode 100644 index 0000000..4c8dbd4 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/AbstractBaseMojoTest.java @@ -0,0 +1,57 @@ +package io.github.bsels.semantic.version; + +import io.github.bsels.semantic.version.test.utils.TestLog; + +import java.net.URISyntaxException; +import java.nio.file.CopyOption; +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +public abstract class AbstractBaseMojoTest { + + protected Path getResourcesPath(String... relativePaths) { + return Stream.of(relativePaths) + .reduce(getResourcesPath(), Path::resolve, (a, b) -> { + throw new UnsupportedOperationException(); + }); + } + + protected Path getResourcesPath() { + try { + return Path.of( + Objects.requireNonNull(UpdatePomMojoTest.class.getResource("/itests/")) + .toURI() + ); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + } + + protected Consumer validateLogRecordDebug(String message) { + return validateLogRecord(TestLog.LogLevel.DEBUG, message); + } + + protected Consumer validateLogRecordInfo(String message) { + return validateLogRecord(TestLog.LogLevel.INFO, message); + } + + protected Consumer validateLogRecordWarn(String message) { + return validateLogRecord(TestLog.LogLevel.WARN, message); + } + + protected Consumer validateLogRecord(TestLog.LogLevel level, String message) { + return record -> assertThat(record) + .hasFieldOrPropertyWithValue("level", level) + .hasFieldOrPropertyWithValue("throwable", Optional.empty()) + .hasFieldOrPropertyWithValue("message", Optional.of(message)); + } + + protected record CopyPath(Path original, Path copy, List options) { + } +} diff --git a/src/test/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojoTest.java b/src/test/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojoTest.java new file mode 100644 index 0000000..03d1f22 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojoTest.java @@ -0,0 +1,554 @@ +package io.github.bsels.semantic.version; + +import io.github.bsels.semantic.version.models.SemanticVersionBump; +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; +import io.github.bsels.semantic.version.utils.Utils; +import org.apache.maven.plugin.MojoFailureException; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +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.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.PrintStream; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.OpenOption; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Scanner; +import java.util.Set; +import java.util.stream.Stream; + +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) +public class CreateVersionMarkdownMojoTest extends AbstractBaseMojoTest { + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2023, 1, 1, 12, 0, 8); + private static final Path TEMP_FILE = Path.of("/tmp", "target", "test-output.md"); + private final InputStream originalSystemIn = System.in; + private final PrintStream originalSystemOut = System.out; + @Mock + Process processMock; + private CreateVersionMarkdownMojo classUnderTest; + private TestLog testLog; + private Map mockedOutputFiles; + private Set mockedCreatedDirectories; + private MockedStatic filesMockedStatic; + private MockedStatic localDateTimeMockedStatic; + private MockedConstruction mockedProcessBuilderConstruction; + private ByteArrayOutputStream outputStream; + + @BeforeEach + public void setUp() { + classUnderTest = new CreateVersionMarkdownMojo(); + testLog = new TestLog(TestLog.LogLevel.DEBUG); + classUnderTest.setLog(testLog); + + mockedOutputFiles = new HashMap<>(); + mockedCreatedDirectories = new HashSet<>(); + + filesMockedStatic = Mockito.mockStatic(Files.class, Mockito.CALLS_REAL_METHODS); + filesMockedStatic.when(() -> Files.newBufferedWriter(Mockito.any(), Mockito.any(), Mockito.any(OpenOption[].class))) + .thenAnswer(answer -> { + Path path = answer.getArgument(0); + mockedOutputFiles.put(path, new StringWriter()); + return new BufferedWriter(mockedOutputFiles.get(path)); + }); + filesMockedStatic.when(() -> Files.createDirectories(Mockito.any())) + .thenAnswer(answer -> { + Path argument = answer.getArgument(0); + mockedCreatedDirectories.add(argument); + return argument; + }); + filesMockedStatic.when(() -> Files.createTempFile(Mockito.any(), Mockito.any())) + .thenReturn(TEMP_FILE); + filesMockedStatic.when(() -> Files.exists(TEMP_FILE)) + .thenReturn(true); + filesMockedStatic.when(() -> Files.deleteIfExists(TEMP_FILE)) + .thenReturn(true); + filesMockedStatic.when(() -> Files.lines(TEMP_FILE, StandardCharsets.UTF_8)) + .thenReturn(Stream.of("Testing external")); + + localDateTimeMockedStatic = Mockito.mockStatic(LocalDateTime.class); + localDateTimeMockedStatic.when(LocalDateTime::now) + .thenReturn(DATE_TIME); + + mockedProcessBuilderConstruction = Mockito.mockConstruction(ProcessBuilder.class, (mock, context) -> { + Mockito.when(mock.inheritIO()).thenReturn(mock); + Mockito.when(mock.start()).thenReturn(processMock); + }); + + outputStream = new ByteArrayOutputStream(); + System.setIn(new ByteArrayInputStream(new byte[0])); + System.setOut(new PrintStream(outputStream)); + } + + @AfterEach + public void tearDown() { + System.setIn(originalSystemIn); + System.setOut(originalSystemOut); + filesMockedStatic.close(); + localDateTimeMockedStatic.close(); + mockedProcessBuilderConstruction.close(); + } + + private void setSystemIn(String input) { + System.setIn(new ByteArrayInputStream(input.getBytes())); + } + + @Test + void noExecutionOnSubProjectIfDisabled_SkipExecution() { + classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession( + getResourcesPath().resolve("leaves"), + Path.of("child-1") + ); + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + Mockito.verify(classUnderTest.session, Mockito.times(1)) + .getCurrentProject(); + Mockito.verify(classUnderTest.session, Mockito.times(1)) + .getTopLevelProject(); + Mockito.verifyNoMoreInteractions(classUnderTest.session); + + assertThat(testLog.getLogRecords()) + .hasSize(1) + .satisfiesExactly(validateLogRecordInfo( + "Skipping execution for subproject org.example.itests.leaves:child-1:5.0.0-child-1" + )); + + assertThat(mockedOutputFiles) + .isEmpty(); + } + + @ParameterizedTest + @EnumSource(value = Modus.class, names = {"PROJECT_VERSION", "PROJECT_VERSION_ONLY_LEAFS"}) + void noProjectsInScope_LogsWarning(Modus modus) { + classUnderTest.session = ReadMockedMavenSession.readMockedMavenSessionNoTopologicalSortedProjects( + getResourcesPath("single"), + Path.of(".") + ); + classUnderTest.modus = modus; + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + assertThat(testLog.getLogRecords()) + .isNotEmpty() + .hasSize(2) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.single:project:1.0.0"), + validateLogRecordWarn("No projects found in scope") + ); + + assertThat(mockedOutputFiles) + .isEmpty(); + } + + @Nested + class MultiProjectExecutionTest { + + @BeforeEach + void setUp() { + classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession( + getResourcesPath("multi"), + Path.of(".") + ); + classUnderTest.modus = Modus.PROJECT_VERSION; + } + + @Test + void noProjectsSelected_LogWarning() { + setSystemIn(""); + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + assertThat(testLog.getLogRecords()) + .isNotEmpty() + .hasSize(3) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.multi:parent:4.0.0-parent"), + validateLogRecordDebug("No projects selected"), + validateLogRecordWarn("No projects selected") + ); + + assertThat(outputStream.toString()) + .isEqualTo(""" + Select projects: + 1: org.example.itests.multi:parent + 2: org.example.itests.multi:combination + 3: org.example.itests.multi:dependency + 4: org.example.itests.multi:dependency-management + 5: org.example.itests.multi:excluded + 6: org.example.itests.multi:plugin + 7: org.example.itests.multi:plugin-management + Enter project numbers separated by spaces, commas or semicolons: \ + """); + + assertThat(mockedOutputFiles) + .isEmpty(); + } + + @ParameterizedTest + @EnumSource(value = SemanticVersionBump.class, names = {"MAJOR", "MINOR", "PATCH"}) + void selectSingleProject_Valid(SemanticVersionBump bump) { + classUnderTest.dryRun = false; + + 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("1"); + } else if (context.getCount() == 2) { + Mockito.when(mock.nextLine()).thenReturn(bump.name().toLowerCase()); + } else { + Mockito.when(mock.nextLine()).thenReturn("Testing"); + } + } + )) { + assertThatNoException() + .isThrownBy(classUnderTest::execute); + } + + assertThat(testLog.getLogRecords()) + .isNotEmpty() + .hasSize(2) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.multi:parent:4.0.0-parent"), + validateLogRecordDebug(""" + Version bumps YAML: + org.example.itests.multi:parent: "%s" + """.formatted(bump)) + ); + + assertThat(outputStream.toString()) + .isEqualTo(""" + Select projects: + 1: org.example.itests.multi:parent + 2: org.example.itests.multi:combination + 3: org.example.itests.multi:dependency + 4: org.example.itests.multi:dependency-management + 5: org.example.itests.multi:excluded + 6: org.example.itests.multi:plugin + 7: org.example.itests.multi:plugin-management + Enter project numbers separated by spaces, commas or semicolons: Selected projects: org.example.itests.multi:parent + Select semantic version bump for org.example.itests.multi:parent:\s + 1: PATCH + 2: MINOR + 3: MAJOR + Enter semantic version name or number: Version bumps: 'org.example.itests.multi:parent': %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) + .isNotEmpty() + .hasSize(1) + .hasEntrySatisfying( + getVersioningMarkdown(), + writer -> assertThat(writer.toString()) + .isEqualTo(""" + --- + org.example.itests.multi:parent: "%s" + + --- + + Testing + """.formatted(bump)) + ); + } + + @Test + void selectMultipleProjects_Valid() { + classUnderTest.dryRun = false; + + 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("1,3,6"); + } else if (context.getCount() >= 2 && context.getCount() <= 4) { + Mockito.when(mock.nextLine()).thenReturn( + SemanticVersionBump.values()[context.getCount() - 1].name().toLowerCase() + ); + } else { + Mockito.when(mock.nextLine()).thenReturn("Testing"); + } + } + )) { + assertThatNoException() + .isThrownBy(classUnderTest::execute); + } + + assertThat(testLog.getLogRecords()) + .isNotEmpty() + .hasSize(2) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.multi:parent:4.0.0-parent"), + record -> assertThat(record) + .hasFieldOrPropertyWithValue("level", TestLog.LogLevel.DEBUG) + .hasFieldOrPropertyWithValue("throwable", Optional.empty()) + .extracting(TestLog.LogRecord::message) + .asInstanceOf(InstanceOfAssertFactories.optional(String.class)) + .isPresent() + .get() + .asInstanceOf(InstanceOfAssertFactories.STRING) + .startsWith("Version bumps YAML:\n") + .contains(" org.example.itests.multi:parent: \"PATCH\"\n") + .contains(" org.example.itests.multi:dependency: \"MINOR\"\n") + .contains(" org.example.itests.multi:plugin: \"MAJOR\"\n") + ); + + assertThat(outputStream.toString()) + .isEqualTo(""" + Select projects: + 1: org.example.itests.multi:parent + 2: org.example.itests.multi:combination + 3: org.example.itests.multi:dependency + 4: org.example.itests.multi:dependency-management + 5: org.example.itests.multi:excluded + 6: org.example.itests.multi:plugin + 7: org.example.itests.multi:plugin-management + Enter project numbers separated by spaces, commas or semicolons: Selected projects: org.example.itests.multi:parent, org.example.itests.multi:dependency, org.example.itests.multi:plugin + Select semantic version bump for org.example.itests.multi:parent:\s + 1: PATCH + 2: MINOR + 3: MAJOR + Enter semantic version name or number: Select semantic version bump for org.example.itests.multi:dependency:\s + 1: PATCH + 2: MINOR + 3: MAJOR + Enter semantic version name or number: Select semantic version bump for org.example.itests.multi:plugin:\s + 1: PATCH + 2: MINOR + 3: MAJOR + Enter semantic version name or number: Version bumps: 'org.example.itests.multi:dependency': MINOR, 'org.example.itests.multi:parent': PATCH, 'org.example.itests.multi:plugin': MAJOR + Please type the changelog entry here (enter empty line to open external editor, two empty lines after your input to end): + """); + + assertThat(mockedOutputFiles) + .isNotEmpty() + .hasSize(1) + .hasEntrySatisfying( + getVersioningMarkdown(), + writer -> assertThat(writer.toString()) + .startsWith("---\n") + .contains("org.example.itests.multi:parent: \"PATCH\"\n") + .contains("org.example.itests.multi:dependency: \"MINOR\"\n") + .contains("org.example.itests.multi:plugin: \"MAJOR\"\n") + .contains("---\n") + .contains("Testing") + ); + } + + private Path getVersioningMarkdown() { + return getResourcesPath("multi", ".versioning", + "versioning-%s.md".formatted(Utils.DATE_TIME_FORMATTER.format(DATE_TIME))); + } + } + + @Nested + class SingleProjectExecutionTest { + + @BeforeEach + void setUp() { + classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession( + getResourcesPath("single"), + Path.of(".") + ); + classUnderTest.modus = Modus.PROJECT_VERSION; + } + + @ParameterizedTest + @EnumSource(value = SemanticVersionBump.class, names = {"MAJOR", "MINOR", "PATCH"}) + void dryRunInlineEditor_Valid(SemanticVersionBump bump) { + classUnderTest.dryRun = true; + + 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: + org.example.itests.single:project: "%s" + """.formatted(bump)), + validateLogRecordInfo(""" + 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(); + } + + @Test + void externalEditorFails_ThrowsMojoFailureException() throws InterruptedException { + Mockito.when(processMock.waitFor()) + .thenReturn(1); + try (MockedConstruction ignored = Mockito.mockConstruction( + Scanner.class, (mock, context) -> { + if (context.getCount() == 1) { + Mockito.when(mock.hasNextLine()).thenReturn(true, false); + Mockito.when(mock.nextLine()).thenReturn("minor"); + } else { + Mockito.when(mock.hasNextLine()).thenReturn(false); + } + } + )) { + assertThatThrownBy(classUnderTest::execute) + .isInstanceOf(MojoFailureException.class) + .hasMessage("Unable to create a new Markdown file in external editor."); + } + + assertThat(testLog.getLogRecords()) + .isNotEmpty() + .hasSize(2) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.single:project:1.0.0"), + validateLogRecordDebug(""" + Version bumps YAML: + org.example.itests.single:project: "MINOR" + """) + ); + + 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': MINOR + Please type the changelog entry here (enter empty line to open external editor, \ + two empty lines after your input to end): + """); + + assertThat(mockedOutputFiles) + .isEmpty(); + } + + @ParameterizedTest + @EnumSource(value = SemanticVersionBump.class, names = {"MAJOR", "MINOR", "PATCH"}) + void externalEditor_Valid(SemanticVersionBump bump) { + classUnderTest.dryRun = false; + + try (MockedConstruction ignored = Mockito.mockConstruction( + Scanner.class, (mock, context) -> { + if (context.getCount() == 1) { + Mockito.when(mock.hasNextLine()).thenReturn(true, false); + Mockito.when(mock.nextLine()).thenReturn(bump.name()); + } else { + Mockito.when(mock.hasNextLine()).thenReturn(false); + } + } + )) { + 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: + org.example.itests.single:project: "%s" + """.formatted(bump)), + validateLogRecordInfo("Read 1 lines from %s".formatted(TEMP_FILE)) + ); + + 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) + .isNotEmpty() + .hasSize(1) + .hasEntrySatisfying( + getSingleVersioningMarkdown(), + writer -> assertThat(writer.toString()) + .isEqualTo(""" + --- + org.example.itests.single:project: "%s" + + --- + + Testing external + """.formatted(bump)) + ); + } + + private Path getSingleVersioningMarkdown() { + return getResourcesPath("single", ".versioning", + "versioning-%s.md".formatted(Utils.DATE_TIME_FORMATTER.format(DATE_TIME))); + } + } +} diff --git a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java new file mode 100644 index 0000000..a5a4613 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java @@ -0,0 +1,3015 @@ +package io.github.bsels.semantic.version; + +import io.github.bsels.semantic.version.models.SemanticVersionBump; +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; +import io.github.bsels.semantic.version.test.utils.TestLog; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +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.IOException; +import java.io.StringWriter; +import java.nio.file.CopyOption; +import java.nio.file.Files; +import java.nio.file.OpenOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.IntStream; + +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) +public class UpdatePomMojoTest extends AbstractBaseMojoTest { + private static final LocalDate DATE = LocalDate.of(2025, 1, 1); + private UpdatePomMojo classUnderTest; + private TestLog testLog; + private Map mockedOutputFiles; + private List mockedCopiedFiles; + private List mockedDeletedFiles; + + private MockedStatic filesMockedStatic; + private MockedStatic localDateMockedStatic; + + @BeforeEach + void setUp() { + classUnderTest = new UpdatePomMojo(); + testLog = new TestLog(TestLog.LogLevel.NONE); + classUnderTest.setLog(testLog); + mockedOutputFiles = new HashMap<>(); + mockedCopiedFiles = new ArrayList<>(); + mockedDeletedFiles = new ArrayList<>(); + + filesMockedStatic = Mockito.mockStatic(Files.class, Mockito.CALLS_REAL_METHODS); + filesMockedStatic.when(() -> Files.newBufferedWriter(Mockito.any(), Mockito.any(), Mockito.any(OpenOption[].class))) + .thenAnswer(answer -> { + Path path = answer.getArgument(0); + mockedOutputFiles.put(path, new StringWriter()); + return new BufferedWriter(mockedOutputFiles.get(path)); + }); + filesMockedStatic.when(() -> Files.copy(Mockito.any(Path.class), Mockito.any(), Mockito.any(CopyOption[].class))) + .thenAnswer(answer -> { + Path original = answer.getArgument(0); + Path copy = answer.getArgument(1); + List options = IntStream.range(2, answer.getArguments().length) + .mapToObj(answer::getArgument) + .toList(); + mockedCopiedFiles.add(new CopyPath(original, copy, options)); + return copy; + }); + filesMockedStatic.when(() -> Files.deleteIfExists(Mockito.any(Path.class))) + .thenAnswer(answer -> mockedDeletedFiles.add(answer.getArgument(0))); + filesMockedStatic.when(() -> Files.delete(Mockito.any(Path.class))) + .thenAnswer(answer -> mockedDeletedFiles.add(answer.getArgument(0))); + + localDateMockedStatic = Mockito.mockStatic(LocalDate.class); + localDateMockedStatic.when(LocalDate::now) + .thenReturn(DATE); + } + + @AfterEach + void tearDown() { + filesMockedStatic.close(); + localDateMockedStatic.close(); + } + + @Test + void noExecutionOnSubProjectIfDisabled_SkipExecution() { + classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession( + getResourcesPath().resolve("leaves"), + Path.of("child-1") + ); + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + Mockito.verify(classUnderTest.session, Mockito.times(1)) + .getCurrentProject(); + Mockito.verify(classUnderTest.session, Mockito.times(1)) + .getTopLevelProject(); + Mockito.verifyNoMoreInteractions(classUnderTest.session); + + assertThat(testLog.getLogRecords()) + .hasSize(1) + .satisfiesExactly(validateLogRecordInfo( + "Skipping execution for subproject org.example.itests.leaves:child-1:5.0.0-child-1" + )); + + assertThat(mockedOutputFiles) + .isEmpty(); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); + } + + @ParameterizedTest + @EnumSource(value = Modus.class, names = {"PROJECT_VERSION", "PROJECT_VERSION_ONLY_LEAFS"}) + void noProjectsInScope_LogsWarning(Modus modus) { + classUnderTest.session = ReadMockedMavenSession.readMockedMavenSessionNoTopologicalSortedProjects( + getResourcesPath("single"), + Path.of(".") + ); + classUnderTest.modus = modus; + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + assertThat(testLog.getLogRecords()) + .isNotEmpty() + .hasSize(3) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.single:project:1.0.0"), + validateLogRecordWarn( + "No versioning files found in %s as folder does not exists".formatted( + getResourcesPath("single", ".versioning") + ) + ), + validateLogRecordWarn("No projects found in scope") + ); + + assertThat(mockedOutputFiles) + .isEmpty(); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); + } + + @Nested + class LeavesProjectTest { + + @BeforeEach + void setUp() { + classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession( + getResourcesPath("leaves"), + Path.of(".") + ); + classUnderTest.modus = Modus.PROJECT_VERSION_ONLY_LEAFS; + } + + @ParameterizedTest + @EnumSource(value = VersionBump.class, names = {"FILE_BASED"}, mode = EnumSource.Mode.EXCLUDE) + void fixedVersionBump_Valid(VersionBump versionBump) { + classUnderTest.versionBump = versionBump; + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + assertThat(testLog.getLogRecords()) + .hasSize(19) + .satisfiesExactlyInAnyOrder( + validateLogRecordInfo("Execution for project: org.example.itests.leaves:root:5.0.0-root"), + validateLogRecordWarn("No versioning files found in %s as folder does not exists".formatted( + getResourcesPath("leaves", ".versioning") + )), + validateLogRecordInfo("Multiple projects in scope"), + validateLogRecordInfo("Found 3 projects in scope"), + validateLogRecordInfo("Updating version with a %s semantic version".formatted(versionBump)), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("leaves", "child-1", "CHANGELOG.md") + )), + validateLogRecordDebug("Original changelog"), + validateLogRecordDebug("Updated changelog"), + validateLogRecordInfo("Updating version with a %s semantic version".formatted(versionBump)), + 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 %s semantic version".formatted(versionBump)), + 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") + ); + + String expectedVersion = switch (versionBump) { + case FILE_BASED -> throw new AssertionError("Should not be called"); + case MAJOR -> "6.0.0"; + case MINOR -> "5.1.0"; + case PATCH -> "5.0.1"; + }; + assertThat(mockedOutputFiles) + .hasSize(6); + for (int i = 0; i < 3; i++) { + final int index = i + 1; + Path path; + if (i == 0) { + path = getResourcesPath("leaves", "child-%d".formatted(index)); + } else { + path = getResourcesPath("leaves", "intermediate", "child-%d".formatted(index)); + } + assertThat(mockedOutputFiles) + .hasEntrySatisfying( + path.resolve("pom.xml"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + + + 4.0.0 + org.example.itests.leaves + child-%1$d + %2$s-child-%1$d + + """.formatted(index, expectedVersion) + ) + ) + .hasEntrySatisfying( + path.resolve("CHANGELOG.md"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + # Changelog + + ## %2$s-child-%1$d - 2025-01-01 + + ### Other + + Project version bumped as result of dependency bumps + + ## 5.0.0-child-%1$d - 2026-01-01 + + Initial child %1$d release. + """.formatted(index, expectedVersion) + ) + ); + } + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); + } + + @Test + void noSemanticVersionBumpFileBased_NothingChanged() { + classUnderTest.versionBump = VersionBump.FILE_BASED; + classUnderTest.versionDirectory = getResourcesPath("versioning", "leaves", "none"); + + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + assertThat(testLog.getLogRecords()) + .hasSize(12) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.leaves:root:5.0.0-root"), + validateLogRecordInfo("Read 7 lines from %s".formatted( + getResourcesPath("versioning", "leaves", "none", "versioning.md") + )), + validateLogRecordDebug(""" + YAML front matter: + 'org.example.itests.leaves:child-1': none + 'org.example.itests.leaves:child-2': none + 'org.example.itests.leaves:child-3': none\ + """), + validateLogRecordDebug(""" + Maven artifacts and semantic version bumps: + {org.example.itests.leaves:child-2=NONE, org.example.itests.leaves:child-1=NONE, \ + org.example.itests.leaves:child-3=NONE}\ + """), + validateLogRecordInfo("Multiple projects in scope"), + validateLogRecordInfo("Found 3 projects in scope"), + validateLogRecordInfo("Updating version with a NONE semantic version"), + validateLogRecordInfo("No version update required"), + validateLogRecordInfo("Updating version with a NONE semantic version"), + validateLogRecordInfo("No version update required"), + validateLogRecordInfo("Updating version with a NONE semantic version"), + validateLogRecordInfo("No version update required") + ); + + assertThat(mockedOutputFiles) + .isEmpty(); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); + } + + @Test + void singleFileBased_Valid() { + classUnderTest.versionBump = VersionBump.FILE_BASED; + classUnderTest.versionDirectory = getResourcesPath("versioning", "leaves", "single"); + + 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", "versioning.md") + )), + validateLogRecordDebug(""" + YAML front matter: + 'org.example.itests.leaves:child-1': patch + 'org.example.itests.leaves:child-2': minor + 'org.example.itests.leaves: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", "versioning.md")); + } + + @Test + void multiFileBased_Valid() { + classUnderTest.versionBump = VersionBump.FILE_BASED; + classUnderTest.versionDirectory = getResourcesPath("versioning", "leaves", "multi"); + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + assertThat(testLog.getLogRecords()) + .hasSize(27) + .satisfiesExactlyInAnyOrder( + validateLogRecordInfo("Execution for project: org.example.itests.leaves:root:5.0.0-root"), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("versioning", "leaves", "multi", "child-1.md") + )), + validateLogRecordDebug(""" + YAML front matter: + 'org.example.itests.leaves:child-1': patch\ + """), + validateLogRecordDebug(""" + Maven artifacts and semantic version bumps: + {org.example.itests.leaves:child-1=PATCH}\ + """), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("versioning", "leaves", "multi", "child-2.md") + )), + validateLogRecordDebug(""" + YAML front matter: + 'org.example.itests.leaves:child-2': minor\ + """), + validateLogRecordDebug(""" + Maven artifacts and semantic version bumps: + {org.example.itests.leaves:child-2=MINOR}\ + """), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("versioning", "leaves", "multi", "child-3.md") + )), + validateLogRecordDebug(""" + YAML front matter: + 'org.example.itests.leaves:child-3': major\ + """), + validateLogRecordDebug(""" + Maven artifacts and semantic version bumps: + {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 + + Child 1 = Patch + + ## 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 + + Child 2 = Minor + + ## 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 + + Child 3 = Major + + ## 5.0.0-child-3 - 2026-01-01 + + Initial child 3 release. + """ + ) + ); + assertThat(mockedCopiedFiles) + .isEmpty(); + + assertThat(mockedDeletedFiles) + .isNotEmpty() + .hasSize(3) + .containsExactlyInAnyOrder( + getResourcesPath("versioning", "leaves", "multi", "child-1.md"), + getResourcesPath("versioning", "leaves", "multi", "child-2.md"), + getResourcesPath("versioning", "leaves", "multi", "child-3.md") + ); + } + + } + + @Nested + class MultiProjectTest { + + private static String getVersioningMessage(String dependency) { + return switch (dependency) { + case "dependency" -> "Dependency"; + case "dependencyManagement" -> "Dependency management"; + case "plugin" -> "Plugin"; + case "pluginManagement" -> "Plugin management"; + case "parent" -> "Parent"; + default -> throw new IllegalStateException("Unknown dependency type: " + dependency); + }; + } + + private static String folderToMessage(String dependency) { + return switch (dependency) { + case "dependency" -> "dependency"; + case "dependencyManagement" -> "dependency-management"; + case "plugin" -> "plugin"; + case "pluginManagement" -> "plugin-management"; + case "parent" -> "parent"; + default -> throw new IllegalStateException("Unknown dependency type: " + dependency); + }; + } + + @BeforeEach + void setUp() { + classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession( + getResourcesPath("multi"), + Path.of(".") + ); + classUnderTest.modus = Modus.PROJECT_VERSION; + } + + @ParameterizedTest + @CsvSource({ + "dependency,4.1.0-dependency,4.0.0-dependency-management,4.0.0-plugin,4.0.0-plugin-management,4.0.0-parent", + "dependencyManagement,4.0.0-dependency,4.1.0-dependency-management,4.0.0-plugin,4.0.0-plugin-management,4.0.0-parent", + "plugin,4.0.0-dependency,4.0.0-dependency-management,4.1.0-plugin,4.0.0-plugin-management,4.0.0-parent", + "pluginManagement,4.0.0-dependency,4.0.0-dependency-management,4.0.0-plugin,4.1.0-plugin-management,4.0.0-parent", + "parent,4.0.0-dependency,4.0.0-dependency-management,4.0.0-plugin,4.0.0-plugin-management,4.1.0-parent" + }) + void handleDependencyCorrect_NoErrors( + String dependency, + String dependencyVersion, + String dependencyManagementVersion, + String pluginVersion, + String pluginManagementVersion, + String parentVersion + ) { + classUnderTest.versionBump = VersionBump.FILE_BASED; + classUnderTest.versionDirectory = getResourcesPath("versioning", "multi", dependency); + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + assertThat(mockedOutputFiles) + .hasSize(4) + .hasEntrySatisfying( + getResourcesPath("multi", "combination", "CHANGELOG.md"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + # Changelog + + ## 4.0.1-combination - 2025-01-01 + + ### Other + + Project version bumped as result of dependency bumps + + ## 4.0.0-combination - 2026-01-01 + + Initial dependency release. + """) + ) + .hasEntrySatisfying( + getResourcesPath("multi", "combination", "pom.xml"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + + + + org.example.itests.multi + parent + %5$s + + + 4.0.0 + combination + 4.0.1-combination + + + + org.example.itests.multi + dependency + %1$s + + + + + + + org.example.itests.multi + dependency-management + %2$s + + + + + + + + org.example.itests.multi + plugin + %3$s + + + + + + org.example.itests.multi + plugin-management + %4$s + + + + + + """.formatted( + dependencyVersion, + dependencyManagementVersion, + pluginVersion, + pluginManagementVersion, + parentVersion + ) + ) + ); + + if ("parent".equals(dependency)) { + assertThat(mockedOutputFiles) + .hasEntrySatisfying( + getResourcesPath("multi", "CHANGELOG.md"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + # Changelog + + ## 4.1.0-%1$s - 2025-01-01 + + ### Minor + + %2$s update. + + ## 4.0.0-%1$s - 2026-01-01 + + Initial %3$s release. + """.formatted( + folderToMessage(dependency), + getVersioningMessage(dependency), + getVersioningMessage(dependency).toLowerCase() + )) + ) + .hasEntrySatisfying( + getResourcesPath("multi", "pom.xml"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + + + 4.0.0 + org.example.itests.multi + %1$s + 4.1.0-%1$s + + + dependency + dependencyManagement + plugin + pluginManagement + combination + excluded + + + """.formatted(folderToMessage(dependency)) + ) + ); + } else { + assertThat(mockedOutputFiles) + .hasEntrySatisfying( + getResourcesPath("multi", dependency, "CHANGELOG.md"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + # Changelog + + ## 4.1.0-%1$s - 2025-01-01 + + ### Minor + + %2$s update. + + ## 4.0.0-%1$s - 2026-01-01 + + Initial %3$s release. + """.formatted( + folderToMessage(dependency), + getVersioningMessage(dependency), + getVersioningMessage(dependency).toLowerCase() + )) + ) + .hasEntrySatisfying( + getResourcesPath("multi", dependency, "pom.xml"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + + + 4.0.0 + org.example.itests.multi + %1$s + 4.1.0-%1$s + + """.formatted(folderToMessage(dependency)) + ) + ); + } + + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isNotEmpty() + .hasSize(1) + .containsExactly(getResourcesPath("versioning", "multi", dependency, "versioning.md")); + } + + @Test + void independentProject_NoDependencyUpdates() { + classUnderTest.versionBump = VersionBump.FILE_BASED; + classUnderTest.versionDirectory = getResourcesPath("versioning", "multi", "excluded"); + + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + assertThat(mockedOutputFiles) + .hasSize(2) + .hasEntrySatisfying( + getResourcesPath("multi", "excluded", "CHANGELOG.md"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + # Changelog + + ## 4.1.0-excluded - 2025-01-01 + + ### Minor + + Excluded update. + + ## 4.0.0-excluded - 2026-01-01 + + Initial excluded release. + """) + ) + .hasEntrySatisfying( + getResourcesPath("multi", "excluded", "pom.xml"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + + + 4.0.0 + org.example.itests.multi + excluded + 4.1.0-excluded + + """) + ); + + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isNotEmpty() + .hasSize(1) + .containsExactly(getResourcesPath("versioning", "multi", "excluded", "versioning.md")); + } + } + + @Nested + class MultiRecursiveProjectTest { + + @BeforeEach + void setUp() { + classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession( + getResourcesPath("multi-recursive"), + Path.of(".") + ); + classUnderTest.modus = Modus.PROJECT_VERSION; + } + + @Test + void handleMultiRecursiveProjectCorrect_NoErrors() { + classUnderTest.versionBump = VersionBump.FILE_BASED; + classUnderTest.versionDirectory = getResourcesPath("versioning", "multi-recursive"); + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + assertThat(mockedOutputFiles) + .hasSize(6) + .hasEntrySatisfying( + getResourcesPath("multi-recursive", "CHANGELOG.md"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + # Changelog + + ## 6.1.0-parent - 2025-01-01 + + ### Minor + + Parent update. + + ## 6.0.0-parent - 2026-01-01 + + Initial parent release. + """) + ) + .hasEntrySatisfying( + getResourcesPath("multi-recursive", "pom.xml"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + + + 4.0.0 + org.example.itests.multi-recursive + parent + 6.1.0-parent + + + child-1 + child-2 + + + """) + ) + .hasEntrySatisfying( + getResourcesPath("multi-recursive", "child-1", "CHANGELOG.md"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + # Changelog + + ## 6.0.1-child-1 - 2025-01-01 + + ### Other + + Project version bumped as result of dependency bumps + + ## 6.0.0-child-1 - 2026-01-01 + + Initial child 1 release. + """) + ) + .hasEntrySatisfying( + getResourcesPath("multi-recursive", "child-1", "pom.xml"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + + + + org.example.itests.multi-recursive + parent + 6.1.0-parent + + + 4.0.0 + child-1 + 6.0.1-child-1 + + """) + ) + .hasEntrySatisfying( + getResourcesPath("multi-recursive", "child-2", "CHANGELOG.md"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + # Changelog + + ## 6.0.1-child-2 - 2025-01-01 + + ### Other + + Project version bumped as result of dependency bumps + + ## 6.0.0-child-2 - 2026-01-01 + + Initial child 2 release. + """) + ) + .hasEntrySatisfying( + getResourcesPath("multi-recursive", "child-2", "pom.xml"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + + + 4.0.0 + org.example.itests.multi-recursive + child-2 + 6.0.1-child-2 + + + + org.example.itests.multi-recursive + child-1 + 6.0.1-child-1 + + + + """) + ); + + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isNotEmpty() + .hasSize(1) + .containsExactly(getResourcesPath("versioning", "multi-recursive", "versioning.md")); + } + } + + @Nested + class RevisionMultiProjectTest { + + @BeforeEach + void setUp() { + classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession( + getResourcesPath("revision", "multi"), + Path.of(".") + ); + classUnderTest.modus = Modus.REVISION_PROPERTY; + } + + @ParameterizedTest + @EnumSource(value = VersionBump.class, names = {"FILE_BASED"}, mode = EnumSource.Mode.EXCLUDE) + void fixedVersionBump_Valid(VersionBump versionBump) { + classUnderTest.versionBump = versionBump; + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + assertThat(testLog.getLogRecords()) + .hasSize(7) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.revision.multi:parent:3.0.0"), + validateLogRecordWarn("No versioning files found in %s as folder does not exists".formatted( + getResourcesPath("revision", "multi", ".versioning") + )), + validateLogRecordInfo("Single project in scope"), + validateLogRecordInfo("Updating version with a %s semantic version".formatted(versionBump)), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("revision", "multi", "CHANGELOG.md") + )), + validateLogRecordDebug("Original changelog"), + validateLogRecordDebug("Updated changelog") + ); + + String expectedVersion = switch (versionBump) { + case FILE_BASED -> throw new AssertionError("Should not be called"); + case MAJOR -> "4.0.0"; + case MINOR -> "3.1.0"; + case PATCH -> "3.0.1"; + }; + assertThat(mockedOutputFiles) + .hasSize(2) + .hasEntrySatisfying( + getResourcesPath("revision", "multi", "pom.xml"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + + + 4.0.0 + org.example.itests.revision.multi + parent + ${revision} + + + %s + + + + child1 + child2 + + + """.formatted(expectedVersion) + ) + ) + .hasEntrySatisfying( + getResourcesPath("revision", "multi", "CHANGELOG.md"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + # Changelog + + ## %s - 2025-01-01 + + ### Other + + Project version bumped as result of dependency bumps + + ## 3.0.0 - 2026-01-01 + + Initial release. + """.formatted(expectedVersion) + ) + ); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); + } + + @ParameterizedTest + @EnumSource(value = VersionBump.class, names = {"FILE_BASED"}, mode = EnumSource.Mode.EXCLUDE) + void fixedVersionBumpWithBackup_Valid(VersionBump versionBump) { + classUnderTest.versionBump = versionBump; + classUnderTest.backupFiles = true; + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + assertThat(testLog.getLogRecords()) + .hasSize(7) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.revision.multi:parent:3.0.0"), + validateLogRecordWarn("No versioning files found in %s as folder does not exists".formatted( + getResourcesPath("revision", "multi", ".versioning") + )), + validateLogRecordInfo("Single project in scope"), + validateLogRecordInfo("Updating version with a %s semantic version".formatted(versionBump)), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("revision", "multi", "CHANGELOG.md") + )), + validateLogRecordDebug("Original changelog"), + validateLogRecordDebug("Updated changelog") + ); + + String expectedVersion = switch (versionBump) { + case FILE_BASED -> throw new AssertionError("Should not be called"); + case MAJOR -> "4.0.0"; + case MINOR -> "3.1.0"; + case PATCH -> "3.0.1"; + }; + assertThat(mockedOutputFiles) + .hasSize(2) + .hasEntrySatisfying( + getResourcesPath("revision", "multi", "pom.xml"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + + + 4.0.0 + org.example.itests.revision.multi + parent + ${revision} + + + %s + + + + child1 + child2 + + + """.formatted(expectedVersion) + ) + ) + .hasEntrySatisfying( + getResourcesPath("revision", "multi", "CHANGELOG.md"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + # Changelog + + ## %s - 2025-01-01 + + ### Other + + Project version bumped as result of dependency bumps + + ## 3.0.0 - 2026-01-01 + + Initial release. + """.formatted(expectedVersion) + ) + ); + assertThat(mockedCopiedFiles) + .isNotEmpty() + .hasSize(2) + .containsExactlyInAnyOrder( + new CopyPath( + getResourcesPath("revision", "multi", "pom.xml"), + getResourcesPath("revision", "multi", "pom.xml.backup"), + List.of( + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.COPY_ATTRIBUTES, + StandardCopyOption.REPLACE_EXISTING + ) + ), + new CopyPath( + getResourcesPath("revision", "multi", "CHANGELOG.md"), + getResourcesPath("revision", "multi", "CHANGELOG.md.backup"), + List.of( + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.COPY_ATTRIBUTES, + StandardCopyOption.REPLACE_EXISTING + ) + ) + ); + assertThat(mockedDeletedFiles) + .isEmpty(); + } + + @ParameterizedTest + @EnumSource(value = VersionBump.class, names = {"FILE_BASED"}, mode = EnumSource.Mode.EXCLUDE) + void fixedVersionBumpDryRun_Valid(VersionBump versionBump) { + classUnderTest.versionBump = versionBump; + classUnderTest.dryRun = true; + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + String expectedVersion = switch (versionBump) { + case FILE_BASED -> throw new AssertionError("Should not be called"); + case MAJOR -> "4.0.0"; + case MINOR -> "3.1.0"; + case PATCH -> "3.0.1"; + }; + + assertThat(testLog.getLogRecords()) + .hasSize(9) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.revision.multi:parent:3.0.0"), + validateLogRecordWarn("No versioning files found in %s as folder does not exists".formatted( + getResourcesPath("revision", "multi", ".versioning") + )), + validateLogRecordInfo("Single project in scope"), + validateLogRecordInfo("Updating version with a %s semantic version".formatted(versionBump)), + validateLogRecordInfo(""" + Dry-run: new pom at %s: + + + 4.0.0 + org.example.itests.revision.multi + parent + ${revision} + + + %s + + + + child1 + child2 + + \ + """.formatted(getResourcesPath("revision", "multi", "pom.xml"), expectedVersion)), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("revision", "multi", "CHANGELOG.md") + )), + validateLogRecordDebug("Original changelog"), + validateLogRecordDebug("Updated changelog"), + validateLogRecordInfo(""" + Dry-run: new markdown file at %s: + # Changelog + + ## %s - 2025-01-01 + + ### Other + + Project version bumped as result of dependency bumps + + ## 3.0.0 - 2026-01-01 + + Initial release. + """.formatted(getResourcesPath("revision", "multi", "CHANGELOG.md"), expectedVersion)) + ); + + assertThat(mockedOutputFiles) + .isEmpty(); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); + } + + @ParameterizedTest + @EnumSource(value = VersionBump.class, names = {"FILE_BASED"}, mode = EnumSource.Mode.EXCLUDE) + void dryRunStringWriteCloseFailure_ThrowMojoExecutionException(VersionBump versionBump) { + classUnderTest.versionBump = versionBump; + classUnderTest.dryRun = true; + + IOException ioException = new IOException("Unable to open output stream for writing"); + try (MockedConstruction ignored = Mockito.mockConstruction( + StringWriter.class, + (mock, context) -> { + Mockito.doThrow(ioException).when(mock).close(); + Mockito.when(mock.toString()).thenReturn("Mock for StringWriter, hashCode: 0"); + } + )) { + assertThatThrownBy(classUnderTest::execute) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Unable to open output stream for writing") + .hasRootCause(ioException); + } + + assertThat(testLog.getLogRecords()) + .hasSize(5) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.revision.multi:parent:3.0.0"), + validateLogRecordWarn("No versioning files found in %s as folder does not exists".formatted( + getResourcesPath("revision", "multi", ".versioning") + )), + validateLogRecordInfo("Single project in scope"), + validateLogRecordInfo("Updating version with a %s semantic version".formatted(versionBump)), + validateLogRecordInfo(""" + Dry-run: new pom at %s: + Mock for StringWriter, hashCode: 0\ + """.formatted(getResourcesPath("revision", "multi", "pom.xml"))) + ); + + assertThat(mockedOutputFiles) + .isEmpty(); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); + } + + @Test + void filedBasedWalkFailed_ThrowMojoExecutionException() { + classUnderTest.versionBump = VersionBump.FILE_BASED; + classUnderTest.versionDirectory = getResourcesPath("versioning", "revision", "multi", "unknown-project"); + filesMockedStatic.when(() -> Files.walk(Mockito.any(Path.class), Mockito.eq(1))) + .thenThrow(IOException.class); + + assertThatThrownBy(classUnderTest::execute) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Unable to read versioning folder") + .hasRootCauseInstanceOf(IOException.class); + + assertThat(testLog.getLogRecords()) + .hasSize(1) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.revision.multi:parent:3.0.0") + ); + + assertThat(mockedOutputFiles) + .isEmpty(); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); + } + + @Test + void unknownProjectFileBased_ThrowMojoFailureException() { + classUnderTest.versionBump = VersionBump.FILE_BASED; + classUnderTest.versionDirectory = getResourcesPath("versioning", "revision", "multi", "unknown-project"); + + + assertThatThrownBy(classUnderTest::execute) + .isInstanceOf(MojoFailureException.class) + .hasMessage(""" + The following artifacts in the Markdown files are not present in the project scope: \ + org.example.itests.single:unknown-project\ + """); + + assertThat(testLog.getLogRecords()) + .hasSize(4) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.revision.multi:parent:3.0.0"), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("versioning", "revision", "multi", "unknown-project", "versioning.md") + )), + validateLogRecordDebug(""" + YAML front matter: + 'org.example.itests.single:unknown-project': major\ + """), + validateLogRecordDebug(""" + Maven artifacts and semantic version bumps: + {org.example.itests.single:unknown-project=%s}\ + """.formatted(SemanticVersionBump.MAJOR)) + ); + + assertThat(mockedOutputFiles) + .isEmpty(); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); + } + + @Test + void noSemanticVersionBumpFileBased_NothingChanged() { + classUnderTest.versionBump = VersionBump.FILE_BASED; + classUnderTest.versionDirectory = getResourcesPath("versioning", "revision", "multi", "none"); + + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + assertThat(testLog.getLogRecords()) + .hasSize(7) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.revision.multi:parent:3.0.0"), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("versioning", "revision", "multi", "none", "versioning.md") + )), + validateLogRecordDebug(""" + YAML front matter: + 'org.example.itests.revision.multi:parent': none\ + """), + validateLogRecordDebug(""" + Maven artifacts and semantic version bumps: + {org.example.itests.revision.multi:parent=%s}\ + """.formatted(SemanticVersionBump.NONE)), + validateLogRecordInfo("Single project in scope"), + validateLogRecordInfo( + "Updating version with a %s semantic version".formatted(SemanticVersionBump.NONE) + ), + validateLogRecordInfo("No version update required") + ); + + assertThat(mockedOutputFiles) + .isEmpty(); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); + } + + @ParameterizedTest + @CsvSource({ + "major,Major,4.0.0", + "minor,Minor,3.1.0", + "patch,Patch,3.0.1" + }) + void singleSemanticVersionBumFile_Valid(String folder, String title, String expectedVersion) { + classUnderTest.versionBump = VersionBump.FILE_BASED; + classUnderTest.versionDirectory = getResourcesPath("versioning", "revision", "multi", folder); + + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + SemanticVersionBump semanticVersionBump = SemanticVersionBump.fromString(folder); + assertThat(testLog.getLogRecords()) + .hasSize(9) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.revision.multi:parent:3.0.0"), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("versioning", "revision", "multi", folder, "versioning.md") + )), + validateLogRecordDebug(""" + YAML front matter: + 'org.example.itests.revision.multi:parent': %s\ + """.formatted(folder)), + validateLogRecordDebug(""" + Maven artifacts and semantic version bumps: + {org.example.itests.revision.multi:parent=%s}\ + """.formatted(semanticVersionBump)), + validateLogRecordInfo("Single project in scope"), + validateLogRecordInfo( + "Updating version with a %s semantic version".formatted(semanticVersionBump) + ), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("revision", "multi", "CHANGELOG.md") + )), + validateLogRecordDebug("Original changelog"), + validateLogRecordDebug("Updated changelog") + ); + + assertThat(mockedOutputFiles) + .hasSize(2) + .hasEntrySatisfying( + getResourcesPath("revision", "multi", "pom.xml"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + + + 4.0.0 + org.example.itests.revision.multi + parent + ${revision} + + + %s + + + + child1 + child2 + + + """.formatted(expectedVersion) + ) + ) + .hasEntrySatisfying( + getResourcesPath("revision", "multi", "CHANGELOG.md"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + # Changelog + + ## %1$s - 2025-01-01 + + ### %2$s + + %2$s versioning applied. + + ## 3.0.0 - 2026-01-01 + + Initial release. + """.formatted(expectedVersion, title) + ) + ); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isNotEmpty() + .hasSize(1) + .containsExactlyInAnyOrder( + getResourcesPath("versioning", "revision", "multi", folder, "versioning.md") + ); + } + + @Test + void multipleSemanticVersionBumpFiles_Valid() { + classUnderTest.versionBump = VersionBump.FILE_BASED; + classUnderTest.versionDirectory = getResourcesPath("versioning", "revision", "multi", "multiple"); + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + assertThat(testLog.getLogRecords()) + .hasSize(18) + .satisfiesExactlyInAnyOrder( + validateLogRecordInfo("Execution for project: org.example.itests.revision.multi:parent:3.0.0"), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("versioning", "revision", "multi", "multiple", "major.md") + )), + validateLogRecordDebug(""" + YAML front matter: + 'org.example.itests.revision.multi:parent': major\ + """), + validateLogRecordDebug(""" + Maven artifacts and semantic version bumps: + {org.example.itests.revision.multi:parent=%s}\ + """.formatted(SemanticVersionBump.MAJOR)), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("versioning", "revision", "multi", "multiple", "minor.md") + )), + validateLogRecordDebug(""" + YAML front matter: + 'org.example.itests.revision.multi:parent': minor\ + """), + validateLogRecordDebug(""" + Maven artifacts and semantic version bumps: + {org.example.itests.revision.multi:parent=%s}\ + """.formatted(SemanticVersionBump.MINOR)), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("versioning", "revision", "multi", "multiple", "none.md") + )), + validateLogRecordDebug(""" + YAML front matter: + 'org.example.itests.revision.multi:parent': none\ + """), + validateLogRecordDebug(""" + Maven artifacts and semantic version bumps: + {org.example.itests.revision.multi:parent=%s}\ + """.formatted(SemanticVersionBump.NONE)), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("versioning", "revision", "multi", "multiple", "patch.md") + )), + validateLogRecordDebug(""" + YAML front matter: + 'org.example.itests.revision.multi:parent': patch\ + """), + validateLogRecordDebug(""" + Maven artifacts and semantic version bumps: + {org.example.itests.revision.multi:parent=%s}\ + """.formatted(SemanticVersionBump.PATCH)), + validateLogRecordInfo("Single project in scope"), + validateLogRecordInfo( + "Updating version with a %s semantic version".formatted(SemanticVersionBump.MAJOR) + ), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("revision", "multi", "CHANGELOG.md") + )), + validateLogRecordDebug("Original changelog"), + validateLogRecordDebug("Updated changelog") + ); + + assertThat(mockedOutputFiles) + .hasSize(2) + .hasEntrySatisfying( + getResourcesPath("revision", "multi", "pom.xml"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + + + 4.0.0 + org.example.itests.revision.multi + parent + ${revision} + + + 4.0.0 + + + + child1 + child2 + + + """ + ) + ) + .hasEntrySatisfying( + getResourcesPath("revision", "multi", "CHANGELOG.md"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + # Changelog + + ## 4.0.0 - 2025-01-01 + + ### Major + + Major versioning applied. + + ### Minor + + Minor versioning applied. + + ### Patch + + Patch versioning applied. + + ### Other + + No versioning applied. + + ## 3.0.0 - 2026-01-01 + + Initial release. + """ + ) + ); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isNotEmpty() + .hasSize(4) + .containsExactlyInAnyOrder( + getResourcesPath("versioning", "revision", "multi", "multiple", "major.md"), + getResourcesPath("versioning", "revision", "multi", "multiple", "minor.md"), + getResourcesPath("versioning", "revision", "multi", "multiple", "patch.md"), + getResourcesPath("versioning", "revision", "multi", "multiple", "none.md") + ); + } + } + + @Nested + class RevisionSingleProjectTest { + + @BeforeEach + void setUp() { + classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession( + getResourcesPath("revision", "single"), + Path.of(".") + ); + classUnderTest.modus = Modus.REVISION_PROPERTY; + } + + @ParameterizedTest + @EnumSource(value = VersionBump.class, names = {"FILE_BASED"}, mode = EnumSource.Mode.EXCLUDE) + void fixedVersionBump_Valid(VersionBump versionBump) { + classUnderTest.versionBump = versionBump; + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + assertThat(testLog.getLogRecords()) + .hasSize(7) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.revision.single:project:2.0.0"), + validateLogRecordWarn("No versioning files found in %s as folder does not exists".formatted( + getResourcesPath("revision", "single", ".versioning") + )), + validateLogRecordInfo("Single project in scope"), + validateLogRecordInfo("Updating version with a %s semantic version".formatted(versionBump)), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("revision", "single", "CHANGELOG.md") + )), + validateLogRecordDebug("Original changelog"), + validateLogRecordDebug("Updated changelog") + ); + + String expectedVersion = switch (versionBump) { + case FILE_BASED -> throw new AssertionError("Should not be called"); + case MAJOR -> "3.0.0"; + case MINOR -> "2.1.0"; + case PATCH -> "2.0.1"; + }; + assertThat(mockedOutputFiles) + .hasSize(2) + .hasEntrySatisfying( + getResourcesPath("revision", "single", "pom.xml"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + + + 4.0.0 + org.example.itests.revision.single + project + ${revision} + + + %s + + + """.formatted(expectedVersion) + ) + ) + .hasEntrySatisfying( + getResourcesPath("revision", "single", "CHANGELOG.md"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + # Changelog + + ## %s - 2025-01-01 + + ### Other + + Project version bumped as result of dependency bumps + + ## 2.0.0 - 2026-01-01 + + Initial release. + """.formatted(expectedVersion) + ) + ); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); + } + + @ParameterizedTest + @EnumSource(value = VersionBump.class, names = {"FILE_BASED"}, mode = EnumSource.Mode.EXCLUDE) + void fixedVersionBumpWithBackup_Valid(VersionBump versionBump) { + classUnderTest.versionBump = versionBump; + classUnderTest.backupFiles = true; + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + assertThat(testLog.getLogRecords()) + .hasSize(7) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.revision.single:project:2.0.0"), + validateLogRecordWarn("No versioning files found in %s as folder does not exists".formatted( + getResourcesPath("revision", "single", ".versioning") + )), + validateLogRecordInfo("Single project in scope"), + validateLogRecordInfo("Updating version with a %s semantic version".formatted(versionBump)), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("revision", "single", "CHANGELOG.md") + )), + validateLogRecordDebug("Original changelog"), + validateLogRecordDebug("Updated changelog") + ); + + String expectedVersion = switch (versionBump) { + case FILE_BASED -> throw new AssertionError("Should not be called"); + case MAJOR -> "3.0.0"; + case MINOR -> "2.1.0"; + case PATCH -> "2.0.1"; + }; + assertThat(mockedOutputFiles) + .hasSize(2) + .hasEntrySatisfying( + getResourcesPath("revision", "single", "pom.xml"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + + + 4.0.0 + org.example.itests.revision.single + project + ${revision} + + + %s + + + """.formatted(expectedVersion) + ) + ) + .hasEntrySatisfying( + getResourcesPath("revision", "single", "CHANGELOG.md"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + # Changelog + + ## %s - 2025-01-01 + + ### Other + + Project version bumped as result of dependency bumps + + ## 2.0.0 - 2026-01-01 + + Initial release. + """.formatted(expectedVersion) + ) + ); + assertThat(mockedCopiedFiles) + .isNotEmpty() + .hasSize(2) + .containsExactlyInAnyOrder( + new CopyPath( + getResourcesPath("revision", "single", "pom.xml"), + getResourcesPath("revision", "single", "pom.xml.backup"), + List.of( + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.COPY_ATTRIBUTES, + StandardCopyOption.REPLACE_EXISTING + ) + ), + new CopyPath( + getResourcesPath("revision", "single", "CHANGELOG.md"), + getResourcesPath("revision", "single", "CHANGELOG.md.backup"), + List.of( + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.COPY_ATTRIBUTES, + StandardCopyOption.REPLACE_EXISTING + ) + ) + ); + assertThat(mockedDeletedFiles) + .isEmpty(); + } + + @ParameterizedTest + @EnumSource(value = VersionBump.class, names = {"FILE_BASED"}, mode = EnumSource.Mode.EXCLUDE) + void fixedVersionBumpDryRun_Valid(VersionBump versionBump) { + classUnderTest.versionBump = versionBump; + classUnderTest.dryRun = true; + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + String expectedVersion = switch (versionBump) { + case FILE_BASED -> throw new AssertionError("Should not be called"); + case MAJOR -> "3.0.0"; + case MINOR -> "2.1.0"; + case PATCH -> "2.0.1"; + }; + + assertThat(testLog.getLogRecords()) + .hasSize(9) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.revision.single:project:2.0.0"), + validateLogRecordWarn("No versioning files found in %s as folder does not exists".formatted( + getResourcesPath("revision", "single", ".versioning") + )), + validateLogRecordInfo("Single project in scope"), + validateLogRecordInfo("Updating version with a %s semantic version".formatted(versionBump)), + validateLogRecordInfo(""" + Dry-run: new pom at %s: + + + 4.0.0 + org.example.itests.revision.single + project + ${revision} + + + %s + + \ + """.formatted(getResourcesPath("revision", "single", "pom.xml"), expectedVersion)), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("revision", "single", "CHANGELOG.md") + )), + validateLogRecordDebug("Original changelog"), + validateLogRecordDebug("Updated changelog"), + validateLogRecordInfo(""" + Dry-run: new markdown file at %s: + # Changelog + + ## %s - 2025-01-01 + + ### Other + + Project version bumped as result of dependency bumps + + ## 2.0.0 - 2026-01-01 + + Initial release. + """.formatted(getResourcesPath("revision", "single", "CHANGELOG.md"), expectedVersion)) + ); + + assertThat(mockedOutputFiles) + .isEmpty(); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); + } + + @ParameterizedTest + @EnumSource(value = VersionBump.class, names = {"FILE_BASED"}, mode = EnumSource.Mode.EXCLUDE) + void dryRunStringWriteCloseFailure_ThrowMojoExecutionException(VersionBump versionBump) { + classUnderTest.versionBump = versionBump; + classUnderTest.dryRun = true; + + IOException ioException = new IOException("Unable to open output stream for writing"); + try (MockedConstruction ignored = Mockito.mockConstruction( + StringWriter.class, + (mock, context) -> { + Mockito.doThrow(ioException).when(mock).close(); + Mockito.when(mock.toString()).thenReturn("Mock for StringWriter, hashCode: 0"); + } + )) { + assertThatThrownBy(classUnderTest::execute) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Unable to open output stream for writing") + .hasRootCause(ioException); + } + + assertThat(testLog.getLogRecords()) + .hasSize(5) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.revision.single:project:2.0.0"), + validateLogRecordWarn("No versioning files found in %s as folder does not exists".formatted( + getResourcesPath("revision", "single", ".versioning") + )), + validateLogRecordInfo("Single project in scope"), + validateLogRecordInfo("Updating version with a %s semantic version".formatted(versionBump)), + validateLogRecordInfo(""" + Dry-run: new pom at %s: + Mock for StringWriter, hashCode: 0\ + """.formatted(getResourcesPath("revision", "single", "pom.xml"))) + ); + + assertThat(mockedOutputFiles) + .isEmpty(); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); + } + + @Test + void filedBasedWalkFailed_ThrowMojoExecutionException() { + classUnderTest.versionBump = VersionBump.FILE_BASED; + classUnderTest.versionDirectory = getResourcesPath("versioning", "revision", "single", "unknown-project"); + filesMockedStatic.when(() -> Files.walk(Mockito.any(Path.class), Mockito.eq(1))) + .thenThrow(IOException.class); + + assertThatThrownBy(classUnderTest::execute) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Unable to read versioning folder") + .hasRootCauseInstanceOf(IOException.class); + + assertThat(testLog.getLogRecords()) + .hasSize(1) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.revision.single:project:2.0.0") + ); + + assertThat(mockedOutputFiles) + .isEmpty(); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); + } + + @Test + void unknownProjectFileBased_ThrowMojoFailureException() { + classUnderTest.versionBump = VersionBump.FILE_BASED; + classUnderTest.versionDirectory = getResourcesPath("versioning", "revision", "single", "unknown-project"); + + + assertThatThrownBy(classUnderTest::execute) + .isInstanceOf(MojoFailureException.class) + .hasMessage(""" + The following artifacts in the Markdown files are not present in the project scope: \ + org.example.itests.single:unknown-project\ + """); + + assertThat(testLog.getLogRecords()) + .hasSize(4) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.revision.single:project:2.0.0"), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("versioning", "revision", "single", "unknown-project", "versioning.md") + )), + validateLogRecordDebug(""" + YAML front matter: + 'org.example.itests.single:unknown-project': major\ + """), + validateLogRecordDebug(""" + Maven artifacts and semantic version bumps: + {org.example.itests.single:unknown-project=%s}\ + """.formatted(SemanticVersionBump.MAJOR)) + ); + + assertThat(mockedOutputFiles) + .isEmpty(); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); + } + + @Test + void noSemanticVersionBumpFileBased_NothingChanged() { + classUnderTest.versionBump = VersionBump.FILE_BASED; + classUnderTest.versionDirectory = getResourcesPath("versioning", "revision", "single", "none"); + + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + assertThat(testLog.getLogRecords()) + .hasSize(7) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.revision.single:project:2.0.0"), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("versioning", "revision", "single", "none", "versioning.md") + )), + validateLogRecordDebug(""" + YAML front matter: + 'org.example.itests.revision.single:project': none\ + """), + validateLogRecordDebug(""" + Maven artifacts and semantic version bumps: + {org.example.itests.revision.single:project=%s}\ + """.formatted(SemanticVersionBump.NONE)), + validateLogRecordInfo("Single project in scope"), + validateLogRecordInfo( + "Updating version with a %s semantic version".formatted(SemanticVersionBump.NONE) + ), + validateLogRecordInfo("No version update required") + ); + + assertThat(mockedOutputFiles) + .isEmpty(); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); + } + + @ParameterizedTest + @CsvSource({ + "major,Major,3.0.0", + "minor,Minor,2.1.0", + "patch,Patch,2.0.1" + }) + void singleSemanticVersionBumFile_Valid(String folder, String title, String expectedVersion) { + classUnderTest.versionBump = VersionBump.FILE_BASED; + classUnderTest.versionDirectory = getResourcesPath("versioning", "revision", "single", folder); + + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + SemanticVersionBump semanticVersionBump = SemanticVersionBump.fromString(folder); + assertThat(testLog.getLogRecords()) + .hasSize(9) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.revision.single:project:2.0.0"), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("versioning", "revision", "single", folder, "versioning.md") + )), + validateLogRecordDebug(""" + YAML front matter: + 'org.example.itests.revision.single:project': %s\ + """.formatted(folder)), + validateLogRecordDebug(""" + Maven artifacts and semantic version bumps: + {org.example.itests.revision.single:project=%s}\ + """.formatted(semanticVersionBump)), + validateLogRecordInfo("Single project in scope"), + validateLogRecordInfo( + "Updating version with a %s semantic version".formatted(semanticVersionBump) + ), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("revision", "single", "CHANGELOG.md") + )), + validateLogRecordDebug("Original changelog"), + validateLogRecordDebug("Updated changelog") + ); + + assertThat(mockedOutputFiles) + .hasSize(2) + .hasEntrySatisfying( + getResourcesPath("revision", "single", "pom.xml"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + + + 4.0.0 + org.example.itests.revision.single + project + ${revision} + + + %s + + + """.formatted(expectedVersion) + ) + ) + .hasEntrySatisfying( + getResourcesPath("revision", "single", "CHANGELOG.md"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + # Changelog + + ## %1$s - 2025-01-01 + + ### %2$s + + %2$s versioning applied. + + ## 2.0.0 - 2026-01-01 + + Initial release. + """.formatted(expectedVersion, title) + ) + ); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isNotEmpty() + .hasSize(1) + .containsExactlyInAnyOrder( + getResourcesPath("versioning", "revision", "single", folder, "versioning.md") + ); + } + + @Test + void multipleSemanticVersionBumpFiles_Valid() { + classUnderTest.versionBump = VersionBump.FILE_BASED; + classUnderTest.versionDirectory = getResourcesPath("versioning", "revision", "single", "multiple"); + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + assertThat(testLog.getLogRecords()) + .hasSize(18) + .satisfiesExactlyInAnyOrder( + validateLogRecordInfo("Execution for project: org.example.itests.revision.single:project:2.0.0"), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("versioning", "revision", "single", "multiple", "major.md") + )), + validateLogRecordDebug(""" + YAML front matter: + 'org.example.itests.revision.single:project': major\ + """), + validateLogRecordDebug(""" + Maven artifacts and semantic version bumps: + {org.example.itests.revision.single:project=%s}\ + """.formatted(SemanticVersionBump.MAJOR)), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("versioning", "revision", "single", "multiple", "minor.md") + )), + validateLogRecordDebug(""" + YAML front matter: + 'org.example.itests.revision.single:project': minor\ + """), + validateLogRecordDebug(""" + Maven artifacts and semantic version bumps: + {org.example.itests.revision.single:project=%s}\ + """.formatted(SemanticVersionBump.MINOR)), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("versioning", "revision", "single", "multiple", "none.md") + )), + validateLogRecordDebug(""" + YAML front matter: + 'org.example.itests.revision.single:project': none\ + """), + validateLogRecordDebug(""" + Maven artifacts and semantic version bumps: + {org.example.itests.revision.single:project=%s}\ + """.formatted(SemanticVersionBump.NONE)), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("versioning", "revision", "single", "multiple", "patch.md") + )), + validateLogRecordDebug(""" + YAML front matter: + 'org.example.itests.revision.single:project': patch\ + """), + validateLogRecordDebug(""" + Maven artifacts and semantic version bumps: + {org.example.itests.revision.single:project=%s}\ + """.formatted(SemanticVersionBump.PATCH)), + validateLogRecordInfo("Single project in scope"), + validateLogRecordInfo( + "Updating version with a %s semantic version".formatted(SemanticVersionBump.MAJOR) + ), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("revision", "single", "CHANGELOG.md") + )), + validateLogRecordDebug("Original changelog"), + validateLogRecordDebug("Updated changelog") + ); + + assertThat(mockedOutputFiles) + .hasSize(2) + .hasEntrySatisfying( + getResourcesPath("revision", "single", "pom.xml"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + + + 4.0.0 + org.example.itests.revision.single + project + ${revision} + + + 3.0.0 + + + """ + ) + ) + .hasEntrySatisfying( + getResourcesPath("revision", "single", "CHANGELOG.md"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + # Changelog + + ## 3.0.0 - 2025-01-01 + + ### Major + + Major versioning applied. + + ### Minor + + Minor versioning applied. + + ### Patch + + Patch versioning applied. + + ### Other + + No versioning applied. + + ## 2.0.0 - 2026-01-01 + + Initial release. + """ + ) + ); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isNotEmpty() + .hasSize(4) + .containsExactlyInAnyOrder( + getResourcesPath("versioning", "revision", "single", "multiple", "major.md"), + getResourcesPath("versioning", "revision", "single", "multiple", "minor.md"), + getResourcesPath("versioning", "revision", "single", "multiple", "patch.md"), + getResourcesPath("versioning", "revision", "single", "multiple", "none.md") + ); + } + } + + @Nested + class SingleProjectTest { + + @BeforeEach + void setUp() { + classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession( + getResourcesPath("single"), + Path.of(".") + ); + classUnderTest.modus = Modus.PROJECT_VERSION; + } + + @ParameterizedTest + @EnumSource(value = VersionBump.class, names = {"FILE_BASED"}, mode = EnumSource.Mode.EXCLUDE) + void fixedVersionBump_Valid(VersionBump versionBump) { + classUnderTest.versionBump = versionBump; + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + assertThat(testLog.getLogRecords()) + .hasSize(7) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.single:project:1.0.0"), + validateLogRecordWarn("No versioning files found in %s as folder does not exists".formatted( + getResourcesPath("single", ".versioning") + )), + validateLogRecordInfo("Single project in scope"), + validateLogRecordInfo("Updating version with a %s semantic version".formatted(versionBump)), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("single", "CHANGELOG.md") + )), + validateLogRecordDebug("Original changelog"), + validateLogRecordDebug("Updated changelog") + ); + + String expectedVersion = switch (versionBump) { + case FILE_BASED -> throw new AssertionError("Should not be called"); + case MAJOR -> "2.0.0"; + case MINOR -> "1.1.0"; + case PATCH -> "1.0.1"; + }; + assertThat(mockedOutputFiles) + .hasSize(2) + .hasEntrySatisfying( + getResourcesPath("single", "pom.xml"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + + + 4.0.0 + org.example.itests.single + project + %s + + """.formatted(expectedVersion) + ) + ) + .hasEntrySatisfying( + getResourcesPath("single", "CHANGELOG.md"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + # Changelog + + ## %s - 2025-01-01 + + ### Other + + Project version bumped as result of dependency bumps + + ## 1.0.0 - 2026-01-01 + + Initial release. + """.formatted(expectedVersion) + ) + ); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); + } + + @ParameterizedTest + @EnumSource(value = VersionBump.class, names = {"FILE_BASED"}, mode = EnumSource.Mode.EXCLUDE) + void fixedVersionBumpWithBackup_Valid(VersionBump versionBump) { + classUnderTest.versionBump = versionBump; + classUnderTest.backupFiles = true; + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + assertThat(testLog.getLogRecords()) + .hasSize(7) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.single:project:1.0.0"), + validateLogRecordWarn("No versioning files found in %s as folder does not exists".formatted( + getResourcesPath("single", ".versioning") + )), + validateLogRecordInfo("Single project in scope"), + validateLogRecordInfo("Updating version with a %s semantic version".formatted(versionBump)), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("single", "CHANGELOG.md") + )), + validateLogRecordDebug("Original changelog"), + validateLogRecordDebug("Updated changelog") + ); + + String expectedVersion = switch (versionBump) { + case FILE_BASED -> throw new AssertionError("Should not be called"); + case MAJOR -> "2.0.0"; + case MINOR -> "1.1.0"; + case PATCH -> "1.0.1"; + }; + assertThat(mockedOutputFiles) + .hasSize(2) + .hasEntrySatisfying( + getResourcesPath("single", "pom.xml"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + + + 4.0.0 + org.example.itests.single + project + %s + + """.formatted(expectedVersion) + ) + ) + .hasEntrySatisfying( + getResourcesPath("single", "CHANGELOG.md"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + # Changelog + + ## %s - 2025-01-01 + + ### Other + + Project version bumped as result of dependency bumps + + ## 1.0.0 - 2026-01-01 + + Initial release. + """.formatted(expectedVersion) + ) + ); + assertThat(mockedCopiedFiles) + .isNotEmpty() + .hasSize(2) + .containsExactlyInAnyOrder( + new CopyPath( + getResourcesPath("single", "pom.xml"), + getResourcesPath("single", "pom.xml.backup"), + List.of( + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.COPY_ATTRIBUTES, + StandardCopyOption.REPLACE_EXISTING + ) + ), + new CopyPath( + getResourcesPath("single", "CHANGELOG.md"), + getResourcesPath("single", "CHANGELOG.md.backup"), + List.of( + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.COPY_ATTRIBUTES, + StandardCopyOption.REPLACE_EXISTING + ) + ) + ); + assertThat(mockedDeletedFiles) + .isEmpty(); + } + + @ParameterizedTest + @EnumSource(value = VersionBump.class, names = {"FILE_BASED"}, mode = EnumSource.Mode.EXCLUDE) + void fixedVersionBumpDryRun_Valid(VersionBump versionBump) { + classUnderTest.versionBump = versionBump; + classUnderTest.dryRun = true; + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + String expectedVersion = switch (versionBump) { + case FILE_BASED -> throw new AssertionError("Should not be called"); + case MAJOR -> "2.0.0"; + case MINOR -> "1.1.0"; + case PATCH -> "1.0.1"; + }; + + assertThat(testLog.getLogRecords()) + .hasSize(9) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.single:project:1.0.0"), + validateLogRecordWarn("No versioning files found in %s as folder does not exists".formatted( + getResourcesPath("single", ".versioning") + )), + validateLogRecordInfo("Single project in scope"), + validateLogRecordInfo("Updating version with a %s semantic version".formatted(versionBump)), + validateLogRecordInfo(""" + Dry-run: new pom at %s: + + + 4.0.0 + org.example.itests.single + project + %s + \ + """.formatted(getResourcesPath("single", "pom.xml"), expectedVersion)), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("single", "CHANGELOG.md") + )), + validateLogRecordDebug("Original changelog"), + validateLogRecordDebug("Updated changelog"), + validateLogRecordInfo(""" + Dry-run: new markdown file at %s: + # Changelog + + ## %s - 2025-01-01 + + ### Other + + Project version bumped as result of dependency bumps + + ## 1.0.0 - 2026-01-01 + + Initial release. + """.formatted(getResourcesPath("single", "CHANGELOG.md"), expectedVersion)) + ); + + assertThat(mockedOutputFiles) + .isEmpty(); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); + } + + @ParameterizedTest + @EnumSource(value = VersionBump.class, names = {"FILE_BASED"}, mode = EnumSource.Mode.EXCLUDE) + void dryRunStringWriteCloseFailure_ThrowMojoExecutionException(VersionBump versionBump) { + classUnderTest.versionBump = versionBump; + classUnderTest.dryRun = true; + + IOException ioException = new IOException("Unable to open output stream for writing"); + try (MockedConstruction ignored = Mockito.mockConstruction( + StringWriter.class, + (mock, context) -> { + Mockito.doThrow(ioException).when(mock).close(); + Mockito.when(mock.toString()).thenReturn("Mock for StringWriter, hashCode: 0"); + } + )) { + assertThatThrownBy(classUnderTest::execute) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Unable to open output stream for writing") + .hasRootCause(ioException); + } + + assertThat(testLog.getLogRecords()) + .hasSize(5) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.single:project:1.0.0"), + validateLogRecordWarn("No versioning files found in %s as folder does not exists".formatted( + getResourcesPath("single", ".versioning") + )), + validateLogRecordInfo("Single project in scope"), + validateLogRecordInfo("Updating version with a %s semantic version".formatted(versionBump)), + validateLogRecordInfo(""" + Dry-run: new pom at %s: + Mock for StringWriter, hashCode: 0\ + """.formatted(getResourcesPath("single", "pom.xml"))) + ); + + assertThat(mockedOutputFiles) + .isEmpty(); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); + } + + @Test + void filedBasedWalkFailed_ThrowMojoExecutionException() { + classUnderTest.versionBump = VersionBump.FILE_BASED; + classUnderTest.versionDirectory = getResourcesPath("versioning", "single", "unknown-project"); + filesMockedStatic.when(() -> Files.walk(Mockito.any(Path.class), Mockito.eq(1))) + .thenThrow(IOException.class); + + assertThatThrownBy(classUnderTest::execute) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Unable to read versioning folder") + .hasRootCauseInstanceOf(IOException.class); + + assertThat(testLog.getLogRecords()) + .hasSize(1) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.single:project:1.0.0") + ); + + assertThat(mockedOutputFiles) + .isEmpty(); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); + } + + @Test + void unknownProjectFileBased_ThrowMojoFailureException() { + classUnderTest.versionBump = VersionBump.FILE_BASED; + classUnderTest.versionDirectory = getResourcesPath("versioning", "single", "unknown-project"); + + + assertThatThrownBy(classUnderTest::execute) + .isInstanceOf(MojoFailureException.class) + .hasMessage(""" + The following artifacts in the Markdown files are not present in the project scope: \ + org.example.itests.single:unknown-project\ + """); + + assertThat(testLog.getLogRecords()) + .hasSize(4) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.single:project:1.0.0"), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("versioning", "single", "unknown-project", "versioning.md") + )), + validateLogRecordDebug(""" + YAML front matter: + 'org.example.itests.single:unknown-project': major\ + """), + validateLogRecordDebug(""" + Maven artifacts and semantic version bumps: + {org.example.itests.single:unknown-project=%s}\ + """.formatted(SemanticVersionBump.MAJOR)) + ); + + assertThat(mockedOutputFiles) + .isEmpty(); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); + } + + @Test + void noSemanticVersionBumpFileBased_NothingChanged() { + classUnderTest.versionBump = VersionBump.FILE_BASED; + classUnderTest.versionDirectory = getResourcesPath("versioning", "single", "none"); + + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + assertThat(testLog.getLogRecords()) + .hasSize(7) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.single:project:1.0.0"), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("versioning", "single", "none", "versioning.md") + )), + validateLogRecordDebug(""" + YAML front matter: + 'org.example.itests.single:project': none\ + """), + validateLogRecordDebug(""" + Maven artifacts and semantic version bumps: + {org.example.itests.single:project=%s}\ + """.formatted(SemanticVersionBump.NONE)), + validateLogRecordInfo("Single project in scope"), + validateLogRecordInfo( + "Updating version with a %s semantic version".formatted(SemanticVersionBump.NONE) + ), + validateLogRecordInfo("No version update required") + ); + + assertThat(mockedOutputFiles) + .isEmpty(); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); + } + + @ParameterizedTest + @CsvSource({ + "major,Major,2.0.0", + "minor,Minor,1.1.0", + "patch,Patch,1.0.1" + }) + void singleSemanticVersionBumFile_Valid(String folder, String title, String expectedVersion) { + classUnderTest.versionBump = VersionBump.FILE_BASED; + classUnderTest.versionDirectory = getResourcesPath("versioning", "single", folder); + + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + SemanticVersionBump semanticVersionBump = SemanticVersionBump.fromString(folder); + assertThat(testLog.getLogRecords()) + .hasSize(9) + .satisfiesExactly( + validateLogRecordInfo("Execution for project: org.example.itests.single:project:1.0.0"), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("versioning", "single", folder, "versioning.md") + )), + validateLogRecordDebug(""" + YAML front matter: + 'org.example.itests.single:project': %s\ + """.formatted(folder)), + validateLogRecordDebug(""" + Maven artifacts and semantic version bumps: + {org.example.itests.single:project=%s}\ + """.formatted(semanticVersionBump)), + validateLogRecordInfo("Single project in scope"), + validateLogRecordInfo( + "Updating version with a %s semantic version".formatted(semanticVersionBump) + ), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("single", "CHANGELOG.md") + )), + validateLogRecordDebug("Original changelog"), + validateLogRecordDebug("Updated changelog") + ); + + assertThat(mockedOutputFiles) + .hasSize(2) + .hasEntrySatisfying( + getResourcesPath("single", "pom.xml"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + + + 4.0.0 + org.example.itests.single + project + %s + + """.formatted(expectedVersion) + ) + ) + .hasEntrySatisfying( + getResourcesPath("single", "CHANGELOG.md"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + # Changelog + + ## %1$s - 2025-01-01 + + ### %2$s + + %2$s versioning applied. + + ## 1.0.0 - 2026-01-01 + + Initial release. + """.formatted(expectedVersion, title) + ) + ); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isNotEmpty() + .hasSize(1) + .containsExactlyInAnyOrder( + getResourcesPath("versioning", "single", folder, "versioning.md") + ); + } + + @Test + void multipleSemanticVersionBumpFiles_Valid() { + classUnderTest.versionBump = VersionBump.FILE_BASED; + classUnderTest.versionDirectory = getResourcesPath("versioning", "single", "multiple"); + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + assertThat(testLog.getLogRecords()) + .hasSize(18) + .satisfiesExactlyInAnyOrder( + validateLogRecordInfo("Execution for project: org.example.itests.single:project:1.0.0"), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("versioning", "single", "multiple", "major.md") + )), + validateLogRecordDebug(""" + YAML front matter: + 'org.example.itests.single:project': major\ + """), + validateLogRecordDebug(""" + Maven artifacts and semantic version bumps: + {org.example.itests.single:project=%s}\ + """.formatted(SemanticVersionBump.MAJOR)), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("versioning", "single", "multiple", "minor.md") + )), + validateLogRecordDebug(""" + YAML front matter: + 'org.example.itests.single:project': minor\ + """), + validateLogRecordDebug(""" + Maven artifacts and semantic version bumps: + {org.example.itests.single:project=%s}\ + """.formatted(SemanticVersionBump.MINOR)), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("versioning", "single", "multiple", "none.md") + )), + validateLogRecordDebug(""" + YAML front matter: + 'org.example.itests.single:project': none\ + """), + validateLogRecordDebug(""" + Maven artifacts and semantic version bumps: + {org.example.itests.single:project=%s}\ + """.formatted(SemanticVersionBump.NONE)), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("versioning", "single", "multiple", "patch.md") + )), + validateLogRecordDebug(""" + YAML front matter: + 'org.example.itests.single:project': patch\ + """), + validateLogRecordDebug(""" + Maven artifacts and semantic version bumps: + {org.example.itests.single:project=%s}\ + """.formatted(SemanticVersionBump.PATCH)), + validateLogRecordInfo("Single project in scope"), + validateLogRecordInfo( + "Updating version with a %s semantic version".formatted(SemanticVersionBump.MAJOR) + ), + validateLogRecordInfo("Read 5 lines from %s".formatted( + getResourcesPath("single", "CHANGELOG.md") + )), + validateLogRecordDebug("Original changelog"), + validateLogRecordDebug("Updated changelog") + ); + + assertThat(mockedOutputFiles) + .hasSize(2) + .hasEntrySatisfying( + getResourcesPath("single", "pom.xml"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + + + 4.0.0 + org.example.itests.single + project + 2.0.0 + + """ + ) + ) + .hasEntrySatisfying( + getResourcesPath("single", "CHANGELOG.md"), + writer -> assertThat(writer.toString()) + .isEqualToIgnoringNewLines(""" + # Changelog + + ## 2.0.0 - 2025-01-01 + + ### Major + + Major versioning applied. + + ### Minor + + Minor versioning applied. + + ### Patch + + Patch versioning applied. + + ### Other + + No versioning applied. + + ## 1.0.0 - 2026-01-01 + + Initial release. + """ + ) + ); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isNotEmpty() + .hasSize(4) + .containsExactlyInAnyOrder( + getResourcesPath("versioning", "single", "multiple", "major.md"), + getResourcesPath("versioning", "single", "multiple", "minor.md"), + getResourcesPath("versioning", "single", "multiple", "patch.md"), + getResourcesPath("versioning", "single", "multiple", "none.md") + ); + } + } +} diff --git a/src/test/java/io/github/bsels/semantic/version/models/MarkdownMappingTest.java b/src/test/java/io/github/bsels/semantic/version/models/MarkdownMappingTest.java new file mode 100644 index 0000000..f34cfa7 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/models/MarkdownMappingTest.java @@ -0,0 +1,56 @@ +package io.github.bsels.semantic.version.models; + +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class MarkdownMappingTest { + + @Test + void versionBumpMapIsNull_ThrowsNullPointerException() { + assertThatThrownBy(() -> new MarkdownMapping(null, Map.of())) + .isInstanceOf(NullPointerException.class) + .hasMessage("`versionBumpMap` must not be null"); + } + + @Test + void markdownMapIsNull_ThrowsNullPointerException() { + assertThatThrownBy(() -> new MarkdownMapping(Map.of(), null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`markdownMap` must not be null"); + } + + @Test + void immutableMap_SameValue() { + Map versionBumpMap = Map.of(); + Map> markdownMap = Map.of(); + + MarkdownMapping mapping = new MarkdownMapping(versionBumpMap, markdownMap); + assertThat(mapping.versionBumpMap()) + .isSameAs(versionBumpMap); + assertThat(mapping.markdownMap()) + .isSameAs(markdownMap); + } + + @Test + void mutableMap_StoryAsImmutableCopy() { + Map versionBumpMap = new HashMap<>(); + Map> markdownMap = new HashMap<>(); + + MarkdownMapping mapping = new MarkdownMapping(versionBumpMap, markdownMap); + assertThat(mapping.versionBumpMap()) + .isNotSameAs(versionBumpMap); + assertThat(mapping.markdownMap()) + .isNotSameAs(markdownMap); + + assertThat(Map.copyOf(mapping.versionBumpMap())) + .isSameAs(mapping.versionBumpMap()); + assertThat(Map.copyOf(mapping.markdownMap())) + .isSameAs(mapping.markdownMap()); + } +} diff --git a/src/test/java/io/github/bsels/semantic/version/models/MavenArtifactTest.java b/src/test/java/io/github/bsels/semantic/version/models/MavenArtifactTest.java new file mode 100644 index 0000000..050ee3b --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/models/MavenArtifactTest.java @@ -0,0 +1,118 @@ +package io.github.bsels.semantic.version.models; + +import org.junit.jupiter.api.Nested; +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.EmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class MavenArtifactTest { + private static final String ARTIFACT_ID = "artifactId"; + private static final String GROUP_ID = "groupId"; + + @Nested + class ConstructorTest { + + @ParameterizedTest + @CsvSource(value = { + "null,null,groupId", + "null," + ARTIFACT_ID + ",groupId", + GROUP_ID + ",null,artifactId" + }, nullValues = "null") + void nullInput_ThrowsNullPointerException(String groupId, String artifact, String exceptionParameter) { + assertThatThrownBy(() -> new MavenArtifact(groupId, artifact)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`%s` must not be null", exceptionParameter); + } + + @Test + void validInputs_ReturnsCorrectArtifact() { + MavenArtifact artifact = new MavenArtifact(GROUP_ID, ARTIFACT_ID); + assertThat(artifact) + .isNotNull() + .hasFieldOrPropertyWithValue("groupId", GROUP_ID) + .hasFieldOrPropertyWithValue("artifactId", ARTIFACT_ID); + } + } + + @Nested + class OfTest { + + @Test + void nullInput_ThrowsNullPointerException() { + assertThatThrownBy(() -> MavenArtifact.of(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`colonSeparatedString` must not be null"); + } + + @ParameterizedTest + @EmptySource + @ValueSource(strings = {"data", "groupId:artifactId:version"}) + void invalidInput_ThrowsIllegalArgumentException(String colonSeparatedString) { + assertThatThrownBy(() -> MavenArtifact.of(colonSeparatedString)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid Maven artifact format: %s, expected :".formatted( + colonSeparatedString + )); + } + + @Test + void validInput_ReturnsCorrectArtifact() { + MavenArtifact artifact = MavenArtifact.of(GROUP_ID + ":" + ARTIFACT_ID); + assertThat(artifact) + .isNotNull() + .hasFieldOrPropertyWithValue("groupId", GROUP_ID) + .hasFieldOrPropertyWithValue("artifactId", ARTIFACT_ID); + } + } + + @Nested + class ToStringTest { + + @Test + void toString_ReturnsCorrectFormat() { + MavenArtifact artifact = new MavenArtifact(GROUP_ID, ARTIFACT_ID); + assertThat(artifact.toString()) + .isNotNull() + .isEqualTo("%s:%s".formatted(GROUP_ID, ARTIFACT_ID)); + } + } + + @Nested + class CompareToTest { + + @Test + void sameArtifact_ReturnsZero() { + MavenArtifact artifact1 = new MavenArtifact(GROUP_ID, ARTIFACT_ID); + MavenArtifact artifact2 = new MavenArtifact(GROUP_ID, ARTIFACT_ID); + assertThat(artifact1.compareTo(artifact2)) + .isEqualTo(0); + } + + @Test + void differentGroupId_ReturnCorrectValue() { + MavenArtifact artifact1 = new MavenArtifact(GROUP_ID, ARTIFACT_ID); + MavenArtifact artifact2 = new MavenArtifact("groupId2", ARTIFACT_ID); + + assertThat(artifact1.compareTo(artifact2)) + .isLessThan(0); + assertThat(artifact2.compareTo(artifact1)) + .isGreaterThan(0); + } + + @Test + void differentArtifactId_ReturnCorrectValue() { + MavenArtifact artifact1 = new MavenArtifact(GROUP_ID, ARTIFACT_ID); + MavenArtifact artifact2 = new MavenArtifact(GROUP_ID, "artifactId2"); + + assertThat(artifact1.compareTo(artifact2)) + .isLessThan(0); + assertThat(artifact2.compareTo(artifact1)) + .isGreaterThan(0); + } + } +} diff --git a/src/test/java/io/github/bsels/semantic/version/models/SemanticVersionBumpTest.java b/src/test/java/io/github/bsels/semantic/version/models/SemanticVersionBumpTest.java new file mode 100644 index 0000000..0b5fdad --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/models/SemanticVersionBumpTest.java @@ -0,0 +1,259 @@ +package io.github.bsels.semantic.version.models; + +import io.github.bsels.semantic.version.test.utils.ArrayArgumentConverter; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.converter.ConvertWith; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class SemanticVersionBumpTest { + + @Nested + class ArchitectureTest { + + @Test + void numberOfEnumElements_Return3() { + assertThat(SemanticVersionBump.values()) + .hasSize(4) + .extracting(SemanticVersionBump::name) + .containsExactlyInAnyOrder("MAJOR", "MINOR", "PATCH", "NONE"); + } + + @ParameterizedTest + @EnumSource(SemanticVersionBump.class) + void toString_ReturnsCorrectValue(SemanticVersionBump semanticVersionBump) { + assertThat(semanticVersionBump.toString()) + .isEqualTo(semanticVersionBump.name()); + + } + + @ParameterizedTest + @EnumSource(SemanticVersionBump.class) + void valueOf_ReturnCorrectValue(SemanticVersionBump semanticVersionBump) { + assertThat(SemanticVersionBump.valueOf(semanticVersionBump.toString())) + .isEqualTo(semanticVersionBump); + } + } + + @Nested + class FromStringTest { + + @Test + void nullInput_ReturnsNone() { + assertThat(SemanticVersionBump.fromString(null)) + .isEqualTo(SemanticVersionBump.NONE); + } + + @ParameterizedTest + @CsvSource({ + "major,MAJOR", + "Major,MAJOR", + "MAJOR,MAJOR", + "minor,MINOR", + "Minor,MINOR", + "MINOR,MINOR", + "patch,PATCH", + "Patch,PATCH", + "PATCH,PATCH", + "none,NONE", + "None,NONE", + "NONE,NONE" + }) + void validInput_ReturnsCorrectValue(String input, SemanticVersionBump expected) { + assertThat(SemanticVersionBump.fromString(input)) + .isEqualTo(expected); + } + + @Test + void invalidInput_ThrowsIllegalArgumentException() { + assertThatThrownBy(() -> SemanticVersionBump.fromString("unknown")) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Nested + class MaxArrayInputTest { + + @Test + void nullPointerArray_ThrowsNullPointerException() { + SemanticVersionBump[] array = null; + assertThatThrownBy(() -> SemanticVersionBump.max(array)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`bumps` must not be null"); + } + + @Test + void emptyArray_ReturnsNone() { + assertThat(SemanticVersionBump.max()) + .isEqualTo(SemanticVersionBump.NONE); + } + + @Test + void singleNullElementArray_ReturnsNone() { + SemanticVersionBump o = null; + assertThat(SemanticVersionBump.max(o)) + .isEqualTo(SemanticVersionBump.NONE); + } + + @ParameterizedTest + @CsvSource({ + "MAJOR,MAJOR", + "MINOR,MINOR", + "PATCH,PATCH", + "NONE,NONE", + "PATCH,NONE;PATCH", + "PATCH,PATCH;NONE", + "MINOR,NONE;MINOR", + "MINOR,MINOR;NONE", + "MINOR,PATCH;MINOR", + "MINOR,MINOR;PATCH", + "MAJOR,NONE;MAJOR", + "MAJOR,MAJOR;NONE", + "MAJOR,PATCH;MAJOR", + "MAJOR,MAJOR;PATCH", + "MAJOR,MINOR;MAJOR", + "MAJOR,MAJOR;MINOR", + "MINOR,NONE;PATCH;MINOR", + "MAJOR,NONE;PATCH;MAJOR", + "MAJOR,PATCH;MINOR;MAJOR", + "MAJOR,NONE;MINOR;MAJOR", + "MAJOR,NONE;PATCH;MINOR;MAJOR" + }) + void nonEmptyArray_ReturnsCorrectValue( + SemanticVersionBump expected, + @ConvertWith(ArrayArgumentConverter.class) + SemanticVersionBump... input + ) { + assertThat(SemanticVersionBump.max(input)) + .isEqualTo(expected); + } + } + + @Nested + class MaxCollectionInputTest { + + @Test + void nullPointerCollection_ThrowsNullPointerException() { + Collection collection = null; + assertThatThrownBy(() -> SemanticVersionBump.max(collection)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`bumps` must not be null"); + } + + @Test + void nullPointerList_ThrowsNullPointerException() { + List collection = null; + assertThatThrownBy(() -> SemanticVersionBump.max(collection)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`bumps` must not be null"); + } + + @Test + void nullPointerSet_ThrowsNullPointerException() { + Set collection = null; + assertThatThrownBy(() -> SemanticVersionBump.max(collection)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`bumps` must not be null"); + } + + @Test + void emptyList_ReturnsNone() { + assertThat(SemanticVersionBump.max(List.of())) + .isEqualTo(SemanticVersionBump.NONE); + } + + @Test + void emptySet_ReturnsNone() { + assertThat(SemanticVersionBump.max(Set.of())) + .isEqualTo(SemanticVersionBump.NONE); + } + + @Test + void singleNullElementList_ReturnsNone() { + assertThat(SemanticVersionBump.max(Collections.singletonList(null))) + .isEqualTo(SemanticVersionBump.NONE); + } + + @Test + void singleNullElementSet_ReturnsNone() { + assertThat(SemanticVersionBump.max(Collections.singleton(null))) + .isEqualTo(SemanticVersionBump.NONE); + } + + @ParameterizedTest + @CsvSource({ + "MAJOR,MAJOR", + "MINOR,MINOR", + "PATCH,PATCH", + "NONE,NONE", + "PATCH,NONE;PATCH", + "PATCH,PATCH;NONE", + "MINOR,NONE;MINOR", + "MINOR,MINOR;NONE", + "MINOR,PATCH;MINOR", + "MINOR,MINOR;PATCH", + "MAJOR,NONE;MAJOR", + "MAJOR,MAJOR;NONE", + "MAJOR,PATCH;MAJOR", + "MAJOR,MAJOR;PATCH", + "MAJOR,MINOR;MAJOR", + "MAJOR,MAJOR;MINOR", + "MINOR,NONE;PATCH;MINOR", + "MAJOR,NONE;PATCH;MAJOR", + "MAJOR,PATCH;MINOR;MAJOR", + "MAJOR,NONE;MINOR;MAJOR", + "MAJOR,NONE;PATCH;MINOR;MAJOR" + }) + void nonEmptyList_ReturnsCorrectValue( + SemanticVersionBump expected, + @ConvertWith(ArrayArgumentConverter.class) + List input + ) { + assertThat(SemanticVersionBump.max(input)) + .isEqualTo(expected); + } + + @ParameterizedTest + @CsvSource({ + "MAJOR,MAJOR", + "MINOR,MINOR", + "PATCH,PATCH", + "NONE,NONE", + "PATCH,NONE;PATCH", + "PATCH,PATCH;NONE", + "MINOR,NONE;MINOR", + "MINOR,MINOR;NONE", + "MINOR,PATCH;MINOR", + "MINOR,MINOR;PATCH", + "MAJOR,NONE;MAJOR", + "MAJOR,MAJOR;NONE", + "MAJOR,PATCH;MAJOR", + "MAJOR,MAJOR;PATCH", + "MAJOR,MINOR;MAJOR", + "MAJOR,MAJOR;MINOR", + "MINOR,NONE;PATCH;MINOR", + "MAJOR,NONE;PATCH;MAJOR", + "MAJOR,PATCH;MINOR;MAJOR", + "MAJOR,NONE;MINOR;MAJOR", + "MAJOR,NONE;PATCH;MINOR;MAJOR" + }) + void nonEmptySet_ReturnsCorrectValue( + SemanticVersionBump expected, + @ConvertWith(ArrayArgumentConverter.class) + Set input + ) { + assertThat(SemanticVersionBump.max(input)) + .isEqualTo(expected); + } + } +} diff --git a/src/test/java/io/github/bsels/semantic/version/models/SemanticVersionTest.java b/src/test/java/io/github/bsels/semantic/version/models/SemanticVersionTest.java new file mode 100644 index 0000000..6284661 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/models/SemanticVersionTest.java @@ -0,0 +1,274 @@ +package io.github.bsels.semantic.version.models; + +import org.junit.jupiter.api.Nested; +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.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class SemanticVersionTest { + + @Nested + class ConstructorTest { + + @ParameterizedTest + @CsvSource({ + "-1,-1,-1", + "-1,-1,0", + "-1,0,-1", + "0,-1,-1", + "0,0,-1", + "0,-1,0", + "-1,0,0" + }) + void invalidVersionNumbers_ThrowsIllegalArgumentException(int major, int minor, int patch) { + assertThatThrownBy(() -> new SemanticVersion(major, minor, patch, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Version parts must be non-negative"); + } + + @Test + void validVersionNumbersNullSuffix_ValidObject() { + SemanticVersion semanticVersion = new SemanticVersion(1, 2, 3, null); + assertThat(semanticVersion) + .hasFieldOrPropertyWithValue("major", 1) + .hasFieldOrPropertyWithValue("minor", 2) + .hasFieldOrPropertyWithValue("patch", 3) + .hasFieldOrPropertyWithValue("suffix", Optional.empty()); + } + + @ParameterizedTest + @NullAndEmptySource + void validVersionNumberNoSuffix_ValidObject(String suffix) { + SemanticVersion semanticVersion = new SemanticVersion(1, 2, 3, Optional.ofNullable(suffix)); + assertThat(semanticVersion) + .hasFieldOrPropertyWithValue("major", 1) + .hasFieldOrPropertyWithValue("minor", 2) + .hasFieldOrPropertyWithValue("patch", 3) + .hasFieldOrPropertyWithValue("suffix", Optional.empty()); + } + + @ParameterizedTest + @ValueSource(strings = {"-alpha?", "alpha-", "alpha.1", "alpha-1"}) + void invalidSuffix_ThrowsIllegalArgumentException(String suffix) { + assertThatThrownBy(() -> new SemanticVersion(1, 2, 3, Optional.of(suffix))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Suffix must be alphanumeric, dash, or dot, and should not start with a dash"); + } + + @ParameterizedTest + @ValueSource(strings = {"-alpha", "-ALPHA", "-Alpha.1", "-SNAPSHOT"}) + void validSuffix_ValidObject(String suffix) { + SemanticVersion semanticVersion = new SemanticVersion(1, 2, 3, Optional.of(suffix)); + assertThat(semanticVersion) + .hasFieldOrPropertyWithValue("major", 1) + .hasFieldOrPropertyWithValue("minor", 2) + .hasFieldOrPropertyWithValue("patch", 3) + .hasFieldOrPropertyWithValue("suffix", Optional.of(suffix)); + } + } + + @Nested + class BumpTest { + + @Test + void nullBump_ThrowsNullPointerException() { + SemanticVersion version = new SemanticVersion(1, 2, 3, Optional.empty()); + assertThatThrownBy(() -> version.bump(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`bump` must not be null"); + } + + @ParameterizedTest + @CsvSource({ + "1.2.3,MAJOR,2.0.0", + "1.2.3,MINOR,1.3.0", + "1.2.3,PATCH,1.2.4", + "1.2.3,NONE,1.2.3", + "1.2.3-alpha,MAJOR,2.0.0-alpha", + "1.2.3-alpha,MINOR,1.3.0-alpha", + "1.2.3-alpha,PATCH,1.2.4-alpha", + "1.2.3-alpha,NONE,1.2.3-alpha" + }) + void validBump_ReturnsNewObject(String oldVersion, SemanticVersionBump bump, String expectedNewVersion) { + SemanticVersion semanticVersion = SemanticVersion.of(oldVersion); + SemanticVersion expected = SemanticVersion.of(expectedNewVersion); + if (SemanticVersionBump.NONE.equals(bump)) { + assertThat(semanticVersion.bump(bump)) + .isSameAs(semanticVersion) + .isEqualTo(expected); + } else { + assertThat(semanticVersion.bump(bump)) + .isNotSameAs(semanticVersion) + .isEqualTo(expected); + } + } + } + + @Nested + class OfTest { + + @Test + void nullInput_ThrowsNullPointerException() { + assertThatThrownBy(() -> SemanticVersion.of(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`version` must not be null"); + } + + @ParameterizedTest + @ValueSource(strings = { + "1.2.3", + "0.0.0", + "10.20.30", + "999.999.999", + " 1.2.3 " + }) + void validVersionWithoutSuffix_ReturnsValidObject(String version) { + SemanticVersion semanticVersion = SemanticVersion.of(version); + assertThat(semanticVersion).isNotNull(); + assertThat(semanticVersion.suffix()).isEmpty(); + } + + @ParameterizedTest + @CsvSource({ + "1.2.3-SNAPSHOT,1,2,3", + "0.0.0-alpha,0,0,0", + "10.20.30-beta.1,10,20,30", + "1.0.0-rc.1,1,0,0", + "2.3.4-SNAPSHOT,2,3,4", + " 5.6.7-dev ,5,6,7" + }) + void validVersionWithSuffix_ReturnsValidObject(String input, int major, int minor, int patch) { + SemanticVersion semanticVersion = SemanticVersion.of(input); + assertThat(semanticVersion) + .hasFieldOrPropertyWithValue("major", major) + .hasFieldOrPropertyWithValue("minor", minor) + .hasFieldOrPropertyWithValue("patch", patch); + assertThat(semanticVersion.suffix()).isPresent(); + } + + @ParameterizedTest + @ValueSource(strings = { + "", + " ", + "1", + "1.2", + "1.2.3.4", + "a.b.c", + "1.2.a", + "1.a.3", + "a.2.3", + "1.2.3-", + "1.2.3-suffix with spaces", + "v1.2.3", + "1.2.3-suffix!", + "-1.2.3", + "1.-2.3", + "1.2.-3", + "1..3", + ".1.2.3", + "1.2.3." + }) + void invalidVersionFormat_ThrowsIllegalArgumentException(String version) { + assertThatThrownBy(() -> SemanticVersion.of(version)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid semantic version format"); + } + } + + @Nested + class StripSuffixTest { + + @Test + void nullSuffix_ReturnsSameObject() { + SemanticVersion version = new SemanticVersion(1, 2, 3, Optional.empty()); + assertThat(version.stripSuffix()) + .isSameAs(version); + } + + @Test + void nonNullSuffix_ReturnsNewObject() { + SemanticVersion version = new SemanticVersion(1, 2, 3, Optional.of("-alpha")); + assertThat(version.stripSuffix()) + .isNotSameAs(version) + .hasFieldOrPropertyWithValue("suffix", Optional.empty()) + .hasFieldOrPropertyWithValue("major", 1) + .hasFieldOrPropertyWithValue("minor", 2) + .hasFieldOrPropertyWithValue("patch", 3); + } + } + + @Nested + class ToStringTest { + + @Test + void withSuffix_ReturnsCorrectFormat() { + SemanticVersion version = new SemanticVersion(1, 2, 3, Optional.of("-alpha")); + assertThat(version.toString()) + .isEqualTo("1.2.3-alpha"); + } + + @Test + void withoutSuffix_ReturnsCorrectFormat() { + SemanticVersion version = new SemanticVersion(1, 2, 3, Optional.empty()); + assertThat(version.toString()) + .isEqualTo("1.2.3"); + } + } + + @Nested + class WithSuffixTest { + + @Test + void nullSuffixInput_ThrowsNullPointerException() { + SemanticVersion version = new SemanticVersion(1, 2, 3, Optional.empty()); + assertThatThrownBy(() -> version.withSuffix(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`suffix` must not be null"); + } + + @ParameterizedTest + @ValueSource(strings = {"-alpha?", "alpha-", "alpha.1", "alpha-1"}) + void invalidSuffix_ThrowsIllegalArgumentException(String suffix) { + SemanticVersion version = new SemanticVersion(1, 2, 3, Optional.empty()); + assertThatThrownBy(() -> version.withSuffix(suffix)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Suffix must be alphanumeric, dash, or dot, and should not start with a dash"); + } + + @Test + void withoutSuffix_SuffixAdded() { + SemanticVersion version = new SemanticVersion(1, 2, 3, Optional.empty()); + assertThat(version.withSuffix("-alpha")) + .isNotSameAs(version) + .hasFieldOrPropertyWithValue("suffix", Optional.of("-alpha")) + .hasFieldOrPropertyWithValue("major", 1) + .hasFieldOrPropertyWithValue("minor", 2) + .hasFieldOrPropertyWithValue("patch", 3); + } + + @Test + void withOtherSuffix_SuffixReplaced() { + SemanticVersion version = new SemanticVersion(1, 2, 3, Optional.of("-alpha")); + assertThat(version.withSuffix("-beta")) + .isNotSameAs(version) + .hasFieldOrPropertyWithValue("suffix", Optional.of("-beta")) + .hasFieldOrPropertyWithValue("major", 1) + .hasFieldOrPropertyWithValue("minor", 2) + .hasFieldOrPropertyWithValue("patch", 3); + } + + @Test + void withSameSuffix_ReturnsSameObject() { + SemanticVersion version = new SemanticVersion(1, 2, 3, Optional.of("-alpha")); + assertThat(version.withSuffix("-alpha")) + .isSameAs(version); + } + } +} diff --git a/src/test/java/io/github/bsels/semantic/version/models/VersionChangeTest.java b/src/test/java/io/github/bsels/semantic/version/models/VersionChangeTest.java new file mode 100644 index 0000000..1fe08b3 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/models/VersionChangeTest.java @@ -0,0 +1,31 @@ +package io.github.bsels.semantic.version.models; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class VersionChangeTest { + + @ParameterizedTest + @CsvSource(value = { + "null,null,oldVersion", + "null,1.0.0,oldVersion", + "1.0.0,null,newVersion" + }, nullValues = "null") + void nullInput_ThrowsNullPointerException(String oldVersion, String newVersion, String exceptionParameter) { + assertThatThrownBy(() -> new VersionChange(oldVersion, newVersion)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`%s` must not be null", exceptionParameter); + } + + @Test + void validConstruction_ReturnProvidedInputs() { + VersionChange versionChange = new VersionChange("1.0.0", "2.0.0"); + assertThat(versionChange) + .hasFieldOrPropertyWithValue("oldVersion", "1.0.0") + .hasFieldOrPropertyWithValue("newVersion", "2.0.0"); + } +} diff --git a/src/test/java/io/github/bsels/semantic/version/models/VersionMarkdownTest.java b/src/test/java/io/github/bsels/semantic/version/models/VersionMarkdownTest.java new file mode 100644 index 0000000..55333ce --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/models/VersionMarkdownTest.java @@ -0,0 +1,66 @@ +package io.github.bsels.semantic.version.models; + +import org.commonmark.node.Document; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class VersionMarkdownTest { + public static final Document CONTENT = new Document(); + private static final MavenArtifact MAVEN_ARTIFACT = new MavenArtifact("groupId", "artifactId"); + + @Test + void nullNode_ThrowsNullPointerException() { + assertThatThrownBy(() -> new VersionMarkdown(null, null, Map.of(MAVEN_ARTIFACT, SemanticVersionBump.NONE))) + .isInstanceOf(NullPointerException.class) + .hasMessage("`content` must not be null"); + } + + @Test + void nullBumps_ThrowsNullPointerException() { + assertThatThrownBy(() -> new VersionMarkdown(null, CONTENT, null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`bumps` must not be null"); + } + + @Test + void emptyBumps_ThrowsIllegalArgumentException() { + assertThatThrownBy(() -> new VersionMarkdown(null, CONTENT, Map.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("`bumps` must not be empty"); + } + + @Test + void mutableMapInput_MakeImmutable() { + Map bumps = new HashMap<>(); + bumps.put(MAVEN_ARTIFACT, SemanticVersionBump.NONE); + + VersionMarkdown markdown = new VersionMarkdown(null, CONTENT, bumps); + assertThat(markdown) + .hasFieldOrPropertyWithValue("content", CONTENT) + .hasFieldOrPropertyWithValue("bumps", bumps) + .satisfies( + m -> assertThat(m.bumps()) + .isNotSameAs(bumps) + .isSameAs(Map.copyOf(m.bumps())) + ); + } + + @Test + void immutableMapInput_KeepsImmutable() { + Map bumps = Map.of(MAVEN_ARTIFACT, SemanticVersionBump.NONE); + VersionMarkdown markdown = new VersionMarkdown(null, CONTENT, bumps); + assertThat(markdown) + .hasFieldOrPropertyWithValue("content", CONTENT) + .hasFieldOrPropertyWithValue("bumps", bumps) + .satisfies( + m -> assertThat(m.bumps()) + .isSameAs(bumps) + .isSameAs(Map.copyOf(m.bumps())) + ); + } +} diff --git a/src/test/java/io/github/bsels/semantic/version/parameters/ModusTest.java b/src/test/java/io/github/bsels/semantic/version/parameters/ModusTest.java new file mode 100644 index 0000000..1469b05 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/parameters/ModusTest.java @@ -0,0 +1,33 @@ +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.EnumSource; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ModusTest { + + @Test + void numberOfEnumElements_Return3() { + assertThat(Modus.values()) + .hasSize(3) + .extracting(Modus::name) + .containsExactlyInAnyOrder("PROJECT_VERSION", "REVISION_PROPERTY", "PROJECT_VERSION_ONLY_LEAFS"); + } + + @ParameterizedTest + @EnumSource(Modus.class) + void toString_ReturnsCorrectValue(Modus modus) { + assertThat(modus.toString()) + .isEqualTo(modus.name()); + + } + + @ParameterizedTest + @EnumSource(Modus.class) + void valueOf_ReturnCorrectValue(Modus modus) { + assertThat(Modus.valueOf(modus.toString())) + .isEqualTo(modus); + } +} diff --git a/src/test/java/io/github/bsels/semantic/version/parameters/VersionBumpTest.java b/src/test/java/io/github/bsels/semantic/version/parameters/VersionBumpTest.java new file mode 100644 index 0000000..0791d15 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/parameters/VersionBumpTest.java @@ -0,0 +1,33 @@ +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.EnumSource; + +import static org.assertj.core.api.Assertions.assertThat; + +public class VersionBumpTest { + + @Test + void numberOfEnumElements_Return3() { + assertThat(VersionBump.values()) + .hasSize(4) + .extracting(VersionBump::name) + .containsExactlyInAnyOrder("FILE_BASED", "MAJOR", "MINOR", "PATCH"); + } + + @ParameterizedTest + @EnumSource(VersionBump.class) + void toString_ReturnsCorrectValue(VersionBump versionBump) { + assertThat(versionBump.toString()) + .isEqualTo(versionBump.name()); + + } + + @ParameterizedTest + @EnumSource(VersionBump.class) + void valueOf_ReturnCorrectValue(VersionBump versionBump) { + assertThat(VersionBump.valueOf(versionBump.toString())) + .isEqualTo(versionBump); + } +} diff --git a/src/test/java/io/github/bsels/semantic/version/test/utils/ArrayArgumentConverter.java b/src/test/java/io/github/bsels/semantic/version/test/utils/ArrayArgumentConverter.java new file mode 100644 index 0000000..c48b516 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/test/utils/ArrayArgumentConverter.java @@ -0,0 +1,80 @@ +package io.github.bsels.semantic.version.test.utils; + +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.params.converter.ArgumentConversionException; +import org.junit.jupiter.params.converter.ArgumentConverter; +import org.junit.jupiter.params.converter.DefaultArgumentConverter; + +import java.lang.reflect.Array; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Collector; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class ArrayArgumentConverter implements ArgumentConverter { + private static final Pattern REGEX = Pattern.compile(";"); + + private static Stream getObjectStream(String string, Class componentType) { + return REGEX.splitAsStream(string) + .map(data -> DefaultArgumentConverter.INSTANCE.convert( + data, + componentType, + Thread.currentThread().getContextClassLoader() + )); + } + + @Override + public Object convert(Object source, ParameterContext context) + throws ArgumentConversionException { + if (source == null) { + return null; + } + if (!(source instanceof String string)) { + throw new ArgumentConversionException("Cannot convert a non string '%s' to array/collection".formatted(source)); + } + Type type = context.getParameter() + .getParameterizedType(); + if (type instanceof Class clazz) { + if (clazz.isArray()) { + Class componentType = clazz.getComponentType(); + List list = getObjectStream(string, componentType) + .toList(); + Object array = Array.newInstance(componentType, list.size()); + for (int i = 0; i < list.size(); i++) { + Array.set(array, i, list.get(i)); + } + return array; + } else { + return DefaultArgumentConverter.INSTANCE.convert(string, context); + } + } else if (type instanceof ParameterizedType parameterizedType) { + Class rawType = (Class) parameterizedType.getRawType(); + if (Collection.class.isAssignableFrom(rawType)) { + Collector> collector; + if (List.class.isAssignableFrom(rawType) || Collection.class.equals(rawType)) { + collector = Collectors.toList(); + } else if (Set.class.isAssignableFrom(rawType)) { + collector = Collectors.toSet(); + } else { + throw new IllegalStateException("Unsupported collection type '%s'".formatted(rawType)); + } + Type[] typeArguments = parameterizedType.getActualTypeArguments(); + if (typeArguments.length != 1) { + throw new IllegalStateException("Unsupported collection type '%s'".formatted(rawType)); + } + if (typeArguments[0] instanceof Class componentType) { + return getObjectStream(string, componentType) + .collect(collector); + } else { + throw new IllegalStateException("Unsupported collection element type '%s'".formatted(typeArguments[0])); + } + } + } + throw new ArgumentConversionException("Cannot convert '%s' to array".formatted(source)); + } +} diff --git a/src/test/java/io/github/bsels/semantic/version/test/utils/MarkdownDocumentAsserter.java b/src/test/java/io/github/bsels/semantic/version/test/utils/MarkdownDocumentAsserter.java new file mode 100644 index 0000000..8fb6c79 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/test/utils/MarkdownDocumentAsserter.java @@ -0,0 +1,70 @@ +package io.github.bsels.semantic.version.test.utils; + +import org.assertj.core.api.AbstractObjectAssert; +import org.commonmark.node.Document; +import org.commonmark.node.Heading; +import org.commonmark.node.Node; +import org.commonmark.node.Paragraph; +import org.commonmark.node.Text; +import org.commonmark.node.ThematicBreak; + +import java.util.function.UnaryOperator; + +import static org.assertj.core.api.Assertions.assertThat; + +public class MarkdownDocumentAsserter { + private MarkdownDocumentAsserter() { + // No instances allowed + } + + public static UnaryOperator> hasHeading( + int level, + String literal + ) { + return objectAssert -> objectAssert.isInstanceOf(Heading.class) + .hasFieldOrPropertyWithValue("level", level) + .satisfies(heading -> assertThat(heading.getFirstChild()) + .isNotNull() + .isInstanceOf(Text.class) + .hasFieldOrPropertyWithValue("literal", literal) + .extracting(Node::getNext) + .isNull() + ) + .extracting(Node::getNext); + } + + public static UnaryOperator> hasParagraph( + String literal + ) { + return objectAssert -> objectAssert.isInstanceOf(Paragraph.class) + .satisfies(paragraph -> assertThat(paragraph.getFirstChild()) + .isNotNull() + .isInstanceOf(Text.class) + .hasFieldOrPropertyWithValue("literal", literal) + .extracting(Node::getNext) + .isNull() + ) + .extracting(Node::getNext); + } + + public static UnaryOperator> hasThematicBreak() { + return objectAssert -> objectAssert.isInstanceOf(ThematicBreak.class) + .hasFieldOrPropertyWithValue("firstChild", null) + .extracting(Node::getNext); + } + + @SafeVarargs + public static void assertThatDocument( + Node document, + UnaryOperator>... asserts + ) { + AbstractObjectAssert asserter = assertThat(document) + .isNotNull() + .isInstanceOf(Document.class) + .extracting(Node::getFirstChild); + for (UnaryOperator> assertFunction : asserts) { + asserter = assertFunction.apply(asserter); + } + asserter.isNull(); + } +} diff --git a/src/test/java/io/github/bsels/semantic/version/test/utils/ReadMockedMavenSession.java b/src/test/java/io/github/bsels/semantic/version/test/utils/ReadMockedMavenSession.java new file mode 100644 index 0000000..c0574d5 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/test/utils/ReadMockedMavenSession.java @@ -0,0 +1,250 @@ +package io.github.bsels.semantic.version.test.utils; + +import org.apache.maven.execution.BuildSummary; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.project.DependencyResolutionResult; +import org.apache.maven.project.MavenProject; +import org.mockito.Mockito; +import org.w3c.dom.Document; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +public class ReadMockedMavenSession { + private static final String PROJECT = "project"; + private static final String GROUP_ID = "groupId"; + private static final String PARENT = "parent"; + private static final String ARTIFACT_ID = "artifactId"; + private static final String VERSION = "version"; + private static final String PROPERTIES = "properties"; + private static final String REVISION = "revision"; + private static final Path POM_FILE = Path.of("pom.xml"); + private static final String $_REVISION = "${revision}"; + private static final String MODULE = "module"; + private static final String MODULES = "modules"; + private static final DocumentBuilder DOCUMENT_BUILDER = getDocumentBuilder(); + + private ReadMockedMavenSession() { + // No instance needed + } + + public static MavenSession readMockedMavenSession(Path projectRoot, Path currentModule) { + Map projects = readMavenProjectsAsMap(projectRoot); + Path normalizeCurrentModule = projectRoot.resolve(currentModule).normalize(); + List sortedProjects = projects.entrySet() + .stream() + .sorted(Map.Entry.comparingByKey()) + .filter(entry -> entry.getKey().startsWith(normalizeCurrentModule)) + .map(Map.Entry::getValue) + .toList(); + + return readMockedMavenSession(projects, projectRoot, currentModule, sortedProjects); + } + + public static MavenSession readMockedMavenSessionNoTopologicalSortedProjects( + Path projectRoot, + Path currentModule + ) { + Map projects = readMavenProjectsAsMap(projectRoot); + return readMockedMavenSession(projects, projectRoot, currentModule, List.of()); + } + + private static MavenSession readMockedMavenSession( + Map projects, + Path projectRoot, + Path currentModule, + List topologicallySortedProjects + ) { + MavenSession session = Mockito.mock(MavenSession.class); + + Mockito.lenient() + .when(session.getExecutionRootDirectory()) + .thenReturn(projectRoot.toAbsolutePath().toString()); + Path normalizeCurrentModule = projectRoot.resolve(currentModule).normalize(); + Mockito.lenient() + .when(session.getCurrentProject()) + .thenReturn(projects.get(normalizeCurrentModule)); + Mockito.lenient() + .when(session.getTopLevelProject()) + .thenReturn(projects.get(projectRoot.resolve(".").normalize())); + + List sortedProjects = projects.entrySet() + .stream() + .sorted(Map.Entry.comparingByKey()) + .filter(entry -> entry.getKey().startsWith(normalizeCurrentModule)) + .map(Map.Entry::getValue) + .toList(); + + Mockito.lenient() + .when(session.getResult()) + .thenReturn(new MavenExecutionResultMock(topologicallySortedProjects)); + return session; + } + + private static Map readMavenProjectsAsMap(Path projectRoot) { + return readMavenProjects(projectRoot) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + private static Stream> readMavenProjects(Path path) { + Path pomFile = path.resolve(POM_FILE).toAbsolutePath(); + MavenProject mavenProject = new MavenProject(); + mavenProject.setFile(pomFile.toFile()); + + Document pom = readPom(pomFile); + String revision = walk(pom, List.of(PROJECT, PROPERTIES, REVISION), 0) + .map(Node::getTextContent) + .orElse($_REVISION); + + String groupId = walk(pom, List.of(PROJECT, GROUP_ID), 0) + .or(() -> walk(pom, List.of(PROJECT, PARENT, GROUP_ID), 0)) + .map(Node::getTextContent) + .orElseThrow(); + String artifactId = walk(pom, List.of(PROJECT, ARTIFACT_ID), 0) + .map(Node::getTextContent) + .orElseThrow(); + String version = walk(pom, List.of(PROJECT, VERSION), 0) + .or(() -> walk(pom, List.of(PROJECT, PARENT, VERSION), 0)) + .map(Node::getTextContent) + .map(text -> $_REVISION.equals(text) ? revision : text) + .orElseThrow(); + + mavenProject.setGroupId(groupId); + mavenProject.setArtifactId(artifactId); + mavenProject.setVersion(version); + + Optional modules = walk(pom, List.of(PROJECT, MODULES), 0); + Stream> currentProject = Stream.of(Map.entry(path.normalize(), mavenProject)); + if (modules.isPresent()) { + NodeList nodeList = modules.get().getChildNodes(); + List modulesString = IntStream.range(0, nodeList.getLength()) + .mapToObj(nodeList::item) + .filter(node -> MODULE.equals(node.getNodeName())) + .map(Node::getTextContent) + .toList(); + mavenProject.getModules().addAll(modulesString); + return Stream.concat( + currentProject, + modulesString.stream() + .map(path::resolve) + .flatMap(ReadMockedMavenSession::readMavenProjects) + ); + } else { + return currentProject; + } + } + + private static Optional walk(Node parent, List path, int currentElementIndex) throws IllegalStateException { + if (currentElementIndex == path.size()) { + return Optional.of(parent); + } + String currentElementName = path.get(currentElementIndex); + NodeList childNodes = parent.getChildNodes(); + return IntStream.range(0, childNodes.getLength()) + .mapToObj(childNodes::item) + .filter(child -> currentElementName.equals(child.getNodeName())) + .findFirst() + .flatMap(child -> walk(child, path, currentElementIndex + 1)); + } + + public static Document readPom(Path pomFile) { + try (InputStream inputStream = Files.newInputStream(pomFile)) { + return DOCUMENT_BUILDER.parse(inputStream); + } catch (IOException | SAXException e) { + throw new RuntimeException(e); + } + } + + private static DocumentBuilder getDocumentBuilder() { + DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); + documentBuilderFactory.setNamespaceAware(true); + documentBuilderFactory.setIgnoringElementContentWhitespace(false); + documentBuilderFactory.setIgnoringComments(false); + try { + return documentBuilderFactory.newDocumentBuilder(); + } catch (ParserConfigurationException e) { + throw new RuntimeException(e); + } + } + + private record MavenExecutionResultMock(List topologicallySortedProjects) + implements MavenExecutionResult { + + public MavenExecutionResultMock { + Objects.requireNonNull(topologicallySortedProjects, "`topologicallySortedProjects` must not be null"); + topologicallySortedProjects.forEach(Objects::requireNonNull); + topologicallySortedProjects = List.copyOf(topologicallySortedProjects); + } + + @Override + public MavenExecutionResult setProject(MavenProject project) { + throw new UnsupportedOperationException(); + } + + @Override + public MavenProject getProject() { + throw new UnsupportedOperationException(); + } + + @Override + public MavenExecutionResult setTopologicallySortedProjects(List projects) { + throw new UnsupportedOperationException(); + } + + @Override + public List getTopologicallySortedProjects() { + return topologicallySortedProjects(); + } + + @Override + public MavenExecutionResult setDependencyResolutionResult(DependencyResolutionResult result) { + throw new UnsupportedOperationException(); + } + + @Override + public DependencyResolutionResult getDependencyResolutionResult() { + throw new UnsupportedOperationException(); + } + + @Override + public List getExceptions() { + throw new UnsupportedOperationException(); + } + + @Override + public MavenExecutionResult addException(Throwable e) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean hasExceptions() { + throw new UnsupportedOperationException(); + } + + @Override + public BuildSummary getBuildSummary(MavenProject project) { + throw new UnsupportedOperationException(); + } + + @Override + public void addBuildSummary(BuildSummary summary) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/src/test/java/io/github/bsels/semantic/version/test/utils/TestLog.java b/src/test/java/io/github/bsels/semantic/version/test/utils/TestLog.java new file mode 100644 index 0000000..2558432 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/test/utils/TestLog.java @@ -0,0 +1,146 @@ +package io.github.bsels.semantic.version.test.utils; + +import org.apache.maven.plugin.logging.Log; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public final class TestLog implements Log { + + private final List records; + private final LogLevel minimalLogLevel; + private final List logRecords; + + public TestLog(LogLevel minimalLogLevel) { + this.records = new ArrayList<>(); + this.logRecords = Collections.unmodifiableList(this.records); + this.minimalLogLevel = Objects.requireNonNull(minimalLogLevel, "`minimalLogLevel` must not be null"); + } + + @Override + public boolean isDebugEnabled() { + return LogLevel.DEBUG.compareTo(minimalLogLevel) == 0; + } + + @Override + public void debug(CharSequence charSequence) { + records.add(new LogRecord(LogLevel.DEBUG, charSequence)); + } + + @Override + public void debug(CharSequence charSequence, Throwable throwable) { + records.add(new LogRecord(LogLevel.DEBUG, charSequence, throwable)); + } + + @Override + public void debug(Throwable throwable) { + records.add(new LogRecord(LogLevel.DEBUG, throwable)); + } + + @Override + public boolean isInfoEnabled() { + return LogLevel.INFO.compareTo(minimalLogLevel) >= 0; + } + + @Override + public void info(CharSequence charSequence) { + records.add(new LogRecord(LogLevel.INFO, charSequence)); + } + + @Override + public void info(CharSequence charSequence, Throwable throwable) { + records.add(new LogRecord(LogLevel.INFO, charSequence, throwable)); + } + + @Override + public void info(Throwable throwable) { + records.add(new LogRecord(LogLevel.INFO, throwable)); + } + + @Override + public boolean isWarnEnabled() { + return LogLevel.WARN.compareTo(minimalLogLevel) >= 0; + } + + @Override + public void warn(CharSequence charSequence) { + records.add(new LogRecord(LogLevel.WARN, charSequence)); + } + + @Override + public void warn(CharSequence charSequence, Throwable throwable) { + records.add(new LogRecord(LogLevel.WARN, charSequence, throwable)); + } + + @Override + public void warn(Throwable throwable) { + records.add(new LogRecord(LogLevel.WARN, throwable)); + } + + @Override + public boolean isErrorEnabled() { + return LogLevel.ERROR.compareTo(minimalLogLevel) >= 0; + } + + @Override + public void error(CharSequence charSequence) { + records.add(new LogRecord(LogLevel.ERROR, charSequence)); + } + + @Override + public void error(CharSequence charSequence, Throwable throwable) { + records.add(new LogRecord(LogLevel.ERROR, charSequence, throwable)); + } + + @Override + public void error(Throwable throwable) { + records.add(new LogRecord(LogLevel.ERROR, throwable)); + } + + public List getLogRecords() { + return logRecords; + } + + public void clear() { + records.clear(); + } + + public enum LogLevel { + DEBUG, INFO, WARN, ERROR, NONE + } + + public record LogRecord(LogLevel level, Optional message, Optional throwable) { + public LogRecord { + Objects.requireNonNull(level, "`level` must not be null"); + Objects.requireNonNull(message, "`message` must not be null"); + Objects.requireNonNull(throwable, "`throwable` must not be null"); + } + + public LogRecord(LogLevel level, CharSequence message) { + this( + level, + Optional.of(Objects.requireNonNull(message, "`message` must not be null").toString()), + Optional.empty() + ); + } + + public LogRecord(LogLevel level, Throwable throwable) { + this( + level, + Optional.empty(), + Optional.of(Objects.requireNonNull(throwable, "`throwable` must not be null")) + ); + } + + public LogRecord(LogLevel level, CharSequence message, Throwable throwable) { + this( + level, + Optional.of(Objects.requireNonNull(message, "`message` must not be null").toString()), + Optional.of(Objects.requireNonNull(throwable, "`throwable` must not be null")) + ); + } + } +} 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 new file mode 100644 index 0000000..a05a720 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java @@ -0,0 +1,871 @@ +package io.github.bsels.semantic.version.utils; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonMappingException; +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.test.utils.TestLog; +import io.github.bsels.semantic.version.utils.yaml.front.block.YamlFrontMatterBlock; +import org.apache.maven.plugin.MojoExecutionException; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.commonmark.node.Document; +import org.commonmark.node.Heading; +import org.commonmark.node.Node; +import org.commonmark.node.Paragraph; +import org.commonmark.node.Text; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import static io.github.bsels.semantic.version.test.utils.MarkdownDocumentAsserter.assertThatDocument; +import static io.github.bsels.semantic.version.test.utils.MarkdownDocumentAsserter.hasHeading; +import static io.github.bsels.semantic.version.test.utils.MarkdownDocumentAsserter.hasParagraph; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class MarkdownUtilsTest { + private static final String ARTIFACT_ID = "artifactId"; + private static final String GROUP_ID = "groupId"; + 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 LocalDate DATE = LocalDate.of(2025, 1, 1); + private static final String CHANGE_LINE = "Version bumped with a %s semantic version at index %d"; + + private Node createDummyChangelogDocument() { + Document document = new Document(); + Heading heading = new Heading(); + heading.setLevel(1); + heading.appendChild(new Text("Changelog")); + document.appendChild(heading); + + Paragraph paragraph = new Paragraph(); + paragraph.appendChild(new Text("Test paragraph")); + document.appendChild(paragraph); + return document; + + } + + private Node createDummyVersionBumpDocument(SemanticVersionBump bump, int index) { + Document document = new Document(); + Paragraph paragraph = new Paragraph(); + paragraph.appendChild(new Text(CHANGE_LINE.formatted(bump, index))); + document.appendChild(paragraph); + return document; + } + + @Nested + class CreateSimpleVersionBumpDocumentTest { + + @Test + void nullArtifact_ThrowsNullPointerException() { + assertThatThrownBy(() -> MarkdownUtils.createSimpleVersionBumpDocument(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`mavenArtifact` must not be null"); + } + + @Test + void createDocument_ValidMarkdown() { + VersionMarkdown actual = MarkdownUtils.createSimpleVersionBumpDocument(MAVEN_ARTIFACT); + assertThat(actual.content()) + .isInstanceOf(Document.class) + .extracting(Node::getFirstChild) + .isInstanceOf(Paragraph.class) + .satisfies( + n -> assertThat(n.getFirstChild()) + .isNotNull() + .isInstanceOf(Text.class) + .hasFieldOrPropertyWithValue("literal", "Project version bumped as result of dependency bumps") + .extracting(Node::getNext) + .isNull() + ) + .extracting(Node::getNext) + .isNull(); + + assertThat(actual.bumps()) + .hasSize(1) + .containsEntry(MAVEN_ARTIFACT, SemanticVersionBump.NONE); + } + } + + @Nested + class PrintMarkdownTest { + + @ParameterizedTest + @EnumSource(value = TestLog.LogLevel.class, mode = EnumSource.Mode.EXCLUDE, names = {"DEBUG"}) + void noDebugLogging_NoLogging(TestLog.LogLevel logLevel) { + TestLog log = new TestLog(logLevel); + + assertThatNoException() + .isThrownBy(() -> MarkdownUtils.printMarkdown(log, createDummyChangelogDocument(), 0)); + + assertThat(log.getLogRecords()) + .isEmpty(); + } + + @Test + void debugLogging_Logging() { + TestLog log = new TestLog(TestLog.LogLevel.DEBUG); + + MarkdownUtils.printMarkdown(log, createDummyChangelogDocument(), 0); + assertThat(log.getLogRecords()) + .hasSize(5) + .satisfiesExactly( + line -> assertThat(line) + .hasFieldOrPropertyWithValue("level", TestLog.LogLevel.DEBUG) + .hasFieldOrPropertyWithValue("message", Optional.of("Document{}")) + .hasFieldOrPropertyWithValue("throwable", Optional.empty()), + line -> assertThat(line) + .hasFieldOrPropertyWithValue("level", TestLog.LogLevel.DEBUG) + .hasFieldOrPropertyWithValue("message", Optional.of(" Heading{}")) + .hasFieldOrPropertyWithValue("throwable", Optional.empty()), + line -> assertThat(line) + .hasFieldOrPropertyWithValue("level", TestLog.LogLevel.DEBUG) + .hasFieldOrPropertyWithValue("message", Optional.of(" Text{literal=Changelog}")) + .hasFieldOrPropertyWithValue("throwable", Optional.empty()), + line -> assertThat(line) + .hasFieldOrPropertyWithValue("level", TestLog.LogLevel.DEBUG) + .hasFieldOrPropertyWithValue("message", Optional.of(" Paragraph{}")) + .hasFieldOrPropertyWithValue("throwable", Optional.empty()), + line -> assertThat(line) + .hasFieldOrPropertyWithValue("level", TestLog.LogLevel.DEBUG) + .hasFieldOrPropertyWithValue("message", Optional.of(" Text{literal=Test paragraph}")) + .hasFieldOrPropertyWithValue("throwable", Optional.empty()) + ); + } + } + + @Nested + class WriteMarkdownTest { + + @Test + void nullWriter_ThrowsNullPointerException() { + assertThatThrownBy(() -> MarkdownUtils.writeMarkdown(null, createDummyChangelogDocument())) + .isInstanceOf(NullPointerException.class) + .hasMessage("`output` must not be null"); + } + + @Test + void nullDocument_ThrowsNullPointerException() { + StringWriter writer = new StringWriter(); + assertThatThrownBy(() -> MarkdownUtils.writeMarkdown(writer, null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`document` must not be null"); + } + + @Test + void validDocument_WritesMarkdown() { + StringWriter writer = new StringWriter(); + MarkdownUtils.writeMarkdown(writer, createDummyChangelogDocument()); + assertThat(writer.toString()) + .isEqualTo(""" + # Changelog + + Test paragraph + """); + } + } + + @Nested + class WriteMarkdownFileTest { + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void markdownFileIsNull_ThrowsNullPointerException(boolean backupOld) { + assertThatThrownBy(() -> MarkdownUtils.writeMarkdownFile(null, createDummyChangelogDocument(), backupOld)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`markdownFile` must not be null"); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void documentIsNull_ThrowsNullPointerException(boolean backupOld) { + assertThatThrownBy(() -> MarkdownUtils.writeMarkdownFile(CHANGELOG_PATH, null, backupOld)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`document` must not be null"); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void failedToCreateFileWriter_ThrowsMojoExceptionException(boolean backupOld) { + try (MockedStatic filesMockedStatic = Mockito.mockStatic(Files.class)) { + filesMockedStatic.when(() -> Files.exists(CHANGELOG_PATH)) + .thenReturn(true); + filesMockedStatic.when(() -> Files.newBufferedWriter( + CHANGELOG_PATH, + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING + )) + .thenThrow(new IOException("Failed to create writer")); + + assertThatThrownBy(() -> MarkdownUtils.writeMarkdownFile(CHANGELOG_PATH, createDummyChangelogDocument(), backupOld)) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Unable to write %s".formatted(CHANGELOG_PATH)) + .hasRootCauseInstanceOf(IOException.class) + .hasRootCauseMessage("Failed to create writer"); + + filesMockedStatic.verify(() -> Files.copy( + CHANGELOG_PATH, + CHANGELOG_BACKUP_PATH, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.COPY_ATTRIBUTES, + StandardCopyOption.REPLACE_EXISTING + ), Mockito.times(backupOld ? 1 : 0)); + filesMockedStatic.verify(() -> Files.newBufferedWriter( + CHANGELOG_PATH, + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING + ), Mockito.times(1)); + } + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void happyFlow_CorrectlyWritten(boolean backupOld) { + try (MockedStatic filesMockedStatic = Mockito.mockStatic(Files.class)) { + StringWriter writer = new StringWriter(); + filesMockedStatic.when(() -> Files.exists(CHANGELOG_PATH)) + .thenReturn(true); + filesMockedStatic.when(() -> Files.newBufferedWriter( + CHANGELOG_PATH, + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING + )) + .thenReturn(new BufferedWriter(writer)); + + assertThatNoException() + .isThrownBy(() -> MarkdownUtils.writeMarkdownFile(CHANGELOG_PATH, createDummyChangelogDocument(), backupOld)); + assertThat(writer.toString()) + .isEqualTo(""" + # Changelog + + Test paragraph + """); + + filesMockedStatic.verify(() -> Files.copy(CHANGELOG_PATH, + CHANGELOG_BACKUP_PATH, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.COPY_ATTRIBUTES, + StandardCopyOption.REPLACE_EXISTING + ), Mockito.times(backupOld ? 1 : 0)); + filesMockedStatic.verify(() -> Files.newBufferedWriter( + CHANGELOG_PATH, + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING + ), Mockito.times(1)); + } + } + } + + @Nested + class MergeVersionMarkdownsInChangelogTest { + + @Test + void nullChangelog_ThrowsNullPointerException() { + assertThatThrownBy(() -> MarkdownUtils.mergeVersionMarkdownsInChangelog( + null, + VERSION, + Map.ofEntries(createDummyVersionMarkdown(SemanticVersionBump.PATCH, 1)) + )) + .isInstanceOf(NullPointerException.class) + .hasMessage("`changelog` must not be null"); + } + + @Test + void nullVersion_ThrowsNullPointerException() { + assertThatThrownBy(() -> MarkdownUtils.mergeVersionMarkdownsInChangelog( + createDummyChangelogDocument(), + null, + Map.ofEntries(createDummyVersionMarkdown(SemanticVersionBump.PATCH, 1)) + )) + .isInstanceOf(NullPointerException.class) + .hasMessage("`version` must not be null"); + } + + @Test + void nullHeaderToNodes_ThrowsNullPointerException() { + assertThatThrownBy(() -> MarkdownUtils.mergeVersionMarkdownsInChangelog( + createDummyChangelogDocument(), + VERSION, + null + )) + .isInstanceOf(NullPointerException.class) + .hasMessage("`headerToNodes` must not be null"); + } + + @Test + void changelogIsNotDocument_ThrowsIllegalArgumentException() { + assertThatThrownBy(() -> MarkdownUtils.mergeVersionMarkdownsInChangelog( + new Paragraph(), + VERSION, + Map.ofEntries(createDummyVersionMarkdown(SemanticVersionBump.PATCH, 1)) + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("`changelog` must be a Document"); + } + + @Test + void changelogStartWithParagraph_ThrowsIllegalArgumentException() { + Document document = new Document(); + Paragraph paragraph = new Paragraph(); + paragraph.appendChild(new Text("Changelog")); + document.appendChild(paragraph); + assertThatThrownBy(() -> MarkdownUtils.mergeVersionMarkdownsInChangelog( + document, + VERSION, + Map.ofEntries(createDummyVersionMarkdown(SemanticVersionBump.PATCH, 1)) + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Changelog must start with a single H1 heading with the text 'Changelog'"); + } + + @Test + void changelogStartWithLevel2Heading_ThrowsIllegalArgumentException() { + Document document = new Document(); + Heading heading = new Heading(); + heading.setLevel(2); + heading.appendChild(new Text("Changelog")); + document.appendChild(heading); + assertThatThrownBy(() -> MarkdownUtils.mergeVersionMarkdownsInChangelog( + document, + VERSION, + Map.ofEntries(createDummyVersionMarkdown(SemanticVersionBump.PATCH, 1)) + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Changelog must start with a single H1 heading with the text 'Changelog'"); + } + + @Test + void changelogStartWithLevel1HeadingNoText_ThrowsIllegalArgumentException() { + Document document = new Document(); + Heading heading = new Heading(); + heading.setLevel(1); + heading.appendChild(new Text("No Changelog")); + document.appendChild(heading); + assertThatThrownBy(() -> MarkdownUtils.mergeVersionMarkdownsInChangelog( + document, + VERSION, + Map.ofEntries(createDummyVersionMarkdown(SemanticVersionBump.PATCH, 1)) + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Changelog must start with a single H1 heading with the text 'Changelog'"); + } + + @Test + void changelogStartWithLevel1HeadingNotChangelogText_ThrowsIllegalArgumentException() { + Document document = new Document(); + Heading heading = new Heading(); + heading.setLevel(1); + document.appendChild(heading); + assertThatThrownBy(() -> MarkdownUtils.mergeVersionMarkdownsInChangelog( + document, + VERSION, + Map.ofEntries(createDummyVersionMarkdown(SemanticVersionBump.PATCH, 1)) + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Changelog must start with a single H1 heading with the text 'Changelog'"); + } + + @Test + void programmaticError_ThrowAssertionError() { + Document document = new Document(); + Heading headingMock = Mockito.mock(Heading.class); + Mockito.when(headingMock.getLevel()).thenReturn(1); + Mockito.when(headingMock.getFirstChild()).thenReturn(new Text("Changelog")); + document.appendChild(headingMock); + + Mockito.when(headingMock.getNext()).thenReturn(new Paragraph(), new Paragraph()); + + assertThatThrownBy(() -> MarkdownUtils.mergeVersionMarkdownsInChangelog( + document, + VERSION, + Map.of() + )) + .isInstanceOf(AssertionError.class) + .hasMessage("Incorrectly inserted nodes into changelog"); + } + + @Test + void nodeToAddIsNotADocument_ThrowsIllegalArgumentException() { + Node changelogDocument = createDummyChangelogDocument(); + + try (MockedStatic localDateMockedStatic = Mockito.mockStatic(LocalDate.class)) { + localDateMockedStatic.when(LocalDate::now) + .thenReturn(DATE); + + assertThatThrownBy(() -> MarkdownUtils.mergeVersionMarkdownsInChangelog( + changelogDocument, + VERSION, + Map.of(SemanticVersionBump.PATCH, List.of(new Paragraph()))) + ) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Node must be a Document"); + } + } + + @Test + void noNodes_OnlyIncludeVersionHeader() { + Node changelogDocument = createDummyChangelogDocument(); + + try (MockedStatic localDateMockedStatic = Mockito.mockStatic(LocalDate.class)) { + localDateMockedStatic.when(LocalDate::now) + .thenReturn(DATE); + + assertThatNoException() + .isThrownBy(() -> MarkdownUtils.mergeVersionMarkdownsInChangelog( + changelogDocument, + VERSION, + Map.of()) + ); + } + + assertThatDocument( + changelogDocument, + hasHeading(1, "Changelog"), + hasHeading(2, "%s - %s".formatted(VERSION, DATE)), + hasParagraph("Test paragraph") + ); + } + + @Test + void multipleNodes_IncludeVersionHeaderForEachNode() { + Document changelogDocument = new Document(); + Heading firstHeader = new Heading(); + firstHeader.setLevel(1); + firstHeader.appendChild(new Text("Changelog")); + changelogDocument.appendChild(firstHeader); + + try (MockedStatic localDateMockedStatic = Mockito.mockStatic(LocalDate.class)) { + localDateMockedStatic.when(LocalDate::now) + .thenReturn(DATE); + + assertThatNoException() + .isThrownBy(() -> MarkdownUtils.mergeVersionMarkdownsInChangelog( + changelogDocument, + VERSION, + Map.ofEntries( + createDummyVersionMarkdown(SemanticVersionBump.NONE, 1), + createDummyVersionMarkdown(SemanticVersionBump.PATCH, 2), + createDummyVersionMarkdown(SemanticVersionBump.MINOR, 3), + createDummyVersionMarkdown(SemanticVersionBump.MAJOR, 4) + )) + ); + } + + assertThatDocument( + changelogDocument, + hasHeading(1, "Changelog"), + hasHeading(2, "%s - %s".formatted(VERSION, DATE)), + hasHeading(3, "Major"), + hasParagraph(CHANGE_LINE.formatted(SemanticVersionBump.MAJOR, 0)), + hasParagraph(CHANGE_LINE.formatted(SemanticVersionBump.MAJOR, 1)), + hasParagraph(CHANGE_LINE.formatted(SemanticVersionBump.MAJOR, 2)), + hasParagraph(CHANGE_LINE.formatted(SemanticVersionBump.MAJOR, 3)), + hasHeading(3, "Minor"), + hasParagraph(CHANGE_LINE.formatted(SemanticVersionBump.MINOR, 0)), + hasParagraph(CHANGE_LINE.formatted(SemanticVersionBump.MINOR, 1)), + hasParagraph(CHANGE_LINE.formatted(SemanticVersionBump.MINOR, 2)), + hasHeading(3, "Patch"), + hasParagraph(CHANGE_LINE.formatted(SemanticVersionBump.PATCH, 0)), + hasParagraph(CHANGE_LINE.formatted(SemanticVersionBump.PATCH, 1)), + hasHeading(3, "Other"), + hasParagraph(CHANGE_LINE.formatted(SemanticVersionBump.NONE, 0)) + ); + + } + + private Map.Entry> createDummyVersionMarkdown( + SemanticVersionBump bump, + int items + ) { + return Map.entry( + bump, + IntStream.range(0, items) + .mapToObj(index -> createDummyVersionBumpDocument(bump, index)) + .collect(Utils.asImmutableList()) + ); + } + } + + @Nested + class ReadMarkdownTest { + + @Test + void nullLog_ThrowNullPointerException() { + assertThatThrownBy(() -> MarkdownUtils.readMarkdown(null, CHANGELOG_PATH)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`log` must not be null"); + } + + @Test + void nullMarkdownFile_ThrowNullPointerException() { + assertThatThrownBy(() -> MarkdownUtils.readMarkdown(new TestLog(TestLog.LogLevel.DEBUG), null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`markdownFile` must not be null"); + } + + @Test + void fileDoesNotExists_CreateInternalEmptyChangelog() throws MojoExecutionException { + TestLog log = new TestLog(TestLog.LogLevel.DEBUG); + try (MockedStatic filesMockedStatic = Mockito.mockStatic(Files.class)) { + filesMockedStatic.when(() -> Files.exists(CHANGELOG_PATH)) + .thenReturn(false); + + Node document = MarkdownUtils.readMarkdown(log, CHANGELOG_PATH); + assertThatDocument( + document, + hasHeading(1, "Changelog") + ); + } + + assertThat(log.getLogRecords()) + .hasSize(1) + .satisfiesExactly( + line -> assertThat(line) + .hasFieldOrPropertyWithValue("level", TestLog.LogLevel.INFO) + .hasFieldOrPropertyWithValue("message", Optional.of("No changelog file found at '%s', creating an empty CHANGELOG internally".formatted(CHANGELOG_PATH))) + .hasFieldOrPropertyWithValue("throwable", Optional.empty()) + ); + } + + @Test + void readingLinesThrowsIOException_ThrowsMojoExecutionException() { + TestLog log = new TestLog(TestLog.LogLevel.DEBUG); + try (MockedStatic filesMockedStatic = Mockito.mockStatic(Files.class)) { + filesMockedStatic.when(() -> Files.exists(CHANGELOG_PATH)) + .thenReturn(true); + filesMockedStatic.when(() -> Files.lines(CHANGELOG_PATH, StandardCharsets.UTF_8)) + .thenThrow(new IOException("Failed to read file")); + + assertThatThrownBy(() -> MarkdownUtils.readMarkdown(log, CHANGELOG_PATH)) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Unable to read '%s' file".formatted(CHANGELOG_PATH)) + .hasRootCauseInstanceOf(IOException.class) + .hasRootCauseMessage("Failed to read file"); + } + + assertThat(log.getLogRecords()) + .isEmpty(); + } + + @Test + void happyFlow_ReturnsCorrectDocument() throws MojoExecutionException { + TestLog log = new TestLog(TestLog.LogLevel.DEBUG); + 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(Stream.of("# My Changelog", "", "Test paragraph")); + + Node document = MarkdownUtils.readMarkdown(log, CHANGELOG_PATH); + + assertThatDocument( + document, + hasHeading(1, "My Changelog"), + hasParagraph("Test paragraph") + ); + } + + assertThat(log.getLogRecords()) + .hasSize(1) + .satisfiesExactly( + line -> assertThat(line) + .hasFieldOrPropertyWithValue("level", TestLog.LogLevel.INFO) + .hasFieldOrPropertyWithValue("message", Optional.of("Read 3 lines from %s".formatted(CHANGELOG_PATH))) + .hasFieldOrPropertyWithValue("throwable", Optional.empty()) + ); + } + } + + @Nested + class ReadVersionMarkdownTest { + + @Test + void nullLog_ThrowsNullPointerException() { + assertThatThrownBy(() -> MarkdownUtils.readVersionMarkdown(null, CHANGELOG_PATH)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`log` must not be null"); + } + + @Test + void nullMarkdownFile_ThrowsNullPointerException() { + TestLog log = new TestLog(TestLog.LogLevel.DEBUG); + assertThatThrownBy(() -> MarkdownUtils.readVersionMarkdown(log, null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`markdownFile` must not be null"); + } + + @Test + void hasNoFrontBlock_ThrowsMojoExecutionException() { + TestLog log = new TestLog(TestLog.LogLevel.DEBUG); + String markdown = """ + # Header 1 + + Header 1 paragraph. + + ## Header 2 + + Header 2 paragraph. + """; + + 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()); + + assertThatThrownBy(() -> MarkdownUtils.readVersionMarkdown(log, CHANGELOG_PATH)) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("YAML front matter block not found in '%s' file".formatted(CHANGELOG_PATH)); + } + + assertThat(log.getLogRecords()) + .hasSize(1) + .satisfiesExactly( + line -> assertThat(line) + .hasFieldOrPropertyWithValue("level", TestLog.LogLevel.INFO) + .hasFieldOrPropertyWithValue("message", Optional.of("Read 7 lines from %s".formatted(CHANGELOG_PATH))) + .hasFieldOrPropertyWithValue("throwable", Optional.empty()) + ); + } + + @Test + void hasNoVersionBumpFrontBlock_ThrowsMojoExecutionException() { + TestLog log = new TestLog(TestLog.LogLevel.DEBUG); + String markdown = """ + --- + this: + yaml: + is: + not: a version bump block + --- + # Header 1 + + Header 1 paragraph. + """; + + 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()); + + assertThatThrownBy(() -> MarkdownUtils.readVersionMarkdown(log, CHANGELOG_PATH)) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("YAML front matter does not contain valid maven artifacts and semantic version bump") + .hasRootCauseInstanceOf(JsonProcessingException.class) + .hasRootCauseMessage( + """ + Cannot deserialize Map key of type \ + `io.github.bsels.semantic.version.models.MavenArtifact` from String "this": \ + not a valid representation, problem: \ + (java.lang.reflect.InvocationTargetException) Invalid Maven artifact format: \ + this, expected : + at [Source: (StringReader); line: 1, column: 1]\ + """ + ); + } + + assertThat(log.getLogRecords()) + .hasSize(2) + .satisfiesExactly( + line -> assertThat(line) + .hasFieldOrPropertyWithValue("level", TestLog.LogLevel.INFO) + .hasFieldOrPropertyWithValue("message", Optional.of("Read 9 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: + this: + yaml: + is: + not: a version bump block\ + """)) + .hasFieldOrPropertyWithValue("throwable", Optional.empty()) + ); + } + + @Test + void happyFlow_ValidObject() throws MojoExecutionException { + TestLog log = new TestLog(TestLog.LogLevel.NONE); + String markdown = """ + --- + 'group:none': None + 'group:patch': patch + 'group-2:minor': MINOR + 'group-2: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); + } + + assertThat(versionMarkdown) + .satisfies( + data -> assertThat(data.bumps()) + .hasSize(4) + .containsEntry( + new MavenArtifact("group", "none"), + SemanticVersionBump.NONE + ) + .containsEntry( + new MavenArtifact("group", "patch"), + SemanticVersionBump.PATCH + ) + .containsEntry( + new MavenArtifact("group-2", "minor"), + SemanticVersionBump.MINOR + ) + .containsEntry( + new MavenArtifact("group-2", "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: + 'group:none': None + 'group:patch': patch + 'group-2:minor': MINOR + 'group-2:major': MAJOR\ + """)) + .hasFieldOrPropertyWithValue("throwable", Optional.empty()), + line -> assertThat(line) + .hasFieldOrPropertyWithValue("level", TestLog.LogLevel.DEBUG) + .hasFieldOrPropertyWithValue("message", Optional.of(""" + Maven artifacts and semantic version bumps: + {group:none=NONE, group:patch=PATCH, group-2:minor=MINOR, group-2:major=MAJOR}\ + """)) + .hasFieldOrPropertyWithValue("throwable", Optional.empty()) + ); + } + } + + @Nested + class CreateVersionBumpHeaderTest { + + @Test + void logIsNull_ThrowNullPointerException() { + assertThatThrownBy(() -> MarkdownUtils.createVersionBumpsHeader(null, Map.of())) + .isInstanceOf(NullPointerException.class) + .hasMessage("`log` must not be null"); + } + + @Test + void bumpsIsNull_ThrowNullPointerException() { + assertThatThrownBy(() -> MarkdownUtils.createVersionBumpsHeader(new TestLog(TestLog.LogLevel.DEBUG), null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`bumps` must not be null"); + } + + @Test + void nullKey_ThrowMojoExecutionException() { + TestLog log = new TestLog(TestLog.LogLevel.DEBUG); + Map bumps = new HashMap<>(); + bumps.put(null, SemanticVersionBump.PATCH); + assertThatThrownBy(() -> MarkdownUtils.createVersionBumpsHeader(log, bumps)) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Unable to construct version bump YAML") + .hasRootCauseInstanceOf(JsonMappingException.class) + .hasRootCauseMessage(""" + Null key for a Map not allowed in JSON (use a converting NullKeySerializer?) \ + (through reference chain: java.util.HashMap["null"])\ + """); + } + + @ParameterizedTest + @EnumSource(value = SemanticVersionBump.class, names = {"MAJOR", "MINOR", "PATCH"}) + void singleEntry_Valid(SemanticVersionBump bump) throws MojoExecutionException { + TestLog log = new TestLog(TestLog.LogLevel.NONE); + Map bumps = Map.of( + new MavenArtifact("group", "artifact"), bump + ); + YamlFrontMatterBlock block = MarkdownUtils.createVersionBumpsHeader(log, bumps); + assertThat(block) + .isNotNull() + .hasFieldOrPropertyWithValue("yaml", """ + group:artifact: "%s" + """.formatted(bump)); + + assertThat(log.getLogRecords()) + .isNotEmpty() + .hasSize(1) + .satisfiesExactly( + line -> assertThat(line) + .returns(""" + Version bumps YAML: + group:artifact: "%s" + """.formatted(bump), l -> l.message().orElseThrow()) + ); + } + + @Test + void multipleEntries_Valid() throws MojoExecutionException { + TestLog log = new TestLog(TestLog.LogLevel.NONE); + Map bumps = Map.of( + new MavenArtifact("group-1", "major"), SemanticVersionBump.MAJOR, + new MavenArtifact("group-2", "minor"), SemanticVersionBump.MINOR, + new MavenArtifact("group-3", "patch"), SemanticVersionBump.PATCH + ); + YamlFrontMatterBlock block = MarkdownUtils.createVersionBumpsHeader(log, bumps); + assertThat(block) + .isNotNull() + .extracting(YamlFrontMatterBlock::getYaml) + .asInstanceOf(InstanceOfAssertFactories.STRING) + .contains("group-1:major: \"MAJOR\"") + .contains("group-2:minor: \"MINOR\"") + .contains("group-3:patch: \"PATCH\""); + } + } +} diff --git a/src/test/java/io/github/bsels/semantic/version/utils/POMUtilsTest.java b/src/test/java/io/github/bsels/semantic/version/utils/POMUtilsTest.java new file mode 100644 index 0000000..e0cbc7a --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/utils/POMUtilsTest.java @@ -0,0 +1,852 @@ +package io.github.bsels.semantic.version.utils; + +import io.github.bsels.semantic.version.models.MavenArtifact; +import io.github.bsels.semantic.version.models.SemanticVersionBump; +import io.github.bsels.semantic.version.models.VersionChange; +import io.github.bsels.semantic.version.parameters.Modus; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.w3c.dom.Document; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerConfigurationException; +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import java.io.BufferedWriter; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.StringWriter; +import java.io.Writer; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.stream.Stream; + +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) +public class POMUtilsTest { + private static final Path POM_FILE = Path.of("project", "pom.xml"); + private static final Path POM_BACKUP_FILE = Path.of("project", "pom.xml.backup"); + + @Mock + Node nodeMock; + + private static void clearFieldOnPOMUtils(String field) { + try { + Field transformerField = POMUtils.class.getDeclaredField(field); + transformerField.setAccessible(true); + transformerField.set(null, null); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + + private static DocumentBuilder getDocumentBuilder() { + DocumentBuilder documentBuilder; + try { + documentBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder(); + } catch (ParserConfigurationException e) { + throw new RuntimeException(e); + } + return documentBuilder; + } + + private static Document createEmptyPom() { + Document document = getDocumentBuilder().newDocument(); + document.appendChild(document.createElement("project")); + return document; + } + + @Nested + class UpdateVersionNodeIfOldVersionMatchesTest { + + @Test + void nullVersionChange_ThrowsNullPointerException() { + assertThatThrownBy(() -> POMUtils.updateVersionNodeIfOldVersionMatches(null, nodeMock)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`versionChange` must not be null"); + } + + @Test + void nullNode_ThrowsNullPointerException() { + VersionChange versionChange = new VersionChange("1.2.3", "1.2.4"); + assertThatThrownBy(() -> POMUtils.updateVersionNodeIfOldVersionMatches(versionChange, null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`node` must not be null"); + } + + @Test + void versionChangeDoesNotMatch_DoesNotUpdateNode() { + VersionChange versionChange = new VersionChange("1.2.3", "1.2.4"); + Mockito.when(nodeMock.getTextContent()) + .thenReturn("1.0.0"); + + assertThatNoException() + .isThrownBy(() -> POMUtils.updateVersionNodeIfOldVersionMatches(versionChange, nodeMock)); + + Mockito.verify(nodeMock, Mockito.times(1)) + .getTextContent(); + Mockito.verify(nodeMock, Mockito.never()) + .setTextContent(Mockito.anyString()); + Mockito.verifyNoMoreInteractions(nodeMock); + } + + @Test + void versionChangeMatches_UpdatesNode() { + VersionChange versionChange = new VersionChange("1.2.3", "1.2.4"); + Mockito.when(nodeMock.getTextContent()) + .thenReturn("1.2.3"); + + assertThatNoException() + .isThrownBy(() -> POMUtils.updateVersionNodeIfOldVersionMatches(versionChange, nodeMock)); + + Mockito.verify(nodeMock, Mockito.times(1)) + .getTextContent(); + Mockito.verify(nodeMock, Mockito.times(1)) + .setTextContent(versionChange.newVersion()); + Mockito.verifyNoMoreInteractions(nodeMock); + } + } + + @Nested + class UpdateVersionTest { + + @Test + void nullNodeElement_ThrowsNullPointerException() { + assertThatThrownBy(() -> POMUtils.updateVersion(null, SemanticVersionBump.NONE)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`nodeElement` must not be null"); + } + + @Test + void nullBump_ThrowsNullPointerException() { + assertThatThrownBy(() -> POMUtils.updateVersion(nodeMock, null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`bump` must not be null"); + } + + @ParameterizedTest + @CsvSource({ + "1.2.3,MAJOR,2.0.0", + "1.2.3,MINOR,1.3.0", + "1.2.3,PATCH,1.2.4", + "1.2.3,NONE,1.2.3", + "1.2.3-SNAPSHOT,MAJOR,2.0.0-SNAPSHOT", + "1.2.3-SNAPSHOT,MINOR,1.3.0-SNAPSHOT", + "1.2.3-SNAPSHOT,PATCH,1.2.4-SNAPSHOT", + "1.2.3-SNAPSHOT,NONE,1.2.3-SNAPSHOT" + }) + void happyFlow_Success(String currentVersion, SemanticVersionBump bump, String expectedVersion) { + Mockito.when(nodeMock.getTextContent()) + .thenReturn(currentVersion); + + assertThatNoException() + .isThrownBy(() -> POMUtils.updateVersion(nodeMock, bump)); + + Mockito.verify(nodeMock, Mockito.times(1)) + .getTextContent(); + Mockito.verify(nodeMock, Mockito.times(1)) + .setTextContent(expectedVersion); + Mockito.verifyNoMoreInteractions(nodeMock); + } + } + + @Nested + class StreamWalkTest { + + private Constructor streamWalkConstructor; + + @BeforeEach + public void setUp() throws NoSuchMethodException { + streamWalkConstructor = Stream.of(POMUtils.class.getDeclaredClasses()) + .filter(clazz -> "StreamWalk".equals(clazz.getSimpleName())) + .findFirst() + .orElseThrow() + .getDeclaredConstructor(String.class, Stream.class); + streamWalkConstructor.setAccessible(true); + } + + @Test + void nullCurrentElementName_ThrowsNullPointerException() { + String nullString = null; + Stream emptyStream = Stream.empty(); + assertThatThrownBy(() -> streamWalkConstructor.newInstance(nullString, emptyStream)) + .isInstanceOf(InvocationTargetException.class) + .hasRootCauseInstanceOf(NullPointerException.class) + .hasRootCauseMessage("`currentElementName` must not be null"); + } + + @Test + void nullNodeStream_ThrowsNullPointerException() { + String name = "name"; + Stream nullStream = null; + assertThatThrownBy(() -> streamWalkConstructor.newInstance(name, nullStream)) + .isInstanceOf(InvocationTargetException.class) + .hasRootCauseInstanceOf(NullPointerException.class) + .hasRootCauseMessage("`nodeStream` must not be null"); + } + + @Test + void happyFlow_Success() throws InvocationTargetException, InstantiationException, IllegalAccessException { + String name = "name"; + Stream stream = Stream.empty(); + + Object instance = streamWalkConstructor.newInstance(name, stream); + assertThat(instance) + .hasNoNullFieldsOrProperties() + .hasFieldOrPropertyWithValue("currentElementName", name) + .hasFieldOrPropertyWithValue("nodeStream", stream); + } + } + + @Nested + class GetOrCreateTransformerTest { + + @Mock + Transformer transformerMock; + + @Mock + TransformerFactory transformerFactoryMock; + + private Method getOrCreateTransformerMethod; + + @BeforeEach + public void setUp() throws NoSuchMethodException { + getOrCreateTransformerMethod = POMUtils.class.getDeclaredMethod("getOrCreateTransformer"); + getOrCreateTransformerMethod.setAccessible(true); + // Make sure transformer is cleared before each test + clearFieldOnPOMUtils("transformer"); + } + + @AfterEach + public void tearDown() { + // Make sure transformer is cleared after each test + clearFieldOnPOMUtils("transformer"); + } + + @Test + void transformerCreationFailed_ThrowsMojoFailureException() throws TransformerConfigurationException { + try (MockedStatic transformerFactoryStatic = Mockito.mockStatic(TransformerFactory.class)) { + transformerFactoryStatic.when(TransformerFactory::newInstance) + .thenReturn(transformerFactoryMock); + + Mockito.when(transformerFactoryMock.newTransformer()) + .thenThrow(new TransformerConfigurationException("Transformer configuration issues")); + + assertThatThrownBy(() -> getOrCreateTransformerMethod.invoke(null)) + .isInstanceOf(InvocationTargetException.class) + .hasCauseInstanceOf(MojoFailureException.class) + .satisfies( + throwable -> assertThat(throwable.getCause()) + .isInstanceOf(MojoFailureException.class) + .hasMessage("Unable to construct XML transformer") + ) + .hasRootCauseInstanceOf(TransformerConfigurationException.class) + .hasRootCauseMessage("Transformer configuration issues"); + + transformerFactoryStatic.verify(TransformerFactory::newInstance, Mockito.times(1)); + Mockito.verify(transformerFactoryMock, Mockito.times(1)) + .newTransformer(); + + Mockito.verifyNoMoreInteractions(transformerFactoryMock); + transformerFactoryStatic.verifyNoMoreInteractions(); + } + } + + @Test + void happyFlow_Success() throws InvocationTargetException, IllegalAccessException, TransformerConfigurationException { + try (MockedStatic transformerFactoryStatic = Mockito.mockStatic(TransformerFactory.class)) { + transformerFactoryStatic.when(TransformerFactory::newInstance) + .thenReturn(transformerFactoryMock); + + Mockito.when(transformerFactoryMock.newTransformer()) + .thenReturn(transformerMock); + + assertThat(getOrCreateTransformerMethod.invoke(null)) + .isSameAs(transformerMock); + + // Next call should return the same transformer + assertThat(getOrCreateTransformerMethod.invoke(null)) + .isSameAs(transformerMock); + + transformerFactoryStatic.verify(TransformerFactory::newInstance, Mockito.times(1)); + Mockito.verify(transformerFactoryMock, Mockito.times(1)) + .newTransformer(); + + Mockito.verify(transformerMock, Mockito.times(1)) + .setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes"); + + Mockito.verifyNoMoreInteractions(transformerFactoryMock, transformerMock); + transformerFactoryStatic.verifyNoMoreInteractions(); + } + } + } + + @Nested + class GetOrCreateDocumentBuilderTest { + + @Mock + DocumentBuilder documentBuilderMock; + + @Mock + DocumentBuilderFactory documentBuilderFactoryMock; + + private Method getOrCreateDocumentBuilderMethod; + + @BeforeEach + public void setUp() throws NoSuchMethodException { + getOrCreateDocumentBuilderMethod = POMUtils.class.getDeclaredMethod("getOrCreateDocumentBuilder"); + getOrCreateDocumentBuilderMethod.setAccessible(true); + // Make sure transformer is cleared before each test + clearFieldOnPOMUtils("documentBuilder"); + } + + @AfterEach + public void tearDown() { + // Make sure transformer is cleared before each test + clearFieldOnPOMUtils("documentBuilder"); + } + + @Test + void documentBuilderCreationFailed_ThrowsMojoFailureException() throws ParserConfigurationException { + try (MockedStatic documentBuilderFactoryStatic = Mockito.mockStatic(DocumentBuilderFactory.class)) { + documentBuilderFactoryStatic.when(DocumentBuilderFactory::newInstance) + .thenReturn(documentBuilderFactoryMock); + + Mockito.when(documentBuilderFactoryMock.newDocumentBuilder()) + .thenThrow(new ParserConfigurationException("Parser configuration failure")); + + assertThatThrownBy(() -> getOrCreateDocumentBuilderMethod.invoke(null)) + .isInstanceOf(InvocationTargetException.class) + .hasCauseInstanceOf(MojoFailureException.class) + .satisfies( + throwable -> assertThat(throwable.getCause()) + .isInstanceOf(MojoFailureException.class) + .hasMessage("Unable to construct XML document builder") + ) + .hasRootCauseInstanceOf(ParserConfigurationException.class) + .hasRootCauseMessage("Parser configuration failure"); + + documentBuilderFactoryStatic.verify(DocumentBuilderFactory::newInstance, Mockito.times(1)); + Mockito.verify(documentBuilderFactoryMock, Mockito.times(1)) + .setNamespaceAware(true); + Mockito.verify(documentBuilderFactoryMock, Mockito.times(1)) + .setIgnoringElementContentWhitespace(false); + Mockito.verify(documentBuilderFactoryMock, Mockito.times(1)) + .setIgnoringComments(false); + Mockito.verify(documentBuilderFactoryMock, Mockito.times(1)) + .newDocumentBuilder(); + + Mockito.verifyNoMoreInteractions(documentBuilderFactoryMock); + documentBuilderFactoryStatic.verifyNoMoreInteractions(); + } + } + + @Test + void happyFlow_Success() throws InvocationTargetException, IllegalAccessException, ParserConfigurationException { + try (MockedStatic documentBuilderFactoryStatic = Mockito.mockStatic(DocumentBuilderFactory.class)) { + documentBuilderFactoryStatic.when(DocumentBuilderFactory::newInstance) + .thenReturn(documentBuilderFactoryMock); + + Mockito.when(documentBuilderFactoryMock.newDocumentBuilder()) + .thenReturn(documentBuilderMock); + + assertThat(getOrCreateDocumentBuilderMethod.invoke(null)) + .isSameAs(documentBuilderMock); + + // Second call return same element + assertThat(getOrCreateDocumentBuilderMethod.invoke(null)) + .isSameAs(documentBuilderMock); + + documentBuilderFactoryStatic.verify(DocumentBuilderFactory::newInstance, Mockito.times(1)); + Mockito.verify(documentBuilderFactoryMock, Mockito.times(1)) + .setNamespaceAware(true); + Mockito.verify(documentBuilderFactoryMock, Mockito.times(1)) + .setIgnoringElementContentWhitespace(false); + Mockito.verify(documentBuilderFactoryMock, Mockito.times(1)) + .setIgnoringComments(false); + Mockito.verify(documentBuilderFactoryMock, Mockito.times(1)) + .newDocumentBuilder(); + + Mockito.verifyNoMoreInteractions(documentBuilderFactoryMock, documentBuilderMock); + documentBuilderFactoryStatic.verifyNoMoreInteractions(); + } + } + } + + @Nested + class GetProjectVersionNodeTest { + + @ParameterizedTest + @EnumSource(Modus.class) + void nullDocument_ThrowsNullPointerException(Modus modus) { + assertThatThrownBy(() -> POMUtils.getProjectVersionNode(null, modus)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`document` must not be null"); + } + + @Test + void nullModus_ThrowsNullPointerException() { + Document document = createDummyPom(); + assertThatThrownBy(() -> POMUtils.getProjectVersionNode(document, null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`modus` must not be null"); + } + + @ParameterizedTest + @CsvSource({ + "REVISION_PROPERTY,project->properties->revision,properties", + "PROJECT_VERSION,project->version,version", + "PROJECT_VERSION_ONLY_LEAFS,project->version,version", + }) + void emptyDocument_ThrowsMojoExecutionException(Modus modus, String propertyPath, String firstMissingElement) { + Document emptyPom = createEmptyPom(); + assertThatThrownBy(() -> POMUtils.getProjectVersionNode(emptyPom, modus)) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Unable to find project version on the path: %s".formatted(propertyPath)) + .hasRootCauseInstanceOf(IllegalStateException.class) + .hasRootCauseMessage("Unable to find element '%s' in 'project'".formatted(firstMissingElement)); + } + + @ParameterizedTest + @CsvSource({ + "REVISION_PROPERTY,2.0.0", + "PROJECT_VERSION,1.0.0", + "PROJECT_VERSION_ONLY_LEAFS,1.0.0", + }) + void happyFlow_Success(Modus modus, String expectedVersion) throws MojoExecutionException { + Document pom = createDummyPom(); + Node node = POMUtils.getProjectVersionNode(pom, modus); + assertThat(node.getTextContent()) + .isEqualTo(expectedVersion); + } + + private Document createDummyPom() { + DocumentBuilder documentBuilder = getDocumentBuilder(); + Document document = documentBuilder.newDocument(); + Node project = document.appendChild(document.createElement("project")); + project.appendChild(document.createElement("version")).setTextContent("1.0.0"); + Node properties = project.appendChild(document.createElement("properties")); + properties.appendChild(document.createElement("revision")).setTextContent("2.0.0"); + return document; + } + } + + @Nested + class GetMavenArtifactsTest { + + @Test + void nullDocument_ThrowsNullPointerException() { + assertThatThrownBy(() -> POMUtils.getMavenArtifacts(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`document` must not be null"); + } + + @Test + void emptyDocument_ReturnEmpty() { + Document pom = createEmptyPom(); + assertThat(POMUtils.getMavenArtifacts(pom)) + .isNotNull() + .isEmpty(); + } + + @Test + void fullPom_ReturnProcessableMavenArtifacts() { + Document pom = createDummyPom(); + String groupId = "com.example"; + assertThat(POMUtils.getMavenArtifacts(pom)) + .isNotNull() + .isNotEmpty() + .hasSize(5) + .hasEntrySatisfying( + new MavenArtifact(groupId, "parent"), + list -> assertThat(list) + .hasSize(1) + .extracting(Node::getTextContent) + .containsExactlyInAnyOrder("1.0.0") + ) + .hasEntrySatisfying( + new MavenArtifact(groupId, "dependency"), + list -> assertThat(list) + .hasSize(2) + .extracting(Node::getTextContent) + .containsExactlyInAnyOrder("1.0.1", "0.0.1") + ) + .hasEntrySatisfying( + new MavenArtifact(groupId, "dependencyManagement"), + list -> assertThat(list) + .hasSize(1) + .extracting(Node::getTextContent) + .containsExactlyInAnyOrder("1.0.2") + ) + .hasEntrySatisfying( + new MavenArtifact(groupId, "plugin"), + list -> assertThat(list) + .hasSize(1) + .extracting(Node::getTextContent) + .containsExactlyInAnyOrder("1.0.3") + ) + .hasEntrySatisfying( + new MavenArtifact(groupId, "pluginManagement"), + list -> assertThat(list) + .hasSize(1) + .extracting(Node::getTextContent) + .containsExactlyInAnyOrder("1.0.4") + ); + } + + private Document createDummyPom() { + DocumentBuilder documentBuilder = getDocumentBuilder(); + Document document = documentBuilder.newDocument(); + Node project = document.appendChild(document.createElement("project")); + + // Properties + Node properties = project.appendChild(document.createElement("properties")); + properties.appendChild(document.createElement("revision")).setTextContent("2.0.0"); + properties.appendChild(document.createElement("property.version")).setTextContent("1.0.0-alpha"); + + // Parent + Node parent = project.appendChild(document.createElement("parent")); + parent.appendChild(document.createElement("version")).setTextContent("1.0.0"); + parent.appendChild(document.createElement("artifactId")).setTextContent("parent"); + parent.appendChild(document.createElement("groupId")).setTextContent("com.example"); + + // Dependencies + Node dependencies = project.appendChild(document.createElement("dependencies")); + Node dependencyWithVersion = dependencies.appendChild(document.createElement("dependency")); + dependencyWithVersion.appendChild(document.createElement("version")).setTextContent("1.0.1"); + dependencyWithVersion.appendChild(document.createElement("artifactId")).setTextContent("dependency"); + dependencyWithVersion.appendChild(document.createElement("groupId")).setTextContent("com.example"); + Node dependencyWithoutVersion = dependencies.appendChild(document.createElement("dependency")); + dependencyWithoutVersion.appendChild(document.createElement("artifactId")).setTextContent("dependencyManagement"); + dependencyWithoutVersion.appendChild(document.createElement("groupId")).setTextContent("com.example"); + + // DependencyManagement + Node dependencyManagement = project.appendChild(document.createElement("dependencyManagement")); + Node dependencyManagementDependencies = dependencyManagement.appendChild(document.createElement("dependencies")); + Node dependencyManagementDependency0 = dependencyManagementDependencies.appendChild(document.createElement("dependency")); + dependencyManagementDependency0.appendChild(document.createElement("artifactId")).setTextContent("dependencyManagement"); + dependencyManagementDependency0.appendChild(document.createElement("groupId")).setTextContent("com.example"); + dependencyManagementDependency0.appendChild(document.createElement("version")).setTextContent("1.0.2"); + Node dependencyManagementDependency1 = dependencyManagementDependencies.appendChild(document.createElement("dependency")); + dependencyManagementDependency1.appendChild(document.createElement("artifactId")).setTextContent("dependencyManagement2"); + dependencyManagementDependency1.appendChild(document.createElement("groupId")).setTextContent("com.example"); + dependencyManagementDependency1.appendChild(document.createElement("version")).setTextContent("${property.version}"); + Node dependencyManagementDependencyDuplicatedDependency = dependencyManagementDependencies.appendChild(document.createElement("dependency")); + dependencyManagementDependencyDuplicatedDependency.appendChild(document.createElement("artifactId")).setTextContent("dependency"); + dependencyManagementDependencyDuplicatedDependency.appendChild(document.createElement("groupId")).setTextContent("com.example"); + dependencyManagementDependencyDuplicatedDependency.appendChild(document.createElement("version")).setTextContent("0.0.1"); + + // Build plugins + Node buildPlugins = project.appendChild(document.createElement("build")); + Node buildPluginsPlugins = buildPlugins.appendChild(document.createElement("plugins")); + Node buildPluginsPluginWithVersion = buildPluginsPlugins.appendChild(document.createElement("plugin")); + buildPluginsPluginWithVersion.appendChild(document.createElement("version")).setTextContent("1.0.3"); + buildPluginsPluginWithVersion.appendChild(document.createElement("artifactId")).setTextContent("plugin"); + buildPluginsPluginWithVersion.appendChild(document.createElement("groupId")).setTextContent("com.example"); + Node buildPluginsPluginWithoutVersion = buildPluginsPlugins.appendChild(document.createElement("plugin")); + buildPluginsPluginWithoutVersion.appendChild(document.createElement("artifactId")).setTextContent("pluginManagement"); + buildPluginsPluginWithoutVersion.appendChild(document.createElement("groupId")).setTextContent("com.example"); + + // Build plugin management + Node buildPluginManagement = buildPlugins.appendChild(document.createElement("pluginManagement")); + Node buildPluginManagementPlugins = buildPluginManagement.appendChild(document.createElement("plugins")); + Node buildPluginManagementPlugin0 = buildPluginManagementPlugins.appendChild(document.createElement("plugin")); + buildPluginManagementPlugin0.appendChild(document.createElement("artifactId")).setTextContent("pluginManagement"); + buildPluginManagementPlugin0.appendChild(document.createElement("groupId")).setTextContent("com.example"); + buildPluginManagementPlugin0.appendChild(document.createElement("version")).setTextContent("1.0.4"); + Node buildPluginManagementPlugin1 = buildPluginManagementPlugins.appendChild(document.createElement("plugin")); + buildPluginManagementPlugin1.appendChild(document.createElement("artifactId")).setTextContent("pluginManagement2"); + buildPluginManagementPlugin1.appendChild(document.createElement("groupId")).setTextContent("com.example"); + buildPluginManagementPlugin1.appendChild(document.createElement("version")).setTextContent("${property.version}"); + return document; + } + } + + @Nested + class WritePomTest { + + @Nested + class WriterFlowTest { + + @Mock + Transformer transformerMock; + + @Mock + TransformerFactory transformerFactoryMock; + + @Mock + Writer writerMock; + + @Test + void nullDocument_ThrowsNullPointerException() { + assertThatThrownBy(() -> POMUtils.writePom(null, writerMock)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`document` must not be null"); + } + + @Test + void nullWriter_ThrowsNullPointerException() { + Document pom = createEmptyPom(); + assertThatThrownBy(() -> POMUtils.writePom(pom, null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`writer` must not be null"); + } + + @Test + void ioExceptionHappened_ThrowsMojoExecutionException() + throws IOException { + Document pom = createEmptyPom(); + Mockito.doThrow(IOException.class) + .when(writerMock) + .write(Mockito.anyString()); + + assertThatThrownBy(() -> POMUtils.writePom(pom, writerMock)) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Unable to write XML document") + .hasCauseInstanceOf(IOException.class); + } + + @Test + void transformerExceptionHappened_ThrowsMojoExecutionException() + throws TransformerException { + clearFieldOnPOMUtils("transformer"); + try (MockedStatic transformerFactoryStatic = Mockito.mockStatic(TransformerFactory.class)) { + transformerFactoryStatic.when(TransformerFactory::newInstance) + .thenReturn(transformerFactoryMock); + Mockito.when(transformerFactoryMock.newTransformer()) + .thenReturn(transformerMock); + Mockito.doThrow(TransformerException.class) + .when(transformerMock) + .transform(Mockito.any(), Mockito.any()); + + Document pom = createEmptyPom(); + assertThatThrownBy(() -> POMUtils.writePom(pom, writerMock)) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Unable to write XML document") + .hasCauseInstanceOf(TransformerException.class); + } finally { + clearFieldOnPOMUtils("transformer"); + } + } + + @Test + void happyFlow_CorrectWritten() { + Document pom = createEmptyPom(); + StringWriter writer = new StringWriter(); + assertThatNoException() + .isThrownBy(() -> POMUtils.writePom(pom, writer)); + + assertThat(writer.toString()) + .isEqualTo(""" + + \ + """); + } + } + + @Nested + class FileFlowTest { + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void nullDocument_ThrowsNullPointerException(boolean backup) { + assertThatThrownBy(() -> POMUtils.writePom(null, POM_FILE, backup)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`document` must not be null"); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void nullPomFile_ThrowsNullPointerException(boolean backup) { + Document pom = createEmptyPom(); + assertThatThrownBy(() -> POMUtils.writePom(pom, null, backup)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`pomFile` must not be null"); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void openingWriterFails_ThrowsMojoExecutionException(boolean backup) throws IOException { + Document pom = createEmptyPom(); + try (MockedStatic filesMockedStatic = Mockito.mockStatic(Files.class)) { + filesMockedStatic.when(() -> Files.exists(POM_FILE)) + .thenReturn(backup); + filesMockedStatic.when(() -> Files.newBufferedWriter( + POM_FILE, + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING + )).thenThrow(new IOException("Unable to open writer")); + + assertThatThrownBy(() -> POMUtils.writePom(pom, POM_FILE, backup)) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Unable to write to %s".formatted(POM_FILE)) + .hasRootCauseInstanceOf(IOException.class) + .hasRootCauseMessage("Unable to open writer"); + + filesMockedStatic.verify(() -> Files.copy( + POM_FILE, + POM_BACKUP_FILE, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.COPY_ATTRIBUTES, + StandardCopyOption.REPLACE_EXISTING + ), Mockito.times(backup ? 1 : 0)); + } + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void happyFlow_CorrectlyWritten(boolean backup) throws IOException { + Document pom = createEmptyPom(); + try (MockedStatic filesMockedStatic = Mockito.mockStatic(Files.class)) { + filesMockedStatic.when(() -> Files.exists(POM_FILE)) + .thenReturn(backup); + StringWriter writer = new StringWriter(); + BufferedWriter bufferedWriter = new BufferedWriter(writer); + + filesMockedStatic.when(() -> Files.newBufferedWriter( + POM_FILE, + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING + )).thenReturn(bufferedWriter); + + assertThatNoException() + .isThrownBy(() -> POMUtils.writePom(pom, POM_FILE, backup)); + + assertThat(writer.toString()) + .isEqualTo(""" + + \ + """); + + filesMockedStatic.verify(() -> Files.copy( + POM_FILE, + POM_BACKUP_FILE, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.COPY_ATTRIBUTES, + StandardCopyOption.REPLACE_EXISTING + ), Mockito.times(backup ? 1 : 0)); + } + } + } + } + + @Nested + class ReadPomTest { + + @Test + void nullPomFile_ThrowsNullPointerException() { + assertThatThrownBy(() -> POMUtils.readPom(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`pomFile` must not be null"); + } + + @Test + void openInputStreamFailed_ThrowsMojoExecutionException() { + try (MockedStatic filesMockedStatic = Mockito.mockStatic(Files.class)) { + filesMockedStatic.when(() -> Files.newInputStream(Mockito.any())) + .thenThrow(new IOException("Unable to open input stream")); + + assertThatThrownBy(() -> POMUtils.readPom(POM_FILE)) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Unable to read '%s' file".formatted(POM_FILE)) + .hasRootCauseInstanceOf(IOException.class) + .hasRootCauseMessage("Unable to open input stream"); + + filesMockedStatic.verify(() -> Files.newInputStream(POM_FILE), Mockito.times(1)); + filesMockedStatic.verifyNoMoreInteractions(); + } + } + + @Test + void nonXMLDocument_ThrowsMojoExecutionException() { + try (MockedStatic filesMockedStatic = Mockito.mockStatic(Files.class)) { + filesMockedStatic.when(() -> Files.newInputStream(Mockito.any())) + .thenReturn(new ByteArrayInputStream("Not an XML File, just a normal text file".getBytes())); + + assertThatThrownBy(() -> POMUtils.readPom(POM_FILE)) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Unable to read '%s' file".formatted(POM_FILE)) + .hasRootCauseInstanceOf(SAXException.class) + .hasRootCauseMessage("Content is not allowed in prolog."); + + filesMockedStatic.verify(() -> Files.newInputStream(POM_FILE), Mockito.times(1)); + filesMockedStatic.verifyNoMoreInteractions(); + } + } + + @Test + void validXMLDocument_ReturnCorrectDocument() throws MojoExecutionException, MojoFailureException { + try (MockedStatic filesMockedStatic = Mockito.mockStatic(Files.class)) { + filesMockedStatic.when(() -> Files.newInputStream(Mockito.any())) + .thenReturn(new ByteArrayInputStream(""" + \ + com.example\ + project\ + 1.0.0\ + + """.getBytes())); + + Document document = POMUtils.readPom(POM_FILE); + assertThat(document) + .returns("project", d -> d.getDocumentElement().getNodeName()) + .satisfies( + d -> assertThat(d.getDocumentElement().getChildNodes()) + .returns(3, NodeList::getLength) + .satisfies( + nodes -> assertThat(nodes.item(0)) + .returns("groupId", Node::getNodeName) + .returns("com.example", Node::getTextContent), + nodes -> assertThat(nodes.item(1)) + .returns("artifactId", Node::getNodeName) + .returns("project", Node::getTextContent), + nodes -> assertThat(nodes.item(2)) + .returns("version", Node::getNodeName) + .returns("1.0.0", Node::getTextContent) + ) + ); + + filesMockedStatic.verify(() -> Files.newInputStream(POM_FILE), Mockito.times(1)); + filesMockedStatic.verifyNoMoreInteractions(); + } + } + + private InputStream stringToInputStream(String string) { + return new ByteArrayInputStream(string.getBytes(StandardCharsets.UTF_8)); + } + } +} 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 new file mode 100644 index 0000000..a59d650 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/utils/ProcessUtilsTest.java @@ -0,0 +1,358 @@ +package io.github.bsels.semantic.version.utils; + +import org.apache.maven.plugin.MojoExecutionException; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedConstruction; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@ExtendWith(MockitoExtension.class) +public class ProcessUtilsTest { + + @Mock + Process process; + + private String originalVisual; + private String originalEditor; + private String originalOsName; + + @BeforeEach + void setUp() { + // Save original system properties + originalVisual = System.getProperty("VISUAL"); + originalEditor = System.getProperty("EDITOR"); + originalOsName = System.getProperty("os.name"); + } + + @AfterEach + void tearDown() { + // Restore original system properties + restoreSystemProperty("VISUAL", originalVisual); + restoreSystemProperty("EDITOR", originalEditor); + restoreSystemProperty("os.name", originalOsName); + } + + private void restoreSystemProperty(String key, String value) { + if (value == null) { + System.clearProperty(key); + } else { + System.setProperty(key, value); + } + } + + @Nested + class GetDefaultEditorTest { + + @Test + void visualPropertySet_ReturnsVisual() { + System.setProperty("VISUAL", "vim"); + System.setProperty("EDITOR", "nano"); + + assertThat(ProcessUtils.getDefaultEditor()) + .isEqualTo("vim"); + } + + @Test + void visualPropertySetWithWhitespace_ReturnsStrippedVisual() { + System.setProperty("VISUAL", " vim "); + System.setProperty("EDITOR", "nano"); + + assertThat(ProcessUtils.getDefaultEditor()) + .isEqualTo("vim"); + } + + @Test + void visualPropertyBlank_FallsBackToEditor() { + System.setProperty("VISUAL", " "); + System.setProperty("EDITOR", "nano"); + + assertThat(ProcessUtils.getDefaultEditor()) + .isEqualTo("nano"); + } + + @Test + void visualPropertyEmpty_FallsBackToEditor() { + System.setProperty("VISUAL", ""); + System.setProperty("EDITOR", "emacs"); + + assertThat(ProcessUtils.getDefaultEditor()) + .isEqualTo("emacs"); + } + + @Test + void onlyEditorPropertySet_ReturnsEditor() { + System.clearProperty("VISUAL"); + System.setProperty("EDITOR", "nano"); + + assertThat(ProcessUtils.getDefaultEditor()) + .isEqualTo("nano"); + } + + @Test + void editorPropertySetWithWhitespace_ReturnsStrippedEditor() { + System.clearProperty("VISUAL"); + System.setProperty("EDITOR", " nano "); + + assertThat(ProcessUtils.getDefaultEditor()) + .isEqualTo("nano"); + } + + @Test + void noPropertiesSet_WindowsOs_ReturnsNotepad() { + System.clearProperty("VISUAL"); + System.clearProperty("EDITOR"); + System.setProperty("os.name", "Windows 10"); + + assertThat(ProcessUtils.getDefaultEditor()) + .isEqualTo("notepad"); + } + + @Test + void noPropertiesSet_WindowsOs_CaseInsensitive_ReturnsNotepad() { + System.clearProperty("VISUAL"); + System.clearProperty("EDITOR"); + System.setProperty("os.name", "WINDOWS 11"); + + assertThat(ProcessUtils.getDefaultEditor()) + .isEqualTo("notepad"); + } + + @Test + void noPropertiesSet_LinuxOs_ReturnsVi() { + System.clearProperty("VISUAL"); + System.clearProperty("EDITOR"); + System.setProperty("os.name", "Linux"); + + assertThat(ProcessUtils.getDefaultEditor()) + .isEqualTo("vi"); + } + + @Test + void noPropertiesSet_MacOs_ReturnsVi() { + System.clearProperty("VISUAL"); + System.clearProperty("EDITOR"); + System.setProperty("os.name", "Mac OS X"); + + assertThat(ProcessUtils.getDefaultEditor()) + .isEqualTo("vi"); + } + + @Test + void editorPropertyBlank_FallsBackToOsDefault() { + System.clearProperty("VISUAL"); + System.setProperty("EDITOR", " "); + System.setProperty("os.name", "Linux"); + + assertThat(ProcessUtils.getDefaultEditor()) + .isEqualTo("vi"); + } + } + + @Nested + class ExecuteEditorTest { + + @Test + void nullInput_ThrowsNullPointerException() { + assertThatThrownBy(() -> ProcessUtils.executeEditor(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`file` must not be null"); + } + + @Test + void processExitsWithZero_ReturnsTrue() throws Exception { + Path file = Path.of("test.md"); + System.setProperty("VISUAL", "vim"); + + try (MockedConstruction mockedBuilder = Mockito.mockConstruction(ProcessBuilder.class, + (mock, context) -> { + Mockito.when(mock.inheritIO()).thenReturn(mock); + Mockito.when(mock.start()).thenReturn(process); + })) { + + Mockito.when(process.waitFor()).thenReturn(0); + + boolean result = ProcessUtils.executeEditor(file); + + assertThat(result).isTrue(); + assertThat(mockedBuilder.constructed()).hasSize(1); + ProcessBuilder builder = mockedBuilder.constructed().get(0); + Mockito.verify(builder).inheritIO(); + Mockito.verify(builder).start(); + Mockito.verify(process).waitFor(); + } + } + + @Test + void processExitsWithNonZero_ReturnsFalse() throws Exception { + Path file = Path.of("test.md"); + System.setProperty("EDITOR", "nano"); + + try (MockedConstruction mockedBuilder = Mockito.mockConstruction(ProcessBuilder.class, + (mock, context) -> { + Mockito.when(mock.inheritIO()).thenReturn(mock); + Mockito.when(mock.start()).thenReturn(process); + })) { + + Mockito.when(process.waitFor()).thenReturn(1); + + boolean result = ProcessUtils.executeEditor(file); + + assertThat(result).isFalse(); + assertThat(mockedBuilder.constructed()).hasSize(1); + Mockito.verify(process).waitFor(); + } + } + + @Test + void processBuilderThrowsIOException_ThrowsMojoExecutionException() { + Path file = Path.of("test.md"); + System.setProperty("VISUAL", "vim"); + IOException ioException = new IOException("Failed to start process"); + + try (MockedConstruction mockedBuilder = Mockito.mockConstruction(ProcessBuilder.class, + (mock, context) -> { + Mockito.when(mock.inheritIO()).thenReturn(mock); + Mockito.when(mock.start()).thenThrow(ioException); + })) { + + assertThatThrownBy(() -> ProcessUtils.executeEditor(file)) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Unable to execute editor") + .hasCause(ioException); + + assertThat(mockedBuilder.constructed()).hasSize(1); + } + } + + @Test + void processWaitForThrowsInterruptedException_ThrowsMojoExecutionException() throws Exception { + Path file = Path.of("test.md"); + System.setProperty("VISUAL", "vim"); + InterruptedException interruptedException = new InterruptedException("Process interrupted"); + + try (MockedConstruction mockedBuilder = Mockito.mockConstruction(ProcessBuilder.class, + (mock, context) -> { + Mockito.when(mock.inheritIO()).thenReturn(mock); + Mockito.when(mock.start()).thenReturn(process); + })) { + + Mockito.when(process.waitFor()).thenThrow(interruptedException); + + assertThatThrownBy(() -> ProcessUtils.executeEditor(file)) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Unable to execute editor") + .hasCause(interruptedException); + + assertThat(mockedBuilder.constructed()).hasSize(1); + Mockito.verify(process).waitFor(); + } + } + + @Test + void usesCorrectEditorFromVisualProperty() throws Exception { + Path file = Path.of("/tmp/changelog.md"); + System.setProperty("VISUAL", "emacs"); + + try (MockedConstruction mockedBuilder = Mockito.mockConstruction(ProcessBuilder.class, + (mock, context) -> { + validateProcessArguments(context, "emacs", file); + Mockito.when(mock.inheritIO()).thenReturn(mock); + Mockito.when(mock.start()).thenReturn(process); + })) { + + Mockito.when(process.waitFor()).thenReturn(0); + + ProcessUtils.executeEditor(file); + + assertThat(mockedBuilder.constructed()).hasSize(1); + } + } + + @Test + void usesCorrectEditorFromEditorProperty() throws Exception { + Path file = Path.of("/tmp/changelog.md"); + System.clearProperty("VISUAL"); + System.setProperty("EDITOR", "nano"); + + try (MockedConstruction mockedBuilder = Mockito.mockConstruction(ProcessBuilder.class, + (mock, context) -> { + validateProcessArguments(context, "nano", file); + Mockito.when(mock.inheritIO()).thenReturn(mock); + Mockito.when(mock.start()).thenReturn(process); + })) { + + Mockito.when(process.waitFor()).thenReturn(0); + + ProcessUtils.executeEditor(file); + + assertThat(mockedBuilder.constructed()).hasSize(1); + } + } + + @Test + void usesCorrectFallbackEditorForWindows() throws Exception { + Path file = Path.of("C:\\temp\\changelog.md"); + System.clearProperty("VISUAL"); + System.clearProperty("EDITOR"); + System.setProperty("os.name", "Windows 10"); + + try (MockedConstruction mockedBuilder = Mockito.mockConstruction(ProcessBuilder.class, + (mock, context) -> { + validateProcessArguments(context, "notepad", file); + Mockito.when(mock.inheritIO()).thenReturn(mock); + Mockito.when(mock.start()).thenReturn(process); + })) { + + Mockito.when(process.waitFor()).thenReturn(0); + + ProcessUtils.executeEditor(file); + + assertThat(mockedBuilder.constructed()).hasSize(1); + } + } + + @Test + void usesCorrectFallbackEditorForLinux() throws Exception { + Path file = Path.of("/tmp/changelog.md"); + System.clearProperty("VISUAL"); + System.clearProperty("EDITOR"); + System.setProperty("os.name", "Linux"); + + try (MockedConstruction mockedBuilder = Mockito.mockConstruction(ProcessBuilder.class, + (mock, context) -> { + validateProcessArguments(context, "vi", file); + Mockito.when(mock.inheritIO()).thenReturn(mock); + Mockito.when(mock.start()).thenReturn(process); + })) { + + Mockito.when(process.waitFor()).thenReturn(0); + + ProcessUtils.executeEditor(file); + + assertThat(mockedBuilder.constructed()).hasSize(1); + } + } + + private void validateProcessArguments(MockedConstruction.Context context, String editor, Path file) { + assertThat(context.arguments()) + .isNotNull() + .isNotEmpty() + .hasSize(1) + .first() + .asInstanceOf(InstanceOfAssertFactories.array(String[].class)) + .containsExactly(editor, file.toString()); + } + } +} diff --git a/src/test/java/io/github/bsels/semantic/version/utils/TerminalHelperTest.java b/src/test/java/io/github/bsels/semantic/version/utils/TerminalHelperTest.java new file mode 100644 index 0000000..4fb1994 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/utils/TerminalHelperTest.java @@ -0,0 +1,494 @@ +package io.github.bsels.semantic.version.utils; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.PrintStream; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class TerminalHelperTest { + + private final InputStream originalSystemIn = System.in; + private final PrintStream originalSystemOut = System.out; + private ByteArrayOutputStream outputStream; + + @BeforeEach + void setUp() { + outputStream = new ByteArrayOutputStream(); + System.setOut(new PrintStream(outputStream)); + } + + @AfterEach + void tearDown() { + System.setIn(originalSystemIn); + System.setOut(originalSystemOut); + } + + private void setSystemIn(String input) { + System.setIn(new ByteArrayInputStream(input.getBytes())); + } + + private String getOutput() { + return outputStream.toString(); + } + + private enum TestEnum { + FIRST, SECOND, THIRD + } + + @Nested + class ReadMultiLineInputTest { + + @Test + void nullPrompt_ThrowsNullPointerException() { + assertThatThrownBy(() -> TerminalHelper.readMultiLineInput(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`prompt` must not be null"); + } + + @Test + void firstLineBlank_ReturnsEmpty() { + setSystemIn("\n"); + + Optional result = TerminalHelper.readMultiLineInput("Enter text:"); + + assertThat(result).isEmpty(); + assertThat(getOutput()).contains("Enter text:"); + } + + @Test + void singleLineInput_TwoBlankLines_ReturnsInput() { + setSystemIn("First line\n\n\n"); + + Optional result = TerminalHelper.readMultiLineInput("Enter text:"); + + assertThat(result) + .isPresent() + .hasValue("First line"); + } + + @Test + void multiLineInput_TwoConsecutiveBlankLines_ReturnsAllLines() { + setSystemIn("Line 1\nLine 2\nLine 3\n\n\n"); + + Optional result = TerminalHelper.readMultiLineInput("Enter text:"); + + assertThat(result) + .isPresent() + .hasValue("Line 1\nLine 2\nLine 3"); + } + + @Test + void multiLineInputWithSingleBlankLine_ThenTwoBlankLines_ReturnsAllLines() { + setSystemIn("Line 1\n\nLine 2\n\n\n"); + + Optional result = TerminalHelper.readMultiLineInput("Enter text:"); + + assertThat(result) + .isPresent() + .hasValue("Line 1\n\nLine 2"); + } + + @Test + void promptIsDisplayed() { + setSystemIn("\n"); + + TerminalHelper.readMultiLineInput("Custom prompt message:"); + + assertThat(getOutput()).isEqualTo("Custom prompt message:\n"); + } + } + + @Nested + class SingleChoiceTest { + + @Test + void nullChoiceHeader_ThrowsNullPointerException() { + assertThatThrownBy(() -> TerminalHelper.singleChoice(null, "item", List.of("A", "B"))) + .isInstanceOf(NullPointerException.class) + .hasMessage("`choiceHeader` must not be null"); + } + + @Test + void nullPromptObject_ThrowsNullPointerException() { + assertThatThrownBy(() -> TerminalHelper.singleChoice("Header", null, List.of("A", "B"))) + .isInstanceOf(NullPointerException.class) + .hasMessage("`promptObject` must not be null"); + } + + @Test + void nullChoices_ThrowsNullPointerException() { + assertThatThrownBy(() -> TerminalHelper.singleChoice("Header", "item", null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`choices` must not be null"); + } + + @Test + void emptyChoices_ThrowsIllegalArgumentException() { + assertThatThrownBy(() -> TerminalHelper.singleChoice("Header", "item", List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("No choices provided"); + } + + @Test + void choicesContainsNull_ThrowsNullPointerException() { + assertThatThrownBy(() -> TerminalHelper.singleChoice("Header", "item", Arrays.asList("A", null, "C"))) + .isInstanceOf(NullPointerException.class) + .hasMessage("All choices must not be null"); + } + + @Test + void validNumberInput_ReturnsCorrectChoice() { + setSystemIn("2\n"); + List choices = List.of("Apple", "Banana", "Cherry"); + + String result = TerminalHelper.singleChoice("Select fruit:", "fruit", choices); + + assertThat(result).isEqualTo("Banana"); + assertThat(getOutput()) + .contains("Select fruit:") + .contains("1: Apple") + .contains("2: Banana") + .contains("3: Cherry") + .contains("Enter fruit number:"); + } + + @Test + void firstChoiceByNumber_ReturnsFirstChoice() { + setSystemIn("1\n"); + List choices = List.of("First", "Second", "Third"); + + String result = TerminalHelper.singleChoice("Select:", "option", choices); + + assertThat(result).isEqualTo("First"); + } + + @Test + void lastChoiceByNumber_ReturnsLastChoice() { + setSystemIn("3\n"); + List choices = List.of("First", "Second", "Third"); + + String result = TerminalHelper.singleChoice("Select:", "option", choices); + + assertThat(result).isEqualTo("Third"); + } + + @Test + void enumChoice_ValidNumber_ReturnsCorrectEnum() { + setSystemIn("2\n"); + List choices = List.of(TestEnum.FIRST, TestEnum.SECOND, TestEnum.THIRD); + + TestEnum result = TerminalHelper.singleChoice("Select enum:", "enum", choices); + + assertThat(result).isEqualTo(TestEnum.SECOND); + assertThat(getOutput()).contains("Enter enum name or number:"); + } + + @Test + void enumChoice_ValidName_ReturnsCorrectEnum() { + setSystemIn("SECOND\n"); + List choices = List.of(TestEnum.FIRST, TestEnum.SECOND, TestEnum.THIRD); + + TestEnum result = TerminalHelper.singleChoice("Select enum:", "enum", choices); + + assertThat(result).isEqualTo(TestEnum.SECOND); + } + + @Test + void enumChoice_ValidNameCaseInsensitive_ReturnsCorrectEnum() { + setSystemIn("second\n"); + List choices = List.of(TestEnum.FIRST, TestEnum.SECOND, TestEnum.THIRD); + + TestEnum result = TerminalHelper.singleChoice("Select enum:", "enum", choices); + + assertThat(result).isEqualTo(TestEnum.SECOND); + } + + @Test + void enumChoice_ValidNameMixedCase_ReturnsCorrectEnum() { + setSystemIn("SeCOnD\n"); + List choices = List.of(TestEnum.FIRST, TestEnum.SECOND, TestEnum.THIRD); + + TestEnum result = TerminalHelper.singleChoice("Select enum:", "enum", choices); + + assertThat(result).isEqualTo(TestEnum.SECOND); + } + + @Test + void invalidInputThenValid_RepromptsAndReturnsCorrectChoice() { + setSystemIn("0\n2\n"); + List choices = List.of("Apple", "Banana", "Cherry"); + + String result = TerminalHelper.singleChoice("Select fruit:", "fruit", choices); + + assertThat(result).isEqualTo("Banana"); + assertThat(getOutput()).contains("Select fruit:"); + } + + @Test + void numberOutOfRangeThenValid_RepromptsAndReturnsCorrectChoice() { + setSystemIn("10\n1\n"); + List choices = List.of("Apple", "Banana"); + + String result = TerminalHelper.singleChoice("Select fruit:", "fruit", choices); + + assertThat(result).isEqualTo("Apple"); + } + + @Test + void negativeNumberThenValid_RepromptsAndReturnsCorrectChoice() { + setSystemIn("-1\n1\n"); + List choices = List.of("Apple", "Banana"); + + String result = TerminalHelper.singleChoice("Select fruit:", "fruit", choices); + + assertThat(result).isEqualTo("Apple"); + } + + @Test + void nonNumericInputForNonEnum_RepromptsAndReturnsCorrectChoice() { + setSystemIn("invalid\n2\n"); + List choices = List.of("Apple", "Banana"); + + String result = TerminalHelper.singleChoice("Select fruit:", "fruit", choices); + + assertThat(result).isEqualTo("Banana"); + } + + @Test + void inputWithWhitespace_TrimsAndReturnsCorrectChoice() { + setSystemIn(" 2 \n"); + List choices = List.of("Apple", "Banana", "Cherry"); + + String result = TerminalHelper.singleChoice("Select fruit:", "fruit", choices); + + assertThat(result).isEqualTo("Banana"); + } + } + + @Nested + class MultiChoiceTest { + + @Test + void nullChoiceHeader_ThrowsNullPointerException() { + assertThatThrownBy(() -> TerminalHelper.multiChoice(null, "item", List.of("A", "B"))) + .isInstanceOf(NullPointerException.class) + .hasMessage("`choiceHeader` must not be null"); + } + + @Test + void nullPromptObject_ThrowsNullPointerException() { + assertThatThrownBy(() -> TerminalHelper.multiChoice("Header", null, List.of("A", "B"))) + .isInstanceOf(NullPointerException.class) + .hasMessage("`promptObject` must not be null"); + } + + @Test + void nullChoices_ThrowsNullPointerException() { + assertThatThrownBy(() -> TerminalHelper.multiChoice("Header", "item", null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`choices` must not be null"); + } + + @Test + void emptyChoices_ReturnsEmptyList() { + assertThatThrownBy(() -> TerminalHelper.multiChoice("Header", "item", List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("No choices provided"); + } + + @Test + void choicesContainsNull_ThrowsNullPointerException() { + assertThatThrownBy(() -> TerminalHelper.multiChoice("Header", "item", Arrays.asList("A", null, "C"))) + .isInstanceOf(NullPointerException.class) + .hasMessage("All choices must not be null"); + } + + @Test + void singleNumberInput_ReturnsSingleChoice() { + setSystemIn("2\n"); + List choices = List.of("Apple", "Banana", "Cherry"); + + List result = TerminalHelper.multiChoice("Select fruits:", "fruit", choices); + + assertThat(result) + .hasSize(1) + .containsExactly("Banana"); + assertThat(getOutput()) + .contains("Select fruits:") + .contains("1: Apple") + .contains("2: Banana") + .contains("3: Cherry") + .contains("Enter fruit numbers separated by spaces, commas or semicolons:"); + } + + @Test + void multipleNumbersSpaceSeparated_ReturnsMultipleChoices() { + setSystemIn("1 2 3\n"); + List choices = List.of("Apple", "Banana", "Cherry"); + + List result = TerminalHelper.multiChoice("Select fruits:", "fruit", choices); + + assertThat(result) + .hasSize(3) + .containsExactly("Apple", "Banana", "Cherry"); + } + + @Test + void multipleNumbersCommaSeparated_ReturnsMultipleChoices() { + setSystemIn("1,2,3\n"); + List choices = List.of("Apple", "Banana", "Cherry"); + + List result = TerminalHelper.multiChoice("Select fruits:", "fruit", choices); + + assertThat(result) + .hasSize(3) + .containsExactly("Apple", "Banana", "Cherry"); + } + + @Test + void multipleNumbersSemicolonSeparated_ReturnsMultipleChoices() { + setSystemIn("1;2;3\n"); + List choices = List.of("Apple", "Banana", "Cherry"); + + List result = TerminalHelper.multiChoice("Select fruits:", "fruit", choices); + + assertThat(result) + .hasSize(3) + .containsExactly("Apple", "Banana", "Cherry"); + } + + @Test + void multipleNumbersMixedSeparators_ReturnsMultipleChoices() { + setSystemIn("1, 2; 3\n"); + List choices = List.of("Apple", "Banana", "Cherry"); + + List result = TerminalHelper.multiChoice("Select fruits:", "fruit", choices); + + assertThat(result) + .hasSize(3) + .containsExactly("Apple", "Banana", "Cherry"); + } + + @Test + void enumChoice_ValidNumbers_ReturnsCorrectEnums() { + setSystemIn("1 3\n"); + List choices = List.of(TestEnum.FIRST, TestEnum.SECOND, TestEnum.THIRD); + + List result = TerminalHelper.multiChoice("Select enums:", "enum", choices); + + assertThat(result) + .hasSize(2) + .containsExactly(TestEnum.FIRST, TestEnum.THIRD); + assertThat(getOutput()).contains("Enter enum names or number separated by spaces, commas or semicolons:"); + } + + @Test + void enumChoice_ValidNames_ReturnsCorrectEnums() { + setSystemIn("FIRST THIRD\n"); + List choices = List.of(TestEnum.FIRST, TestEnum.SECOND, TestEnum.THIRD); + + List result = TerminalHelper.multiChoice("Select enums:", "enum", choices); + + assertThat(result) + .hasSize(2) + .containsExactly(TestEnum.FIRST, TestEnum.THIRD); + } + + @Test + void enumChoice_MixedNumbersAndNames_ReturnsCorrectEnums() { + setSystemIn("1 THIRD\n"); + List choices = List.of(TestEnum.FIRST, TestEnum.SECOND, TestEnum.THIRD); + + List result = TerminalHelper.multiChoice("Select enums:", "enum", choices); + + assertThat(result) + .hasSize(2) + .containsExactly(TestEnum.FIRST, TestEnum.THIRD); + } + + @Test + void enumChoice_CaseInsensitiveNames_ReturnsCorrectEnums() { + setSystemIn("first third\n"); + List choices = List.of(TestEnum.FIRST, TestEnum.SECOND, TestEnum.THIRD); + + List result = TerminalHelper.multiChoice("Select enums:", "enum", choices); + + assertThat(result) + .hasSize(2) + .containsExactly(TestEnum.FIRST, TestEnum.THIRD); + } + + @Test + void blankInputThenValid_RepromptsAndReturnsCorrectChoices() { + setSystemIn("\n1 2\n"); + List choices = List.of("Apple", "Banana", "Cherry"); + + List result = TerminalHelper.multiChoice("Select fruits:", "fruit", choices); + + assertThat(result) + .hasSize(2) + .containsExactly("Apple", "Banana"); + } + + @Test + void invalidChoiceThenValid_RepromptsAndReturnsCorrectChoices() { + setSystemIn("1 99\n1 2\n"); + List choices = List.of("Apple", "Banana", "Cherry"); + + List result = TerminalHelper.multiChoice("Select fruits:", "fruit", choices); + + assertThat(result) + .hasSize(2) + .containsExactly("Apple", "Banana"); + assertThat(getOutput()).contains("Invalid fruit: 99"); + } + + @Test + void invalidChoiceInMiddleThenValid_RepromptsAndReturnsCorrectChoices() { + setSystemIn("1 invalid 2\n1 2\n"); + List choices = List.of("Apple", "Banana", "Cherry"); + + List result = TerminalHelper.multiChoice("Select fruits:", "fruit", choices); + + assertThat(result) + .hasSize(2) + .containsExactly("Apple", "Banana"); + assertThat(getOutput()).contains("Invalid fruit: invalid"); + } + + @Test + void inputWithExtraWhitespace_TrimsAndReturnsCorrectChoices() { + setSystemIn(" 1 2 3 \n\n"); + List choices = List.of("Apple", "Banana", "Cherry"); + + List result = TerminalHelper.multiChoice("Select fruits:", "fruit", choices); + + assertThat(result) + .hasSize(3) + .containsExactly("Apple", "Banana", "Cherry"); + } + + @Test + void duplicateChoices_ReturnsWithDuplicates() { + setSystemIn("1 1 2\n\n"); + List choices = List.of("Apple", "Banana", "Cherry"); + + List result = TerminalHelper.multiChoice("Select fruits:", "fruit", choices); + + assertThat(result) + .hasSize(3) + .containsExactly("Apple", "Apple", "Banana"); + } + } +} 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 new file mode 100644 index 0000000..4cc0d74 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java @@ -0,0 +1,530 @@ +package io.github.bsels.semantic.version.utils; + +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.project.MavenProject; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.nio.file.CopyOption; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.DayOfWeek; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.BinaryOperator; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +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) +public class UtilsTest { + + @Mock + MavenProject mavenProject; + + @Nested + class BackupFileTest { + + @Test + void nullInput_ThrowsNullPointerException() { + assertThatThrownBy(() -> Utils.backupFile(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`file` must not be null"); + } + + @Test + void nonExistingFile_DoNothing() { + try (MockedStatic files = Mockito.mockStatic(Files.class)) { + Path file = Path.of("project/pom.xml"); + files.when(() -> Files.exists(file)) + .thenReturn(false); + + assertThatNoException() + .isThrownBy(() -> Utils.backupFile(file)); + + files.verify(() -> Files.copy( + Mockito.any(Path.class), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any() + ), Mockito.never()); + } + } + + @Test + void copyFailed_ThrowsMojoExceptionException() { + try (MockedStatic files = Mockito.mockStatic(Files.class)) { + files.when(() -> Files.copy(Mockito.any(Path.class), Mockito.any(), Mockito.any(CopyOption[].class))) + .thenThrow(new IOException("copy failed")); + + Path file = Path.of("project/pom.xml"); + files.when(() -> Files.exists(file)) + .thenReturn(true); + Path backupFile = Path.of("project/pom.xml" + Utils.BACKUP_SUFFIX); + assertThatThrownBy(() -> Utils.backupFile(file)) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Failed to backup %s to %s".formatted(file, backupFile)) + .hasRootCauseInstanceOf(IOException.class) + .hasRootCauseMessage("copy failed"); + + files.verify(() -> Files.copy( + file, + backupFile, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.COPY_ATTRIBUTES, + StandardCopyOption.REPLACE_EXISTING + ), Mockito.times(1)); + } + } + + @Test + void copySuccess_NoErrors() { + try (MockedStatic files = Mockito.mockStatic(Files.class)) { + Path backupFile = Path.of("project/pom.xml" + Utils.BACKUP_SUFFIX); + files.when(() -> Files.copy(Mockito.any(Path.class), Mockito.any(), Mockito.any(CopyOption[].class))) + .thenReturn(backupFile); + + Path file = Path.of("project/pom.xml"); + files.when(() -> Files.exists(file)) + .thenReturn(true); + assertThatNoException() + .isThrownBy(() -> Utils.backupFile(file)); + + files.verify(() -> Files.copy( + file, + backupFile, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.COPY_ATTRIBUTES, + StandardCopyOption.REPLACE_EXISTING + ), Mockito.times(1)); + } + } + } + + @Nested + class CreateTemporaryMarkdownFileTest { + + @Test + void createTempFileSuccess_ReturnsPath() { + try (MockedStatic files = Mockito.mockStatic(Files.class)) { + Path expectedPath = Path.of("/tmp/versioning-12345.md"); + files.when(() -> Files.createTempFile("versioning-", ".md")) + .thenReturn(expectedPath); + + assertThatNoException() + .isThrownBy(() -> { + Path actualPath = Utils.createTemporaryMarkdownFile(); + assertThat(actualPath).isEqualTo(expectedPath); + }); + + files.verify(() -> Files.createTempFile("versioning-", ".md"), Mockito.times(1)); + } + } + + @Test + void createTempFileFails_ThrowsMojoExecutionException() { + try (MockedStatic files = Mockito.mockStatic(Files.class)) { + IOException ioException = new IOException("Unable to create temp file"); + files.when(() -> Files.createTempFile("versioning-", ".md")) + .thenThrow(ioException); + + assertThatThrownBy(Utils::createTemporaryMarkdownFile) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Failed to create temporary file") + .hasCause(ioException); + + files.verify(() -> Files.createTempFile("versioning-", ".md"), Mockito.times(1)); + } + } + } + + @Nested + class DeleteFileIfExistsTest { + + @Test + void nullInput_ThrowsNullPointerException() { + assertThatThrownBy(() -> Utils.deleteFileIfExists(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`path` must not be null"); + } + + @Test + void fileDoesNotExist_NoException() { + try (MockedStatic files = Mockito.mockStatic(Files.class)) { + Path file = Path.of("project/file.txt"); + files.when(() -> Files.deleteIfExists(file)) + .thenReturn(false); + + assertThatNoException() + .isThrownBy(() -> Utils.deleteFileIfExists(file)); + + files.verify(() -> Files.deleteIfExists(file), Mockito.times(1)); + } + } + + @Test + void fileExists_DeletesSuccessfully() { + try (MockedStatic files = Mockito.mockStatic(Files.class)) { + Path file = Path.of("project/file.txt"); + files.when(() -> Files.deleteIfExists(file)) + .thenReturn(true); + + assertThatNoException() + .isThrownBy(() -> Utils.deleteFileIfExists(file)); + + files.verify(() -> Files.deleteIfExists(file), Mockito.times(1)); + } + } + + @Test + void deletionFails_ThrowsMojoExecutionException() { + try (MockedStatic files = Mockito.mockStatic(Files.class)) { + Path file = Path.of("project/file.txt"); + IOException ioException = new IOException("deletion failed"); + files.when(() -> Files.deleteIfExists(file)) + .thenThrow(ioException); + + assertThatThrownBy(() -> Utils.deleteFileIfExists(file)) + .isInstanceOf(MojoExecutionException.class) + .hasCause(ioException); + + files.verify(() -> Files.deleteIfExists(file), Mockito.times(1)); + } + } + } + + @Nested + class DeleteFilesIfExistsTest { + + @Test + void nullInput_ThrowsNullPointerException() { + assertThatThrownBy(() -> Utils.deleteFilesIfExists(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`paths` must not be null"); + } + + @Test + void emptyCollection_NoException() { + try (MockedStatic files = Mockito.mockStatic(Files.class)) { + assertThatNoException() + .isThrownBy(() -> Utils.deleteFilesIfExists(List.of())); + + files.verify(() -> Files.deleteIfExists(Mockito.any()), Mockito.never()); + } + } + + @Test + void multipleFiles_DeletesAllSuccessfully() { + try (MockedStatic files = Mockito.mockStatic(Files.class)) { + Path file1 = Path.of("project/file1.txt"); + Path file2 = Path.of("project/file2.txt"); + Path file3 = Path.of("project/file3.txt"); + List paths = List.of(file1, file2, file3); + + files.when(() -> Files.deleteIfExists(Mockito.any())) + .thenReturn(true); + + assertThatNoException() + .isThrownBy(() -> Utils.deleteFilesIfExists(paths)); + + files.verify(() -> Files.deleteIfExists(file1), Mockito.times(1)); + files.verify(() -> Files.deleteIfExists(file2), Mockito.times(1)); + files.verify(() -> Files.deleteIfExists(file3), Mockito.times(1)); + } + } + + @Test + void deletionFailsOnSecondFile_ThrowsMojoExecutionException() { + try (MockedStatic files = Mockito.mockStatic(Files.class)) { + Path file1 = Path.of("project/file1.txt"); + Path file2 = Path.of("project/file2.txt"); + Path file3 = Path.of("project/file3.txt"); + List paths = List.of(file1, file2, file3); + + IOException ioException = new IOException("deletion failed"); + files.when(() -> Files.deleteIfExists(file1)) + .thenReturn(true); + files.when(() -> Files.deleteIfExists(file2)) + .thenThrow(ioException); + + assertThatThrownBy(() -> Utils.deleteFilesIfExists(paths)) + .isInstanceOf(MojoExecutionException.class) + .hasCause(ioException); + + files.verify(() -> Files.deleteIfExists(file1), Mockito.times(1)); + files.verify(() -> Files.deleteIfExists(file2), Mockito.times(1)); + files.verify(() -> Files.deleteIfExists(file3), Mockito.never()); + } + } + } + + @Nested + class CreateDirectoryIfNotExistsTest { + + @Test + void nullPath_ThrowsNullPointerException() { + assertThatThrownBy(() -> Utils.createDirectoryIfNotExists(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`path` must not be null"); + } + + @Test + void directoryExists_NoException() { + try (MockedStatic files = Mockito.mockStatic(Files.class)) { + Path directory = Path.of("project/directory"); + files.when(() -> Files.exists(directory)) + .thenReturn(true); + + assertThatNoException() + .isThrownBy(() -> Utils.createDirectoryIfNotExists(directory)); + + files.verify(() -> Files.createDirectories(directory), Mockito.never()); + } + } + + @Test + void directoryDoesNotExist_CreatesSuccessfully() { + try (MockedStatic files = Mockito.mockStatic(Files.class)) { + Path directory = Path.of("project/directory"); + files.when(() -> Files.exists(directory)) + .thenReturn(false); + + assertThatNoException() + .isThrownBy(() -> Utils.createDirectoryIfNotExists(directory)); + + files.verify(() -> Files.createDirectories(directory), Mockito.times(1)); + } + } + + @Test + void creationFails_ThrowsMojoExecutionException() { + try (MockedStatic files = Mockito.mockStatic(Files.class)) { + Path directory = Path.of("project/directory"); + IOException ioException = new IOException("creation failed"); + files.when(() -> Files.exists(directory)) + .thenReturn(false); + files.when(() -> Files.createDirectories(directory)) + .thenThrow(ioException); + + assertThatThrownBy(() -> Utils.createDirectoryIfNotExists(directory)) + .isInstanceOf(MojoExecutionException.class) + .hasMessage("Failed to create directory") + .hasCause(ioException); + + files.verify(() -> Files.createDirectories(directory), Mockito.times(1)); + } + } + } + + @Nested + class AlwaysTrueTest { + + @ParameterizedTest + @NullSource + @EnumSource(DayOfWeek.class) + @ValueSource(booleans = {true, false}) + @ValueSource(ints = {-4, -3, -2, -1, 0, 1, 2, 3, 4}) + @ValueSource(strings = {"", "a", "abc"}) + void anyInput_AlwaysTrue(Object input) { + Predicate predicate = Utils.alwaysTrue(); + assertThat(predicate.test(input)) + .isTrue(); + } + } + + @Nested + class MavenProjectHasNoModulesTest { + + @Test + void noModules_True() { + Mockito.when(mavenProject.getModules()) + .thenReturn(List.of()); + + Predicate predicate = Utils.mavenProjectHasNoModules(); + assertThat(predicate.test(mavenProject)) + .isTrue(); + } + + @Test + void withModules_False() { + Mockito.when(mavenProject.getModules()) + .thenReturn(List.of("module1", "module2")); + + Predicate predicate = Utils.mavenProjectHasNoModules(); + assertThat(predicate.test(mavenProject)) + .isFalse(); + } + } + + @Nested + class ConsumerToOperatorTest { + + @Test + void nullInput_ThrowsNullPointerException() { + assertThatThrownBy(() -> Utils.consumerToOperator(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`accumulator` must not be null"); + } + + @Test + void setAddAll_CorrectlyAddedAndReturned() { + BinaryOperator> operator = Utils.consumerToOperator(Set::addAll); + + Set set = new HashSet<>(Set.of(1, 2, 3)); + assertThat(operator.apply(set, Set.of(4, 5, 6))) + .isEqualTo(Set.of(1, 2, 3, 4, 5, 6)) + .isSameAs(set); + } + } + + @Nested + class GroupingByImmutableTest { + + @Test + void oddEvenNumbers_CorrectlySplitMapIsImmutable() { + Map> actual = IntStream.range(0, 10) + .boxed() + .collect(Utils.groupingByImmutable(i -> (i & 1) == 1, Collectors.toList())); + + assertThat(actual) + .isNotNull() + .hasSize(2) + .hasEntrySatisfying(true, list -> assertThat(list) + .hasSize(5) + .containsExactly(1, 3, 5, 7, 9) + ) + .hasEntrySatisfying(false, list -> assertThat(list) + .hasSize(5) + .containsExactly(0, 2, 4, 6, 8) + ); + + assertThatThrownBy(() -> actual.put(true, List.of(10))) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(actual::clear) + .isInstanceOf(UnsupportedOperationException.class); + + assertThat(Map.copyOf(actual)) + .isSameAs(actual); + } + } + + @Nested + class AsImmutableListTest { + + @Test + void emptyStream_EmptyAndImmutable() { + List list = Stream.of() + .collect(Utils.asImmutableList()); + + assertThat(list) + .isEmpty(); + + assertThatThrownBy(() -> list.add(1)) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(list::clear) + .isInstanceOf(UnsupportedOperationException.class); + + assertThat(List.copyOf(list)) + .isSameAs(list); + } + + @Test + void nonEmptyStream_NonEmptyAndImmutable() { + List list = Stream.of(1, 2, 3) + .collect(Utils.asImmutableList()); + + assertThat(list) + .containsExactly(1, 2, 3); + + assertThatThrownBy(() -> list.add(4)) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(list::clear) + .isInstanceOf(UnsupportedOperationException.class); + + assertThat(List.copyOf(list)) + .isSameAs(list); + } + } + + @Nested + class AsImmutableSetTest { + + @Test + void emptyStream_EmptyAndImmutable() { + Set set = Stream.of() + .collect(Utils.asImmutableSet()); + + assertThat(set) + .isEmpty(); + + assertThatThrownBy(() -> set.add(1)) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(set::clear) + .isInstanceOf(UnsupportedOperationException.class); + + assertThat(Set.copyOf(set)) + .isSameAs(set); + } + + @Test + void nonEmptyStream_NonEmptyAndImmutable() { + Set list = Stream.of(1, 2, 3) + .collect(Utils.asImmutableSet()); + + assertThat(list) + .containsExactlyInAnyOrder(1, 2, 3); + + assertThatThrownBy(() -> list.add(4)) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(list::clear) + .isInstanceOf(UnsupportedOperationException.class); + + assertThat(Set.copyOf(list)) + .isSameAs(list); + } + } + + @Nested + class ResolveVersioningFileTest { + + @Test + void nullProject_ThrowsNullPointerException() { + assertThatThrownBy(() -> Utils.resolveVersioningFile(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`folder` must not be null"); + } + + @Test + void resolveNewVersioningFile_ValidPath() { + Path folder = Path.of("project"); + LocalDateTime localDateTime = LocalDateTime.of(2023, 1, 1, 12, 0, 8); + try (MockedStatic localDateTimeMock = Mockito.mockStatic(LocalDateTime.class)) { + localDateTimeMock.when(LocalDateTime::now) + .thenReturn(localDateTime); + Path expectedPath = Path.of("project/versioning-20230101120008.md"); + assertThat(Utils.resolveVersioningFile(folder)) + .isEqualTo(expectedPath); + } + } + } +} diff --git a/src/test/java/io/github/bsels/semantic/version/utils/yaml/front/block/MarkdownYamFrontMatterBlockRendererFactoryTest.java b/src/test/java/io/github/bsels/semantic/version/utils/yaml/front/block/MarkdownYamFrontMatterBlockRendererFactoryTest.java new file mode 100644 index 0000000..86a6584 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/utils/yaml/front/block/MarkdownYamFrontMatterBlockRendererFactoryTest.java @@ -0,0 +1,54 @@ +package io.github.bsels.semantic.version.utils.yaml.front.block; + +import org.commonmark.renderer.markdown.MarkdownNodeRendererContext; +import org.commonmark.renderer.markdown.MarkdownWriter; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@ExtendWith(MockitoExtension.class) +public class MarkdownYamFrontMatterBlockRendererFactoryTest { + + @Mock + MarkdownWriter writerMock; + + @Mock + MarkdownNodeRendererContext contextMock; + + @Test + void getSpecialCharacters_ReturnEmptySet() { + Set specialCharacters = MarkdownYamFrontMatterBlockRendererFactory.getInstance().getSpecialCharacters(); + assertThat(specialCharacters) + .isSameAs(Set.of()) + .isEmpty(); + } + + @Test + void createNullPointerContext_ThrowsNullPointerException() { + MarkdownYamFrontMatterBlockRendererFactory instance = MarkdownYamFrontMatterBlockRendererFactory.getInstance(); + assertThatThrownBy(() -> instance.create(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`context` must not be null"); + } + + @Test + void createValidContext_ReturnsInstance() { + Mockito.when(contextMock.getWriter()) + .thenReturn(writerMock); + + MarkdownYamFrontMatterBlockRendererFactory instance = MarkdownYamFrontMatterBlockRendererFactory.getInstance(); + assertThat(instance.create(contextMock)) + .isNotNull() + .isInstanceOf(MarkdownYamFrontMatterBlockRenderer.class); + + Mockito.verify(contextMock, Mockito.times(1)) + .getWriter(); + } +} diff --git a/src/test/java/io/github/bsels/semantic/version/utils/yaml/front/block/MarkdownYamFrontMatterBlockRendererTest.java b/src/test/java/io/github/bsels/semantic/version/utils/yaml/front/block/MarkdownYamFrontMatterBlockRendererTest.java new file mode 100644 index 0000000..97cc9db --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/utils/yaml/front/block/MarkdownYamFrontMatterBlockRendererTest.java @@ -0,0 +1,157 @@ +package io.github.bsels.semantic.version.utils.yaml.front.block; + +import org.commonmark.node.Document; +import org.commonmark.node.Heading; +import org.commonmark.node.Node; +import org.commonmark.node.Paragraph; +import org.commonmark.node.Text; +import org.commonmark.renderer.Renderer; +import org.commonmark.renderer.markdown.MarkdownNodeRendererContext; +import org.commonmark.renderer.markdown.MarkdownRenderer; +import org.commonmark.renderer.markdown.MarkdownWriter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.InvocationTargetException; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@ExtendWith(MockitoExtension.class) +public class MarkdownYamFrontMatterBlockRendererTest { + private static final Renderer MARKDOWN_RENDERER = MarkdownRenderer.builder() + .nodeRendererFactory(MarkdownYamFrontMatterBlockRendererFactory.getInstance()) + .build(); + + @Mock + MarkdownWriter writerMock; + + @Mock + MarkdownNodeRendererContext contextMock; + + @BeforeEach + void setUp() { + Mockito.lenient() + .when(contextMock.getWriter()) + .thenReturn(writerMock); + } + + @Nested + class InstanceMethodsTest { + + @Test + void constructorNullParameter_ThrowsNullPointerException() { + assertThatThrownBy(() -> new MarkdownYamFrontMatterBlockRenderer(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`context` must not be null"); + + Mockito.verifyNoInteractions(contextMock, writerMock); + } + + @Test + void getNodeTypes_ReturnYamlFrontMatterBlock() { + MarkdownYamFrontMatterBlockRenderer classUnderTest = new MarkdownYamFrontMatterBlockRenderer(contextMock); + Set> nodeTypes = classUnderTest.getNodeTypes(); + assertThat(nodeTypes) + .isNotNull() + .hasSize(1) + .containsExactly(YamlFrontMatterBlock.class) + .isSameAs(Set.copyOf(nodeTypes)); + + Mockito.verify(contextMock, Mockito.times(1)) + .getWriter(); + Mockito.verifyNoMoreInteractions(contextMock); + Mockito.verifyNoInteractions(writerMock); + } + + @Test + void nullInputRender_DoNothing() { + MarkdownYamFrontMatterBlockRenderer classUnderTest = new MarkdownYamFrontMatterBlockRenderer(contextMock); + classUnderTest.render(null); + + + Mockito.verify(contextMock, Mockito.times(1)) + .getWriter(); + Mockito.verifyNoMoreInteractions(contextMock); + Mockito.verifyNoInteractions(writerMock); + } + + @ParameterizedTest + @ValueSource(classes = {Document.class, Text.class, Paragraph.class, Heading.class}) + void unsupportedNodeTypes_DoNothing(Class clazz) + throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException { + Node node = clazz.getConstructor().newInstance(); + + MarkdownYamFrontMatterBlockRenderer classUnderTest = new MarkdownYamFrontMatterBlockRenderer(contextMock); + classUnderTest.render(node); + + Mockito.verify(contextMock, Mockito.times(1)) + .getWriter(); + Mockito.verifyNoMoreInteractions(contextMock); + Mockito.verifyNoInteractions(writerMock); + } + + @Test + void renderFrontMatter_CorrectlyCalledMocks() { + YamlFrontMatterBlock block = new YamlFrontMatterBlock("test: data"); + + MarkdownYamFrontMatterBlockRenderer classUnderTest = new MarkdownYamFrontMatterBlockRenderer(contextMock); + classUnderTest.render(block); + + Mockito.verify(contextMock, Mockito.times(1)) + .getWriter(); + Mockito.verify(writerMock, Mockito.times(2)) + .raw("---"); + Mockito.verify(writerMock, Mockito.times(1)) + .raw("test: data"); + Mockito.verify(writerMock, Mockito.times(4)) + .line(); + + Mockito.verifyNoMoreInteractions(contextMock, writerMock); + } + } + + @Nested + class IntegrationTest { + + @Test + void withoutFrontMatter_ValidMarkdown() { + Document document = new Document(); + Paragraph paragraph = new Paragraph(); + paragraph.appendChild(new Text("Test")); + document.appendChild(paragraph); + + String markdown = MARKDOWN_RENDERER.render(document); + assertThat(markdown) + .isEqualTo("Test\n"); + } + + @Test + void withFrontMatter_ValidMarkdown() { + Document document = new Document(); + YamlFrontMatterBlock block = new YamlFrontMatterBlock("test: data"); + document.appendChild(block); + Paragraph paragraph = new Paragraph(); + paragraph.appendChild(new Text("Test")); + document.appendChild(paragraph); + + String markdown = MARKDOWN_RENDERER.render(document); + assertThat(markdown) + .isEqualTo(""" + --- + test: data + --- + + Test + """); + } + } +} diff --git a/src/test/java/io/github/bsels/semantic/version/utils/yaml/front/block/YamlFrontMatterBlockParserTest.java b/src/test/java/io/github/bsels/semantic/version/utils/yaml/front/block/YamlFrontMatterBlockParserTest.java new file mode 100644 index 0000000..f7fd1aa --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/utils/yaml/front/block/YamlFrontMatterBlockParserTest.java @@ -0,0 +1,202 @@ +package io.github.bsels.semantic.version.utils.yaml.front.block; + +import org.commonmark.node.BulletList; +import org.commonmark.node.Document; +import org.commonmark.node.Heading; +import org.commonmark.node.ListItem; +import org.commonmark.node.Node; +import org.commonmark.node.Paragraph; +import org.commonmark.node.Text; +import org.commonmark.parser.IncludeSourceSpans; +import org.commonmark.parser.Parser; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static io.github.bsels.semantic.version.test.utils.MarkdownDocumentAsserter.assertThatDocument; +import static io.github.bsels.semantic.version.test.utils.MarkdownDocumentAsserter.hasHeading; +import static io.github.bsels.semantic.version.test.utils.MarkdownDocumentAsserter.hasParagraph; +import static io.github.bsels.semantic.version.test.utils.MarkdownDocumentAsserter.hasThematicBreak; +import static org.assertj.core.api.Assertions.assertThat; + +public class YamlFrontMatterBlockParserTest { + private static final Parser PARSER = Parser.builder() + .extensions(List.of(YamlFrontMatterExtension.create())) + .includeSourceSpans(IncludeSourceSpans.BLOCKS_AND_INLINES) + .build(); + + @Nested + class NoFrontMatterBlockTest { + + @Test + void noHeaderBlock_ReturnValidMarkdownTree() { + String markdown = """ + # No front matter + + This is a test + """; + + Node actual = PARSER.parse(markdown); + + assertThatDocument( + actual, + hasHeading(1, "No front matter"), + hasParagraph("This is a test") + ); + } + + @Test + void noFrontMatterBlockButHasHorizontalLine_ReturnValidMarkdownTree() { + String markdown = """ + Paragraph 1 + + --- + + Paragraph 2 + """; + + Node actual = PARSER.parse(markdown); + + assertThatDocument( + actual, + hasParagraph("Paragraph 1"), + hasThematicBreak(), + hasParagraph("Paragraph 2") + ); + } + + @Test + void noFrontMatterWithList_ReturnValidMarkdownTree() { + String markdown = """ + - Item 1 + - Item 2 + - Item 3 + """; + + Node actual = PARSER.parse(markdown); + + assertThat(actual) + .isInstanceOf(Document.class) + .extracting(Node::getFirstChild) + .isNotNull() + .isInstanceOf(BulletList.class) + .satisfies( + list -> assertThat(list.getFirstChild()) + .isNotNull() + .isInstanceOf(ListItem.class) + .satisfies( + listItem -> assertThat(listItem.getFirstChild()) + .isInstanceOf(Paragraph.class) + .satisfies( + n -> assertThat(n.getFirstChild()) + .isNotNull() + .isInstanceOf(Text.class) + .hasFieldOrPropertyWithValue("literal", "Item 1") + .extracting(Node::getNext) + .isNull() + ) + .extracting(Node::getNext) + .isNull() + ) + .extracting(Node::getNext) + .isInstanceOf(ListItem.class) + .satisfies( + listItem -> assertThat(listItem.getFirstChild()) + .isInstanceOf(Paragraph.class) + .satisfies( + n -> assertThat(n.getFirstChild()) + .isNotNull() + .isInstanceOf(Text.class) + .hasFieldOrPropertyWithValue("literal", "Item 2") + .extracting(Node::getNext) + .isNull() + ) + .extracting(Node::getNext) + .isNull() + ) + .extracting(Node::getNext) + .isInstanceOf(ListItem.class) + .satisfies( + listItem -> assertThat(listItem.getFirstChild()) + .isInstanceOf(Paragraph.class) + .satisfies( + n -> assertThat(n.getFirstChild()) + .isNotNull() + .isInstanceOf(Text.class) + .hasFieldOrPropertyWithValue("literal", "Item 3") + .extracting(Node::getNext) + .isNull() + ) + .extracting(Node::getNext) + .isNull() + ) + .extracting(Node::getNext) + .isNull() + ) + .extracting(Node::getNext) + .isNull(); + } + } + + @Nested + class WithFrontMatterBlockTest { + + @Test + void withYamlFrontMatterBlock_ReturnCorrectMarkdownAndYamlBlock() { + + String markdown = """ + --- + test: + data: "Test data" + index: 0 + --- + + # Front matter + + This is a test + """; + + Node actual = PARSER.parse(markdown); + + assertThat(actual) + .isInstanceOf(Document.class) + .extracting(Node::getFirstChild) + .isInstanceOf(YamlFrontMatterBlock.class) + .hasFieldOrPropertyWithValue( + "yaml", + """ + test: + data: "Test data" + index: 0\ + """ + ) + .hasFieldOrPropertyWithValue("firstChild", null) + .extracting(Node::getNext) + .isNotNull() + .isInstanceOf(Heading.class) + .hasFieldOrPropertyWithValue("level", 1) + .satisfies( + n -> assertThat(n.getFirstChild()) + .isNotNull() + .isInstanceOf(Text.class) + .hasFieldOrPropertyWithValue("literal", "Front matter") + .extracting(Node::getNext) + .isNull() + ) + .extracting(Node::getNext) + .isNotNull() + .isInstanceOf(Paragraph.class) + .satisfies( + n -> assertThat(n.getFirstChild()) + .isNotNull() + .isInstanceOf(Text.class) + .hasFieldOrPropertyWithValue("literal", "This is a test") + .extracting(Node::getNext) + .isNull() + ) + .extracting(Node::getNext) + .isNull(); + } + } +} diff --git a/src/test/java/io/github/bsels/semantic/version/utils/yaml/front/block/YamlFrontMatterBlockTest.java b/src/test/java/io/github/bsels/semantic/version/utils/yaml/front/block/YamlFrontMatterBlockTest.java new file mode 100644 index 0000000..34b8701 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/utils/yaml/front/block/YamlFrontMatterBlockTest.java @@ -0,0 +1,40 @@ +package io.github.bsels.semantic.version.utils.yaml.front.block; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class YamlFrontMatterBlockTest { + + @Test + void nullPointerInConstructor_ThrowsNullPointerException() { + assertThatThrownBy(() -> new YamlFrontMatterBlock(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`yaml` must not be null"); + } + + @Test + void validConstructor_GetterReturnsValue() { + String yaml = "test: data"; + YamlFrontMatterBlock block = new YamlFrontMatterBlock(yaml); + assertThat(block.getYaml()) + .isEqualTo(yaml); + } + + @Test + void nullPointerInSetter_ThrowsNullPointerException() { + YamlFrontMatterBlock block = new YamlFrontMatterBlock(""); + assertThatThrownBy(() -> block.setYaml(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`yaml` must not be null"); + } + + @Test + void validSetter_GetterReturnsValue() { + YamlFrontMatterBlock block = new YamlFrontMatterBlock(""); + block.setYaml("test: data"); + assertThat(block.getYaml()) + .isEqualTo("test: data"); + } +} diff --git a/src/test/java/io/github/bsels/semantic/version/utils/yaml/front/block/YamlFrontMatterExtensionTest.java b/src/test/java/io/github/bsels/semantic/version/utils/yaml/front/block/YamlFrontMatterExtensionTest.java new file mode 100644 index 0000000..6554a61 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/utils/yaml/front/block/YamlFrontMatterExtensionTest.java @@ -0,0 +1,38 @@ +package io.github.bsels.semantic.version.utils.yaml.front.block; + +import org.commonmark.parser.Parser; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThatNoException; + +@ExtendWith(MockitoExtension.class) +public class YamlFrontMatterExtensionTest { + + @Mock + Parser.Builder parserBuilderMock; + + @Test + public void constructionThroughConstructor_NoErrors() { + assertThatNoException() + .isThrownBy(YamlFrontMatterExtension::new); + } + + @Test + public void constructionThroughStaticMethod_NoErrors() { + assertThatNoException() + .isThrownBy(YamlFrontMatterExtension::create); + } + + @Test + public void extend_NoErrors() { + assertThatNoException() + .isThrownBy(() -> YamlFrontMatterExtension.create().extend(parserBuilderMock)); + + Mockito.verify(parserBuilderMock, Mockito.times(1)) + .customBlockParserFactory(Mockito.any(YamlFrontMatterBlockParser.Factory.class)); + } +} diff --git a/src/test/resources/itests/leaves/child-1/CHANGELOG.md b/src/test/resources/itests/leaves/child-1/CHANGELOG.md new file mode 100644 index 0000000..6c1876e --- /dev/null +++ b/src/test/resources/itests/leaves/child-1/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 5.0.0-child-1 - 2026-01-01 + +Initial child 1 release. \ No newline at end of file diff --git a/src/test/resources/itests/leaves/child-1/pom.xml b/src/test/resources/itests/leaves/child-1/pom.xml new file mode 100644 index 0000000..85889a2 --- /dev/null +++ b/src/test/resources/itests/leaves/child-1/pom.xml @@ -0,0 +1,9 @@ + + + 4.0.0 + org.example.itests.leaves + child-1 + 5.0.0-child-1 + \ No newline at end of file diff --git a/src/test/resources/itests/leaves/intermediate/child-2/CHANGELOG.md b/src/test/resources/itests/leaves/intermediate/child-2/CHANGELOG.md new file mode 100644 index 0000000..2bea163 --- /dev/null +++ b/src/test/resources/itests/leaves/intermediate/child-2/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 5.0.0-child-2 - 2026-01-01 + +Initial child 2 release. \ No newline at end of file diff --git a/src/test/resources/itests/leaves/intermediate/child-2/pom.xml b/src/test/resources/itests/leaves/intermediate/child-2/pom.xml new file mode 100644 index 0000000..0ed5109 --- /dev/null +++ b/src/test/resources/itests/leaves/intermediate/child-2/pom.xml @@ -0,0 +1,9 @@ + + + 4.0.0 + org.example.itests.leaves + child-2 + 5.0.0-child-2 + \ No newline at end of file diff --git a/src/test/resources/itests/leaves/intermediate/child-3/CHANGELOG.md b/src/test/resources/itests/leaves/intermediate/child-3/CHANGELOG.md new file mode 100644 index 0000000..01638db --- /dev/null +++ b/src/test/resources/itests/leaves/intermediate/child-3/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 5.0.0-child-3 - 2026-01-01 + +Initial child 3 release. \ No newline at end of file diff --git a/src/test/resources/itests/leaves/intermediate/child-3/pom.xml b/src/test/resources/itests/leaves/intermediate/child-3/pom.xml new file mode 100644 index 0000000..cd8ef8f --- /dev/null +++ b/src/test/resources/itests/leaves/intermediate/child-3/pom.xml @@ -0,0 +1,9 @@ + + + 4.0.0 + org.example.itests.leaves + child-3 + 5.0.0-child-3 + \ No newline at end of file diff --git a/src/test/resources/itests/leaves/intermediate/pom.xml b/src/test/resources/itests/leaves/intermediate/pom.xml new file mode 100644 index 0000000..c3fd102 --- /dev/null +++ b/src/test/resources/itests/leaves/intermediate/pom.xml @@ -0,0 +1,14 @@ + + + 4.0.0 + org.example.itests.leaves + intermediate + 5.0.0-intermediate + + + child-2 + child-3 + + \ No newline at end of file diff --git a/src/test/resources/itests/leaves/pom.xml b/src/test/resources/itests/leaves/pom.xml new file mode 100644 index 0000000..faba402 --- /dev/null +++ b/src/test/resources/itests/leaves/pom.xml @@ -0,0 +1,14 @@ + + + 4.0.0 + org.example.itests.leaves + root + 5.0.0-root + + + child-1 + intermediate + + \ No newline at end of file diff --git a/src/test/resources/itests/multi-recursive/CHANGELOG.md b/src/test/resources/itests/multi-recursive/CHANGELOG.md new file mode 100644 index 0000000..1720a8c --- /dev/null +++ b/src/test/resources/itests/multi-recursive/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 6.0.0-parent - 2026-01-01 + +Initial parent release. \ No newline at end of file diff --git a/src/test/resources/itests/multi-recursive/child-1/CHANGELOG.md b/src/test/resources/itests/multi-recursive/child-1/CHANGELOG.md new file mode 100644 index 0000000..f321150 --- /dev/null +++ b/src/test/resources/itests/multi-recursive/child-1/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 6.0.0-child-1 - 2026-01-01 + +Initial child 1 release. \ No newline at end of file diff --git a/src/test/resources/itests/multi-recursive/child-1/pom.xml b/src/test/resources/itests/multi-recursive/child-1/pom.xml new file mode 100644 index 0000000..e6bddd8 --- /dev/null +++ b/src/test/resources/itests/multi-recursive/child-1/pom.xml @@ -0,0 +1,14 @@ + + + + org.example.itests.multi-recursive + parent + 6.0.0-parent + + + 4.0.0 + child-1 + 6.0.0-child-1 + \ No newline at end of file diff --git a/src/test/resources/itests/multi-recursive/child-2/CHANGELOG.md b/src/test/resources/itests/multi-recursive/child-2/CHANGELOG.md new file mode 100644 index 0000000..451ec68 --- /dev/null +++ b/src/test/resources/itests/multi-recursive/child-2/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 6.0.0-child-2 - 2026-01-01 + +Initial child 2 release. \ No newline at end of file diff --git a/src/test/resources/itests/multi-recursive/child-2/pom.xml b/src/test/resources/itests/multi-recursive/child-2/pom.xml new file mode 100644 index 0000000..8a3c3b9 --- /dev/null +++ b/src/test/resources/itests/multi-recursive/child-2/pom.xml @@ -0,0 +1,17 @@ + + + 4.0.0 + org.example.itests.multi-recursive + child-2 + 6.0.0-child-2 + + + + org.example.itests.multi-recursive + child-1 + 6.0.0-child-1 + + + \ No newline at end of file diff --git a/src/test/resources/itests/multi-recursive/pom.xml b/src/test/resources/itests/multi-recursive/pom.xml new file mode 100644 index 0000000..092b9e4 --- /dev/null +++ b/src/test/resources/itests/multi-recursive/pom.xml @@ -0,0 +1,14 @@ + + + 4.0.0 + org.example.itests.multi-recursive + parent + 6.0.0-parent + + + child-1 + child-2 + + \ No newline at end of file diff --git a/src/test/resources/itests/multi/CHANGELOG.md b/src/test/resources/itests/multi/CHANGELOG.md new file mode 100644 index 0000000..0aa603e --- /dev/null +++ b/src/test/resources/itests/multi/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 4.0.0-parent - 2026-01-01 + +Initial parent release. \ No newline at end of file diff --git a/src/test/resources/itests/multi/combination/CHANGELOG.md b/src/test/resources/itests/multi/combination/CHANGELOG.md new file mode 100644 index 0000000..97e47a0 --- /dev/null +++ b/src/test/resources/itests/multi/combination/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 4.0.0-combination - 2026-01-01 + +Initial dependency release. \ No newline at end of file diff --git a/src/test/resources/itests/multi/combination/pom.xml b/src/test/resources/itests/multi/combination/pom.xml new file mode 100644 index 0000000..7be062b --- /dev/null +++ b/src/test/resources/itests/multi/combination/pom.xml @@ -0,0 +1,51 @@ + + + + org.example.itests.multi + parent + 4.0.0-parent + + + 4.0.0 + combination + 4.0.0-combination + + + + org.example.itests.multi + dependency + 4.0.0-dependency + + + + + + + org.example.itests.multi + dependency-management + 4.0.0-dependency-management + + + + + + + + org.example.itests.multi + plugin + 4.0.0-plugin + + + + + + org.example.itests.multi + plugin-management + 4.0.0-plugin-management + + + + + \ No newline at end of file diff --git a/src/test/resources/itests/multi/dependency/CHANGELOG.md b/src/test/resources/itests/multi/dependency/CHANGELOG.md new file mode 100644 index 0000000..c1f3d51 --- /dev/null +++ b/src/test/resources/itests/multi/dependency/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 4.0.0-dependency - 2026-01-01 + +Initial dependency release. \ No newline at end of file diff --git a/src/test/resources/itests/multi/dependency/pom.xml b/src/test/resources/itests/multi/dependency/pom.xml new file mode 100644 index 0000000..ccf31a4 --- /dev/null +++ b/src/test/resources/itests/multi/dependency/pom.xml @@ -0,0 +1,9 @@ + + + 4.0.0 + org.example.itests.multi + dependency + 4.0.0-dependency + \ No newline at end of file diff --git a/src/test/resources/itests/multi/dependencyManagement/CHANGELOG.md b/src/test/resources/itests/multi/dependencyManagement/CHANGELOG.md new file mode 100644 index 0000000..ab37a6c --- /dev/null +++ b/src/test/resources/itests/multi/dependencyManagement/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 4.0.0-dependency-management - 2026-01-01 + +Initial dependency management release. \ No newline at end of file diff --git a/src/test/resources/itests/multi/dependencyManagement/pom.xml b/src/test/resources/itests/multi/dependencyManagement/pom.xml new file mode 100644 index 0000000..5790301 --- /dev/null +++ b/src/test/resources/itests/multi/dependencyManagement/pom.xml @@ -0,0 +1,9 @@ + + + 4.0.0 + org.example.itests.multi + dependency-management + 4.0.0-dependency-management + \ No newline at end of file diff --git a/src/test/resources/itests/multi/excluded/CHANGELOG.md b/src/test/resources/itests/multi/excluded/CHANGELOG.md new file mode 100644 index 0000000..39ef5e2 --- /dev/null +++ b/src/test/resources/itests/multi/excluded/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 4.0.0-excluded - 2026-01-01 + +Initial excluded release. \ No newline at end of file diff --git a/src/test/resources/itests/multi/excluded/pom.xml b/src/test/resources/itests/multi/excluded/pom.xml new file mode 100644 index 0000000..7b2de95 --- /dev/null +++ b/src/test/resources/itests/multi/excluded/pom.xml @@ -0,0 +1,9 @@ + + + 4.0.0 + org.example.itests.multi + excluded + 4.0.0-excluded + \ No newline at end of file diff --git a/src/test/resources/itests/multi/plugin/CHANGELOG.md b/src/test/resources/itests/multi/plugin/CHANGELOG.md new file mode 100644 index 0000000..a3d5f03 --- /dev/null +++ b/src/test/resources/itests/multi/plugin/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 4.0.0-plugin - 2026-01-01 + +Initial plugin release. \ No newline at end of file diff --git a/src/test/resources/itests/multi/plugin/pom.xml b/src/test/resources/itests/multi/plugin/pom.xml new file mode 100644 index 0000000..619dfa0 --- /dev/null +++ b/src/test/resources/itests/multi/plugin/pom.xml @@ -0,0 +1,9 @@ + + + 4.0.0 + org.example.itests.multi + plugin + 4.0.0-plugin + \ No newline at end of file diff --git a/src/test/resources/itests/multi/pluginManagement/CHANGELOG.md b/src/test/resources/itests/multi/pluginManagement/CHANGELOG.md new file mode 100644 index 0000000..4130c30 --- /dev/null +++ b/src/test/resources/itests/multi/pluginManagement/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 4.0.0-plugin-management - 2026-01-01 + +Initial plugin management release. \ No newline at end of file diff --git a/src/test/resources/itests/multi/pluginManagement/pom.xml b/src/test/resources/itests/multi/pluginManagement/pom.xml new file mode 100644 index 0000000..c943c71 --- /dev/null +++ b/src/test/resources/itests/multi/pluginManagement/pom.xml @@ -0,0 +1,9 @@ + + + 4.0.0 + org.example.itests.multi + plugin-management + 4.0.0-plugin-management + \ No newline at end of file diff --git a/src/test/resources/itests/multi/pom.xml b/src/test/resources/itests/multi/pom.xml new file mode 100644 index 0000000..ce48418 --- /dev/null +++ b/src/test/resources/itests/multi/pom.xml @@ -0,0 +1,18 @@ + + + 4.0.0 + org.example.itests.multi + parent + 4.0.0-parent + + + dependency + dependencyManagement + plugin + pluginManagement + combination + excluded + + \ No newline at end of file diff --git a/src/test/resources/itests/revision/multi/CHANGELOG.md b/src/test/resources/itests/revision/multi/CHANGELOG.md new file mode 100644 index 0000000..e2341e0 --- /dev/null +++ b/src/test/resources/itests/revision/multi/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 3.0.0 - 2026-01-01 + +Initial release. \ No newline at end of file diff --git a/src/test/resources/itests/revision/multi/child1/pom.xml b/src/test/resources/itests/revision/multi/child1/pom.xml new file mode 100644 index 0000000..2281762 --- /dev/null +++ b/src/test/resources/itests/revision/multi/child1/pom.xml @@ -0,0 +1,15 @@ + + + 4.0.0 + + + org.example.itests.revision.multi + parent + ${revision} + ../pom.xml + + + child1 + \ No newline at end of file diff --git a/src/test/resources/itests/revision/multi/child2/pom.xml b/src/test/resources/itests/revision/multi/child2/pom.xml new file mode 100644 index 0000000..a7e117f --- /dev/null +++ b/src/test/resources/itests/revision/multi/child2/pom.xml @@ -0,0 +1,15 @@ + + + 4.0.0 + + + org.example.itests.revision.multi + parent + ${revision} + ../pom.xml + + + child2 + \ No newline at end of file diff --git a/src/test/resources/itests/revision/multi/pom.xml b/src/test/resources/itests/revision/multi/pom.xml new file mode 100644 index 0000000..472d1fe --- /dev/null +++ b/src/test/resources/itests/revision/multi/pom.xml @@ -0,0 +1,18 @@ + + + 4.0.0 + org.example.itests.revision.multi + parent + ${revision} + + + 3.0.0 + + + + child1 + child2 + + \ No newline at end of file diff --git a/src/test/resources/itests/revision/single/CHANGELOG.md b/src/test/resources/itests/revision/single/CHANGELOG.md new file mode 100644 index 0000000..7c8a88d --- /dev/null +++ b/src/test/resources/itests/revision/single/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 2.0.0 - 2026-01-01 + +Initial release. \ No newline at end of file diff --git a/src/test/resources/itests/revision/single/pom.xml b/src/test/resources/itests/revision/single/pom.xml new file mode 100644 index 0000000..eac0828 --- /dev/null +++ b/src/test/resources/itests/revision/single/pom.xml @@ -0,0 +1,13 @@ + + + 4.0.0 + org.example.itests.revision.single + project + ${revision} + + + 2.0.0 + + \ No newline at end of file diff --git a/src/test/resources/itests/single/CHANGELOG.md b/src/test/resources/itests/single/CHANGELOG.md new file mode 100644 index 0000000..089274d --- /dev/null +++ b/src/test/resources/itests/single/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.0.0 - 2026-01-01 + +Initial release. \ No newline at end of file diff --git a/src/test/resources/itests/single/pom.xml b/src/test/resources/itests/single/pom.xml new file mode 100644 index 0000000..078e777 --- /dev/null +++ b/src/test/resources/itests/single/pom.xml @@ -0,0 +1,9 @@ + + + 4.0.0 + org.example.itests.single + project + 1.0.0 + \ No newline at end of file diff --git a/src/test/resources/itests/versioning/leaves/multi/child-1.md b/src/test/resources/itests/versioning/leaves/multi/child-1.md new file mode 100644 index 0000000..39cfebe --- /dev/null +++ b/src/test/resources/itests/versioning/leaves/multi/child-1.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.leaves:child-1': patch +--- + +Child 1 = Patch \ No newline at end of file diff --git a/src/test/resources/itests/versioning/leaves/multi/child-2.md b/src/test/resources/itests/versioning/leaves/multi/child-2.md new file mode 100644 index 0000000..8fb902b --- /dev/null +++ b/src/test/resources/itests/versioning/leaves/multi/child-2.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.leaves:child-2': minor +--- + +Child 2 = Minor \ No newline at end of file diff --git a/src/test/resources/itests/versioning/leaves/multi/child-3.md b/src/test/resources/itests/versioning/leaves/multi/child-3.md new file mode 100644 index 0000000..b1f118e --- /dev/null +++ b/src/test/resources/itests/versioning/leaves/multi/child-3.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.leaves:child-3': major +--- + +Child 3 = Major \ No newline at end of file diff --git a/src/test/resources/itests/versioning/leaves/none/versioning.md b/src/test/resources/itests/versioning/leaves/none/versioning.md new file mode 100644 index 0000000..0d64c10 --- /dev/null +++ b/src/test/resources/itests/versioning/leaves/none/versioning.md @@ -0,0 +1,7 @@ +--- +'org.example.itests.leaves:child-1': none +'org.example.itests.leaves:child-2': none +'org.example.itests.leaves:child-3': none +--- + +No active changes diff --git a/src/test/resources/itests/versioning/leaves/single/versioning.md b/src/test/resources/itests/versioning/leaves/single/versioning.md new file mode 100644 index 0000000..dc5ebaf --- /dev/null +++ b/src/test/resources/itests/versioning/leaves/single/versioning.md @@ -0,0 +1,7 @@ +--- +'org.example.itests.leaves:child-1': patch +'org.example.itests.leaves:child-2': minor +'org.example.itests.leaves:child-3': major +--- + +Different versions bump in different modules. diff --git a/src/test/resources/itests/versioning/multi-recursive/versioning.md b/src/test/resources/itests/versioning/multi-recursive/versioning.md new file mode 100644 index 0000000..658cebf --- /dev/null +++ b/src/test/resources/itests/versioning/multi-recursive/versioning.md @@ -0,0 +1,5 @@ +--- +org.example.itests.multi-recursive:parent: minor +--- + +Parent update. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/multi/dependency/versioning.md b/src/test/resources/itests/versioning/multi/dependency/versioning.md new file mode 100644 index 0000000..6f007c7 --- /dev/null +++ b/src/test/resources/itests/versioning/multi/dependency/versioning.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.multi:dependency': minor +--- + +Dependency update. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/multi/dependencyManagement/versioning.md b/src/test/resources/itests/versioning/multi/dependencyManagement/versioning.md new file mode 100644 index 0000000..ddbe921 --- /dev/null +++ b/src/test/resources/itests/versioning/multi/dependencyManagement/versioning.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.multi:dependency-management': minor +--- + +Dependency management update. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/multi/excluded/versioning.md b/src/test/resources/itests/versioning/multi/excluded/versioning.md new file mode 100644 index 0000000..f64012a --- /dev/null +++ b/src/test/resources/itests/versioning/multi/excluded/versioning.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.multi:excluded': minor +--- + +Excluded update. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/multi/parent/versioning.md b/src/test/resources/itests/versioning/multi/parent/versioning.md new file mode 100644 index 0000000..c79d193 --- /dev/null +++ b/src/test/resources/itests/versioning/multi/parent/versioning.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.multi:parent': minor +--- + +Parent update. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/multi/plugin/versioning.md b/src/test/resources/itests/versioning/multi/plugin/versioning.md new file mode 100644 index 0000000..9e5ee20 --- /dev/null +++ b/src/test/resources/itests/versioning/multi/plugin/versioning.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.multi:plugin': minor +--- + +Plugin update. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/multi/pluginManagement/versioning.md b/src/test/resources/itests/versioning/multi/pluginManagement/versioning.md new file mode 100644 index 0000000..ccf77f5 --- /dev/null +++ b/src/test/resources/itests/versioning/multi/pluginManagement/versioning.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.multi:plugin-management': minor +--- + +Plugin management update. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/revision/multi/major/versioning.md b/src/test/resources/itests/versioning/revision/multi/major/versioning.md new file mode 100644 index 0000000..910bfdc --- /dev/null +++ b/src/test/resources/itests/versioning/revision/multi/major/versioning.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.revision.multi:parent': major +--- + +Major versioning applied. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/revision/multi/minor/versioning.md b/src/test/resources/itests/versioning/revision/multi/minor/versioning.md new file mode 100644 index 0000000..0c6c9be --- /dev/null +++ b/src/test/resources/itests/versioning/revision/multi/minor/versioning.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.revision.multi:parent': minor +--- + +Minor versioning applied. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/revision/multi/multiple/major.md b/src/test/resources/itests/versioning/revision/multi/multiple/major.md new file mode 100644 index 0000000..910bfdc --- /dev/null +++ b/src/test/resources/itests/versioning/revision/multi/multiple/major.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.revision.multi:parent': major +--- + +Major versioning applied. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/revision/multi/multiple/minor.md b/src/test/resources/itests/versioning/revision/multi/multiple/minor.md new file mode 100644 index 0000000..0c6c9be --- /dev/null +++ b/src/test/resources/itests/versioning/revision/multi/multiple/minor.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.revision.multi:parent': minor +--- + +Minor versioning applied. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/revision/multi/multiple/none.md b/src/test/resources/itests/versioning/revision/multi/multiple/none.md new file mode 100644 index 0000000..d2ba01f --- /dev/null +++ b/src/test/resources/itests/versioning/revision/multi/multiple/none.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.revision.multi:parent': none +--- + +No versioning applied. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/revision/multi/multiple/patch.md b/src/test/resources/itests/versioning/revision/multi/multiple/patch.md new file mode 100644 index 0000000..83d7ae1 --- /dev/null +++ b/src/test/resources/itests/versioning/revision/multi/multiple/patch.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.revision.multi:parent': patch +--- + +Patch versioning applied. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/revision/multi/none/versioning.md b/src/test/resources/itests/versioning/revision/multi/none/versioning.md new file mode 100644 index 0000000..d2ba01f --- /dev/null +++ b/src/test/resources/itests/versioning/revision/multi/none/versioning.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.revision.multi:parent': none +--- + +No versioning applied. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/revision/multi/patch/versioning.md b/src/test/resources/itests/versioning/revision/multi/patch/versioning.md new file mode 100644 index 0000000..83d7ae1 --- /dev/null +++ b/src/test/resources/itests/versioning/revision/multi/patch/versioning.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.revision.multi:parent': patch +--- + +Patch versioning applied. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/revision/multi/unknown-project/versioning.md b/src/test/resources/itests/versioning/revision/multi/unknown-project/versioning.md new file mode 100644 index 0000000..989a62b --- /dev/null +++ b/src/test/resources/itests/versioning/revision/multi/unknown-project/versioning.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.single:unknown-project': major +--- + +Unknown project. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/revision/single/major/versioning.md b/src/test/resources/itests/versioning/revision/single/major/versioning.md new file mode 100644 index 0000000..596c6db --- /dev/null +++ b/src/test/resources/itests/versioning/revision/single/major/versioning.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.revision.single:project': major +--- + +Major versioning applied. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/revision/single/minor/versioning.md b/src/test/resources/itests/versioning/revision/single/minor/versioning.md new file mode 100644 index 0000000..d53bdb0 --- /dev/null +++ b/src/test/resources/itests/versioning/revision/single/minor/versioning.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.revision.single:project': minor +--- + +Minor versioning applied. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/revision/single/multiple/major.md b/src/test/resources/itests/versioning/revision/single/multiple/major.md new file mode 100644 index 0000000..596c6db --- /dev/null +++ b/src/test/resources/itests/versioning/revision/single/multiple/major.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.revision.single:project': major +--- + +Major versioning applied. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/revision/single/multiple/minor.md b/src/test/resources/itests/versioning/revision/single/multiple/minor.md new file mode 100644 index 0000000..d53bdb0 --- /dev/null +++ b/src/test/resources/itests/versioning/revision/single/multiple/minor.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.revision.single:project': minor +--- + +Minor versioning applied. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/revision/single/multiple/none.md b/src/test/resources/itests/versioning/revision/single/multiple/none.md new file mode 100644 index 0000000..5f2c55c --- /dev/null +++ b/src/test/resources/itests/versioning/revision/single/multiple/none.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.revision.single:project': none +--- + +No versioning applied. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/revision/single/multiple/patch.md b/src/test/resources/itests/versioning/revision/single/multiple/patch.md new file mode 100644 index 0000000..beddd67 --- /dev/null +++ b/src/test/resources/itests/versioning/revision/single/multiple/patch.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.revision.single:project': patch +--- + +Patch versioning applied. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/revision/single/none/versioning.md b/src/test/resources/itests/versioning/revision/single/none/versioning.md new file mode 100644 index 0000000..5f2c55c --- /dev/null +++ b/src/test/resources/itests/versioning/revision/single/none/versioning.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.revision.single:project': none +--- + +No versioning applied. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/revision/single/patch/versioning.md b/src/test/resources/itests/versioning/revision/single/patch/versioning.md new file mode 100644 index 0000000..beddd67 --- /dev/null +++ b/src/test/resources/itests/versioning/revision/single/patch/versioning.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.revision.single:project': patch +--- + +Patch versioning applied. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/revision/single/unknown-project/versioning.md b/src/test/resources/itests/versioning/revision/single/unknown-project/versioning.md new file mode 100644 index 0000000..989a62b --- /dev/null +++ b/src/test/resources/itests/versioning/revision/single/unknown-project/versioning.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.single:unknown-project': major +--- + +Unknown project. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/single/major/versioning.md b/src/test/resources/itests/versioning/single/major/versioning.md new file mode 100644 index 0000000..3b7675b --- /dev/null +++ b/src/test/resources/itests/versioning/single/major/versioning.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.single:project': major +--- + +Major versioning applied. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/single/minor/versioning.md b/src/test/resources/itests/versioning/single/minor/versioning.md new file mode 100644 index 0000000..0c99807 --- /dev/null +++ b/src/test/resources/itests/versioning/single/minor/versioning.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.single:project': minor +--- + +Minor versioning applied. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/single/multiple/major.md b/src/test/resources/itests/versioning/single/multiple/major.md new file mode 100644 index 0000000..3b7675b --- /dev/null +++ b/src/test/resources/itests/versioning/single/multiple/major.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.single:project': major +--- + +Major versioning applied. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/single/multiple/minor.md b/src/test/resources/itests/versioning/single/multiple/minor.md new file mode 100644 index 0000000..0c99807 --- /dev/null +++ b/src/test/resources/itests/versioning/single/multiple/minor.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.single:project': minor +--- + +Minor versioning applied. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/single/multiple/none.md b/src/test/resources/itests/versioning/single/multiple/none.md new file mode 100644 index 0000000..bc6aabd --- /dev/null +++ b/src/test/resources/itests/versioning/single/multiple/none.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.single:project': none +--- + +No versioning applied. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/single/multiple/patch.md b/src/test/resources/itests/versioning/single/multiple/patch.md new file mode 100644 index 0000000..a2e5d00 --- /dev/null +++ b/src/test/resources/itests/versioning/single/multiple/patch.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.single:project': patch +--- + +Patch versioning applied. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/single/none/versioning.md b/src/test/resources/itests/versioning/single/none/versioning.md new file mode 100644 index 0000000..bc6aabd --- /dev/null +++ b/src/test/resources/itests/versioning/single/none/versioning.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.single:project': none +--- + +No versioning applied. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/single/patch/versioning.md b/src/test/resources/itests/versioning/single/patch/versioning.md new file mode 100644 index 0000000..a2e5d00 --- /dev/null +++ b/src/test/resources/itests/versioning/single/patch/versioning.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.single:project': patch +--- + +Patch versioning applied. \ No newline at end of file diff --git a/src/test/resources/itests/versioning/single/unknown-project/versioning.md b/src/test/resources/itests/versioning/single/unknown-project/versioning.md new file mode 100644 index 0000000..989a62b --- /dev/null +++ b/src/test/resources/itests/versioning/single/unknown-project/versioning.md @@ -0,0 +1,5 @@ +--- +'org.example.itests.single:unknown-project': major +--- + +Unknown project. \ No newline at end of file