diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/ToolchainPluginStrategy.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/ToolchainPluginStrategy.java
new file mode 100644
index 000000000000..42ff9250abc5
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/ToolchainPluginStrategy.java
@@ -0,0 +1,347 @@
+/*
+ * 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.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 org.apache.maven.impl.JdkSourceLevelSupport;
+
+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.GROUP_ID;
+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;
+
+/**
+ * Strategy for adding the {@code maven-toolchains-plugin} with the {@code select-jdk-toolchain}
+ * goal when the project's required {@code --source}/{@code --release} level is no longer supported
+ * by the running JDK.
+ *
+ *
This strategy detects the project's source level from:
+ *
+ *
{@code maven.compiler.release} property
+ *
{@code maven.compiler.source} property
+ *
Compiler plugin {@code } or {@code }
+ *
+ *
+ *
If the running JDK does not support the detected source level (per JEP 182 retirement
+ * schedule), and the {@code maven-toolchains-plugin} is not already configured with the
+ * {@code select-jdk-toolchain} goal, this strategy adds it so that the plugin's built-in
+ * JDK discovery mechanism can find a compatible JDK at build time.
+ *
+ * @see JEP 182: Policy for Retiring javac -source and -target Options
+ */
+@Named
+@Singleton
+@Priority(15)
+public class ToolchainPluginStrategy extends AbstractUpgradeStrategy {
+
+ static final String MAVEN_TOOLCHAINS_PLUGIN = "maven-toolchains-plugin";
+ static final String TOOLCHAINS_PLUGIN_GROUP_ID = "org.apache.maven.plugins";
+ static final String SELECT_JDK_TOOLCHAIN_GOAL = "select-jdk-toolchain";
+
+ 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_PLUGIN = "maven-compiler-plugin";
+
+ @Override
+ public boolean isApplicable(UpgradeContext context) {
+ UpgradeOptions options = getOptions(context);
+
+ if (options.all().orElse(false)) {
+ return true;
+ }
+
+ // Same default logic as CompatibilityFixStrategy: run when no specific options
+ boolean noOptionsSpecified = options.all().isEmpty()
+ && options.infer().isEmpty()
+ && options.model().isEmpty()
+ && options.plugins().isEmpty()
+ && options.modelVersion().isEmpty();
+
+ if (noOptionsSpecified) {
+ return true;
+ }
+
+ // Run when --model is explicitly set
+ if (options.model().isPresent()) {
+ return options.model().get();
+ }
+
+ return false;
+ }
+
+ @Override
+ public String getDescription() {
+ return "Adding maven-toolchains-plugin for JDK source level compatibility";
+ }
+
+ @Override
+ protected UpgradeResult doApply(UpgradeContext context, Map pomMap) {
+ Set processedPoms = new HashSet<>();
+ Set modifiedPoms = new HashSet<>();
+ Set errorPoms = new HashSet<>();
+
+ int runningJdkMajor = getRunningJdkMajor();
+
+ for (Map.Entry entry : pomMap.entrySet()) {
+ Path pomPath = entry.getKey();
+ Document pomDocument = entry.getValue();
+ processedPoms.add(pomPath);
+
+ context.info(pomPath + " (checking JDK source level compatibility)");
+ context.indent();
+
+ try {
+ int sourceLevel = detectSourceLevel(pomDocument);
+ if (sourceLevel <= 0) {
+ context.success("No source level configured");
+ continue;
+ }
+
+ if (JdkSourceLevelSupport.supportsSourceLevel(runningJdkMajor, sourceLevel)) {
+ context.success("Running JDK " + runningJdkMajor + " supports --source " + sourceLevel);
+ continue;
+ }
+
+ if (hasToolchainsPluginWithSelectGoal(pomDocument)) {
+ context.success(
+ "maven-toolchains-plugin with " + SELECT_JDK_TOOLCHAIN_GOAL + " goal already configured");
+ continue;
+ }
+
+ int latestJdk = JdkSourceLevelSupport.latestJdkForSourceLevel(sourceLevel);
+ addToolchainsPlugin(pomDocument, latestJdk);
+ modifiedPoms.add(pomPath);
+ context.success("Added maven-toolchains-plugin with " + SELECT_JDK_TOOLCHAIN_GOAL + " goal (--source "
+ + sourceLevel + " requires JDK <= " + latestJdk + ")");
+ } catch (Exception e) {
+ context.failure("Failed to add toolchains plugin: " + e.getMessage());
+ errorPoms.add(pomPath);
+ } finally {
+ context.unindent();
+ }
+ }
+
+ return new UpgradeResult(processedPoms, modifiedPoms, errorPoms);
+ }
+
+ /**
+ * Detects the project's required source level from properties or compiler plugin configuration.
+ *
+ * @return the source level as a major version, or {@code -1} if none is configured
+ */
+ int detectSourceLevel(Document pomDocument) {
+ Element root = pomDocument.root();
+
+ // Check properties: maven.compiler.release takes precedence
+ Element properties = root.childElement(PROPERTIES).orElse(null);
+ if (properties != null) {
+ Element releaseElement =
+ properties.childElement(MAVEN_COMPILER_RELEASE).orElse(null);
+ if (releaseElement != null) {
+ int level = JdkSourceLevelSupport.normalizeSourceLevel(
+ releaseElement.textContent().trim());
+ if (level > 0) {
+ return level;
+ }
+ }
+
+ Element sourceElement =
+ properties.childElement(MAVEN_COMPILER_SOURCE).orElse(null);
+ if (sourceElement != null) {
+ int level = JdkSourceLevelSupport.normalizeSourceLevel(
+ sourceElement.textContent().trim());
+ if (level > 0) {
+ return level;
+ }
+ }
+ }
+
+ // Check compiler plugin configuration
+ return detectSourceLevelFromCompilerPlugin(root);
+ }
+
+ private int detectSourceLevelFromCompilerPlugin(Element root) {
+ Element build = root.childElement(BUILD).orElse(null);
+ if (build == null) {
+ return -1;
+ }
+
+ // Check
+ int level = detectFromPluginSection(build.childElement(PLUGINS).orElse(null));
+ if (level > 0) {
+ return level;
+ }
+
+ // Check
+ Element pluginManagement = build.childElement(PLUGIN_MANAGEMENT).orElse(null);
+ if (pluginManagement != null) {
+ level = detectFromPluginSection(
+ pluginManagement.childElement(PLUGINS).orElse(null));
+ }
+
+ return level;
+ }
+
+ private int detectFromPluginSection(Element pluginsElement) {
+ if (pluginsElement == null) {
+ return -1;
+ }
+
+ for (Element plugin : pluginsElement.childElements(PLUGIN).toList()) {
+ String artifactId = plugin.childTextTrimmed(ARTIFACT_ID);
+ String groupId = plugin.childTextTrimmed(GROUP_ID);
+ if (MAVEN_COMPILER_PLUGIN.equals(artifactId)
+ && (groupId == null || groupId.isEmpty() || TOOLCHAINS_PLUGIN_GROUP_ID.equals(groupId))) {
+ Element config = plugin.childElement(CONFIGURATION).orElse(null);
+ if (config != null) {
+ // takes precedence
+ Element releaseNode = config.childElement("release").orElse(null);
+ if (releaseNode != null
+ && releaseNode.textContent() != null
+ && !releaseNode.textContent().isBlank()) {
+ int level = JdkSourceLevelSupport.normalizeSourceLevel(
+ releaseNode.textContent().trim());
+ if (level > 0) {
+ return level;
+ }
+ }
+ Element sourceNode = config.childElement("source").orElse(null);
+ if (sourceNode != null
+ && sourceNode.textContent() != null
+ && !sourceNode.textContent().isBlank()) {
+ int level = JdkSourceLevelSupport.normalizeSourceLevel(
+ sourceNode.textContent().trim());
+ if (level > 0) {
+ return level;
+ }
+ }
+ }
+ }
+ }
+
+ return -1;
+ }
+
+ /**
+ * Checks whether the POM already has the {@code maven-toolchains-plugin}
+ * configured with the {@code select-jdk-toolchain} goal.
+ */
+ boolean hasToolchainsPluginWithSelectGoal(Document pomDocument) {
+ Element root = pomDocument.root();
+ Element build = root.childElement(BUILD).orElse(null);
+ if (build == null) {
+ return false;
+ }
+
+ // Check both and
+ if (hasSelectGoalInPluginSection(build.childElement(PLUGINS).orElse(null))) {
+ return true;
+ }
+
+ Element pluginManagement = build.childElement(PLUGIN_MANAGEMENT).orElse(null);
+ if (pluginManagement != null) {
+ return hasSelectGoalInPluginSection(
+ pluginManagement.childElement(PLUGINS).orElse(null));
+ }
+
+ return false;
+ }
+
+ private boolean hasSelectGoalInPluginSection(Element pluginsElement) {
+ if (pluginsElement == null) {
+ return false;
+ }
+
+ for (Element plugin : pluginsElement.childElements(PLUGIN).toList()) {
+ String artifactId = plugin.childTextTrimmed(ARTIFACT_ID);
+ String groupId = plugin.childTextTrimmed(GROUP_ID);
+ if (MAVEN_TOOLCHAINS_PLUGIN.equals(artifactId)
+ && (groupId == null || groupId.isEmpty() || TOOLCHAINS_PLUGIN_GROUP_ID.equals(groupId))) {
+ // Check if it has the select-jdk-toolchain goal in any execution
+ Element executions = plugin.childElement("executions").orElse(null);
+ if (executions != null) {
+ for (Element execution :
+ executions.childElements("execution").toList()) {
+ Element goals = execution.childElement("goals").orElse(null);
+ if (goals != null) {
+ for (Element goal : goals.childElements("goal").toList()) {
+ if (SELECT_JDK_TOOLCHAIN_GOAL.equals(goal.textContentTrimmed())) {
+ return true;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Adds the {@code maven-toolchains-plugin} with {@code select-jdk-toolchain} goal
+ * to the POM's {@code } section, configured with a version constraint
+ * so the plugin selects a JDK that supports the project's source level.
+ *
+ * @param pomDocument the POM document to modify
+ * @param maxJdkVersion the latest JDK major version that supports the source level
+ */
+ void addToolchainsPlugin(Document pomDocument, int maxJdkVersion) {
+ Element root = pomDocument.root();
+ Element build = root.childElement(BUILD).orElse(null);
+ if (build == null) {
+ build = DomUtils.insertNewElement(BUILD, root);
+ }
+ Element plugins = build.childElement(PLUGINS).orElse(null);
+ if (plugins == null) {
+ plugins = DomUtils.insertNewElement(PLUGINS, build);
+ }
+
+ Element plugin = DomUtils.createPlugin(plugins, TOOLCHAINS_PLUGIN_GROUP_ID, MAVEN_TOOLCHAINS_PLUGIN, null);
+ Element executions = DomUtils.insertNewElement("executions", plugin);
+ Element execution = DomUtils.insertNewElement("execution", executions);
+ Element goals = DomUtils.insertNewElement("goals", execution);
+ DomUtils.insertContentElement(goals, "goal", SELECT_JDK_TOOLCHAIN_GOAL);
+ Element configuration = DomUtils.insertNewElement(CONFIGURATION, execution);
+ DomUtils.insertContentElement(configuration, "version", "(," + maxJdkVersion + "]");
+ }
+
+ /**
+ * Returns the major version of the running JDK.
+ * Extracted as a method so tests can override it.
+ */
+ int getRunningJdkMajor() {
+ return JdkSourceLevelSupport.getRunningJdkMajor();
+ }
+}
diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/ToolchainPluginStrategyTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/ToolchainPluginStrategyTest.java
new file mode 100644
index 000000000000..7414d262a124
--- /dev/null
+++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/ToolchainPluginStrategyTest.java
@@ -0,0 +1,531 @@
+/*
+ * 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.nio.file.Paths;
+import java.util.Map;
+
+import eu.maveniverse.domtrip.Document;
+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.assertTrue;
+
+/**
+ * Unit tests for the {@link ToolchainPluginStrategy} class.
+ */
+@DisplayName("ToolchainPluginStrategy")
+class ToolchainPluginStrategyTest {
+
+ private static final Path POM_PATH = Paths.get("/project/pom.xml");
+
+ @Nested
+ @DisplayName("Applicability")
+ class ApplicabilityTests {
+
+ @Test
+ @DisplayName("should be applicable when --all option is true")
+ void applicableWithAll() {
+ ToolchainPluginStrategy strategy = new ToolchainPluginStrategy();
+ assertTrue(strategy.isApplicable(TestUtils.createMockContext(TestUtils.createOptionsWithAll(true))));
+ }
+
+ @Test
+ @DisplayName("should be applicable with default options (no flags)")
+ void applicableWithDefaults() {
+ ToolchainPluginStrategy strategy = new ToolchainPluginStrategy();
+ assertTrue(strategy.isApplicable(TestUtils.createMockContext()));
+ }
+
+ @Test
+ @DisplayName("should be applicable when --model is true")
+ void applicableWithModel() {
+ ToolchainPluginStrategy strategy = new ToolchainPluginStrategy();
+ assertTrue(strategy.isApplicable(TestUtils.createMockContext(TestUtils.createOptionsWithFixModel(true))));
+ }
+
+ @Test
+ @DisplayName("should not be applicable when --model is false")
+ void notApplicableWithModelFalse() {
+ ToolchainPluginStrategy strategy = new ToolchainPluginStrategy();
+ assertFalse(strategy.isApplicable(TestUtils.createMockContext(TestUtils.createOptionsWithFixModel(false))));
+ }
+ }
+
+ @Nested
+ @DisplayName("Source level detection")
+ class SourceLevelDetectionTests {
+
+ private ToolchainPluginStrategy strategy;
+
+ @BeforeEach
+ void setUp() {
+ strategy = new ToolchainPluginStrategy();
+ }
+
+ @Test
+ @DisplayName("should detect source level from maven.compiler.release property")
+ void detectFromRelease() {
+ String pomXml = """
+
+
+ 4.0.0
+ com.example
+ test
+ 1.0
+
+ 6
+
+
+ """;
+ Document doc = Document.of(pomXml);
+ assertEquals(6, strategy.detectSourceLevel(doc));
+ }
+
+ @Test
+ @DisplayName("should detect source level from maven.compiler.source property")
+ void detectFromSource() {
+ String pomXml = """
+
+
+ 4.0.0
+ com.example
+ test
+ 1.0
+
+ 1.6
+
+
+ """;
+ Document doc = Document.of(pomXml);
+ assertEquals(6, strategy.detectSourceLevel(doc));
+ }
+
+ @Test
+ @DisplayName("release property takes precedence over source property")
+ void releaseTakesPrecedenceOverSource() {
+ String pomXml = """
+
+
+ 4.0.0
+ com.example
+ test
+ 1.0
+
+ 7
+ 6
+
+
+ """;
+ Document doc = Document.of(pomXml);
+ assertEquals(7, strategy.detectSourceLevel(doc));
+ }
+
+ @Test
+ @DisplayName("should detect from compiler plugin ")
+ void detectFromCompilerPluginRelease() {
+ String pomXml = """
+
+
+ 4.0.0
+ com.example
+ test
+ 1.0
+
+
+
+ maven-compiler-plugin
+
+ 6
+
+
+
+
+
+ """;
+ Document doc = Document.of(pomXml);
+ assertEquals(6, strategy.detectSourceLevel(doc));
+ }
+
+ @Test
+ @DisplayName("should detect from compiler plugin ")
+ void detectFromCompilerPluginSource() {
+ String pomXml = """
+
+
+ 4.0.0
+ com.example
+ test
+ 1.0
+
+
+
+ maven-compiler-plugin
+
+ 1.5
+
+
+
+
+
+ """;
+ Document doc = Document.of(pomXml);
+ assertEquals(5, strategy.detectSourceLevel(doc));
+ }
+
+ @Test
+ @DisplayName("should return -1 when no source level configured")
+ void noSourceLevel() {
+ String pomXml = """
+
+
+ 4.0.0
+ com.example
+ test
+ 1.0
+
+ """;
+ Document doc = Document.of(pomXml);
+ assertEquals(-1, strategy.detectSourceLevel(doc));
+ }
+
+ @Test
+ @DisplayName("should detect from pluginManagement")
+ void detectFromPluginManagement() {
+ String pomXml = """
+
+
+ 4.0.0
+ com.example
+ test
+ 1.0
+
+
+
+
+ maven-compiler-plugin
+
+ 6
+
+
+
+
+
+
+ """;
+ Document doc = Document.of(pomXml);
+ assertEquals(6, strategy.detectSourceLevel(doc));
+ }
+ }
+
+ @Nested
+ @DisplayName("Toolchains plugin detection")
+ class ToolchainsPluginDetectionTests {
+
+ private ToolchainPluginStrategy strategy;
+
+ @BeforeEach
+ void setUp() {
+ strategy = new ToolchainPluginStrategy();
+ }
+
+ @Test
+ @DisplayName("should detect existing select-jdk-toolchain goal")
+ void detectExistingGoal() {
+ String pomXml = """
+
+
+ 4.0.0
+ com.example
+ test
+ 1.0
+
+
+
+ org.apache.maven.plugins
+ maven-toolchains-plugin
+
+
+
+ select-jdk-toolchain
+
+
+
+
+
+
+
+ """;
+ Document doc = Document.of(pomXml);
+ assertTrue(strategy.hasToolchainsPluginWithSelectGoal(doc));
+ }
+
+ @Test
+ @DisplayName("should not detect toolchains plugin without select-jdk-toolchain goal")
+ void noSelectGoal() {
+ String pomXml = """
+
+
+ 4.0.0
+ com.example
+ test
+ 1.0
+
+
+
+ org.apache.maven.plugins
+ maven-toolchains-plugin
+
+
+
+ toolchain
+
+
+
+
+
+
+
+ """;
+ Document doc = Document.of(pomXml);
+ assertFalse(strategy.hasToolchainsPluginWithSelectGoal(doc));
+ }
+
+ @Test
+ @DisplayName("should not detect when no toolchains plugin present")
+ void noToolchainsPlugin() {
+ String pomXml = """
+
+
+ 4.0.0
+ com.example
+ test
+ 1.0
+
+ """;
+ Document doc = Document.of(pomXml);
+ assertFalse(strategy.hasToolchainsPluginWithSelectGoal(doc));
+ }
+ }
+
+ @Nested
+ @DisplayName("Plugin addition")
+ class PluginAdditionTests {
+
+ private ToolchainPluginStrategy strategy;
+
+ @BeforeEach
+ void setUp() {
+ strategy = new ToolchainPluginStrategy();
+ }
+
+ @Test
+ @DisplayName("should add toolchains plugin with version constraint to POM without build section")
+ void addToEmptyPom() {
+ String pomXml = """
+
+
+ 4.0.0
+ com.example
+ test
+ 1.0
+
+ """;
+ Document doc = Document.of(pomXml);
+ strategy.addToolchainsPlugin(doc, 11);
+
+ assertTrue(strategy.hasToolchainsPluginWithSelectGoal(doc));
+ String output = doc.toXml();
+ assertTrue(output.contains("(,11]"), "Expected version constraint in output: " + output);
+ }
+
+ @Test
+ @DisplayName("should add toolchains plugin with version constraint to POM with existing build section")
+ void addToExistingBuild() {
+ String pomXml = """
+
+
+ 4.0.0
+ com.example
+ test
+ 1.0
+
+
+
+ maven-compiler-plugin
+
+
+
+
+ """;
+ Document doc = Document.of(pomXml);
+ strategy.addToolchainsPlugin(doc, 8);
+
+ assertTrue(strategy.hasToolchainsPluginWithSelectGoal(doc));
+ String output = doc.toXml();
+ assertTrue(output.contains("(,8]"), "Expected version constraint in output: " + output);
+ }
+ }
+
+ @Nested
+ @DisplayName("Full apply")
+ class ApplyTests {
+
+ @Test
+ @DisplayName("should add plugin when source level is incompatible with running JDK")
+ void addsPluginWhenIncompatible() {
+ // Simulate running JDK 21, project targets source 6
+ ToolchainPluginStrategy strategy = new ToolchainPluginStrategy() {
+ @Override
+ int getRunningJdkMajor() {
+ return 21;
+ }
+ };
+
+ String pomXml = """
+
+
+ 4.0.0
+ com.example
+ test
+ 1.0
+
+ 6
+
+
+ """;
+ Document doc = Document.of(pomXml);
+ UpgradeContext context = TestUtils.createMockContext();
+
+ UpgradeResult result = strategy.doApply(context, Map.of(POM_PATH, doc));
+
+ assertEquals(1, result.modifiedPoms().size());
+ assertTrue(strategy.hasToolchainsPluginWithSelectGoal(doc));
+ }
+
+ @Test
+ @DisplayName("should not modify POM when source level is compatible")
+ void noModificationWhenCompatible() {
+ // Simulate running JDK 17, project targets source 11
+ ToolchainPluginStrategy strategy = new ToolchainPluginStrategy() {
+ @Override
+ int getRunningJdkMajor() {
+ return 17;
+ }
+ };
+
+ String pomXml = """
+
+
+ 4.0.0
+ com.example
+ test
+ 1.0
+
+ 11
+
+
+ """;
+ Document doc = Document.of(pomXml);
+ UpgradeContext context = TestUtils.createMockContext();
+
+ UpgradeResult result = strategy.doApply(context, Map.of(POM_PATH, doc));
+
+ assertEquals(0, result.modifiedPoms().size());
+ }
+
+ @Test
+ @DisplayName("should not modify POM when no source level configured")
+ void noModificationWithoutSourceLevel() {
+ ToolchainPluginStrategy strategy = new ToolchainPluginStrategy() {
+ @Override
+ int getRunningJdkMajor() {
+ return 21;
+ }
+ };
+
+ String pomXml = """
+
+
+ 4.0.0
+ com.example
+ test
+ 1.0
+
+ """;
+ Document doc = Document.of(pomXml);
+ UpgradeContext context = TestUtils.createMockContext();
+
+ UpgradeResult result = strategy.doApply(context, Map.of(POM_PATH, doc));
+
+ assertEquals(0, result.modifiedPoms().size());
+ }
+
+ @Test
+ @DisplayName("should not add duplicate plugin when already present")
+ void noDuplicatePlugin() {
+ ToolchainPluginStrategy strategy = new ToolchainPluginStrategy() {
+ @Override
+ int getRunningJdkMajor() {
+ return 21;
+ }
+ };
+
+ String pomXml = """
+
+
+ 4.0.0
+ com.example
+ test
+ 1.0
+
+ 6
+
+
+
+
+ org.apache.maven.plugins
+ maven-toolchains-plugin
+
+
+
+ select-jdk-toolchain
+
+
+
+
+
+
+
+ """;
+ Document doc = Document.of(pomXml);
+ UpgradeContext context = TestUtils.createMockContext();
+
+ UpgradeResult result = strategy.doApply(context, Map.of(POM_PATH, doc));
+
+ assertEquals(0, result.modifiedPoms().size());
+ }
+ }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultToolchainManager.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultToolchainManager.java
index cc2cb0360bc3..73a0e2db3599 100644
--- a/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultToolchainManager.java
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultToolchainManager.java
@@ -35,12 +35,16 @@
import org.apache.maven.api.di.Inject;
import org.apache.maven.api.di.Named;
import org.apache.maven.api.di.Singleton;
+import org.apache.maven.api.model.Build;
+import org.apache.maven.api.model.Plugin;
+import org.apache.maven.api.model.Source;
import org.apache.maven.api.services.Lookup;
import org.apache.maven.api.services.ToolchainFactory;
import org.apache.maven.api.services.ToolchainFactoryException;
import org.apache.maven.api.services.ToolchainManager;
import org.apache.maven.api.services.ToolchainManagerException;
import org.apache.maven.api.toolchain.ToolchainModel;
+import org.apache.maven.api.xml.XmlNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -52,13 +56,13 @@ public class DefaultToolchainManager implements ToolchainManager {
@Inject
public DefaultToolchainManager(Map factories) {
- this(factories, null);
+ this(factories, (Logger) null);
}
/**
- * Used for tests only
+ * Constructor with custom logger. Used by the compatibility layer and tests.
*/
- protected DefaultToolchainManager(Map factories, Logger logger) {
+ public DefaultToolchainManager(Map factories, Logger logger) {
this.factories = factories;
this.logger = logger != null ? logger : LoggerFactory.getLogger(DefaultToolchainManager.class);
}
@@ -89,7 +93,17 @@ public Optional getToolchainFromBuildContext(@Nonnull Session session
throws ToolchainManagerException {
Map context = retrieveContext(session);
ToolchainModel model = (ToolchainModel) context.get("toolchain-" + type);
- return Optional.ofNullable(model).flatMap(this::createToolchain);
+ if (model != null) {
+ return createToolchain(model);
+ }
+
+ // For JDK type, check if the running JDK supports the project's source level
+ // and emit a clear, actionable error if not
+ if ("jdk".equals(type)) {
+ checkJdkSourceLevelCompatibility(session);
+ }
+
+ return Optional.empty();
}
@Override
@@ -98,6 +112,154 @@ public void storeToolchainToBuildContext(@Nonnull Session session, @Nonnull Tool
context.put("toolchain-" + toolchain.getType(), toolchain.getModel());
}
+ /**
+ * Checks whether the running JDK supports the project's required {@code --source}/{@code --release}
+ * level. If not, emits a clear, actionable error message instead of letting the build fail later
+ * with a cryptic javac error.
+ *
+ * The error tells the user exactly which JDK version they need and how to fix it
+ * (run {@code mvnup} to add the {@code maven-toolchains-plugin} with automatic JDK discovery).
+ */
+ void checkJdkSourceLevelCompatibility(Session session) {
+ int requiredSourceLevel = getProjectRequiredSourceLevel(session);
+ if (requiredSourceLevel <= 0) {
+ return;
+ }
+
+ int runningJdkMajor = getRunningJdkMajor();
+ if (JdkSourceLevelSupport.supportsSourceLevel(runningJdkMajor, requiredSourceLevel)) {
+ return;
+ }
+
+ // Running JDK is incompatible — emit clear, actionable error
+ int latestJdk = JdkSourceLevelSupport.latestJdkForSourceLevel(requiredSourceLevel);
+ logger.error(
+ "Project requires --source {} which needs JDK <= {}, but the running JDK {} no longer supports it.",
+ requiredSourceLevel,
+ latestJdk,
+ runningJdkMajor);
+ logger.error("To fix: run 'mvnup' to add the maven-toolchains-plugin with automatic JDK discovery,");
+ logger.error(
+ "or install JDK {} and configure it in toolchains.xml or via the maven-toolchains-plugin.", latestJdk);
+ }
+
+ /**
+ * Reads the project's required source level from Model 4.1.0
+ * {@code } elements, legacy properties
+ * ({@code maven.compiler.release}, {@code maven.compiler.source}),
+ * or compiler plugin configuration ({@code }, {@code }).
+ *
+ * @return the required source level as a major version, or {@code -1} if none is specified
+ */
+ int getProjectRequiredSourceLevel(Session session) {
+ Optional current = session.getService(Lookup.class).lookupOptional(Project.class);
+ if (current.isEmpty()) {
+ return -1;
+ }
+
+ Project project = current.get();
+
+ // Check Model 4.1.0 elements
+ Build build = project.getModel().getBuild();
+ if (build != null) {
+ List sources = build.getSources();
+ if (sources != null) {
+ for (Source source : sources) {
+ String targetVersion = source.getTargetVersion();
+ if (targetVersion != null && !targetVersion.isEmpty()) {
+ int level = JdkSourceLevelSupport.normalizeSourceLevel(targetVersion);
+ if (level > 0) {
+ return level;
+ }
+ }
+ }
+ }
+ }
+
+ // Fall back to legacy properties
+ Map properties = project.getModel().getProperties();
+ if (properties != null) {
+ // maven.compiler.release takes precedence
+ String release = properties.get("maven.compiler.release");
+ if (release != null && !release.isEmpty()) {
+ int level = JdkSourceLevelSupport.normalizeSourceLevel(release);
+ if (level > 0) {
+ return level;
+ }
+ }
+
+ // Then maven.compiler.source
+ String source = properties.get("maven.compiler.source");
+ if (source != null && !source.isEmpty()) {
+ int level = JdkSourceLevelSupport.normalizeSourceLevel(source);
+ if (level > 0) {
+ return level;
+ }
+ }
+ }
+
+ // Fall back to compiler plugin configuration (, )
+ int pluginLevel = getSourceLevelFromCompilerPlugin(build);
+ if (pluginLevel > 0) {
+ return pluginLevel;
+ }
+
+ return -1;
+ }
+
+ /**
+ * Reads the source level from the maven-compiler-plugin configuration.
+ * Checks both {@code } and {@code } elements in the plugin's
+ * {@code } block.
+ *
+ * @return the source level, or {@code -1} if not configured
+ */
+ private int getSourceLevelFromCompilerPlugin(Build build) {
+ if (build == null) {
+ return -1;
+ }
+ for (Plugin plugin : build.getPlugins()) {
+ if ("maven-compiler-plugin".equals(plugin.getArtifactId())
+ && (plugin.getGroupId() == null
+ || plugin.getGroupId().isEmpty()
+ || "org.apache.maven.plugins".equals(plugin.getGroupId()))) {
+ XmlNode config = plugin.getConfiguration();
+ if (config != null) {
+ // takes precedence over
+ XmlNode releaseNode = config.child("release");
+ if (releaseNode != null
+ && releaseNode.value() != null
+ && !releaseNode.value().isBlank()) {
+ int level = JdkSourceLevelSupport.normalizeSourceLevel(
+ releaseNode.value().trim());
+ if (level > 0) {
+ return level;
+ }
+ }
+ XmlNode sourceNode = config.child("source");
+ if (sourceNode != null
+ && sourceNode.value() != null
+ && !sourceNode.value().isBlank()) {
+ int level = JdkSourceLevelSupport.normalizeSourceLevel(
+ sourceNode.value().trim());
+ if (level > 0) {
+ return level;
+ }
+ }
+ }
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * Returns the major version of the running JDK.
+ * Extracted as a method so tests can override it.
+ */
+ int getRunningJdkMajor() {
+ return JdkSourceLevelSupport.getRunningJdkMajor();
+ }
+
private Optional createToolchain(ToolchainModel model) {
String type = Objects.requireNonNull(model.getType(), "model.getType()");
ToolchainFactory factory = factories.get(type);
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/JdkSourceLevelSupport.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/JdkSourceLevelSupport.java
new file mode 100644
index 000000000000..7571829a7da4
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/JdkSourceLevelSupport.java
@@ -0,0 +1,162 @@
+/*
+ * 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.impl;
+
+/**
+ * Utility class for JDK source level compatibility checks.
+ *
+ * Maps JDK major versions to their supported {@code --source}/{@code --release} levels,
+ * based on the javac retirement schedule defined in
+ * JEP 182 and subsequent JDK releases.
+ *
+ * The retirement schedule follows these milestones:
+ *
+ *
JDK 9: removed {@code --source 1} through {@code 5}, minimum is {@code 6}
+ *
JDK 12: removed {@code --source 6}, minimum is {@code 7}
+ *
JDK 21: removed {@code --source 7}, minimum is {@code 8}
+ *
+ */
+public final class JdkSourceLevelSupport {
+
+ private JdkSourceLevelSupport() {}
+
+ /**
+ * Returns the minimum {@code --source} level supported by a given JDK major version.
+ *
+ * @param jdkMajor the JDK major version (e.g., {@code 17}, {@code 21})
+ * @return the minimum supported source level
+ */
+ public static int minimumSupportedSourceLevel(int jdkMajor) {
+ if (jdkMajor <= 8) {
+ return 1;
+ }
+ if (jdkMajor <= 11) {
+ return 6;
+ }
+ if (jdkMajor <= 20) {
+ return 7;
+ }
+ return 8;
+ }
+
+ /**
+ * Returns whether a given JDK version supports the specified {@code --source} level.
+ *
+ * @param jdkMajor the JDK major version
+ * @param sourceLevel the desired source level
+ * @return {@code true} if the JDK supports the source level
+ */
+ public static boolean supportsSourceLevel(int jdkMajor, int sourceLevel) {
+ return sourceLevel >= minimumSupportedSourceLevel(jdkMajor) && sourceLevel <= jdkMajor;
+ }
+
+ /**
+ * Normalizes a source level string to a major version number.
+ *
+ * Handles legacy formats:
+ *
+ *
{@code "1.5"} → {@code 5}
+ *
{@code "1.8"} → {@code 8}
+ *
{@code "11"} → {@code 11}
+ *
{@code "21.0.1"} → {@code 21}
+ *
+ *
+ * @param version the source level string
+ * @return the normalized major version, or {@code -1} if the string cannot be parsed
+ */
+ public static int normalizeSourceLevel(String version) {
+ if (version == null || version.isEmpty()) {
+ return -1;
+ }
+ version = version.trim();
+ // Handle "1.x" legacy format (e.g., "1.5", "1.8", "1.8.0_392")
+ if (version.startsWith("1.") && version.length() > 2) {
+ String rest = version.substring(2);
+ // Strip any trailing qualifiers (e.g. "8.0_392" → "8")
+ int sep = indexOfNonDigit(rest);
+ if (sep > 0) {
+ rest = rest.substring(0, sep);
+ }
+ try {
+ return Integer.parseInt(rest);
+ } catch (NumberFormatException e) {
+ return -1;
+ }
+ }
+ // Handle dotted versions like "21.0.1" — take the first segment
+ int dotIndex = version.indexOf('.');
+ if (dotIndex > 0) {
+ version = version.substring(0, dotIndex);
+ }
+ try {
+ return Integer.parseInt(version);
+ } catch (NumberFormatException e) {
+ return -1;
+ }
+ }
+
+ /**
+ * Returns the index of the first non-digit character in the string, or -1 if all characters are digits.
+ */
+ private static int indexOfNonDigit(String s) {
+ for (int i = 0; i < s.length(); i++) {
+ if (!Character.isDigit(s.charAt(i))) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * Returns the latest JDK major version that still supports the given {@code --source} level.
+ *
+ * Based on the retirement schedule:
+ *
+ *
source 1–5 → last supported by JDK 8
+ *
source 6 → last supported by JDK 11
+ *
source 7 → last supported by JDK 20
+ *
source 8+ → still supported by current JDKs
+ *
+ *
+ * @param sourceLevel the source level
+ * @return the latest JDK major version that supports it, or {@code -1} if the source level
+ * is still supported by all current JDKs
+ */
+ public static int latestJdkForSourceLevel(int sourceLevel) {
+ if (sourceLevel <= 5) {
+ return 8;
+ }
+ if (sourceLevel == 6) {
+ return 11;
+ }
+ if (sourceLevel == 7) {
+ return 20;
+ }
+ return -1; // still supported
+ }
+
+ /**
+ * Returns the major version of the currently running JDK.
+ *
+ * @return the running JDK major version
+ */
+ public static int getRunningJdkMajor() {
+ return Runtime.version().feature();
+ }
+}
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/DefaultToolchainManagerTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/DefaultToolchainManagerTest.java
index cec13a201948..b471453c1b48 100644
--- a/impl/maven-impl/src/test/java/org/apache/maven/impl/DefaultToolchainManagerTest.java
+++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/DefaultToolchainManagerTest.java
@@ -27,6 +27,9 @@
import org.apache.maven.api.Session;
import org.apache.maven.api.SessionData;
import org.apache.maven.api.Toolchain;
+import org.apache.maven.api.model.Build;
+import org.apache.maven.api.model.Model;
+import org.apache.maven.api.model.Source;
import org.apache.maven.api.services.Lookup;
import org.apache.maven.api.services.ToolchainFactory;
import org.apache.maven.api.toolchain.ToolchainModel;
@@ -35,12 +38,16 @@
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.slf4j.Logger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@@ -123,4 +130,269 @@ void retrieveContextWithoutProject() {
void getToolchainsWithNullType() {
assertThrows(NullPointerException.class, () -> manager.getToolchains(session, null, null));
}
+
+ // --- Source level compatibility check tests ---
+
+ @Test
+ void checkCompatibilityNoTargetVersion() {
+ // Project has no targetVersion configured — no error
+ Logger testLogger = mock(Logger.class);
+ DefaultToolchainManager testManager = new DefaultToolchainManager(Map.of("jdk", jdkFactory), testLogger) {
+ @Override
+ int getRunningJdkMajor() {
+ return 17;
+ }
+ };
+
+ when(session.getService(Lookup.class)).thenReturn(lookup);
+ when(lookup.lookupOptional(Project.class)).thenReturn(Optional.of(project));
+ Model model = Model.newBuilder().build(Build.newBuilder().build()).build();
+ when(project.getModel()).thenReturn(model);
+
+ testManager.checkJdkSourceLevelCompatibility(session);
+
+ verify(testLogger, never()).error(any(String.class), any(), any(), any());
+ }
+
+ @Test
+ void checkCompatibilityRunningJdkSupportsLevel() {
+ // Project targets source 11, running JDK 17 supports it — no error
+ Logger testLogger = mock(Logger.class);
+ DefaultToolchainManager testManager = new DefaultToolchainManager(Map.of("jdk", jdkFactory), testLogger) {
+ @Override
+ int getRunningJdkMajor() {
+ return 17;
+ }
+ };
+
+ when(session.getService(Lookup.class)).thenReturn(lookup);
+ when(lookup.lookupOptional(Project.class)).thenReturn(Optional.of(project));
+ Model model = Model.newBuilder()
+ .build(Build.newBuilder()
+ .sources(List.of(Source.newBuilder().targetVersion("11").build()))
+ .build())
+ .build();
+ when(project.getModel()).thenReturn(model);
+
+ testManager.checkJdkSourceLevelCompatibility(session);
+
+ verify(testLogger, never()).error(any(String.class), any(), any(), any());
+ }
+
+ @Test
+ void checkCompatibilityEmitsErrorWhenIncompatible() {
+ // Project targets source 6, running JDK 17 doesn't support it — should emit error
+ Logger testLogger = mock(Logger.class);
+ DefaultToolchainManager testManager = new DefaultToolchainManager(Map.of("jdk", jdkFactory), testLogger) {
+ @Override
+ int getRunningJdkMajor() {
+ return 17;
+ }
+ };
+
+ when(session.getService(Lookup.class)).thenReturn(lookup);
+ when(lookup.lookupOptional(Project.class)).thenReturn(Optional.of(project));
+ Model model = Model.newBuilder()
+ .build(Build.newBuilder()
+ .sources(List.of(Source.newBuilder().targetVersion("6").build()))
+ .build())
+ .build();
+ when(project.getModel()).thenReturn(model);
+
+ testManager.checkJdkSourceLevelCompatibility(session);
+
+ verify(testLogger)
+ .error(
+ "Project requires --source {} which needs JDK <= {}, but the running JDK {} no longer supports it.",
+ 6,
+ 11,
+ 17);
+ }
+
+ @Test
+ void checkCompatibilityEmitsErrorForSource5() {
+ // Project targets source 5, running JDK 21 — max JDK is 8
+ Logger testLogger = mock(Logger.class);
+ DefaultToolchainManager testManager = new DefaultToolchainManager(Map.of("jdk", jdkFactory), testLogger) {
+ @Override
+ int getRunningJdkMajor() {
+ return 21;
+ }
+ };
+
+ when(session.getService(Lookup.class)).thenReturn(lookup);
+ when(lookup.lookupOptional(Project.class)).thenReturn(Optional.of(project));
+ Model model = Model.newBuilder()
+ .build(Build.newBuilder()
+ .sources(List.of(Source.newBuilder().targetVersion("5").build()))
+ .build())
+ .build();
+ when(project.getModel()).thenReturn(model);
+
+ testManager.checkJdkSourceLevelCompatibility(session);
+
+ verify(testLogger)
+ .error(
+ "Project requires --source {} which needs JDK <= {}, but the running JDK {} no longer supports it.",
+ 5,
+ 8,
+ 21);
+ }
+
+ @Test
+ void checkCompatibilityFromLegacyProperties() {
+ // Project uses maven.compiler.release=6, running JDK 17
+ Logger testLogger = mock(Logger.class);
+ DefaultToolchainManager testManager = new DefaultToolchainManager(Map.of("jdk", jdkFactory), testLogger) {
+ @Override
+ int getRunningJdkMajor() {
+ return 17;
+ }
+ };
+
+ when(session.getService(Lookup.class)).thenReturn(lookup);
+ when(lookup.lookupOptional(Project.class)).thenReturn(Optional.of(project));
+ Model model = Model.newBuilder()
+ .properties(Map.of("maven.compiler.release", "6"))
+ .build();
+ when(project.getModel()).thenReturn(model);
+
+ testManager.checkJdkSourceLevelCompatibility(session);
+
+ verify(testLogger)
+ .error(
+ "Project requires --source {} which needs JDK <= {}, but the running JDK {} no longer supports it.",
+ 6,
+ 11,
+ 17);
+ }
+
+ @Test
+ void checkCompatibilityFromLegacySourceProperty() {
+ // Project uses maven.compiler.source=1.6, running JDK 17
+ Logger testLogger = mock(Logger.class);
+ DefaultToolchainManager testManager = new DefaultToolchainManager(Map.of("jdk", jdkFactory), testLogger) {
+ @Override
+ int getRunningJdkMajor() {
+ return 17;
+ }
+ };
+
+ when(session.getService(Lookup.class)).thenReturn(lookup);
+ when(lookup.lookupOptional(Project.class)).thenReturn(Optional.of(project));
+ Model model = Model.newBuilder()
+ .properties(Map.of("maven.compiler.source", "1.6"))
+ .build();
+ when(project.getModel()).thenReturn(model);
+
+ testManager.checkJdkSourceLevelCompatibility(session);
+
+ verify(testLogger)
+ .error(
+ eq(
+ "Project requires --source {} which needs JDK <= {}, but the running JDK {} no longer supports it."),
+ eq(6),
+ eq(11),
+ eq(17));
+ }
+
+ @Test
+ void getToolchainFromBuildContextChecksCompatibility() {
+ // Verify getToolchainFromBuildContext calls compatibility check for jdk type
+ Logger testLogger = mock(Logger.class);
+ Map context = new ConcurrentHashMap<>();
+ SessionData data = mock(SessionData.class);
+
+ DefaultToolchainManager testManager = new DefaultToolchainManager(Map.of("jdk", jdkFactory), testLogger) {
+ @Override
+ int getRunningJdkMajor() {
+ return 17;
+ }
+ };
+
+ when(session.getService(Lookup.class)).thenReturn(lookup);
+ when(lookup.lookupOptional(Project.class)).thenReturn(Optional.of(project));
+ when(session.getData()).thenReturn(data);
+ when(data.computeIfAbsent(any(), any())).thenReturn(context);
+
+ Model model = Model.newBuilder()
+ .build(Build.newBuilder()
+ .sources(List.of(Source.newBuilder().targetVersion("6").build()))
+ .build())
+ .build();
+ when(project.getModel()).thenReturn(model);
+
+ Optional result = testManager.getToolchainFromBuildContext(session, "jdk");
+
+ // Should return empty (no auto-selection) but emit error
+ assertTrue(result.isEmpty());
+ verify(testLogger)
+ .error(
+ "Project requires --source {} which needs JDK <= {}, but the running JDK {} no longer supports it.",
+ 6,
+ 11,
+ 17);
+ }
+
+ @Test
+ void getToolchainFromBuildContextReturnsExplicitToolchain() {
+ // When an explicit toolchain is stored via storeToolchainToBuildContext,
+ // it takes precedence — no compatibility check needed
+ Map context = new ConcurrentHashMap<>();
+ SessionData data = mock(SessionData.class);
+ toolchainModel = ToolchainModel.newBuilder().type("jdk").build();
+
+ when(session.getService(Lookup.class)).thenReturn(lookup);
+ when(lookup.lookupOptional(Project.class)).thenReturn(Optional.of(project));
+ when(session.getData()).thenReturn(data);
+ when(data.computeIfAbsent(any(), any())).thenReturn(context);
+ when(mockToolchain.getType()).thenReturn("jdk");
+ when(mockToolchain.getModel()).thenReturn(toolchainModel);
+ when(jdkFactory.createToolchain(any(ToolchainModel.class))).thenReturn(mockToolchain);
+
+ manager.storeToolchainToBuildContext(session, mockToolchain);
+ Optional result = manager.getToolchainFromBuildContext(session, "jdk");
+
+ assertTrue(result.isPresent());
+ assertEquals(mockToolchain, result.get());
+ }
+
+ @Test
+ void getToolchainFromBuildContextNonJdkTypeNoCheck() {
+ // Compatibility check should only apply to "jdk" type
+ Map context = new ConcurrentHashMap<>();
+ SessionData data = mock(SessionData.class);
+
+ when(session.getService(Lookup.class)).thenReturn(lookup);
+ when(lookup.lookupOptional(Project.class)).thenReturn(Optional.of(project));
+ when(session.getData()).thenReturn(data);
+ when(data.computeIfAbsent(any(), any())).thenReturn(context);
+
+ Optional result = manager.getToolchainFromBuildContext(session, "otherType");
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ void getProjectRequiredSourceLevelTargetVersionTakesPrecedence() {
+ // targetVersion in sources should take precedence over properties
+ when(session.getService(Lookup.class)).thenReturn(lookup);
+ when(lookup.lookupOptional(Project.class)).thenReturn(Optional.of(project));
+ Model model = Model.newBuilder()
+ .properties(Map.of("maven.compiler.release", "11"))
+ .build(Build.newBuilder()
+ .sources(List.of(Source.newBuilder().targetVersion("8").build()))
+ .build())
+ .build();
+ when(project.getModel()).thenReturn(model);
+
+ assertEquals(8, manager.getProjectRequiredSourceLevel(session));
+ }
+
+ @Test
+ void getProjectRequiredSourceLevelNoProject() {
+ when(session.getService(Lookup.class)).thenReturn(lookup);
+ when(lookup.lookupOptional(Project.class)).thenReturn(Optional.empty());
+
+ assertEquals(-1, manager.getProjectRequiredSourceLevel(session));
+ }
}
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/JdkSourceLevelSupportTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/JdkSourceLevelSupportTest.java
new file mode 100644
index 000000000000..a93a37793b66
--- /dev/null
+++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/JdkSourceLevelSupportTest.java
@@ -0,0 +1,178 @@
+/*
+ * 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.impl;
+
+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.assertTrue;
+
+class JdkSourceLevelSupportTest {
+
+ @Test
+ void minimumSupportedSourceLevelJdk8AndEarlier() {
+ assertEquals(1, JdkSourceLevelSupport.minimumSupportedSourceLevel(7));
+ assertEquals(1, JdkSourceLevelSupport.minimumSupportedSourceLevel(8));
+ }
+
+ @Test
+ void minimumSupportedSourceLevelJdk9To11() {
+ assertEquals(6, JdkSourceLevelSupport.minimumSupportedSourceLevel(9));
+ assertEquals(6, JdkSourceLevelSupport.minimumSupportedSourceLevel(10));
+ assertEquals(6, JdkSourceLevelSupport.minimumSupportedSourceLevel(11));
+ }
+
+ @Test
+ void minimumSupportedSourceLevelJdk12To20() {
+ assertEquals(7, JdkSourceLevelSupport.minimumSupportedSourceLevel(12));
+ assertEquals(7, JdkSourceLevelSupport.minimumSupportedSourceLevel(15));
+ assertEquals(7, JdkSourceLevelSupport.minimumSupportedSourceLevel(17));
+ assertEquals(7, JdkSourceLevelSupport.minimumSupportedSourceLevel(20));
+ }
+
+ @Test
+ void minimumSupportedSourceLevelJdk21AndLater() {
+ assertEquals(8, JdkSourceLevelSupport.minimumSupportedSourceLevel(21));
+ assertEquals(8, JdkSourceLevelSupport.minimumSupportedSourceLevel(22));
+ assertEquals(8, JdkSourceLevelSupport.minimumSupportedSourceLevel(25));
+ }
+
+ @Test
+ void supportsSourceLevelJdk8() {
+ assertTrue(JdkSourceLevelSupport.supportsSourceLevel(8, 1));
+ assertTrue(JdkSourceLevelSupport.supportsSourceLevel(8, 5));
+ assertTrue(JdkSourceLevelSupport.supportsSourceLevel(8, 6));
+ assertTrue(JdkSourceLevelSupport.supportsSourceLevel(8, 8));
+ assertFalse(JdkSourceLevelSupport.supportsSourceLevel(8, 9));
+ }
+
+ @Test
+ void supportsSourceLevelJdk11() {
+ assertFalse(JdkSourceLevelSupport.supportsSourceLevel(11, 5));
+ assertTrue(JdkSourceLevelSupport.supportsSourceLevel(11, 6));
+ assertTrue(JdkSourceLevelSupport.supportsSourceLevel(11, 8));
+ assertTrue(JdkSourceLevelSupport.supportsSourceLevel(11, 11));
+ assertFalse(JdkSourceLevelSupport.supportsSourceLevel(11, 12));
+ }
+
+ @Test
+ void supportsSourceLevelJdk17() {
+ assertFalse(JdkSourceLevelSupport.supportsSourceLevel(17, 5));
+ assertFalse(JdkSourceLevelSupport.supportsSourceLevel(17, 6));
+ assertTrue(JdkSourceLevelSupport.supportsSourceLevel(17, 7));
+ assertTrue(JdkSourceLevelSupport.supportsSourceLevel(17, 8));
+ assertTrue(JdkSourceLevelSupport.supportsSourceLevel(17, 11));
+ assertTrue(JdkSourceLevelSupport.supportsSourceLevel(17, 17));
+ assertFalse(JdkSourceLevelSupport.supportsSourceLevel(17, 18));
+ }
+
+ @Test
+ void supportsSourceLevelJdk21() {
+ assertFalse(JdkSourceLevelSupport.supportsSourceLevel(21, 6));
+ assertFalse(JdkSourceLevelSupport.supportsSourceLevel(21, 7));
+ assertTrue(JdkSourceLevelSupport.supportsSourceLevel(21, 8));
+ assertTrue(JdkSourceLevelSupport.supportsSourceLevel(21, 11));
+ assertTrue(JdkSourceLevelSupport.supportsSourceLevel(21, 17));
+ assertTrue(JdkSourceLevelSupport.supportsSourceLevel(21, 21));
+ assertFalse(JdkSourceLevelSupport.supportsSourceLevel(21, 22));
+ }
+
+ @Test
+ void normalizeSourceLevelLegacyFormat() {
+ assertEquals(5, JdkSourceLevelSupport.normalizeSourceLevel("1.5"));
+ assertEquals(6, JdkSourceLevelSupport.normalizeSourceLevel("1.6"));
+ assertEquals(7, JdkSourceLevelSupport.normalizeSourceLevel("1.7"));
+ assertEquals(8, JdkSourceLevelSupport.normalizeSourceLevel("1.8"));
+ }
+
+ @Test
+ void normalizeSourceLevelModernFormat() {
+ assertEquals(5, JdkSourceLevelSupport.normalizeSourceLevel("5"));
+ assertEquals(6, JdkSourceLevelSupport.normalizeSourceLevel("6"));
+ assertEquals(8, JdkSourceLevelSupport.normalizeSourceLevel("8"));
+ assertEquals(9, JdkSourceLevelSupport.normalizeSourceLevel("9"));
+ assertEquals(11, JdkSourceLevelSupport.normalizeSourceLevel("11"));
+ assertEquals(17, JdkSourceLevelSupport.normalizeSourceLevel("17"));
+ assertEquals(21, JdkSourceLevelSupport.normalizeSourceLevel("21"));
+ }
+
+ @Test
+ void normalizeSourceLevelDottedVersion() {
+ assertEquals(21, JdkSourceLevelSupport.normalizeSourceLevel("21.0.1"));
+ assertEquals(17, JdkSourceLevelSupport.normalizeSourceLevel("17.0.2"));
+ assertEquals(11, JdkSourceLevelSupport.normalizeSourceLevel("11.0.3"));
+ }
+
+ @Test
+ void normalizeSourceLevelInvalid() {
+ assertEquals(-1, JdkSourceLevelSupport.normalizeSourceLevel(null));
+ assertEquals(-1, JdkSourceLevelSupport.normalizeSourceLevel(""));
+ assertEquals(-1, JdkSourceLevelSupport.normalizeSourceLevel("abc"));
+ assertEquals(-1, JdkSourceLevelSupport.normalizeSourceLevel("${java.version}"));
+ }
+
+ @Test
+ void normalizeSourceLevelWithWhitespace() {
+ assertEquals(11, JdkSourceLevelSupport.normalizeSourceLevel(" 11 "));
+ assertEquals(8, JdkSourceLevelSupport.normalizeSourceLevel(" 1.8 "));
+ }
+
+ @Test
+ void normalizeSourceLevelLegacyFormatWithPatchVersion() {
+ // "1.8.0_392" format from some JDK distributions
+ assertEquals(8, JdkSourceLevelSupport.normalizeSourceLevel("1.8.0_392"));
+ assertEquals(7, JdkSourceLevelSupport.normalizeSourceLevel("1.7.0_80"));
+ assertEquals(6, JdkSourceLevelSupport.normalizeSourceLevel("1.6.0_45"));
+ }
+
+ @Test
+ void latestJdkForSourceLevelRetiredLevels() {
+ // source 1-5 → last supported by JDK 8
+ assertEquals(8, JdkSourceLevelSupport.latestJdkForSourceLevel(1));
+ assertEquals(8, JdkSourceLevelSupport.latestJdkForSourceLevel(3));
+ assertEquals(8, JdkSourceLevelSupport.latestJdkForSourceLevel(5));
+ }
+
+ @Test
+ void latestJdkForSourceLevel6() {
+ // source 6 → last supported by JDK 11
+ assertEquals(11, JdkSourceLevelSupport.latestJdkForSourceLevel(6));
+ }
+
+ @Test
+ void latestJdkForSourceLevel7() {
+ // source 7 → last supported by JDK 20
+ assertEquals(20, JdkSourceLevelSupport.latestJdkForSourceLevel(7));
+ }
+
+ @Test
+ void latestJdkForSourceLevelStillSupported() {
+ // source 8+ → still supported by current JDKs
+ assertEquals(-1, JdkSourceLevelSupport.latestJdkForSourceLevel(8));
+ assertEquals(-1, JdkSourceLevelSupport.latestJdkForSourceLevel(11));
+ assertEquals(-1, JdkSourceLevelSupport.latestJdkForSourceLevel(17));
+ assertEquals(-1, JdkSourceLevelSupport.latestJdkForSourceLevel(21));
+ }
+
+ @Test
+ void getRunningJdkMajorReturnsPositive() {
+ assertTrue(JdkSourceLevelSupport.getRunningJdkMajor() > 0);
+ }
+}
diff --git a/its/core-it-suite/pom.xml b/its/core-it-suite/pom.xml
index 5871a3570f4e..4ae52e82eeae 100644
--- a/its/core-it-suite/pom.xml
+++ b/its/core-it-suite/pom.xml
@@ -519,7 +519,7 @@ under the License.
-
+
@@ -749,7 +749,7 @@ under the License.
-
+
@@ -804,7 +804,7 @@ under the License.
-
+
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITMvnupToolchainPluginStrategyTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITMvnupToolchainPluginStrategyTest.java
new file mode 100644
index 000000000000..718b6db66d8d
--- /dev/null
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITMvnupToolchainPluginStrategyTest.java
@@ -0,0 +1,88 @@
+/*
+ * 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.it;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Integration test for the {@code ToolchainPluginStrategy} in {@code mvnup}.
+ *
+ * Verifies that running {@code mvnup apply} on a project with an old
+ * {@code --source} level (unsupported by the running JDK) automatically adds
+ * the {@code maven-toolchains-plugin} with the {@code select-jdk-toolchain}
+ * goal and the correct {@code } constraint, and that a second run
+ * is idempotent.
+ *
+ * @since 4.1.0
+ */
+class MavenITMvnupToolchainPluginStrategyTest extends AbstractMavenIntegrationTestCase {
+
+ /**
+ * Verify that mvnup adds the maven-toolchains-plugin with select-jdk-toolchain
+ * goal and version constraint when the project's source level is unsupported
+ * by the running JDK, and that a second run is idempotent.
+ */
+ @Test
+ void testMvnupAddsToolchainsPluginForOldSourceLevel() throws Exception {
+ Path testDir = extractResources("mvnup-toolchain-plugin-strategy");
+
+ // First run — should add the plugin
+ Verifier verifier = newVerifier(testDir);
+ verifier.setForkJvm(true);
+ verifier.setLogFileName("first-run.txt");
+ verifier.setExecutable("mvnup");
+ verifier.addCliArgument("apply");
+ verifier.addCliArgument("-d");
+ verifier.addCliArgument(testDir.toString());
+ verifier.execute();
+ verifier.verifyErrorFreeLog();
+
+ // Verify mvnup reported adding the plugin
+ verifier.verifyTextInLog("Added maven-toolchains-plugin with select-jdk-toolchain goal");
+
+ // Verify the POM was modified to include the toolchains plugin
+ String pomContent = Files.readString(testDir.resolve("pom.xml"));
+ assertTrue(
+ pomContent.contains("maven-toolchains-plugin"),
+ "POM should contain maven-toolchains-plugin after mvnup apply");
+ assertTrue(
+ pomContent.contains("select-jdk-toolchain"),
+ "POM should contain select-jdk-toolchain goal after mvnup apply");
+ assertTrue(
+ pomContent.contains("(,8]"),
+ "POM should contain version constraint (,8] for source 5 after mvnup apply");
+
+ // Second run — should be idempotent (skip, already present)
+ verifier = newVerifier(testDir);
+ verifier.setForkJvm(true);
+ verifier.setLogFileName("second-run.txt");
+ verifier.setExecutable("mvnup");
+ verifier.addCliArgument("apply");
+ verifier.addCliArgument("-d");
+ verifier.addCliArgument(testDir.toString());
+ verifier.execute();
+ verifier.verifyErrorFreeLog();
+ verifier.verifyTextInLog("maven-toolchains-plugin with select-jdk-toolchain goal already configured");
+ }
+}
diff --git a/its/core-it-suite/src/test/resources/mvnup-toolchain-plugin-strategy/pom.xml b/its/core-it-suite/src/test/resources/mvnup-toolchain-plugin-strategy/pom.xml
new file mode 100644
index 000000000000..6fa0b3dc18ac
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/mvnup-toolchain-plugin-strategy/pom.xml
@@ -0,0 +1,12 @@
+
+ 4.0.0
+ com.example
+ old-source-test
+ 1.0-SNAPSHOT
+
+ 1.5
+ 1.5
+
+