diff --git a/.gitignore b/.gitignore
index b9abcea..8b262d9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,4 +17,5 @@ buildNumber.properties
.classpath
# JetBrains
-.idea
\ No newline at end of file
+.idea
+.junie
\ No newline at end of file
diff --git a/.versioning/versioning-20260214170109.md b/.versioning/versioning-20260214170109.md
new file mode 100644
index 0000000..c404b68
--- /dev/null
+++ b/.versioning/versioning-20260214170109.md
@@ -0,0 +1,5 @@
+---
+io.github.bsels:semantic-version-maven-plugin: "MINOR"
+---
+
+Added new `graph` goal, to list the different project and their internal dependencies.
diff --git a/.versioning/versioning-20260214170800.md b/.versioning/versioning-20260214170800.md
new file mode 100644
index 0000000..cf8edbd
--- /dev/null
+++ b/.versioning/versioning-20260214170800.md
@@ -0,0 +1,8 @@
+---
+io.github.bsels:semantic-version-maven-plugin: "PATCH"
+---
+
+Updated the following project dependencies:
+- `maven-dependency-plugin` from `3.9.0` to `3.10.0`
+- `maven-compiler-plugin` from `3.14.1` to `3.15.0`
+- `assertj-core` from `3.27.6` to `3.27.7`
diff --git a/README.md b/README.md
index 2dbad02..9cf3c11 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,7 @@ A Maven plugin for automated semantic versioning with Markdown-based changelog m
- [create](#create)
- [update](#update)
- [verify](#verify)
+ - [graph](#graph)
- [Configuration Properties](#configuration-properties)
- [Examples](#examples)
- [License](#license)
@@ -246,6 +247,94 @@ mvn io.github.bsels:semantic-version-maven-plugin:verify \
-Dversioning.verification.consistent=true
```
+---
+
+### graph
+
+**Full name**: `io.github.bsels:semantic-version-maven-plugin:graph`
+
+**Description**: Generates a JSON representation of the dependency graph for Maven projects within the current execution
+scope. It identifies internal dependencies between projects and can output the graph either to the console or to a
+specified file. The output includes transitive dependencies sorted in build order (topological order).
+
+**Phase**: Not bound to any lifecycle phase (standalone goal)
+
+#### Configuration Properties
+
+| Property | Type | Default | Description |
+|----------------------------|---------------|-----------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `versioning.graphOutput` | `GraphOutput` | `ARTIFACT_AND_FOLDER` | Specifies the graph output format: • `ARTIFACT_ONLY`: Only Maven artifact identifiers • `FOLDER_ONLY`: Only project folder paths • `ARTIFACT_AND_FOLDER`: Both artifact identifiers and folder paths |
+| `versioning.outputFile` | `Path` | `-` | Path to the output file. If not specified, the graph is printed to the console. |
+| `versioning.relativePaths` | `boolean` | `true` | When `true`, project folder paths are resolved relative to the execution root; otherwise, absolute paths are used. |
+| `versioning.modus` | `Modus` | `PROJECT_VERSION` | Project scope for the graph: • `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) |
+
+#### Output Format Description
+
+The generated JSON is a map where each key is a project's artifact identifier (`groupId:artifactId`), and the value is a
+list of its internal dependencies.
+
+**ARTIFACT_ONLY**:
+
+```json
+{
+ "io.github.bsels:parent": [],
+ "io.github.bsels:child": [
+ "io.github.bsels:parent"
+ ]
+}
+```
+
+**FOLDER_ONLY**:
+
+```json
+{
+ "io.github.bsels:parent": [],
+ "io.github.bsels:child": [
+ "."
+ ]
+}
+```
+
+**ARTIFACT_AND_FOLDER**:
+
+```json
+{
+ "io.github.bsels:parent": [],
+ "io.github.bsels:child": [
+ {
+ "artifact": {
+ "groupId": "io.github.bsels",
+ "artifactId": "parent"
+ },
+ "folder": "."
+ }
+ ]
+}
+```
+
+#### Example Usage
+
+**Basic usage** (print to console):
+
+```bash
+mvn io.github.bsels:semantic-version-maven-plugin:graph
+```
+
+**Write to file with absolute paths**:
+
+```bash
+mvn io.github.bsels:semantic-version-maven-plugin:graph \
+ -Dversioning.outputFile=graph.json \
+ -Dversioning.relativePaths=false
+```
+
+**Artifact-only output**:
+
+```bash
+mvn io.github.bsels:semantic-version-maven-plugin:graph \
+ -Dversioning.graphOutput=ARTIFACT_ONLY
+```
+
## Configuration Properties
### Common Properties
@@ -290,6 +379,14 @@ These properties apply to `create`, `update`, and `verify` goals. The `verify` g
| `versioning.verification.mode` | `VerificationMode` | `ALL_PROJECTS` | Verification scope: • `NONE`: skip verification • `AT_LEAST_ONE_PROJECT`: require at least one version-marked project • `DEPENDENT_PROJECTS`: require all dependent projects to be version-marked • `ALL_PROJECTS`: all projects in scope must be version-marked |
| `versioning.verification.consistent` | `boolean` | `false` | When `true`, all version-marked projects must share the same version bump type |
+### graph-Specific Properties
+
+| Property | Type | Default | Description |
+|----------------------------|---------------|-----------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `versioning.graphOutput` | `GraphOutput` | `ARTIFACT_AND_FOLDER` | Specifies the graph output format: • `ARTIFACT_ONLY`: Only artifact identifiers • `FOLDER_ONLY`: Only folder paths • `ARTIFACT_AND_FOLDER`: Artifacts and folder paths |
+| `versioning.outputFile` | `Path` | `-` | Path to the output file. If not specified, output is printed to the console. |
+| `versioning.relativePaths` | `boolean` | `true` | When `true`, project paths are relative to execution root; otherwise, absolute. |
+
## Examples
### Example 1: Single Project Workflow
diff --git a/pom.xml b/pom.xml
index 654c600..2449fea 100644
--- a/pom.xml
+++ b/pom.xml
@@ -8,7 +8,7 @@
maven-plugin
${project.groupId}:${project.artifactId}
- A Maven plugin that automates semantic versioning with markdown‑based changelog management.
+ 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.
@@ -43,8 +43,8 @@
0.8.14
3.9.12
3.6.2
- 3.9.0
- 3.14.1
+ 3.10.0
+ 3.15.0
3.5.4
3.15.2
3.2.8
@@ -53,7 +53,7 @@
0.10.0
- 3.27.6
+ 3.27.7
0.27.1
2.21.0
6.0.2
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 ce70465..94ac23d 100644
--- a/src/main/java/io/github/bsels/semantic/version/BaseMojo.java
+++ b/src/main/java/io/github/bsels/semantic/version/BaseMojo.java
@@ -62,7 +62,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 CreateVersionMarkdownMojo, UpdatePomMojo, VerifyMojo {
+public abstract sealed class BaseMojo extends AbstractMojo permits CreateVersionMarkdownMojo, DependencyGraphMojo, UpdatePomMojo, VerifyMojo {
/// A constant string representing the filename of the changelog file, "CHANGELOG.md".
///
@@ -305,7 +305,7 @@ protected MarkdownMapping getMarkdownMapping(List versionMarkdo
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()))
+ Set artifacts = projectsInScope.map(Utils::mavenProjectToArtifact)
.collect(Utils.asImmutableSet());
if (!artifacts.containsAll(artifactsInMarkdown)) {
@@ -418,7 +418,7 @@ protected Map readAllPoms(List documents = new HashMap<>();
for (MavenProject project : projects) {
- MavenArtifact mavenArtifact = new MavenArtifact(project.getGroupId(), project.getArtifactId());
+ MavenArtifact mavenArtifact = Utils.mavenProjectToArtifact(project);
Path pomFile = project.getFile().toPath();
MavenProjectAndDocument projectAndDocument = new MavenProjectAndDocument(
mavenArtifact,
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 14b8575..6b7b5a2 100644
--- a/src/main/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojo.java
+++ b/src/main/java/io/github/bsels/semantic/version/CreateVersionMarkdownMojo.java
@@ -107,7 +107,7 @@ protected void internalExecute() throws MojoExecutionException, MojoFailureExcep
Log log = getLog();
List projects = getProjectsInScope()
- .map(mavenProject -> new MavenArtifact(mavenProject.getGroupId(), mavenProject.getArtifactId()))
+ .map(Utils::mavenProjectToArtifact)
.toList();
if (projects.isEmpty()) {
log.warn("No projects found in scope");
diff --git a/src/main/java/io/github/bsels/semantic/version/DependencyGraphMojo.java b/src/main/java/io/github/bsels/semantic/version/DependencyGraphMojo.java
new file mode 100644
index 0000000..90896c9
--- /dev/null
+++ b/src/main/java/io/github/bsels/semantic/version/DependencyGraphMojo.java
@@ -0,0 +1,312 @@
+package io.github.bsels.semantic.version;
+
+import io.github.bsels.semantic.version.models.MavenArtifact;
+import io.github.bsels.semantic.version.models.MavenProjectAndDocument;
+import io.github.bsels.semantic.version.models.graph.ArtifactLocation;
+import io.github.bsels.semantic.version.models.graph.DetailedGraphNode;
+import io.github.bsels.semantic.version.parameters.GraphOutput;
+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.Parameter;
+import org.apache.maven.plugins.annotations.ResolutionScope;
+import org.apache.maven.project.MavenProject;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/// Represents a Maven Mojo goal for generating a dependency graph of Maven projects within the current execution scope.
+/// This goal facilitates the extraction, transformation, and representation of dependency relationships among Maven
+/// project artifacts and supports producing the graph output in a configurable format.
+///
+/// This Mojo is typically used during Maven builds to analyze project dependencies and produce insights
+/// into the structure of the dependency tree, which may assist in understanding transitive dependencies,
+/// resolving conflicts, or debugging build issues.
+///
+/// The generated dependency graph can include both direct and transitive dependencies and is represented
+/// as directed graph nodes, where each node corresponds to a Maven project artifact.
+///
+/// The final graph representation, including its format and location, is configurable via parameters.
+@Mojo(name = "graph", aggregator = true, requiresDependencyResolution = ResolutionScope.NONE)
+@Execute(phase = LifecyclePhase.NONE)
+public final class DependencyGraphMojo extends BaseMojo {
+
+ /// Indicates whether project folder paths should be resolved as relative paths with respect to
+ /// the execution root directory.
+ /// If set to `true`, the project paths will be returned as relative paths.
+ /// If set to `false`, absolute paths will be used instead.
+ /// This parameter is required and defaults to `true`.
+ ///
+ /// Configurable via the Maven property `versioning.relativePaths`.
+ @Parameter(property = "versioning.relativePaths", required = true, defaultValue = "true")
+ boolean useRelativePaths = true;
+
+ /// Specifies the format in which the dependency graph will be produced.
+ ///
+ /// This variable determines whether the dependency graph output includes:
+ /// - Only Maven artifact data ([GraphOutput#ARTIFACT_ONLY]), format:
+ /// ```json
+ /// {
+ /// "{groupId}:{artifactId}": ["{groupId}:{artifactId}",...],
+ /// ...
+ /// }
+ /// ```
+ /// - Only the folder paths of the projects ([GraphOutput#FOLDER_ONLY]), format
+ /// ```json
+ /// {
+ /// "{groupId}:{artifactId}": ["{folder}",...],
+ /// ...
+ /// }
+ /// ```
+ /// - Both Maven artifact data and folder paths ([GraphOutput#ARTIFACT_AND_FOLDER]), format
+ /// ```json
+ /// {
+ /// "{groupId}:{artifactId}": [
+ /// {
+ /// "artifact": {
+ /// "groupId": "{groupId}",
+ /// "artifactId": "{artifactId}"
+ /// },
+ /// "folder": "{folder}"
+ /// },...
+ /// ],
+ /// ...
+ /// }
+ /// ```
+ ///
+ /// The value of this field is required and defaults to [GraphOutput#ARTIFACT_AND_FOLDER].
+ /// It is set via the Maven plugin parameter `versioning.graphOutput`.
+ @Parameter(property = "versioning.graphOutput", required = true, defaultValue = "ARTIFACT_AND_FOLDER")
+ GraphOutput graphOutput = GraphOutput.ARTIFACT_AND_FOLDER;
+
+ /// Specifies the output file for the generated dependency graph.
+ ///
+ /// If this value is set to `null`,
+ /// the dependency graph output will be printed to the console instead of being written to an external file.
+ ///
+ /// Uses the Maven property `versioning.outputFile` to allow configuration via Maven's usage of properties
+ /// or command-line arguments.
+ @Parameter(property = "versioning.outputFile")
+ Path outputFile = null;
+
+ /// Default constructor for the `DependencyGraphMojo` class.
+ /// Initializes an instance by invoking the superclass constructor.
+ ///
+ /// This constructor is typically used by the Maven framework during the build process to instantiate
+ /// the goal implementation, enabling the execution of its logic.
+ public DependencyGraphMojo() {
+ super();
+ }
+
+ /// Executes the internal logic for creating a dependency graph representation of Maven projects in the current scope.
+ /// This method performs the following steps:
+ /// 1. Resolves the execution root directory and logs it for debugging.
+ /// 2. Retrieves all Maven projects in the current execution scope.
+ /// 3. Maps Maven projects to their corresponding artifacts and project folder paths.
+ /// 4. Parses and reads all POM files for the projects, creating a mapping of Maven artifacts to their parsed documents.
+ /// 5. Creates a dependency mapping for artifacts, associating project artifacts with their dependent artifacts.
+ /// 6. Resolves transitive dependencies for each project artifact and creates a mapping of project artifacts to their resolved dependencies.
+ /// 7. Prepares a directed graph representation of Maven project artifacts (`Node` instances) using the resolved dependency data.
+ /// 8. Produces the output of the dependency graph.
+ ///
+ /// @throws MojoExecutionException if an unexpected error occurs during execution.
+ /// @throws MojoFailureException if the execution fails due to inconsistent or invalid Maven project data.
+ @Override
+ protected void internalExecute() throws MojoExecutionException, MojoFailureException {
+ Path executionRootDirectory = Path.of(session.getExecutionRootDirectory())
+ .toAbsolutePath();
+ getLog().debug("Execution root directory: %s".formatted(executionRootDirectory));
+ List projectsInScope = getProjectsInScope().toList();
+ Map projectArtifacts = projectsInScope.stream()
+ .collect(Collectors.toMap(
+ Utils::mavenProjectToArtifact,
+ project -> getProjectFolderAsString(project, executionRootDirectory)
+ ));
+
+ Map documents = readAllPoms(projectsInScope);
+ Map> dependencyToProjectArtifactMapping =
+ createDependencyToProjectArtifactMapping(documents.values(), projectArtifacts.keySet());
+
+ Map> projectToDependenciesMapping = projectArtifacts.keySet()
+ .stream()
+ .collect(Collectors.toMap(
+ Function.identity(),
+ artifact -> collectProjectDependencies(artifact, dependencyToProjectArtifactMapping)
+ ));
+
+ Map graph = projectArtifacts.keySet()
+ .stream()
+ .collect(Collectors.toMap(
+ Function.identity(),
+ project -> prepareMavenProjectNode(project, projectToDependenciesMapping, projectArtifacts)
+ ));
+
+ produceGraphOutput(graph);
+ }
+
+ /// Produces the output of the dependency graph for Maven artifacts based on the specified graph output type.
+ /// The method transforms the given dependency graph into a format determined by the output configuration
+ /// (artifact-only, folder-only, or a combination of both) and serializes the resulting data as JSON.
+ /// The JSON output is either written to a file or printed to the console.
+ ///
+ /// @param graph a mapping of Maven artifacts to their corresponding detailed graph nodes, representing the dependency relationships between the artifacts
+ /// @throws MojoExecutionException if an error occurs while transforming the graph or writing the output to a file
+ private void produceGraphOutput(Map graph) throws MojoExecutionException {
+ Function mapper = Object::toString;
+ Object output = switch (graphOutput) {
+ case ARTIFACT_ONLY -> transformGraph(graph, mapper.compose(ArtifactLocation::artifact));
+ case FOLDER_ONLY -> transformGraph(graph, ArtifactLocation::folder);
+ case ARTIFACT_AND_FOLDER -> transformGraph(graph, Function.identity());
+ };
+
+ String objectAsJson = Utils.writeObjectAsJson(output);
+ if (outputFile != null) {
+ try (BufferedWriter writer = Files.newBufferedWriter(
+ outputFile,
+ StandardCharsets.UTF_8,
+ StandardOpenOption.CREATE,
+ StandardOpenOption.WRITE,
+ StandardOpenOption.TRUNCATE_EXISTING
+ )) {
+ writer.write(objectAsJson);
+ } catch (IOException e) {
+ throw new MojoExecutionException("Unable to write to output file '%s'".formatted(outputFile), e);
+ }
+ } else {
+ System.out.println(objectAsJson);
+ }
+ }
+
+ /// Transforms a dependency graph of Maven artifacts into a new mapping where the dependencies
+ /// of each artifact are processed using the provided mapping function.
+ ///
+ /// @param the target type of the transformation
+ /// @param graph a mapping of Maven artifacts to their detailed graph nodes, representing dependency relationships between artifacts
+ /// @param mapper a function that maps artifact locations (dependencies) to the desired target type
+ /// @return a transformed mapping of Maven artifacts to lists of processed dependencies where each dependency is mapped using the provided function
+ private Map> transformGraph(
+ Map graph,
+ Function mapper
+ ) {
+ return graph.entrySet()
+ .stream()
+ .collect(Collectors.toMap(
+ Map.Entry::getKey,
+ entry -> entry.getValue()
+ .dependencies()
+ .stream()
+ .map(mapper)
+ .toList()
+ ));
+ }
+
+ /// Resolves and collects all Maven artifacts that are dependent on the specified artifact within the provided
+ /// dependency mapping, including transitive dependencies.
+ ///
+ /// This method performs a depth-first traversal to find all direct and transitive dependencies
+ /// of the given artifact. The dependencies are returned in topological order (build order),
+ /// where dependencies that need to be built first appear earlier in the list.
+ ///
+ /// @param artifact the Maven artifact whose dependents need to be collected
+ /// @param dependencyToProjectArtifactMapping a mapping that associates project artifacts with their dependencies
+ /// @return a list of Maven artifacts that depend on the specified artifact, sorted in build order
+ private List collectProjectDependencies(
+ MavenArtifact artifact,
+ Map> dependencyToProjectArtifactMapping
+ ) {
+ Set visited = new HashSet<>();
+ List result = new ArrayList<>();
+ collectTransitiveDependencies(artifact, dependencyToProjectArtifactMapping, visited, result);
+ return List.copyOf(result);
+ }
+
+ /// Recursively collects transitive dependencies using depth-first search.
+ /// Dependencies are added to the result list in post-order (dependencies before dependents),
+ /// which ensures topological ordering for build purposes.
+ ///
+ /// @param artifact the current artifact being processed
+ /// @param dependencyToProjectArtifactMapping a mapping that associates project artifacts with their dependencies
+ /// @param visited set of already visited artifacts to avoid cycles
+ /// @param result list to accumulate dependencies in topological order
+ private void collectTransitiveDependencies(
+ MavenArtifact artifact,
+ Map> dependencyToProjectArtifactMapping,
+ Set visited,
+ List result
+ ) {
+ if (visited.contains(artifact)) {
+ return;
+ }
+ visited.add(artifact);
+
+ // Find all direct dependencies of this artifact
+ List directDependencies = dependencyToProjectArtifactMapping.entrySet()
+ .stream()
+ .filter(entry -> entry.getValue().contains(artifact))
+ .map(Map.Entry::getKey)
+ .toList();
+
+ // Recursively process each direct dependency
+ for (MavenArtifact dependency : directDependencies) {
+ collectTransitiveDependencies(dependency, dependencyToProjectArtifactMapping, visited, result);
+ }
+
+ // Add the current artifact after its dependencies (post-order)
+ result.add(artifact);
+ }
+
+ /// Returns the project folder path as a string.
+ /// If relative paths are enabled, the path is returned relative to the specified execution root directory;
+ /// otherwise, the absolute path of the project is returned.
+ ///
+ /// @param project the [MavenProject] instance representing the current project
+ /// @param executionRootDirectory the root directory of the current execution context
+ /// @return the project folder path as a string, either relative to the execution root directory or as an absolute path
+ private String getProjectFolderAsString(MavenProject project, Path executionRootDirectory) {
+ Path projectBasePath = project.getBasedir().toPath().toAbsolutePath();
+ if (!useRelativePaths) {
+ return projectBasePath.toString();
+ }
+ String relativePath = executionRootDirectory.relativize(projectBasePath).toString();
+ if (relativePath.isBlank()) {
+ return ".";
+ }
+ return relativePath;
+ }
+
+ /// Prepares a `Node` representation of a Maven project artifact, including its associated
+ /// folder path and its minimal dependencies as `MinDependency` instances.
+ ///
+ /// @param artifact the Maven artifact for which the `Node` is to be prepared; must not be null
+ /// @param projectToDependenciesMapping a mapping of Maven projects to their respective dependencies; used to determine the dependencies of the provided artifact; must not be null
+ /// @param projectArtifacts a mapping of Maven artifacts to their corresponding folder paths; must not be null
+ /// @return a `Node` instance representing the given artifact, its folder path, and its resolved dependencies
+ private DetailedGraphNode prepareMavenProjectNode(
+ MavenArtifact artifact,
+ Map> projectToDependenciesMapping,
+ Map projectArtifacts
+ ) {
+ List minDependencies = projectToDependenciesMapping.getOrDefault(artifact, List.of()).stream()
+ .map(depArtifact -> new ArtifactLocation(depArtifact, projectArtifacts.get(depArtifact)))
+ .toList();
+ return new DetailedGraphNode(
+ artifact,
+ projectArtifacts.get(artifact),
+ minDependencies
+ );
+ }
+}
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 1001fb6..681426c 100644
--- a/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java
+++ b/src/main/java/io/github/bsels/semantic/version/UpdatePomMojo.java
@@ -311,7 +311,7 @@ private int handleSingleProject(MarkdownMapping markdownMapping, MavenProject pr
throws MojoExecutionException, MojoFailureException {
Path pom = project.getFile()
.toPath();
- MavenArtifact artifact = new MavenArtifact(project.getGroupId(), project.getArtifactId());
+ MavenArtifact artifact = Utils.mavenProjectToArtifact(project);
Document document = POMUtils.readPom(pom);
diff --git a/src/main/java/io/github/bsels/semantic/version/VerifyMojo.java b/src/main/java/io/github/bsels/semantic/version/VerifyMojo.java
index cfc3a75..b7eccfa 100644
--- a/src/main/java/io/github/bsels/semantic/version/VerifyMojo.java
+++ b/src/main/java/io/github/bsels/semantic/version/VerifyMojo.java
@@ -7,6 +7,7 @@
import io.github.bsels.semantic.version.parameters.Git;
import io.github.bsels.semantic.version.parameters.VerificationMode;
import io.github.bsels.semantic.version.utils.ProcessUtils;
+import io.github.bsels.semantic.version.utils.Utils;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugin.MojoFailureException;
import org.apache.maven.plugin.logging.Log;
@@ -15,6 +16,7 @@
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.plugins.annotations.ResolutionScope;
+import org.apache.maven.project.MavenProject;
import java.util.ArrayDeque;
import java.util.HashSet;
@@ -124,7 +126,7 @@ protected void internalExecute() throws MojoExecutionException, MojoFailureExcep
}
Set projects = getProjectsInScope()
- .map(project -> new MavenArtifact(project.getGroupId(), project.getArtifactId()))
+ .map(Utils::mavenProjectToArtifact)
.collect(Collectors.toSet());
List versionMarkdowns = getVersionMarkdowns();
diff --git a/src/main/java/io/github/bsels/semantic/version/models/graph/ArtifactLocation.java b/src/main/java/io/github/bsels/semantic/version/models/graph/ArtifactLocation.java
new file mode 100644
index 0000000..623c16f
--- /dev/null
+++ b/src/main/java/io/github/bsels/semantic/version/models/graph/ArtifactLocation.java
@@ -0,0 +1,25 @@
+package io.github.bsels.semantic.version.models.graph;
+
+import io.github.bsels.semantic.version.models.MavenArtifact;
+
+import java.util.Objects;
+
+/// Represents the physical location of a Maven artifact within the project structure.
+///
+/// This record associates a [MavenArtifact] with its corresponding folder path,
+/// which can be either absolute or relative depending on the plugin configuration.
+///
+/// @param artifact the Maven artifact; must not be null
+/// @param folder the folder path where the artifact's project is located; must not be null
+public record ArtifactLocation(MavenArtifact artifact, String folder) {
+
+ /// Constructs a new `ArtifactLocation` and validates that its components are non-null.
+ ///
+ /// @param artifact the Maven artifact
+ /// @param folder the folder path
+ /// @throws NullPointerException if `artifact` or `folder` is null
+ public ArtifactLocation {
+ Objects.requireNonNull(artifact, "`artifact` must not be null");
+ Objects.requireNonNull(folder, "`folder` must not be null");
+ }
+}
diff --git a/src/main/java/io/github/bsels/semantic/version/models/graph/DetailedGraphNode.java b/src/main/java/io/github/bsels/semantic/version/models/graph/DetailedGraphNode.java
new file mode 100644
index 0000000..df58b96
--- /dev/null
+++ b/src/main/java/io/github/bsels/semantic/version/models/graph/DetailedGraphNode.java
@@ -0,0 +1,29 @@
+package io.github.bsels.semantic.version.models.graph;
+
+import io.github.bsels.semantic.version.models.MavenArtifact;
+
+import java.util.List;
+import java.util.Objects;
+
+/// Represents a detailed node in the dependency graph, including the artifact's location and its dependencies.
+///
+/// This record contains comprehensive information about a project artifact, including its identity,
+/// its physical location, and a list of its internal project dependencies with their locations.
+///
+/// @param artifact the Maven artifact representing the project; must not be null
+/// @param folder the folder path of the project; must not be null
+/// @param dependencies the list of internal project dependencies and their locations; must not be null
+public record DetailedGraphNode(MavenArtifact artifact, String folder, List dependencies) {
+
+ /// Constructs a new `DetailedGraphNode` and validates that its components are non-null.
+ ///
+ /// @param artifact the Maven artifact
+ /// @param folder the folder path
+ /// @param dependencies the list of dependencies
+ /// @throws NullPointerException if `artifact`, `folder`, or `dependencies` is null
+ public DetailedGraphNode {
+ Objects.requireNonNull(artifact, "`artifact` must not be null");
+ Objects.requireNonNull(folder, "`folder` must not be null");
+ Objects.requireNonNull(dependencies, "`dependencies` must not be null");
+ }
+}
diff --git a/src/main/java/io/github/bsels/semantic/version/models/graph/package-info.java b/src/main/java/io/github/bsels/semantic/version/models/graph/package-info.java
new file mode 100644
index 0000000..5089c39
--- /dev/null
+++ b/src/main/java/io/github/bsels/semantic/version/models/graph/package-info.java
@@ -0,0 +1,5 @@
+/// Contains data models specifically for representing and managing the dependency graph of Maven projects.
+///
+/// These models facilitate the visualization and processing of project dependencies,
+/// including their artifact identifiers and physical folder locations.
+package io.github.bsels.semantic.version.models.graph;
\ No newline at end of file
diff --git a/src/main/java/io/github/bsels/semantic/version/models/package-info.java b/src/main/java/io/github/bsels/semantic/version/models/package-info.java
new file mode 100644
index 0000000..8c00102
--- /dev/null
+++ b/src/main/java/io/github/bsels/semantic/version/models/package-info.java
@@ -0,0 +1,5 @@
+/// Contains the core data models for the semantic version Maven plugin.
+///
+/// These models represent semantic versions, version changes, changelog entries in Markdown format, Maven artifacts,
+/// and supporting structures.
+package io.github.bsels.semantic.version.models;
\ No newline at end of file
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
index d362848..2eda729 100644
--- a/src/main/java/io/github/bsels/semantic/version/package-info.java
+++ b/src/main/java/io/github/bsels/semantic/version/package-info.java
@@ -1,2 +1,5 @@
-/// This package contains all the Mojos of the semantic version Maven plugin
+/// This package contains the Mojos (Maven plugin goals) of the semantic version Maven plugin.
+///
+/// Mojos provide the core functionality such as updating POM versions, creating version Markdown, verifying versions,
+/// and generating dependency graphs.
package io.github.bsels.semantic.version;
\ No newline at end of file
diff --git a/src/main/java/io/github/bsels/semantic/version/parameters/GraphOutput.java b/src/main/java/io/github/bsels/semantic/version/parameters/GraphOutput.java
new file mode 100644
index 0000000..0ae8f94
--- /dev/null
+++ b/src/main/java/io/github/bsels/semantic/version/parameters/GraphOutput.java
@@ -0,0 +1,14 @@
+package io.github.bsels.semantic.version.parameters;
+
+/// Specifies the format of the dependency graph output.
+///
+/// This enum defines the available output formats for the `graph` Mojo.
+/// The choice of output determines what information is included for each node in the dependency graph.
+public enum GraphOutput {
+ /// Includes only the Maven artifact identifier (groupId:artifactId).
+ ARTIFACT_ONLY,
+ /// Includes only the folder path of the project.
+ FOLDER_ONLY,
+ /// Includes both the Maven artifact identifier and the project folder path.
+ ARTIFACT_AND_FOLDER
+}
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
index ee71e13..21c47f2 100644
--- 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
@@ -1,2 +1,5 @@
-/// This package contains the necessary classes for the plugin parameters
+/// Contains parameter classes and enums used by the semantic version Maven plugin Mojos.
+///
+/// These classes define configuration options such as version bump types, git settings,
+/// verification modes, and artifact identifiers.
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/Utils.java b/src/main/java/io/github/bsels/semantic/version/utils/Utils.java
index bce271f..226cd12 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,6 +1,14 @@
package io.github.bsels.semantic.version.utils;
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import io.github.bsels.semantic.version.models.MavenArtifact;
import io.github.bsels.semantic.version.models.PlaceHolderWithType;
+import io.github.bsels.semantic.version.utils.mapper.MavenArtifactArtifactOnlySerializer;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.project.MavenProject;
@@ -58,8 +66,18 @@ public final class Utils {
/// dynamically replaceable placeholders for date and version values.
private static final Pattern PLACEHOLDER_FORMAT_EXTRACTOR = Pattern.compile("\\{(date(#([^{}]*))?|version)}");
+ /// A cached collection of DateTimeFormatter instances, where the key is a [String] representing
+ /// the date-time pattern and the value is a corresponding [DateTimeFormatter] object.
+ /// This map is used to optimize the creation of [DateTimeFormatter] objects by reusing previously created instances
+ /// for the same pattern, reducing the overhead of instantiating new formatters.
private static final Map CACHED_DATE_FORMATTERS = new HashMap<>();
+ /// A statically initialized instance of [ObjectMapper] configured with custom serializers.
+ /// This instance is designed to handle serialization of [MavenArtifact] objects,
+ /// where both a key serializer and a general serializer are registered.
+ /// The custom serializers are provided by [MavenArtifactArtifactOnlySerializer].
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
/// Utility class containing static constants and methods for various common operations.
/// This class is not designed to be instantiated.
private Utils() {
@@ -322,4 +340,26 @@ public static BinaryOperator consumerToOperator(BiConsumer
public static Collector> asImmutableSet() {
return Collectors.collectingAndThen(Collectors.toSet(), Set::copyOf);
}
+
+ /// Converts a [MavenProject] instance into a [MavenArtifact] instance.
+ ///
+ /// @param project the [MavenProject] to be converted; must provide valid group ID and artifact ID.
+ /// @return a [MavenArtifact] instance containing the group ID and artifact ID from the provided [MavenProject].
+ public static MavenArtifact mavenProjectToArtifact(MavenProject project) {
+ return new MavenArtifact(project.getGroupId(), project.getArtifactId());
+ }
+
+ /// Serializes the given object into a JSON-formatted string.
+ ///
+ /// @param object the object to be serialized into JSON
+ /// @return a JSON-formatted string representation of the given object
+ /// @throws MojoExecutionException if an error occurs during serialization
+ public static String writeObjectAsJson(Object object) throws MojoExecutionException {
+ try {
+ return OBJECT_MAPPER.writerWithDefaultPrettyPrinter()
+ .writeValueAsString(object);
+ } catch (JsonProcessingException e) {
+ throw new MojoExecutionException("Failed to serialize object to JSON", e);
+ }
+ }
}
diff --git a/src/main/java/io/github/bsels/semantic/version/utils/mapper/MavenArtifactArtifactOnlySerializer.java b/src/main/java/io/github/bsels/semantic/version/utils/mapper/MavenArtifactArtifactOnlySerializer.java
index e78d282..6cdf8bf 100644
--- a/src/main/java/io/github/bsels/semantic/version/utils/mapper/MavenArtifactArtifactOnlySerializer.java
+++ b/src/main/java/io/github/bsels/semantic/version/utils/mapper/MavenArtifactArtifactOnlySerializer.java
@@ -1,6 +1,7 @@
package io.github.bsels.semantic.version.utils.mapper;
import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonStreamContext;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import io.github.bsels.semantic.version.models.MavenArtifact;
@@ -47,7 +48,8 @@ public void serialize(
JsonGenerator jsonGenerator,
SerializerProvider serializerProvider
) throws IOException {
- if (jsonGenerator.getOutputContext().hasCurrentName()) {
+ JsonStreamContext outputContext = jsonGenerator.getOutputContext();
+ if (outputContext.hasCurrentName() || outputContext.getParent() == null) {
jsonGenerator.writeString(mavenArtifact.artifactId());
} else {
jsonGenerator.writeFieldName(mavenArtifact.artifactId());
diff --git a/src/main/java/io/github/bsels/semantic/version/utils/mapper/package-info.java b/src/main/java/io/github/bsels/semantic/version/utils/mapper/package-info.java
index dc01e84..8523b50 100644
--- a/src/main/java/io/github/bsels/semantic/version/utils/mapper/package-info.java
+++ b/src/main/java/io/github/bsels/semantic/version/utils/mapper/package-info.java
@@ -1,2 +1,5 @@
-/// Custom serialization and deserialization utilities for the Semantic Versioning plugin.
+/// Custom Jackson serializers and deserializers for the semantic version Maven plugin.
+///
+/// Primarily handles specialized serialization of [io.github.bsels.semantic.version.models.MavenArtifact]
+/// for JSON processing, supporting artifact-only keys and full representations.
package io.github.bsels.semantic.version.utils.mapper;
\ No newline at end of file
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 0c356b0..160d920 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,8 @@
-/// This package contains the utils class for processing POM files, Markdown files, Terminal interaction, and other utility functions.
+/// Utility classes for various tasks in the semantic version Maven plugin.
+///
+/// Includes processing of POM and Markdown files, terminal interactions, process execution,
+/// and general helper functions.
+///
+/// @see io.github.bsels.semantic.version.utils.mapper for serialization utils
+/// @see io.github.bsels.semantic.version.utils.yaml.front.block for YAML front matter
package io.github.bsels.semantic.version.utils;
\ No newline at end of file
diff --git a/src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/package-info.java b/src/main/java/io/github/bsels/semantic/version/utils/yaml/front/block/package-info.java
index 10637ba..860476f 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,5 @@
-/// This package contains the YAML Front Matter Block Parser and Renderer for the extension with CommonMark.
+/// YAML Front Matter block parser and renderer for CommonMark Markdown extension.
+///
+/// Enables parsing and rendering of YAML front matter blocks in Markdown documents,
+/// used for changelog and version metadata extraction.
package io.github.bsels.semantic.version.utils.yaml.front.block;
\ No newline at end of file
diff --git a/src/test/java/io/github/bsels/semantic/version/DependencyGraphMojoTest.java b/src/test/java/io/github/bsels/semantic/version/DependencyGraphMojoTest.java
new file mode 100644
index 0000000..66b30dd
--- /dev/null
+++ b/src/test/java/io/github/bsels/semantic/version/DependencyGraphMojoTest.java
@@ -0,0 +1,755 @@
+package io.github.bsels.semantic.version;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.github.bsels.semantic.version.models.MavenArtifact;
+import io.github.bsels.semantic.version.models.graph.ArtifactLocation;
+import io.github.bsels.semantic.version.parameters.GraphOutput;
+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.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.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.PrintStream;
+import java.io.StringWriter;
+import java.nio.file.Files;
+import java.nio.file.OpenOption;
+import java.nio.file.Path;
+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.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatNoException;
+
+@ExtendWith(MockitoExtension.class)
+class DependencyGraphMojoTest extends AbstractBaseMojoTest {
+
+ private DependencyGraphMojo classUnderTest;
+ private TestLog testLog;
+ private Map mockedOutputFiles;
+ private MockedStatic filesMockedStatic;
+ private ByteArrayOutputStream outputStream;
+ private PrintStream originalSystemOut;
+ private ObjectMapper objectMapper;
+
+ @BeforeEach
+ void setUp() {
+ classUnderTest = new DependencyGraphMojo();
+ testLog = new TestLog(TestLog.LogLevel.NONE);
+ classUnderTest.setLog(testLog);
+ mockedOutputFiles = new HashMap<>();
+ objectMapper = new ObjectMapper();
+
+ 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));
+ });
+
+ outputStream = new ByteArrayOutputStream();
+ originalSystemOut = System.out;
+ System.setOut(new PrintStream(outputStream));
+ }
+
+ @AfterEach
+ void tearDown() {
+ System.setOut(originalSystemOut);
+ filesMockedStatic.close();
+ }
+
+ @Nested
+ class SingleProjectTests {
+
+ @Test
+ void internalExecute_SingleProject_ArtifactAndFolder_RelativePaths_Console() throws Exception {
+ // Arrange
+ Path projectRoot = getResourcesPath("single");
+ classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession(projectRoot, Path.of("."));
+ classUnderTest.graphOutput = GraphOutput.ARTIFACT_AND_FOLDER;
+ classUnderTest.useRelativePaths = true;
+ classUnderTest.outputFile = null;
+
+ // Act
+ classUnderTest.internalExecute();
+
+ // Assert
+ String output = outputStream.toString().trim();
+ assertThat(output).isNotEmpty();
+
+ Map> graph = objectMapper.readValue(
+ output,
+ new TypeReference<>() {
+ }
+ );
+
+ assertThat(graph).hasSize(1);
+ MavenArtifact singleArtifact = new MavenArtifact("org.example.itests.single", "project");
+ assertThat(graph).containsKey(singleArtifact);
+ assertThat(graph.get(singleArtifact)).hasSize(1);
+ assertThat(mockedOutputFiles).isEmpty();
+ }
+
+ @Test
+ void internalExecute_SingleProject_ArtifactOnly_RelativePaths_Console() throws Exception {
+ // Arrange
+ Path projectRoot = getResourcesPath("single");
+ classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession(projectRoot, Path.of("."));
+ classUnderTest.graphOutput = GraphOutput.ARTIFACT_ONLY;
+ classUnderTest.useRelativePaths = true;
+ classUnderTest.outputFile = null;
+
+ // Act
+ classUnderTest.internalExecute();
+
+ // Assert
+ String output = outputStream.toString().trim();
+ assertThat(output).isNotEmpty();
+
+ Map> graph = objectMapper.readValue(
+ output,
+ new TypeReference<>() {
+ }
+ );
+
+ assertThat(graph).hasSize(1);
+ MavenArtifact singleArtifact = new MavenArtifact("org.example.itests.single", "project");
+ assertThat(graph).containsKey(singleArtifact);
+ assertThat(graph.get(singleArtifact)).hasSize(1);
+ assertThat(mockedOutputFiles).isEmpty();
+ }
+
+ @Test
+ void internalExecute_SingleProject_FolderOnly_RelativePaths_Console() throws Exception {
+ // Arrange
+ Path projectRoot = getResourcesPath("single");
+ classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession(projectRoot, Path.of("."));
+ classUnderTest.graphOutput = GraphOutput.FOLDER_ONLY;
+ classUnderTest.useRelativePaths = true;
+ classUnderTest.outputFile = null;
+
+ // Act
+ classUnderTest.internalExecute();
+
+ // Assert
+ String output = outputStream.toString().trim();
+ assertThat(output).isNotEmpty();
+
+ Map> graph = objectMapper.readValue(
+ output,
+ new TypeReference<>() {
+ }
+ );
+
+ assertThat(graph).hasSize(1);
+ MavenArtifact singleArtifact = new MavenArtifact("org.example.itests.single", "project");
+ assertThat(graph).containsKey(singleArtifact);
+ assertThat(graph.get(singleArtifact)).hasSize(1);
+ assertThat(mockedOutputFiles).isEmpty();
+ }
+
+ @Test
+ void internalExecute_SingleProject_AbsolutePaths_Console() throws Exception {
+ // Arrange
+ Path projectRoot = getResourcesPath("single");
+ classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession(projectRoot, Path.of("."));
+ classUnderTest.graphOutput = GraphOutput.ARTIFACT_AND_FOLDER;
+ classUnderTest.useRelativePaths = false;
+ classUnderTest.outputFile = null;
+
+ // Act
+ classUnderTest.internalExecute();
+
+ // Assert
+ String output = outputStream.toString().trim();
+ assertThat(output).isNotEmpty();
+
+ Map> graph = objectMapper.readValue(
+ output,
+ new TypeReference<>() {
+ }
+ );
+
+ assertThat(graph).hasSize(1);
+ MavenArtifact singleArtifact = new MavenArtifact("org.example.itests.single", "project");
+ assertThat(graph).containsKey(singleArtifact);
+ assertThat(graph.get(singleArtifact)).hasSize(1);
+ assertThat(mockedOutputFiles).isEmpty();
+ }
+
+ @Test
+ void internalExecute_SingleProject_WriteToFile() throws Exception {
+ // Arrange
+ Path projectRoot = getResourcesPath("single");
+ Path outputFile = Path.of("/tmp/graph-output.json");
+ classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession(projectRoot, Path.of("."));
+ classUnderTest.graphOutput = GraphOutput.ARTIFACT_AND_FOLDER;
+ classUnderTest.useRelativePaths = true;
+ classUnderTest.outputFile = outputFile;
+
+ // Act
+ classUnderTest.internalExecute();
+
+ // Assert
+ assertThat(mockedOutputFiles).containsKey(outputFile);
+ String fileContent = mockedOutputFiles.get(outputFile).toString();
+ assertThat(fileContent).isNotEmpty();
+
+ Map> graph = objectMapper.readValue(
+ fileContent,
+ new TypeReference<>() {
+ }
+ );
+
+ assertThat(graph).hasSize(1);
+ MavenArtifact singleArtifact = new MavenArtifact("org.example.itests.single", "project");
+ assertThat(graph).containsKey(singleArtifact);
+ assertThat(outputStream.toString()).isEmpty();
+ }
+ }
+
+ @Nested
+ class MultiProjectTests {
+
+ @Test
+ void internalExecute_MultiProject_ArtifactAndFolder_RelativePaths_Console() throws Exception {
+ // Arrange
+ Path projectRoot = getResourcesPath("multi");
+ classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession(projectRoot, Path.of("."));
+ classUnderTest.graphOutput = GraphOutput.ARTIFACT_AND_FOLDER;
+ classUnderTest.useRelativePaths = true;
+ classUnderTest.outputFile = null;
+
+ // Act
+ classUnderTest.internalExecute();
+
+ // Assert
+ String output = outputStream.toString().trim();
+ assertThat(output).isNotEmpty();
+
+ Map> graph = objectMapper.readValue(
+ output,
+ new TypeReference<>() {
+ }
+ );
+
+ // Verify all projects are present
+ assertThat(graph).containsKeys(
+ new MavenArtifact("org.example.itests.multi", "parent"),
+ new MavenArtifact("org.example.itests.multi", "dependency"),
+ new MavenArtifact("org.example.itests.multi", "plugin"),
+ new MavenArtifact("org.example.itests.multi", "plugin-management"),
+ new MavenArtifact("org.example.itests.multi", "dependency-management"),
+ new MavenArtifact("org.example.itests.multi", "combination"),
+ new MavenArtifact("org.example.itests.multi", "excluded")
+ );
+
+ // Verify transitive dependencies for combination
+ MavenArtifact combination = new MavenArtifact("org.example.itests.multi", "combination");
+ List combinationDeps = graph.get(combination);
+ assertThat(combinationDeps).hasSize(6);
+
+ // Verify dependencies are in build order (topological sort)
+ List depArtifacts = combinationDeps.stream()
+ .map(ArtifactLocation::artifact)
+ .toList();
+
+ assertThat(depArtifacts).containsExactlyInAnyOrder(
+ new MavenArtifact("org.example.itests.multi", "parent"),
+ new MavenArtifact("org.example.itests.multi", "dependency-management"),
+ new MavenArtifact("org.example.itests.multi", "plugin-management"),
+ new MavenArtifact("org.example.itests.multi", "plugin"),
+ new MavenArtifact("org.example.itests.multi", "dependency"),
+ new MavenArtifact("org.example.itests.multi", "combination")
+ );
+
+ assertThat(mockedOutputFiles).isEmpty();
+ }
+
+ @Test
+ void internalExecute_MultiProject_ArtifactOnly_Console() throws Exception {
+ // Arrange
+ Path projectRoot = getResourcesPath("multi");
+ classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession(projectRoot, Path.of("."));
+ classUnderTest.graphOutput = GraphOutput.ARTIFACT_ONLY;
+ classUnderTest.useRelativePaths = true;
+ classUnderTest.outputFile = null;
+
+ // Act
+ classUnderTest.internalExecute();
+
+ // Assert
+ String output = outputStream.toString().trim();
+ assertThat(output).isNotEmpty();
+
+ Map> graph = objectMapper.readValue(
+ output,
+ new TypeReference<>() {
+ }
+ );
+
+ assertThat(graph).hasSize(7);
+
+ // Verify combination has correct dependencies as strings
+ MavenArtifact combination = new MavenArtifact("org.example.itests.multi", "combination");
+ List combinationDeps = graph.get(combination);
+ assertThat(combinationDeps).containsExactlyInAnyOrder(
+ "org.example.itests.multi:parent",
+ "org.example.itests.multi:dependency-management",
+ "org.example.itests.multi:plugin-management",
+ "org.example.itests.multi:plugin",
+ "org.example.itests.multi:dependency",
+ "org.example.itests.multi:combination"
+ );
+ }
+
+ @Test
+ void internalExecute_MultiProject_FolderOnly_Console() throws Exception {
+ // Arrange
+ Path projectRoot = getResourcesPath("multi");
+ classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession(projectRoot, Path.of("."));
+ classUnderTest.graphOutput = GraphOutput.FOLDER_ONLY;
+ classUnderTest.useRelativePaths = true;
+ classUnderTest.outputFile = null;
+
+ // Act
+ classUnderTest.internalExecute();
+
+ // Assert
+ String output = outputStream.toString().trim();
+ assertThat(output).isNotEmpty();
+
+ Map> graph = objectMapper.readValue(
+ output,
+ new TypeReference<>() {
+ }
+ );
+
+ assertThat(graph).hasSize(7);
+
+ // Verify combination has correct folder paths
+ MavenArtifact combination = new MavenArtifact("org.example.itests.multi", "combination");
+ List combinationFolders = graph.get(combination);
+ assertThat(combinationFolders).hasSize(6);
+ assertThat(combinationFolders).allMatch(folder -> !folder.startsWith("/"));
+ }
+
+ @Test
+ void internalExecute_MultiProject_AbsolutePaths_Console() throws Exception {
+ // Arrange
+ Path projectRoot = getResourcesPath("multi");
+ classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession(projectRoot, Path.of("."));
+ classUnderTest.graphOutput = GraphOutput.ARTIFACT_AND_FOLDER;
+ classUnderTest.useRelativePaths = false;
+ classUnderTest.outputFile = null;
+
+ // Act
+ classUnderTest.internalExecute();
+
+ // Assert
+ String output = outputStream.toString().trim();
+ assertThat(output).isNotEmpty();
+
+ Map> graph = objectMapper.readValue(
+ output,
+ new TypeReference<>() {
+ }
+ );
+
+ assertThat(graph).hasSize(7);
+
+ // Verify paths are absolute
+ MavenArtifact combination = new MavenArtifact("org.example.itests.multi", "combination");
+ List combinationDeps = graph.get(combination);
+ assertThat(combinationDeps).hasSize(6);
+ assertThat(combinationDeps).allMatch(dep -> dep.folder().startsWith("/"));
+ }
+
+ @Test
+ void internalExecute_MultiProject_WriteToFile() throws Exception {
+ // Arrange
+ Path projectRoot = getResourcesPath("multi");
+ Path outputFile = Path.of("/tmp/multi-graph.json");
+ classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession(projectRoot, Path.of("."));
+ classUnderTest.graphOutput = GraphOutput.ARTIFACT_AND_FOLDER;
+ classUnderTest.useRelativePaths = true;
+ classUnderTest.outputFile = outputFile;
+
+ // Act
+ classUnderTest.internalExecute();
+
+ // Assert
+ assertThat(mockedOutputFiles).containsKey(outputFile);
+ String fileContent = mockedOutputFiles.get(outputFile).toString();
+ assertThat(fileContent).isNotEmpty();
+
+ Map> graph = objectMapper.readValue(
+ fileContent,
+ new TypeReference<>() {
+ }
+ );
+
+ assertThat(graph).hasSize(7);
+ assertThat(outputStream.toString()).isEmpty();
+ }
+ }
+
+ @Nested
+ class ChainedDependencyTests {
+
+ @Test
+ void internalExecute_ChainedDependency_ArtifactAndFolder_Console() throws Exception {
+ // Arrange
+ Path projectRoot = getResourcesPath("chained-dependency");
+ classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession(projectRoot, Path.of("."));
+ classUnderTest.graphOutput = GraphOutput.ARTIFACT_AND_FOLDER;
+ classUnderTest.useRelativePaths = true;
+ classUnderTest.outputFile = null;
+
+ // Act
+ classUnderTest.internalExecute();
+
+ // Assert
+ String output = outputStream.toString().trim();
+ assertThat(output).isNotEmpty();
+
+ Map> graph = objectMapper.readValue(
+ output,
+ new TypeReference<>() {
+ }
+ );
+
+ // Verify all projects are present
+ assertThat(graph).containsKeys(
+ new MavenArtifact("org.example.itests.chained", "root"),
+ new MavenArtifact("org.example.itests.chained", "a"),
+ new MavenArtifact("org.example.itests.chained", "b"),
+ new MavenArtifact("org.example.itests.chained", "c"),
+ new MavenArtifact("org.example.itests.chained", "d"),
+ new MavenArtifact("org.example.itests.chained", "e")
+ );
+
+ // Verify chained dependencies: e -> d -> c -> b -> a
+ MavenArtifact e = new MavenArtifact("org.example.itests.chained", "e");
+ List eDeps = graph.get(e);
+ assertThat(eDeps).hasSize(5);
+ assertThat(eDeps.stream().map(ArtifactLocation::artifact)).containsExactlyInAnyOrder(
+ new MavenArtifact("org.example.itests.chained", "a"),
+ new MavenArtifact("org.example.itests.chained", "b"),
+ new MavenArtifact("org.example.itests.chained", "c"),
+ new MavenArtifact("org.example.itests.chained", "d"),
+ new MavenArtifact("org.example.itests.chained", "e")
+ );
+ }
+
+ @ParameterizedTest
+ @EnumSource(GraphOutput.class)
+ void internalExecute_ChainedDependency_AllGraphOutputs(GraphOutput graphOutput) throws Exception {
+ // Arrange
+ Path projectRoot = getResourcesPath("chained-dependency");
+ classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession(projectRoot, Path.of("."));
+ classUnderTest.graphOutput = graphOutput;
+ classUnderTest.useRelativePaths = true;
+ classUnderTest.outputFile = null;
+
+ // Act & Assert
+ assertThatNoException().isThrownBy(() -> classUnderTest.internalExecute());
+
+ String output = outputStream.toString().trim();
+ assertThat(output).isNotEmpty();
+ }
+ }
+
+ @Nested
+ class LeavesProjectTests {
+
+ @Test
+ void internalExecute_LeavesProject_ArtifactAndFolder_Console() throws Exception {
+ // Arrange
+ Path projectRoot = getResourcesPath("leaves");
+ classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession(projectRoot, Path.of("."));
+ classUnderTest.graphOutput = GraphOutput.ARTIFACT_AND_FOLDER;
+ classUnderTest.useRelativePaths = true;
+ classUnderTest.outputFile = null;
+
+ // Act
+ classUnderTest.internalExecute();
+
+ // Assert
+ String output = outputStream.toString().trim();
+ assertThat(output).isNotEmpty();
+
+ Map> graph = objectMapper.readValue(
+ output,
+ new TypeReference<>() {
+ }
+ );
+
+ // Verify all projects are present
+ assertThat(graph).containsKeys(
+ new MavenArtifact("org.example.itests.leaves", "root"),
+ new MavenArtifact("org.example.itests.leaves", "child-1"),
+ new MavenArtifact("org.example.itests.leaves", "intermediate"),
+ new MavenArtifact("org.example.itests.leaves", "child-2"),
+ new MavenArtifact("org.example.itests.leaves", "child-3")
+ );
+ }
+ }
+
+ @Nested
+ class MultiRecursiveProjectTests {
+
+ @Test
+ void internalExecute_MultiRecursiveProject_ArtifactAndFolder_Console() throws Exception {
+ // Arrange
+ Path projectRoot = getResourcesPath("multi-recursive");
+ classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession(projectRoot, Path.of("."));
+ classUnderTest.graphOutput = GraphOutput.ARTIFACT_AND_FOLDER;
+ classUnderTest.useRelativePaths = true;
+ classUnderTest.outputFile = null;
+
+ // Act
+ classUnderTest.internalExecute();
+
+ // Assert
+ String output = outputStream.toString().trim();
+ assertThat(output).isNotEmpty();
+
+ Map> graph = objectMapper.readValue(
+ output,
+ new TypeReference<>() {
+ }
+ );
+
+ // Verify all projects are present
+ assertThat(graph).containsKeys(
+ new MavenArtifact("org.example.itests.multi-recursive", "parent"),
+ new MavenArtifact("org.example.itests.multi-recursive", "child-1"),
+ new MavenArtifact("org.example.itests.multi-recursive", "child-2")
+ );
+ }
+ }
+
+ @Nested
+ class RevisionProjectTests {
+
+ @Test
+ void internalExecute_RevisionMultiProject_ArtifactAndFolder_Console() throws Exception {
+ // Arrange
+ Path projectRoot = getResourcesPath("revision", "multi");
+ classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession(projectRoot, Path.of("."));
+ classUnderTest.graphOutput = GraphOutput.ARTIFACT_AND_FOLDER;
+ classUnderTest.useRelativePaths = true;
+ classUnderTest.outputFile = null;
+
+ // Act
+ classUnderTest.internalExecute();
+
+ // Assert
+ String output = outputStream.toString().trim();
+ assertThat(output).isNotEmpty();
+
+ Map> graph = objectMapper.readValue(
+ output,
+ new TypeReference<>() {
+ }
+ );
+
+ // Verify all projects are present
+ assertThat(graph).containsKeys(
+ new MavenArtifact("org.example.itests.revision.multi", "parent"),
+ new MavenArtifact("org.example.itests.revision.multi", "child1"),
+ new MavenArtifact("org.example.itests.revision.multi", "child2")
+ );
+ }
+
+ @Test
+ void internalExecute_RevisionSingleProject_ArtifactAndFolder_Console() throws Exception {
+ // Arrange
+ Path projectRoot = getResourcesPath("revision", "single");
+ classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession(projectRoot, Path.of("."));
+ classUnderTest.graphOutput = GraphOutput.ARTIFACT_AND_FOLDER;
+ classUnderTest.useRelativePaths = true;
+ classUnderTest.outputFile = null;
+
+ // Act
+ classUnderTest.internalExecute();
+
+ // Assert
+ String output = outputStream.toString().trim();
+ assertThat(output).isNotEmpty();
+
+ Map> graph = objectMapper.readValue(
+ output,
+ new TypeReference<>() {
+ }
+ );
+
+ assertThat(graph).hasSize(1);
+ assertThat(graph).containsKey(
+ new MavenArtifact("org.example.itests.revision.single", "project")
+ );
+ }
+ }
+
+ @Nested
+ class ExceptionTests {
+
+ @Test
+ void internalExecute_IoExceptionWhenWritingFile_ThrowsMojoExecutionException() {
+ // Arrange
+ Path projectRoot = getResourcesPath("single");
+ Path outputFile = Path.of("/tmp/failing-output.json");
+ classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession(projectRoot, Path.of("."));
+ classUnderTest.graphOutput = GraphOutput.ARTIFACT_AND_FOLDER;
+ classUnderTest.useRelativePaths = true;
+ classUnderTest.outputFile = outputFile;
+
+ // Mock IOException when writing to file
+ filesMockedStatic.when(() -> Files.newBufferedWriter(Mockito.eq(outputFile), Mockito.any(), Mockito.any(OpenOption[].class)))
+ .thenThrow(new IOException("Simulated IO error"));
+
+ // Act & Assert
+ assertThatExceptionOfType(MojoExecutionException.class)
+ .isThrownBy(() -> classUnderTest.internalExecute())
+ .withMessageContaining("Unable to write to output file")
+ .withMessageContaining("/tmp/failing-output.json")
+ .withCauseInstanceOf(IOException.class);
+ }
+
+ @Test
+ void internalExecute_IoExceptionDuringWrite_ThrowsMojoExecutionException() throws Exception {
+ // Arrange
+ Path projectRoot = getResourcesPath("single");
+ Path outputFile = Path.of("/tmp/write-error.json");
+ classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession(projectRoot, Path.of("."));
+ classUnderTest.graphOutput = GraphOutput.ARTIFACT_AND_FOLDER;
+ classUnderTest.useRelativePaths = true;
+ classUnderTest.outputFile = outputFile;
+
+ // Create a BufferedWriter that throws IOException on write
+ BufferedWriter failingWriter = Mockito.mock(BufferedWriter.class);
+ Mockito.doThrow(new IOException("Write failed")).when(failingWriter).write(Mockito.anyString());
+
+ filesMockedStatic.when(() -> Files.newBufferedWriter(Mockito.eq(outputFile), Mockito.any(), Mockito.any(OpenOption[].class)))
+ .thenReturn(failingWriter);
+
+ // Act & Assert
+ assertThatExceptionOfType(MojoExecutionException.class)
+ .isThrownBy(() -> classUnderTest.internalExecute())
+ .withMessageContaining("Unable to write to output file")
+ .withCauseInstanceOf(IOException.class);
+ }
+ }
+
+ @Nested
+ class EdgeCaseTests {
+
+ @Test
+ void internalExecute_RootProjectPath_UsesCurrentDirectory() throws Exception {
+ // Arrange
+ Path projectRoot = getResourcesPath("single");
+ classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession(projectRoot, Path.of("."));
+ classUnderTest.graphOutput = GraphOutput.ARTIFACT_AND_FOLDER;
+ classUnderTest.useRelativePaths = true;
+ classUnderTest.outputFile = null;
+
+ // Act
+ classUnderTest.internalExecute();
+
+ // Assert
+ String output = outputStream.toString().trim();
+ Map> graph = objectMapper.readValue(
+ output,
+ new TypeReference<>() {
+ }
+ );
+
+ // Root project should have "." as folder when using relative paths
+ assertThat(graph).hasSize(1);
+ }
+
+ @Test
+ void internalExecute_EmptyDependencies_ProducesValidGraph() throws Exception {
+ // Arrange
+ Path projectRoot = getResourcesPath("single");
+ classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession(projectRoot, Path.of("."));
+ classUnderTest.graphOutput = GraphOutput.ARTIFACT_AND_FOLDER;
+ classUnderTest.useRelativePaths = true;
+ classUnderTest.outputFile = null;
+
+ // Act
+ classUnderTest.internalExecute();
+
+ // Assert
+ String output = outputStream.toString().trim();
+ Map> graph = objectMapper.readValue(
+ output,
+ new TypeReference<>() {
+ }
+ );
+
+ MavenArtifact singleArtifact = new MavenArtifact("org.example.itests.single", "project");
+ assertThat(graph.get(singleArtifact)).hasSize(1);
+ }
+
+ @Test
+ void internalExecute_MultipleGraphOutputFormats_ProduceConsistentResults() throws Exception {
+ // Arrange
+ Path projectRoot = getResourcesPath("multi");
+
+ // Test ARTIFACT_AND_FOLDER
+ classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession(projectRoot, Path.of("."));
+ classUnderTest.graphOutput = GraphOutput.ARTIFACT_AND_FOLDER;
+ classUnderTest.useRelativePaths = true;
+ classUnderTest.outputFile = null;
+ classUnderTest.internalExecute();
+ String outputFull = outputStream.toString().trim();
+ outputStream.reset();
+
+ // Test ARTIFACT_ONLY
+ classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession(projectRoot, Path.of("."));
+ classUnderTest.graphOutput = GraphOutput.ARTIFACT_ONLY;
+ classUnderTest.internalExecute();
+ String outputArtifact = outputStream.toString().trim();
+ outputStream.reset();
+
+ // Test FOLDER_ONLY
+ classUnderTest.session = ReadMockedMavenSession.readMockedMavenSession(projectRoot, Path.of("."));
+ classUnderTest.graphOutput = GraphOutput.FOLDER_ONLY;
+ classUnderTest.internalExecute();
+ String outputFolder = outputStream.toString().trim();
+
+ // Assert all produce valid JSON
+ assertThat(outputFull).isNotEmpty();
+ assertThat(outputArtifact).isNotEmpty();
+ assertThat(outputFolder).isNotEmpty();
+
+ // Verify they all have the same number of keys
+ Map graphFull = objectMapper.readValue(outputFull, new TypeReference<>() {
+ });
+ Map graphArtifact = objectMapper.readValue(outputArtifact, new TypeReference<>() {
+ });
+ Map graphFolder = objectMapper.readValue(outputFolder, new TypeReference<>() {
+ });
+
+ assertThat(graphFull).hasSameSizeAs(graphArtifact);
+ assertThat(graphFull).hasSameSizeAs(graphFolder);
+ }
+ }
+}
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 634031a..24b24c2 100644
--- a/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java
+++ b/src/test/java/io/github/bsels/semantic/version/UpdatePomMojoTest.java
@@ -1110,6 +1110,8 @@ void handleDependencyCorrect_NoErrors(
%1$s
4.1.0-%1$s
+ pom
+
dependency
dependencyManagement
@@ -1283,6 +1285,8 @@ void handleMultiRecursiveProjectCorrect_NoErrors() {
parent
6.1.0-parent
+ pom
+
child-1
child-2
@@ -1436,6 +1440,8 @@ void fixedVersionBump_Valid(VersionBump versionBump) {
parent
${revision}
+ pom
+
%s
@@ -1521,6 +1527,8 @@ void fixedVersionBumpWithBackup_Valid(VersionBump versionBump) {
parent
${revision}
+ pom
+
%s
@@ -1617,6 +1625,8 @@ void fixedVersionBumpDryRun_Valid(VersionBump versionBump) {
parent
${revision}
+ pom
+
%s
@@ -1873,6 +1883,8 @@ void singleSemanticVersionBumFile_Valid(String folder, String title, String expe
parent
${revision}
+ pom
+
%s
@@ -1998,6 +2010,8 @@ void multipleSemanticVersionBumpFiles_Valid() {
parent
${revision}
+ pom
+
4.0.0
@@ -2152,6 +2166,8 @@ void multipleSemanticVersionBumpFiles_CustomHeaders_Valid() {
parent
${revision}
+ pom
+
4.0.0
diff --git a/src/test/java/io/github/bsels/semantic/version/models/graph/ArtifactLocationTest.java b/src/test/java/io/github/bsels/semantic/version/models/graph/ArtifactLocationTest.java
new file mode 100644
index 0000000..af9ab7e
--- /dev/null
+++ b/src/test/java/io/github/bsels/semantic/version/models/graph/ArtifactLocationTest.java
@@ -0,0 +1,96 @@
+package io.github.bsels.semantic.version.models.graph;
+
+import io.github.bsels.semantic.version.models.MavenArtifact;
+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 static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+public class ArtifactLocationTest {
+ private static final String GROUP_ID = "groupId";
+ private static final String ARTIFACT_ID = "artifactId";
+ private static final String FOLDER = "folder/path";
+
+ @Nested
+ class ConstructorTest {
+
+ @ParameterizedTest
+ @CsvSource(value = {
+ "null,null,artifact",
+ "null," + FOLDER + ",artifact",
+ GROUP_ID + ":" + ARTIFACT_ID + ",null,folder"
+ }, nullValues = "null")
+ void nullInput_ThrowsNullPointerException(String artifactString, String folder, String exceptionParameter) {
+ MavenArtifact artifact = artifactString == null ? null : MavenArtifact.of(artifactString);
+ assertThatThrownBy(() -> new ArtifactLocation(artifact, folder))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("`%s` must not be null", exceptionParameter);
+ }
+
+ @Test
+ void validInputs_ReturnsCorrectArtifactLocation() {
+ MavenArtifact artifact = new MavenArtifact(GROUP_ID, ARTIFACT_ID);
+ ArtifactLocation location = new ArtifactLocation(artifact, FOLDER);
+ assertThat(location)
+ .isNotNull()
+ .hasFieldOrPropertyWithValue("artifact", artifact)
+ .hasFieldOrPropertyWithValue("folder", FOLDER);
+ }
+ }
+
+ @Nested
+ class AccessorTest {
+
+ @Test
+ void artifact_ReturnsCorrectValue() {
+ MavenArtifact artifact = new MavenArtifact(GROUP_ID, ARTIFACT_ID);
+ ArtifactLocation location = new ArtifactLocation(artifact, FOLDER);
+ assertThat(location.artifact())
+ .isEqualTo(artifact);
+ }
+
+ @Test
+ void folder_ReturnsCorrectValue() {
+ MavenArtifact artifact = new MavenArtifact(GROUP_ID, ARTIFACT_ID);
+ ArtifactLocation location = new ArtifactLocation(artifact, FOLDER);
+ assertThat(location.folder())
+ .isEqualTo(FOLDER);
+ }
+ }
+
+ @Nested
+ class EqualsAndHashCodeTest {
+
+ @Test
+ void sameValues_AreEqual() {
+ MavenArtifact artifact = new MavenArtifact(GROUP_ID, ARTIFACT_ID);
+ ArtifactLocation location1 = new ArtifactLocation(artifact, FOLDER);
+ ArtifactLocation location2 = new ArtifactLocation(artifact, FOLDER);
+ assertThat(location1)
+ .isEqualTo(location2)
+ .hasSameHashCodeAs(location2);
+ }
+
+ @Test
+ void differentArtifact_AreNotEqual() {
+ MavenArtifact artifact1 = new MavenArtifact(GROUP_ID, ARTIFACT_ID);
+ MavenArtifact artifact2 = new MavenArtifact("otherGroup", ARTIFACT_ID);
+ ArtifactLocation location1 = new ArtifactLocation(artifact1, FOLDER);
+ ArtifactLocation location2 = new ArtifactLocation(artifact2, FOLDER);
+ assertThat(location1)
+ .isNotEqualTo(location2);
+ }
+
+ @Test
+ void differentFolder_AreNotEqual() {
+ MavenArtifact artifact = new MavenArtifact(GROUP_ID, ARTIFACT_ID);
+ ArtifactLocation location1 = new ArtifactLocation(artifact, FOLDER);
+ ArtifactLocation location2 = new ArtifactLocation(artifact, "other/folder");
+ assertThat(location1)
+ .isNotEqualTo(location2);
+ }
+ }
+}
diff --git a/src/test/java/io/github/bsels/semantic/version/models/graph/DetailedGraphNodeTest.java b/src/test/java/io/github/bsels/semantic/version/models/graph/DetailedGraphNodeTest.java
new file mode 100644
index 0000000..153a1c5
--- /dev/null
+++ b/src/test/java/io/github/bsels/semantic/version/models/graph/DetailedGraphNodeTest.java
@@ -0,0 +1,141 @@
+package io.github.bsels.semantic.version.models.graph;
+
+import io.github.bsels.semantic.version.models.MavenArtifact;
+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 java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+public class DetailedGraphNodeTest {
+ private static final String GROUP_ID = "groupId";
+ private static final String ARTIFACT_ID = "artifactId";
+ private static final String FOLDER = "folder/path";
+
+ @Nested
+ class ConstructorTest {
+
+ @ParameterizedTest
+ @CsvSource(value = {
+ "null,null,null,artifact",
+ "null," + FOLDER + ",empty,artifact",
+ GROUP_ID + ":" + ARTIFACT_ID + ",null,empty,folder",
+ GROUP_ID + ":" + ARTIFACT_ID + "," + FOLDER + ",null,dependencies"
+ }, nullValues = "null")
+ void nullInput_ThrowsNullPointerException(String artifactString, String folder, String dependenciesType, String exceptionParameter) {
+ MavenArtifact artifact = artifactString == null ? null : MavenArtifact.of(artifactString);
+ List dependencies = dependenciesType == null ? null : List.of();
+ assertThatThrownBy(() -> new DetailedGraphNode(artifact, folder, dependencies))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("`%s` must not be null", exceptionParameter);
+ }
+
+ @Test
+ void validInputs_ReturnsCorrectDetailedGraphNode() {
+ MavenArtifact artifact = new MavenArtifact(GROUP_ID, ARTIFACT_ID);
+ List dependencies = List.of();
+ DetailedGraphNode node = new DetailedGraphNode(artifact, FOLDER, dependencies);
+ assertThat(node)
+ .isNotNull()
+ .hasFieldOrPropertyWithValue("artifact", artifact)
+ .hasFieldOrPropertyWithValue("folder", FOLDER)
+ .hasFieldOrPropertyWithValue("dependencies", dependencies);
+ }
+
+ @Test
+ void validInputsWithDependencies_ReturnsCorrectDetailedGraphNode() {
+ MavenArtifact artifact = new MavenArtifact(GROUP_ID, ARTIFACT_ID);
+ MavenArtifact depArtifact = new MavenArtifact("depGroup", "depArtifact");
+ ArtifactLocation depLocation = new ArtifactLocation(depArtifact, "dep/folder");
+ List dependencies = List.of(depLocation);
+ DetailedGraphNode node = new DetailedGraphNode(artifact, FOLDER, dependencies);
+ assertThat(node)
+ .isNotNull()
+ .hasFieldOrPropertyWithValue("artifact", artifact)
+ .hasFieldOrPropertyWithValue("folder", FOLDER)
+ .hasFieldOrPropertyWithValue("dependencies", dependencies);
+ }
+ }
+
+ @Nested
+ class AccessorTest {
+
+ @Test
+ void artifact_ReturnsCorrectValue() {
+ MavenArtifact artifact = new MavenArtifact(GROUP_ID, ARTIFACT_ID);
+ DetailedGraphNode node = new DetailedGraphNode(artifact, FOLDER, List.of());
+ assertThat(node.artifact())
+ .isEqualTo(artifact);
+ }
+
+ @Test
+ void folder_ReturnsCorrectValue() {
+ MavenArtifact artifact = new MavenArtifact(GROUP_ID, ARTIFACT_ID);
+ DetailedGraphNode node = new DetailedGraphNode(artifact, FOLDER, List.of());
+ assertThat(node.folder())
+ .isEqualTo(FOLDER);
+ }
+
+ @Test
+ void dependencies_ReturnsCorrectValue() {
+ MavenArtifact artifact = new MavenArtifact(GROUP_ID, ARTIFACT_ID);
+ MavenArtifact depArtifact = new MavenArtifact("depGroup", "depArtifact");
+ ArtifactLocation depLocation = new ArtifactLocation(depArtifact, "dep/folder");
+ List dependencies = List.of(depLocation);
+ DetailedGraphNode node = new DetailedGraphNode(artifact, FOLDER, dependencies);
+ assertThat(node.dependencies())
+ .isEqualTo(dependencies)
+ .hasSize(1)
+ .containsExactly(depLocation);
+ }
+ }
+
+ @Nested
+ class EqualsAndHashCodeTest {
+
+ @Test
+ void sameValues_AreEqual() {
+ MavenArtifact artifact = new MavenArtifact(GROUP_ID, ARTIFACT_ID);
+ List dependencies = List.of();
+ DetailedGraphNode node1 = new DetailedGraphNode(artifact, FOLDER, dependencies);
+ DetailedGraphNode node2 = new DetailedGraphNode(artifact, FOLDER, dependencies);
+ assertThat(node1)
+ .isEqualTo(node2)
+ .hasSameHashCodeAs(node2);
+ }
+
+ @Test
+ void differentArtifact_AreNotEqual() {
+ MavenArtifact artifact1 = new MavenArtifact(GROUP_ID, ARTIFACT_ID);
+ MavenArtifact artifact2 = new MavenArtifact("otherGroup", ARTIFACT_ID);
+ DetailedGraphNode node1 = new DetailedGraphNode(artifact1, FOLDER, List.of());
+ DetailedGraphNode node2 = new DetailedGraphNode(artifact2, FOLDER, List.of());
+ assertThat(node1)
+ .isNotEqualTo(node2);
+ }
+
+ @Test
+ void differentFolder_AreNotEqual() {
+ MavenArtifact artifact = new MavenArtifact(GROUP_ID, ARTIFACT_ID);
+ DetailedGraphNode node1 = new DetailedGraphNode(artifact, FOLDER, List.of());
+ DetailedGraphNode node2 = new DetailedGraphNode(artifact, "other/folder", List.of());
+ assertThat(node1)
+ .isNotEqualTo(node2);
+ }
+
+ @Test
+ void differentDependencies_AreNotEqual() {
+ MavenArtifact artifact = new MavenArtifact(GROUP_ID, ARTIFACT_ID);
+ MavenArtifact depArtifact = new MavenArtifact("depGroup", "depArtifact");
+ ArtifactLocation depLocation = new ArtifactLocation(depArtifact, "dep/folder");
+ DetailedGraphNode node1 = new DetailedGraphNode(artifact, FOLDER, List.of());
+ DetailedGraphNode node2 = new DetailedGraphNode(artifact, FOLDER, List.of(depLocation));
+ assertThat(node1)
+ .isNotEqualTo(node2);
+ }
+ }
+}
diff --git a/src/test/java/io/github/bsels/semantic/version/parameters/GraphOutputTest.java b/src/test/java/io/github/bsels/semantic/version/parameters/GraphOutputTest.java
new file mode 100644
index 0000000..1eebf0a
--- /dev/null
+++ b/src/test/java/io/github/bsels/semantic/version/parameters/GraphOutputTest.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 GraphOutputTest {
+
+ @Test
+ void numberOfEnumElements_Return3() {
+ assertThat(GraphOutput.values())
+ .hasSize(3)
+ .extracting(GraphOutput::name)
+ .containsExactlyInAnyOrder("ARTIFACT_ONLY", "FOLDER_ONLY", "ARTIFACT_AND_FOLDER");
+ }
+
+ @ParameterizedTest
+ @EnumSource(GraphOutput.class)
+ void toString_ReturnsCorrectValue(GraphOutput graphOutput) {
+ assertThat(graphOutput.toString())
+ .isEqualTo(graphOutput.name());
+
+ }
+
+ @ParameterizedTest
+ @EnumSource(GraphOutput.class)
+ void valueOf_ReturnCorrectValue(GraphOutput graphOutput) {
+ assertThat(GraphOutput.valueOf(graphOutput.toString()))
+ .isEqualTo(graphOutput);
+ }
+}
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 6da538e..4b2356b 100644
--- a/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java
+++ b/src/test/java/io/github/bsels/semantic/version/utils/UtilsTest.java
@@ -1,5 +1,7 @@
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.PlaceHolderWithType;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.project.MavenProject;
@@ -738,4 +740,69 @@ void unknownPlaceholder_Preserved() {
assertThat(result).isEqualTo("Release {unknown} on 2024-02-03");
}
}
+
+ @Nested
+ class MavenProjectToArtifactTest {
+
+ @Test
+ void validProject_ReturnsCorrectArtifact() {
+ Mockito.when(mavenProject.getGroupId())
+ .thenReturn("io.github.bsels");
+ Mockito.when(mavenProject.getArtifactId())
+ .thenReturn("semantic-version-maven-plugin");
+
+ MavenArtifact artifact = Utils.mavenProjectToArtifact(mavenProject);
+ assertThat(artifact).isNotNull();
+ assertThat(artifact.groupId()).isEqualTo("io.github.bsels");
+ assertThat(artifact.artifactId()).isEqualTo("semantic-version-maven-plugin");
+ }
+ }
+
+ @Nested
+ class WriteObjectAsJsonTest {
+
+ @Test
+ void validObject_ReturnsJson() throws MojoExecutionException {
+ Map map = Map.of("key", "value");
+ String json = Utils.writeObjectAsJson(map);
+
+ assertThat(json).contains("\"key\" : \"value\"");
+ }
+
+ @Test
+ void mavenArtifact_ReturnsJson() throws MojoExecutionException {
+ MavenArtifact artifact = new MavenArtifact("io.github.bsels", "semantic-version-maven-plugin");
+ String json = Utils.writeObjectAsJson(artifact);
+
+ assertThat(json).isEqualToIgnoringNewLines("""
+ {
+ "groupId" : "io.github.bsels",
+ "artifactId" : "semantic-version-maven-plugin"
+ }
+ """);
+ }
+
+ @Test
+ void mavenArtifactAsKey_ReturnsJsonWithMavenArtifactAsKey() throws MojoExecutionException {
+ MavenArtifact artifact = new MavenArtifact("io.github.bsels", "semantic-version-maven-plugin");
+ Map map = Map.of(artifact, "value");
+ String json = Utils.writeObjectAsJson(map);
+
+ assertThat(json).contains("\"io.github.bsels:semantic-version-maven-plugin\" : \"value\"");
+ }
+
+ @Test
+ void failingObject_ThrowsMojoExecutionException() {
+ Object failingObject = new Object() {
+ public String getFailingProperty() {
+ throw new RuntimeException("Failing getter");
+ }
+ };
+
+ assertThatThrownBy(() -> Utils.writeObjectAsJson(failingObject))
+ .isInstanceOf(MojoExecutionException.class)
+ .hasMessage("Failed to serialize object to JSON")
+ .hasCauseInstanceOf(JsonProcessingException.class);
+ }
+ }
}
diff --git a/src/test/java/io/github/bsels/semantic/version/utils/mapper/MavenArtifactArtifactOnlySerializerTest.java b/src/test/java/io/github/bsels/semantic/version/utils/mapper/MavenArtifactArtifactOnlySerializerTest.java
index 2f2c95f..cabadd0 100644
--- a/src/test/java/io/github/bsels/semantic/version/utils/mapper/MavenArtifactArtifactOnlySerializerTest.java
+++ b/src/test/java/io/github/bsels/semantic/version/utils/mapper/MavenArtifactArtifactOnlySerializerTest.java
@@ -38,4 +38,17 @@ void serializeKey_WritesArtifactIdFieldName() throws Exception {
assertThat(json)
.isEqualTo("{\"" + ARTIFACT_ID + "\":\"value\"}");
}
+
+ @Test
+ void serializeRoot_WritesArtifactIdString() throws Exception {
+ ObjectMapper mapper = new ObjectMapper()
+ .registerModule(new SimpleModule()
+ .addSerializer(MavenArtifact.class, new MavenArtifactArtifactOnlySerializer())
+ );
+
+ String json = mapper.writeValueAsString(new MavenArtifact(GROUP_ID, ARTIFACT_ID));
+
+ assertThat(json)
+ .isEqualTo("\"" + ARTIFACT_ID + "\"");
+ }
}
diff --git a/src/test/resources/itests/chained-dependency/a/CHANGELOG.md b/src/test/resources/itests/chained-dependency/a/CHANGELOG.md
new file mode 100644
index 0000000..26a604f
--- /dev/null
+++ b/src/test/resources/itests/chained-dependency/a/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Changelog
+
+## 6.0.0-a - 2026-01-01
+
+Initial a release.
\ No newline at end of file
diff --git a/src/test/resources/itests/chained-dependency/a/pom.xml b/src/test/resources/itests/chained-dependency/a/pom.xml
new file mode 100644
index 0000000..413989b
--- /dev/null
+++ b/src/test/resources/itests/chained-dependency/a/pom.xml
@@ -0,0 +1,9 @@
+
+
+ 4.0.0
+ org.example.itests.chained
+ a
+ 6.0.0-a
+
\ No newline at end of file
diff --git a/src/test/resources/itests/chained-dependency/b/CHANGELOG.md b/src/test/resources/itests/chained-dependency/b/CHANGELOG.md
new file mode 100644
index 0000000..9a0cccd
--- /dev/null
+++ b/src/test/resources/itests/chained-dependency/b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Changelog
+
+## 6.0.0-b - 2026-01-01
+
+Initial b release.
\ No newline at end of file
diff --git a/src/test/resources/itests/chained-dependency/b/pom.xml b/src/test/resources/itests/chained-dependency/b/pom.xml
new file mode 100644
index 0000000..6ed15e3
--- /dev/null
+++ b/src/test/resources/itests/chained-dependency/b/pom.xml
@@ -0,0 +1,17 @@
+
+
+ 4.0.0
+ org.example.itests.chained
+ b
+ 6.0.0-b
+
+
+
+ org.example.itests.chained
+ a
+ 6.0.0-a
+
+
+
\ No newline at end of file
diff --git a/src/test/resources/itests/chained-dependency/c/CHANGELOG.md b/src/test/resources/itests/chained-dependency/c/CHANGELOG.md
new file mode 100644
index 0000000..cf78217
--- /dev/null
+++ b/src/test/resources/itests/chained-dependency/c/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Changelog
+
+## 6.0.0-c - 2026-01-01
+
+Initial c release.
\ No newline at end of file
diff --git a/src/test/resources/itests/chained-dependency/c/pom.xml b/src/test/resources/itests/chained-dependency/c/pom.xml
new file mode 100644
index 0000000..d4128c3
--- /dev/null
+++ b/src/test/resources/itests/chained-dependency/c/pom.xml
@@ -0,0 +1,17 @@
+
+
+ 4.0.0
+ org.example.itests.chained
+ c
+ 6.0.0-c
+
+
+
+ org.example.itests.chained
+ a
+ 6.0.0-a
+
+
+
\ No newline at end of file
diff --git a/src/test/resources/itests/chained-dependency/d/CHANGELOG.md b/src/test/resources/itests/chained-dependency/d/CHANGELOG.md
new file mode 100644
index 0000000..619e649
--- /dev/null
+++ b/src/test/resources/itests/chained-dependency/d/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Changelog
+
+## 6.0.0-d - 2026-01-01
+
+Initial d release.
\ No newline at end of file
diff --git a/src/test/resources/itests/chained-dependency/d/pom.xml b/src/test/resources/itests/chained-dependency/d/pom.xml
new file mode 100644
index 0000000..52f9cd4
--- /dev/null
+++ b/src/test/resources/itests/chained-dependency/d/pom.xml
@@ -0,0 +1,22 @@
+
+
+ 4.0.0
+ org.example.itests.chained
+ d
+ 6.0.0-d
+
+
+
+ org.example.itests.chained
+ b
+ 6.0.0-b
+
+
+ org.example.itests.chained
+ c
+ 6.0.0-c
+
+
+
\ No newline at end of file
diff --git a/src/test/resources/itests/chained-dependency/e/CHANGELOG.md b/src/test/resources/itests/chained-dependency/e/CHANGELOG.md
new file mode 100644
index 0000000..460bc07
--- /dev/null
+++ b/src/test/resources/itests/chained-dependency/e/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Changelog
+
+## 6.0.0-e - 2026-01-01
+
+Initial e release.
\ No newline at end of file
diff --git a/src/test/resources/itests/chained-dependency/e/pom.xml b/src/test/resources/itests/chained-dependency/e/pom.xml
new file mode 100644
index 0000000..c9b1416
--- /dev/null
+++ b/src/test/resources/itests/chained-dependency/e/pom.xml
@@ -0,0 +1,17 @@
+
+
+ 4.0.0
+ org.example.itests.chained
+ e
+ 6.0.0-e
+
+
+
+ org.example.itests.chained
+ d
+ 6.0.0-d
+
+
+
\ No newline at end of file
diff --git a/src/test/resources/itests/chained-dependency/pom.xml b/src/test/resources/itests/chained-dependency/pom.xml
new file mode 100644
index 0000000..add4a9c
--- /dev/null
+++ b/src/test/resources/itests/chained-dependency/pom.xml
@@ -0,0 +1,19 @@
+
+
+ 4.0.0
+ org.example.itests.chained
+ root
+ 6.0.0-root
+
+ pom
+
+
+ a
+ b
+ c
+ d
+ e
+
+
\ 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
index c3fd102..ad5546e 100644
--- a/src/test/resources/itests/leaves/intermediate/pom.xml
+++ b/src/test/resources/itests/leaves/intermediate/pom.xml
@@ -7,6 +7,8 @@
intermediate
5.0.0-intermediate
+ pom
+
child-2
child-3
diff --git a/src/test/resources/itests/leaves/pom.xml b/src/test/resources/itests/leaves/pom.xml
index faba402..4cfcd02 100644
--- a/src/test/resources/itests/leaves/pom.xml
+++ b/src/test/resources/itests/leaves/pom.xml
@@ -7,6 +7,8 @@
root
5.0.0-root
+ pom
+
child-1
intermediate
diff --git a/src/test/resources/itests/multi-recursive/pom.xml b/src/test/resources/itests/multi-recursive/pom.xml
index 092b9e4..a92808f 100644
--- a/src/test/resources/itests/multi-recursive/pom.xml
+++ b/src/test/resources/itests/multi-recursive/pom.xml
@@ -7,6 +7,8 @@
parent
6.0.0-parent
+ pom
+
child-1
child-2
diff --git a/src/test/resources/itests/multi/pom.xml b/src/test/resources/itests/multi/pom.xml
index ce48418..56b52d6 100644
--- a/src/test/resources/itests/multi/pom.xml
+++ b/src/test/resources/itests/multi/pom.xml
@@ -7,6 +7,8 @@
parent
4.0.0-parent
+ pom
+
dependency
dependencyManagement
diff --git a/src/test/resources/itests/revision/multi/pom.xml b/src/test/resources/itests/revision/multi/pom.xml
index 472d1fe..7bee1a3 100644
--- a/src/test/resources/itests/revision/multi/pom.xml
+++ b/src/test/resources/itests/revision/multi/pom.xml
@@ -7,6 +7,8 @@
parent
${revision}
+ pom
+
3.0.0