diff --git a/maven-executor/src/main/java/org/apache/maven/executor/support/DefaultExecutorTool.java b/maven-executor/src/main/java/org/apache/maven/executor/support/DefaultExecutorTool.java
new file mode 100644
index 0000000..f699c66
--- /dev/null
+++ b/maven-executor/src/main/java/org/apache/maven/executor/support/DefaultExecutorTool.java
@@ -0,0 +1,350 @@
+/*
+ * 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.executor.support;
+
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.maven.executor.ExecutorException;
+import org.apache.maven.executor.ExecutorRequest;
+import org.apache.maven.executor.ExecutorTool;
+import org.w3c.dom.Document;
+import org.w3c.dom.NodeList;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * {@link ExecutorTool} implementation that computes the local repository and the paths in it without running Maven,
+ * so it does not resolve any plugin into the local repository under test.
+ *
+ * The local repository is, in order: a {@code -Dmaven.repo.local=} argument of the request, the same argument in
+ * {@code .mvn/maven.config} of the request's working directory, the {@code maven.repo.local} JVM system property of
+ * the request, the {@code maven.repo.local} system property of this JVM, {@code } of the user
+ * settings ({@code -s} argument or {@code ~/.m2/settings.xml}), {@code } of the global settings
+ * ({@code -gs} argument or {@code conf/settings.xml} of the installation, when one is given), and finally
+ * {@code ~/.m2/repository} of the request's user home.
+ *
+ * Paths follow the default repository layout, the way Resolver's enhanced local repository manager lays it out,
+ * which is the default of Maven 3.9 and 4. This is best effort: it cannot see what the Maven under test sees, such as
+ * a split local repository, a non-default layout or settings profiles. {@link ToolboxExecutorTool} asks Maven itself
+ * and stays the authoritative choice; it also provides {@link #dump(ExecutorRequest.Builder)}, which this tool cannot.
+ *
+ * @since 1.1.0
+ */
+public class DefaultExecutorTool implements ExecutorTool {
+ private static final String MAVEN_REPO_LOCAL = "maven.repo.local";
+
+ private static final String DEFAULT_METADATA = "maven-metadata.xml";
+
+ private static final Pattern TIMESTAMPED_SNAPSHOT = Pattern.compile("^(.*-)?(\\d{8}\\.\\d{6}-\\d+)$");
+
+ private static final Pattern PROPERTY = Pattern.compile("\\$\\{([^}]+)}");
+
+ private final Path installationDirectory;
+
+ /**
+ * Creates a tool that does not read global settings.
+ */
+ public DefaultExecutorTool() {
+ this(null);
+ }
+
+ /**
+ * @param installationDirectory the Maven installation whose {@code conf/settings.xml} is the global settings, or
+ * {@code null} to not read global settings
+ */
+ public DefaultExecutorTool(Path installationDirectory) {
+ this.installationDirectory = installationDirectory;
+ }
+
+ /**
+ * Not supported: a dump needs the Maven under test.
+ *
+ * @throws ExecutorException always
+ */
+ @Override
+ public Map dump(ExecutorRequest.Builder request) throws ExecutorException {
+ throw new ExecutorException("dump() needs the Maven under test; use ToolboxExecutorTool");
+ }
+
+ @Override
+ public String localRepository(ExecutorRequest.Builder request) throws ExecutorException {
+ ExecutorRequest executorRequest = requireNonNull(request, "request").build();
+ String localRepository = localRepository(executorRequest);
+ return executorRequest
+ .cwd()
+ .resolve(localRepository)
+ .toAbsolutePath()
+ .normalize()
+ .toString();
+ }
+
+ /**
+ * Returns the path of the artifact relative to the local repository. Only a timestamped snapshot depends on
+ * {@code repositoryId}: downloaded ({@code repositoryId} given), its file keeps the timestamp; installed
+ * ({@code repositoryId} {@code null}), it is named after the base version, as Resolver does.
+ *
+ * @param gav {@code :[:[:]]:}
+ */
+ @Override
+ public String artifactPath(ExecutorRequest.Builder request, String gav, String repositoryId)
+ throws ExecutorException {
+ requireNonNull(request, "request");
+ String[] parts = requireNonNull(gav, "gav").split(":", -1);
+ if (parts.length < 3 || parts.length > 5) {
+ throw new ExecutorException("Bad artifact coordinates " + gav
+ + ", expected format is :[:[:]]:");
+ }
+ String groupId = parts[0];
+ String artifactId = parts[1];
+ String version = parts[parts.length - 1];
+ String extension = parts.length > 3 && !parts[2].isEmpty() ? parts[2] : "jar";
+ String classifier = parts.length > 4 ? parts[3] : "";
+ if (groupId.isEmpty() || artifactId.isEmpty() || version.isEmpty()) {
+ throw new ExecutorException(
+ "Bad artifact coordinates " + gav + ", groupId, artifactId and version" + " must not be empty");
+ }
+
+ StringBuilder path = new StringBuilder(128);
+ path.append(groupId.replace('.', '/')).append('/');
+ path.append(artifactId).append('/');
+ path.append(baseVersion(version)).append('/');
+ path.append(artifactId).append('-').append(repositoryId != null ? version : baseVersion(version));
+ if (!classifier.isEmpty()) {
+ path.append('-').append(classifier);
+ }
+ path.append('.').append(extension);
+ return path.toString().replace('/', File.separatorChar);
+ }
+
+ /**
+ * Returns the path of the metadata relative to the local repository: {@code maven-metadata.xml} becomes
+ * {@code maven-metadata-local.xml}, or {@code maven-metadata-.xml} for the copy of a remote one.
+ */
+ @Override
+ public String metadataPath(ExecutorRequest.Builder request, String gav, String repositoryId)
+ throws ExecutorException {
+ requireNonNull(request, "request");
+ String[] parts = requireNonNull(gav, "gav").split(":", -1);
+ if (parts.length > 4) {
+ throw new ExecutorException("Bad metadata coordinates " + gav + ", expected format is [G]:[A]:[V]:[type]");
+ }
+ String groupId = parts[0];
+ String artifactId = parts.length > 1 ? parts[1] : "";
+ String version = parts.length > 2 ? parts[2] : "";
+ String type = parts.length > 3 && !parts[3].isEmpty() ? parts[3] : DEFAULT_METADATA;
+
+ StringBuilder path = new StringBuilder(128);
+ if (!groupId.isEmpty()) {
+ path.append(groupId.replace('.', '/')).append('/');
+ if (!artifactId.isEmpty()) {
+ path.append(artifactId).append('/');
+ if (!version.isEmpty()) {
+ path.append(version).append('/');
+ }
+ }
+ }
+ path.append(insertRepositoryKey(type, repositoryId != null ? repositoryId : "local"));
+ return path.toString().replace('/', File.separatorChar);
+ }
+
+ private String localRepository(ExecutorRequest request) {
+ String fromArguments = mavenRepoLocal(request.arguments());
+ if (fromArguments != null) {
+ return fromArguments;
+ }
+ String fromMavenConfig = mavenRepoLocal(mavenConfig(request.cwd()));
+ if (fromMavenConfig != null) {
+ return fromMavenConfig;
+ }
+ String fromRequestProperties = request.jvmSystemProperties()
+ .map(properties -> properties.get(MAVEN_REPO_LOCAL))
+ .orElse(null);
+ if (notEmpty(fromRequestProperties)) {
+ return fromRequestProperties;
+ }
+ String fromThisJvm = System.getProperty(MAVEN_REPO_LOCAL);
+ if (notEmpty(fromThisJvm)) {
+ return fromThisJvm;
+ }
+ Path userHome = request.userHomeDirectory();
+ Path userSettings = settingsArgument(request, "-s", "--settings");
+ String fromUserSettings = settingsLocalRepository(
+ userSettings != null ? userSettings : userHome.resolve(".m2").resolve("settings.xml"), userHome);
+ if (fromUserSettings != null) {
+ return fromUserSettings;
+ }
+ Path globalSettings = settingsArgument(request, "-gs", "--global-settings");
+ if (globalSettings == null && installationDirectory != null) {
+ globalSettings = installationDirectory.resolve("conf").resolve("settings.xml");
+ }
+ if (globalSettings != null) {
+ String fromGlobalSettings = settingsLocalRepository(globalSettings, userHome);
+ if (fromGlobalSettings != null) {
+ return fromGlobalSettings;
+ }
+ }
+ return userHome.resolve(".m2").resolve("repository").toString();
+ }
+
+ /**
+ * The value of the last {@code -Dmaven.repo.local=} in the arguments, given as one argument or as {@code -D}
+ * followed by the property, as Maven itself lets a later definition win.
+ */
+ private static String mavenRepoLocal(List arguments) {
+ String result = null;
+ for (int i = 0; i < arguments.size(); i++) {
+ String argument = arguments.get(i);
+ String property = null;
+ if (argument.startsWith("-D") && argument.length() > 2) {
+ property = argument.substring(2);
+ } else if (("-D".equals(argument) || "--define".equals(argument)) && i + 1 < arguments.size()) {
+ property = arguments.get(++i);
+ }
+ if (property != null && property.startsWith(MAVEN_REPO_LOCAL + "=")) {
+ String value = property.substring(MAVEN_REPO_LOCAL.length() + 1);
+ if (!value.isEmpty()) {
+ result = value;
+ }
+ }
+ }
+ return result;
+ }
+
+ private static List mavenConfig(Path cwd) {
+ Path mavenConfig = cwd.resolve(".mvn").resolve("maven.config");
+ if (!Files.isRegularFile(mavenConfig)) {
+ return Collections.emptyList();
+ }
+ try {
+ List arguments = new ArrayList<>();
+ for (String line : Files.readAllLines(mavenConfig, StandardCharsets.UTF_8)) {
+ for (String argument : line.trim().split("\\s+")) {
+ if (!argument.isEmpty()) {
+ arguments.add(argument);
+ }
+ }
+ }
+ return arguments;
+ } catch (IOException e) {
+ throw new ExecutorException("Unable to read " + mavenConfig, e);
+ }
+ }
+
+ private static Path settingsArgument(ExecutorRequest request, String shortOption, String longOption) {
+ List arguments = request.arguments();
+ Path result = null;
+ for (int i = 0; i < arguments.size(); i++) {
+ String argument = arguments.get(i);
+ if ((shortOption.equals(argument) || longOption.equals(argument)) && i + 1 < arguments.size()) {
+ result = request.cwd().resolve(arguments.get(++i));
+ } else if (argument.startsWith(longOption + "=")) {
+ result = request.cwd().resolve(argument.substring(longOption.length() + 1));
+ }
+ }
+ return result;
+ }
+
+ private static String settingsLocalRepository(Path settings, Path userHome) {
+ if (!Files.isRegularFile(settings)) {
+ return null;
+ }
+ try (InputStream in = Files.newInputStream(settings)) {
+ DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+ factory.setNamespaceAware(true);
+ factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
+ factory.setExpandEntityReferences(false);
+ DocumentBuilder builder = factory.newDocumentBuilder();
+ Document document = builder.parse(in);
+ NodeList children = document.getDocumentElement().getChildNodes();
+ for (int i = 0; i < children.getLength(); i++) {
+ if ("localRepository".equals(children.item(i).getLocalName())) {
+ String value = interpolate(children.item(i).getTextContent().trim(), userHome);
+ return value.isEmpty() ? null : value;
+ }
+ }
+ return null;
+ } catch (Exception e) {
+ throw new ExecutorException("Unable to read the local repository from " + settings, e);
+ }
+ }
+
+ /**
+ * Replaces {@code ${user.home}} with the request's user home, {@code ${env.NAME}} with the environment variable
+ * and any other {@code ${name}} with the system property of this JVM; an unknown expression stays as it is.
+ */
+ private static String interpolate(String value, Path userHome) {
+ Matcher matcher = PROPERTY.matcher(value);
+ StringBuffer result = new StringBuffer();
+ while (matcher.find()) {
+ String expression = matcher.group(1);
+ String replacement;
+ if ("user.home".equals(expression)) {
+ replacement = userHome.toString();
+ } else if (expression.startsWith("env.")) {
+ replacement = System.getenv(expression.substring(4));
+ } else {
+ replacement = System.getProperty(expression);
+ }
+ matcher.appendReplacement(
+ result, Matcher.quoteReplacement(replacement != null ? replacement : "${" + expression + "}"));
+ }
+ matcher.appendTail(result);
+ return result.toString();
+ }
+
+ /**
+ * A timestamped snapshot version {@code 1.0-20260101.120000-1} lives in the {@code 1.0-SNAPSHOT} directory.
+ */
+ private static String baseVersion(String version) {
+ Matcher matcher = TIMESTAMPED_SNAPSHOT.matcher(version);
+ if (matcher.matches()) {
+ return (matcher.group(1) != null ? matcher.group(1) : "") + "SNAPSHOT";
+ }
+ return version;
+ }
+
+ /**
+ * As Resolver does: {@code maven-metadata.xml} and the key {@code local} give {@code maven-metadata-local.xml}.
+ */
+ private static String insertRepositoryKey(String fileName, String repositoryKey) {
+ int dot = fileName.indexOf('.');
+ if (dot < 0) {
+ return fileName + '-' + repositoryKey;
+ }
+ return fileName.substring(0, dot) + '-' + repositoryKey + fileName.substring(dot);
+ }
+
+ private static boolean notEmpty(String value) {
+ return value != null && !value.isEmpty();
+ }
+}
diff --git a/maven-executor/src/main/java9/module-info.java b/maven-executor/src/main/java9/module-info.java
index 298f3d0..d886455 100644
--- a/maven-executor/src/main/java9/module-info.java
+++ b/maven-executor/src/main/java9/module-info.java
@@ -18,6 +18,8 @@
*/
module org.apache.maven.executor {
+ requires java.xml;
+
exports org.apache.maven.executor;
exports org.apache.maven.executor.forked;
exports org.apache.maven.executor.embedded;
diff --git a/maven-executor/src/test/java/org/apache/maven/executor/DefaultExecutorToolMatchesToolboxTest.java b/maven-executor/src/test/java/org/apache/maven/executor/DefaultExecutorToolMatchesToolboxTest.java
new file mode 100644
index 0000000..2aa893f
--- /dev/null
+++ b/maven-executor/src/test/java/org/apache/maven/executor/DefaultExecutorToolMatchesToolboxTest.java
@@ -0,0 +1,107 @@
+/*
+ * 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.executor;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.NoSuchElementException;
+
+import org.apache.maven.executor.support.DefaultExecutorTool;
+import org.apache.maven.executor.support.ToolboxExecutorTool;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.TestInfo;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.api.io.CleanupMode;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * {@link DefaultExecutorTool} gives the answers that the Maven under test gives through {@link ToolboxExecutorTool}.
+ */
+@Timeout(300)
+class DefaultExecutorToolMatchesToolboxTest {
+ @TempDir(cleanup = CleanupMode.NEVER)
+ private static Path tempDir;
+
+ private Path userHome;
+ private Path cwd;
+
+ @BeforeEach
+ void beforeEach(TestInfo testInfo) throws Exception {
+ String testName = testInfo.getTestMethod()
+ .orElseThrow(() -> new NoSuchElementException("No such element"))
+ .getName()
+ + testInfo.getDisplayName().replaceAll("\\W", "");
+ userHome = tempDir.resolve(testName);
+ cwd = userHome.resolve("cwd");
+ Files.createDirectories(cwd.resolve(".mvn"));
+ }
+
+ private ExecutorRequest.Builder request() {
+ ExecutorRequest.Builder builder = ExecutorRequest.mavenBuilder()
+ .userHomeDirectory(userHome)
+ .cwd(cwd)
+ .argument("-Daether.remoteRepositoryFilter.prefixes=false");
+ if (System.getProperty("localRepository") != null) {
+ builder.argument("-Dmaven.repo.local.tail=" + System.getProperty("localRepository"));
+ }
+ return builder;
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"3", "4"})
+ void sameAnswers(String maven) throws Exception {
+ Path mavenHome = Paths.get("3".equals(maven) ? Environment.MAVEN3_HOME : Environment.MAVEN4_HOME);
+ try (ExecutorHelper helper = ExecutorHelper.forMavenInstallation(mavenHome, ExecutorHelper.Mode.FORKED)) {
+ ExecutorTool toolbox = new ToolboxExecutorTool(helper, Environment.TOOLBOX_VERSION);
+ ExecutorTool local = new DefaultExecutorTool(mavenHome);
+
+ assertTrue(Files.isSameFile(
+ Paths.get(toolbox.localRepository(request())), Paths.get(local.localRepository(request()))));
+
+ for (String gav : new String[] {
+ "aopalliance:aopalliance:1.0",
+ "org.example:lib:pom:1.0",
+ "org.example:lib:jar:tests:1.0",
+ "org.example:lib:1.0-SNAPSHOT",
+ "org.example:lib:1.0-20260101.120000-3"
+ }) {
+ for (String repositoryId : new String[] {null, "central"}) {
+ assertEquals(
+ toolbox.artifactPath(request(), gav, repositoryId),
+ local.artifactPath(request(), gav, repositoryId),
+ gav + " from " + repositoryId);
+ }
+ }
+ for (String gav : new String[] {"aopalliance", "org.example:lib::", "org.example:lib:1.0-SNAPSHOT:"}) {
+ for (String repositoryId : new String[] {null, "someremote"}) {
+ assertEquals(
+ toolbox.metadataPath(request(), gav, repositoryId),
+ local.metadataPath(request(), gav, repositoryId),
+ gav + " from " + repositoryId);
+ }
+ }
+ }
+ }
+}
diff --git a/maven-executor/src/test/java/org/apache/maven/executor/DefaultExecutorToolTest.java b/maven-executor/src/test/java/org/apache/maven/executor/DefaultExecutorToolTest.java
new file mode 100644
index 0000000..c5cd186
--- /dev/null
+++ b/maven-executor/src/test/java/org/apache/maven/executor/DefaultExecutorToolTest.java
@@ -0,0 +1,200 @@
+/*
+ * 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.executor;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collections;
+
+import org.apache.maven.executor.support.DefaultExecutorTool;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * {@link DefaultExecutorTool} without any Maven: the local repository lookup order and the default layout.
+ */
+class DefaultExecutorToolTest {
+ @TempDir
+ Path userHome;
+
+ private Path cwd;
+
+ private final DefaultExecutorTool tool = new DefaultExecutorTool();
+
+ @BeforeEach
+ void beforeEach() throws Exception {
+ cwd = Files.createDirectories(userHome.resolve("cwd"));
+ }
+
+ private ExecutorRequest.Builder request() {
+ return ExecutorRequest.mavenBuilder().userHomeDirectory(userHome).cwd(cwd);
+ }
+
+ private static String path(String path) {
+ return path.replace('/', File.separatorChar);
+ }
+
+ @Test
+ void localRepositoryDefaultsToUserHome() {
+ assertEquals(userHome.resolve(".m2").resolve("repository").toString(), tool.localRepository(request()));
+ }
+
+ @Test
+ void localRepositoryFromArgumentWinsAndLastOneCounts() throws Exception {
+ writeUserSettings("/from/settings");
+ Path first = userHome.resolve("first");
+ Path second = userHome.resolve("second");
+ assertEquals(
+ second.toString(),
+ tool.localRepository(request()
+ .argument("-Dmaven.repo.local=" + first)
+ .argument("-D")
+ .argument("maven.repo.local=" + second)));
+ }
+
+ @Test
+ void relativeLocalRepositoryIsResolvedAgainstTheWorkingDirectory() {
+ assertEquals(
+ cwd.resolve("repo").toString(), tool.localRepository(request().argument("-Dmaven.repo.local=repo")));
+ }
+
+ @Test
+ void localRepositoryFromMavenConfig() throws Exception {
+ Path repo = userHome.resolve("from-maven-config");
+ Files.createDirectories(cwd.resolve(".mvn"));
+ Files.write(
+ cwd.resolve(".mvn").resolve("maven.config"),
+ Collections.singletonList("-B -Dmaven.repo.local=" + repo),
+ StandardCharsets.UTF_8);
+ assertEquals(repo.toString(), tool.localRepository(request()));
+ }
+
+ @Test
+ void localRepositoryFromRequestSystemProperty() {
+ Path repo = userHome.resolve("from-property");
+ assertEquals(
+ repo.toString(),
+ tool.localRepository(request().jvmSystemProperty("maven.repo.local", repo.toString())));
+ }
+
+ @Test
+ void localRepositoryFromUserSettingsWithUserHome() throws Exception {
+ writeUserSettings("${user.home}/custom-repo");
+ assertEquals(userHome.resolve("custom-repo").toString(), tool.localRepository(request()));
+ }
+
+ @Test
+ void localRepositoryFromSettingsArgument() throws Exception {
+ writeUserSettings("/from/default/settings");
+ Path settings = cwd.resolve("it-settings.xml");
+ Path repo = userHome.resolve("from-s");
+ write(settings, "" + repo + "");
+ assertEquals(
+ repo.toString(), tool.localRepository(request().argument("-s").argument("it-settings.xml")));
+ }
+
+ @Test
+ void localRepositoryFromGlobalSettingsOfTheInstallation() throws Exception {
+ Path installation = userHome.resolve("maven");
+ Path repo = userHome.resolve("from-global");
+ write(installation.resolve("conf").resolve("settings.xml"), "" + repo + "");
+ assertEquals(repo.toString(), new DefaultExecutorTool(installation).localRepository(request()));
+ assertEquals(
+ userHome.resolve(".m2").resolve("repository").toString(),
+ tool.localRepository(request()),
+ "no installation, no global settings");
+ }
+
+ @Test
+ void artifactPath() {
+ assertEquals(
+ path("aopalliance/aopalliance/1.0/aopalliance-1.0.jar"),
+ tool.artifactPath(request(), "aopalliance:aopalliance:1.0", null));
+ assertEquals(
+ path("org/apache/maven/maven-core/3.9.9/maven-core-3.9.9.pom"),
+ tool.artifactPath(request(), "org.apache.maven:maven-core:pom:3.9.9", "central"));
+ assertEquals(
+ path("org/example/lib/1.0/lib-1.0-tests.jar"),
+ tool.artifactPath(request(), "org.example:lib:jar:tests:1.0", null));
+ assertEquals(
+ path("org/example/lib/1.0/lib-1.0.jar"),
+ tool.artifactPath(request(), "org.example:lib::1.0", null),
+ "an empty extension is jar");
+ }
+
+ @Test
+ void artifactPathOfATimestampedSnapshot() {
+ assertEquals(
+ path("org/example/lib/1.0-SNAPSHOT/lib-1.0-20260101.120000-3.jar"),
+ tool.artifactPath(request(), "org.example:lib:1.0-20260101.120000-3", "central"));
+ assertEquals(
+ path("org/example/lib/1.0-SNAPSHOT/lib-1.0-SNAPSHOT.jar"),
+ tool.artifactPath(request(), "org.example:lib:1.0-20260101.120000-3", null),
+ "an installed snapshot is named after the base version");
+ assertEquals(
+ path("org/example/lib/1.0-SNAPSHOT/lib-1.0-SNAPSHOT.jar"),
+ tool.artifactPath(request(), "org.example:lib:1.0-SNAPSHOT", null));
+ }
+
+ @Test
+ void metadataPath() {
+ assertEquals(path("maven-metadata-local.xml"), tool.metadataPath(request(), ":::", null));
+ assertEquals(
+ path("aopalliance/maven-metadata-someremote.xml"),
+ tool.metadataPath(request(), "aopalliance", "someremote"));
+ assertEquals(
+ path("org/example/maven-metadata-local.xml"), tool.metadataPath(request(), "org.example:::", null));
+ assertEquals(
+ path("org/example/lib/maven-metadata-central.xml"),
+ tool.metadataPath(request(), "org.example:lib::", "central"));
+ assertEquals(
+ path("org/example/lib/1.0-SNAPSHOT/maven-metadata-local.xml"),
+ tool.metadataPath(request(), "org.example:lib:1.0-SNAPSHOT:", null));
+ assertEquals(
+ path("org/example/resolver-status-local.properties"),
+ tool.metadataPath(request(), "org.example:::resolver-status.properties", null));
+ }
+
+ @Test
+ void badCoordinates() {
+ assertThrows(ExecutorException.class, () -> tool.artifactPath(request(), "org.example:lib", null));
+ assertThrows(ExecutorException.class, () -> tool.artifactPath(request(), "org.example::1.0", null));
+ assertThrows(ExecutorException.class, () -> tool.metadataPath(request(), "a:b:c:d:e", null));
+ assertThrows(ExecutorException.class, () -> tool.dump(request()));
+ }
+
+ private void writeUserSettings(String content) throws Exception {
+ write(userHome.resolve(".m2").resolve("settings.xml"), content);
+ }
+
+ private static void write(Path settings, String content) throws Exception {
+ Files.createDirectories(settings.getParent());
+ Files.write(
+ settings,
+ Collections.singletonList(
+ "" + content + ""),
+ StandardCharsets.UTF_8);
+ }
+}