diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/MavenExecutionContextView.java b/rewrite-maven/src/main/java/org/openrewrite/maven/MavenExecutionContextView.java index c57d328a09a..5f3c43c6826 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/MavenExecutionContextView.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/MavenExecutionContextView.java @@ -15,6 +15,8 @@ */ package org.openrewrite.maven; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; import org.jspecify.annotations.Nullable; import org.openrewrite.DelegatingExecutionContext; import org.openrewrite.ExecutionContext; @@ -52,6 +54,8 @@ public class MavenExecutionContextView extends DelegatingExecutionContext { private static final String MAVEN_REPOSITORIES = "org.openrewrite.maven.repos"; private static final String MAVEN_PINNED_SNAPSHOT_VERSIONS = "org.openrewrite.maven.pinnedSnapshotVersions"; private static final String MAVEN_POM_CACHE = "org.openrewrite.maven.pomCache"; + private static final String MAVEN_EFFECTIVE_SETTINGS_CACHE = "org.openrewrite.maven.effectiveSettingsCache"; + private static final String MAVEN_MIRRORS_CACHE = "org.openrewrite.maven.mirrorsCache"; private static final String MAVEN_ARTIFACT_CACHE = "org.openrewrite.maven.artifactCache"; private static final String MAVEN_RESOLUTION_LISTENER = "org.openrewrite.maven.resolutionListener"; private static final String MAVEN_RESOLUTION_TIME = "org.openrewrite.maven.resolutionTime"; @@ -103,6 +107,7 @@ public ResolutionEventListener getResolutionListener() { public MavenExecutionContextView setMirrors(@Nullable Collection mirrors) { putMessage(MAVEN_MIRRORS, mirrors); + putMessage(MAVEN_MIRRORS_CACHE, null); return this; } @@ -112,15 +117,20 @@ public Collection getMirrors() { /** * Get mirrors set on this execution context, unless overridden by a supplied maven settings file. + * Supplied settings take precedence: when they differ in value from the settings on this context, + * their mirrors replace (not merge with) the context's mirror list, including a list installed + * explicitly via {@link #setMirrors(Collection)}. * * @param mavenSettings The maven settings defining mirrors to use, if any. * @return The mirrors to use for dependency resolution. */ public Collection getMirrors(@Nullable MavenSettings mavenSettings) { - if (mavenSettings != null && !Objects.equals(mavenSettings, getSettings())) { - return mapMirrors(mavenSettings); + if (mavenSettings == null) { + return getMirrors(); } - return getMirrors(); + return this.>getIdentityCache(MAVEN_MIRRORS_CACHE) + .get(mavenSettings, settings -> + !Objects.equals(settings, getSettings()) ? mapMirrors(settings) : getMirrors()); } public MavenExecutionContextView setCredentials(Collection credentials) { @@ -312,6 +322,8 @@ public MavenExecutionContextView setMavenSettings(@Nullable MavenSettings settin } putMessage(MAVEN_SETTINGS, settings); + putMessage(MAVEN_EFFECTIVE_SETTINGS_CACHE, null); + putMessage(MAVEN_MIRRORS_CACHE, null); List effectiveActiveProfiles = mapActiveProfiles(settings, activeProfiles); setActiveProfiles(effectiveActiveProfiles); setCredentials(mapCredentials(settings)); @@ -332,13 +344,26 @@ public MavenExecutionContextView setMavenSettings(@Nullable MavenSettings settin * */ public @Nullable MavenSettings effectiveSettings(MavenResolutionResult mrr) { - MavenSettings effectiveSettings = getMessage(MAVEN_SETTINGS); - if (effectiveSettings == null) { - effectiveSettings = mrr.getMavenSettings(); - } else { - effectiveSettings = effectiveSettings.merge(mrr.getMavenSettings()); + MavenSettings contextSettings = getMessage(MAVEN_SETTINGS); + MavenSettings parsedSettings = mrr.getMavenSettings(); + if (contextSettings == null) { + return parsedSettings; } - return effectiveSettings; + if (parsedSettings == null) { + return contextSettings; + } + // Recipes construct a downloader per visited tag, so memoize the merge per parsed-settings + // instance; returning a stable instance also lets getMirrors(MavenSettings) memoize. + return this.getIdentityCache(MAVEN_EFFECTIVE_SETTINGS_CACHE) + .get(parsedSettings, contextSettings::merge); + } + + private Cache getIdentityCache(String key) { + // Weak keys give identity comparison and let entries die with the settings instances they + // memoize for, so a long run over many repositories does not pin every parsed settings graph. + //noinspection unchecked + return (Cache) getMessages().computeIfAbsent(key, + k -> Caffeine.newBuilder().weakKeys().build()); } private static List mapActiveProfiles(MavenSettings settings, String... activeProfiles) { diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/MavenSettings.java b/rewrite-maven/src/main/java/org/openrewrite/maven/MavenSettings.java index 700fff54a73..2d45152f835 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/MavenSettings.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/MavenSettings.java @@ -40,6 +40,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.*; +import java.util.function.Function; import java.util.function.UnaryOperator; import static java.util.Collections.emptyList; @@ -47,26 +48,30 @@ @FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) @ToString(onlyExplicitlyIncluded = true) -@EqualsAndHashCode(onlyExplicitlyIncluded = true) @Data @AllArgsConstructor @JacksonXmlRootElement(localName = "settings") public class MavenSettings { @Nullable + @ToString.Include String localRepository; @Nullable @NonFinal @JsonIgnore + @EqualsAndHashCode.Exclude MavenRepository mavenLocal; @Nullable + @ToString.Include Profiles profiles; @Nullable + @ToString.Include ActiveProfiles activeProfiles; @Nullable + @ToString.Include Mirrors mirrors; @Nullable @@ -203,7 +208,7 @@ private static boolean exists(Path path) { } public MavenSettings merge(@Nullable MavenSettings installSettings) { - return installSettings == null ? this : new MavenSettings( + return installSettings == null || equals(installSettings) ? this : new MavenSettings( localRepository == null ? installSettings.localRepository : localRepository, profiles == null ? installSettings.profiles : profiles.merge(installSettings.profiles), activeProfiles == null ? installSettings.activeProfiles : activeProfiles.merge(installSettings.activeProfiles), @@ -213,6 +218,41 @@ public MavenSettings merge(@Nullable MavenSettings installSettings) { ); } + /** + * Entries from {@code preferred} win over same-id entries from {@code other}, and duplicate ids + * within one side collapse to the last value at the first position, as Maven does. Entries without + * an id cannot be correlated, so both sides' are kept; merging two equal settings stays idempotent + * only through the equality short-circuit in {@link #merge(MavenSettings)}. + */ + private static List mergeById(List preferred, @Nullable List other, Function id) { + List merged = new ArrayList<>(preferred.size()); + Map indexOfId = new HashMap<>(); + for (T t : preferred) { + String tId = id.apply(t); + Integer existing = tId == null ? null : indexOfId.get(tId); + if (existing != null) { + merged.set(existing, t); + } else { + if (tId != null) { + indexOfId.put(tId, merged.size()); + } + merged.add(t); + } + } + if (other != null) { + for (T t : other) { + String tId = id.apply(t); + if (tId == null || !indexOfId.containsKey(tId)) { + if (tId != null) { + indexOfId.put(tId, merged.size()); + } + merged.add(t); + } + } + } + return merged; + } + public List getActiveRepositories(Iterable activeProfiles) { LinkedHashMap activeRepositories = new LinkedHashMap<>(); @@ -332,6 +372,8 @@ private Proxy interpolate(Proxy proxy) { @FieldDefaults(level = AccessLevel.PRIVATE) @Getter @Setter + @EqualsAndHashCode + @ToString @AllArgsConstructor @NoArgsConstructor public static class Profiles { @@ -340,20 +382,16 @@ public static class Profiles { List profiles = emptyList(); public Profiles merge(@Nullable Profiles profiles) { - final Map merged = new LinkedHashMap<>(); - for (Profile profile : this.profiles) { - merged.put(profile.id, profile); - } - if (profiles != null) { - profiles.getProfiles().forEach(profile -> merged.putIfAbsent(profile.getId(), profile)); - } - return new Profiles(new ArrayList<>(merged.values())); + return new Profiles( + mergeById(this.profiles, profiles == null ? null : profiles.getProfiles(), Profile::getId)); } } @FieldDefaults(level = AccessLevel.PRIVATE) @Getter @Setter + @EqualsAndHashCode + @ToString @AllArgsConstructor @NoArgsConstructor public static class ActiveProfiles { @@ -401,6 +439,8 @@ public boolean isActive(String... activeProfiles) { @FieldDefaults(level = AccessLevel.PRIVATE) @Getter @Setter + @EqualsAndHashCode + @ToString @AllArgsConstructor @NoArgsConstructor public static class Mirrors { @@ -409,14 +449,7 @@ public static class Mirrors { List mirrors = emptyList(); public Mirrors merge(@Nullable Mirrors mirrors) { - final Map merged = new LinkedHashMap<>(); - for (Mirror mirror : this.mirrors) { - merged.put(mirror.id, mirror); - } - if (mirrors != null) { - mirrors.getMirrors().forEach(mirror -> merged.putIfAbsent(mirror.getId(), mirror)); - } - return new Mirrors(new ArrayList<>(merged.values())); + return new Mirrors(mergeById(this.mirrors, mirrors == null ? null : mirrors.getMirrors(), Mirror::getId)); } } @@ -442,6 +475,7 @@ public static class Mirror { @FieldDefaults(level = AccessLevel.PRIVATE) @Getter @Setter + @EqualsAndHashCode @AllArgsConstructor @NoArgsConstructor public static class Servers { @@ -451,14 +485,7 @@ public static class Servers { List servers = emptyList(); public Servers merge(@Nullable Servers servers) { - final Map merged = new LinkedHashMap<>(); - for (Server server : this.servers) { - merged.put(server.id, server); - } - if (servers != null) { - servers.getServers().forEach(server -> merged.putIfAbsent(server.getId(), server)); - } - return new Servers(new ArrayList<>(merged.values())); + return new Servers(mergeById(this.servers, servers == null ? null : servers.getServers(), Server::getId)); } } @@ -478,6 +505,7 @@ public static class Server { @FieldDefaults(level = AccessLevel.PRIVATE) @Getter @Setter + @EqualsAndHashCode @AllArgsConstructor @NoArgsConstructor public static class Proxies { @@ -486,22 +514,7 @@ public static class Proxies { List proxies = emptyList(); public Proxies merge(@Nullable Proxies proxies) { - final Map merged = new LinkedHashMap<>(); - int nullIndex = 0; - for (Proxy proxy : this.proxies) { - String key = proxy.id != null ? proxy.id : "__null_" + nullIndex++; - merged.put(key, proxy); - } - if (proxies != null) { - for (Proxy proxy : proxies.getProxies()) { - if (proxy.getId() != null) { - merged.putIfAbsent(proxy.getId(), proxy); - } else { - merged.put("__null_" + nullIndex++, proxy); - } - } - } - return new Proxies(new ArrayList<>(merged.values())); + return new Proxies(mergeById(this.proxies, proxies == null ? null : proxies.getProxies(), Proxy::getId)); } } diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/internal/RawRepositories.java b/rewrite-maven/src/main/java/org/openrewrite/maven/internal/RawRepositories.java index 28dab9d9919..73992ccc67e 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/internal/RawRepositories.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/internal/RawRepositories.java @@ -28,6 +28,8 @@ @FieldDefaults(level = AccessLevel.PRIVATE) @Getter @Setter +@EqualsAndHashCode +@ToString public class RawRepositories { @JacksonXmlProperty(localName = "repository") @JacksonXmlElementWrapper(useWrapping = false) @@ -51,6 +53,7 @@ public static class Repository { @FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) @EqualsAndHashCode + @ToString @Getter public static class ArtifactPolicy { diff --git a/rewrite-maven/src/test/java/org/openrewrite/maven/MavenCentralMirrorTest.java b/rewrite-maven/src/test/java/org/openrewrite/maven/MavenCentralMirrorTest.java new file mode 100644 index 00000000000..cf3f6305ef6 --- /dev/null +++ b/rewrite-maven/src/test/java/org/openrewrite/maven/MavenCentralMirrorTest.java @@ -0,0 +1,240 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed 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 + *

+ * https://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.openrewrite.maven; + +import org.intellij.lang.annotations.Language; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.openrewrite.ExecutionContext; +import org.openrewrite.HttpSenderExecutionContextView; +import org.openrewrite.InMemoryExecutionContext; +import org.openrewrite.Parser; +import org.openrewrite.Recipe; +import org.openrewrite.ipc.http.HttpSender; +import org.openrewrite.maven.internal.MavenPomDownloader; +import org.openrewrite.maven.table.MavenMetadataFailures; +import org.openrewrite.maven.trait.MavenDependency; +import org.openrewrite.maven.tree.GroupArtifact; +import org.openrewrite.maven.tree.MavenMetadata; +import org.openrewrite.maven.tree.MavenRepository; +import org.openrewrite.maven.tree.MavenResolutionResult; +import org.openrewrite.semver.Semver; +import org.openrewrite.semver.VersionComparator; +import org.openrewrite.xml.tree.Xml; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static java.util.Objects.requireNonNull; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Pins the guarantee that a {@code central} (or {@code *}) mirror carried in an LST's + * {@link MavenResolutionResult#getMavenSettings()} redirects all resolution traffic to the mirror, + * including the implicitly added Maven Central, and that {@code repo.maven.apache.org} is never contacted. + *

+ * In particular this guards the behavior fixed in #4956: + * Maven settings supplied on the {@link ExecutionContext} at recipe run time must be merged with, not + * replace, the settings captured in the LST at parse time. + */ +class MavenCentralMirrorTest { + + private static final String MIRROR_URL = "https://artifacts.example.com/maven2"; + + @Language("xml") + private static final String POM = """ + + 4.0.0 + com.mycompany.app + my-app + 1 + + """; + + @Language("xml") + private static final String METADATA = """ + + org.example.hermetic + example-lib + + 1.1 + 1.1 + + 1.0 + 1.1 + + + + """; + + /** + * Serves canned metadata for any {@code maven-metadata.xml} request and records every requested URL, + * so tests can assert on which hosts resolution actually contacted without any real network access. + */ + static class RecordingHttpSender implements HttpSender { + final List requestedUrls = new CopyOnWriteArrayList<>(); + + @Override + public Response send(Request request) { + String url = request.getUrl().toString(); + requestedUrls.add(url); + byte[] body = url.endsWith("maven-metadata.xml") ? METADATA.getBytes(StandardCharsets.UTF_8) : new byte[0]; + return new Response(200, new ByteArrayInputStream(body), () -> { + }); + } + } + + @ParameterizedTest + @ValueSource(strings = {"central", "*"}) + void lstMirrorHonoredWhenContextSettingsHaveNoMirror(String mirrorOf) throws MavenDownloadingException { + // At recipe run time settings are often supplied on the execution context, e.g. to carry + // credentials. Their presence must not discard the mirror captured in the LST (#4956). + MavenResolutionResult mrr = parsePomWith(settingsWithMirror(mirrorOf)); + ExecutionContext ctx = runContext(); + MavenExecutionContextView.view(ctx).setMavenSettings(settingsWithServerCredentialsOnly()); + + String newerVersion = findNewerVersion(mrr, ctx); + + assertThat(newerVersion).isEqualTo("1.1"); + assertOnlyMirrorContacted(ctx); + } + + @Test + void lstMirrorHonoredWhenContextHasNoSettings() throws MavenDownloadingException { + MavenResolutionResult mrr = parsePomWith(settingsWithMirror("central")); + ExecutionContext ctx = runContext(); + + String newerVersion = findNewerVersion(mrr, ctx); + + assertThat(newerVersion).isEqualTo("1.1"); + assertOnlyMirrorContacted(ctx); + } + + @Test + void implicitlyAddedCentralRedirectedToMirror() throws MavenDownloadingException { + // Even when no repository is supplied at all, the downloader implicitly adds Maven Central; + // that injected repository must also come out redirected to the mirror. + ExecutionContext ctx = runContext(); + MavenPomDownloader downloader = new MavenPomDownloader(emptyMap(), ctx, settingsWithMirror("central"), null); + + MavenRepository normalizedCentral = downloader.normalizeRepository( + MavenRepository.MAVEN_CENTRAL, MavenExecutionContextView.view(ctx), null); + assertThat(normalizedCentral).isNotNull(); + assertThat(normalizedCentral.getUri()).startsWith(MIRROR_URL); + assertThat(normalizedCentral.getId()).isEqualTo("internal-mirror"); + + MavenMetadata metadata = downloader.downloadMetadata( + new GroupArtifact("org.example.hermetic", "example-lib"), null, emptyList()); + + assertThat(metadata.getVersioning().getVersions()).contains("1.0", "1.1"); + assertOnlyMirrorContacted(ctx); + } + + private static MavenSettings settingsWithMirror(String mirrorOf) { + //language=xml + return requireNonNull(MavenSettings.parse(Parser.Input.fromString(Path.of("settings.xml"), """ + + + + internal-mirror + %s + %s + + + + """.formatted(MIRROR_URL, mirrorOf)), throwingContext())); + } + + private static MavenSettings settingsWithServerCredentialsOnly() { + //language=xml + return requireNonNull(MavenSettings.parse(Parser.Input.fromString(Path.of("settings.xml"), """ + + + + internal-mirror + ci + secret + + + + """), throwingContext())); + } + + /** + * Parses a pom with the given settings on the parsing context, as an LST producer would, and returns + * the resolution result marker carrying those settings. + */ + private static MavenResolutionResult parsePomWith(MavenSettings settings) { + ExecutionContext parseCtx = parseContext(settings); + Xml.Document pom = (Xml.Document) MavenParser.builder().build() + .parse(parseCtx, POM) + .findFirst() + .orElseThrow(); + RecordingHttpSender parseSender = (RecordingHttpSender) + HttpSenderExecutionContextView.view(parseCtx).getHttpSender(); + assertThat(parseSender.requestedUrls).isEmpty(); + + MavenResolutionResult mrr = pom.getMarkers().findFirst(MavenResolutionResult.class).orElseThrow(); + assertThat(requireNonNull(mrr.getMavenSettings()).getMirrors()).isNotNull(); + return mrr; + } + + private static ExecutionContext parseContext(MavenSettings settings) { + ExecutionContext parseCtx = runContext(); + MavenExecutionContextView.view(parseCtx).setMavenSettings(settings); + return parseCtx; + } + + private static ExecutionContext runContext() { + ExecutionContext ctx = throwingContext(); + HttpSenderExecutionContextView.view(ctx).setHttpSender(new RecordingHttpSender()); + // The local repository is consulted through the filesystem, invisible to the recording + // sender; disable it so the developer's ~/.m2 cannot influence results. + MavenExecutionContextView.view(ctx).setAddLocalRepository(false); + return ctx; + } + + private static InMemoryExecutionContext throwingContext() { + return new InMemoryExecutionContext(t -> { + throw new RuntimeException(t); + }); + } + + private static @Nullable String findNewerVersion(MavenResolutionResult mrr, ExecutionContext ctx) + throws MavenDownloadingException { + VersionComparator latestRelease = requireNonNull(Semver.validate("latest.release", null).getValue()); + return MavenDependency.findNewerVersion( + "org.example.hermetic", "example-lib", "1.0", mrr, + new MavenMetadataFailures(Recipe.noop()), latestRelease, ctx); + } + + private static void assertOnlyMirrorContacted(ExecutionContext ctx) { + RecordingHttpSender sender = (RecordingHttpSender) + HttpSenderExecutionContextView.view(ctx).getHttpSender(); + assertThat(sender.requestedUrls) + .as("all resolution traffic must be redirected to the mirror") + .allSatisfy(url -> assertThat(url).startsWith(MIRROR_URL)) + .as("at least one metadata request must have reached the mirror") + .anySatisfy(url -> assertThat(url).endsWith("maven-metadata.xml")); + } +} diff --git a/rewrite-maven/src/test/java/org/openrewrite/maven/MavenSettingsTest.java b/rewrite-maven/src/test/java/org/openrewrite/maven/MavenSettingsTest.java index 6a930789891..15fcdf2ac52 100644 --- a/rewrite-maven/src/test/java/org/openrewrite/maven/MavenSettingsTest.java +++ b/rewrite-maven/src/test/java/org/openrewrite/maven/MavenSettingsTest.java @@ -911,6 +911,190 @@ void replacesElementsWithMatchingIds() { .hasFieldOrPropertyWithValue("username", "foo") .hasFieldOrPropertyWithValue("password", null); } + + @Test + void keepsIdLessEntriesFromBothSides() { + var first = MavenSettings.parse(Parser.Input.fromString(Path.of("settings.xml"), + //language=xml + """ + + + + + + first-repo + https://first.example.com/maven2 + + + + + + + https://first.example.com/maven2 + central + + + + + first + secret + + + + + first-proxy.example.com + + + + """ + ), ctx); + var second = MavenSettings.parse(Parser.Input.fromString(Path.of("settings.xml"), + //language=xml + """ + + + + + + second-repo + https://second.example.com/maven2 + + + + + + + https://second.example.com/maven2 + * + + + + + second + secret + + + + + second-proxy.example.com + + + + """ + ), ctx); + + var mergedSettings = first.merge(second); + + assertThat(mergedSettings.getProfiles().getProfiles()) + .extracting(p -> p.getRepositories().getRepositories().getFirst().getId()) + .containsExactly("first-repo", "second-repo"); + assertThat(mergedSettings.getMirrors().getMirrors()) + .extracting(MavenSettings.Mirror::getUrl) + .containsExactly("https://first.example.com/maven2", "https://second.example.com/maven2"); + assertThat(mergedSettings.getServers().getServers()) + .extracting(MavenSettings.Server::getUsername) + .containsExactly("first", "second"); + assertThat(mergedSettings.getProxies().getProxies()) + .extracting(MavenSettings.Proxy::getHost) + .containsExactly("first-proxy.example.com", "second-proxy.example.com"); + } + + @Test + void collapsesDuplicateIdsWithinOneSideToTheLastValue() { + var first = MavenSettings.parse(Parser.Input.fromString(Path.of("settings.xml"), + //language=xml + """ + + + + duplicate + https://stale.example.com/maven2 + central + + + tail + https://tail.example.com/maven2 + * + + + duplicate + https://effective.example.com/maven2 + central + + + + + duplicate + stale + secret + + + duplicate + effective + secret + + + + """ + ), ctx); + var second = MavenSettings.parse(Parser.Input.fromString(Path.of("settings.xml"), + //language=xml + """ + + /tmp/other-repo + + """ + ), ctx); + + var mergedSettings = first.merge(second); + + assertThat(mergedSettings.getMirrors().getMirrors()) + .extracting(MavenSettings.Mirror::getUrl) + .containsExactly("https://effective.example.com/maven2", "https://tail.example.com/maven2"); + assertThat(mergedSettings.getServers().getServers()) + .extracting(MavenSettings.Server::getUsername) + .containsExactly("effective"); + } + + @Test + void replacesProxiesWithMatchingIds() { + var first = MavenSettings.parse(Parser.Input.fromString(Path.of("settings.xml"), + //language=xml + """ + + + + corp + preferred.example.com + + + + """ + ), ctx); + var second = MavenSettings.parse(Parser.Input.fromString(Path.of("settings.xml"), + //language=xml + """ + + + + corp + other.example.com + + + backup + backup.example.com + + + + """ + ), ctx); + + var mergedSettings = first.merge(second); + + assertThat(mergedSettings.getProxies().getProxies()) + .extracting(MavenSettings.Proxy::getHost) + .containsExactly("preferred.example.com", "backup.example.com"); + } } /** @@ -1048,4 +1232,103 @@ void parseProxies() { assertThat(proxy.getPassword()).isEqualTo("proxypass"); assertThat(proxy.getNonProxyHosts()).isEqualTo("localhost|*.example.com"); } + + /** + * {@link MavenExecutionContextView#getMirrors(MavenSettings)} decides whether supplied settings + * override those on the execution context by comparing them for equality, so {@link MavenSettings} + * must compare by value across all of its nested types. + */ + @Nested + class Equality { + //language=xml + private final String settingsXml = """ + + ~/.m2/repository + + repo + + + + repo + + false + 11 + + env + ci + + + + + internal + https://artifacts.example.com/maven2 + + false + + + + + + + + internal-mirror + https://artifacts.example.com/maven2 + central + + + + + internal-mirror + ci + secret + + 30000 + + + X-Custom + value + + + + + + + + my-proxy + proxy.example.com + + + anonymous-proxy.example.com + + + + """; + + private MavenSettings parse(String xml) { + MavenSettings settings = MavenSettings.parse(Parser.Input.fromString(Path.of("settings.xml"), xml), ctx); + assertThat(settings).isNotNull(); + return settings; + } + + @Test + void equalWhenParsedFromSameDocument() { + assertThat(parse(settingsXml)) + .isEqualTo(parse(settingsXml)) + .hasSameHashCodeAs(parse(settingsXml)); + } + + @Test + void notEqualWhenMirrorsDiffer() { + MavenSettings withMirror = parse(settingsXml); + MavenSettings withoutMirror = parse(settingsXml.replaceAll("(?s).*", "")); + assertThat(withMirror).isNotEqualTo(withoutMirror); + } + + @Test + void lazyMavenLocalCacheDoesNotAffectEquality() { + MavenSettings primed = parse(settingsXml); + primed.getMavenLocal(); + assertThat(primed).isEqualTo(parse(settingsXml)); + } + } }