This is an automated email from the ASF dual-hosted git repository. gnodet pushed a commit to branch feature/mvnup-source-strategy in repository https://gitbox.apache.org/repos/asf/maven.git
commit 63979cdaa02710ebdb59793f51755588a46ffc75 Author: Guillaume Nodet <[email protected]> AuthorDate: Tue Jun 23 22:40:42 2026 +0200 Add mvnup SourceStrategy for migrating to <source> elements Adds a new mvnup upgrade strategy that migrates legacy source configuration to Maven 4.1.0+ <source> elements. Handles four migration phases: - Compiler properties (maven.compiler.release, source/target) to <source><targetVersion> - Compiler plugin configuration (<release>, <source>/<target>) to <source><targetVersion> - Custom source/test directories to <source><directory> - Resource/testResource sections to <source> with lang=resources Applies when --model-version is 4.1.0+ or --all is set. Runs at @Priority(20), after all other strategies. Co-Authored-By: Claude Opus 4.6 <[email protected]> --- .../cling/invoker/mvnup/goals/SourceStrategy.java | 493 ++++++++++++ .../invoker/mvnup/goals/SourceStrategyTest.java | 855 +++++++++++++++++++++ 2 files changed, 1348 insertions(+) diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/SourceStrategy.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/SourceStrategy.java new file mode 100644 index 0000000000..2fc07311ea --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/SourceStrategy.java @@ -0,0 +1,493 @@ +/* + * 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.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import eu.maveniverse.domtrip.Document; +import eu.maveniverse.domtrip.Element; +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.ARTIFACT_ID; +import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.BUILD; +import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.CONFIGURATION; +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.PROPERTIES; +import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.SOURCE_DIRECTORY; +import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.TEST_SOURCE_DIRECTORY; +import static eu.maveniverse.domtrip.maven.MavenPomElements.ModelVersions.MODEL_VERSION_4_1_0; + +/** + * Strategy for migrating legacy source configuration to Maven 4.1.0+ {@code <source>} elements. + * + * <p>Handles four migration phases: + * <ol> + * <li>Compiler properties ({@code maven.compiler.release}, {@code maven.compiler.source/target}) + * → {@code <source><targetVersion>}</li> + * <li>Compiler plugin configuration ({@code <release>}, {@code <source>/<target>}) + * → {@code <source><targetVersion>}</li> + * <li>Custom source/test directories → {@code <source>} with {@code <directory>}</li> + * <li>Resource directories → {@code <source>} with {@code <lang>resources</lang>}</li> + * </ol> + */ +@Named +@Singleton +@Priority(20) +public class SourceStrategy extends AbstractUpgradeStrategy { + + private static final String MAVEN_COMPILER_RELEASE = "maven.compiler.release"; + private static final String MAVEN_COMPILER_SOURCE = "maven.compiler.source"; + private static final String MAVEN_COMPILER_TARGET = "maven.compiler.target"; + private static final String MAVEN_COMPILER_PLUGIN = "maven-compiler-plugin"; + + private static final String DEFAULT_SOURCE_DIR = "src/main/java"; + private static final String DEFAULT_TEST_SOURCE_DIR = "src/test/java"; + private static final String DEFAULT_RESOURCE_DIR = "src/main/resources"; + private static final String DEFAULT_TEST_RESOURCE_DIR = "src/test/resources"; + + @Override + public boolean isApplicable(UpgradeContext context) { + UpgradeOptions options = getOptions(context); + + if (options.all().orElse(false)) { + return true; + } + + String modelVersion = options.modelVersion().orElse(null); + return modelVersion != null && ModelVersionUtils.isVersionGreaterOrEqual(modelVersion, MODEL_VERSION_4_1_0); + } + + @Override + public String getDescription() { + return "Migrating source configuration to <source> elements"; + } + + @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); + + String currentVersion = ModelVersionUtils.detectModelVersion(pomDocument); + context.info(pomPath + " (current: " + currentVersion + ")"); + context.indent(); + + try { + if (!ModelVersionUtils.isVersionGreaterOrEqual(currentVersion, MODEL_VERSION_4_1_0)) { + context.success("Skipping (model version " + currentVersion + " < 4.1.0)"); + continue; + } + + boolean hasChanges = migrateSources(context, pomDocument); + + if (hasChanges) { + modifiedPoms.add(pomPath); + context.success("Source configuration migrated to <source> elements"); + } else { + context.success("No source configuration to migrate"); + } + } catch (Exception e) { + context.failure("Failed to migrate source configuration: " + e.getMessage()); + errorPoms.add(pomPath); + } finally { + context.unindent(); + } + } + + return new UpgradeResult(processedPoms, modifiedPoms, errorPoms); + } + + private boolean migrateSources(UpgradeContext context, Document pomDocument) { + Element root = pomDocument.root(); + boolean hasChanges = false; + + String targetVersion = extractTargetVersionFromProperties(context, root); + + if (targetVersion == null) { + targetVersion = extractTargetVersionFromCompilerPlugin(context, root); + } + + if (targetVersion != null) { + Element sourcesElement = ensureSourcesElement(root); + Element sourceElement = DomUtils.insertNewElement("source", sourcesElement); + DomUtils.insertContentElement(sourceElement, "targetVersion", targetVersion); + context.detail("Set targetVersion: " + targetVersion); + hasChanges = true; + } + + hasChanges |= migrateSourceDirectories(context, root); + hasChanges |= migrateResources(context, root); + + return hasChanges; + } + + String extractTargetVersionFromProperties(UpgradeContext context, Element root) { + Element properties = root.childElement(PROPERTIES).orElse(null); + if (properties == null) { + return null; + } + + String targetVersion = null; + + Element releaseElement = properties.childElement(MAVEN_COMPILER_RELEASE).orElse(null); + if (releaseElement != null) { + targetVersion = releaseElement.textContent().trim(); + DomUtils.removeElement(releaseElement); + context.detail("Migrated property: " + MAVEN_COMPILER_RELEASE + " = " + targetVersion); + + // Also remove source/target if present (release takes precedence) + properties.childElement(MAVEN_COMPILER_SOURCE).ifPresent(e -> { + DomUtils.removeElement(e); + context.detail("Removed property: " + MAVEN_COMPILER_SOURCE + " (release takes precedence)"); + }); + properties.childElement(MAVEN_COMPILER_TARGET).ifPresent(e -> { + DomUtils.removeElement(e); + context.detail("Removed property: " + MAVEN_COMPILER_TARGET + " (release takes precedence)"); + }); + } else { + Element sourceElement = + properties.childElement(MAVEN_COMPILER_SOURCE).orElse(null); + Element targetElement = + properties.childElement(MAVEN_COMPILER_TARGET).orElse(null); + + if (sourceElement != null && targetElement != null) { + String sourceValue = sourceElement.textContent().trim(); + String targetValue = targetElement.textContent().trim(); + + if (sourceValue.equals(targetValue)) { + targetVersion = sourceValue; + DomUtils.removeElement(sourceElement); + DomUtils.removeElement(targetElement); + context.detail("Migrated properties: " + MAVEN_COMPILER_SOURCE + " = " + MAVEN_COMPILER_TARGET + + " = " + targetVersion); + } + } + } + + if (targetVersion != null) { + removeIfEmpty(properties); + } + + return targetVersion; + } + + String extractTargetVersionFromCompilerPlugin(UpgradeContext context, Element root) { + String targetVersion = extractFromPluginSection( + context, + root.childElement(BUILD).flatMap(b -> b.childElement(PLUGINS)).orElse(null)); + + if (targetVersion == null) { + targetVersion = extractFromPluginSection( + context, + root.childElement(BUILD) + .flatMap(b -> b.childElement(PLUGIN_MANAGEMENT)) + .flatMap(pm -> pm.childElement(PLUGINS)) + .orElse(null)); + } + + return targetVersion; + } + + private String extractFromPluginSection(UpgradeContext context, Element pluginsElement) { + if (pluginsElement == null) { + return null; + } + + Element compilerPlugin = findCompilerPlugin(pluginsElement); + if (compilerPlugin == null) { + return null; + } + + Element configuration = compilerPlugin.childElement(CONFIGURATION).orElse(null); + if (configuration == null) { + return null; + } + + String targetVersion = null; + + Element releaseElement = configuration.childElement("release").orElse(null); + if (releaseElement != null) { + targetVersion = releaseElement.textContent().trim(); + DomUtils.removeElement(releaseElement); + context.detail("Migrated compiler plugin <release>: " + targetVersion); + + configuration.childElement("source").ifPresent(e -> { + DomUtils.removeElement(e); + context.detail("Removed compiler plugin <source> (release takes precedence)"); + }); + configuration.childElement("target").ifPresent(e -> { + DomUtils.removeElement(e); + context.detail("Removed compiler plugin <target> (release takes precedence)"); + }); + } else { + Element sourceElement = configuration.childElement("source").orElse(null); + Element targetElement = configuration.childElement("target").orElse(null); + + if (sourceElement != null && targetElement != null) { + String sourceValue = sourceElement.textContent().trim(); + String targetValue = targetElement.textContent().trim(); + + if (sourceValue.equals(targetValue)) { + targetVersion = sourceValue; + DomUtils.removeElement(sourceElement); + DomUtils.removeElement(targetElement); + context.detail("Migrated compiler plugin <source>/<target>: " + targetVersion); + } + } + } + + if (targetVersion != null) { + cleanupCompilerPlugin(compilerPlugin, configuration, pluginsElement); + } + + return targetVersion; + } + + private Element findCompilerPlugin(Element pluginsElement) { + return pluginsElement.childElements(PLUGIN).toList().stream() + .filter(plugin -> { + String artifactId = plugin.childTextTrimmed(ARTIFACT_ID); + return MAVEN_COMPILER_PLUGIN.equals(artifactId); + }) + .findFirst() + .orElse(null); + } + + private void cleanupCompilerPlugin(Element compilerPlugin, Element configuration, Element pluginsElement) { + if (!configuration.childElements().findAny().isPresent()) { + DomUtils.removeElement(configuration); + } + + boolean hasConfig = compilerPlugin.childElement(CONFIGURATION).isPresent(); + boolean hasExecutions = compilerPlugin.childElement("executions").isPresent(); + boolean hasDeps = compilerPlugin.childElement("dependencies").isPresent(); + + if (!hasConfig && !hasExecutions && !hasDeps) { + DomUtils.removeElement(compilerPlugin); + removeIfEmpty(pluginsElement); + } + } + + boolean migrateSourceDirectories(UpgradeContext context, Element root) { + Element buildElement = root.childElement(BUILD).orElse(null); + if (buildElement == null) { + return false; + } + + boolean hasChanges = false; + + Element sourceDir = buildElement.childElement(SOURCE_DIRECTORY).orElse(null); + if (sourceDir != null) { + String dir = sourceDir.textContent().trim(); + DomUtils.removeElement(sourceDir); + + if (!DEFAULT_SOURCE_DIR.equals(dir)) { + Element sourcesElement = ensureSourcesElement(root); + Element mainSource = findOrCreateMainJavaSource(sourcesElement); + DomUtils.insertContentElement(mainSource, "directory", dir); + context.detail("Migrated sourceDirectory: " + dir); + } else { + context.detail("Removed default sourceDirectory"); + } + hasChanges = true; + } + + Element testSourceDir = buildElement.childElement(TEST_SOURCE_DIRECTORY).orElse(null); + if (testSourceDir != null) { + String dir = testSourceDir.textContent().trim(); + DomUtils.removeElement(testSourceDir); + + if (!DEFAULT_TEST_SOURCE_DIR.equals(dir)) { + Element sourcesElement = ensureSourcesElement(root); + Element testSource = DomUtils.insertNewElement("source", sourcesElement); + DomUtils.insertContentElement(testSource, "scope", "test"); + DomUtils.insertContentElement(testSource, "directory", dir); + context.detail("Migrated testSourceDirectory: " + dir); + } else { + context.detail("Removed default testSourceDirectory"); + } + hasChanges = true; + } + + return hasChanges; + } + + boolean migrateResources(UpgradeContext context, Element root) { + Element buildElement = root.childElement(BUILD).orElse(null); + if (buildElement == null) { + return false; + } + + boolean hasChanges = false; + + hasChanges |= migrateResourceSection(context, root, buildElement, "resources", "resource", "main"); + hasChanges |= migrateResourceSection(context, root, buildElement, "testResources", "testResource", "test"); + + return hasChanges; + } + + private boolean migrateResourceSection( + UpgradeContext context, + Element root, + Element buildElement, + String containerName, + String elementName, + String scope) { + Element container = buildElement.childElement(containerName).orElse(null); + if (container == null) { + return false; + } + + List<Element> resources = container.childElements(elementName).toList(); + + for (Element resource : resources) { + if (isDefaultResource(resource, scope)) { + continue; + } + + Element sourcesElement = ensureSourcesElement(root); + Element sourceElement = DomUtils.insertNewElement("source", sourcesElement); + + if ("test".equals(scope)) { + DomUtils.insertContentElement(sourceElement, "scope", "test"); + } + DomUtils.insertContentElement(sourceElement, "lang", "resources"); + + String directory = resource.childTextTrimmed("directory"); + String defaultDir = "main".equals(scope) ? DEFAULT_RESOURCE_DIR : DEFAULT_TEST_RESOURCE_DIR; + if (directory != null && !directory.isEmpty() && !defaultDir.equals(directory)) { + DomUtils.insertContentElement(sourceElement, "directory", directory); + } + + String filtering = resource.childTextTrimmed("filtering"); + if ("true".equals(filtering)) { + DomUtils.insertContentElement(sourceElement, "stringFiltering", "true"); + } + + copyIncludesExcludes(resource, sourceElement); + + String targetPath = resource.childTextTrimmed("targetPath"); + if (targetPath != null && !targetPath.isEmpty()) { + DomUtils.insertContentElement(sourceElement, "targetPath", targetPath); + } + + context.detail("Migrated " + scope + " resource: " + (directory != null ? directory : defaultDir)); + } + + DomUtils.removeElement(container); + return true; + } + + private boolean isDefaultResource(Element resource, String scope) { + String directory = resource.childTextTrimmed("directory"); + String defaultDir = "main".equals(scope) ? DEFAULT_RESOURCE_DIR : DEFAULT_TEST_RESOURCE_DIR; + boolean isDefaultDir = directory == null || directory.isEmpty() || defaultDir.equals(directory); + if (!isDefaultDir) { + return false; + } + + String filtering = resource.childTextTrimmed("filtering"); + if ("true".equals(filtering)) { + return false; + } + + String targetPath = resource.childTextTrimmed("targetPath"); + if (targetPath != null && !targetPath.isEmpty()) { + return false; + } + + if (resource.childElement("includes").isPresent()) { + return false; + } + if (resource.childElement("excludes").isPresent()) { + return false; + } + + return true; + } + + private void copyIncludesExcludes(Element source, Element target) { + source.childElement("includes").ifPresent(includes -> { + Element newIncludes = DomUtils.insertNewElement("includes", target); + includes.childElements("include").forEach(include -> { + String value = include.textContent(); + if (value != null && !value.trim().isEmpty()) { + DomUtils.insertContentElement(newIncludes, "include", value.trim()); + } + }); + }); + + source.childElement("excludes").ifPresent(excludes -> { + Element newExcludes = DomUtils.insertNewElement("excludes", target); + excludes.childElements("exclude").forEach(exclude -> { + String value = exclude.textContent(); + if (value != null && !value.trim().isEmpty()) { + DomUtils.insertContentElement(newExcludes, "exclude", value.trim()); + } + }); + }); + } + + private Element ensureSourcesElement(Element root) { + Element buildElement = root.childElement(BUILD).orElse(null); + if (buildElement == null) { + buildElement = DomUtils.insertNewElement(BUILD, root); + } + + Element sourcesElement = buildElement.childElement("sources").orElse(null); + if (sourcesElement == null) { + sourcesElement = DomUtils.insertNewElement("sources", buildElement); + } + return sourcesElement; + } + + private Element findOrCreateMainJavaSource(Element sourcesElement) { + // Look for an existing main/java source element (e.g. one created for targetVersion) + for (Element source : sourcesElement.childElements("source").toList()) { + String scope = source.childTextTrimmed("scope"); + String lang = source.childTextTrimmed("lang"); + if ((scope == null || scope.isEmpty() || "main".equals(scope)) + && (lang == null || lang.isEmpty() || "java".equals(lang))) { + return source; + } + } + return DomUtils.insertNewElement("source", sourcesElement); + } + + private static void removeIfEmpty(Element element) { + if (!element.childElements().findAny().isPresent()) { + DomUtils.removeElement(element); + } + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/SourceStrategyTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/SourceStrategyTest.java new file mode 100644 index 0000000000..69dc742971 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/SourceStrategyTest.java @@ -0,0 +1,855 @@ +/* + * 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.Paths; +import java.util.HashMap; +import java.util.Map; + +import eu.maveniverse.domtrip.Document; +import eu.maveniverse.domtrip.Element; +import org.apache.maven.cling.invoker.mvnup.UpgradeContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("SourceStrategy") +class SourceStrategyTest { + + private SourceStrategy strategy; + + @BeforeEach + void setUp() { + strategy = new SourceStrategy(); + } + + @Nested + @DisplayName("Applicability") + class ApplicabilityTests { + + @Test + @DisplayName("should not be applicable by default") + void shouldNotBeApplicableByDefault() { + UpgradeContext context = TestUtils.createMockContext(TestUtils.createDefaultOptions()); + assertFalse(strategy.isApplicable(context)); + } + + @Test + @DisplayName("should be applicable with --model-version 4.1.0") + void shouldBeApplicableWithModelVersion410() { + UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.1.0")); + assertTrue(strategy.isApplicable(context)); + } + + @Test + @DisplayName("should be applicable with --model-version 4.2.0") + void shouldBeApplicableWithModelVersion420() { + UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.2.0")); + assertTrue(strategy.isApplicable(context)); + } + + @Test + @DisplayName("should not be applicable with --model-version 4.0.0") + void shouldNotBeApplicableWithModelVersion400() { + UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.0.0")); + assertFalse(strategy.isApplicable(context)); + } + + @Test + @DisplayName("should be applicable with --all") + void shouldBeApplicableWithAll() { + UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithAll(true)); + assertTrue(strategy.isApplicable(context)); + } + } + + @Nested + @DisplayName("Compiler Properties Migration") + class CompilerPropertiesTests { + + @Test + @DisplayName("should migrate maven.compiler.release to targetVersion") + void shouldMigrateCompilerRelease() { + String pomXml = """ + <?xml version="1.0" encoding="UTF-8"?> + <project xmlns="http://maven.apache.org/POM/4.1.0"> + <modelVersion>4.1.0</modelVersion> + <groupId>com.example</groupId> + <artifactId>test</artifactId> + <version>1.0</version> + <properties> + <maven.compiler.release>17</maven.compiler.release> + </properties> + </project> + """; + + Document doc = Document.of(pomXml); + UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.1.0")); + strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc))); + + Element source = doc.root() + .childElement("build") + .orElseThrow() + .childElement("sources") + .orElseThrow() + .childElement("source") + .orElseThrow(); + + assertEquals("17", source.childTextTrimmed("targetVersion")); + assertNull(DomUtils.findChildElement(doc.root(), "properties")); + } + + @Test + @DisplayName("should migrate matching maven.compiler.source and target") + void shouldMigrateMatchingSourceTarget() { + String pomXml = """ + <?xml version="1.0" encoding="UTF-8"?> + <project xmlns="http://maven.apache.org/POM/4.1.0"> + <modelVersion>4.1.0</modelVersion> + <groupId>com.example</groupId> + <artifactId>test</artifactId> + <version>1.0</version> + <properties> + <maven.compiler.source>11</maven.compiler.source> + <maven.compiler.target>11</maven.compiler.target> + </properties> + </project> + """; + + Document doc = Document.of(pomXml); + UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.1.0")); + strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc))); + + Element source = doc.root() + .childElement("build") + .orElseThrow() + .childElement("sources") + .orElseThrow() + .childElement("source") + .orElseThrow(); + + assertEquals("11", source.childTextTrimmed("targetVersion")); + assertNull(DomUtils.findChildElement(doc.root(), "properties")); + } + + @Test + @DisplayName("should skip when source and target differ") + void shouldSkipWhenSourceTargetDiffer() { + String pomXml = """ + <?xml version="1.0" encoding="UTF-8"?> + <project xmlns="http://maven.apache.org/POM/4.1.0"> + <modelVersion>4.1.0</modelVersion> + <groupId>com.example</groupId> + <artifactId>test</artifactId> + <version>1.0</version> + <properties> + <maven.compiler.source>11</maven.compiler.source> + <maven.compiler.target>17</maven.compiler.target> + </properties> + </project> + """; + + Document doc = Document.of(pomXml); + UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.1.0")); + UpgradeResult result = strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc))); + + assertFalse(doc.root().childElement("build").isPresent()); + assertNotNull(DomUtils.findChildElement(doc.root(), "properties")); + assertEquals(0, result.modifiedCount()); + } + + @Test + @DisplayName("should prefer release over source/target") + void shouldPreferReleaseOverSourceTarget() { + String pomXml = """ + <?xml version="1.0" encoding="UTF-8"?> + <project xmlns="http://maven.apache.org/POM/4.1.0"> + <modelVersion>4.1.0</modelVersion> + <groupId>com.example</groupId> + <artifactId>test</artifactId> + <version>1.0</version> + <properties> + <maven.compiler.release>21</maven.compiler.release> + <maven.compiler.source>17</maven.compiler.source> + <maven.compiler.target>17</maven.compiler.target> + </properties> + </project> + """; + + Document doc = Document.of(pomXml); + UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.1.0")); + strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc))); + + Element source = doc.root() + .childElement("build") + .orElseThrow() + .childElement("sources") + .orElseThrow() + .childElement("source") + .orElseThrow(); + + assertEquals("21", source.childTextTrimmed("targetVersion")); + assertNull(DomUtils.findChildElement(doc.root(), "properties")); + } + + @Test + @DisplayName("should keep other properties when removing compiler properties") + void shouldKeepOtherProperties() { + String pomXml = """ + <?xml version="1.0" encoding="UTF-8"?> + <project xmlns="http://maven.apache.org/POM/4.1.0"> + <modelVersion>4.1.0</modelVersion> + <groupId>com.example</groupId> + <artifactId>test</artifactId> + <version>1.0</version> + <properties> + <maven.compiler.release>17</maven.compiler.release> + <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> + </properties> + </project> + """; + + Document doc = Document.of(pomXml); + UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.1.0")); + strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc))); + + Element properties = DomUtils.findChildElement(doc.root(), "properties"); + assertNotNull(properties); + assertEquals("UTF-8", properties.childTextTrimmed("project.build.sourceEncoding")); + assertNull(DomUtils.findChildElement(properties, "maven.compiler.release")); + } + } + + @Nested + @DisplayName("Compiler Plugin Migration") + class CompilerPluginTests { + + @Test + @DisplayName("should migrate plugin release configuration") + void shouldMigratePluginRelease() { + String pomXml = """ + <?xml version="1.0" encoding="UTF-8"?> + <project xmlns="http://maven.apache.org/POM/4.1.0"> + <modelVersion>4.1.0</modelVersion> + <groupId>com.example</groupId> + <artifactId>test</artifactId> + <version>1.0</version> + <build> + <plugins> + <plugin> + <artifactId>maven-compiler-plugin</artifactId> + <configuration> + <release>17</release> + </configuration> + </plugin> + </plugins> + </build> + </project> + """; + + Document doc = Document.of(pomXml); + UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.1.0")); + strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc))); + + Element source = doc.root() + .childElement("build") + .orElseThrow() + .childElement("sources") + .orElseThrow() + .childElement("source") + .orElseThrow(); + + assertEquals("17", source.childTextTrimmed("targetVersion")); + + // Plugin should be removed since it has no remaining config + assertFalse(doc.root() + .childElement("build") + .orElseThrow() + .childElement("plugins") + .isPresent()); + } + + @Test + @DisplayName("should migrate plugin source/target configuration") + void shouldMigratePluginSourceTarget() { + String pomXml = """ + <?xml version="1.0" encoding="UTF-8"?> + <project xmlns="http://maven.apache.org/POM/4.1.0"> + <modelVersion>4.1.0</modelVersion> + <groupId>com.example</groupId> + <artifactId>test</artifactId> + <version>1.0</version> + <build> + <plugins> + <plugin> + <artifactId>maven-compiler-plugin</artifactId> + <configuration> + <source>11</source> + <target>11</target> + </configuration> + </plugin> + </plugins> + </build> + </project> + """; + + Document doc = Document.of(pomXml); + UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.1.0")); + strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc))); + + Element source = doc.root() + .childElement("build") + .orElseThrow() + .childElement("sources") + .orElseThrow() + .childElement("source") + .orElseThrow(); + + assertEquals("11", source.childTextTrimmed("targetVersion")); + } + + @Test + @DisplayName("should keep plugin with remaining executions") + void shouldKeepPluginWithExecutions() { + String pomXml = """ + <?xml version="1.0" encoding="UTF-8"?> + <project xmlns="http://maven.apache.org/POM/4.1.0"> + <modelVersion>4.1.0</modelVersion> + <groupId>com.example</groupId> + <artifactId>test</artifactId> + <version>1.0</version> + <build> + <plugins> + <plugin> + <artifactId>maven-compiler-plugin</artifactId> + <configuration> + <release>17</release> + </configuration> + <executions> + <execution> + <id>custom</id> + </execution> + </executions> + </plugin> + </plugins> + </build> + </project> + """; + + Document doc = Document.of(pomXml); + UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.1.0")); + strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc))); + + // Plugin should still exist (has executions) + Element plugin = doc.root() + .childElement("build") + .orElseThrow() + .childElement("plugins") + .orElseThrow() + .childElement("plugin") + .orElseThrow(); + + assertNotNull(plugin); + assertFalse(plugin.childElement("configuration").isPresent()); + assertTrue(plugin.childElement("executions").isPresent()); + } + + @Test + @DisplayName("should not extract from plugin when properties already provided targetVersion") + void shouldNotDuplicateTargetVersion() { + String pomXml = """ + <?xml version="1.0" encoding="UTF-8"?> + <project xmlns="http://maven.apache.org/POM/4.1.0"> + <modelVersion>4.1.0</modelVersion> + <groupId>com.example</groupId> + <artifactId>test</artifactId> + <version>1.0</version> + <properties> + <maven.compiler.release>21</maven.compiler.release> + </properties> + <build> + <plugins> + <plugin> + <artifactId>maven-compiler-plugin</artifactId> + <configuration> + <release>21</release> + </configuration> + </plugin> + </plugins> + </build> + </project> + """; + + Document doc = Document.of(pomXml); + UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.1.0")); + strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc))); + + // Should have exactly one <source> element with targetVersion from properties + var sources = doc.root() + .childElement("build") + .orElseThrow() + .childElement("sources") + .orElseThrow() + .childElements("source") + .toList(); + + assertEquals(1, sources.size()); + assertEquals("21", sources.get(0).childTextTrimmed("targetVersion")); + } + } + + @Nested + @DisplayName("Source Directory Migration") + class SourceDirectoryTests { + + @Test + @DisplayName("should migrate non-default sourceDirectory") + void shouldMigrateNonDefaultSourceDirectory() { + String pomXml = """ + <?xml version="1.0" encoding="UTF-8"?> + <project xmlns="http://maven.apache.org/POM/4.1.0"> + <modelVersion>4.1.0</modelVersion> + <groupId>com.example</groupId> + <artifactId>test</artifactId> + <version>1.0</version> + <build> + <sourceDirectory>src/main/java-custom</sourceDirectory> + </build> + </project> + """; + + Document doc = Document.of(pomXml); + UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.1.0")); + strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc))); + + Element source = doc.root() + .childElement("build") + .orElseThrow() + .childElement("sources") + .orElseThrow() + .childElement("source") + .orElseThrow(); + + assertEquals("src/main/java-custom", source.childTextTrimmed("directory")); + assertNull( + DomUtils.findChildElement(doc.root().childElement("build").orElseThrow(), "sourceDirectory")); + } + + @Test + @DisplayName("should migrate non-default testSourceDirectory") + void shouldMigrateNonDefaultTestSourceDirectory() { + String pomXml = """ + <?xml version="1.0" encoding="UTF-8"?> + <project xmlns="http://maven.apache.org/POM/4.1.0"> + <modelVersion>4.1.0</modelVersion> + <groupId>com.example</groupId> + <artifactId>test</artifactId> + <version>1.0</version> + <build> + <testSourceDirectory>src/test/java-custom</testSourceDirectory> + </build> + </project> + """; + + Document doc = Document.of(pomXml); + UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.1.0")); + strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc))); + + Element source = doc.root() + .childElement("build") + .orElseThrow() + .childElement("sources") + .orElseThrow() + .childElement("source") + .orElseThrow(); + + assertEquals("test", source.childTextTrimmed("scope")); + assertEquals("src/test/java-custom", source.childTextTrimmed("directory")); + } + + @Test + @DisplayName("should remove default sourceDirectory without creating source element") + void shouldRemoveDefaultSourceDirectory() { + String pomXml = """ + <?xml version="1.0" encoding="UTF-8"?> + <project xmlns="http://maven.apache.org/POM/4.1.0"> + <modelVersion>4.1.0</modelVersion> + <groupId>com.example</groupId> + <artifactId>test</artifactId> + <version>1.0</version> + <build> + <sourceDirectory>src/main/java</sourceDirectory> + </build> + </project> + """; + + Document doc = Document.of(pomXml); + UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.1.0")); + UpgradeResult result = strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc))); + + assertNull( + DomUtils.findChildElement(doc.root().childElement("build").orElseThrow(), "sourceDirectory")); + assertFalse(doc.root() + .childElement("build") + .orElseThrow() + .childElement("sources") + .isPresent()); + assertEquals(1, result.modifiedCount()); + } + } + + @Nested + @DisplayName("Resource Migration") + class ResourceTests { + + @Test + @DisplayName("should migrate resource with filtering") + void shouldMigrateResourceWithFiltering() { + String pomXml = """ + <?xml version="1.0" encoding="UTF-8"?> + <project xmlns="http://maven.apache.org/POM/4.1.0"> + <modelVersion>4.1.0</modelVersion> + <groupId>com.example</groupId> + <artifactId>test</artifactId> + <version>1.0</version> + <build> + <resources> + <resource> + <directory>src/main/resources</directory> + <filtering>true</filtering> + </resource> + </resources> + </build> + </project> + """; + + Document doc = Document.of(pomXml); + UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.1.0")); + strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc))); + + Element source = doc.root() + .childElement("build") + .orElseThrow() + .childElement("sources") + .orElseThrow() + .childElement("source") + .orElseThrow(); + + assertEquals("resources", source.childTextTrimmed("lang")); + assertEquals("true", source.childTextTrimmed("stringFiltering")); + assertNull( + DomUtils.findChildElement(doc.root().childElement("build").orElseThrow(), "resources")); + } + + @Test + @DisplayName("should migrate resource with includes and excludes") + void shouldMigrateResourceWithIncludesExcludes() { + String pomXml = """ + <?xml version="1.0" encoding="UTF-8"?> + <project xmlns="http://maven.apache.org/POM/4.1.0"> + <modelVersion>4.1.0</modelVersion> + <groupId>com.example</groupId> + <artifactId>test</artifactId> + <version>1.0</version> + <build> + <resources> + <resource> + <directory>src/main/resources</directory> + <includes> + <include>**/*.xml</include> + </includes> + <excludes> + <exclude>**/*.bak</exclude> + </excludes> + </resource> + </resources> + </build> + </project> + """; + + Document doc = Document.of(pomXml); + UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.1.0")); + strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc))); + + Element source = doc.root() + .childElement("build") + .orElseThrow() + .childElement("sources") + .orElseThrow() + .childElement("source") + .orElseThrow(); + + assertEquals("resources", source.childTextTrimmed("lang")); + + Element includes = source.childElement("includes").orElseThrow(); + assertEquals("**/*.xml", includes.childTextTrimmed("include")); + + Element excludes = source.childElement("excludes").orElseThrow(); + assertEquals("**/*.bak", excludes.childTextTrimmed("exclude")); + } + + @Test + @DisplayName("should migrate resource with targetPath") + void shouldMigrateResourceWithTargetPath() { + String pomXml = """ + <?xml version="1.0" encoding="UTF-8"?> + <project xmlns="http://maven.apache.org/POM/4.1.0"> + <modelVersion>4.1.0</modelVersion> + <groupId>com.example</groupId> + <artifactId>test</artifactId> + <version>1.0</version> + <build> + <resources> + <resource> + <directory>src/main/resources</directory> + <targetPath>META-INF</targetPath> + </resource> + </resources> + </build> + </project> + """; + + Document doc = Document.of(pomXml); + UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.1.0")); + strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc))); + + Element source = doc.root() + .childElement("build") + .orElseThrow() + .childElement("sources") + .orElseThrow() + .childElement("source") + .orElseThrow(); + + assertEquals("resources", source.childTextTrimmed("lang")); + assertEquals("META-INF", source.childTextTrimmed("targetPath")); + } + + @Test + @DisplayName("should migrate test resource") + void shouldMigrateTestResource() { + String pomXml = """ + <?xml version="1.0" encoding="UTF-8"?> + <project xmlns="http://maven.apache.org/POM/4.1.0"> + <modelVersion>4.1.0</modelVersion> + <groupId>com.example</groupId> + <artifactId>test</artifactId> + <version>1.0</version> + <build> + <testResources> + <testResource> + <directory>src/test/resources</directory> + <filtering>true</filtering> + </testResource> + </testResources> + </build> + </project> + """; + + Document doc = Document.of(pomXml); + UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.1.0")); + strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc))); + + Element source = doc.root() + .childElement("build") + .orElseThrow() + .childElement("sources") + .orElseThrow() + .childElement("source") + .orElseThrow(); + + assertEquals("test", source.childTextTrimmed("scope")); + assertEquals("resources", source.childTextTrimmed("lang")); + assertEquals("true", source.childTextTrimmed("stringFiltering")); + } + + @Test + @DisplayName("should remove default resource without creating source element") + void shouldRemoveDefaultResource() { + String pomXml = """ + <?xml version="1.0" encoding="UTF-8"?> + <project xmlns="http://maven.apache.org/POM/4.1.0"> + <modelVersion>4.1.0</modelVersion> + <groupId>com.example</groupId> + <artifactId>test</artifactId> + <version>1.0</version> + <build> + <resources> + <resource> + <directory>src/main/resources</directory> + </resource> + </resources> + </build> + </project> + """; + + Document doc = Document.of(pomXml); + UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.1.0")); + UpgradeResult result = strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc))); + + assertNull( + DomUtils.findChildElement(doc.root().childElement("build").orElseThrow(), "resources")); + assertFalse(doc.root() + .childElement("build") + .orElseThrow() + .childElement("sources") + .isPresent()); + assertEquals(1, result.modifiedCount()); + } + } + + @Nested + @DisplayName("Model Version Filtering") + class ModelVersionFilteringTests { + + @Test + @DisplayName("should skip POM at model version 4.0.0") + void shouldSkipPomAt400() { + String pomXml = """ + <?xml version="1.0" encoding="UTF-8"?> + <project xmlns="http://maven.apache.org/POM/4.0.0"> + <modelVersion>4.0.0</modelVersion> + <groupId>com.example</groupId> + <artifactId>test</artifactId> + <version>1.0</version> + <properties> + <maven.compiler.release>17</maven.compiler.release> + </properties> + </project> + """; + + Document doc = Document.of(pomXml); + UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.1.0")); + UpgradeResult result = strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc))); + + assertNotNull(DomUtils.findChildElement(doc.root(), "properties")); + assertEquals(0, result.modifiedCount()); + } + + @Test + @DisplayName("should process POM at model version 4.1.0") + void shouldProcessPomAt410() { + String pomXml = """ + <?xml version="1.0" encoding="UTF-8"?> + <project xmlns="http://maven.apache.org/POM/4.1.0"> + <modelVersion>4.1.0</modelVersion> + <groupId>com.example</groupId> + <artifactId>test</artifactId> + <version>1.0</version> + <properties> + <maven.compiler.release>17</maven.compiler.release> + </properties> + </project> + """; + + Document doc = Document.of(pomXml); + UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.1.0")); + UpgradeResult result = strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc))); + + assertEquals(1, result.modifiedCount()); + } + } + + @Nested + @DisplayName("Combined Migration") + class CombinedTests { + + @Test + @DisplayName("should merge targetVersion and directory into single source element") + void shouldMergeTargetVersionAndDirectory() { + String pomXml = """ + <?xml version="1.0" encoding="UTF-8"?> + <project xmlns="http://maven.apache.org/POM/4.1.0"> + <modelVersion>4.1.0</modelVersion> + <groupId>com.example</groupId> + <artifactId>test</artifactId> + <version>1.0</version> + <properties> + <maven.compiler.release>17</maven.compiler.release> + </properties> + <build> + <sourceDirectory>src/main/java-custom</sourceDirectory> + </build> + </project> + """; + + Document doc = Document.of(pomXml); + UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.1.0")); + strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc))); + + var sources = doc.root() + .childElement("build") + .orElseThrow() + .childElement("sources") + .orElseThrow() + .childElements("source") + .toList(); + + assertEquals(1, sources.size()); + assertEquals("17", sources.get(0).childTextTrimmed("targetVersion")); + assertEquals("src/main/java-custom", sources.get(0).childTextTrimmed("directory")); + } + + @Test + @DisplayName("should create multiple source elements for different resource configs") + void shouldCreateMultipleResourceSources() { + String pomXml = """ + <?xml version="1.0" encoding="UTF-8"?> + <project xmlns="http://maven.apache.org/POM/4.1.0"> + <modelVersion>4.1.0</modelVersion> + <groupId>com.example</groupId> + <artifactId>test</artifactId> + <version>1.0</version> + <build> + <resources> + <resource> + <directory>src/main/resources</directory> + <filtering>true</filtering> + </resource> + <resource> + <directory>src/main/resources-extra</directory> + </resource> + </resources> + </build> + </project> + """; + + Document doc = Document.of(pomXml); + UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.1.0")); + strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc))); + + var sources = doc.root() + .childElement("build") + .orElseThrow() + .childElement("sources") + .orElseThrow() + .childElements("source") + .toList(); + + assertEquals(2, sources.size()); + } + } +}
