DeduplicateDependenciesStrategy.java
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.maven.cling.invoker.mvnup.goals;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Stream;
import eu.maveniverse.domtrip.Document;
import eu.maveniverse.domtrip.Element;
import eu.maveniverse.domtrip.maven.MavenPomElements;
import org.apache.maven.api.cli.mvnup.UpgradeOptions;
import org.apache.maven.api.di.Named;
import org.apache.maven.api.di.Priority;
import org.apache.maven.api.di.Singleton;
import org.apache.maven.cling.invoker.mvnup.UpgradeContext;
import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.BUILD;
import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.DEPENDENCIES;
import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.DEPENDENCY;
import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.DEPENDENCY_MANAGEMENT;
import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PLUGIN;
import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PLUGINS;
import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PLUGIN_MANAGEMENT;
import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PROFILE;
import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PROFILES;
import static eu.maveniverse.domtrip.maven.MavenPomElements.Plugins.DEFAULT_MAVEN_PLUGIN_GROUP_ID;
import static eu.maveniverse.domtrip.maven.MavenPomElements.Plugins.MAVEN_PLUGIN_PREFIX;
/**
* Strategy for deduplicating dependency and plugin declarations in POM files.
*
* <p>Maven 4 rejects duplicate {@code <dependency>} entries (same groupId, artifactId,
* type, and classifier) that Maven 3 silently accepted. This strategy scans each POM's
* {@code <dependencies>} and {@code <dependencyManagement>/<dependencies>} sections and
* removes duplicates using last-wins semantics (keeping the last declaration). Note that
* Maven 3's dependency resolver uses first-wins for same-depth conflicts; last-wins is
* chosen here intentionally so the most recently added (and presumably most up-to-date)
* declaration is preserved.
*
* <p>The strategy also handles duplicate {@code <plugin>} entries in {@code <plugins>}
* and {@code <pluginManagement>/<plugins>} sections with the same last-wins semantics
* (which does match Maven 3's plugin configuration merging behavior).
*
* <p>Profile-scoped dependency and plugin sections are processed as well.
*/
@Named
@Singleton
@Priority(19)
public class DeduplicateDependenciesStrategy extends AbstractUpgradeStrategy {
@Override
public boolean isApplicable(UpgradeContext context) {
UpgradeOptions options = getOptions(context);
return isOptionEnabled(options, options.model(), true);
}
@Override
public String getDescription() {
return "Deduplicating dependency and plugin declarations";
}
@Override
protected UpgradeResult doApply(UpgradeContext context, Map<Path, Document> pomMap) {
Set<Path> processedPoms = new HashSet<>();
Set<Path> modifiedPoms = new HashSet<>();
Set<Path> errorPoms = new HashSet<>();
for (Map.Entry<Path, Document> entry : pomMap.entrySet()) {
Path pomPath = entry.getKey();
Document pomDocument = entry.getValue();
processedPoms.add(pomPath);
context.info(pomPath + " (checking for duplicate declarations)");
context.indent();
try {
boolean hasIssues = false;
hasIssues |= fixDuplicateDependencies(pomDocument, context);
hasIssues |= fixDuplicatePlugins(pomDocument, context);
if (hasIssues) {
context.success("Duplicate declarations removed");
modifiedPoms.add(pomPath);
} else {
context.success("No duplicate declarations found");
}
} catch (Exception e) {
context.failure("Failed to deduplicate declarations: " + e.getMessage());
errorPoms.add(pomPath);
} finally {
context.unindent();
}
}
return new UpgradeResult(processedPoms, modifiedPoms, errorPoms);
}
/**
* Fixes duplicate dependencies in dependencies and dependencyManagement sections,
* including profile-scoped sections.
*/
private boolean fixDuplicateDependencies(Document pomDocument, UpgradeContext context) {
Element root = pomDocument.root();
Stream<DependencyContainer> dependencyContainers = Stream.concat(
// Root level dependencies
Stream.of(
new DependencyContainer(
root.childElement(DEPENDENCIES).orElse(null), DEPENDENCIES),
new DependencyContainer(
root.childElement(DEPENDENCY_MANAGEMENT)
.flatMap(dm -> dm.childElement(DEPENDENCIES))
.orElse(null),
DEPENDENCY_MANAGEMENT))
.filter(container -> container.element != null),
// Profile dependencies
root.childElement(PROFILES).stream()
.flatMap(profiles -> profiles.childElements(PROFILE))
.flatMap(profile -> Stream.of(
new DependencyContainer(
profile.childElement(DEPENDENCIES)
.orElse(null),
"profile dependencies"),
new DependencyContainer(
profile.childElement(DEPENDENCY_MANAGEMENT)
.flatMap(dm -> dm.childElement(DEPENDENCIES))
.orElse(null),
"profile dependencyManagement"))
.filter(container -> container.element != null)));
return dependencyContainers
.map(container -> fixDuplicateDependenciesInSection(container.element, context, container.sectionName))
.reduce(false, Boolean::logicalOr);
}
/**
* Fixes duplicate plugins in plugins and pluginManagement sections,
* including profile-scoped sections.
*/
private boolean fixDuplicatePlugins(Document pomDocument, UpgradeContext context) {
Element root = pomDocument.root();
Stream<BuildContainer> buildContainers = Stream.concat(
// Root level build
Stream.of(new BuildContainer(root.childElement(BUILD).orElse(null), BUILD))
.filter(container -> container.element != null),
// Profile builds
root.childElement(PROFILES).stream()
.flatMap(profiles -> profiles.childElements(PROFILE))
.map(profile ->
new BuildContainer(profile.childElement(BUILD).orElse(null), "profile build"))
.filter(container -> container.element != null));
return buildContainers
.map(container -> fixPluginsInBuildElement(container.element, context, container.sectionName))
.reduce(false, Boolean::logicalOr);
}
/**
* Deduplicates dependencies within a specific dependencies section using last-wins semantics.
* When a duplicate is found, the earlier occurrence is removed and the later one is kept,
* matching Maven 3's runtime behavior.
*/
private boolean fixDuplicateDependenciesInSection(
Element dependenciesElement, UpgradeContext context, String sectionName) {
List<Element> dependencies =
dependenciesElement.childElements(DEPENDENCY).toList();
Map<String, Element> seenDependencies = new HashMap<>();
List<Element> duplicates = new ArrayList<>();
for (Element dependency : dependencies) {
String key = createDependencyKey(dependency);
Element previous = seenDependencies.put(key, dependency);
if (previous != null) {
duplicates.add(previous);
String keptVersion = dependency.childText(MavenPomElements.Elements.VERSION);
String versionInfo = keptVersion != null ? " (keeping version " + keptVersion + ")" : "";
context.detail(
"Removed duplicate dependency declaration for " + key + versionInfo + " in " + sectionName);
}
}
duplicates.forEach(DomUtils::removeElement);
return !duplicates.isEmpty();
}
/**
* Creates a unique key for a dependency based on groupId, artifactId, type, and classifier.
* Type defaults to "jar" and classifier defaults to empty when not specified.
*/
private String createDependencyKey(Element dependency) {
String groupId = dependency.childText(MavenPomElements.Elements.GROUP_ID);
String artifactId = dependency.childText(MavenPomElements.Elements.ARTIFACT_ID);
String type = dependency.childText(MavenPomElements.Elements.TYPE);
String classifier = dependency.childText(MavenPomElements.Elements.CLASSIFIER);
return groupId + ":" + artifactId + ":" + (type != null ? type : "jar") + ":"
+ (classifier != null ? classifier : "");
}
/**
* Fixes duplicate plugins within a build element, covering both plugins and pluginManagement.
*/
private boolean fixPluginsInBuildElement(Element buildElement, UpgradeContext context, String sectionName) {
boolean fixed = false;
Element pluginsElement = buildElement.childElement(PLUGINS).orElse(null);
if (pluginsElement != null) {
fixed |= fixDuplicatePluginsInSection(pluginsElement, context, sectionName + "/" + PLUGINS);
}
Element pluginManagementElement =
buildElement.childElement(PLUGIN_MANAGEMENT).orElse(null);
if (pluginManagementElement != null) {
Element managedPluginsElement =
pluginManagementElement.childElement(PLUGINS).orElse(null);
if (managedPluginsElement != null) {
fixed |= fixDuplicatePluginsInSection(
managedPluginsElement, context, sectionName + "/" + PLUGIN_MANAGEMENT + "/" + PLUGINS);
}
}
return fixed;
}
/**
* Deduplicates plugins within a specific plugins section using last-wins semantics.
*/
private boolean fixDuplicatePluginsInSection(Element pluginsElement, UpgradeContext context, String sectionName) {
List<Element> plugins = pluginsElement.childElements(PLUGIN).toList();
Map<String, Element> seenPlugins = new HashMap<>();
List<Element> duplicates = new ArrayList<>();
for (Element plugin : plugins) {
String key = createPluginKey(plugin);
if (key != null) {
Element previous = seenPlugins.put(key, plugin);
if (previous != null) {
duplicates.add(previous);
String keptVersion = plugin.childText(MavenPomElements.Elements.VERSION);
String versionInfo = keptVersion != null ? " (keeping version " + keptVersion + ")" : "";
context.detail(
"Removed duplicate plugin declaration for " + key + versionInfo + " in " + sectionName);
}
}
}
duplicates.forEach(DomUtils::removeElement);
return !duplicates.isEmpty();
}
/**
* Creates a unique key for a plugin based on groupId and artifactId.
* Defaults groupId to org.apache.maven.plugins for Maven plugins.
*/
private String createPluginKey(Element plugin) {
String groupId = plugin.childText(MavenPomElements.Elements.GROUP_ID);
String artifactId = plugin.childText(MavenPomElements.Elements.ARTIFACT_ID);
if (groupId == null && artifactId != null && artifactId.startsWith(MAVEN_PLUGIN_PREFIX)) {
groupId = DEFAULT_MAVEN_PLUGIN_GROUP_ID;
}
return (groupId != null && artifactId != null) ? groupId + ":" + artifactId : null;
}
private record DependencyContainer(Element element, String sectionName) {}
private record BuildContainer(Element element, String sectionName) {}
}