From 85afdd3b0cb5d4ffbe10e8018858ec40caec0b99 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sat, 3 Jan 2026 18:12:56 +0100 Subject: [PATCH 01/63] Add core semantic versioning models and POM updating logic --- pom.xml | 6 +- .../github/bsels/semantic/version/Main.java | 14 ++ .../bsels/semantic/version/UpdatePomMojo.java | 116 +++++++++++++++++ .../version/models/SemanticVersion.java | 120 ++++++++++++++++++ .../version/models/SemanticVersionBump.java | 69 ++++++++++ 5 files changed, 322 insertions(+), 3 deletions(-) create mode 100644 src/main/java/io/github/bsels/semantic/version/Main.java create mode 100644 src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java create mode 100644 src/main/java/io/github/bsels/semantic/version/models/SemanticVersion.java create mode 100644 src/main/java/io/github/bsels/semantic/version/models/SemanticVersionBump.java diff --git a/pom.xml b/pom.xml index 8ee9bf7..06bf116 100644 --- a/pom.xml +++ b/pom.xml @@ -81,7 +81,7 @@ ${maven.plugin.api.version} - >=25.0.0 + >=25.0.0 @@ -192,13 +192,13 @@ org.apache.maven maven-core ${maven.plugin.api.version} - provided + org.apache.maven maven-plugin-api ${maven.plugin.api.version} - provided + org.apache.maven.plugin-tools diff --git a/src/main/java/io/github/bsels/semantic/version/Main.java b/src/main/java/io/github/bsels/semantic/version/Main.java new file mode 100644 index 0000000..67c989d --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/Main.java @@ -0,0 +1,14 @@ +package io.github.bsels.semantic.version; + +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; + +import java.nio.file.Path; + +public class Main { + public static void main(String[] args) throws MojoExecutionException, MojoFailureException { + UpdatePomMojo mojo = new UpdatePomMojo(); + mojo.baseDirectory = Path.of("/mnt/Data/Development/semantic-version-maven-plugin/"); + mojo.execute(); + } +} 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..19e5b84 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java @@ -0,0 +1,116 @@ +package io.github.bsels.semantic.version; + +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.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.w3c.dom.Attr; +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.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.StringWriter; +import java.nio.file.Files; +import java.nio.file.Path; + +@Mojo(name = "update", requiresDependencyResolution = ResolutionScope.RUNTIME) +@Execute(phase = LifecyclePhase.NONE) +public class UpdatePomMojo extends AbstractMojo { + /// Represents the default filename of the Maven Project Object Model (POM) file, typically used in Maven projects. + /// The constant value "pom.xml" corresponds to the standard filename for the main POM configuration file, + /// which defines the project's dependencies, build configuration, and other metadata. + /// This variable is used within the plugin to resolve or reference the POM file in the Maven project directory. + private static final String POM_XML = "pom.xml"; + + /// Represents the base directory of the Maven project. This directory is resolved to the "basedir" + /// property of the Maven build, typically corresponding to the root directory containing the + /// `pom.xml` file. + /// This variable is used as a reference point for resolving relative paths in the build process + /// and is essential for various plugin operations. + /// The value is immutable during execution and must be provided as it is a required parameter. + /// Configuration: + /// - `readonly`: Ensures the value remains constant throughout the execution. + /// - `required`: Denotes that this parameter must be set. + /// - `defaultValue`: Defaults to Maven's `${basedir}` property, which refers to the root project directory. + @Parameter(readonly = true, required = true, defaultValue = "${basedir}") + protected Path baseDirectory; + + @Override + public void execute() throws MojoExecutionException, MojoFailureException { + Log log = getLog(); + Path pom = baseDirectory.resolve(POM_XML); + DocumentBuilder documentBuilder = createDocumentBuilder(); + + Document document; + try (InputStream reader = Files.newInputStream(pom)) { + document = documentBuilder.parse(reader); + } catch (IOException e) { + throw new MojoExecutionException("Unable to read %s file".formatted(POM_XML), e); + } catch (SAXException e) { + throw new RuntimeException(e); + } + + NodeList childNodes = document.getChildNodes(); + for (int i = 0; i < childNodes.getLength(); i++) { + Node node = childNodes.item(i); + if ("project".equals(node.getNodeName())) { + NodeList properties = node.getChildNodes(); + for (int j = 0; j < properties.getLength(); j++) { + Node n = properties.item(j); + if ("version".equals(n.getNodeName())) { + String version = n.getTextContent(); + String newVersion = "1.0.0"; + log.info("Updating version from %s to %s".formatted(version, newVersion)); + n.setTextContent(newVersion); + } + } + } + } + + Source source = new DOMSource(document); + try (StringWriter writer = new StringWriter()) { + StreamResult result = new StreamResult(writer); + Transformer identity = TransformerFactory.newInstance() + .newTransformer(); + identity.transform(source, result); + + log.info(writer.toString()); + } catch (IOException e) { + throw new MojoExecutionException("Unable to write %s file".formatted(POM_XML), e); + } catch (TransformerConfigurationException e) { + throw new MojoFailureException("Unable to configure XML transformer", e); + } catch (TransformerException e) { + throw new MojoFailureException("Unable to transform XML document", e); + } + } + + private DocumentBuilder createDocumentBuilder() throws MojoFailureException { + DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); + documentBuilderFactory.setNamespaceAware(true); + documentBuilderFactory.setIgnoringElementContentWhitespace(false); + documentBuilderFactory.setIgnoringComments(false); + try { + return documentBuilderFactory.newDocumentBuilder(); + } catch (ParserConfigurationException e) { + throw new MojoFailureException("Unable to construct XML document builder", e); + } + } +} 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..608dc67 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/models/SemanticVersion.java @@ -0,0 +1,120 @@ +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-.]+)?$"); + + /// 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 = suffix.filter(Predicate.not(String::isEmpty)); + if (suffix.isPresent() && !suffix.get().matches("^-[a-zA-Z0-9-.]+$")) { + throw new IllegalArgumentException("Suffix must be alphanumeric, dash, or dot, and should not start with a dash"); + } + } + + /// 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)) + ); + } + + /// 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() { + return new SemanticVersion(major, minor, patch, Optional.empty()); + } + + /// 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 + /// @throws NullPointerException if the `suffix` parameter is null + public SemanticVersion withSuffix(String suffix) throws NullPointerException { + Objects.requireNonNull(suffix, "`suffix` must not be null"); + 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..b14505d --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/models/SemanticVersionBump.java @@ -0,0 +1,69 @@ +package io.github.bsels.semantic.version.models; + +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: +/// - MAJOR: Indicates a major version increment, which may introduce breaking changes. +/// - MINOR: Indicates a minor version increment, which may add functionality in a backward-compatible manner. +/// - PATCH: Indicates a patch version increment, which may introduce backward-compatible bug fixes. +/// - NONE: Indicates that no version increment is to occur. +public enum SemanticVersionBump { + /// 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, + /// 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 patch version increment in the context of semantic versioning. + /// A patch increment is typically used to introduce backward-compatible bug fixes. + PATCH, + /// 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; + + /// 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 + public static SemanticVersionBump fromString(String value) throws IllegalArgumentException { + 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, [#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() + .max(Comparator.naturalOrder()) + .orElse(NONE); + } +} From 85538ece0d1a8dbc8f6ca499c4d0f2cc98534aa2 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sun, 4 Jan 2026 12:54:37 +0100 Subject: [PATCH 02/63] Refactor the project structure: replace `Main` with modular utilities and extend versioning capabilities. Add enums for version bump strategies and execution modes. Updates include new `BaseMojo` abstraction and XML POM handling utilities. --- pom.xml | 4 +- .../bsels/semantic/version/BaseMojo.java | 171 +++++++++++ .../github/bsels/semantic/version/Main.java | 14 - .../bsels/semantic/version/UpdatePomMojo.java | 204 ++++++++------ .../version/models/SemanticVersion.java | 5 +- .../semantic/version/parameters/Modus.java | 14 + .../version/parameters/VersionBump.java | 18 ++ .../semantic/version/utils/POMUtils.java | 266 ++++++++++++++++++ 8 files changed, 597 insertions(+), 99 deletions(-) create mode 100644 src/main/java/io/github/bsels/semantic/version/BaseMojo.java delete mode 100644 src/main/java/io/github/bsels/semantic/version/Main.java create mode 100644 src/main/java/io/github/bsels/semantic/version/parameters/Modus.java create mode 100644 src/main/java/io/github/bsels/semantic/version/parameters/VersionBump.java create mode 100644 src/main/java/io/github/bsels/semantic/version/utils/POMUtils.java diff --git a/pom.xml b/pom.xml index 06bf116..67de06b 100644 --- a/pom.xml +++ b/pom.xml @@ -192,13 +192,13 @@ org.apache.maven maven-core ${maven.plugin.api.version} - + provided org.apache.maven maven-plugin-api ${maven.plugin.api.version} - + provided org.apache.maven.plugin-tools 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..7b68109 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/BaseMojo.java @@ -0,0 +1,171 @@ +package io.github.bsels.semantic.version; + +import io.github.bsels.semantic.version.parameters.Modus; +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 java.nio.file.Path; +import java.util.List; + +/// 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 UpdatePomMojo { + + /// Represents the base directory of the Maven project. This directory is resolved to the "basedir" + /// property of the Maven build, typically corresponding to the root directory containing the + /// `pom.xml` file. + /// This variable is used as a reference point for resolving relative paths in the build process + /// and is essential for various plugin operations. + /// The value is immutable during execution and must be provided as it is a required parameter. + /// Configuration: + /// - `readonly`: Ensures the value remains constant throughout the execution. + /// - `required`: Denotes that this parameter must be set. + /// - `defaultValue`: Defaults to Maven's `${basedir}` property, which refers to the root project directory. + @Parameter(readonly = true, required = true, defaultValue = "${basedir}") + protected Path baseDirectory; + + /// 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 `SINGLE_PROJECT_VERSION` mode, where versioning is executed for a single project. + /// + /// Supported Modes: + /// - [Modus#SINGLE_PROJECT_VERSION]: Handles versioning for a single project. + /// - [Modus#REVISION_PROPERTY]: Handles versioning for projects using the revision property. + /// - [Modus#MULTI_PROJECT_VERSION]: Handles versioning across multiple projects (including intermediary projects). + /// - [Modus#MULTI_PROJECT_VERSION_ONLY_LEAFS]: Handles versioning for leaf projects in multi-module setups. + @Parameter(property = "versioning.modus", required = true, defaultValue = "SINGLE_PROJECT_VERSION") + protected Modus modus = Modus.SINGLE_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; + + /// Determines whether the plugin should execute its logic for subprojects in a multi-module Maven project. + /// + /// Configuration: + /// - `property`: "versioning.executeForSubProject", allows external configuration via Maven plugin properties. + /// - `defaultValue`: Defaults to `false`, meaning the plugin will skip its execution for subprojects + /// unless explicitly enabled. + /// + /// When set to `true`, the plugin will apply its logic to subprojects as well as the root project. + /// When set to `false`, it will only apply its logic to the root project. + /// + /// This parameter is useful in scenarios where selective execution of versioning logic is desired within a + /// multi-module project hierarchy. + @Parameter(property = "versioning.executeForSubproject", defaultValue = "false") + protected boolean executeForSubproject = false; + + /// 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; + + /// 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(); + List topologicallySortedProjects = session.getResult() + .getTopologicallySortedProjects(); + MavenProject rootProject = topologicallySortedProjects + .get(0); + MavenProject currentProject = session.getCurrentProject(); + if (!rootProject.equals(currentProject) && !executeForSubproject) { + 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; +} diff --git a/src/main/java/io/github/bsels/semantic/version/Main.java b/src/main/java/io/github/bsels/semantic/version/Main.java deleted file mode 100644 index 67c989d..0000000 --- a/src/main/java/io/github/bsels/semantic/version/Main.java +++ /dev/null @@ -1,14 +0,0 @@ -package io.github.bsels.semantic.version; - -import org.apache.maven.plugin.MojoExecutionException; -import org.apache.maven.plugin.MojoFailureException; - -import java.nio.file.Path; - -public class Main { - public static void main(String[] args) throws MojoExecutionException, MojoFailureException { - UpdatePomMojo mojo = new UpdatePomMojo(); - mojo.baseDirectory = Path.of("/mnt/Data/Development/semantic-version-maven-plugin/"); - mojo.execute(); - } -} diff --git a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java index 19e5b84..511999d 100644 --- a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java @@ -1,6 +1,8 @@ package io.github.bsels.semantic.version; -import org.apache.maven.plugin.AbstractMojo; +import io.github.bsels.semantic.version.models.SemanticVersionBump; +import io.github.bsels.semantic.version.parameters.VersionBump; +import io.github.bsels.semantic.version.utils.POMUtils; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; import org.apache.maven.plugin.logging.Log; @@ -9,108 +11,146 @@ import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; import org.apache.maven.plugins.annotations.ResolutionScope; -import org.w3c.dom.Attr; 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.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.StringWriter; -import java.nio.file.Files; import java.nio.file.Path; @Mojo(name = "update", requiresDependencyResolution = ResolutionScope.RUNTIME) @Execute(phase = LifecyclePhase.NONE) -public class UpdatePomMojo extends AbstractMojo { - /// Represents the default filename of the Maven Project Object Model (POM) file, typically used in Maven projects. - /// The constant value "pom.xml" corresponds to the standard filename for the main POM configuration file, - /// which defines the project's dependencies, build configuration, and other metadata. - /// This variable is used within the plugin to resolve or reference the POM file in the Maven project directory. - private static final String POM_XML = "pom.xml"; +public final class UpdatePomMojo extends BaseMojo { - /// Represents the base directory of the Maven project. This directory is resolved to the "basedir" - /// property of the Maven build, typically corresponding to the root directory containing the - /// `pom.xml` file. - /// This variable is used as a reference point for resolving relative paths in the build process - /// and is essential for various plugin operations. - /// The value is immutable during execution and must be provided as it is a required parameter. + /// 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: - /// - `readonly`: Ensures the value remains constant throughout the execution. - /// - `required`: Denotes that this parameter must be set. - /// - `defaultValue`: Defaults to Maven's `${basedir}` property, which refers to the root project directory. - @Parameter(readonly = true, required = true, defaultValue = "${basedir}") - protected Path baseDirectory; + /// - `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; + + /// Indicates whether the original POM 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 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 backupOldPom = false; + + /// 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(); + } @Override - public void execute() throws MojoExecutionException, MojoFailureException { + public void internalExecute() throws MojoExecutionException, MojoFailureException { Log log = getLog(); - Path pom = baseDirectory.resolve(POM_XML); - DocumentBuilder documentBuilder = createDocumentBuilder(); - - Document document; - try (InputStream reader = Files.newInputStream(pom)) { - document = documentBuilder.parse(reader); - } catch (IOException e) { - throw new MojoExecutionException("Unable to read %s file".formatted(POM_XML), e); - } catch (SAXException e) { - throw new RuntimeException(e); + switch (modus) { + case REVISION_PROPERTY, SINGLE_PROJECT_VERSION -> handleSingleVersionUpdate(); + case MULTI_PROJECT_VERSION -> + log.warn("Versioning mode is set to MULTI_PROJECT_VERSION, skipping execution not yet implemented"); + case MULTI_PROJECT_VERSION_ONLY_LEAFS -> + log.warn("Versioning mode is set to MULTI_PROJECT_VERSION_ONLY_LEAFS, skipping execution not yet implemented"); } + } - NodeList childNodes = document.getChildNodes(); - for (int i = 0; i < childNodes.getLength(); i++) { - Node node = childNodes.item(i); - if ("project".equals(node.getNodeName())) { - NodeList properties = node.getChildNodes(); - for (int j = 0; j < properties.getLength(); j++) { - Node n = properties.item(j); - if ("version".equals(n.getNodeName())) { - String version = n.getTextContent(); - String newVersion = "1.0.0"; - log.info("Updating version from %s to %s".formatted(version, newVersion)); - n.setTextContent(newVersion); - } - } - } + /// Handles the process of performing a single version update within a Maven project. + /// + /// This method determines the semantic version increment to apply, updates the project version + /// in the corresponding POM file, and either performs an actual update or demonstrates the proposed + /// changes in a dry-run mode. + /// + /// Key Operations: + /// - Resolves the POM file from the base directory. + /// - Reads the project version node from the POM using the specified update mode. + /// - Calculates the appropriate semantic version increment to apply. + /// - Logs the type of semantic version modification being applied. + /// - Updates the POM version node with the new version. + /// - Performs a dry-run if enabled, writing the proposed changes to a log instead of modifying the file. + /// + /// @throws MojoExecutionException if the POM cannot be read or written, or it cannot update the version node. + /// @throws MojoFailureException if the runtime system fails to initial the XML reader and writer helper classes + private void handleSingleVersionUpdate() throws MojoExecutionException, MojoFailureException { + Log log = getLog(); + Path pom = session.getCurrentProject() + .getFile() + .toPath(); + Document document = POMUtils.readPom(pom); + Node versionNode = POMUtils.getProjectVersionNode(document, modus); + SemanticVersionBump semanticVersionBump = getSemanticVersionBump(document); + log.info("Updating version with a %s semantic version".formatted(semanticVersionBump)); + try { + POMUtils.updateVersion(versionNode, semanticVersionBump); + } catch (IllegalArgumentException e) { + throw new MojoExecutionException("Unable to update version node", e); } - Source source = new DOMSource(document); - try (StringWriter writer = new StringWriter()) { - StreamResult result = new StreamResult(writer); - Transformer identity = TransformerFactory.newInstance() - .newTransformer(); - identity.transform(source, result); + writeUpdatedPom(document, pom); + } - log.info(writer.toString()); - } catch (IOException e) { - throw new MojoExecutionException("Unable to write %s file".formatted(POM_XML), e); - } catch (TransformerConfigurationException e) { - throw new MojoFailureException("Unable to configure XML transformer", e); - } catch (TransformerException e) { - throw new MojoFailureException("Unable to transform XML document", e); + /// 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) { + try (StringWriter writer = new StringWriter()) { + POMUtils.writePom(document, writer); + getLog().info("Dry-run: new pom at %s:%n%s".formatted(pom, writer)); + } catch (IOException e) { + throw new MojoExecutionException("Unable to open output stream for writing", e); + } + } else { + POMUtils.writePom(document, pom, backupOldPom); } } - private DocumentBuilder createDocumentBuilder() throws MojoFailureException { - DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); - documentBuilderFactory.setNamespaceAware(true); - documentBuilderFactory.setIgnoringElementContentWhitespace(false); - documentBuilderFactory.setIgnoringComments(false); - try { - return documentBuilderFactory.newDocumentBuilder(); - } catch (ParserConfigurationException e) { - throw new MojoFailureException("Unable to construct XML document builder", e); - } + /// Determines the type of semantic version increment to be applied based on the current configuration + /// and the provided document. + /// + /// @param document the XML document used to determine the semantic version bump, typically representing the content of a POM file or similar configuration. + /// @return the type of semantic version bump to apply, which can be one of the predefined values in [SemanticVersionBump], such as MAJOR, MINOR, PATCH, or a custom determination based on file-based analysis. + private SemanticVersionBump getSemanticVersionBump(Document document) { + return switch (versionBump) { + case FILE_BASED -> getSemanticVersionBumpFromFile(document); + case MAJOR -> SemanticVersionBump.MAJOR; + case MINOR -> SemanticVersionBump.MINOR; + case PATCH -> SemanticVersionBump.PATCH; + }; + } + + private SemanticVersionBump getSemanticVersionBumpFromFile(Document document) { + throw new UnsupportedOperationException("File based versioning not yet implemented"); } } 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 index 608dc67..bb353b1 100644 --- a/src/main/java/io/github/bsels/semantic/version/models/SemanticVersion.java +++ b/src/main/java/io/github/bsels/semantic/version/models/SemanticVersion.java @@ -104,7 +104,10 @@ public SemanticVersion bump(SemanticVersionBump bump) throws NullPointerExceptio /// /// @return a new `SemanticVersion` instance without the suffix. public SemanticVersion stripSuffix() { - return new SemanticVersion(major, minor, patch, Optional.empty()); + if (suffix.isPresent()) { + return new SemanticVersion(major, minor, patch, Optional.empty()); + } + return this; } /// Returns a new `SemanticVersion` instance with the specified suffix. 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..97904e7 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/parameters/Modus.java @@ -0,0 +1,14 @@ +package io.github.bsels.semantic.version.parameters; + +/// Enum representing different modes of handling project versions. +public enum Modus { + /// Represents the mode for handling a single project version. + SINGLE_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 multi-project versions. + MULTI_PROJECT_VERSION, + /// Represents the mode for handling multi-project versions, but only for leaf projects. + MULTI_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/utils/POMUtils.java b/src/main/java/io/github/bsels/semantic/version/utils/POMUtils.java new file mode 100644 index 0000000..a75c6c1 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/utils/POMUtils.java @@ -0,0 +1,266 @@ +package io.github.bsels.semantic.version.utils; + +import io.github.bsels.semantic.version.models.SemanticVersion; +import io.github.bsels.semantic.version.models.SemanticVersionBump; +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.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.List; +import java.util.Objects; +import java.util.stream.IntStream; + +public final class POMUtils { + /// Represents the file suffix used for creating backup copies of POM (Project Object Model) files. + /// This constant is appended to the original file name when a backup is created. + /// For example, it allows storing a backup of the original POM file before modifications occur. + /// + /// Used primarily in operations where modifications to a POM file need a recoverable backup version + /// to safeguard against data loss or corruption during write operations. + /// + /// This suffix ensures backups are easily identifiable and avoids overwriting the original file + /// or creating name conflicts. + public static final String POM_XML_BACKUP_SUFFIX = ".backup"; + + private static final List VERSION_PROPERTY_PATH = List.of("project", "version"); + private static final List REVISION_PROPERTY_PATH = List.of("project", "properties", "revision"); + + private static DocumentBuilder documentBuilder = null; + private static Transformer transformer = null; + + 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 IllegalStateException if the project version node cannot be located in the document + public static Node getProjectVersionNode(Document document, Modus modus) + throws NullPointerException, IllegalStateException { + 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 SINGLE_PROJECT_VERSION, MULTI_PROJECT_VERSION, MULTI_PROJECT_VERSION_ONLY_LEAFS -> + VERSION_PROPERTY_PATH; + }; + try { + return walk(document, versionPropertyPath, 0); + } catch (IllegalStateException e) { + throw new IllegalStateException("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) { + backupPom(pomFile); + } + try (Writer writer = Files.newBufferedWriter(pomFile, StandardCharsets.UTF_8, StandardOpenOption.CREATE)) { + writePom(document, writer); + } catch (IOException e) { + throw new MojoExecutionException("Unable to write %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).stripSuffix(); + nodeElement.setTextContent(updatedVersion.toString()); + } + + /// 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; + } + 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() + .map(child -> walk(child, path, currentElementIndex + 1)) + .orElseThrow(() -> new IllegalStateException( + "Unable to find element %s in %s".formatted(currentElementName, parent.getNodeName()) + )); + } + + + /// Creates a backup of the specified POM (Project Object Model) file. + /// The method copies the given POM file to a backup location in the same directory, + /// replacing existing backups if necessary. + /// + /// @param pomFile the path to the POM file to be backed up; must not be null + /// @throws MojoExecutionException if an I/O error occurs during the backup operation + private static void backupPom(Path pomFile) throws MojoExecutionException { + String fileName = pomFile.getFileName().toString(); + Path backupPom = pomFile.getParent() + .resolve(fileName + POM_XML_BACKUP_SUFFIX); + try { + Files.copy( + pomFile, + backupPom, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.COPY_ATTRIBUTES, + StandardCopyOption.REPLACE_EXISTING + ); + } catch (IOException e) { + throw new MojoExecutionException("Failed to backup %s to %s".formatted(pomFile, backupPom), e); + } + } + + /// 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); + } + } +} From 3bab93510d81c1383861245c5151f76cc1249ee1 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sun, 4 Jan 2026 12:59:25 +0100 Subject: [PATCH 03/63] Enhance `POMUtils` documentation for clarity on usage, responsibilities, and design intent of constants, methods, and fields. --- .../semantic/version/utils/POMUtils.java | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) 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 index a75c6c1..549642f 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/POMUtils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/POMUtils.java @@ -34,6 +34,12 @@ import java.util.Objects; import java.util.stream.IntStream; +/// 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 file suffix used for creating backup copies of POM (Project Object Model) files. /// This constant is appended to the original file name when a backup is created. @@ -46,12 +52,61 @@ public final class POMUtils { /// or creating name conflicts. public static final String POM_XML_BACKUP_SUFFIX = ".backup"; + /// 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"); + /// 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"); + /// 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 } From 1684ebe58538735ece0ff5fabead6f84bf813acb Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sun, 4 Jan 2026 15:19:08 +0100 Subject: [PATCH 04/63] Introduce Markdown processing with CommonMark, YAML front matter handling, `VersionMarkdown`, and improve `UpdatePomMojo`. Add `MavenArtifact` model and `.versioning` metadata support. Update `pom.xml` dependencies. --- .versioning/20250104-132200.md | 5 + pom.xml | 6 + .../bsels/semantic/version/UpdatePomMojo.java | 9 ++ .../version/models/MavenArtifact.java | 54 +++++++ .../version/models/VersionMarkdown.java | 40 ++++++ .../semantic/version/utils/MarkdownUtils.java | 76 ++++++++++ .../front/block/YamlFrontMatterBlock.java | 34 +++++ .../block/YamlFrontMatterBlockParser.java | 134 ++++++++++++++++++ .../front/block/YamlFrontMatterExtension.java | 47 ++++++ .../utils/yaml/front/block/package-info.java | 2 + 10 files changed, 407 insertions(+) create mode 100644 .versioning/20250104-132200.md create mode 100644 src/main/java/io/github/bsels/semantic/version/models/MavenArtifact.java create mode 100644 src/main/java/io/github/bsels/semantic/version/models/VersionMarkdown.java create mode 100644 src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java create mode 100644 src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/YamlFrontMatterBlock.java create mode 100644 src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/YamlFrontMatterBlockParser.java create mode 100644 src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/YamlFrontMatterExtension.java create mode 100644 src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/package-info.java diff --git a/.versioning/20250104-132200.md b/.versioning/20250104-132200.md new file mode 100644 index 0000000..4d29e72 --- /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**. \ No newline at end of file diff --git a/pom.xml b/pom.xml index 67de06b..1f7939a 100644 --- a/pom.xml +++ b/pom.xml @@ -54,6 +54,7 @@ 6.0.1 5.21.0 3.15.2 + 0.27.0 UTF-8 @@ -205,6 +206,11 @@ maven-plugin-annotations ${maven.plugin.version} + + org.commonmark + commonmark + ${commonmark.version} + diff --git a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java index 511999d..805c7b5 100644 --- a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java @@ -1,7 +1,9 @@ package io.github.bsels.semantic.version; import io.github.bsels.semantic.version.models.SemanticVersionBump; +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 org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; @@ -11,6 +13,7 @@ import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; import org.apache.maven.plugins.annotations.ResolutionScope; +import org.commonmark.parser.Parser; import org.w3c.dom.Document; import org.w3c.dom.Node; @@ -102,6 +105,12 @@ private void handleSingleVersionUpdate() throws MojoExecutionException, MojoFail .toPath(); Document document = POMUtils.readPom(pom); Node versionNode = POMUtils.getProjectVersionNode(document, modus); + + VersionMarkdown markdown = MarkdownUtils.readMarkdown( + log, + baseDirectory.resolve(".versioning").resolve("20250104-132200.md") + ); + SemanticVersionBump semanticVersionBump = getSemanticVersionBump(document); log.info("Updating version with a %s semantic version".formatted(semanticVersionBump)); try { 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..86839fb --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/models/MavenArtifact.java @@ -0,0 +1,54 @@ +package io.github.bsels.semantic.version.models; + +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) { + + /// 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` cannot be null"); + Objects.requireNonNull(artifactId, "`artifactId` cannot 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 + public static MavenArtifact of(String colonSeparatedString) { + String[] parts = colonSeparatedString.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); + } +} 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..bf9a742 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/models/VersionMarkdown.java @@ -0,0 +1,40 @@ +package io.github.bsels.semantic.version.models; + +import org.commonmark.node.Node; + +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 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( + 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 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/utils/MarkdownUtils.java b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java new file mode 100644 index 0000000..7439cec --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java @@ -0,0 +1,76 @@ +package io.github.bsels.semantic.version.utils; + +import io.github.bsels.semantic.version.models.VersionMarkdown; +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.Node; +import org.commonmark.parser.IncludeSourceSpans; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.markdown.MarkdownRenderer; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +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.stream.Stream; + +public class MarkdownUtils { + + private static final Parser PARSER = Parser.builder() + .extensions(List.of(YamlFrontMatterExtension.create())) + .includeSourceSpans(IncludeSourceSpans.BLOCKS_AND_INLINES) + .build(); + private static final MarkdownRenderer RENDERER = MarkdownRenderer.builder() + .build(); + + /** + * 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 + } + + public static VersionMarkdown readMarkdown(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; + try (Stream lineStream = Files.lines(markdownFile, StandardCharsets.UTF_8)) { + List lines = lineStream.toList(); + log.info("Read %d lines from %s".formatted(lines.size(), markdownFile)); + document = PARSER.parse(String.join("\n", lines)); + } catch (IOException e) { + throw new MojoExecutionException("Unable to read '%s' file".formatted(markdownFile), e); + } + + if (!(document.getFirstChild() instanceof YamlFrontMatterBlock yamlFrontMatterBlock)) { + throw new MojoExecutionException("YAML front matter block not found in '%s' file".formatted(markdownFile)); + } + + yamlFrontMatterBlock.unlink(); + + printMarkdown(log, document, 0); + return new VersionMarkdown( + document, + Map.of() // TODO: parse metadata + ); + } + + private static void printMarkdown(Log log, Node node, int level) { + if (node == null) { + return; + } + log.info(node.toString().indent(level).stripTrailing()); + if (node instanceof YamlFrontMatterBlock block) { + log.info(block.getYaml().indent(level + 2).stripTrailing()); + } + printMarkdown(log, node.getFirstChild(), level + 2); + printMarkdown(log, node.getNext(), level); + } +} 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..4ab1b34 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/YamlFrontMatterBlock.java @@ -0,0 +1,34 @@ +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 final 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; + } +} 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..59a03cf --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/YamlFrontMatterBlockParser.java @@ -0,0 +1,134 @@ +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; + + /// 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<>(); + } + + /// 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 new YamlFrontMatterBlock(String.join("\n", lines)); + } + + /// 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()) { + 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..bb87aa2 --- /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 for the extension with CommonMark. +package io.github.bsels.semantic.version.utils.yaml.front.block; \ No newline at end of file From 8bccb6788afbbd49ea6ba4cc687116dce20627f4 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sun, 4 Jan 2026 16:17:35 +0100 Subject: [PATCH 05/63] Add comprehensive Markdown-based versioning support, including `MarkdownMapping`, `getVersionMarkdowns`, YAML parsing with Jackson, and updates to `UpdatePomMojo` for artifact-specific semantic version handling. Refactor related utilities and dependencies in the `pom.xml`. --- pom.xml | 15 ++- .../bsels/semantic/version/BaseMojo.java | 34 +++++++ .../bsels/semantic/version/UpdatePomMojo.java | 96 ++++++++++++++----- .../version/models/MarkdownMapping.java | 31 ++++++ .../version/models/MavenArtifact.java | 7 +- .../version/models/SemanticVersionBump.java | 5 +- .../version/parameters/package-info.java | 2 + .../semantic/version/utils/MarkdownUtils.java | 82 ++++++++++++---- .../front/block/YamlFrontMatterBlock.java | 13 ++- .../block/YamlFrontMatterBlockParser.java | 15 ++- 10 files changed, 254 insertions(+), 46 deletions(-) create mode 100644 src/main/java/io/github/bsels/semantic/version/models/MarkdownMapping.java create mode 100644 src/main/java/io/github/bsels/semantic/version/parameters/package-info.java diff --git a/pom.xml b/pom.xml index 1f7939a..07e5102 100644 --- a/pom.xml +++ b/pom.xml @@ -51,10 +51,11 @@ 3.27.6 + 0.27.0 + 2.20.1 6.0.1 - 5.21.0 3.15.2 - 0.27.0 + 5.21.0 UTF-8 @@ -211,6 +212,16 @@ 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 index 7b68109..8041f91 100644 --- a/src/main/java/io/github/bsels/semantic/version/BaseMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/BaseMojo.java @@ -1,6 +1,8 @@ package io.github.bsels.semantic.version; +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 org.apache.maven.execution.MavenSession; import org.apache.maven.plugin.AbstractMojo; import org.apache.maven.plugin.MojoExecutionException; @@ -9,8 +11,12 @@ import org.apache.maven.plugins.annotations.Parameter; import org.apache.maven.project.MavenProject; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; +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 @@ -168,4 +174,32 @@ public final void execute() throws MojoExecutionException, MojoFailureException /// @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 base 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 = baseDirectory.resolve(".versioning"); + List versionMarkdowns; + try (Stream markdownFileStream = Files.walk(versioningFolder)) { + 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; + } } diff --git a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java index 805c7b5..ef85489 100644 --- a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java @@ -1,9 +1,10 @@ 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.VersionBump; -import io.github.bsels.semantic.version.utils.MarkdownUtils; import io.github.bsels.semantic.version.utils.POMUtils; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; @@ -13,13 +14,17 @@ import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; import org.apache.maven.plugins.annotations.ResolutionScope; -import org.commonmark.parser.Parser; +import org.apache.maven.project.MavenProject; import org.w3c.dom.Document; import org.w3c.dom.Node; import java.io.IOException; import java.io.StringWriter; import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; @Mojo(name = "update", requiresDependencyResolution = ResolutionScope.RUNTIME) @Execute(phase = LifecyclePhase.NONE) @@ -73,8 +78,11 @@ public UpdatePomMojo() { @Override public void internalExecute() throws MojoExecutionException, MojoFailureException { Log log = getLog(); + List versionMarkdowns = getVersionMarkdowns(); + MarkdownMapping markdownMapping = getMarkdownMapping(versionMarkdowns); + switch (modus) { - case REVISION_PROPERTY, SINGLE_PROJECT_VERSION -> handleSingleVersionUpdate(); + case REVISION_PROPERTY, SINGLE_PROJECT_VERSION -> handleSingleVersionUpdate(markdownMapping); case MULTI_PROJECT_VERSION -> log.warn("Versioning mode is set to MULTI_PROJECT_VERSION, skipping execution not yet implemented"); case MULTI_PROJECT_VERSION_ONLY_LEAFS -> @@ -96,22 +104,32 @@ public void internalExecute() throws MojoExecutionException, MojoFailureExceptio /// - Updates the POM version node with the new version. /// - Performs a dry-run if enabled, writing the proposed changes to a log instead of modifying the file. /// + /// @param markdownMapping the markdown version file mappings /// @throws MojoExecutionException if the POM cannot be read or written, or it cannot update the version node. /// @throws MojoFailureException if the runtime system fails to initial the XML reader and writer helper classes - private void handleSingleVersionUpdate() throws MojoExecutionException, MojoFailureException { + private void handleSingleVersionUpdate(MarkdownMapping markdownMapping) + throws MojoExecutionException, MojoFailureException { Log log = getLog(); - Path pom = session.getCurrentProject() + MavenProject currentProject = session.getCurrentProject(); + Path pom = currentProject .getFile() .toPath(); + MavenArtifact projectArtifact = new MavenArtifact(currentProject.getGroupId(), currentProject.getArtifactId()); + Document document = POMUtils.readPom(pom); Node versionNode = POMUtils.getProjectVersionNode(document, modus); - VersionMarkdown markdown = MarkdownUtils.readMarkdown( - log, - baseDirectory.resolve(".versioning").resolve("20250104-132200.md") - ); + Map versionBumpMap = markdownMapping.versionBumpMap(); + if (!Set.of(projectArtifact).equals(versionBumpMap.keySet())) { + throw new MojoExecutionException( + "Single version update expected to update only the project %s, found: %s".formatted( + currentProject, + versionBumpMap.keySet() + ) + ); + } - SemanticVersionBump semanticVersionBump = getSemanticVersionBump(document); + SemanticVersionBump semanticVersionBump = getSemanticVersionBump(projectArtifact, versionBumpMap); log.info("Updating version with a %s semantic version".formatted(semanticVersionBump)); try { POMUtils.updateVersion(versionNode, semanticVersionBump); @@ -122,16 +140,48 @@ private void handleSingleVersionUpdate() throws MojoExecutionException, MojoFail writeUpdatedPom(document, pom); } + /// 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 + private MarkdownMapping getMarkdownMapping(List versionMarkdowns) { + Map versionBumpMap = versionMarkdowns.stream() + .map(VersionMarkdown::bumps) + .map(Map::entrySet) + .flatMap(Set::stream) + .collect(Collectors.groupingBy( + 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(Collectors.groupingBy( + Map.Entry::getKey, + Collectors.mapping( + Map.Entry::getValue, + Collectors.collectingAndThen(Collectors.toList(), List::copyOf) + ) + )); + return new MarkdownMapping(versionBumpMap, markdownMap); + } + /// 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 + /// 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 + /// @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 + /// @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) { try (StringWriter writer = new StringWriter()) { @@ -145,21 +195,21 @@ private void writeUpdatedPom(Document document, Path pom) throws MojoExecutionEx } } - /// Determines the type of semantic version increment to be applied based on the current configuration - /// and the provided document. + /// 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 document the XML document used to determine the semantic version bump, typically representing the content of a POM file or similar configuration. - /// @return the type of semantic version bump to apply, which can be one of the predefined values in [SemanticVersionBump], such as MAJOR, MINOR, PATCH, or a custom determination based on file-based analysis. - private SemanticVersionBump getSemanticVersionBump(Document document) { + /// @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 -> getSemanticVersionBumpFromFile(document); + case FILE_BASED -> bumps.getOrDefault(artifact, SemanticVersionBump.NONE); case MAJOR -> SemanticVersionBump.MAJOR; case MINOR -> SemanticVersionBump.MINOR; case PATCH -> SemanticVersionBump.PATCH; }; } - - private SemanticVersionBump getSemanticVersionBumpFromFile(Document document) { - throw new UnsupportedOperationException("File based versioning not yet implemented"); - } } 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 index 86839fb..58a63f9 100644 --- a/src/main/java/io/github/bsels/semantic/version/models/MavenArtifact.java +++ b/src/main/java/io/github/bsels/semantic/version/models/MavenArtifact.java @@ -1,5 +1,7 @@ package io.github.bsels.semantic.version.models; +import com.fasterxml.jackson.annotation.JsonCreator; + import java.util.Objects; /// Represents a Maven artifact consisting of a group ID and an artifact ID. @@ -32,8 +34,11 @@ public record MavenArtifact(String groupId, String artifactId) { /// @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 = colonSeparatedString.split(":"); + 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( 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 index b14505d..c168581 100644 --- a/src/main/java/io/github/bsels/semantic/version/models/SemanticVersionBump.java +++ b/src/main/java/io/github/bsels/semantic/version/models/SemanticVersionBump.java @@ -1,5 +1,7 @@ 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; @@ -36,6 +38,7 @@ public enum SemanticVersionBump { /// @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 { return valueOf(value.toUpperCase()); } @@ -63,7 +66,7 @@ public static SemanticVersionBump max(SemanticVersionBump... bumps) throws NullP public static SemanticVersionBump max(Collection bumps) throws NullPointerException { Objects.requireNonNull(bumps, "`bumps` must not be null"); return bumps.stream() - .max(Comparator.naturalOrder()) + .min(Comparator.naturalOrder()) .orElse(NONE); } } 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 index 7439cec..e5153a4 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java @@ -1,5 +1,12 @@ 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.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.YamlFrontMatterBlock; import io.github.bsels.semantic.version.utils.yaml.front.block.YamlFrontMatterExtension; @@ -8,12 +15,12 @@ import org.commonmark.node.Node; import org.commonmark.parser.IncludeSourceSpans; import org.commonmark.parser.Parser; -import org.commonmark.renderer.markdown.MarkdownRenderer; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -21,22 +28,52 @@ public 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 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(); - private static final MarkdownRenderer RENDERER = MarkdownRenderer.builder() - .build(); + /// 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(); - /** - * Utility class for handling operations related to Markdown processing. - * This class contains static methods and is not intended to be instantiated. - */ + /// 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 } - public static VersionMarkdown readMarkdown(Log log, Path markdownFile) + /// 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"); @@ -52,24 +89,35 @@ public static VersionMarkdown readMarkdown(Log log, Path 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( - document, - Map.of() // TODO: parse metadata - ); + return new VersionMarkdown(document, bumps); } + /// 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 private static void printMarkdown(Log log, Node node, int level) { if (node == null) { return; } - log.info(node.toString().indent(level).stripTrailing()); - if (node instanceof YamlFrontMatterBlock block) { - log.info(block.getYaml().indent(level + 2).stripTrailing()); - } + log.debug(node.toString().indent(level).stripTrailing()); printMarkdown(log, node.getFirstChild(), level + 2); printMarkdown(log, node.getNext(), level); } 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 index 4ab1b34..63a779d 100644 --- 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 @@ -15,7 +15,7 @@ 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 final String yaml; + private String yaml; /// Constructs a new instance of the YamlFrontMatterBlock class with the specified YAML content. /// @@ -31,4 +31,15 @@ public YamlFrontMatterBlock(String yaml) throws NullPointerException { 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 index 59a03cf..424c6d9 100644 --- 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 @@ -48,6 +48,17 @@ public class YamlFrontMatterBlockParser extends AbstractBlockParser { /// [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. @@ -59,6 +70,7 @@ public class YamlFrontMatterBlockParser extends AbstractBlockParser { /// encountered during parsing. public YamlFrontMatterBlockParser() { lines = new ArrayList<>(); + block = new YamlFrontMatterBlock(""); } /// Returns a [Block] object representing the YAML front matter block. @@ -67,7 +79,7 @@ public YamlFrontMatterBlockParser() { /// @return a [YamlFrontMatterBlock] containing the serialized YAML front matter content @Override public Block getBlock() { - return new YamlFrontMatterBlock(String.join("\n", lines)); + return block; } /// Attempts to continue parsing a block of text according to the current parser state. @@ -80,6 +92,7 @@ public Block getBlock() { 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()); From 1bf4b2aaa6ea75ae3df2ba9414ad6596847ff3bd Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sun, 4 Jan 2026 17:00:25 +0100 Subject: [PATCH 06/63] Add changelog support and enhance Markdown utils with changelog merging, node handling, and structured processing methods. Refactor Markdown reading logic. --- .versioning/20250104-132200.md | 2 +- CHANGELOG.md | 4 + .../bsels/semantic/version/BaseMojo.java | 2 +- .../bsels/semantic/version/UpdatePomMojo.java | 24 ++++ .../semantic/version/utils/MarkdownUtils.java | 118 ++++++++++++++++-- 5 files changed, 137 insertions(+), 13 deletions(-) create mode 100644 CHANGELOG.md diff --git a/.versioning/20250104-132200.md b/.versioning/20250104-132200.md index 4d29e72..73d38b8 100644 --- a/.versioning/20250104-132200.md +++ b/.versioning/20250104-132200.md @@ -2,4 +2,4 @@ 'io.github.bsels:semantic-version-maven-plugin': major --- -Initial version of the **semantic-version-maven-plugin**. \ No newline at end of file +Initial version of the **semantic-version-maven-plugin**. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6b4aea3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,4 @@ +# Changelog + +## 0.0.1 - 2025-01-01 +Project started \ No newline at end of file diff --git a/src/main/java/io/github/bsels/semantic/version/BaseMojo.java b/src/main/java/io/github/bsels/semantic/version/BaseMojo.java index 8041f91..57c5e41 100644 --- a/src/main/java/io/github/bsels/semantic/version/BaseMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/BaseMojo.java @@ -188,7 +188,7 @@ protected final List getVersionMarkdowns() throws MojoExecution Log log = getLog(); Path versioningFolder = baseDirectory.resolve(".versioning"); List versionMarkdowns; - try (Stream markdownFileStream = Files.walk(versioningFolder)) { + try (Stream markdownFileStream = Files.walk(versioningFolder, 1)) { List markdownFiles = markdownFileStream.filter(Files::isRegularFile) .filter(path -> path.toString().toLowerCase().endsWith(".md")) .toList(); diff --git a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java index ef85489..f125bec 100644 --- a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java @@ -5,6 +5,7 @@ import io.github.bsels.semantic.version.models.SemanticVersionBump; 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 org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; @@ -26,6 +27,8 @@ import java.util.Set; import java.util.stream.Collectors; +import static io.github.bsels.semantic.version.utils.MarkdownUtils.readMarkdown; + @Mojo(name = "update", requiresDependencyResolution = ResolutionScope.RUNTIME) @Execute(phase = LifecyclePhase.NONE) public final class UpdatePomMojo extends BaseMojo { @@ -138,6 +141,27 @@ private void handleSingleVersionUpdate(MarkdownMapping markdownMapping) } writeUpdatedPom(document, pom); + + Path changelogFile = baseDirectory.resolve("CHANGELOG.md"); + org.commonmark.node.Node node = readMarkdown(log, changelogFile); + log.debug("Original changelog"); + MarkdownUtils.printMarkdown(log, node, 0); + MarkdownUtils.mergeVersionMarkdownsInChangelog( + log, + node, + versionNode.getTextContent(), + markdownMapping.markdownMap() + .getOrDefault(projectArtifact, List.of()) + .stream() + .collect(Collectors.groupingBy( + entry -> entry.bumps().get(projectArtifact), + Collectors.mapping(VersionMarkdown::content, Collectors.toList()) + )) + ); + log.debug("Updated changelog"); + MarkdownUtils.printMarkdown(log, node, 0); + + // TODO: Write markdown file } /// Creates a MarkdownMapping instance based on a list of [VersionMarkdown] objects. diff --git a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java index e5153a4..6f1746f 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java @@ -12,7 +12,9 @@ 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.Heading; import org.commonmark.node.Node; +import org.commonmark.node.Text; import org.commonmark.parser.IncludeSourceSpans; import org.commonmark.parser.Parser; @@ -20,10 +22,12 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.time.LocalDate; 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; public class MarkdownUtils { @@ -71,20 +75,13 @@ private MarkdownUtils() { /// @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 + /// @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; - try (Stream lineStream = Files.lines(markdownFile, StandardCharsets.UTF_8)) { - List lines = lineStream.toList(); - log.info("Read %d lines from %s".formatted(lines.size(), markdownFile)); - document = PARSER.parse(String.join("\n", lines)); - } catch (IOException e) { - throw new MojoExecutionException("Unable to read '%s' file".formatted(markdownFile), e); - } + 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)); @@ -106,6 +103,105 @@ public static VersionMarkdown readVersionMarkdown(Log log, Path markdownFile) return new VersionMarkdown(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"); + 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 PARSER.parse(String.join("\n", lines)); + } catch (IOException e) { + throw new MojoExecutionException("Unable to read '%s' file".formatted(markdownFile), e); + } + } + + public static void mergeVersionMarkdownsInChangelog( + Log log, + Node changelog, + String version, + Map> headerToNodes + ) { + Objects.requireNonNull(log, "`log` must not be null"); + 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.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); + + Node current = headerToNodes.entrySet() + .stream() + .sorted(Map.Entry.comparingByKey()) + .reduce(newVersionHeading, MarkdownUtils::copyVersionMarkdownToChangeset, mergeNodes()); + + while (nextChild != null) { + Node nextSibling = nextChild.getNext(); + current.insertAfter(nextChild); + current = nextChild; + nextChild = nextSibling; + } + } + + /// 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; + }; + } + + private static Node copyVersionMarkdownToChangeset(Node current, Map.Entry> entry) { + 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() + .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) { + Node nextChild = node.getFirstChild(); + while (nextChild != null) { + Node nextSibling = nextChild.getNext(); + currentLambda.insertAfter(nextChild); + currentLambda = nextChild; + nextChild = nextSibling; + } + return currentLambda; + } + /// 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. @@ -113,7 +209,7 @@ public static VersionMarkdown readVersionMarkdown(Log log, Path markdownFile) /// @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 - private static void printMarkdown(Log log, Node node, int level) { + public static void printMarkdown(Log log, Node node, int level) { if (node == null) { return; } From 7a5f7c4ff8bc161bba38018b78cfa777388cacb9 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sun, 4 Jan 2026 18:50:29 +0100 Subject: [PATCH 07/63] Refactor `Utils` into a standalone utility class, centralizing file backup logic. Update `POMUtils` and `MarkdownUtils` to use the new `Utils.backupFile` method. Extend `MarkdownUtils` with enhanced rendering, Markdown writing, and changelog processing features. Revise `UpdatePomMojo` to support changelog updates and configurable backup handling for both POM and Markdown files. --- .../bsels/semantic/version/UpdatePomMojo.java | 28 ++++-- .../semantic/version/utils/MarkdownUtils.java | 92 +++++++++++++++++-- .../semantic/version/utils/POMUtils.java | 37 +------- .../bsels/semantic/version/utils/Utils.java | 45 +++++++++ 4 files changed, 147 insertions(+), 55 deletions(-) create mode 100644 src/main/java/io/github/bsels/semantic/version/utils/Utils.java diff --git a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java index f125bec..a16d091 100644 --- a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java @@ -54,13 +54,13 @@ public final class UpdatePomMojo extends BaseMojo { @Parameter(property = "versioning.bump", required = true, defaultValue = "FILE_BASED") VersionBump versionBump = VersionBump.FILE_BASED; - /// Indicates whether the original POM file should be backed up before modifying its content. + /// 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 file will be created before any updates are applied. + /// 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 backupOldPom = false; + boolean backupFiles = false; /// Default constructor for the UpdatePomMojo class. /// @@ -137,18 +137,18 @@ private void handleSingleVersionUpdate(MarkdownMapping markdownMapping) try { POMUtils.updateVersion(versionNode, semanticVersionBump); } catch (IllegalArgumentException e) { - throw new MojoExecutionException("Unable to update version node", e); + throw new MojoExecutionException("Unable to update version changelog", e); } writeUpdatedPom(document, pom); Path changelogFile = baseDirectory.resolve("CHANGELOG.md"); - org.commonmark.node.Node node = readMarkdown(log, changelogFile); + org.commonmark.node.Node changelog = readMarkdown(log, changelogFile); log.debug("Original changelog"); - MarkdownUtils.printMarkdown(log, node, 0); + MarkdownUtils.printMarkdown(log, changelog, 0); MarkdownUtils.mergeVersionMarkdownsInChangelog( log, - node, + changelog, versionNode.getTextContent(), markdownMapping.markdownMap() .getOrDefault(projectArtifact, List.of()) @@ -159,9 +159,19 @@ private void handleSingleVersionUpdate(MarkdownMapping markdownMapping) )) ); log.debug("Updated changelog"); - MarkdownUtils.printMarkdown(log, node, 0); + MarkdownUtils.printMarkdown(log, changelog, 0); // TODO: Write markdown file + if (dryRun) { + try (StringWriter writer = new StringWriter()) { + MarkdownUtils.writeMarkdown(writer, changelog); + getLog().info("Dry-run: new changelog at %s:%n%s".formatted(changelogFile, writer)); + } catch (IOException e) { + throw new MojoExecutionException("Unable to open output stream for writing", e); + } + } else { + MarkdownUtils.writeMarkdownFile(changelogFile, changelog, backupFiles); + } } /// Creates a MarkdownMapping instance based on a list of [VersionMarkdown] objects. @@ -215,7 +225,7 @@ private void writeUpdatedPom(Document document, Path pom) throws MojoExecutionEx throw new MojoExecutionException("Unable to open output stream for writing", e); } } else { - POMUtils.writePom(document, pom, backupOldPom); + POMUtils.writePom(document, pom, backupFiles); } } diff --git a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java index 6f1746f..0335726 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java @@ -17,11 +17,15 @@ 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.HashMap; import java.util.List; @@ -30,7 +34,12 @@ import java.util.function.BinaryOperator; import java.util.stream.Stream; -public class MarkdownUtils { +/// 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. /// @@ -61,6 +70,21 @@ public class MarkdownUtils { /// It provides a convenient singleton for YAML operations within the context of the MarkdownUtils utility class. private static final ObjectMapper YAML_MAPPER = new YAMLMapper(); + /// 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().build(); + /// Utility class for handling operations related to Markdown processing. /// This class contains static methods and is not intended to be instantiated. private MarkdownUtils() { @@ -123,12 +147,26 @@ public static Node readMarkdown(Log log, Path markdownFile) throws MojoExecution } } + /// 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 log the logger used to output informational and debug messages; must not be null + /// @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 `log`, `changelog`, `version`, or `headerToNodes` is null + /// @throws IllegalArgumentException if the changelog does not start with a single H1 heading titled "Changelog" public static void mergeVersionMarkdownsInChangelog( Log log, Node changelog, String version, Map> headerToNodes - ) { + ) throws NullPointerException, IllegalArgumentException { Objects.requireNonNull(log, "`log` must not be null"); Objects.requireNonNull(changelog, "`changelog` must not be null"); Objects.requireNonNull(version, "`version` must not be null"); @@ -151,14 +189,42 @@ public static void mergeVersionMarkdownsInChangelog( .sorted(Map.Entry.comparingByKey()) .reduce(newVersionHeading, MarkdownUtils::copyVersionMarkdownToChangeset, mergeNodes()); - while (nextChild != null) { - Node nextSibling = nextChild.getNext(); - current.insertAfter(nextChild); - current = nextChild; - nextChild = nextSibling; + assert current.getNext().equals(nextChild); + } + + /// 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 { + 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)) { + 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) { + MARKDOWN_RENDERER.render(document, output); + } + /// 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 @@ -169,6 +235,12 @@ private static BinaryOperator mergeNodes() { }; } + /// 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 private static Node copyVersionMarkdownToChangeset(Node current, Map.Entry> entry) { Heading bumpTypeHeading = new Heading(); bumpTypeHeading.setLevel(3); @@ -189,14 +261,14 @@ private static Node copyVersionMarkdownToChangeset(Node current, Map.Entry binaryOperator = mergeNodes(); Node nextChild = node.getFirstChild(); while (nextChild != null) { Node nextSibling = nextChild.getNext(); - currentLambda.insertAfter(nextChild); - currentLambda = nextChild; + currentLambda = binaryOperator.apply(currentLambda, nextChild); nextChild = nextSibling; } return currentLambda; 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 index 549642f..9c5522f 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/POMUtils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/POMUtils.java @@ -28,7 +28,6 @@ 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.List; import java.util.Objects; @@ -41,16 +40,6 @@ /// /// 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 file suffix used for creating backup copies of POM (Project Object Model) files. - /// This constant is appended to the original file name when a backup is created. - /// For example, it allows storing a backup of the original POM file before modifications occur. - /// - /// Used primarily in operations where modifications to a POM file need a recoverable backup version - /// to safeguard against data loss or corruption during write operations. - /// - /// This suffix ensures backups are easily identifiable and avoids overwriting the original file - /// or creating name conflicts. - public static final String POM_XML_BACKUP_SUFFIX = ".backup"; /// 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 @@ -173,7 +162,7 @@ public static void writePom(Document document, Path pomFile, boolean backupOld) Objects.requireNonNull(document, "`document` must not be null"); Objects.requireNonNull(pomFile, "`pomFile` must not be null"); if (backupOld) { - backupPom(pomFile); + Utils.backupFile(pomFile); } try (Writer writer = Files.newBufferedWriter(pomFile, StandardCharsets.UTF_8, StandardOpenOption.CREATE)) { writePom(document, writer); @@ -251,30 +240,6 @@ private static Node walk(Node parent, List path, int currentElementIndex )); } - - /// Creates a backup of the specified POM (Project Object Model) file. - /// The method copies the given POM file to a backup location in the same directory, - /// replacing existing backups if necessary. - /// - /// @param pomFile the path to the POM file to be backed up; must not be null - /// @throws MojoExecutionException if an I/O error occurs during the backup operation - private static void backupPom(Path pomFile) throws MojoExecutionException { - String fileName = pomFile.getFileName().toString(); - Path backupPom = pomFile.getParent() - .resolve(fileName + POM_XML_BACKUP_SUFFIX); - try { - Files.copy( - pomFile, - backupPom, - StandardCopyOption.ATOMIC_MOVE, - StandardCopyOption.COPY_ATTRIBUTES, - StandardCopyOption.REPLACE_EXISTING - ); - } catch (IOException e) { - throw new MojoExecutionException("Failed to backup %s to %s".formatted(pomFile, backupPom), e); - } - } - /// 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. 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..ddfb9c9 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/utils/Utils.java @@ -0,0 +1,45 @@ +package io.github.bsels.semantic.version.utils; + +import org.apache.maven.plugin.MojoExecutionException; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; + +/// 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"; + + /// 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 + public static void backupFile(Path file) throws MojoExecutionException { + String fileName = file.getFileName().toString(); + Path backupPom = file.getParent() + .resolve(fileName + BACKUP_SUFFIX); + 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); + } + } +} From 8d446526fd354806788146f44fb044233d9cff63 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Mon, 5 Jan 2026 20:06:54 +0100 Subject: [PATCH 08/63] Refactor `Modus` to consolidate versioning modes, update `BaseMojo` to handle unified `PROJECT_VERSION` defaults, and enhance changelog handling with defaults and structured updates. --- CHANGELOG.md | 3 - .../bsels/semantic/version/BaseMojo.java | 56 +++++--- .../bsels/semantic/version/UpdatePomMojo.java | 125 ++++++++++++++---- .../semantic/version/parameters/Modus.java | 13 +- .../semantic/version/utils/MarkdownUtils.java | 31 ++++- .../semantic/version/utils/POMUtils.java | 3 +- .../bsels/semantic/version/utils/Utils.java | 17 +++ 7 files changed, 185 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b4aea3..825c32f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1 @@ # Changelog - -## 0.0.1 - 2025-01-01 -Project started \ No newline at end of file diff --git a/src/main/java/io/github/bsels/semantic/version/BaseMojo.java b/src/main/java/io/github/bsels/semantic/version/BaseMojo.java index 57c5e41..94719be 100644 --- a/src/main/java/io/github/bsels/semantic/version/BaseMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/BaseMojo.java @@ -47,18 +47,12 @@ /// or a [MojoFailureException] being thrown. public abstract sealed class BaseMojo extends AbstractMojo permits UpdatePomMojo { - /// Represents the base directory of the Maven project. This directory is resolved to the "basedir" - /// property of the Maven build, typically corresponding to the root directory containing the - /// `pom.xml` file. - /// This variable is used as a reference point for resolving relative paths in the build process - /// and is essential for various plugin operations. - /// The value is immutable during execution and must be provided as it is a required parameter. - /// Configuration: - /// - `readonly`: Ensures the value remains constant throughout the execution. - /// - `required`: Denotes that this parameter must be set. - /// - `defaultValue`: Defaults to Maven's `${basedir}` property, which refers to the root project directory. - @Parameter(readonly = true, required = true, defaultValue = "${basedir}") - protected Path baseDirectory; + /// 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. @@ -66,15 +60,15 @@ public abstract sealed class BaseMojo extends AbstractMojo permits UpdatePomMojo /// 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 `SINGLE_PROJECT_VERSION` mode, where versioning is executed for a single project. + /// - `defaultValue`: Defaults to `PROJECT_VERSION` mode, where versioning is executed based on the project version. /// /// Supported Modes: - /// - [Modus#SINGLE_PROJECT_VERSION]: Handles versioning for a single project. + /// - [Modus#PROJECT_VERSION]: Handles versioning for projects using the project version property. /// - [Modus#REVISION_PROPERTY]: Handles versioning for projects using the revision property. - /// - [Modus#MULTI_PROJECT_VERSION]: Handles versioning across multiple projects (including intermediary projects). - /// - [Modus#MULTI_PROJECT_VERSION_ONLY_LEAFS]: Handles versioning for leaf projects in multi-module setups. - @Parameter(property = "versioning.modus", required = true, defaultValue = "SINGLE_PROJECT_VERSION") - protected Modus modus = Modus.SINGLE_PROJECT_VERSION; + /// - [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, @@ -117,6 +111,23 @@ public abstract sealed class BaseMojo extends AbstractMojo permits UpdatePomMojo @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"); + /// Default constructor for the BaseMojo class. /// Initializes the instance by invoking the superclass constructor. /// Maven framework typically uses this constructor during the build process. @@ -175,7 +186,7 @@ public final void execute() throws MojoExecutionException, MojoFailureException /// @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 base directory, + /// 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, @@ -186,7 +197,12 @@ public final void execute() throws MojoExecutionException, MojoFailureException /// @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 = baseDirectory.resolve(".versioning"); + Path versioningFolder; + if (versionDirectory.isAbsolute()) { + versioningFolder = versionDirectory; + } else { + versioningFolder = Path.of(session.getExecutionRootDirectory()).resolve(versionDirectory); + } List versionMarkdowns; try (Stream markdownFileStream = Files.walk(versioningFolder, 1)) { List markdownFiles = markdownFileStream.filter(Files::isRegularFile) diff --git a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java index a16d091..bf38959 100644 --- a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java @@ -7,6 +7,7 @@ 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; @@ -21,10 +22,12 @@ import java.io.IOException; import java.io.StringWriter; +import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Predicate; import java.util.stream.Collectors; import static io.github.bsels.semantic.version.utils.MarkdownUtils.readMarkdown; @@ -78,19 +81,66 @@ public UpdatePomMojo() { super(); } + /// Executes the main logic for updating Maven POM versions based on the configured update mode. + /// + /// This method handles different modes of version updates, including + /// - Updating the revision property on the root project. + /// - Updating the project version for all Maven projects. + /// - Updating only the versions of leaf projects without modules. + /// + /// The method processes version updates by creating a MarkdownMapping instance, determining + /// the projects to update, and invoking the appropriate update mechanism based on the selected mode. + /// + /// Upon successful execution, logs the outcome, indicating whether any changes were made. + /// + /// @throws MojoExecutionException if an error occurs during the execution process. + /// @throws MojoFailureException if a failure occurs during the version update process. @Override public void internalExecute() throws MojoExecutionException, MojoFailureException { Log log = getLog(); List versionMarkdowns = getVersionMarkdowns(); - MarkdownMapping markdownMapping = getMarkdownMapping(versionMarkdowns); - - switch (modus) { - case REVISION_PROPERTY, SINGLE_PROJECT_VERSION -> handleSingleVersionUpdate(markdownMapping); - case MULTI_PROJECT_VERSION -> - log.warn("Versioning mode is set to MULTI_PROJECT_VERSION, skipping execution not yet implemented"); - case MULTI_PROJECT_VERSION_ONLY_LEAFS -> - log.warn("Versioning mode is set to MULTI_PROJECT_VERSION_ONLY_LEAFS, skipping execution not yet implemented"); + MarkdownMapping mapping = getMarkdownMapping(versionMarkdowns); + + boolean hasChanges = switch (modus) { + case PROJECT_VERSION -> handleProjectVersionUpdates(mapping, Utils.alwaysTrue()); + case REVISION_PROPERTY -> handleSingleVersionUpdate(mapping, session.getCurrentProject()); + case PROJECT_VERSION_ONLY_LEAFS -> handleProjectVersionUpdates(mapping, Utils.mavenProjectHasNoModules()); + }; + if (hasChanges) { + log.info("Version update completed successfully"); + } else { + log.info("No version updates were performed"); + } + } + + /// Handles the process of updating project versions for Maven projects filtered based on a specified condition. + /// This method identifies relevant projects, determines whether a single or multiple version update process + /// should occur, and executes the appropriate update logic. + /// + /// @param markdownMapping a mapping of Maven artifacts to their corresponding semantic version bumps and version-specific markdown entries + /// @param filter a predicate used to filter Maven projects that should be updated + /// @return true if any projects had their versions updated, false otherwise + /// @throws MojoExecutionException if an error occurs during the execution of version updates + /// @throws MojoFailureException if a failure occurs in the version update process + private boolean handleProjectVersionUpdates(MarkdownMapping markdownMapping, Predicate filter) + throws MojoExecutionException, MojoFailureException { + Log log = getLog(); + List sortedProjects = session.getResult() + .getTopologicallySortedProjects() + .stream() + .filter(filter) + .toList(); + + if (sortedProjects.isEmpty()) { + log.info("No projects found matching filter"); + return false; + } + if (sortedProjects.size() == 1) { + log.info("Updating version for single project"); + return handleSingleVersionUpdate(markdownMapping, sortedProjects.get(0)); } + log.info("Updating version for multiple projects"); + return handleMultiVersionUpdate(markdownMapping, sortedProjects); } /// Handles the process of performing a single version update within a Maven project. @@ -107,17 +157,17 @@ public void internalExecute() throws MojoExecutionException, MojoFailureExceptio /// - Updates the POM version node with the new version. /// - Performs a dry-run if enabled, writing the proposed changes to a log instead of modifying the file. /// - /// @param markdownMapping the markdown version file mappings + /// @param markdownMapping the Markdown version file mappings + /// @param project the Maven project for which the version update is being performed + /// @return `true` if their where changes, `false` otherwise /// @throws MojoExecutionException if the POM cannot be read or written, or it cannot update the version node. /// @throws MojoFailureException if the runtime system fails to initial the XML reader and writer helper classes - private void handleSingleVersionUpdate(MarkdownMapping markdownMapping) + private boolean handleSingleVersionUpdate(MarkdownMapping markdownMapping, MavenProject project) throws MojoExecutionException, MojoFailureException { Log log = getLog(); - MavenProject currentProject = session.getCurrentProject(); - Path pom = currentProject - .getFile() + Path pom = project.getFile() .toPath(); - MavenArtifact projectArtifact = new MavenArtifact(currentProject.getGroupId(), currentProject.getArtifactId()); + MavenArtifact projectArtifact = new MavenArtifact(project.getGroupId(), project.getArtifactId()); Document document = POMUtils.readPom(pom); Node versionNode = POMUtils.getProjectVersionNode(document, modus); @@ -126,7 +176,7 @@ private void handleSingleVersionUpdate(MarkdownMapping markdownMapping) if (!Set.of(projectArtifact).equals(versionBumpMap.keySet())) { throw new MojoExecutionException( "Single version update expected to update only the project %s, found: %s".formatted( - currentProject, + project, versionBumpMap.keySet() ) ); @@ -134,6 +184,10 @@ private void handleSingleVersionUpdate(MarkdownMapping markdownMapping) SemanticVersionBump semanticVersionBump = getSemanticVersionBump(projectArtifact, versionBumpMap); log.info("Updating version with a %s semantic version".formatted(semanticVersionBump)); + if (SemanticVersionBump.NONE.equals(semanticVersionBump)) { + log.info("No version update required"); + return false; + } try { POMUtils.updateVersion(versionNode, semanticVersionBump); } catch (IllegalArgumentException e) { @@ -142,7 +196,7 @@ private void handleSingleVersionUpdate(MarkdownMapping markdownMapping) writeUpdatedPom(document, pom); - Path changelogFile = baseDirectory.resolve("CHANGELOG.md"); + Path changelogFile = pom.getParent().resolve(CHANGELOG_MD); org.commonmark.node.Node changelog = readMarkdown(log, changelogFile); log.debug("Original changelog"); MarkdownUtils.printMarkdown(log, changelog, 0); @@ -161,17 +215,13 @@ private void handleSingleVersionUpdate(MarkdownMapping markdownMapping) log.debug("Updated changelog"); MarkdownUtils.printMarkdown(log, changelog, 0); - // TODO: Write markdown file - if (dryRun) { - try (StringWriter writer = new StringWriter()) { - MarkdownUtils.writeMarkdown(writer, changelog); - getLog().info("Dry-run: new changelog at %s:%n%s".formatted(changelogFile, writer)); - } catch (IOException e) { - throw new MojoExecutionException("Unable to open output stream for writing", e); - } - } else { - MarkdownUtils.writeMarkdownFile(changelogFile, changelog, backupFiles); - } + writeUpdatedChangelog(changelog, changelogFile); + return true; + } + + private boolean handleMultiVersionUpdate(MarkdownMapping markdownMapping, List projects) + throws MojoExecutionException, MojoFailureException { + return false; // TODO } /// Creates a MarkdownMapping instance based on a list of [VersionMarkdown] objects. @@ -229,6 +279,27 @@ private void writeUpdatedPom(Document document, Path pom) throws MojoExecutionEx } } + /// 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 + private void writeUpdatedChangelog(org.commonmark.node.Node changelog, Path changelogFile) + throws MojoExecutionException { + if (dryRun) { + try (StringWriter writer = new StringWriter()) { + MarkdownUtils.writeMarkdown(writer, changelog); + getLog().info("Dry-run: new changelog at %s:%n%s".formatted(changelogFile, writer)); + } catch (IOException e) { + throw new MojoExecutionException("Unable to open output stream for writing", e); + } + } else { + MarkdownUtils.writeMarkdownFile(changelogFile, changelog, backupFiles && Files.exists(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. /// 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 index 97904e7..0ce1911 100644 --- a/src/main/java/io/github/bsels/semantic/version/parameters/Modus.java +++ b/src/main/java/io/github/bsels/semantic/version/parameters/Modus.java @@ -2,13 +2,14 @@ /// Enum representing different modes of handling project versions. public enum Modus { - /// Represents the mode for handling a single project version. - SINGLE_PROJECT_VERSION, + /// 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 multi-project versions. - MULTI_PROJECT_VERSION, - /// Represents the mode for handling multi-project versions, but only for leaf projects. - MULTI_PROJECT_VERSION_ONLY_LEAFS + /// 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/utils/MarkdownUtils.java b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java index 0335726..a00f67d 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java @@ -12,6 +12,7 @@ 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.Text; @@ -53,6 +54,11 @@ public final class MarkdownUtils { 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(); + /// 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 @@ -65,10 +71,6 @@ public final class MarkdownUtils { .extensions(List.of(YamlFrontMatterExtension.create())) .includeSourceSpans(IncludeSourceSpans.BLOCKS_AND_INLINES) .build(); - /// 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(); /// A static, pre-configured instance of the [Renderer] used to process and render Markdown content within /// the [MarkdownUtils] utility class. @@ -85,6 +87,13 @@ public final class MarkdownUtils { /// of Markdown processing methods. private static final Renderer MARKDOWN_RENDERER = MarkdownRenderer.builder().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() { @@ -138,6 +147,15 @@ public static VersionMarkdown readVersionMarkdown(Log log, Path markdownFile) 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 one 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)); @@ -174,7 +192,7 @@ public static void mergeVersionMarkdownsInChangelog( if (!(changelog.getFirstChild() instanceof Heading heading && heading.getLevel() == 1 && - heading.getFirstChild() instanceof Text text && "Changelog".equals(text.getLiteral()))) { + 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(); @@ -282,6 +300,9 @@ private static Node insertNodeChilds(Node currentLambda, Node node) { /// @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; } 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 index 9c5522f..b4f86d7 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/POMUtils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/POMUtils.java @@ -114,8 +114,7 @@ public static Node getProjectVersionNode(Document document, Modus modus) Objects.requireNonNull(modus, "`modus` must not be null"); List versionPropertyPath = switch (modus) { case REVISION_PROPERTY -> REVISION_PROPERTY_PATH; - case SINGLE_PROJECT_VERSION, MULTI_PROJECT_VERSION, MULTI_PROJECT_VERSION_ONLY_LEAFS -> - VERSION_PROPERTY_PATH; + case PROJECT_VERSION, PROJECT_VERSION_ONLY_LEAFS -> VERSION_PROPERTY_PATH; }; try { return walk(document, versionPropertyPath, 0); diff --git a/src/main/java/io/github/bsels/semantic/version/utils/Utils.java b/src/main/java/io/github/bsels/semantic/version/utils/Utils.java index ddfb9c9..a9bc7d0 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/Utils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/Utils.java @@ -1,11 +1,13 @@ 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.util.function.Predicate; /// A utility class containing static constants and methods for various common operations. /// This class is final and not intended to be instantiated. @@ -42,4 +44,19 @@ public static void backupFile(Path file) throws MojoExecutionException { throw new MojoExecutionException("Failed to backup %s to %s".formatted(file, backupPom), e); } } + + /// 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(); + } } From b50a473ba98652c260e3ca9aebf742cc4a505691 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Mon, 5 Jan 2026 20:20:24 +0100 Subject: [PATCH 09/63] Introduce the ` TestLog ` utility for test logging with customizable log levels. --- .../bsels/semantic/version/utils/TestLog.java | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 src/test/java/io/github/bsels/semantic/version/utils/TestLog.java diff --git a/src/test/java/io/github/bsels/semantic/version/utils/TestLog.java b/src/test/java/io/github/bsels/semantic/version/utils/TestLog.java new file mode 100644 index 0000000..b6c29c6 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/utils/TestLog.java @@ -0,0 +1,146 @@ +package io.github.bsels.semantic.version.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")) + ); + } + } +} From d3ab3d85c6af8c6044f03184d7f70ea35bd5cc3a Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Wed, 7 Jan 2026 18:11:56 +0100 Subject: [PATCH 10/63] Enhance `POMUtils` with constants for Maven artifact structure, streamlined XML traversal, and comprehensive artifact extraction logic. Introduce `StreamWalk` for efficient node processing. --- .../semantic/version/utils/POMUtils.java | 234 ++++++++++++++++-- 1 file changed, 211 insertions(+), 23 deletions(-) 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 index b4f86d7..32ad459 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/POMUtils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/POMUtils.java @@ -1,5 +1,6 @@ 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.parameters.Modus; @@ -30,8 +31,14 @@ 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. @@ -40,14 +47,72 @@ /// /// 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"; - /// 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. + /// A constant list of directory names representing the path segments typically used to locate build-related plugins + /// within a project structure. /// - /// 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"); + /// 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, @@ -57,7 +122,22 @@ public final class POMUtils { /// 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"); + 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 @@ -194,16 +274,14 @@ public static void writePom(Document document, Writer writer) } } - /** - * 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 - */ + /// 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"); @@ -214,6 +292,65 @@ public static void updateVersion(Node nodeElement, SemanticVersionBump 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 + public static Map> getMavenArtifacts(Document document) { + 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(Collectors.collectingAndThen( + Collectors.groupingBy( + Map.Entry::getKey, + Collectors.mapping( + Map.Entry::getValue, + Collectors.collectingAndThen(Collectors.toList(), List::copyOf) + ) + ), + Map::copyOf + )); + } + + /// 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. @@ -227,18 +364,47 @@ private static Node walk(Node parent, List path, int currentElementIndex if (currentElementIndex == path.size()) { return 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())) + 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(currentElementName, parent.getNodeName()) + "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. @@ -282,4 +448,26 @@ private static Transformer getOrCreateTransformer() throws MojoFailureException 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"); + } + } } From 9cb71f45922b2ab8a0222c654eff0b2a24739153 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Wed, 7 Jan 2026 19:31:48 +0100 Subject: [PATCH 11/63] Refactor `Utils`, `POMUtils`, and `MarkdownUtils` for immutability and enhanced changelog handling. Streamline grouping logic with new utility methods in `Utils`, consolidate version bump and dependency update logic in `UpdatePomMojo`, and improve Markdown and POM integration. --- .../bsels/semantic/version/UpdatePomMojo.java | 234 +++++++++++++++--- .../semantic/version/utils/MarkdownUtils.java | 49 ++-- .../semantic/version/utils/POMUtils.java | 12 +- .../bsels/semantic/version/utils/Utils.java | 67 +++++ 4 files changed, 296 insertions(+), 66 deletions(-) diff --git a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java index bf38959..691022c 100644 --- a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java @@ -24,8 +24,14 @@ import java.io.StringWriter; import java.nio.file.Files; import java.nio.file.Path; +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.Set; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -164,64 +170,157 @@ private boolean handleProjectVersionUpdates(MarkdownMapping markdownMapping, Pre /// @throws MojoFailureException if the runtime system fails to initial the XML reader and writer helper classes private boolean handleSingleVersionUpdate(MarkdownMapping markdownMapping, MavenProject project) throws MojoExecutionException, MojoFailureException { - Log log = getLog(); Path pom = project.getFile() .toPath(); - MavenArtifact projectArtifact = new MavenArtifact(project.getGroupId(), project.getArtifactId()); + MavenArtifact artifact = new MavenArtifact(project.getGroupId(), project.getArtifactId()); Document document = POMUtils.readPom(pom); - Node versionNode = POMUtils.getProjectVersionNode(document, modus); + SemanticVersionBump semanticVersionBump = getSemanticVersionBumpForSingleProject(markdownMapping, artifact); + Optional version = updateProjectVersion(semanticVersionBump, document); + if (version.isEmpty()) { + return false; + } + + writeUpdatedPom(document, pom); + + updateMarkdownFile(markdownMapping, artifact, pom, version.get()); + return true; + } + + /// Determines the semantic version bump for a single Maven project based on the provided Markdown mapping. + /// Validates that only the specified project artifact is being updated. + /// + /// @param markdownMapping the mapping that contains information about version bumps for multiple artifacts + /// @param projectArtifact the Maven artifact representing the single project whose version bump is to be determined + /// @return the semantic version bump for the provided project artifact + /// @throws MojoExecutionException if the version bump map contains artifacts other than the provided project artifact + private SemanticVersionBump getSemanticVersionBumpForSingleProject( + MarkdownMapping markdownMapping, + MavenArtifact projectArtifact + ) throws MojoExecutionException { Map versionBumpMap = markdownMapping.versionBumpMap(); if (!Set.of(projectArtifact).equals(versionBumpMap.keySet())) { throw new MojoExecutionException( "Single version update expected to update only the project %s, found: %s".formatted( - project, + projectArtifact, versionBumpMap.keySet() ) ); } + return versionBumpMap.get(projectArtifact); + } + + private boolean handleMultiVersionUpdate(MarkdownMapping markdownMapping, List projects) + throws MojoExecutionException, MojoFailureException { + Map documents = new HashMap<>(); + for (MavenProject project : projects) { + MavenArtifact mavenArtifact = new MavenArtifact(project.getGroupId(), project.getArtifactId()); + documents.put( + mavenArtifact, + new MavenProjectAndDocument(project, mavenArtifact, POMUtils.readPom(project.getFile().toPath()))); + } + documents = Map.copyOf(documents); + Collection documentsCollection = documents.values(); + Set reactorArtifacts = documentsCollection.stream() + .map(MavenProjectAndDocument::artifact) + .collect(Collectors.collectingAndThen(Collectors.toSet(), Set::copyOf)); + Map> updatableDependencies = mergeUpdatableDependencies( + documentsCollection, + reactorArtifacts + ); + Map> dependencyToProjectArtifact = createDependencyToProjectArtifactMapping( + documentsCollection, + reactorArtifacts + ); + + Set updatedArtifacts = new HashSet<>(); + for (MavenArtifact artifact : reactorArtifacts) { + // TODO + } + + return !updatedArtifacts.isEmpty(); + } + + /// Updates the project version in the provided document by applying the semantic version bump specified in + /// the Markdown mapping for the given project artifact. + /// + /// @param semanticVersionBump the semantic version bump to apply to the project version + /// @param document the XML document representing the project's POM + /// @return an [Optional] containing the updated version string if the version is updated, or an empty [Optional] if no update is required + /// @throws MojoExecutionException if a semantic version bump cannot be applied, or if the input mapping is invalid for the given project artifact + private Optional updateProjectVersion( + SemanticVersionBump semanticVersionBump, + Document document + ) throws MojoExecutionException { + Log log = getLog(); + Node versionNode = POMUtils.getProjectVersionNode(document, modus); - SemanticVersionBump semanticVersionBump = getSemanticVersionBump(projectArtifact, versionBumpMap); log.info("Updating version with a %s semantic version".formatted(semanticVersionBump)); if (SemanticVersionBump.NONE.equals(semanticVersionBump)) { log.info("No version update required"); - return false; + return Optional.empty(); } try { POMUtils.updateVersion(versionNode, semanticVersionBump); } catch (IllegalArgumentException e) { throw new MojoExecutionException("Unable to update version changelog", e); } + return Optional.of(versionNode.getTextContent()); + } - writeUpdatedPom(document, pom); - - 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( - log, - changelog, - versionNode.getTextContent(), - markdownMapping.markdownMap() - .getOrDefault(projectArtifact, List.of()) - .stream() - .collect(Collectors.groupingBy( - entry -> entry.bumps().get(projectArtifact), - Collectors.mapping(VersionMarkdown::content, Collectors.toList()) - )) - ); - log.debug("Updated changelog"); - MarkdownUtils.printMarkdown(log, changelog, 0); - - writeUpdatedChangelog(changelog, changelogFile); - return true; + /// 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()) + )); } - private boolean handleMultiVersionUpdate(MarkdownMapping markdownMapping, List projects) - throws MojoExecutionException, MojoFailureException { - return false; // TODO + /// 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) + )) + ) + )); } /// Creates a MarkdownMapping instance based on a list of [VersionMarkdown] objects. @@ -236,7 +335,7 @@ private MarkdownMapping getMarkdownMapping(List versionMarkdown .map(VersionMarkdown::bumps) .map(Map::entrySet) .flatMap(Set::stream) - .collect(Collectors.groupingBy( + .collect(Utils.groupingByImmutable( Map.Entry::getKey, Collectors.reducing(SemanticVersionBump.NONE, Map.Entry::getValue, SemanticVersionBump::max) )); @@ -246,12 +345,9 @@ private MarkdownMapping getMarkdownMapping(List versionMarkdown consumer.accept(Map.entry(artifact, item)); } }) - .collect(Collectors.groupingBy( + .collect(Utils.groupingByImmutable( Map.Entry::getKey, - Collectors.mapping( - Map.Entry::getValue, - Collectors.collectingAndThen(Collectors.toList(), List::copyOf) - ) + Collectors.mapping(Map.Entry::getValue, Utils.asImmutableList()) )); return new MarkdownMapping(versionBumpMap, markdownMap); } @@ -279,6 +375,46 @@ private void writeUpdatedPom(Document document, Path pom) throws MojoExecutionEx } } + /// 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 + private void updateMarkdownFile( + MarkdownMapping markdownMapping, + MavenArtifact projectArtifact, + Path pom, + String newVersion + ) throws MojoExecutionException { + 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( + log, + 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. @@ -317,4 +453,26 @@ private SemanticVersionBump getSemanticVersionBump( case PATCH -> SemanticVersionBump.PATCH; }; } + + /// Represents a combination of a MavenProject and its associated Document. + /// This class is a record that holds a Maven project and corresponding document, + /// ensuring that both parameters are non-null during instantiation. + /// + /// @param project the Maven project must not be null + /// @param artifact the Maven artifact must not be null + /// @param document the associated document must not be null + private record MavenProjectAndDocument(MavenProject project, MavenArtifact artifact, Document document) { + + /// Constructs an instance of MavenProjectAndDocument with the specified Maven project and document. + /// + /// @param project the Maven project must not be null + /// @param artifact the Maven artifact must not be null + /// @param document the associated document must not be null + /// @throws NullPointerException if the project or document is null + private MavenProjectAndDocument { + Objects.requireNonNull(project, "`project` must not be null"); + Objects.requireNonNull(artifact, "`artifact` must not be null"); + Objects.requireNonNull(document, "`document` must not be null"); + } + } } diff --git a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java index a00f67d..0cd900d 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java @@ -243,6 +243,36 @@ public static void writeMarkdown(Writer output, Node document) { 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 + public static VersionMarkdown createSimpleVersionBumpDocument(MavenArtifact mavenArtifact) { + Document document = new Document(); + document.appendChild(new Text("Project version bumped as result of dependency bumps")); + return new VersionMarkdown(document, Map.of(mavenArtifact, SemanticVersionBump.PATCH)); + } + /// 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 @@ -291,23 +321,4 @@ private static Node insertNodeChilds(Node currentLambda, Node node) { } return currentLambda; } - - /// 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); - } } 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 index 32ad459..bfc170e 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/POMUtils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/POMUtils.java @@ -312,15 +312,9 @@ public static Map> getMavenArtifacts(Document document .map(POMUtils::handleArtifactNode) .filter(Optional::isPresent) .map(Optional::get) - .collect(Collectors.collectingAndThen( - Collectors.groupingBy( - Map.Entry::getKey, - Collectors.mapping( - Map.Entry::getValue, - Collectors.collectingAndThen(Collectors.toList(), List::copyOf) - ) - ), - Map::copyOf + .collect(Utils.groupingByImmutable( + Map.Entry::getKey, + Collectors.mapping(Map.Entry::getValue, Utils.asImmutableList()) )); } diff --git a/src/main/java/io/github/bsels/semantic/version/utils/Utils.java b/src/main/java/io/github/bsels/semantic/version/utils/Utils.java index a9bc7d0..d1ad167 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/Utils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/Utils.java @@ -7,7 +7,15 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.util.List; +import java.util.Map; +import java.util.Objects; +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. @@ -59,4 +67,63 @@ public static Predicate alwaysTrue() { 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 Collectors.collectingAndThen(Collectors.toList(), List::copyOf); + } } From d7cca96b9dc3d355d2f44381ae5915e8d1783983 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Thu, 8 Jan 2026 19:52:10 +0100 Subject: [PATCH 12/63] Add the ` VersionChange ` record and update related logic for enhanced version tracking and dependency updates. Rework `UpdatePomMojo` and `POMUtils` to use `VersionChange`, enabling precise old-to-new version transitions. Streamline multi-project version bump handling. --- .../bsels/semantic/version/UpdatePomMojo.java | 71 +++++++++++++++---- .../version/models/VersionChange.java | 26 +++++++ .../semantic/version/utils/POMUtils.java | 17 +++++ 3 files changed, 99 insertions(+), 15 deletions(-) create mode 100644 src/main/java/io/github/bsels/semantic/version/models/VersionChange.java diff --git a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java index 691022c..e476e16 100644 --- a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java @@ -3,6 +3,7 @@ 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; @@ -24,6 +25,7 @@ import java.io.StringWriter; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; @@ -32,6 +34,7 @@ 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; @@ -177,14 +180,14 @@ private boolean handleSingleVersionUpdate(MarkdownMapping markdownMapping, Maven Document document = POMUtils.readPom(pom); SemanticVersionBump semanticVersionBump = getSemanticVersionBumpForSingleProject(markdownMapping, artifact); - Optional version = updateProjectVersion(semanticVersionBump, document); + Optional version = updateProjectVersion(semanticVersionBump, document); if (version.isEmpty()) { return false; } writeUpdatedPom(document, pom); - updateMarkdownFile(markdownMapping, artifact, pom, version.get()); + updateMarkdownFile(markdownMapping, artifact, pom, version.get().newVersion()); return true; } @@ -208,9 +211,18 @@ private SemanticVersionBump getSemanticVersionBumpForSingleProject( ) ); } - return versionBumpMap.get(projectArtifact); + return getSemanticVersionBump(projectArtifact, versionBumpMap); } + /// Handles the update process for multiple versions of Maven projects. + /// It updates the project versions, modifies associated dependencies across projects, + /// and updates the Markdown and POM files accordingly. + /// + /// @param markdownMapping the mapping containing version bump information and markdown changes + /// @param projects the list of Maven projects to be processed and updated + /// @return `true` if at least one project version was updated, `false` otherwise + /// @throws MojoExecutionException if an error occurs during the execution of the update process + /// @throws MojoFailureException if a failure requirement is explicitly triggered during the process private boolean handleMultiVersionUpdate(MarkdownMapping markdownMapping, List projects) throws MojoExecutionException, MojoFailureException { Map documents = new HashMap<>(); @@ -229,32 +241,61 @@ private boolean handleMultiVersionUpdate(MarkdownMapping markdownMapping, List> dependencyToProjectArtifact = createDependencyToProjectArtifactMapping( + Map> dependencyToProjectArtifacts = createDependencyToProjectArtifactMapping( documentsCollection, reactorArtifacts ); Set updatedArtifacts = new HashSet<>(); - for (MavenArtifact artifact : reactorArtifacts) { - // TODO - } + Queue toBeUpdated = new ArrayDeque<>(markdownMapping.versionBumpMap().keySet()); + while (!toBeUpdated.isEmpty()) { + MavenArtifact artifact = toBeUpdated.poll(); + toBeUpdated.remove(artifact); + + SemanticVersionBump bump = getSemanticVersionBump(artifact, markdownMapping.versionBumpMap()); + MavenProjectAndDocument mavenProjectAndDocument = documents.get(artifact); + Optional versionChangeOptional = updateProjectVersion(bump, mavenProjectAndDocument.document()); + if (versionChangeOptional.isPresent()) { + VersionChange versionChange = versionChangeOptional.get(); + updatedArtifacts.add(artifact); + dependencyToProjectArtifacts.getOrDefault(artifact, List.of()) + .stream() + .filter(Predicate.not(updatedArtifacts::contains)) + .forEach(toBeUpdated::offer); + Path pom = mavenProjectAndDocument.project() + .getFile() + .toPath(); + + updateMarkdownFile(markdownMapping, artifact, pom, versionChange.newVersion()); + updatableDependencies.getOrDefault(artifact, List.of()) + .forEach(node -> POMUtils.updateVersionNodeIfOldVersionMatches(versionChange, node)); + } + } + for (MavenArtifact artifact : updatedArtifacts) { + MavenProjectAndDocument mavenProjectAndDocument = documents.get(artifact); + Path pom = mavenProjectAndDocument.project() + .getFile() + .toPath(); + writeUpdatedPom(mavenProjectAndDocument.document(), pom); + } return !updatedArtifacts.isEmpty(); } - /// Updates the project version in the provided document by applying the semantic version bump specified in - /// the Markdown mapping for the given project artifact. + /// 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 semantic version bump to apply to the project version - /// @param document the XML document representing the project's POM - /// @return an [Optional] containing the updated version string if the version is updated, or an empty [Optional] if no update is required - /// @throws MojoExecutionException if a semantic version bump cannot be applied, or if the input mapping is invalid for the given project artifact - private Optional updateProjectVersion( + /// @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)) { @@ -266,7 +307,7 @@ private Optional updateProjectVersion( } catch (IllegalArgumentException e) { throw new MojoExecutionException("Unable to update version changelog", e); } - return Optional.of(versionNode.getTextContent()); + return Optional.of(new VersionChange(originalVersion, versionNode.getTextContent())); } /// Creates a mapping between dependency artifacts and project artifacts based on the provided 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/utils/POMUtils.java b/src/main/java/io/github/bsels/semantic/version/utils/POMUtils.java index bfc170e..a76ed97 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/POMUtils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/POMUtils.java @@ -3,6 +3,7 @@ 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; @@ -318,6 +319,22 @@ public static Map> getMavenArtifacts(Document document )); } + /// 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]. From 958c862c2f35b55684a737715c023a0cad0ee010 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Thu, 8 Jan 2026 19:54:16 +0100 Subject: [PATCH 13/63] Enhance `POMUtils` JavaDoc formatting for parameter descriptions. --- .../java/io/github/bsels/semantic/version/utils/POMUtils.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index a76ed97..b99ed7a 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/POMUtils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/POMUtils.java @@ -323,7 +323,7 @@ public static Map> getMavenArtifacts(Document document /// 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 + /// @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 { From 366ddb719b0a645d91f50200151e658acf53f201 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Thu, 8 Jan 2026 20:27:53 +0100 Subject: [PATCH 14/63] Refactor `Utils` for improved null handling and immutability; add comprehensive unit tests for utility methods. --- .../bsels/semantic/version/utils/Utils.java | 6 +- .../semantic/version/utils/UtilsTest.java | 228 ++++++++++++++++++ 2 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java diff --git a/src/main/java/io/github/bsels/semantic/version/utils/Utils.java b/src/main/java/io/github/bsels/semantic/version/utils/Utils.java index d1ad167..8a3c4f6 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/Utils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/Utils.java @@ -36,7 +36,9 @@ private Utils() { /// /// @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 - public static void backupFile(Path file) throws MojoExecutionException { + /// @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); @@ -124,6 +126,6 @@ public static BinaryOperator consumerToOperator(BiConsumer /// @return a collector that produces an immutable list of the collected elements /// @see [#asImmutableList(Collector)] public static Collector> asImmutableList() { - return Collectors.collectingAndThen(Collectors.toList(), List::copyOf); + return asImmutableList(Collectors.toList()); } } 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..64e925e --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java @@ -0,0 +1,228 @@ +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.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 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"); + 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"); + 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 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); + } + } +} From d80b2e44d6043c307eb65c8fa582793f60c2f6ad Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Fri, 9 Jan 2026 18:34:39 +0100 Subject: [PATCH 15/63] Add unit tests for `YamlFrontMatterBlock` and `YamlFrontMatterExtension` to ensure proper validation, construction, and extension behavior. --- .../front/block/YamlFrontMatterBlockTest.java | 40 +++++++++++++++++++ .../block/YamlFrontMatterExtensionTest.java | 38 ++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 src/test/java/io/github/bsels/semantic/version/utils/yaml/front/block/YamlFrontMatterBlockTest.java create mode 100644 src/test/java/io/github/bsels/semantic/version/utils/yaml/front/block/YamlFrontMatterExtensionTest.java 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)); + } +} From 8ff458a1780ded109822ea2572abae777c913bb5 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sat, 10 Jan 2026 10:58:43 +0100 Subject: [PATCH 16/63] Add comprehensive unit tests for `SemanticVersion`, `SemanticVersionBump`, `VersionMarkdown`, and related classes. Improve null and immutability handling, rework version bump logic, and enhance JavaDoc for accuracy and clarity. --- .../version/models/SemanticVersion.java | 5 +- .../version/models/SemanticVersionBump.java | 34 ++- .../semantic/version/utils/package-info.java | 2 + .../version/models/MarkdownMappingTest.java | 56 ++++ .../models/SemanticVersionBumpTest.java | 259 ++++++++++++++++++ .../version/models/VersionChangeTest.java | 31 +++ .../version/models/VersionMarkdownTest.java | 66 +++++ .../version/parameters/ModusTest.java | 33 +++ .../version/parameters/VersionBumpTest.java | 33 +++ .../test/utils/ArrayArgumentConverter.java | 80 ++++++ .../version/{ => test}/utils/TestLog.java | 2 +- 11 files changed, 584 insertions(+), 17 deletions(-) create mode 100644 src/main/java/io/github/bsels/semantic/version/utils/package-info.java create mode 100644 src/test/java/io/github/bsels/semantic/version/models/MarkdownMappingTest.java create mode 100644 src/test/java/io/github/bsels/semantic/version/models/SemanticVersionBumpTest.java create mode 100644 src/test/java/io/github/bsels/semantic/version/models/VersionChangeTest.java create mode 100644 src/test/java/io/github/bsels/semantic/version/models/VersionMarkdownTest.java create mode 100644 src/test/java/io/github/bsels/semantic/version/parameters/ModusTest.java create mode 100644 src/test/java/io/github/bsels/semantic/version/parameters/VersionBumpTest.java create mode 100644 src/test/java/io/github/bsels/semantic/version/test/utils/ArrayArgumentConverter.java rename src/test/java/io/github/bsels/semantic/version/{ => test}/utils/TestLog.java (98%) 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 index bb353b1..b57e3d1 100644 --- a/src/main/java/io/github/bsels/semantic/version/models/SemanticVersion.java +++ b/src/main/java/io/github/bsels/semantic/version/models/SemanticVersion.java @@ -114,10 +114,13 @@ public SemanticVersion stripSuffix() { /// 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 + /// @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"); + 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 index c168581..b8c57e9 100644 --- a/src/main/java/io/github/bsels/semantic/version/models/SemanticVersionBump.java +++ b/src/main/java/io/github/bsels/semantic/version/models/SemanticVersionBump.java @@ -12,24 +12,24 @@ /// 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: -/// - MAJOR: Indicates a major version increment, which may introduce breaking changes. -/// - MINOR: Indicates a minor version increment, which may add functionality in a backward-compatible manner. -/// - PATCH: Indicates a patch version increment, which may introduce backward-compatible bug fixes. /// - 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 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, - /// 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 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 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 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. /// @@ -40,6 +40,9 @@ public enum SemanticVersionBump { /// @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()); } @@ -58,7 +61,7 @@ public static SemanticVersionBump max(SemanticVersionBump... bumps) throws NullP /// 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, [#NONE] 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 @@ -66,7 +69,8 @@ public static SemanticVersionBump max(SemanticVersionBump... bumps) throws NullP public static SemanticVersionBump max(Collection bumps) throws NullPointerException { Objects.requireNonNull(bumps, "`bumps` must not be null"); return bumps.stream() - .min(Comparator.naturalOrder()) + .filter(Objects::nonNull) + .max(Comparator.naturalOrder()) .orElse(NONE); } } 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..850fb7b --- /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, and other utility functions. +package io.github.bsels.semantic.version.utils; \ No newline at end of file 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/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/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..f06139b --- /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, Map.of(MAVEN_ARTIFACT, SemanticVersionBump.NONE))) + .isInstanceOf(NullPointerException.class) + .hasMessage("`content` must not be null"); + } + + @Test + void nullBumps_ThrowsNullPointerException() { + assertThatThrownBy(() -> new VersionMarkdown(CONTENT, null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("`bumps` must not be null"); + } + + @Test + void emptyBumps_ThrowsIllegalArgumentException() { + assertThatThrownBy(() -> new VersionMarkdown(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(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(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/utils/TestLog.java b/src/test/java/io/github/bsels/semantic/version/test/utils/TestLog.java similarity index 98% rename from src/test/java/io/github/bsels/semantic/version/utils/TestLog.java rename to src/test/java/io/github/bsels/semantic/version/test/utils/TestLog.java index b6c29c6..2558432 100644 --- a/src/test/java/io/github/bsels/semantic/version/utils/TestLog.java +++ b/src/test/java/io/github/bsels/semantic/version/test/utils/TestLog.java @@ -1,4 +1,4 @@ -package io.github.bsels.semantic.version.utils; +package io.github.bsels.semantic.version.test.utils; import org.apache.maven.plugin.logging.Log; From e517a34a38dc9ad1f6a58317aba400d362780acd Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sat, 10 Jan 2026 11:39:24 +0100 Subject: [PATCH 17/63] Add unit tests for `MavenArtifact` and `SemanticVersion` to validate constructors, parsing logic, and behavior. Refactor `SemanticVersion` suffix validation and improve null handling with utility methods. Enhance JavaDoc for clarity. --- .../version/models/MavenArtifact.java | 4 +- .../version/models/SemanticVersion.java | 22 +- .../version/models/MavenArtifactTest.java | 84 ++++++ .../version/models/SemanticVersionTest.java | 274 ++++++++++++++++++ 4 files changed, 378 insertions(+), 6 deletions(-) create mode 100644 src/test/java/io/github/bsels/semantic/version/models/MavenArtifactTest.java create mode 100644 src/test/java/io/github/bsels/semantic/version/models/SemanticVersionTest.java 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 index 58a63f9..ed9a339 100644 --- a/src/main/java/io/github/bsels/semantic/version/models/MavenArtifact.java +++ b/src/main/java/io/github/bsels/semantic/version/models/MavenArtifact.java @@ -23,8 +23,8 @@ public record MavenArtifact(String groupId, String artifactId) { /// @param artifactId the artifact ID must not be null /// @throws NullPointerException if `groupId` or `artifactId` is null public MavenArtifact { - Objects.requireNonNull(groupId, "`groupId` cannot be null"); - Objects.requireNonNull(artifactId, "`artifactId` cannot be null"); + 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 `:`. 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 index b57e3d1..3bc349b 100644 --- a/src/main/java/io/github/bsels/semantic/version/models/SemanticVersion.java +++ b/src/main/java/io/github/bsels/semantic/version/models/SemanticVersion.java @@ -28,6 +28,7 @@ public record SemanticVersion(int major, int minor, int patch, 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-.]+)?$"); + 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. @@ -43,10 +44,9 @@ public record SemanticVersion(int major, int minor, int patch, Optional if (major < 0 || minor < 0 || patch < 0) { throw new IllegalArgumentException("Version parts must be non-negative"); } - suffix = suffix.filter(Predicate.not(String::isEmpty)); - if (suffix.isPresent() && !suffix.get().matches("^-[a-zA-Z0-9-.]+$")) { - throw new IllegalArgumentException("Suffix must be alphanumeric, dash, or dot, and should not start with a dash"); - } + 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. @@ -118,9 +118,23 @@ public SemanticVersion stripSuffix() { /// @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)); } + + /// 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) { + if (!suffix.matches(SUFFIX_REGEX_PATTERN)) { + throw new IllegalArgumentException("Suffix must be alphanumeric, dash, or dot, and should not start with a dash"); + } + } } 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..44d7b64 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/models/MavenArtifactTest.java @@ -0,0 +1,84 @@ +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)); + } + } +} 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); + } + } +} From c3006bd67d351e87e8ab0f47a5dc549ac3d92985 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sat, 10 Jan 2026 11:40:54 +0100 Subject: [PATCH 18/63] Refactor `SemanticVersion` to enhance suffix validation with improved regex pattern and detailed JavaDoc. --- .../version/models/SemanticVersion.java | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) 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 index 3bc349b..c47acec 100644 --- a/src/main/java/io/github/bsels/semantic/version/models/SemanticVersion.java +++ b/src/main/java/io/github/bsels/semantic/version/models/SemanticVersion.java @@ -28,6 +28,22 @@ public record SemanticVersion(int major, int minor, int patch, 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, @@ -74,6 +90,17 @@ public static SemanticVersion of(String version) throws IllegalArgumentException ); } + /// 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. /// @@ -124,17 +151,4 @@ public SemanticVersion withSuffix(String suffix) throws NullPointerException { } return new SemanticVersion(major, minor, patch, Optional.of(suffix)); } - - /// 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) { - if (!suffix.matches(SUFFIX_REGEX_PATTERN)) { - throw new IllegalArgumentException("Suffix must be alphanumeric, dash, or dot, and should not start with a dash"); - } - } } From dd274e21278703d80fcea17438faa9f637ec54a9 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sat, 10 Jan 2026 12:13:40 +0100 Subject: [PATCH 19/63] Add unit tests for `YamlFrontMatterBlockParser`, covering scenarios with and without YAML front matter, to validate parsing and tree structure. --- .../block/YamlFrontMatterBlockParserTest.java | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 src/test/java/io/github/bsels/semantic/version/utils/yaml/front/block/YamlFrontMatterBlockParserTest.java 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..3f93223 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/utils/yaml/front/block/YamlFrontMatterBlockParserTest.java @@ -0,0 +1,244 @@ +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.node.ThematicBreak; +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 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); + + assertThat(actual) + .isInstanceOf(Document.class) + .extracting(Node::getFirstChild) + .isNotNull() + .isInstanceOf(Heading.class) + .hasFieldOrPropertyWithValue("level", 1) + .satisfies( + n -> assertThat(n.getFirstChild()) + .isNotNull() + .isInstanceOf(Text.class) + .hasFieldOrPropertyWithValue("literal", "No 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(); + } + + @Test + void noFrontMatterBlockButHasHorizontalLine_ReturnValidMarkdownTree() { + String markdown = """ + Paragraph 1 + + --- + + Paragraph 2 + """; + + Node actual = PARSER.parse(markdown); + + assertThat(actual) + .isInstanceOf(Document.class) + .extracting(Node::getFirstChild) + .isNotNull() + .isInstanceOf(Paragraph.class) + .satisfies( + n -> assertThat(n.getFirstChild()) + .isNotNull() + .isInstanceOf(Text.class) + .hasFieldOrPropertyWithValue("literal", "Paragraph 1") + .extracting(Node::getNext) + .isNull() + ) + .extracting(Node::getNext) + .isNotNull() + .isInstanceOf(ThematicBreak.class) + .extracting(Node::getNext) + .isNotNull() + .isInstanceOf(Paragraph.class) + .satisfies( + n -> assertThat(n.getFirstChild()) + .isNotNull() + .isInstanceOf(Text.class) + .hasFieldOrPropertyWithValue("literal", "Paragraph 2") + .extracting(Node::getNext) + .isNull() + ) + .extracting(Node::getNext) + .isNull(); + } + + @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(); + } + } +} From 90587f02ce806309ec0cabb7cec3b10f8743254f Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sat, 10 Jan 2026 13:17:10 +0100 Subject: [PATCH 20/63] Add YAML front matter rendering support to `MarkdownUtils` with `MarkdownYamFrontMatterBlockRenderer`. Enhance `Utils` JavaDoc references for clarity. --- .../semantic/version/utils/MarkdownUtils.java | 5 +- .../bsels/semantic/version/utils/Utils.java | 4 +- .../MarkdownYamFrontMatterBlockRenderer.java | 65 ++++++++++++++ ...ownYamFrontMatterBlockRendererFactory.java | 84 +++++++++++++++++++ .../utils/yaml/front/block/package-info.java | 2 +- 5 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/MarkdownYamFrontMatterBlockRenderer.java create mode 100644 src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/MarkdownYamFrontMatterBlockRendererFactory.java diff --git a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java index 0cd900d..74c7660 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java @@ -8,6 +8,7 @@ import io.github.bsels.semantic.version.models.MavenArtifact; import io.github.bsels.semantic.version.models.SemanticVersionBump; import io.github.bsels.semantic.version.models.VersionMarkdown; +import io.github.bsels.semantic.version.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; @@ -85,7 +86,9 @@ public final class MarkdownUtils { /// /// 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().build(); + 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. diff --git a/src/main/java/io/github/bsels/semantic/version/utils/Utils.java b/src/main/java/io/github/bsels/semantic/version/utils/Utils.java index 8a3c4f6..534c03f 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/Utils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/Utils.java @@ -114,7 +114,7 @@ public static BinaryOperator consumerToOperator(BiConsumer /// @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()] + /// @see #asImmutableList public static Collector> asImmutableList(Collector> downstream) { return Collectors.collectingAndThen(downstream, List::copyOf); } @@ -124,7 +124,7 @@ public static BinaryOperator consumerToOperator(BiConsumer /// /// @param the type of input elements to the collector /// @return a collector that produces an immutable list of the collected elements - /// @see [#asImmutableList(Collector)] + /// @see #asImmutableList(Collector) public static Collector> asImmutableList() { return asImmutableList(Collectors.toList()); } 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..8747463 --- /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) { + 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..d9ebe4b --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/MarkdownYamFrontMatterBlockRendererFactory.java @@ -0,0 +1,84 @@ +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) { + 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/package-info.java b/src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/package-info.java index bb87aa2..10637ba 100644 --- 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 @@ -1,2 +1,2 @@ -/// This package contains the YAML Front Matter Block Parser for the extension with CommonMark. +/// 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 From e5bf5b2b8d303a6db63e72021e485d7e1e81e1b0 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sat, 10 Jan 2026 14:04:06 +0100 Subject: [PATCH 21/63] Add unit tests for `MarkdownYamFrontMatterBlockRenderer` and `MarkdownYamFrontMatterBlockRendererFactory`. Refactor `createSimpleVersionBumpDocument` in `MarkdownUtils` to ensure proper paragraph handling. Enhance null-checks and JavaDoc clarity. --- .../semantic/version/utils/MarkdownUtils.java | 5 +- .../MarkdownYamFrontMatterBlockRenderer.java | 2 +- ...ownYamFrontMatterBlockRendererFactory.java | 4 +- ...amFrontMatterBlockRendererFactoryTest.java | 54 ++++++ ...rkdownYamFrontMatterBlockRendererTest.java | 157 ++++++++++++++++++ 5 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 src/test/java/io/github/bsels/semantic/version/utils/yaml/front/block/MarkdownYamFrontMatterBlockRendererFactoryTest.java create mode 100644 src/test/java/io/github/bsels/semantic/version/utils/yaml/front/block/MarkdownYamFrontMatterBlockRendererTest.java diff --git a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java index 74c7660..893ebee 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java @@ -16,6 +16,7 @@ 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; @@ -272,7 +273,9 @@ public static void printMarkdown(Log log, Node node, int level) { /// @return a [VersionMarkdown] object containing the generated document and a mapping of the Maven artifact to a PATCH semantic version bump public static VersionMarkdown createSimpleVersionBumpDocument(MavenArtifact mavenArtifact) { Document document = new Document(); - document.appendChild(new Text("Project version bumped as result of dependency bumps")); + Paragraph paragraph = new Paragraph(); + paragraph.appendChild(new Text("Project version bumped as result of dependency bumps")); + document.appendChild(paragraph); return new VersionMarkdown(document, Map.of(mavenArtifact, SemanticVersionBump.PATCH)); } 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 index 8747463..de9afa7 100644 --- 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 @@ -33,7 +33,7 @@ public class MarkdownYamFrontMatterBlockRenderer implements NodeRenderer { /// /// @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) { + public MarkdownYamFrontMatterBlockRenderer(MarkdownNodeRendererContext context) throws NullPointerException { Objects.requireNonNull(context, "`context` must not be null"); this.writer = context.getWriter(); } 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 index d9ebe4b..2054f7f 100644 --- 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 @@ -16,13 +16,11 @@ /// 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. /// @@ -65,7 +63,7 @@ public static MarkdownYamFrontMatterBlockRendererFactory getInstance() { /// @return a [NodeRenderer] responsible for rendering YAML front matter blocks /// @throws NullPointerException if the provided context is null @Override - public NodeRenderer create(MarkdownNodeRendererContext context) { + public NodeRenderer create(MarkdownNodeRendererContext context) throws NullPointerException { Objects.requireNonNull(context, "`context` must not be null"); return new MarkdownYamFrontMatterBlockRenderer(context); } 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 + """); + } + } +} From 1ba2497d78193ef6b9a8541c64afdce0285dfc1a Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sat, 10 Jan 2026 17:29:30 +0100 Subject: [PATCH 22/63] Add `MarkdownDocumentAsserter` utility and comprehensive unit tests for `MarkdownUtils`. Enhance markdown handling with additional null checks and improved error messaging. --- .../bsels/semantic/version/UpdatePomMojo.java | 1 - .../semantic/version/utils/MarkdownUtils.java | 44 +- .../test/utils/MarkdownDocumentAsserter.java | 70 ++ .../version/utils/MarkdownUtilsTest.java | 769 ++++++++++++++++++ .../block/YamlFrontMatterBlockParserTest.java | 72 +- 5 files changed, 883 insertions(+), 73 deletions(-) create mode 100644 src/test/java/io/github/bsels/semantic/version/test/utils/MarkdownDocumentAsserter.java create mode 100644 src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java diff --git a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java index e476e16..a86c71a 100644 --- a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java @@ -436,7 +436,6 @@ private void updateMarkdownFile( log.debug("Original changelog"); MarkdownUtils.printMarkdown(log, changelog, 0); MarkdownUtils.mergeVersionMarkdownsInChangelog( - log, changelog, newVersion, markdownMapping.markdownMap() diff --git a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java index 893ebee..5805560 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java @@ -30,6 +30,7 @@ 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; @@ -152,7 +153,7 @@ public static Node readMarkdown(Log log, Path markdownFile) throws MojoExecution 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 one internally".formatted(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); @@ -177,24 +178,25 @@ public static Node readMarkdown(Log log, Path markdownFile) throws MojoExecution /// grouped by semantic version bump types (e.g., MAJOR, MINOR, PATCH). /// The changelog must begin with a single H1 heading titled "Changelog". /// - /// @param log the logger used to output informational and debug messages; must not be null /// @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 `log`, `changelog`, `version`, or `headerToNodes` is null - /// @throws IllegalArgumentException if the changelog does not start with a single H1 heading titled "Changelog" + /// @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( - Log log, Node changelog, String version, Map> headerToNodes ) throws NullPointerException, IllegalArgumentException { - Objects.requireNonNull(log, "`log` must not be null"); 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.getFirstChild() instanceof Heading heading && + 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'"); @@ -206,12 +208,13 @@ public static void mergeVersionMarkdownsInChangelog( newVersionHeading.appendChild(new Text("%s - %s".formatted(version, LocalDate.now()))); heading.insertAfter(newVersionHeading); + Comparator>> comparator = Map.Entry.comparingByKey(); Node current = headerToNodes.entrySet() .stream() - .sorted(Map.Entry.comparingByKey()) + .sorted(comparator.reversed()) .reduce(newVersionHeading, MarkdownUtils::copyVersionMarkdownToChangeset, mergeNodes()); - assert current.getNext().equals(nextChild); + 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 @@ -223,7 +226,7 @@ public static void mergeVersionMarkdownsInChangelog( /// @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 { + throws MojoExecutionException, NullPointerException { Objects.requireNonNull(markdownFile, "`markdownFile` must not be null"); Objects.requireNonNull(document, "`document` must not be null"); if (backupOld) { @@ -243,7 +246,9 @@ public static void writeMarkdownFile(Path markdownFile, Node document, boolean b /// @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) { + 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); } @@ -271,7 +276,10 @@ public static void printMarkdown(Log log, Node node, int level) { /// /// @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 - public static VersionMarkdown createSimpleVersionBumpDocument(MavenArtifact mavenArtifact) { + /// @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")); @@ -295,7 +303,9 @@ private static BinaryOperator mergeNodes() { /// @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 - private static Node copyVersionMarkdownToChangeset(Node current, Map.Entry> entry) { + /// @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()) { @@ -317,9 +327,13 @@ private static Node copyVersionMarkdownToChangeset(Node current, Map.Entry binaryOperator = mergeNodes(); - Node nextChild = node.getFirstChild(); + Node nextChild = document.getFirstChild(); while (nextChild != null) { Node nextSibling = nextChild.getNext(); currentLambda = binaryOperator.apply(currentLambda, nextChild); 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/utils/MarkdownUtilsTest.java b/src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java new file mode 100644 index 0000000..6a89ffa --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java @@ -0,0 +1,769 @@ +package io.github.bsels.semantic.version.utils; + +import com.fasterxml.jackson.core.JsonProcessingException; +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.VersionMarkdown; +import io.github.bsels.semantic.version.test.utils.TestLog; +import org.apache.maven.plugin.MojoExecutionException; +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.StandardOpenOption; +import java.time.LocalDate; +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 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.PATCH); + } + } + + @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 utilsMockedStatic = Mockito.mockStatic(Utils.class); + MockedStatic filesMockedStatic = Mockito.mockStatic(Files.class)) { + filesMockedStatic.when(() -> Files.newBufferedWriter( + CHANGELOG_PATH, + StandardCharsets.UTF_8, + StandardOpenOption.CREATE + )) + .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"); + + utilsMockedStatic.verify(() -> Utils.backupFile(CHANGELOG_PATH), Mockito.times(backupOld ? 1 : 0)); + filesMockedStatic.verify(() -> Files.newBufferedWriter( + CHANGELOG_PATH, + StandardCharsets.UTF_8, + StandardOpenOption.CREATE + ), Mockito.times(1)); + } + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void happyFlow_CorrectlyWritten(boolean backupOld) { + try (MockedStatic utilsMockedStatic = Mockito.mockStatic(Utils.class); + MockedStatic filesMockedStatic = Mockito.mockStatic(Files.class)) { + StringWriter writer = new StringWriter(); + + filesMockedStatic.when(() -> Files.newBufferedWriter( + CHANGELOG_PATH, + StandardCharsets.UTF_8, + StandardOpenOption.CREATE + )) + .thenReturn(new BufferedWriter(writer)); + + assertThatNoException() + .isThrownBy(() -> MarkdownUtils.writeMarkdownFile(CHANGELOG_PATH, createDummyChangelogDocument(), backupOld)); + assertThat(writer.toString()) + .isEqualTo(""" + # Changelog + + Test paragraph + """); + + utilsMockedStatic.verify(() -> Utils.backupFile(CHANGELOG_PATH), Mockito.times(backupOld ? 1 : 0)); + filesMockedStatic.verify(() -> Files.newBufferedWriter( + CHANGELOG_PATH, + StandardCharsets.UTF_8, + StandardOpenOption.CREATE + ), 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()) + ); + } + } +} 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 index 3f93223..f7fd1aa 100644 --- 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 @@ -7,7 +7,6 @@ import org.commonmark.node.Node; import org.commonmark.node.Paragraph; import org.commonmark.node.Text; -import org.commonmark.node.ThematicBreak; import org.commonmark.parser.IncludeSourceSpans; import org.commonmark.parser.Parser; import org.junit.jupiter.api.Nested; @@ -15,6 +14,10 @@ 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 { @@ -36,33 +39,11 @@ void noHeaderBlock_ReturnValidMarkdownTree() { Node actual = PARSER.parse(markdown); - assertThat(actual) - .isInstanceOf(Document.class) - .extracting(Node::getFirstChild) - .isNotNull() - .isInstanceOf(Heading.class) - .hasFieldOrPropertyWithValue("level", 1) - .satisfies( - n -> assertThat(n.getFirstChild()) - .isNotNull() - .isInstanceOf(Text.class) - .hasFieldOrPropertyWithValue("literal", "No 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(); + assertThatDocument( + actual, + hasHeading(1, "No front matter"), + hasParagraph("This is a test") + ); } @Test @@ -77,35 +58,12 @@ void noFrontMatterBlockButHasHorizontalLine_ReturnValidMarkdownTree() { Node actual = PARSER.parse(markdown); - assertThat(actual) - .isInstanceOf(Document.class) - .extracting(Node::getFirstChild) - .isNotNull() - .isInstanceOf(Paragraph.class) - .satisfies( - n -> assertThat(n.getFirstChild()) - .isNotNull() - .isInstanceOf(Text.class) - .hasFieldOrPropertyWithValue("literal", "Paragraph 1") - .extracting(Node::getNext) - .isNull() - ) - .extracting(Node::getNext) - .isNotNull() - .isInstanceOf(ThematicBreak.class) - .extracting(Node::getNext) - .isNotNull() - .isInstanceOf(Paragraph.class) - .satisfies( - n -> assertThat(n.getFirstChild()) - .isNotNull() - .isInstanceOf(Text.class) - .hasFieldOrPropertyWithValue("literal", "Paragraph 2") - .extracting(Node::getNext) - .isNull() - ) - .extracting(Node::getNext) - .isNull(); + assertThatDocument( + actual, + hasParagraph("Paragraph 1"), + hasThematicBreak(), + hasParagraph("Paragraph 2") + ); } @Test From 7ea2d24684748b455a49fbb0eea817518602e67d Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sat, 10 Jan 2026 18:32:30 +0100 Subject: [PATCH 23/63] Refactor `MarkdownUtilsTest` to replace `Utils.backupFile` with `Files.copy` for changelog backups. Adjust related mocks and validations. --- .../version/utils/MarkdownUtilsTest.java | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java b/src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java index 6a89ffa..399c547 100644 --- a/src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java +++ b/src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java @@ -2,7 +2,6 @@ import com.fasterxml.jackson.core.JsonProcessingException; 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.VersionMarkdown; import io.github.bsels.semantic.version.test.utils.TestLog; @@ -26,6 +25,7 @@ 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.List; @@ -46,6 +46,7 @@ public class MarkdownUtilsTest { 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"; @@ -206,8 +207,7 @@ void documentIsNull_ThrowsNullPointerException(boolean backupOld) { @ParameterizedTest @ValueSource(booleans = {true, false}) void failedToCreateFileWriter_ThrowsMojoExceptionException(boolean backupOld) { - try (MockedStatic utilsMockedStatic = Mockito.mockStatic(Utils.class); - MockedStatic filesMockedStatic = Mockito.mockStatic(Files.class)) { + try (MockedStatic filesMockedStatic = Mockito.mockStatic(Files.class)) { filesMockedStatic.when(() -> Files.newBufferedWriter( CHANGELOG_PATH, StandardCharsets.UTF_8, @@ -221,7 +221,12 @@ void failedToCreateFileWriter_ThrowsMojoExceptionException(boolean backupOld) { .hasRootCauseInstanceOf(IOException.class) .hasRootCauseMessage("Failed to create writer"); - utilsMockedStatic.verify(() -> Utils.backupFile(CHANGELOG_PATH), Mockito.times(backupOld ? 1 : 0)); + 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, @@ -233,8 +238,7 @@ void failedToCreateFileWriter_ThrowsMojoExceptionException(boolean backupOld) { @ParameterizedTest @ValueSource(booleans = {true, false}) void happyFlow_CorrectlyWritten(boolean backupOld) { - try (MockedStatic utilsMockedStatic = Mockito.mockStatic(Utils.class); - MockedStatic filesMockedStatic = Mockito.mockStatic(Files.class)) { + try (MockedStatic filesMockedStatic = Mockito.mockStatic(Files.class)) { StringWriter writer = new StringWriter(); filesMockedStatic.when(() -> Files.newBufferedWriter( @@ -253,7 +257,12 @@ void happyFlow_CorrectlyWritten(boolean backupOld) { Test paragraph """); - utilsMockedStatic.verify(() -> Utils.backupFile(CHANGELOG_PATH), Mockito.times(backupOld ? 1 : 0)); + 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, From 2138d730fdd7bc680a5251f093d2fc369f3a84d2 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sun, 11 Jan 2026 12:46:54 +0100 Subject: [PATCH 24/63] Refactor `POMUtils` for improved exception handling using `MojoExecutionException` and enhanced JavaDoc clarity. Add comprehensive unit tests to validate new behaviors. --- .../semantic/version/utils/POMUtils.java | 16 +- .../bsels/semantic/version/utils/Utils.java | 3 + .../version/utils/MarkdownUtilsTest.java | 8 +- .../semantic/version/utils/POMUtilsTest.java | 842 ++++++++++++++++++ .../semantic/version/utils/UtilsTest.java | 24 + 5 files changed, 884 insertions(+), 9 deletions(-) create mode 100644 src/test/java/io/github/bsels/semantic/version/utils/POMUtilsTest.java 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 index b99ed7a..72caa27 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/POMUtils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/POMUtils.java @@ -188,9 +188,9 @@ private POMUtils() { /// @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 IllegalStateException if the project version node cannot be located in the document + /// @throws MojoExecutionException if the project version node cannot be located in the document public static Node getProjectVersionNode(Document document, Modus modus) - throws NullPointerException, IllegalStateException { + throws NullPointerException, MojoExecutionException { Objects.requireNonNull(document, "`document` must not be null"); Objects.requireNonNull(modus, "`modus` must not be null"); List versionPropertyPath = switch (modus) { @@ -200,7 +200,7 @@ public static Node getProjectVersionNode(Document document, Modus modus) try { return walk(document, versionPropertyPath, 0); } catch (IllegalStateException e) { - throw new IllegalStateException("Unable to find project version on the path: %s".formatted( + throw new MojoExecutionException("Unable to find project version on the path: %s".formatted( String.join("->", versionPropertyPath) ), e); } @@ -247,7 +247,7 @@ public static void writePom(Document document, Path pomFile, boolean backupOld) try (Writer writer = Files.newBufferedWriter(pomFile, StandardCharsets.UTF_8, StandardOpenOption.CREATE)) { writePom(document, writer); } catch (IOException e) { - throw new MojoExecutionException("Unable to write %s".formatted(pomFile), e); + throw new MojoExecutionException("Unable to write to %s".formatted(pomFile), e); } } @@ -289,7 +289,7 @@ public static void updateVersion(Node nodeElement, SemanticVersionBump bump) Objects.requireNonNull(bump, "`bump` must not be null"); SemanticVersion version = SemanticVersion.of(nodeElement.getTextContent()); - SemanticVersion updatedVersion = version.bump(bump).stripSuffix(); + SemanticVersion updatedVersion = version.bump(bump); nodeElement.setTextContent(updatedVersion.toString()); } @@ -299,7 +299,9 @@ public static void updateVersion(Node nodeElement, SemanticVersionBump bump) /// /// @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 - public static Map> getMavenArtifacts(Document document) { + /// @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) @@ -380,7 +382,7 @@ private static Node walk(Node parent, List path, int currentElementIndex .findFirst() .map(child -> walk(child, path, currentElementIndex + 1)) .orElseThrow(() -> new IllegalStateException( - "Unable to find element %s in %s".formatted(result.currentElementName(), parent.getNodeName()) + "Unable to find element '%s' in '%s'".formatted(result.currentElementName(), parent.getNodeName()) )); } diff --git a/src/main/java/io/github/bsels/semantic/version/utils/Utils.java b/src/main/java/io/github/bsels/semantic/version/utils/Utils.java index 534c03f..1f74c22 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/Utils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/Utils.java @@ -42,6 +42,9 @@ public static void backupFile(Path file) throws NullPointerException, MojoExecut String fileName = file.getFileName().toString(); Path backupPom = file.getParent() .resolve(fileName + BACKUP_SUFFIX); + if (!Files.exists(file)) { + return; + } try { Files.copy( file, diff --git a/src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java b/src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java index 399c547..f67e761 100644 --- a/src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java +++ b/src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java @@ -208,6 +208,8 @@ void documentIsNull_ThrowsNullPointerException(boolean backupOld) { @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, @@ -221,7 +223,8 @@ void failedToCreateFileWriter_ThrowsMojoExceptionException(boolean backupOld) { .hasRootCauseInstanceOf(IOException.class) .hasRootCauseMessage("Failed to create writer"); - filesMockedStatic.verify(() -> Files.copy(CHANGELOG_PATH, + filesMockedStatic.verify(() -> Files.copy( + CHANGELOG_PATH, CHANGELOG_BACKUP_PATH, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.COPY_ATTRIBUTES, @@ -240,7 +243,8 @@ void failedToCreateFileWriter_ThrowsMojoExceptionException(boolean backupOld) { 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, 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..188fc59 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/utils/POMUtilsTest.java @@ -0,0 +1,842 @@ +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)) + .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)) + .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/UtilsTest.java b/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java index 64e925e..4fe744d 100644 --- a/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java +++ b/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java @@ -50,6 +50,26 @@ void nullInput_ThrowsNullPointerException() { .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)) { @@ -57,6 +77,8 @@ void copyFailed_ThrowsMojoExceptionException() { .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) @@ -82,6 +104,8 @@ void copySuccess_NoErrors() { .thenReturn(backupFile); Path file = Path.of("project/pom.xml"); + files.when(() -> Files.exists(file)) + .thenReturn(true); assertThatNoException() .isThrownBy(() -> Utils.backupFile(file)); From 4c5dab75a65bca0a41a4ae59ee70b21d8970c9a4 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sun, 11 Jan 2026 14:50:45 +0100 Subject: [PATCH 25/63] Add the `asImmutableSet` utility to `Utils` with unit tests. Enhance `BaseMojo` with new methods for Markdown mapping, validation, and project scope handling. Refactor and streamline the project version update logic in `UpdatePomMojo`. --- .../bsels/semantic/version/BaseMojo.java | 86 ++++ .../bsels/semantic/version/UpdatePomMojo.java | 388 ++++++++++-------- .../bsels/semantic/version/utils/Utils.java | 13 +- .../semantic/version/utils/UtilsTest.java | 38 ++ 4 files changed, 345 insertions(+), 180 deletions(-) diff --git a/src/main/java/io/github/bsels/semantic/version/BaseMojo.java b/src/main/java/io/github/bsels/semantic/version/BaseMojo.java index 94719be..aa82a76 100644 --- a/src/main/java/io/github/bsels/semantic/version/BaseMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/BaseMojo.java @@ -1,8 +1,12 @@ 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; @@ -16,6 +20,9 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; import java.util.stream.Stream; /// Base class for Maven plugin goals, providing foundational functionality for Mojo execution. @@ -218,4 +225,83 @@ protected final List getVersionMarkdowns() throws MojoExecution } return versionMarkdowns; } + + /// 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(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()); + }; + } } diff --git a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java index a86c71a..41eba92 100644 --- a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java @@ -90,88 +90,49 @@ public UpdatePomMojo() { super(); } - /// Executes the main logic for updating Maven POM versions based on the configured update mode. + /// Executes the core logic of the Mojo. /// - /// This method handles different modes of version updates, including - /// - Updating the revision property on the root project. - /// - Updating the project version for all Maven projects. - /// - Updating only the versions of leaf projects without modules. + /// 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. /// - /// The method processes version updates by creating a MarkdownMapping instance, determining - /// the projects to update, and invoking the appropriate update mechanism based on the selected mode. - /// - /// Upon successful execution, logs the outcome, indicating whether any changes were made. - /// - /// @throws MojoExecutionException if an error occurs during the execution process. - /// @throws MojoFailureException if a failure occurs during the version update process. + /// @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); - boolean hasChanges = switch (modus) { - case PROJECT_VERSION -> handleProjectVersionUpdates(mapping, Utils.alwaysTrue()); - case REVISION_PROPERTY -> handleSingleVersionUpdate(mapping, session.getCurrentProject()); - case PROJECT_VERSION_ONLY_LEAFS -> handleProjectVersionUpdates(mapping, Utils.mavenProjectHasNoModules()); - }; - if (hasChanges) { - log.info("Version update completed successfully"); + List projectsInScope = getProjectsInScope() + .collect(Utils.asImmutableList()); + + if (projectsInScope.isEmpty()) { + log.info("No projects found in scope"); + } else if (projectsInScope.size() == 1) { + log.info("Single project in scope"); + handleSingleProject(mapping, projectsInScope.get(0)); } else { - log.info("No version updates were performed"); + log.info("Multiple projects in scope"); + handleMultiProjects(mapping, projectsInScope); } } - /// Handles the process of updating project versions for Maven projects filtered based on a specified condition. - /// This method identifies relevant projects, determines whether a single or multiple version update process - /// should occur, and executes the appropriate update logic. + /// 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 a mapping of Maven artifacts to their corresponding semantic version bumps and version-specific markdown entries - /// @param filter a predicate used to filter Maven projects that should be updated - /// @return true if any projects had their versions updated, false otherwise - /// @throws MojoExecutionException if an error occurs during the execution of version updates - /// @throws MojoFailureException if a failure occurs in the version update process - private boolean handleProjectVersionUpdates(MarkdownMapping markdownMapping, Predicate filter) - throws MojoExecutionException, MojoFailureException { - Log log = getLog(); - List sortedProjects = session.getResult() - .getTopologicallySortedProjects() - .stream() - .filter(filter) - .toList(); - - if (sortedProjects.isEmpty()) { - log.info("No projects found matching filter"); - return false; - } - if (sortedProjects.size() == 1) { - log.info("Updating version for single project"); - return handleSingleVersionUpdate(markdownMapping, sortedProjects.get(0)); - } - log.info("Updating version for multiple projects"); - return handleMultiVersionUpdate(markdownMapping, sortedProjects); - } - - /// Handles the process of performing a single version update within a Maven project. - /// - /// This method determines the semantic version increment to apply, updates the project version - /// in the corresponding POM file, and either performs an actual update or demonstrates the proposed - /// changes in a dry-run mode. - /// - /// Key Operations: - /// - Resolves the POM file from the base directory. - /// - Reads the project version node from the POM using the specified update mode. - /// - Calculates the appropriate semantic version increment to apply. - /// - Logs the type of semantic version modification being applied. - /// - Updates the POM version node with the new version. - /// - Performs a dry-run if enabled, writing the proposed changes to a log instead of modifying the file. - /// - /// @param markdownMapping the Markdown version file mappings - /// @param project the Maven project for which the version update is being performed - /// @return `true` if their where changes, `false` otherwise - /// @throws MojoExecutionException if the POM cannot be read or written, or it cannot update the version node. - /// @throws MojoFailureException if the runtime system fails to initial the XML reader and writer helper classes - private boolean handleSingleVersionUpdate(MarkdownMapping markdownMapping, MavenProject project) + /// @param markdownMapping the mapping that contains the version bump map and markdown file details + /// @param project the Maven project to be processed + /// @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 void handleSingleProject(MarkdownMapping markdownMapping, MavenProject project) throws MojoExecutionException, MojoFailureException { Path pom = project.getFile() .toPath(); @@ -179,107 +140,184 @@ private boolean handleSingleVersionUpdate(MarkdownMapping markdownMapping, Maven Document document = POMUtils.readPom(pom); - SemanticVersionBump semanticVersionBump = getSemanticVersionBumpForSingleProject(markdownMapping, artifact); + SemanticVersionBump semanticVersionBump = getSemanticVersionBump(artifact, markdownMapping.versionBumpMap()); Optional version = updateProjectVersion(semanticVersionBump, document); - if (version.isEmpty()) { - return false; - } - - writeUpdatedPom(document, pom); - - updateMarkdownFile(markdownMapping, artifact, pom, version.get().newVersion()); - return true; - } + if (version.isPresent()) { + String newVersion = version.get() + .newVersion(); - /// Determines the semantic version bump for a single Maven project based on the provided Markdown mapping. - /// Validates that only the specified project artifact is being updated. - /// - /// @param markdownMapping the mapping that contains information about version bumps for multiple artifacts - /// @param projectArtifact the Maven artifact representing the single project whose version bump is to be determined - /// @return the semantic version bump for the provided project artifact - /// @throws MojoExecutionException if the version bump map contains artifacts other than the provided project artifact - private SemanticVersionBump getSemanticVersionBumpForSingleProject( - MarkdownMapping markdownMapping, - MavenArtifact projectArtifact - ) throws MojoExecutionException { - Map versionBumpMap = markdownMapping.versionBumpMap(); - if (!Set.of(projectArtifact).equals(versionBumpMap.keySet())) { - throw new MojoExecutionException( - "Single version update expected to update only the project %s, found: %s".formatted( - projectArtifact, - versionBumpMap.keySet() - ) - ); + writeUpdatedPom(document, pom); + updateMarkdownFile(markdownMapping, artifact, pom, newVersion); } - return getSemanticVersionBump(projectArtifact, versionBumpMap); } - /// Handles the update process for multiple versions of Maven projects. - /// It updates the project versions, modifies associated dependencies across projects, - /// and updates the Markdown and POM files accordingly. + /// Handles multiple Maven projects by processing their POM files, dependencies, and versions, updating the projects as necessary. /// - /// @param markdownMapping the mapping containing version bump information and markdown changes - /// @param projects the list of Maven projects to be processed and updated - /// @return `true` if at least one project version was updated, `false` otherwise - /// @throws MojoExecutionException if an error occurs during the execution of the update process - /// @throws MojoFailureException if a failure requirement is explicitly triggered during the process - private boolean handleMultiVersionUpdate(MarkdownMapping markdownMapping, List projects) + /// @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. + /// @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 void handleMultiProjects(MarkdownMapping markdownMapping, List projects) throws MojoExecutionException, MojoFailureException { - Map documents = new HashMap<>(); - for (MavenProject project : projects) { - MavenArtifact mavenArtifact = new MavenArtifact(project.getGroupId(), project.getArtifactId()); - documents.put( - mavenArtifact, - new MavenProjectAndDocument(project, mavenArtifact, POMUtils.readPom(project.getFile().toPath()))); - } - documents = Map.copyOf(documents); - Collection documentsCollection = documents.values(); - Set reactorArtifacts = documentsCollection.stream() - .map(MavenProjectAndDocument::artifact) - .collect(Collectors.collectingAndThen(Collectors.toSet(), Set::copyOf)); + Log log = getLog(); + Map documents = readAllPoms(projects); + Set reactorArtifacts = documents.keySet(); + log.info("Found %d projects in scope".formatted(documents.size())); + Map> updatableDependencies = mergeUpdatableDependencies( - documentsCollection, + documents.values(), reactorArtifacts ); Map> dependencyToProjectArtifacts = createDependencyToProjectArtifactMapping( - documentsCollection, + documents.values(), reactorArtifacts ); - Set updatedArtifacts = new HashSet<>(); - Queue toBeUpdated = new ArrayDeque<>(markdownMapping.versionBumpMap().keySet()); + UpdatedAndToUpdateArtifacts result = processMarkdownVersions( + markdownMapping, + reactorArtifacts, + documents, + dependencyToProjectArtifacts, + updatableDependencies + ); + + handleDependencyMavenProjects( + markdownMapping, + result, + documents, + dependencyToProjectArtifacts, + updatableDependencies + ); + + writeUpdatedProjects(result.updatedArtifacts(), documents); + } + + /** + * 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 + */ + private void handleDependencyMavenProjects( + MarkdownMapping markdownMapping, + UpdatedAndToUpdateArtifacts result, + Map documents, + Map> dependencyToProjectArtifacts, + Map> updatableDependencies + ) throws MojoExecutionException { + 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 + private UpdatedAndToUpdateArtifacts processMarkdownVersions( + MarkdownMapping markdownMapping, + Set reactorArtifacts, + Map documents, + Map> dependencyToProjectArtifacts, + Map> updatableDependencies + ) throws MojoExecutionException { + 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 versionChangeOptional = updateProjectVersion(bump, mavenProjectAndDocument.document()); - if (versionChangeOptional.isPresent()) { - VersionChange versionChange = versionChangeOptional.get(); + 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); - Path pom = mavenProjectAndDocument.project() - .getFile() - .toPath(); - updateMarkdownFile(markdownMapping, artifact, pom, versionChange.newVersion()); + updateMarkdownFile(markdownMapping, artifact, mavenProjectAndDocument.pomFile(), change.newVersion()); updatableDependencies.getOrDefault(artifact, List.of()) - .forEach(node -> POMUtils.updateVersionNodeIfOldVersionMatches(versionChange, node)); + .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 pom = mavenProjectAndDocument.project() - .getFile() - .toPath(); - writeUpdatedPom(mavenProjectAndDocument.document(), pom); + 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 !updatedArtifacts.isEmpty(); + return Map.copyOf(documents); } /// Updates the project version based on the specified semantic version bump and document. @@ -364,35 +402,6 @@ private Map> mergeUpdatableDependencies( )); } - /// 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 - private 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); - } - /// 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. /// @@ -494,25 +503,46 @@ private SemanticVersionBump getSemanticVersionBump( }; } - /// Represents a combination of a MavenProject and its associated Document. - /// This class is a record that holds a Maven project and corresponding document, - /// ensuring that both parameters are non-null during instantiation. + /// 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 project the Maven project must not be null - /// @param artifact the Maven artifact must not be null - /// @param document the associated document must not be null - private record MavenProjectAndDocument(MavenProject project, MavenArtifact artifact, Document document) { + /// @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 an instance of MavenProjectAndDocument with the specified Maven project and document. + /// Constructs a new instance of the MavenProjectAndDocument record. /// - /// @param project the Maven project must not be null - /// @param artifact the Maven artifact must not be null - /// @param document the associated document must not be null - /// @throws NullPointerException if the project or document is null + /// @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(project, "`project` must not be null"); 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/utils/Utils.java b/src/main/java/io/github/bsels/semantic/version/utils/Utils.java index 1f74c22..53641cb 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/Utils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/Utils.java @@ -10,6 +10,7 @@ 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; @@ -36,7 +37,7 @@ private Utils() { /// /// @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 + /// @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(); @@ -131,4 +132,14 @@ public static BinaryOperator consumerToOperator(BiConsumer 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/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java b/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java index 4fe744d..179bf38 100644 --- a/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java +++ b/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java @@ -249,4 +249,42 @@ void nonEmptyStream_NonEmptyAndImmutable() { .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); + } + } } From 967f9229a44f86d820c12212dc8fecaec0964a25 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sun, 11 Jan 2026 15:04:30 +0100 Subject: [PATCH 26/63] Enhance `UpdatePomMojo` JavaDoc with a detailed class and functionality overview. --- .../github/bsels/semantic/version/UpdatePomMojo.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java index 41eba92..1ea4b13 100644 --- a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java @@ -41,6 +41,16 @@ 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", requiresDependencyResolution = ResolutionScope.RUNTIME) @Execute(phase = LifecyclePhase.NONE) public final class UpdatePomMojo extends BaseMojo { From a8adf884b92244e7cc6aa3ade09e234b4b9ab19e Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sun, 11 Jan 2026 15:36:42 +0100 Subject: [PATCH 27/63] Add example integration test projects for various revision, module, and artifactID configurations. --- .../itests/leaves/child-1/CHANGELOG.md | 5 ++ .../resources/itests/leaves/child-1/pom.xml | 9 ++++ .../leaves/intermediate/child-2/CHANGELOG.md | 5 ++ .../leaves/intermediate/child-2/pom.xml | 9 ++++ .../leaves/intermediate/child-3/CHANGELOG.md | 5 ++ .../leaves/intermediate/child-3/pom.xml | 9 ++++ .../itests/leaves/intermediate/pom.xml | 14 +++++ src/test/resources/itests/leaves/pom.xml | 14 +++++ src/test/resources/itests/multi/CHANGELOG.md | 5 ++ .../itests/multi/combination/CHANGELOG.md | 5 ++ .../itests/multi/combination/pom.xml | 51 +++++++++++++++++++ .../itests/multi/dependency/CHANGELOG.md | 5 ++ .../resources/itests/multi/dependency/pom.xml | 9 ++++ .../multi/dependencyManagement/CHANGELOG.md | 5 ++ .../itests/multi/dependencyManagement/pom.xml | 9 ++++ .../itests/multi/excluded/CHANGELOG.md | 5 ++ .../resources/itests/multi/excluded/pom.xml | 9 ++++ .../itests/multi/plugin/CHANGELOG.md | 5 ++ .../resources/itests/multi/plugin/pom.xml | 9 ++++ .../multi/pluginManagement/CHANGELOG.md | 5 ++ .../itests/multi/pluginManagement/pom.xml | 9 ++++ src/test/resources/itests/multi/pom.xml | 18 +++++++ .../itests/revision/multi/CHANGELOG.md | 5 ++ .../itests/revision/multi/child1/pom.xml | 15 ++++++ .../itests/revision/multi/child2/pom.xml | 15 ++++++ .../resources/itests/revision/multi/pom.xml | 18 +++++++ .../itests/revision/single/CHANGELOG.md | 5 ++ .../resources/itests/revision/single/pom.xml | 13 +++++ src/test/resources/itests/single/CHANGELOG.md | 5 ++ src/test/resources/itests/single/pom.xml | 9 ++++ 30 files changed, 304 insertions(+) create mode 100644 src/test/resources/itests/leaves/child-1/CHANGELOG.md create mode 100644 src/test/resources/itests/leaves/child-1/pom.xml create mode 100644 src/test/resources/itests/leaves/intermediate/child-2/CHANGELOG.md create mode 100644 src/test/resources/itests/leaves/intermediate/child-2/pom.xml create mode 100644 src/test/resources/itests/leaves/intermediate/child-3/CHANGELOG.md create mode 100644 src/test/resources/itests/leaves/intermediate/child-3/pom.xml create mode 100644 src/test/resources/itests/leaves/intermediate/pom.xml create mode 100644 src/test/resources/itests/leaves/pom.xml create mode 100644 src/test/resources/itests/multi/CHANGELOG.md create mode 100644 src/test/resources/itests/multi/combination/CHANGELOG.md create mode 100644 src/test/resources/itests/multi/combination/pom.xml create mode 100644 src/test/resources/itests/multi/dependency/CHANGELOG.md create mode 100644 src/test/resources/itests/multi/dependency/pom.xml create mode 100644 src/test/resources/itests/multi/dependencyManagement/CHANGELOG.md create mode 100644 src/test/resources/itests/multi/dependencyManagement/pom.xml create mode 100644 src/test/resources/itests/multi/excluded/CHANGELOG.md create mode 100644 src/test/resources/itests/multi/excluded/pom.xml create mode 100644 src/test/resources/itests/multi/plugin/CHANGELOG.md create mode 100644 src/test/resources/itests/multi/plugin/pom.xml create mode 100644 src/test/resources/itests/multi/pluginManagement/CHANGELOG.md create mode 100644 src/test/resources/itests/multi/pluginManagement/pom.xml create mode 100644 src/test/resources/itests/multi/pom.xml create mode 100644 src/test/resources/itests/revision/multi/CHANGELOG.md create mode 100644 src/test/resources/itests/revision/multi/child1/pom.xml create mode 100644 src/test/resources/itests/revision/multi/child2/pom.xml create mode 100644 src/test/resources/itests/revision/multi/pom.xml create mode 100644 src/test/resources/itests/revision/single/CHANGELOG.md create mode 100644 src/test/resources/itests/revision/single/pom.xml create mode 100644 src/test/resources/itests/single/CHANGELOG.md create mode 100644 src/test/resources/itests/single/pom.xml 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/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..305a3a1 --- /dev/null +++ b/src/test/resources/itests/multi/excluded/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 5.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 From 718d5a3cc98a2ac01d2bf57de23f17a59ab9c0f2 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sun, 11 Jan 2026 16:28:33 +0100 Subject: [PATCH 28/63] Refactor `BaseMojo` to simplify root project retrieval logic. Add the `ReadMockedMavenSession` utility with a comprehensive implementation for mocking Maven sessions. --- .../bsels/semantic/version/BaseMojo.java | 5 +- .../test/utils/ReadMockedMavenSession.java | 175 ++++++++++++++++++ 2 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 src/test/java/io/github/bsels/semantic/version/test/utils/ReadMockedMavenSession.java diff --git a/src/main/java/io/github/bsels/semantic/version/BaseMojo.java b/src/main/java/io/github/bsels/semantic/version/BaseMojo.java index aa82a76..3d7bbdd 100644 --- a/src/main/java/io/github/bsels/semantic/version/BaseMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/BaseMojo.java @@ -158,10 +158,7 @@ protected BaseMojo() { /// @throws MojoFailureException if the execution fails due to a known configuration or logic failure. public final void execute() throws MojoExecutionException, MojoFailureException { Log log = getLog(); - List topologicallySortedProjects = session.getResult() - .getTopologicallySortedProjects(); - MavenProject rootProject = topologicallySortedProjects - .get(0); + MavenProject rootProject = session.getTopLevelProject(); MavenProject currentProject = session.getCurrentProject(); if (!rootProject.equals(currentProject) && !executeForSubproject) { log.info("Skipping execution for subproject %s:%s:%s".formatted( 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..fc5a729 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/test/utils/ReadMockedMavenSession.java @@ -0,0 +1,175 @@ +package io.github.bsels.semantic.version.test.utils; + +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +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.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(currentModule); + + MavenSession mockedSession = Mockito.mock(MavenSession.class); + MavenExecutionResult mockedResult = Mockito.mock(MavenExecutionResult.class); + + Mockito.lenient() + .when(mockedSession.getExecutionRootDirectory()) + .thenReturn(projectRoot.toAbsolutePath().toString()); + Mockito.lenient() + .when(mockedSession.getResult()) + .thenReturn(mockedResult); + Path normalizeCurrentModule = projectRoot.resolve(currentModule).normalize(); + Mockito.lenient() + .when(mockedSession.getCurrentProject()) + .thenReturn(projects.get(normalizeCurrentModule)); + Mockito.lenient() + .when(mockedSession.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(mockedResult.getTopologicallySortedProjects()) + .thenReturn(sortedProjects); + + return mockedSession; + } + + 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 = Mockito.mock(MavenProject.class); + Mockito.lenient() + .when(mavenProject.getFile()) + .thenReturn(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(); + + Mockito.lenient() + .when(mavenProject.getGroupId()) + .thenReturn(groupId); + Mockito.lenient() + .when(mavenProject.getArtifactId()) + .thenReturn(artifactId); + Mockito.lenient() + .when(mavenProject.getVersion()) + .thenReturn(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(); + Mockito.lenient() + .when(mavenProject.getModules()) + .thenReturn(modulesString); + return Stream.concat( + currentProject, + modulesString.stream() + .map(path::resolve) + .flatMap(ReadMockedMavenSession::readMavenProjects) + ); + } else { + Mockito.lenient() + .when(mavenProject.getModules()) + .thenReturn(List.of()); + 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); + } + } +} From 3e0f2e249fa8d9b23b7fc3688d3a6c66624dec8f Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sun, 11 Jan 2026 16:37:50 +0100 Subject: [PATCH 29/63] Add `package-info.java` for `io.github.bsels.semantic.version` package with overview documentation --- .../java/io/github/bsels/semantic/version/package-info.java | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 src/main/java/io/github/bsels/semantic/version/package-info.java 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 From 40b8c0ed8fee2485a64084073fb4c8567c1e0769 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sun, 11 Jan 2026 16:58:07 +0100 Subject: [PATCH 30/63] Refactor `ReadMockedMavenSession` to replace `Mockito` with concrete implementations for improved test reliability. Add `UpdatePomMojoTest` with a new unit test validating subproject execution skipping. --- .../semantic/version/UpdatePomMojoTest.java | 69 +++++++++++ .../test/utils/ReadMockedMavenSession.java | 115 +++++++++++++----- 2 files changed, 152 insertions(+), 32 deletions(-) create mode 100644 src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java 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..4b3d645 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java @@ -0,0 +1,69 @@ +package io.github.bsels.semantic.version; + +import io.github.bsels.semantic.version.test.utils.ReadMockedMavenSession; +import io.github.bsels.semantic.version.test.utils.TestLog; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.net.URISyntaxException; +import java.nio.file.Path; +import java.util.Objects; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; + +@ExtendWith(MockitoExtension.class) +public class UpdatePomMojoTest { + private UpdatePomMojo classUnderTest; + private TestLog testLog; + + @BeforeEach + void setUp() { + classUnderTest = new UpdatePomMojo(); + testLog = new TestLog(TestLog.LogLevel.NONE); + classUnderTest.setLog(testLog); + } + + @Test + void noExecutionOnSubProjectIfDisabled() { + classUnderTest.executeForSubproject = false; // Just to make explicit that this is the default value + 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) + .first() + .hasFieldOrPropertyWithValue("level", TestLog.LogLevel.INFO) + .hasFieldOrPropertyWithValue("throwable", Optional.empty()) + .hasFieldOrPropertyWithValue( + "message", + Optional.of("Skipping execution for subproject org.example.itests.leaves:child-1:5.0.0-child-1") + ); + } + + private Path getResourcesPath() { + try { + return Path.of( + Objects.requireNonNull(UpdatePomMojoTest.class.getResource("/itests/")) + .toURI() + ); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + } +} 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 index fc5a729..e49c1b1 100644 --- 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 @@ -1,7 +1,9 @@ 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; @@ -18,6 +20,7 @@ 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; @@ -42,23 +45,19 @@ private ReadMockedMavenSession() { } public static MavenSession readMockedMavenSession(Path projectRoot, Path currentModule) { - Map projects = readMavenProjectsAsMap(currentModule); + Map projects = readMavenProjectsAsMap(projectRoot); - MavenSession mockedSession = Mockito.mock(MavenSession.class); - MavenExecutionResult mockedResult = Mockito.mock(MavenExecutionResult.class); + MavenSession session = Mockito.mock(MavenSession.class); Mockito.lenient() - .when(mockedSession.getExecutionRootDirectory()) + .when(session.getExecutionRootDirectory()) .thenReturn(projectRoot.toAbsolutePath().toString()); - Mockito.lenient() - .when(mockedSession.getResult()) - .thenReturn(mockedResult); Path normalizeCurrentModule = projectRoot.resolve(currentModule).normalize(); Mockito.lenient() - .when(mockedSession.getCurrentProject()) + .when(session.getCurrentProject()) .thenReturn(projects.get(normalizeCurrentModule)); Mockito.lenient() - .when(mockedSession.getTopLevelProject()) + .when(session.getTopLevelProject()) .thenReturn(projects.get(projectRoot.resolve(".").normalize())); List sortedProjects = projects.entrySet() @@ -67,11 +66,11 @@ public static MavenSession readMockedMavenSession(Path projectRoot, Path current .filter(entry -> entry.getKey().startsWith(normalizeCurrentModule)) .map(Map.Entry::getValue) .toList(); - Mockito.lenient() - .when(mockedResult.getTopologicallySortedProjects()) - .thenReturn(sortedProjects); - return mockedSession; + Mockito.lenient() + .when(session.getResult()) + .thenReturn(new MavenExecutionResultMock(sortedProjects)); + return session; } private static Map readMavenProjectsAsMap(Path projectRoot) { @@ -81,10 +80,8 @@ private static Map readMavenProjectsAsMap(Path projectRoot) private static Stream> readMavenProjects(Path path) { Path pomFile = path.resolve(POM_FILE).toAbsolutePath(); - MavenProject mavenProject = Mockito.mock(MavenProject.class); - Mockito.lenient() - .when(mavenProject.getFile()) - .thenReturn(pomFile.toFile()); + MavenProject mavenProject = new MavenProject(); + mavenProject.setFile(pomFile.toFile()); Document pom = readPom(pomFile); String revision = walk(pom, List.of(PROJECT, PROPERTIES, REVISION), 0) @@ -104,15 +101,9 @@ private static Stream> readMavenProjects(Path path .map(text -> $_REVISION.equals(text) ? revision : text) .orElseThrow(); - Mockito.lenient() - .when(mavenProject.getGroupId()) - .thenReturn(groupId); - Mockito.lenient() - .when(mavenProject.getArtifactId()) - .thenReturn(artifactId); - Mockito.lenient() - .when(mavenProject.getVersion()) - .thenReturn(version); + 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)); @@ -123,9 +114,7 @@ private static Stream> readMavenProjects(Path path .filter(node -> MODULE.equals(node.getNodeName())) .map(Node::getTextContent) .toList(); - Mockito.lenient() - .when(mavenProject.getModules()) - .thenReturn(modulesString); + mavenProject.getModules().addAll(modulesString); return Stream.concat( currentProject, modulesString.stream() @@ -133,9 +122,6 @@ private static Stream> readMavenProjects(Path path .flatMap(ReadMockedMavenSession::readMavenProjects) ); } else { - Mockito.lenient() - .when(mavenProject.getModules()) - .thenReturn(List.of()); return currentProject; } } @@ -172,4 +158,69 @@ private static DocumentBuilder getDocumentBuilder() { 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(); + } + } } From 4433881a41d42c371ea490de591067ca5594bb20 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sun, 11 Jan 2026 18:45:49 +0100 Subject: [PATCH 31/63] Enhance `BaseMojo` with folder existence validation and warning log when versioning files are missing. Add comprehensive mocks, static replacements, and new nested structure in `UpdatePomMojoTest` for improved test coverage and reliability. Adjust logging level in `UpdatePomMojo` for empty project scope. --- .../bsels/semantic/version/BaseMojo.java | 5 ++ .../bsels/semantic/version/UpdatePomMojo.java | 2 +- .../semantic/version/UpdatePomMojoTest.java | 88 +++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/github/bsels/semantic/version/BaseMojo.java b/src/main/java/io/github/bsels/semantic/version/BaseMojo.java index 3d7bbdd..3f79c30 100644 --- a/src/main/java/io/github/bsels/semantic/version/BaseMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/BaseMojo.java @@ -207,6 +207,11 @@ protected final List getVersionMarkdowns() throws MojoExecution } else { versioningFolder = Path.of(session.getExecutionRootDirectory()).resolve(versionDirectory); } + 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) diff --git a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java index 1ea4b13..4dc2343 100644 --- a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java @@ -125,7 +125,7 @@ public void internalExecute() throws MojoExecutionException, MojoFailureExceptio .collect(Utils.asImmutableList()); if (projectsInScope.isEmpty()) { - log.info("No projects found in scope"); + log.warn("No projects found in scope"); } else if (projectsInScope.size() == 1) { log.info("Single project in scope"); handleSingleProject(mapping, projectsInScope.get(0)); diff --git a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java index 4b3d645..d9c8863 100644 --- a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java +++ b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java @@ -1,15 +1,32 @@ package io.github.bsels.semantic.version; +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.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.MockedStatic; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; +import java.io.BufferedWriter; +import java.io.StringWriter; import java.net.URISyntaxException; +import java.nio.file.CopyOption; +import java.nio.file.Files; +import java.nio.file.OpenOption; import java.nio.file.Path; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -18,14 +35,47 @@ @ExtendWith(MockitoExtension.class) public class UpdatePomMojoTest { + private static final LocalDate DATE = LocalDate.of(2025, 1, 1); private UpdatePomMojo classUnderTest; private TestLog testLog; + private Map mockedOutputFiles; + private List mockedCopiedFiles; + + 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<>(); + + 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); + mockedCopiedFiles.add(new CopyPath(original, copy, List.of(answer.getArgument(2)))); + return copy; + }); + + localDateMockedStatic = Mockito.mockStatic(LocalDate.class); + localDateMockedStatic.when(LocalDate::now) + .thenReturn(DATE); + } + + @AfterEach + void tearDown() { + filesMockedStatic.close(); + localDateMockedStatic.close(); } @Test @@ -66,4 +116,42 @@ private Path getResourcesPath() { throw new RuntimeException(e); } } + + private record CopyPath(Path original, Path copy, List options) { + } + + @Nested + class SingleProjectTest { + + @BeforeEach + void setUp() { + classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession( + getResourcesPath().resolve("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) + .first() + .hasFieldOrPropertyWithValue("level", TestLog.LogLevel.INFO) + .hasFieldOrPropertyWithValue("throwable", Optional.empty()) + .hasFieldOrPropertyWithValue( + "message", + Optional.of("Execution for project: org.example.itests.single:project:1.0.0") + ); + + // TODO: Verify + } + } } From 968ec699487c9e69885e6150f927a05f978e3b89 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Mon, 12 Jan 2026 20:25:35 +0100 Subject: [PATCH 32/63] Refactor `MarkdownUtils` and `UpdatePomMojo` to improve handling for dry-run scenarios and simplify changelog/pom serialization. Update tests to cover new logic with added utilities for path resolution and version expectations. --- .../bsels/semantic/version/UpdatePomMojo.java | 87 ++++++++++++------- .../semantic/version/utils/MarkdownUtils.java | 2 +- .../semantic/version/UpdatePomMojoTest.java | 56 +++++++++++- .../version/utils/MarkdownUtilsTest.java | 2 +- 4 files changed, 113 insertions(+), 34 deletions(-) diff --git a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java index 4dc2343..d49c3c4 100644 --- a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java @@ -23,7 +23,6 @@ import java.io.IOException; import java.io.StringWriter; -import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayDeque; import java.util.ArrayList; @@ -202,25 +201,24 @@ private void handleMultiProjects(MarkdownMapping markdownMapping, List documents, Map> dependencyToProjectArtifacts, Map> updatableDependencies - ) throws MojoExecutionException { + ) throws MojoExecutionException, MojoFailureException { Set updatedArtifacts = result.updatedArtifacts(); Queue toBeUpdated = result.toBeUpdated(); while (!toBeUpdated.isEmpty()) { @@ -256,13 +254,14 @@ private void handleDependencyMavenProjects( /// @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 { + ) throws MojoExecutionException, MojoFailureException { Set updatedArtifacts = new HashSet<>(); Queue toBeUpdated = new ArrayDeque<>(reactorArtifacts.size()); for (MavenArtifact artifact : reactorArtifacts) { @@ -424,12 +423,7 @@ private Map> mergeUpdatableDependencies( /// @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) { - try (StringWriter writer = new StringWriter()) { - POMUtils.writePom(document, writer); - getLog().info("Dry-run: new pom at %s:%n%s".formatted(pom, writer)); - } catch (IOException e) { - throw new MojoExecutionException("Unable to open output stream for writing", e); - } + dryRunWriteFile(writer -> POMUtils.writePom(document, writer), pom, "Dry-run: new pom at %s:%n%s"); } else { POMUtils.writePom(document, pom, backupFiles); } @@ -443,12 +437,13 @@ private void writeUpdatedPom(Document document, Path pom) throws MojoExecutionEx /// @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 { + ) throws MojoExecutionException, MojoFailureException { Log log = getLog(); Path changelogFile = pom.getParent().resolve(CHANGELOG_MD); org.commonmark.node.Node changelog = readMarkdown(log, changelogFile); @@ -481,17 +476,35 @@ private void updateMarkdownFile( /// @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 { + throws MojoExecutionException, MojoFailureException { if (dryRun) { - try (StringWriter writer = new StringWriter()) { - MarkdownUtils.writeMarkdown(writer, changelog); - getLog().info("Dry-run: new changelog at %s:%n%s".formatted(changelogFile, writer)); - } catch (IOException e) { - throw new MojoExecutionException("Unable to open output stream for writing", e); - } + dryRunWriteFile( + writer -> MarkdownUtils.writeMarkdown(writer, changelog), + changelogFile, "Dry-run: new changelog at %s:%n%s" + ); } else { - MarkdownUtils.writeMarkdownFile(changelogFile, changelog, backupFiles && Files.exists(changelogFile)); + MarkdownUtils.writeMarkdownFile(changelogFile, changelog, 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 + private 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); } } @@ -513,6 +526,20 @@ private SemanticVersionBump getSemanticVersionBump( }; } + /// 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 + private 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; + } + /// Represents a combination of a Maven project artifact, its associated POM file path, /// and the XML document of the POM file's contents. /// diff --git a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java index 5805560..25aba82 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java @@ -284,7 +284,7 @@ public static VersionMarkdown createSimpleVersionBumpDocument(MavenArtifact mave Paragraph paragraph = new Paragraph(); paragraph.appendChild(new Text("Project version bumped as result of dependency bumps")); document.appendChild(paragraph); - return new VersionMarkdown(document, Map.of(mavenArtifact, SemanticVersionBump.PATCH)); + return new VersionMarkdown(document, Map.of(mavenArtifact, SemanticVersionBump.NONE)); } /// Merges two [Node] instances by inserting the second node after the first node and returning the second node. diff --git a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java index d9c8863..f312be5 100644 --- a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java +++ b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java @@ -29,6 +29,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatNoException; @@ -106,6 +107,13 @@ void noExecutionOnSubProjectIfDisabled() { ); } + private Path getResourcesPath(String... relativePaths) { + return Stream.of(relativePaths) + .reduce(getResourcesPath(), Path::resolve, (a, b) -> { + throw new UnsupportedOperationException(); + }); + } + private Path getResourcesPath() { try { return Path.of( @@ -126,7 +134,7 @@ class SingleProjectTest { @BeforeEach void setUp() { classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession( - getResourcesPath().resolve("single"), + getResourcesPath("single"), Path.of(".") ); classUnderTest.modus = Modus.PROJECT_VERSION; @@ -151,7 +159,51 @@ void fixedVersionBump_Valid(VersionBump versionBump) { Optional.of("Execution for project: org.example.itests.single:project:1.0.0") ); - // TODO: Verify + 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(); } } } diff --git a/src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java b/src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java index f67e761..ebd4a24 100644 --- a/src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java +++ b/src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java @@ -103,7 +103,7 @@ void createDocument_ValidMarkdown() { assertThat(actual.bumps()) .hasSize(1) - .containsEntry(MAVEN_ARTIFACT, SemanticVersionBump.PATCH); + .containsEntry(MAVEN_ARTIFACT, SemanticVersionBump.NONE); } } From 9359442bb37e215a74bd3af063569c6f97b82c57 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Mon, 12 Jan 2026 20:38:32 +0100 Subject: [PATCH 33/63] Refactor `ReadMockedMavenSession` to support non-topologically sorted projects and adjust `UpdatePomMojoTest` with a new parameterized test to validate warning logs for empty project scope. --- .../semantic/version/UpdatePomMojoTest.java | 44 +++++++++++++++++++ .../test/utils/ReadMockedMavenSession.java | 26 ++++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java index f312be5..2ba36f4 100644 --- a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java +++ b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java @@ -107,6 +107,50 @@ void noExecutionOnSubProjectIfDisabled() { ); } + @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( + first -> assertThat(first) + .hasFieldOrPropertyWithValue("level", TestLog.LogLevel.INFO) + .hasFieldOrPropertyWithValue("throwable", Optional.empty()) + .hasFieldOrPropertyWithValue( + "message", + Optional.of("Execution for project: org.example.itests.single:project:1.0.0") + ), + second -> assertThat(second) + .hasFieldOrPropertyWithValue("level", TestLog.LogLevel.WARN) + .hasFieldOrPropertyWithValue("throwable", Optional.empty()) + .hasFieldOrPropertyWithValue( + "message", + Optional.of( + "No versioning files found in %s as folder does not exists".formatted( + getResourcesPath("single", ".versioning") + ) + ) + ), + third -> assertThat(third) + .hasFieldOrPropertyWithValue("level", TestLog.LogLevel.WARN) + .hasFieldOrPropertyWithValue("throwable", Optional.empty()) + .hasFieldOrPropertyWithValue( + "message", + Optional.of("No projects found in scope") + ) + ); + } + private Path getResourcesPath(String... relativePaths) { return Stream.of(relativePaths) .reduce(getResourcesPath(), Path::resolve, (a, b) -> { 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 index e49c1b1..c0574d5 100644 --- 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 @@ -46,7 +46,31 @@ private ReadMockedMavenSession() { 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() @@ -69,7 +93,7 @@ public static MavenSession readMockedMavenSession(Path projectRoot, Path current Mockito.lenient() .when(session.getResult()) - .thenReturn(new MavenExecutionResultMock(sortedProjects)); + .thenReturn(new MavenExecutionResultMock(topologicallySortedProjects)); return session; } From e750809706f02406263a316ac8d3ae3cdcbfb743 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Tue, 13 Jan 2026 18:54:42 +0100 Subject: [PATCH 34/63] Add semantic versioning integration tests for single projects and simplify `BaseMojo` logic by removing subproject execution flag. Enhance test utilities and logging for better validation and debug coverage. --- .../bsels/semantic/version/BaseMojo.java | 20 +- .../bsels/semantic/version/UpdatePomMojo.java | 6 +- .../semantic/version/UpdatePomMojoTest.java | 593 ++++++++++++++++-- .../versioning/single/major/versioning.md | 5 + .../versioning/single/minor/versioning.md | 5 + .../versioning/single/multiple/major.md | 5 + .../versioning/single/multiple/minor.md | 5 + .../itests/versioning/single/multiple/none.md | 5 + .../versioning/single/multiple/patch.md | 5 + .../versioning/single/none/versioning.md | 5 + .../versioning/single/patch/versioning.md | 5 + .../single/unknown-project/versioning.md | 5 + 12 files changed, 602 insertions(+), 62 deletions(-) create mode 100644 src/test/resources/itests/versioning/single/major/versioning.md create mode 100644 src/test/resources/itests/versioning/single/minor/versioning.md create mode 100644 src/test/resources/itests/versioning/single/multiple/major.md create mode 100644 src/test/resources/itests/versioning/single/multiple/minor.md create mode 100644 src/test/resources/itests/versioning/single/multiple/none.md create mode 100644 src/test/resources/itests/versioning/single/multiple/patch.md create mode 100644 src/test/resources/itests/versioning/single/none/versioning.md create mode 100644 src/test/resources/itests/versioning/single/patch/versioning.md create mode 100644 src/test/resources/itests/versioning/single/unknown-project/versioning.md diff --git a/src/main/java/io/github/bsels/semantic/version/BaseMojo.java b/src/main/java/io/github/bsels/semantic/version/BaseMojo.java index 3f79c30..3906c48 100644 --- a/src/main/java/io/github/bsels/semantic/version/BaseMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/BaseMojo.java @@ -22,6 +22,7 @@ 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; @@ -92,21 +93,6 @@ public abstract sealed class BaseMojo extends AbstractMojo permits UpdatePomMojo @Parameter(defaultValue = "${session}", required = true, readonly = true) protected MavenSession session; - /// Determines whether the plugin should execute its logic for subprojects in a multi-module Maven project. - /// - /// Configuration: - /// - `property`: "versioning.executeForSubProject", allows external configuration via Maven plugin properties. - /// - `defaultValue`: Defaults to `false`, meaning the plugin will skip its execution for subprojects - /// unless explicitly enabled. - /// - /// When set to `true`, the plugin will apply its logic to subprojects as well as the root project. - /// When set to `false`, it will only apply its logic to the root project. - /// - /// This parameter is useful in scenarios where selective execution of versioning logic is desired within a - /// multi-module project hierarchy. - @Parameter(property = "versioning.executeForSubproject", defaultValue = "false") - protected boolean executeForSubproject = false; - /// 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. @@ -160,7 +146,7 @@ public final void execute() throws MojoExecutionException, MojoFailureException Log log = getLog(); MavenProject rootProject = session.getTopLevelProject(); MavenProject currentProject = session.getCurrentProject(); - if (!rootProject.equals(currentProject) && !executeForSubproject) { + if (!rootProject.equals(currentProject)) { log.info("Skipping execution for subproject %s:%s:%s".formatted( currentProject.getGroupId(), currentProject.getArtifactId(), @@ -275,7 +261,7 @@ protected void validateMarkdowns(MarkdownMapping markdownMapping) throws MojoFai if (!artifacts.containsAll(artifactsInMarkdown)) { String unknownArtifacts = artifactsInMarkdown.stream() - .filter(artifacts::contains) + .filter(Predicate.not(artifacts::contains)) .map(MavenArtifact::toString) .collect(Collectors.joining(", ")); diff --git a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java index d49c3c4..99e60b2 100644 --- a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java @@ -349,11 +349,7 @@ private Optional updateProjectVersion( log.info("No version update required"); return Optional.empty(); } - try { - POMUtils.updateVersion(versionNode, semanticVersionBump); - } catch (IllegalArgumentException e) { - throw new MojoExecutionException("Unable to update version changelog", e); - } + POMUtils.updateVersion(versionNode, semanticVersionBump); return Optional.of(new VersionChange(originalVersion, versionNode.getTextContent())); } diff --git a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java index 2ba36f4..93ba374 100644 --- a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java +++ b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java @@ -1,27 +1,34 @@ 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.net.URISyntaxException; 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; @@ -29,10 +36,13 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.function.Consumer; +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 UpdatePomMojoTest { @@ -64,7 +74,10 @@ void setUp() { .thenAnswer(answer -> { Path original = answer.getArgument(0); Path copy = answer.getArgument(1); - mockedCopiedFiles.add(new CopyPath(original, copy, List.of(answer.getArgument(2)))); + List options = IntStream.range(2, answer.getArguments().length) + .mapToObj(answer::getArgument) + .toList(); + mockedCopiedFiles.add(new CopyPath(original, copy, options)); return copy; }); @@ -81,7 +94,6 @@ void tearDown() { @Test void noExecutionOnSubProjectIfDisabled() { - classUnderTest.executeForSubproject = false; // Just to make explicit that this is the default value classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession( getResourcesPath().resolve("leaves"), Path.of("child-1") @@ -98,13 +110,9 @@ void noExecutionOnSubProjectIfDisabled() { assertThat(testLog.getLogRecords()) .hasSize(1) - .first() - .hasFieldOrPropertyWithValue("level", TestLog.LogLevel.INFO) - .hasFieldOrPropertyWithValue("throwable", Optional.empty()) - .hasFieldOrPropertyWithValue( - "message", - Optional.of("Skipping execution for subproject org.example.itests.leaves:child-1:5.0.0-child-1") - ); + .satisfiesExactly(validateLogRecordInfo( + "Skipping execution for subproject org.example.itests.leaves:child-1:5.0.0-child-1" + )); } @ParameterizedTest @@ -123,31 +131,13 @@ void noProjectsInScope_LogsWarning(Modus modus) { .isNotEmpty() .hasSize(3) .satisfiesExactly( - first -> assertThat(first) - .hasFieldOrPropertyWithValue("level", TestLog.LogLevel.INFO) - .hasFieldOrPropertyWithValue("throwable", Optional.empty()) - .hasFieldOrPropertyWithValue( - "message", - Optional.of("Execution for project: org.example.itests.single:project:1.0.0") - ), - second -> assertThat(second) - .hasFieldOrPropertyWithValue("level", TestLog.LogLevel.WARN) - .hasFieldOrPropertyWithValue("throwable", Optional.empty()) - .hasFieldOrPropertyWithValue( - "message", - Optional.of( - "No versioning files found in %s as folder does not exists".formatted( - getResourcesPath("single", ".versioning") - ) - ) - ), - third -> assertThat(third) - .hasFieldOrPropertyWithValue("level", TestLog.LogLevel.WARN) - .hasFieldOrPropertyWithValue("throwable", Optional.empty()) - .hasFieldOrPropertyWithValue( - "message", - Optional.of("No projects found in scope") + 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") ); } @@ -169,6 +159,26 @@ private Path getResourcesPath() { } } + private Consumer validateLogRecordDebug(String message) { + return validateLogRecord(TestLog.LogLevel.DEBUG, message); + } + + private Consumer validateLogRecordInfo(String message) { + return validateLogRecord(TestLog.LogLevel.INFO, message); + } + + private Consumer validateLogRecordWarn(String message) { + return validateLogRecord(TestLog.LogLevel.WARN, message); + } + + private Consumer validateLogRecord(TestLog.LogLevel level, String message) { + return record -> assertThat(record) + .hasFieldOrPropertyWithValue("level", level) + .hasFieldOrPropertyWithValue("throwable", Optional.empty()) + .hasFieldOrPropertyWithValue("message", Optional.of(message)); + } + + private record CopyPath(Path original, Path copy, List options) { } @@ -184,7 +194,6 @@ void setUp() { classUnderTest.modus = Modus.PROJECT_VERSION; } - @ParameterizedTest @EnumSource(value = VersionBump.class, names = {"FILE_BASED"}, mode = EnumSource.Mode.EXCLUDE) void fixedVersionBump_Valid(VersionBump versionBump) { @@ -195,12 +204,90 @@ void fixedVersionBump_Valid(VersionBump versionBump) { assertThat(testLog.getLogRecords()) .hasSize(7) - .first() - .hasFieldOrPropertyWithValue("level", TestLog.LogLevel.INFO) - .hasFieldOrPropertyWithValue("throwable", Optional.empty()) - .hasFieldOrPropertyWithValue( - "message", - Optional.of("Execution for project: org.example.itests.single:project:1.0.0") + .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(); + } + + @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) { @@ -246,6 +333,432 @@ void fixedVersionBump_Valid(VersionBump versionBump) { """.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 + ) + ) + ); + } + + @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 changelog 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(); + } + + @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(); + } + + @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(); + } + + @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(); + } + + @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(); + } + + @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(); + } + + @Test + void multipleSemanticVersionBumpFiles_Valid() { + classUnderTest.versionBump = VersionBump.FILE_BASED; + classUnderTest.versionDirectory = getResourcesPath("versioning", "single", "multiple"); + + assertThatNoException() + .isThrownBy(classUnderTest::execute); + + assertThat(testLog.getLogRecords()) + .hasSize(18) + .satisfiesExactly( + 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(); } 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 From e528719e2a66726d7e735232a9650963afeae2ea Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Thu, 15 Jan 2026 20:12:02 +0100 Subject: [PATCH 35/63] Add integration tests for multi-project semantic versioning and enhance `MarkdownUtils` with `cloneNode` method for deep copying nodes. Refactor project version handling logic and extend `UpdatePomMojoTest` with additional scenarios for improved test coverage. --- .../semantic/version/utils/MarkdownUtils.java | 19 +- .../semantic/version/UpdatePomMojoTest.java | 497 ++++++++++++++++++ .../itests/versioning/leaves/multi/child-1.md | 5 + .../itests/versioning/leaves/multi/child-2.md | 5 + .../itests/versioning/leaves/multi/child-3.md | 5 + .../versioning/leaves/none/versioning.md | 7 + .../versioning/leaves/single/versioning.md | 7 + 7 files changed, 540 insertions(+), 5 deletions(-) create mode 100644 src/test/resources/itests/versioning/leaves/multi/child-1.md create mode 100644 src/test/resources/itests/versioning/leaves/multi/child-2.md create mode 100644 src/test/resources/itests/versioning/leaves/multi/child-3.md create mode 100644 src/test/resources/itests/versioning/leaves/none/versioning.md create mode 100644 src/test/resources/itests/versioning/leaves/single/versioning.md diff --git a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java index 25aba82..b89effa 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java @@ -317,6 +317,7 @@ private static Node copyVersionMarkdownToChangeset(Node current, Map.Entry binaryOperator = mergeNodes(); - Node nextChild = document.getFirstChild(); + Node nextChild = node.getFirstChild(); while (nextChild != null) { Node nextSibling = nextChild.getNext(); currentLambda = binaryOperator.apply(currentLambda, nextChild); @@ -341,4 +338,16 @@ private static Node insertNodeChilds(Node currentLambda, Node node) throws Illeg } 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/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java index 93ba374..3519311 100644 --- a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java +++ b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java @@ -182,6 +182,503 @@ private Consumer validateLogRecord(TestLog.LogLevel level, St private record CopyPath(Path original, Path copy, List options) { } + @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(); + } + + @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(); + } + + @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(); + } + + @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(); + } + + } + @Nested class SingleProjectTest { 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. From cf42403dce2e416bf0e52951e212c3821a27d876 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Thu, 15 Jan 2026 20:24:52 +0100 Subject: [PATCH 36/63] Add integration tests for single-project revision versioning and enhance `UpdatePomMojoTest` with parameterized test cases for version bump validation. Include test scenarios for dry run, backup, and invalid project handling. --- .../semantic/version/UpdatePomMojoTest.java | 602 ++++++++++++++++++ .../revision/single/major/versioning.md | 5 + .../revision/single/minor/versioning.md | 5 + .../revision/single/multiple/major.md | 5 + .../revision/single/multiple/minor.md | 5 + .../revision/single/multiple/none.md | 5 + .../revision/single/multiple/patch.md | 5 + .../revision/single/none/versioning.md | 5 + .../revision/single/patch/versioning.md | 5 + .../single/unknown-project/versioning.md | 5 + 10 files changed, 647 insertions(+) create mode 100644 src/test/resources/itests/versioning/revision/single/major/versioning.md create mode 100644 src/test/resources/itests/versioning/revision/single/minor/versioning.md create mode 100644 src/test/resources/itests/versioning/revision/single/multiple/major.md create mode 100644 src/test/resources/itests/versioning/revision/single/multiple/minor.md create mode 100644 src/test/resources/itests/versioning/revision/single/multiple/none.md create mode 100644 src/test/resources/itests/versioning/revision/single/multiple/patch.md create mode 100644 src/test/resources/itests/versioning/revision/single/none/versioning.md create mode 100644 src/test/resources/itests/versioning/revision/single/patch/versioning.md create mode 100644 src/test/resources/itests/versioning/revision/single/unknown-project/versioning.md diff --git a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java index 3519311..8676c5e 100644 --- a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java +++ b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java @@ -679,6 +679,608 @@ void multiFileBased_Valid() { } + @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(); + } + + @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 + ) + ) + ); + } + + @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 changelog 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(); + } + + @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(); + } + + @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(); + } + + @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(); + } + + @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(); + } + + @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(); + } + + @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) + .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", "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(); + } + } + @Nested class SingleProjectTest { 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 From 1656544226cf3aaeff6a8c9f6a12eaadce3952fc Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Thu, 15 Jan 2026 20:36:20 +0100 Subject: [PATCH 37/63] Add integration tests for multi-project revision versioning to `UpdatePomMojoTest` with scenarios for version bump validation, backups, dry-run, and invalid files. Extend test coverage for multiple semantic version bump files. --- .../semantic/version/UpdatePomMojoTest.java | 627 ++++++++++++++++++ .../revision/multi/major/versioning.md | 5 + .../revision/multi/minor/versioning.md | 5 + .../revision/multi/multiple/major.md | 5 + .../revision/multi/multiple/minor.md | 5 + .../revision/multi/multiple/none.md | 5 + .../revision/multi/multiple/patch.md | 5 + .../revision/multi/none/versioning.md | 5 + .../revision/multi/patch/versioning.md | 5 + .../multi/unknown-project/versioning.md | 5 + 10 files changed, 672 insertions(+) create mode 100644 src/test/resources/itests/versioning/revision/multi/major/versioning.md create mode 100644 src/test/resources/itests/versioning/revision/multi/minor/versioning.md create mode 100644 src/test/resources/itests/versioning/revision/multi/multiple/major.md create mode 100644 src/test/resources/itests/versioning/revision/multi/multiple/minor.md create mode 100644 src/test/resources/itests/versioning/revision/multi/multiple/none.md create mode 100644 src/test/resources/itests/versioning/revision/multi/multiple/patch.md create mode 100644 src/test/resources/itests/versioning/revision/multi/none/versioning.md create mode 100644 src/test/resources/itests/versioning/revision/multi/patch/versioning.md create mode 100644 src/test/resources/itests/versioning/revision/multi/unknown-project/versioning.md diff --git a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java index 8676c5e..f52ab1a 100644 --- a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java +++ b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java @@ -679,6 +679,633 @@ void multiFileBased_Valid() { } + @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(); + } + + @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 + ) + ) + ); + } + + @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 changelog 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(); + } + + @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(); + } + + @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(); + } + + @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(); + } + + @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(); + } + + @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(); + } + + @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) + .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", "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(); + } + } + @Nested class RevisionSingleProjectTest { 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 From f8c3bb2f4ff854ac52be7c9acca6019d8587e1c3 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sat, 17 Jan 2026 12:39:48 +0100 Subject: [PATCH 38/63] Add file deletion utilities to `Utils` and update `UpdatePomMojo` to clean Markdown files after version updates. Extend related test coverage and refactor `VersionMarkdown` for path support. --- .../bsels/semantic/version/UpdatePomMojo.java | 24 ++++++++++-- .../version/models/VersionMarkdown.java | 4 ++ .../semantic/version/utils/MarkdownUtils.java | 4 +- .../bsels/semantic/version/utils/Utils.java | 38 +++++++++++++++++++ .../semantic/version/UpdatePomMojoTest.java | 20 ++++++++++ .../version/models/VersionMarkdownTest.java | 10 ++--- 6 files changed, 89 insertions(+), 11 deletions(-) diff --git a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java index 99e60b2..c57fcee 100644 --- a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java @@ -110,6 +110,7 @@ public UpdatePomMojo() { /// - 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. @@ -123,14 +124,25 @@ public void internalExecute() throws MojoExecutionException, MojoFailureExceptio 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"); - handleSingleProject(mapping, projectsInScope.get(0)); + hasChanges = handleSingleProject(mapping, projectsInScope.get(0)); } else { log.info("Multiple projects in scope"); - handleMultiProjects(mapping, projectsInScope); + hasChanges = handleMultiProjects(mapping, projectsInScope); + } + + if (hasChanges && VersionBump.FILE_BASED.equals(versionBump)) { + Utils.deleteFilesIfExists( + versionMarkdowns.stream() + .map(VersionMarkdown::path) + .filter(Objects::nonNull) + .toList() + ); } } @@ -139,9 +151,10 @@ public void internalExecute() throws MojoExecutionException, MojoFailureExceptio /// /// @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 void handleSingleProject(MarkdownMapping markdownMapping, MavenProject project) + private boolean handleSingleProject(MarkdownMapping markdownMapping, MavenProject project) throws MojoExecutionException, MojoFailureException { Path pom = project.getFile() .toPath(); @@ -158,15 +171,17 @@ private void handleSingleProject(MarkdownMapping markdownMapping, MavenProject p 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 void handleMultiProjects(MarkdownMapping markdownMapping, List projects) + private boolean handleMultiProjects(MarkdownMapping markdownMapping, List projects) throws MojoExecutionException, MojoFailureException { Log log = getLog(); Map documents = readAllPoms(projects); @@ -199,6 +214,7 @@ private void handleMultiProjects(MarkdownMapping markdownMapping, List bumps ) { @@ -26,6 +29,7 @@ public record VersionMarkdown( /// 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 diff --git a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java index b89effa..8478a3f 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java @@ -138,7 +138,7 @@ public static VersionMarkdown readVersionMarkdown(Log log, Path markdownFile) } log.debug("Maven artifacts and semantic version bumps:\n%s".formatted(bumps)); printMarkdown(log, document, 0); - return new VersionMarkdown(document, bumps); + return new VersionMarkdown(markdownFile, document, bumps); } /// Reads and parses a Markdown file, returning its content as a structured Node object. @@ -284,7 +284,7 @@ public static VersionMarkdown createSimpleVersionBumpDocument(MavenArtifact mave Paragraph paragraph = new Paragraph(); paragraph.appendChild(new Text("Project version bumped as result of dependency bumps")); document.appendChild(paragraph); - return new VersionMarkdown(document, Map.of(mavenArtifact, SemanticVersionBump.NONE)); + return new VersionMarkdown(null, document, Map.of(mavenArtifact, SemanticVersionBump.NONE)); } /// Merges two [Node] instances by inserting the second node after the first node and returning the second node. diff --git a/src/main/java/io/github/bsels/semantic/version/utils/Utils.java b/src/main/java/io/github/bsels/semantic/version/utils/Utils.java index 53641cb..a4a3dbb 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/Utils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/Utils.java @@ -7,6 +7,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Objects; @@ -59,6 +60,43 @@ public static void backupFile(Path file) throws NullPointerException, MojoExecut } } + /// 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(e); + } + } + /// Returns a predicate that always evaluates to `true`. /// /// @param the type of the input to the predicate diff --git a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java index f52ab1a..88bb1af 100644 --- a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java +++ b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java @@ -51,6 +51,7 @@ public class UpdatePomMojoTest { private TestLog testLog; private Map mockedOutputFiles; private List mockedCopiedFiles; + private List mockedDeletedFiles; private MockedStatic filesMockedStatic; private MockedStatic localDateMockedStatic; @@ -62,6 +63,7 @@ void setUp() { 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))) @@ -80,6 +82,10 @@ void setUp() { 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) @@ -113,6 +119,13 @@ void noExecutionOnSubProjectIfDisabled() { .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 @@ -139,6 +152,13 @@ void noProjectsInScope_LogsWarning(Modus modus) { ), validateLogRecordWarn("No projects found in scope") ); + + assertThat(mockedOutputFiles) + .isEmpty(); + assertThat(mockedCopiedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); } private Path getResourcesPath(String... relativePaths) { 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 index f06139b..55333ce 100644 --- a/src/test/java/io/github/bsels/semantic/version/models/VersionMarkdownTest.java +++ b/src/test/java/io/github/bsels/semantic/version/models/VersionMarkdownTest.java @@ -15,21 +15,21 @@ public class VersionMarkdownTest { @Test void nullNode_ThrowsNullPointerException() { - assertThatThrownBy(() -> new VersionMarkdown(null, Map.of(MAVEN_ARTIFACT, SemanticVersionBump.NONE))) + 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(CONTENT, null)) + assertThatThrownBy(() -> new VersionMarkdown(null, CONTENT, null)) .isInstanceOf(NullPointerException.class) .hasMessage("`bumps` must not be null"); } @Test void emptyBumps_ThrowsIllegalArgumentException() { - assertThatThrownBy(() -> new VersionMarkdown(CONTENT, Map.of())) + assertThatThrownBy(() -> new VersionMarkdown(null, CONTENT, Map.of())) .isInstanceOf(IllegalArgumentException.class) .hasMessage("`bumps` must not be empty"); } @@ -39,7 +39,7 @@ void mutableMapInput_MakeImmutable() { Map bumps = new HashMap<>(); bumps.put(MAVEN_ARTIFACT, SemanticVersionBump.NONE); - VersionMarkdown markdown = new VersionMarkdown(CONTENT, bumps); + VersionMarkdown markdown = new VersionMarkdown(null, CONTENT, bumps); assertThat(markdown) .hasFieldOrPropertyWithValue("content", CONTENT) .hasFieldOrPropertyWithValue("bumps", bumps) @@ -53,7 +53,7 @@ void mutableMapInput_MakeImmutable() { @Test void immutableMapInput_KeepsImmutable() { Map bumps = Map.of(MAVEN_ARTIFACT, SemanticVersionBump.NONE); - VersionMarkdown markdown = new VersionMarkdown(CONTENT, bumps); + VersionMarkdown markdown = new VersionMarkdown(null, CONTENT, bumps); assertThat(markdown) .hasFieldOrPropertyWithValue("content", CONTENT) .hasFieldOrPropertyWithValue("bumps", bumps) From 1e9cb79dde1cf6934b90857e304d96b9348a5944 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sat, 17 Jan 2026 13:07:40 +0100 Subject: [PATCH 39/63] Add file deletion assertions to `UpdatePomMojoTest` and update `UpdatePomMojo` to respect `dryRun` during Markdown file cleanup. --- .../bsels/semantic/version/UpdatePomMojo.java | 2 +- .../semantic/version/UpdatePomMojoTest.java | 109 ++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java index c57fcee..1d72079 100644 --- a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java @@ -136,7 +136,7 @@ public void internalExecute() throws MojoExecutionException, MojoFailureExceptio hasChanges = handleMultiProjects(mapping, projectsInScope); } - if (hasChanges && VersionBump.FILE_BASED.equals(versionBump)) { + if (!dryRun && hasChanges && VersionBump.FILE_BASED.equals(versionBump)) { Utils.deleteFilesIfExists( versionMarkdowns.stream() .map(VersionMarkdown::path) diff --git a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java index 88bb1af..770a860 100644 --- a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java +++ b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java @@ -309,6 +309,8 @@ void fixedVersionBump_Valid(VersionBump versionBump) { } assertThat(mockedCopiedFiles) .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); } @Test @@ -352,6 +354,8 @@ void noSemanticVersionBumpFileBased_NothingChanged() { .isEmpty(); assertThat(mockedCopiedFiles) .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); } @Test @@ -514,6 +518,11 @@ void singleFileBased_Valid() { ); assertThat(mockedCopiedFiles) .isEmpty(); + + assertThat(mockedDeletedFiles) + .isNotEmpty() + .hasSize(1) + .containsExactly(getResourcesPath("versioning", "leaves", "single", "versioning.md")); } @Test @@ -695,6 +704,15 @@ void multiFileBased_Valid() { ); 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") + ); } } @@ -789,6 +807,8 @@ void fixedVersionBump_Valid(VersionBump versionBump) { ); assertThat(mockedCopiedFiles) .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); } @ParameterizedTest @@ -891,6 +911,8 @@ void fixedVersionBumpWithBackup_Valid(VersionBump versionBump) { ) ) ); + assertThat(mockedDeletedFiles) + .isEmpty(); } @ParameterizedTest @@ -965,6 +987,10 @@ void fixedVersionBumpDryRun_Valid(VersionBump versionBump) { .isEmpty(); assertThat(mockedCopiedFiles) .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); } @ParameterizedTest @@ -1006,6 +1032,10 @@ void dryRunStringWriteCloseFailure_ThrowMojoExecutionException(VersionBump versi .isEmpty(); assertThat(mockedCopiedFiles) .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); } @Test @@ -1030,6 +1060,8 @@ void filedBasedWalkFailed_ThrowMojoExecutionException() { .isEmpty(); assertThat(mockedCopiedFiles) .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); } @Test @@ -1066,6 +1098,8 @@ void unknownProjectFileBased_ThrowMojoFailureException() { .isEmpty(); assertThat(mockedCopiedFiles) .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); } @Test @@ -1103,6 +1137,8 @@ void noSemanticVersionBumpFileBased_NothingChanged() { .isEmpty(); assertThat(mockedCopiedFiles) .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); } @ParameterizedTest @@ -1194,6 +1230,12 @@ void singleSemanticVersionBumFile_Valid(String folder, String title, String expe ); assertThat(mockedCopiedFiles) .isEmpty(); + assertThat(mockedDeletedFiles) + .isNotEmpty() + .hasSize(1) + .containsExactlyInAnyOrder( + getResourcesPath("versioning", "revision", "multi", folder, "versioning.md") + ); } @Test @@ -1323,6 +1365,15 @@ void multipleSemanticVersionBumpFiles_Valid() { ); 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") + ); } } @@ -1411,6 +1462,8 @@ void fixedVersionBump_Valid(VersionBump versionBump) { ); assertThat(mockedCopiedFiles) .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); } @ParameterizedTest @@ -1508,6 +1561,8 @@ void fixedVersionBumpWithBackup_Valid(VersionBump versionBump) { ) ) ); + assertThat(mockedDeletedFiles) + .isEmpty(); } @ParameterizedTest @@ -1577,6 +1632,8 @@ void fixedVersionBumpDryRun_Valid(VersionBump versionBump) { .isEmpty(); assertThat(mockedCopiedFiles) .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); } @ParameterizedTest @@ -1618,6 +1675,8 @@ void dryRunStringWriteCloseFailure_ThrowMojoExecutionException(VersionBump versi .isEmpty(); assertThat(mockedCopiedFiles) .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); } @Test @@ -1642,6 +1701,8 @@ void filedBasedWalkFailed_ThrowMojoExecutionException() { .isEmpty(); assertThat(mockedCopiedFiles) .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); } @Test @@ -1678,6 +1739,8 @@ void unknownProjectFileBased_ThrowMojoFailureException() { .isEmpty(); assertThat(mockedCopiedFiles) .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); } @Test @@ -1715,6 +1778,8 @@ void noSemanticVersionBumpFileBased_NothingChanged() { .isEmpty(); assertThat(mockedCopiedFiles) .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); } @ParameterizedTest @@ -1801,6 +1866,12 @@ void singleSemanticVersionBumFile_Valid(String folder, String title, String expe ); assertThat(mockedCopiedFiles) .isEmpty(); + assertThat(mockedDeletedFiles) + .isNotEmpty() + .hasSize(1) + .containsExactlyInAnyOrder( + getResourcesPath("versioning", "revision", "single", folder, "versioning.md") + ); } @Test @@ -1925,6 +1996,15 @@ void multipleSemanticVersionBumpFiles_Valid() { ); 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") + ); } } @@ -2009,6 +2089,8 @@ void fixedVersionBump_Valid(VersionBump versionBump) { ); assertThat(mockedCopiedFiles) .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); } @ParameterizedTest @@ -2102,6 +2184,8 @@ void fixedVersionBumpWithBackup_Valid(VersionBump versionBump) { ) ) ); + assertThat(mockedDeletedFiles) + .isEmpty(); } @ParameterizedTest @@ -2167,6 +2251,8 @@ void fixedVersionBumpDryRun_Valid(VersionBump versionBump) { .isEmpty(); assertThat(mockedCopiedFiles) .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); } @ParameterizedTest @@ -2208,6 +2294,8 @@ void dryRunStringWriteCloseFailure_ThrowMojoExecutionException(VersionBump versi .isEmpty(); assertThat(mockedCopiedFiles) .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); } @Test @@ -2232,6 +2320,8 @@ void filedBasedWalkFailed_ThrowMojoExecutionException() { .isEmpty(); assertThat(mockedCopiedFiles) .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); } @Test @@ -2268,6 +2358,8 @@ void unknownProjectFileBased_ThrowMojoFailureException() { .isEmpty(); assertThat(mockedCopiedFiles) .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); } @Test @@ -2305,6 +2397,8 @@ void noSemanticVersionBumpFileBased_NothingChanged() { .isEmpty(); assertThat(mockedCopiedFiles) .isEmpty(); + assertThat(mockedDeletedFiles) + .isEmpty(); } @ParameterizedTest @@ -2387,6 +2481,12 @@ void singleSemanticVersionBumFile_Valid(String folder, String title, String expe ); assertThat(mockedCopiedFiles) .isEmpty(); + assertThat(mockedDeletedFiles) + .isNotEmpty() + .hasSize(1) + .containsExactlyInAnyOrder( + getResourcesPath("versioning", "single", folder, "versioning.md") + ); } @Test @@ -2507,6 +2607,15 @@ void multipleSemanticVersionBumpFiles_Valid() { ); 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") + ); } } } From b0cb02467968b6b87e193900fe1f0ed79317778e Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sat, 17 Jan 2026 13:11:31 +0100 Subject: [PATCH 40/63] Add unit tests for `deleteFileIfExists` and `deleteFilesIfExists` methods in `Utils` to validate file deletion behavior and exception handling. --- .../semantic/version/utils/UtilsTest.java | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java b/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java index 179bf38..1065195 100644 --- a/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java +++ b/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java @@ -120,6 +120,126 @@ void copySuccess_NoErrors() { } } + @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 AlwaysTrueTest { From 119ff8e3150d7f67e95af9bcbbeb54541bdcd97a Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sat, 17 Jan 2026 14:26:31 +0100 Subject: [PATCH 41/63] Add integration tests for multi-project semantic versioning with dependency-specific version bumps and changelog validation --- .../semantic/version/UpdatePomMojoTest.java | 241 ++++++++++++++++++ .../versioning/multi/dependency/versioning.md | 5 + .../multi/dependencyManagement/versioning.md | 5 + .../versioning/multi/parent/versioning.md | 5 + .../versioning/multi/plugin/versioning.md | 5 + .../multi/pluginManagement/versioning.md | 5 + 6 files changed, 266 insertions(+) create mode 100644 src/test/resources/itests/versioning/multi/dependency/versioning.md create mode 100644 src/test/resources/itests/versioning/multi/dependencyManagement/versioning.md create mode 100644 src/test/resources/itests/versioning/multi/parent/versioning.md create mode 100644 src/test/resources/itests/versioning/multi/plugin/versioning.md create mode 100644 src/test/resources/itests/versioning/multi/pluginManagement/versioning.md diff --git a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java index 770a860..e08f674 100644 --- a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java +++ b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java @@ -717,6 +717,247 @@ void multiFileBased_Valid() { } + @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")); + } + } + @Nested class RevisionMultiProjectTest { 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/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 From 536a7894ba2df4740f2cc0a175ede7c43743efa6 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sat, 17 Jan 2026 14:33:03 +0100 Subject: [PATCH 42/63] Add tests for excluded multi-project versioning with changelog and file handling validation --- .../semantic/version/UpdatePomMojoTest.java | 53 +++++++++++++++++++ .../itests/multi/excluded/CHANGELOG.md | 2 +- .../versioning/multi/excluded/versioning.md | 5 ++ 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 src/test/resources/itests/versioning/multi/excluded/versioning.md diff --git a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java index e08f674..3ca7987 100644 --- a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java +++ b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java @@ -956,6 +956,59 @@ void handleDependencyCorrect_NoErrors( .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 diff --git a/src/test/resources/itests/multi/excluded/CHANGELOG.md b/src/test/resources/itests/multi/excluded/CHANGELOG.md index 305a3a1..39ef5e2 100644 --- a/src/test/resources/itests/multi/excluded/CHANGELOG.md +++ b/src/test/resources/itests/multi/excluded/CHANGELOG.md @@ -1,5 +1,5 @@ # Changelog -## 5.0.0-excluded - 2026-01-01 +## 4.0.0-excluded - 2026-01-01 Initial excluded release. \ 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 From e0ae76a3ea753dbd08a8d27016b2137de4aa08c8 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sat, 17 Jan 2026 15:04:35 +0100 Subject: [PATCH 43/63] Add integration tests for multi-recursive projects with changelog and file handling validation --- .../semantic/version/UpdatePomMojoTest.java | 146 ++++++++++++++++++ .../itests/multi-recursive/CHANGELOG.md | 5 + .../multi-recursive/child-1/CHANGELOG.md | 5 + .../itests/multi-recursive/child-1/pom.xml | 14 ++ .../multi-recursive/child-2/CHANGELOG.md | 5 + .../itests/multi-recursive/child-2/pom.xml | 17 ++ .../resources/itests/multi-recursive/pom.xml | 14 ++ .../versioning/multi-recursive/versioning.md | 5 + 8 files changed, 211 insertions(+) create mode 100644 src/test/resources/itests/multi-recursive/CHANGELOG.md create mode 100644 src/test/resources/itests/multi-recursive/child-1/CHANGELOG.md create mode 100644 src/test/resources/itests/multi-recursive/child-1/pom.xml create mode 100644 src/test/resources/itests/multi-recursive/child-2/CHANGELOG.md create mode 100644 src/test/resources/itests/multi-recursive/child-2/pom.xml create mode 100644 src/test/resources/itests/multi-recursive/pom.xml create mode 100644 src/test/resources/itests/versioning/multi-recursive/versioning.md diff --git a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java index 3ca7987..020cc01 100644 --- a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java +++ b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java @@ -1011,6 +1011,152 @@ void independentProject_NoDependencyUpdates() { } } + @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 { 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/versioning/multi-recursive/versioning.md b/src/test/resources/itests/versioning/multi-recursive/versioning.md new file mode 100644 index 0000000..71903ff --- /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 From 1b34af6278b7dcfe0a85c6e562e1a8f061fd5bf4 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sat, 17 Jan 2026 15:48:08 +0100 Subject: [PATCH 44/63] Add the ` createTemporaryMarkdownFile ` utility in `Utils` and corresponding unit tests Extend `Utils` with a method to create temporary Markdown files, ensuring proper exception handling. Added comprehensive tests to validate success and failure scenarios. Simplified GitHub Actions workflow by replacing version bump script with semantic version plugin. --- .github/workflows/push-release.yaml | 48 +++++-------------- .../bsels/semantic/version/utils/Utils.java | 17 ++++++- .../semantic/version/utils/UtilsTest.java | 37 ++++++++++++++ 3 files changed, 66 insertions(+), 36 deletions(-) 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/src/main/java/io/github/bsels/semantic/version/utils/Utils.java b/src/main/java/io/github/bsels/semantic/version/utils/Utils.java index a4a3dbb..6cedf40 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/Utils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/Utils.java @@ -93,7 +93,22 @@ public static void deleteFileIfExists(Path path) throws NullPointerException, Mo try { Files.deleteIfExists(path); } catch (IOException e) { - throw new MojoExecutionException(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); } } diff --git a/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java b/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java index 1065195..192ec45 100644 --- a/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java +++ b/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java @@ -120,6 +120,43 @@ void copySuccess_NoErrors() { } } + @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 { From 571bde50ec6d8d40f91962449d8548bb92054946 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sat, 17 Jan 2026 15:51:29 +0100 Subject: [PATCH 45/63] Update PR template to revise versioning checklist --- .github/pull_request_template.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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? From faacc261bb583e887baf18843679bcff04a90418 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sat, 17 Jan 2026 16:16:07 +0100 Subject: [PATCH 46/63] Add `ProcessUtils` utility class with methods to determine the default editor and execute it, along with comprehensive unit tests --- .../semantic/version/utils/ProcessUtils.java | 65 ++++ .../version/utils/ProcessUtilsTest.java | 358 ++++++++++++++++++ 2 files changed, 423 insertions(+) create mode 100644 src/main/java/io/github/bsels/semantic/version/utils/ProcessUtils.java create mode 100644 src/test/java/io/github/bsels/semantic/version/utils/ProcessUtilsTest.java 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/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()); + } + } +} From 896ede30b7ef21171f3baf9dbd706b7f32480f53 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sun, 18 Jan 2026 11:38:28 +0100 Subject: [PATCH 47/63] Add `CreateVersionMarkdownMojo` and `TerminalHelper` for interactive semantic versioning and markdown generation Introduce `CreateVersionMarkdownMojo` to enable semantic versioning with user interaction through terminal prompts. Add `TerminalHelper` utility for handling multi-line input and choice selections. Extend `MavenArtifact` with comparable logic and update `BaseMojo` to include `CreateVersionMarkdownMojo`. --- pom.xml | 2 +- .../bsels/semantic/version/BaseMojo.java | 2 +- .../version/CreateVersionMarkdownMojo.java | 132 ++++++++++++ .../bsels/semantic/version/UpdatePomMojo.java | 2 +- .../version/models/MavenArtifact.java | 25 ++- .../version/utils/TerminalHelper.java | 188 ++++++++++++++++++ .../semantic/version/utils/package-info.java | 2 +- 7 files changed, 347 insertions(+), 6 deletions(-) create mode 100644 src/main/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojo.java create mode 100644 src/main/java/io/github/bsels/semantic/version/utils/TerminalHelper.java diff --git a/pom.xml b/pom.xml index 07e5102..f1775da 100644 --- a/pom.xml +++ b/pom.xml @@ -83,7 +83,7 @@ ${maven.plugin.api.version} - >=25.0.0 + >=17.0.0 diff --git a/src/main/java/io/github/bsels/semantic/version/BaseMojo.java b/src/main/java/io/github/bsels/semantic/version/BaseMojo.java index 3906c48..8f917d6 100644 --- a/src/main/java/io/github/bsels/semantic/version/BaseMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/BaseMojo.java @@ -53,7 +53,7 @@ /// /// Any issues encountered during plugin execution may result in a [MojoExecutionException] /// or a [MojoFailureException] being thrown. -public abstract sealed class BaseMojo extends AbstractMojo permits UpdatePomMojo { +public abstract sealed class BaseMojo extends AbstractMojo permits CreateVersionMarkdownMojo, UpdatePomMojo { /// A constant string representing the filename of the changelog file, "CHANGELOG.md". /// 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..f0d1f12 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojo.java @@ -0,0 +1,132 @@ +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 org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +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(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 + ); + + @Override + protected void internalExecute() throws MojoExecutionException, MojoFailureException { + List projects = getProjectsInScope() + .map(mavenProject -> new MavenArtifact(mavenProject.getGroupId(), mavenProject.getArtifactId())) + .toList(); + if (projects.isEmpty()) { + getLog().warn("No projects found in scope"); + return; + } + Map selectedProjects = determineVersionBumps(projects); + if (selectedProjects == null) return; + + Map bumps = selectedProjects; + Optional input = TerminalHelper.readMultiLineInput( + "Please type the changelog entry here (enter empty line to open external editor, two empty lines to end):" + ); + getLog().info("Creating version markdown file..."); + getLog().info(input.orElse("")); + + +// Node node = createVersionMarkdownInExternalEditor(); +// +// MarkdownUtils.printMarkdown(getLog(), node, 0); + } + + /// 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().warn("No projects selected"); + return null; + } + 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 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"); + } + 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 index 1d72079..a2b7a13 100644 --- a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java @@ -50,7 +50,7 @@ /// - 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", requiresDependencyResolution = ResolutionScope.RUNTIME) +@Mojo(name = "update", aggregator = true, requiresDependencyResolution = ResolutionScope.NONE) @Execute(phase = LifecyclePhase.NONE) public final class UpdatePomMojo extends BaseMojo { 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 index ed9a339..372b0a1 100644 --- a/src/main/java/io/github/bsels/semantic/version/models/MavenArtifact.java +++ b/src/main/java/io/github/bsels/semantic/version/models/MavenArtifact.java @@ -2,6 +2,7 @@ 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. @@ -14,7 +15,16 @@ /// /// @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) { +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. @@ -34,7 +44,7 @@ public record MavenArtifact(String groupId, String artifactId) { /// @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 + /// @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") @@ -56,4 +66,15 @@ public static MavenArtifact of(String colonSeparatedString) { 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/utils/TerminalHelper.java b/src/main/java/io/github/bsels/semantic/version/utils/TerminalHelper.java new file mode 100644 index 0000000..2094341 --- /dev/null +++ b/src/main/java/io/github/bsels/semantic/version/utils/TerminalHelper.java @@ -0,0 +1,188 @@ +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 + public static Optional readMultiLineInput(String prompt) { + System.out.println(prompt); + Scanner scanner = new Scanner(System.in); + StringBuilder builder = new StringBuilder(); + String line = scanner.nextLine(); + if (line.isBlank()) { + return Optional.empty(); + } + builder.append(line).append("\n"); + boolean lastLineEmpty = line.isBlank(); + line = scanner.nextLine(); + while (!line.isBlank() || !lastLineEmpty) { + builder.append(line).append("\n"); + lastLineEmpty = line.isBlank(); + line = scanner.nextLine(); + } + return Optional.of(builder.toString()); + } + + /// 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); + if (choices.isEmpty()) { + return List.of(); + } + 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); + } + String line = scanner.nextLine(); + if (!line.isBlank()) { + List currentSelection = new ArrayList<>(choices.size()); + for (String choice : MULTI_CHOICE_SEPARATOR.split(line)) { + Optional item = parseIndexOrEnum(choice, choices); + if (item.isPresent()) { + currentSelection.add(item.get()); + } else { + System.out.printf("Invalid %s: %s%n", promptObject, choice); + currentSelection = null; + break; + } + } + selectedChoices = currentSelection; + } + } + return selectedChoices; + } + + /// 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/package-info.java b/src/main/java/io/github/bsels/semantic/version/utils/package-info.java index 850fb7b..0c356b0 100644 --- 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 @@ -1,2 +1,2 @@ -/// This package contains the utils class for processing POM files, Markdown files, and other utility functions. +/// 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 From 4ad9ebae057bd1471f751ee47de748fa67cb5844 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sun, 18 Jan 2026 12:21:21 +0100 Subject: [PATCH 48/63] Add YAML header generation and changelog handling to `CreateVersionMarkdownMojo` Implement YAML front matter block creation for semantic version bumps. Enhance changelog generation with user interaction and external editor support. Add utilities for Markdown parsing, versioning file resolution, and dry-run simulation. Refactor `BaseMojo` for file path handling and logging improvements. --- .../bsels/semantic/version/BaseMojo.java | 88 ++++++++++++++++-- .../version/CreateVersionMarkdownMojo.java | 92 ++++++++++++++++--- .../bsels/semantic/version/UpdatePomMojo.java | 52 +---------- .../semantic/version/utils/MarkdownUtils.java | 37 +++++++- .../version/utils/TerminalHelper.java | 4 +- .../bsels/semantic/version/utils/Utils.java | 28 ++++++ .../semantic/version/UpdatePomMojoTest.java | 6 +- .../versioning/multi-recursive/versioning.md | 2 +- 8 files changed, 234 insertions(+), 75 deletions(-) diff --git a/src/main/java/io/github/bsels/semantic/version/BaseMojo.java b/src/main/java/io/github/bsels/semantic/version/BaseMojo.java index 8f917d6..05cc2e5 100644 --- a/src/main/java/io/github/bsels/semantic/version/BaseMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/BaseMojo.java @@ -14,8 +14,10 @@ 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; @@ -121,6 +123,14 @@ public abstract sealed class BaseMojo extends AbstractMojo permits CreateVersion @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. @@ -187,12 +197,7 @@ public final void execute() throws MojoExecutionException, MojoFailureException /// @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; - if (versionDirectory.isAbsolute()) { - versioningFolder = versionDirectory; - } else { - versioningFolder = Path.of(session.getExecutionRootDirectory()).resolve(versionDirectory); - } + 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(); @@ -214,6 +219,21 @@ protected final List getVersionMarkdowns() throws MojoExecution 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 @@ -292,4 +312,60 @@ protected Stream getProjectsInScope() { .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 index f0d1f12..b45d1f6 100644 --- a/src/main/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojo.java @@ -6,14 +6,18 @@ 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.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.HashMap; import java.util.List; @@ -21,6 +25,19 @@ 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 { @@ -41,29 +58,82 @@ public final class CreateVersionMarkdownMojo extends BaseMojo { 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()) { - getLog().warn("No projects found in scope"); + log.warn("No projects found in scope"); return; } Map selectedProjects = determineVersionBumps(projects); - if (selectedProjects == null) return; + if (selectedProjects == null) { + log.warn("No projects selected"); + return; + } - Map bumps = selectedProjects; - Optional input = TerminalHelper.readMultiLineInput( - "Please type the changelog entry here (enter empty line to open external editor, two empty lines to end):" - ); - getLog().info("Creating version markdown file..."); - getLog().info(input.orElse("")); + YamlFrontMatterBlock versionBumpHeader = MarkdownUtils.createVersionBumpsHeader(log, selectedProjects); + Node inputMarkdown = createChangelogEntry(); + inputMarkdown.prependChild(versionBumpHeader); + Path versioningFolder = getVersioningFolder(); + if (!Files.exists(versioningFolder)) { + try { + Files.createDirectories(versioningFolder); + } catch (IOException e) { + throw new MojoExecutionException("Unable to create versioning folder", e); + } + } + Path versioningFile = Utils.resolveVersioningFile(versioningFolder); + writeMarkdownFile(inputMarkdown, versioningFile); + } -// Node node = createVersionMarkdownInExternalEditor(); -// -// MarkdownUtils.printMarkdown(getLog(), node, 0); + /// 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. diff --git a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java index a2b7a13..0f448cd 100644 --- a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java @@ -21,8 +21,6 @@ import org.w3c.dom.Document; import org.w3c.dom.Node; -import java.io.IOException; -import java.io.StringWriter; import java.nio.file.Path; import java.util.ArrayDeque; import java.util.ArrayList; @@ -75,14 +73,6 @@ public final class UpdatePomMojo extends BaseMojo { @Parameter(property = "versioning.bump", required = true, defaultValue = "FILE_BASED") VersionBump versionBump = VersionBump.FILE_BASED; - /// 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 UpdatePomMojo class. /// /// Initializes an instance of the UpdatePomMojo class by invoking the superclass constructor. @@ -491,33 +481,7 @@ private void updateMarkdownFile( /// @throws MojoFailureException if any Mojo-related failure occurs during execution private void writeUpdatedChangelog(org.commonmark.node.Node changelog, Path changelogFile) throws MojoExecutionException, MojoFailureException { - if (dryRun) { - dryRunWriteFile( - writer -> MarkdownUtils.writeMarkdown(writer, changelog), - changelogFile, "Dry-run: new changelog at %s:%n%s" - ); - } else { - MarkdownUtils.writeMarkdownFile(changelogFile, changelog, 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 - private 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); - } + writeMarkdownFile(changelog, changelogFile); } /// Determines the semantic version bump for a given Maven artifact based on the provided map of version bumps @@ -538,20 +502,6 @@ private SemanticVersionBump getSemanticVersionBump( }; } - /// 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 - private 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; - } - /// Represents a combination of a Maven project artifact, its associated POM file path, /// and the XML document of the POM file's contents. /// diff --git a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java index 8478a3f..4230da8 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java @@ -4,6 +4,7 @@ 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; @@ -60,7 +61,8 @@ public final class MarkdownUtils { /// 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(); + 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: @@ -164,12 +166,22 @@ public static Node readMarkdown(Log log, Path markdownFile) throws MojoExecution 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 PARSER.parse(String.join("\n", lines)); + 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 @@ -287,6 +299,27 @@ public static VersionMarkdown createSimpleVersionBumpDocument(MavenArtifact mave 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 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(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 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 index 2094341..7442a4c 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/TerminalHelper.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/TerminalHelper.java @@ -45,7 +45,9 @@ private TerminalHelper() { /// /// @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 - public static Optional readMultiLineInput(String prompt) { + /// @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(); diff --git a/src/main/java/io/github/bsels/semantic/version/utils/Utils.java b/src/main/java/io/github/bsels/semantic/version/utils/Utils.java index 6cedf40..15c81fa 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/Utils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/Utils.java @@ -7,6 +7,8 @@ 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; @@ -25,6 +27,20 @@ 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 + /// `yyyyMMddHHmmssSSS`. + /// 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 + /// - Millisecond: 3 digits + /// + /// The formatter is thread-safe and can be used in concurrent environments. + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS"); /// Utility class containing static constants and methods for various common operations. /// This class is not designed to be instantiated. @@ -112,6 +128,18 @@ public static Path createTemporaryMarkdownFile() throws MojoExecutionException { } } + /// 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 diff --git a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java index 020cc01..532ff67 100644 --- a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java +++ b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java @@ -1408,7 +1408,7 @@ void fixedVersionBumpDryRun_Valid(VersionBump versionBump) { validateLogRecordDebug("Original changelog"), validateLogRecordDebug("Updated changelog"), validateLogRecordInfo(""" - Dry-run: new changelog at %s: + Dry-run: new markdown file at %s: # Changelog ## %s - 2025-01-01 @@ -2053,7 +2053,7 @@ void fixedVersionBumpDryRun_Valid(VersionBump versionBump) { validateLogRecordDebug("Original changelog"), validateLogRecordDebug("Updated changelog"), validateLogRecordInfo(""" - Dry-run: new changelog at %s: + Dry-run: new markdown file at %s: # Changelog ## %s - 2025-01-01 @@ -2672,7 +2672,7 @@ void fixedVersionBumpDryRun_Valid(VersionBump versionBump) { validateLogRecordDebug("Original changelog"), validateLogRecordDebug("Updated changelog"), validateLogRecordInfo(""" - Dry-run: new changelog at %s: + Dry-run: new markdown file at %s: # Changelog ## %s - 2025-01-01 diff --git a/src/test/resources/itests/versioning/multi-recursive/versioning.md b/src/test/resources/itests/versioning/multi-recursive/versioning.md index 71903ff..658cebf 100644 --- a/src/test/resources/itests/versioning/multi-recursive/versioning.md +++ b/src/test/resources/itests/versioning/multi-recursive/versioning.md @@ -1,5 +1,5 @@ --- -'org.example.itests.multi-recursive:parent': minor +org.example.itests.multi-recursive:parent: minor --- Parent update. \ No newline at end of file From c3e21bfae6ee6442696225fd4449a95071707e5c Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sun, 18 Jan 2026 12:23:49 +0100 Subject: [PATCH 49/63] Update `DATE_TIME_FORMATTER` to drop millisecond precision in `Utils` --- .../java/io/github/bsels/semantic/version/utils/Utils.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/github/bsels/semantic/version/utils/Utils.java b/src/main/java/io/github/bsels/semantic/version/utils/Utils.java index 15c81fa..87f5205 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/Utils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/Utils.java @@ -28,7 +28,7 @@ public final class Utils { /// 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 - /// `yyyyMMddHHmmssSSS`. + /// `yyyyMMddHHmmss`. /// This formatter ensures that date-time values are represented in a compact string format with the following /// components: /// - Year: 4 digits @@ -37,10 +37,9 @@ public final class Utils { /// - Hour: 2 digits (24-hour clock) /// - Minute: 2 digits /// - Second: 2 digits - /// - Millisecond: 3 digits /// /// The formatter is thread-safe and can be used in concurrent environments. - private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS"); + private 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. From 0aa97bf96976e16c27b6d9ebfb3fa2d5bc366bc5 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sun, 18 Jan 2026 12:50:31 +0100 Subject: [PATCH 50/63] Add unit tests for `MarkdownUtils.createVersionBumpsHeader` and `Utils.resolveVersioningFile` Extend test coverage with comprehensive scenarios for YAML generation and versioning file resolution. Include null checks, multiple entries handling, and path validation based on timestamped files. --- .../semantic/version/utils/MarkdownUtils.java | 3 +- .../version/utils/MarkdownUtilsTest.java | 83 ++++++++++++++++++- .../semantic/version/utils/UtilsTest.java | 25 ++++++ 3 files changed, 109 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java index 4230da8..fe986fd 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java @@ -304,11 +304,12 @@ public static VersionMarkdown createSimpleVersionBumpDocument(MavenArtifact mave /// @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 is null. + /// @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 { diff --git a/src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java b/src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java index ebd4a24..c23717a 100644 --- a/src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java +++ b/src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java @@ -1,11 +1,14 @@ 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; @@ -28,6 +31,7 @@ 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; @@ -244,7 +248,7 @@ void happyFlow_CorrectlyWritten(boolean backupOld) { try (MockedStatic filesMockedStatic = Mockito.mockStatic(Files.class)) { StringWriter writer = new StringWriter(); filesMockedStatic.when(() -> Files.exists(CHANGELOG_PATH)) - .thenReturn(true); + .thenReturn(true); filesMockedStatic.when(() -> Files.newBufferedWriter( CHANGELOG_PATH, StandardCharsets.UTF_8, @@ -779,4 +783,81 @@ void happyFlow_ValidObject() throws MojoExecutionException { ); } } + + @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/UtilsTest.java b/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java index 192ec45..49dcc67 100644 --- a/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java +++ b/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java @@ -20,6 +20,7 @@ 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; @@ -444,4 +445,28 @@ void nonEmptyStream_NonEmptyAndImmutable() { .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); + } + } + } } From ae201046f1fad90915664eaaea8d0576090785af Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sun, 18 Jan 2026 13:23:43 +0100 Subject: [PATCH 51/63] Add comprehensive unit tests for `TerminalHelper` and fix multi-choice and trailing whitespace handling Introduce extensive tests for `TerminalHelper` methods, including validation scenarios for multi-line input, single-choice, and multi-choice selections. Fixes include trimming trailing whitespace from multi-line inputs and handling blank entries in multi-choice responses. --- .../version/utils/TerminalHelper.java | 8 +- .../version/utils/TerminalHelperTest.java | 494 ++++++++++++++++++ 2 files changed, 498 insertions(+), 4 deletions(-) create mode 100644 src/test/java/io/github/bsels/semantic/version/utils/TerminalHelperTest.java 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 index 7442a4c..c6bba84 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/TerminalHelper.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/TerminalHelper.java @@ -63,7 +63,7 @@ public static Optional readMultiLineInput(String prompt) throws NullPoin lastLineEmpty = line.isBlank(); line = scanner.nextLine(); } - return Optional.of(builder.toString()); + return Optional.of(builder.toString().stripTrailing()); } /// Displays a list of choices to the user and allows selection of a single option @@ -111,9 +111,6 @@ public static T singleChoice(String choiceHeader, String promptObject, List< public static List multiChoice(String choiceHeader, String promptObject, List choices) throws NullPointerException, IllegalArgumentException { validateChoiceMethodHeader(choiceHeader, promptObject, choices); - if (choices.isEmpty()) { - return List.of(); - } boolean isEnum = Enum.class.isAssignableFrom(choices.get(0).getClass()); Scanner scanner = new Scanner(System.in); List selectedChoices = null; @@ -131,6 +128,9 @@ public static List multiChoice(String choiceHeader, String promptObject, if (!line.isBlank()) { 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()); 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"); + } + } +} From 75e6938f0e3ee94c0d6db06c6cb343ece24f279e Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sun, 18 Jan 2026 16:18:11 +0100 Subject: [PATCH 52/63] Add unit tests for `MavenArtifact.compareTo` Introduce tests to validate comparison logic for `MavenArtifact`, covering scenarios for identical artifacts, differing group IDs, and differing artifact IDs. --- .../version/models/MavenArtifactTest.java | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) 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 index 44d7b64..050ee3b 100644 --- a/src/test/java/io/github/bsels/semantic/version/models/MavenArtifactTest.java +++ b/src/test/java/io/github/bsels/semantic/version/models/MavenArtifactTest.java @@ -81,4 +81,38 @@ void toString_ReturnsCorrectFormat() { .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); + } + } } From 7e372e134e5bd82aea44839f699854fd1dfd8231 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sun, 18 Jan 2026 16:39:42 +0100 Subject: [PATCH 53/63] Add unit tests for `CreateVersionMarkdownMojo` and refactor handling of selected projects Introduce tests validating `CreateVersionMarkdownMojo` functionality, including edge cases for project selection and execution. Refactor to replace `null` checks with safe collection operations and improve code readability. Add abstract base class to consolidate shared test utility methods. Introduce security policy and GitHub issue template for vulnerability reporting. --- .github/ISSUE_TEMPLATE/vulnerability.md | 17 +++ SECURITY.md | 9 ++ .../version/CreateVersionMarkdownMojo.java | 6 +- .../version/AbstractBaseMojoTest.java | 57 +++++++++ .../CreateVersionMarkdownMojoTest.java | 119 ++++++++++++++++++ .../semantic/version/UpdatePomMojoTest.java | 48 +------ 6 files changed, 206 insertions(+), 50 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/vulnerability.md create mode 100644 SECURITY.md create mode 100644 src/test/java/io/github/bsels/semantic/version/AbstractBaseMojoTest.java create mode 100644 src/test/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojoTest.java 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/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/src/main/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojo.java b/src/main/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojo.java index b45d1f6..ebea020 100644 --- a/src/main/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojo.java @@ -90,7 +90,7 @@ protected void internalExecute() throws MojoExecutionException, MojoFailureExcep return; } Map selectedProjects = determineVersionBumps(projects); - if (selectedProjects == null) { + if (!selectedProjects.isEmpty()) { log.warn("No projects selected"); return; } @@ -156,7 +156,7 @@ private Map determineVersionBumps(List projectSelections = TerminalHelper.multiChoice("Select projects:", "project", projects); if (projectSelections.isEmpty()) { getLog().warn("No projects selected"); - return null; + return Map.of(); } System.out.printf("Selected projects: %s%n", projectSelections.stream().map(MavenArtifact::toString).collect(Collectors.joining(", "))); for (MavenArtifact mavenArtifact : projectSelections) { @@ -176,7 +176,7 @@ private Map determineVersionBumps(List "'%s': %s".formatted(entry.getKey(), entry.getValue())) .collect(Collectors.joining(", ")) ); - return selectedProjects; + return Map.copyOf(selectedProjects); } /// Creates a Markdown file in an external editor, processes and returns its content as a [Node] object. 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..0a1d871 --- /dev/null +++ b/src/test/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojoTest.java @@ -0,0 +1,119 @@ +package io.github.bsels.semantic.version; + +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 org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.io.BufferedWriter; +import java.io.StringWriter; +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.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; + +public class CreateVersionMarkdownMojoTest extends AbstractBaseMojoTest { + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2023, 1, 1, 12, 0, 8); + private CreateVersionMarkdownMojo classUnderTest; + private TestLog testLog; + private Map mockedOutputFiles; + private Set mockedCreatedDirectories; + + private MockedStatic filesMockedStatic; + private MockedStatic localDateTimeMockedStatic; + + @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 -> mockedCreatedDirectories.add(answer.getArgument(0))); + + localDateTimeMockedStatic = Mockito.mockStatic(LocalDateTime.class); + localDateTimeMockedStatic.when(LocalDateTime::now) + .thenReturn(DATE_TIME); + } + + @AfterEach + public void tearDown() { + filesMockedStatic.close(); + localDateTimeMockedStatic.close(); + } + + @Test + void noExecutionOnSubProjectIfDisabled() { + 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(); + } + + +} diff --git a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java index 532ff67..d689be1 100644 --- a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java +++ b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java @@ -23,7 +23,6 @@ import java.io.BufferedWriter; import java.io.IOException; import java.io.StringWriter; -import java.net.URISyntaxException; import java.nio.file.CopyOption; import java.nio.file.Files; import java.nio.file.OpenOption; @@ -34,18 +33,14 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.function.Consumer; 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 UpdatePomMojoTest { +public class UpdatePomMojoTest extends AbstractBaseMojoTest { private static final LocalDate DATE = LocalDate.of(2025, 1, 1); private UpdatePomMojo classUnderTest; private TestLog testLog; @@ -161,47 +156,6 @@ void noProjectsInScope_LogsWarning(Modus modus) { .isEmpty(); } - private Path getResourcesPath(String... relativePaths) { - return Stream.of(relativePaths) - .reduce(getResourcesPath(), Path::resolve, (a, b) -> { - throw new UnsupportedOperationException(); - }); - } - - private Path getResourcesPath() { - try { - return Path.of( - Objects.requireNonNull(UpdatePomMojoTest.class.getResource("/itests/")) - .toURI() - ); - } catch (URISyntaxException e) { - throw new RuntimeException(e); - } - } - - private Consumer validateLogRecordDebug(String message) { - return validateLogRecord(TestLog.LogLevel.DEBUG, message); - } - - private Consumer validateLogRecordInfo(String message) { - return validateLogRecord(TestLog.LogLevel.INFO, message); - } - - private Consumer validateLogRecordWarn(String message) { - return validateLogRecord(TestLog.LogLevel.WARN, message); - } - - private Consumer validateLogRecord(TestLog.LogLevel level, String message) { - return record -> assertThat(record) - .hasFieldOrPropertyWithValue("level", level) - .hasFieldOrPropertyWithValue("throwable", Optional.empty()) - .hasFieldOrPropertyWithValue("message", Optional.of(message)); - } - - - private record CopyPath(Path original, Path copy, List options) { - } - @Nested class LeavesProjectTest { From 03dd80045069aced4fa09205d6c4a2b503fcbd38 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sun, 18 Jan 2026 18:51:07 +0100 Subject: [PATCH 54/63] Refactor directory creation logic and improve input handling Replace inline directory creation with `Utils.createDirectoryIfNotExists` to improve code reuse and readability. Enhance `TerminalHelper` methods with utility functions for input handling and introduce additional tests for `Utils` and `CreateVersionMarkdownMojo`. Modify log levels for clearer debugging. --- .../version/CreateVersionMarkdownMojo.java | 14 +- .../version/utils/TerminalHelper.java | 62 +++++-- .../bsels/semantic/version/utils/Utils.java | 22 ++- .../CreateVersionMarkdownMojoTest.java | 168 +++++++++++++++++- .../semantic/version/UpdatePomMojoTest.java | 2 +- .../semantic/version/utils/UtilsTest.java | 58 ++++++ 6 files changed, 292 insertions(+), 34 deletions(-) diff --git a/src/main/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojo.java b/src/main/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojo.java index ebea020..38f5705 100644 --- a/src/main/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojo.java @@ -16,8 +16,6 @@ import org.apache.maven.plugins.annotations.ResolutionScope; import org.commonmark.node.Node; -import java.io.IOException; -import java.nio.file.Files; import java.nio.file.Path; import java.util.HashMap; import java.util.List; @@ -90,7 +88,7 @@ protected void internalExecute() throws MojoExecutionException, MojoFailureExcep return; } Map selectedProjects = determineVersionBumps(projects); - if (!selectedProjects.isEmpty()) { + if (selectedProjects.isEmpty()) { log.warn("No projects selected"); return; } @@ -100,13 +98,7 @@ protected void internalExecute() throws MojoExecutionException, MojoFailureExcep inputMarkdown.prependChild(versionBumpHeader); Path versioningFolder = getVersioningFolder(); - if (!Files.exists(versioningFolder)) { - try { - Files.createDirectories(versioningFolder); - } catch (IOException e) { - throw new MojoExecutionException("Unable to create versioning folder", e); - } - } + Utils.createDirectoryIfNotExists(versioningFolder); Path versioningFile = Utils.resolveVersioningFile(versioningFolder); writeMarkdownFile(inputMarkdown, versioningFile); } @@ -155,7 +147,7 @@ private Map determineVersionBumps(List projectSelections = TerminalHelper.multiChoice("Select projects:", "project", projects); if (projectSelections.isEmpty()) { - getLog().warn("No projects selected"); + getLog().debug("No projects selected"); return Map.of(); } System.out.printf("Selected projects: %s%n", projectSelections.stream().map(MavenArtifact::toString).collect(Collectors.joining(", "))); 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 index c6bba84..80986eb 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/TerminalHelper.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/TerminalHelper.java @@ -51,17 +51,17 @@ public static Optional readMultiLineInput(String prompt) throws NullPoin System.out.println(prompt); Scanner scanner = new Scanner(System.in); StringBuilder builder = new StringBuilder(); - String line = scanner.nextLine(); + String line = getLineOrEmpty(scanner); if (line.isBlank()) { return Optional.empty(); } builder.append(line).append("\n"); boolean lastLineEmpty = line.isBlank(); - line = scanner.nextLine(); + line = getLineOrEmpty(scanner); while (!line.isBlank() || !lastLineEmpty) { builder.append(line).append("\n"); lastLineEmpty = line.isBlank(); - line = scanner.nextLine(); + line = getLineOrEmpty(scanner); } return Optional.of(builder.toString().stripTrailing()); } @@ -124,28 +124,54 @@ public static List multiChoice(String choiceHeader, String 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()) { - 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); - currentSelection = null; - break; - } - } - selectedChoices = currentSelection; + 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 diff --git a/src/main/java/io/github/bsels/semantic/version/utils/Utils.java b/src/main/java/io/github/bsels/semantic/version/utils/Utils.java index 87f5205..4b348e9 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/Utils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/Utils.java @@ -39,7 +39,7 @@ public final class Utils { /// - Second: 2 digits /// /// The formatter is thread-safe and can be used in concurrent environments. - private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + 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. @@ -127,6 +127,26 @@ public static Path createTemporaryMarkdownFile() throws MojoExecutionException { } } + /// 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. diff --git a/src/test/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojoTest.java b/src/test/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojoTest.java index 0a1d871..c97b3ab 100644 --- a/src/test/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojoTest.java +++ b/src/test/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojoTest.java @@ -1,18 +1,30 @@ 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.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; @@ -20,20 +32,29 @@ import java.util.HashMap; import java.util.HashSet; import java.util.Map; +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; +@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() { @@ -52,21 +73,49 @@ public void setUp() { return new BufferedWriter(mockedOutputFiles.get(path)); }); filesMockedStatic.when(() -> Files.createDirectories(Mockito.any())) - .thenAnswer(answer -> mockedCreatedDirectories.add(answer.getArgument(0))); + .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() { + void noExecutionOnSubProjectIfDisabled_SkipExecution() { classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession( getResourcesPath().resolve("leaves"), Path.of("child-1") @@ -115,5 +164,118 @@ void noProjectsInScope_LogsWarning(Modus modus) { .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: \ + """); + } + + // TODO + } + + @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 scannerMockedConstruction = 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(getResourcesPath("single", ".versioning", + "versioning-%s.md".formatted(Utils.DATE_TIME_FORMATTER.format(DATE_TIME))), 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)); + } + + // validateLogRecordInfo("Read 1 lines from %s".formatted(TEMP_FILE)), + } } diff --git a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java index d689be1..c5910c2 100644 --- a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java +++ b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java @@ -94,7 +94,7 @@ void tearDown() { } @Test - void noExecutionOnSubProjectIfDisabled() { + void noExecutionOnSubProjectIfDisabled_SkipExecution() { classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession( getResourcesPath().resolve("leaves"), Path.of("child-1") diff --git a/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java b/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java index 49dcc67..4cc0d74 100644 --- a/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java +++ b/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java @@ -278,6 +278,64 @@ void deletionFailsOnSecondFile_ThrowsMojoExecutionException() { } } + @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 { From 04cd3c41fb8c4840801f5d9690481a7d0fb72655 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Sun, 18 Jan 2026 19:01:15 +0100 Subject: [PATCH 55/63] Improve an exception message and extend test coverage in `CreateVersionMarkdownMojo` Clarify an error message for external editor failure to improve user feedback. Add unit tests for `CreateVersionMarkdownMojo` to validate the handling of external editor failures and log output validation. Extend test scenarios for semantic version bumps and markdown generation. --- .../version/CreateVersionMarkdownMojo.java | 2 +- .../CreateVersionMarkdownMojoTest.java | 127 +++++++++++++++++- 2 files changed, 123 insertions(+), 6 deletions(-) diff --git a/src/main/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojo.java b/src/main/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojo.java index 38f5705..c7742b5 100644 --- a/src/main/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojo.java +++ b/src/main/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojo.java @@ -184,7 +184,7 @@ private Node createVersionMarkdownInExternalEditor() throws MojoExecutionExcepti try { boolean valid = ProcessUtils.executeEditor(temporaryMarkdownFile); if (!valid) { - throw new MojoFailureException("Unable to create a new Markdown file"); + throw new MojoFailureException("Unable to create a new Markdown file in external editor."); } return MarkdownUtils.readMarkdown(getLog(), temporaryMarkdownFile); } finally { diff --git a/src/test/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojoTest.java b/src/test/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojoTest.java index c97b3ab..be88bed 100644 --- a/src/test/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojoTest.java +++ b/src/test/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojoTest.java @@ -5,6 +5,7 @@ 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.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -38,6 +39,7 @@ 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 { @@ -204,6 +206,9 @@ void noProjectsSelected_LogWarning() { 7: org.example.itests.multi:plugin-management Enter project numbers separated by spaces, commas or semicolons: \ """); + + assertThat(mockedOutputFiles) + .isEmpty(); } // TODO @@ -226,7 +231,7 @@ void setUp() { void dryRunInlineEditor_Valid(SemanticVersionBump bump) { classUnderTest.dryRun = true; - try (MockedConstruction scannerMockedConstruction = Mockito.mockConstruction( + try (MockedConstruction ignored = Mockito.mockConstruction( Scanner.class, (mock, context) -> { Mockito.when(mock.hasNextLine()).thenReturn(true, false); if (context.getCount() == 1) { @@ -240,7 +245,6 @@ void dryRunInlineEditor_Valid(SemanticVersionBump bump) { .isThrownBy(classUnderTest::execute); } - assertThat(testLog.getLogRecords()) .isNotEmpty() .hasSize(3) @@ -258,8 +262,7 @@ void dryRunInlineEditor_Valid(SemanticVersionBump bump) { --- Testing - """.formatted(getResourcesPath("single", ".versioning", - "versioning-%s.md".formatted(Utils.DATE_TIME_FORMATTER.format(DATE_TIME))), bump)) + """.formatted(getSingleVersioningMarkdown(), bump)) ); assertThat(outputStream.toString()) @@ -274,8 +277,122 @@ void dryRunInlineEditor_Valid(SemanticVersionBump bump) { 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(); } - // validateLogRecordInfo("Read 1 lines from %s".formatted(TEMP_FILE)), + @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))); + } } } From c90fed55e6f8da76671db958b90bf6772f98bfb4 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Mon, 19 Jan 2026 19:05:18 +0100 Subject: [PATCH 56/63] Extend test coverage for `CreateVersionMarkdownMojo` with additional scenarios for project selection, version bumps, and markdown validation. Fix formatting issues in mocked input handling. --- .../CreateVersionMarkdownMojoTest.java | 162 +++++++++++++++++- 1 file changed, 159 insertions(+), 3 deletions(-) diff --git a/src/test/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojoTest.java b/src/test/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojoTest.java index be88bed..03d1f22 100644 --- a/src/test/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojoTest.java +++ b/src/test/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojoTest.java @@ -6,6 +6,7 @@ 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; @@ -33,6 +34,7 @@ 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; @@ -211,7 +213,161 @@ void noProjectsSelected_LogWarning() { .isEmpty(); } - // TODO + @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 @@ -292,7 +448,7 @@ void externalEditorFails_ThrowsMojoFailureException() throws InterruptedExceptio Mockito.when(mock.hasNextLine()).thenReturn(true, false); Mockito.when(mock.nextLine()).thenReturn("minor"); } else { - Mockito.when(mock.hasNextLine()).thenReturn( false); + Mockito.when(mock.hasNextLine()).thenReturn(false); } } )) { @@ -340,7 +496,7 @@ void externalEditor_Valid(SemanticVersionBump bump) { Mockito.when(mock.hasNextLine()).thenReturn(true, false); Mockito.when(mock.nextLine()).thenReturn(bump.name()); } else { - Mockito.when(mock.hasNextLine()).thenReturn( false); + Mockito.when(mock.hasNextLine()).thenReturn(false); } } )) { From 282c01f91ed0c19ba26b12f468159d32dd2b5087 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Mon, 19 Jan 2026 19:10:50 +0100 Subject: [PATCH 57/63] Add `StandardOpenOption.WRITE` to `Files.newBufferedWriter` in `POMUtils` Ensure explicit write mode for `Files.newBufferedWriter` to address potential file handling issues. Update tests in `POMUtilsTest` to reflect the change and validate proper error handling. --- .../io/github/bsels/semantic/version/utils/POMUtils.java | 2 +- .../github/bsels/semantic/version/utils/POMUtilsTest.java | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) 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 index 72caa27..9c3c8d9 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/POMUtils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/POMUtils.java @@ -244,7 +244,7 @@ public static void writePom(Document document, Path pomFile, boolean backupOld) if (backupOld) { Utils.backupFile(pomFile); } - try (Writer writer = Files.newBufferedWriter(pomFile, StandardCharsets.UTF_8, StandardOpenOption.CREATE)) { + try (Writer writer = Files.newBufferedWriter(pomFile, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.WRITE)) { writePom(document, writer); } catch (IOException e) { throw new MojoExecutionException("Unable to write to %s".formatted(pomFile), e); 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 index 188fc59..250ace7 100644 --- a/src/test/java/io/github/bsels/semantic/version/utils/POMUtilsTest.java +++ b/src/test/java/io/github/bsels/semantic/version/utils/POMUtilsTest.java @@ -702,7 +702,8 @@ void openingWriterFails_ThrowsMojoExecutionException(boolean backup) throws IOEx 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)) + filesMockedStatic.when(() -> Files.newBufferedWriter(POM_FILE, StandardCharsets.UTF_8, + StandardOpenOption.CREATE, StandardOpenOption.WRITE)) .thenThrow(new IOException("Unable to open writer")); assertThatThrownBy(() -> POMUtils.writePom(pom, POM_FILE, backup)) @@ -731,7 +732,8 @@ void happyFlow_CorrectlyWritten(boolean backup) throws IOException { StringWriter writer = new StringWriter(); BufferedWriter bufferedWriter = new BufferedWriter(writer); - filesMockedStatic.when(() -> Files.newBufferedWriter(POM_FILE, StandardCharsets.UTF_8, StandardOpenOption.CREATE)) + filesMockedStatic.when(() -> Files.newBufferedWriter(POM_FILE, StandardCharsets.UTF_8, + StandardOpenOption.CREATE, StandardOpenOption.WRITE)) .thenReturn(bufferedWriter); assertThatNoException() From 5d98c4d3ef4bacb2016b99a1ab5051c3144ef9a5 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Mon, 19 Jan 2026 19:26:51 +0100 Subject: [PATCH 58/63] Add `StandardOpenOption.TRUNCATE_EXISTING` to `Files.newBufferedWriter` for file overwrite safety Ensure files are truncated before writing to prevent unintended data retention. Update related tests across `POMUtilsTest` and `MarkdownUtilsTest` to reflect and validate the new behavior. --- .../semantic/version/utils/MarkdownUtils.java | 3 ++- .../semantic/version/utils/POMUtils.java | 3 ++- .../version/utils/MarkdownUtilsTest.java | 16 +++++++++++---- .../semantic/version/utils/POMUtilsTest.java | 20 +++++++++++++------ 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java index fe986fd..6d6f3cf 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/MarkdownUtils.java @@ -244,7 +244,8 @@ public static void writeMarkdownFile(Path markdownFile, Node document, boolean b if (backupOld) { Utils.backupFile(markdownFile); } - try (Writer writer = Files.newBufferedWriter(markdownFile, StandardCharsets.UTF_8, StandardOpenOption.CREATE)) { + 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); 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 index 9c3c8d9..66b9be1 100644 --- a/src/main/java/io/github/bsels/semantic/version/utils/POMUtils.java +++ b/src/main/java/io/github/bsels/semantic/version/utils/POMUtils.java @@ -244,7 +244,8 @@ public static void writePom(Document document, Path pomFile, boolean backupOld) if (backupOld) { Utils.backupFile(pomFile); } - try (Writer writer = Files.newBufferedWriter(pomFile, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.WRITE)) { + 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); diff --git a/src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java b/src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java index c23717a..a05a720 100644 --- a/src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java +++ b/src/test/java/io/github/bsels/semantic/version/utils/MarkdownUtilsTest.java @@ -217,7 +217,9 @@ void failedToCreateFileWriter_ThrowsMojoExceptionException(boolean backupOld) { filesMockedStatic.when(() -> Files.newBufferedWriter( CHANGELOG_PATH, StandardCharsets.UTF_8, - StandardOpenOption.CREATE + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING )) .thenThrow(new IOException("Failed to create writer")); @@ -237,7 +239,9 @@ void failedToCreateFileWriter_ThrowsMojoExceptionException(boolean backupOld) { filesMockedStatic.verify(() -> Files.newBufferedWriter( CHANGELOG_PATH, StandardCharsets.UTF_8, - StandardOpenOption.CREATE + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING ), Mockito.times(1)); } } @@ -252,7 +256,9 @@ void happyFlow_CorrectlyWritten(boolean backupOld) { filesMockedStatic.when(() -> Files.newBufferedWriter( CHANGELOG_PATH, StandardCharsets.UTF_8, - StandardOpenOption.CREATE + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING )) .thenReturn(new BufferedWriter(writer)); @@ -274,7 +280,9 @@ void happyFlow_CorrectlyWritten(boolean backupOld) { filesMockedStatic.verify(() -> Files.newBufferedWriter( CHANGELOG_PATH, StandardCharsets.UTF_8, - StandardOpenOption.CREATE + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING ), Mockito.times(1)); } } 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 index 250ace7..e0cbc7a 100644 --- a/src/test/java/io/github/bsels/semantic/version/utils/POMUtilsTest.java +++ b/src/test/java/io/github/bsels/semantic/version/utils/POMUtilsTest.java @@ -702,9 +702,13 @@ void openingWriterFails_ThrowsMojoExecutionException(boolean backup) throws IOEx 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)) - .thenThrow(new IOException("Unable to open writer")); + 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) @@ -732,9 +736,13 @@ void happyFlow_CorrectlyWritten(boolean backup) throws IOException { StringWriter writer = new StringWriter(); BufferedWriter bufferedWriter = new BufferedWriter(writer); - filesMockedStatic.when(() -> Files.newBufferedWriter(POM_FILE, StandardCharsets.UTF_8, - StandardOpenOption.CREATE, StandardOpenOption.WRITE)) - .thenReturn(bufferedWriter); + 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)); From c60a886b128abd12cbb14647bf22f8cec9bc68e7 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Mon, 19 Jan 2026 19:57:11 +0100 Subject: [PATCH 59/63] Update version to `0.0.1` in `pom.xml` --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index f1775da..eb92f60 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.bsels semantic-version-maven-plugin - 0.0.1-SNAPSHOT + 0.0.1 maven-plugin ${project.groupId}:${project.artifactId} TODO From 87c7fb0cd0056d4d7e9e30abf8d7e5ae3c7fb307 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Mon, 19 Jan 2026 20:06:43 +0100 Subject: [PATCH 60/63] Update `README.md` with detailed plugin usage, configuration, and examples Add comprehensive documentation, covering installation, goals, configuration properties, and usage examples for the Semantic Version Maven Plugin. Improve structure with a table of contents and clarifications for both `create` and `update` goals. --- README.md | 269 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 268 insertions(+), 1 deletion(-) 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. + From 1de6ac85c09a96ecb17821893f4122e69283ffbc Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Mon, 19 Jan 2026 20:17:40 +0100 Subject: [PATCH 61/63] Update project description in `pom.xml` to reflect plugin functionality Replace the placeholder description with a detailed summary of the plugin's purpose, features, and goals, including semantic versioning automation and changelog management. --- pom.xml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index eb92f60..1d1a692 100644 --- a/pom.xml +++ b/pom.xml @@ -9,7 +9,11 @@ 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 From de258a38926ad984ff090fb7d0105d4514e8955b Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Tue, 20 Jan 2026 16:38:23 +0100 Subject: [PATCH 62/63] Make maven wrapper executable in Git --- mvnw | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 mvnw diff --git a/mvnw b/mvnw old mode 100644 new mode 100755 From a370fb939775add223460e484942211fbf33be31 Mon Sep 17 00:00:00 2001 From: Boris Sels Date: Tue, 20 Jan 2026 16:42:53 +0100 Subject: [PATCH 63/63] Update log record validations to use `satisfiesExactlyInAnyOrder` in `UpdatePomMojoTest` Refactor assertions to allow log records to match in any order, ensuring flexibility in test validations. --- .../io/github/bsels/semantic/version/UpdatePomMojoTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java index c5910c2..a5a4613 100644 --- a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java +++ b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java @@ -1642,7 +1642,7 @@ void multipleSemanticVersionBumpFiles_Valid() { assertThat(testLog.getLogRecords()) .hasSize(18) - .satisfiesExactly( + .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") @@ -2278,7 +2278,7 @@ void multipleSemanticVersionBumpFiles_Valid() { assertThat(testLog.getLogRecords()) .hasSize(18) - .satisfiesExactly( + .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") @@ -2893,7 +2893,7 @@ void multipleSemanticVersionBumpFiles_Valid() { assertThat(testLog.getLogRecords()) .hasSize(18) - .satisfiesExactly( + .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")