diff --git a/maven-core/src/main/java/org/apache/maven/DefaultMaven.java b/maven-core/src/main/java/org/apache/maven/DefaultMaven.java
index 976b574fface..e0af7fbbe190 100644
--- a/maven-core/src/main/java/org/apache/maven/DefaultMaven.java
+++ b/maven-core/src/main/java/org/apache/maven/DefaultMaven.java
@@ -47,6 +47,7 @@
import org.apache.maven.internal.aether.MavenChainedWorkspaceReader;
import org.apache.maven.lifecycle.internal.ExecutionEventCatapult;
import org.apache.maven.lifecycle.internal.LifecycleStarter;
+import org.apache.maven.logging.internal.DefaultOutputCapabilities;
import org.apache.maven.model.Prerequisites;
import org.apache.maven.model.building.ModelProblem;
import org.apache.maven.model.building.Result;
@@ -93,6 +94,9 @@ public class DefaultMaven implements Maven {
@Inject
private RepositorySystemSessionFactory repositorySessionFactory;
+ @Inject
+ private DefaultOutputCapabilities outputCapabilities;
+
@Inject
@Named(GraphBuilder.HINT)
private GraphBuilder graphBuilder;
@@ -102,6 +106,7 @@ public MavenExecutionResult execute(MavenExecutionRequest request) {
MavenExecutionResult result;
try {
+ request.getData().put(DefaultOutputCapabilities.REQUEST_DATA_KEY, outputCapabilities.asMap());
result = doExecute(request);
} catch (OutOfMemoryError e) {
result = addExceptionToResult(new DefaultMavenExecutionResult(), e);
diff --git a/maven-core/src/main/java/org/apache/maven/logging/OutputCapabilities.java b/maven-core/src/main/java/org/apache/maven/logging/OutputCapabilities.java
new file mode 100644
index 000000000000..d7b1189cdc61
--- /dev/null
+++ b/maven-core/src/main/java/org/apache/maven/logging/OutputCapabilities.java
@@ -0,0 +1,77 @@
+/*
+ * 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.logging;
+
+import java.nio.charset.Charset;
+import java.util.Optional;
+
+/**
+ * Describes the current destination of Maven logging, including plugin log messages.
+ * The information follows logging configuration and is shared by all projects in a build.
+ * It does not describe files or streams written directly by a plugin.
+ *
+ *
Plugins compiled against older Maven APIs can obtain the same information without
+ * referencing this interface through
+ * {@code session.getRequest().getData().get("maven.logging.outputCapabilities")}.
+ * The value is an immutable {@code Map}. Its {@code destination}
+ * entry is always present and contains {@code CONSOLE}, {@code FILE}, {@code REDIRECTED},
+ * or {@code UNKNOWN}. Its {@code encoding} entry contains the canonical
+ * {@link Charset#name()} when known and is otherwise absent, independently of destination.
+ * An absent request-data entry means the service is unavailable (for example, on older
+ * Maven versions); a present map with {@code UNKNOWN} means detection is unavailable.
+ * Clients should ignore additional keys and treat unfamiliar destinations as unknown.
+ *
+ * The map captures the logging configuration when
+ * {@link org.apache.maven.Maven#execute(org.apache.maven.execution.MavenExecutionRequest)}
+ * begins, before validation and lifecycle callbacks. The entry is absent before execution
+ * unless it remains from an earlier execution of the same request. Each execution replaces
+ * the entry with a fresh capture, including when the request is copied or reused.
+ * The map and its collection views remain unchanged through logging cleanup,
+ * reconfiguration, and subsequent invocations. A retained map captured while information
+ * is unknown remains unknown. Cleanup does not remove the entry from the request.
+ * Unlike the map, this component's getters describe the current logging configuration
+ * and may therefore differ from a retained request's map after reconfiguration or cleanup.
+ */
+public interface OutputCapabilities {
+ /** The known kind of logging destination. */
+ enum Destination {
+ /** Output is attached to a terminal, independently of whether color is enabled. */
+ CONSOLE,
+ /** Maven or its logging provider opened an explicit file destination. */
+ FILE,
+ /** Output is not attached to a terminal; its final destination is not known. */
+ REDIRECTED,
+ /** The logging destination could not be determined. */
+ UNKNOWN
+ }
+
+ /**
+ * Returns the known kind of logging destination.
+ * @return the destination, never {@code null}
+ */
+ Destination getDestination();
+
+ /**
+ * Returns the charset selected for the logging output route, if known.
+ * Encoding may be known even when the destination is unknown. It does not imply
+ * support for terminal control sequences or guarantee that a font contains a glyph.
+ * @return the output charset, or an empty optional when it cannot be determined
+ */
+ Optional getEncoding();
+}
diff --git a/maven-core/src/main/java/org/apache/maven/logging/internal/DefaultOutputCapabilities.java b/maven-core/src/main/java/org/apache/maven/logging/internal/DefaultOutputCapabilities.java
new file mode 100644
index 000000000000..917c3557f024
--- /dev/null
+++ b/maven-core/src/main/java/org/apache/maven/logging/internal/DefaultOutputCapabilities.java
@@ -0,0 +1,110 @@
+/*
+ * 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.logging.internal;
+
+import javax.inject.Named;
+import javax.inject.Singleton;
+
+import java.nio.charset.Charset;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+import org.apache.maven.logging.OutputCapabilities;
+
+/**
+ * Internal bridge between logging setup and the read-only component exposed to plugins.
+ * Publication and restoration follow the logging lifecycle, not the build session.
+ */
+@Named
+@Singleton
+public class DefaultOutputCapabilities implements OutputCapabilities {
+ public static final String REQUEST_DATA_KEY = "maven.logging.outputCapabilities";
+
+ public static final OutputCapabilities UNKNOWN = snapshot(Destination.UNKNOWN, null);
+
+ private volatile Snapshot current = (Snapshot) UNKNOWN;
+
+ /** Internal access to an immutable description of the current logging configuration. */
+ public Map asMap() {
+ return current.properties;
+ }
+
+ @Override
+ public Destination getDestination() {
+ return current.getDestination();
+ }
+
+ @Override
+ public Optional getEncoding() {
+ return current.getEncoding();
+ }
+
+ /** Creates an immutable description for transport from logging setup to the container. */
+ public static OutputCapabilities snapshot(Destination destination, Charset encoding) {
+ return new Snapshot(Objects.requireNonNull(destination), encoding);
+ }
+
+ /**
+ * Installs the current output description and returns its logging-lifecycle cleanup.
+ * An obsolete cleanup must not overwrite a subsequently installed configuration.
+ */
+ public synchronized AutoCloseable install(OutputCapabilities capabilities) {
+ Snapshot previous = current;
+ Snapshot installed = new Snapshot(
+ capabilities.getDestination(), capabilities.getEncoding().orElse(null));
+ current = installed;
+ return () -> {
+ synchronized (DefaultOutputCapabilities.this) {
+ if (current == installed) {
+ current = previous;
+ }
+ }
+ };
+ }
+
+ private static final class Snapshot implements OutputCapabilities {
+ private final Destination destination;
+ private final Optional encoding;
+ private final Map properties;
+
+ private Snapshot(Destination destination, Charset encoding) {
+ this.destination = destination;
+ this.encoding = Optional.ofNullable(encoding);
+ Map values = new LinkedHashMap<>();
+ values.put("destination", destination.name());
+ if (encoding != null) {
+ values.put("encoding", encoding.name());
+ }
+ this.properties = Collections.unmodifiableMap(values);
+ }
+
+ @Override
+ public Destination getDestination() {
+ return destination;
+ }
+
+ @Override
+ public Optional getEncoding() {
+ return encoding;
+ }
+ }
+}
diff --git a/maven-core/src/main/resources/META-INF/maven/extension.xml b/maven-core/src/main/resources/META-INF/maven/extension.xml
index aa333bcabe55..8dfcbff48876 100644
--- a/maven-core/src/main/resources/META-INF/maven/extension.xml
+++ b/maven-core/src/main/resources/META-INF/maven/extension.xml
@@ -32,6 +32,7 @@ under the License.
org.apache.maven.execution.scope
org.apache.maven.graph
org.apache.maven.lifecycle
+ org.apache.maven.logging.*
org.apache.maven.model
org.apache.maven.monitor
org.apache.maven.plugin
diff --git a/maven-core/src/test/java/org/apache/maven/OutputCapabilitiesRequestTest.java b/maven-core/src/test/java/org/apache/maven/OutputCapabilitiesRequestTest.java
new file mode 100644
index 000000000000..f530d4bfcc66
--- /dev/null
+++ b/maven-core/src/test/java/org/apache/maven/OutputCapabilitiesRequestTest.java
@@ -0,0 +1,217 @@
+/*
+ * 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;
+
+import javax.inject.Inject;
+import javax.inject.Named;
+import javax.inject.Singleton;
+
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.maven.execution.DefaultMavenExecutionRequest;
+import org.apache.maven.execution.MavenExecutionRequest;
+import org.apache.maven.execution.MavenExecutionResult;
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.logging.OutputCapabilities.Destination;
+import org.apache.maven.logging.internal.DefaultOutputCapabilities;
+import org.codehaus.plexus.testing.PlexusTest;
+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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@PlexusTest
+class OutputCapabilitiesRequestTest extends AbstractCoreMavenComponentTestCase {
+ private static final String KEY = "maven.logging.outputCapabilities";
+
+ @Inject
+ private Maven maven;
+
+ @Inject
+ private DefaultOutputCapabilities capabilities;
+
+ @Override
+ protected String getProjectsDirectory() {
+ return "src/test/projects/default-maven";
+ }
+
+ @Test
+ void capturesEveryConfigurationBeforeRepositoryValidation(@TempDir Path directory) throws Exception {
+ Path invalidRepository = Files.createFile(directory.resolve("repository-file"));
+ MavenExecutionRequest request =
+ new DefaultMavenExecutionRequest().setLocalRepositoryPath(invalidRepository.toFile());
+ request.getData().put("unrelated", "retained");
+ assertNull(request.getData().get(KEY));
+ for (Destination destination : Destination.values()) {
+ for (Charset encoding : Arrays.asList(null, StandardCharsets.UTF_8, Charset.forName("latin1"))) {
+ Map captured;
+ try (AutoCloseable cleanup =
+ capabilities.install(DefaultOutputCapabilities.snapshot(destination, encoding))) {
+ captured = capabilities.asMap();
+ MavenExecutionResult result = maven.execute(request);
+ assertFalse(result.getExceptions().isEmpty());
+ assertSame(captured, request.getData().get(KEY));
+ assertEquals(destination.name(), captured.get("destination"));
+ assertEquals(encoding == null ? null : encoding.name(), captured.get("encoding"));
+ assertEquals("retained", request.getData().get("unrelated"));
+ }
+ assertSame(captured, request.getData().get(KEY));
+ assertEquals(destination.name(), captured.get("destination"));
+ assertEquals(encoding == null ? null : encoding.name(), captured.get("encoding"));
+ assertEquals(Destination.UNKNOWN, capabilities.getDestination());
+ }
+ }
+ }
+
+ @Test
+ void publishesBeforeLifecycleCallbacksAndParallelReaders() throws Exception {
+ MavenExecutionRequest request = request("simple");
+ assertNull(request.getData().get(KEY));
+ RequestObserver observer = observer();
+ try (AutoCloseable cleanup =
+ capabilities.install(DefaultOutputCapabilities.snapshot(Destination.FILE, StandardCharsets.UTF_8))) {
+ executeSuccessfully(request);
+ Map captured = capabilities.asMap();
+ assertSame(request, observer.session.getRequest());
+ assertSame(captured, observer.captured);
+ assertNull(observer.resolverMetadata);
+ assertSame(
+ captured,
+ CompletableFuture.supplyAsync(() -> observer.session
+ .clone()
+ .getRequest()
+ .getData()
+ .get(KEY))
+ .get(10, TimeUnit.SECONDS));
+ assertEquals("UTF-8", captured.get("encoding"));
+ }
+ assertEquals("FILE", observer.captured.get("destination"));
+ assertEquals(Destination.UNKNOWN, capabilities.getDestination());
+ assertEquals(Optional.empty(), capabilities.getEncoding());
+ }
+
+ @Test
+ void copiedIndependentAndReusedRequestsCaptureTheirOwnExecution() throws Exception {
+ MavenExecutionRequest original = request("simple");
+ original.getData().put("unrelated", "original");
+ Map fileMap;
+ Map consoleMap;
+ try (AutoCloseable file =
+ capabilities.install(DefaultOutputCapabilities.snapshot(Destination.FILE, StandardCharsets.UTF_8))) {
+ executeSuccessfully(original);
+ fileMap = capabilities.asMap();
+ MavenExecutionRequest copy = DefaultMavenExecutionRequest.copy(original);
+ assertTrue(copy.getData().isEmpty());
+ copy.getData().put("unrelated", "copy");
+ MavenExecutionRequest independent = request("simple");
+ assertNull(independent.getData().get(KEY));
+ try (AutoCloseable console = capabilities.install(
+ DefaultOutputCapabilities.snapshot(Destination.CONSOLE, StandardCharsets.ISO_8859_1))) {
+ consoleMap = capabilities.asMap();
+ executeSuccessfully(copy);
+ executeSuccessfully(independent);
+ assertSame(consoleMap, copy.getData().get(KEY));
+ assertSame(consoleMap, independent.getData().get(KEY));
+ assertSame(fileMap, original.getData().get(KEY));
+ assertEquals("original", original.getData().get("unrelated"));
+ assertEquals("copy", copy.getData().get("unrelated"));
+ assertNull(independent.getData().get("unrelated"));
+ executeSuccessfully(original);
+ assertSame(consoleMap, original.getData().get(KEY));
+ assertEquals("original", original.getData().get("unrelated"));
+ assertEquals("FILE", fileMap.get("destination"));
+ }
+ assertSame(consoleMap, original.getData().get(KEY));
+ executeSuccessfully(original);
+ assertSame(fileMap, original.getData().get(KEY));
+ assertEquals("CONSOLE", consoleMap.get("destination"));
+ assertEquals("ISO-8859-1", consoleMap.get("encoding"));
+ }
+ assertSame(fileMap, original.getData().get(KEY));
+ executeSuccessfully(original);
+ assertSame(capabilities.asMap(), original.getData().get(KEY));
+ assertEquals("UNKNOWN", capabilities.asMap().get("destination"));
+ assertFalse(capabilities.asMap().containsKey("encoding"));
+ assertEquals("original", original.getData().get("unrelated"));
+ assertEquals("FILE", fileMap.get("destination"));
+ assertEquals("UTF-8", fileMap.get("encoding"));
+ assertEquals("CONSOLE", consoleMap.get("destination"));
+ }
+
+ @Test
+ void projectDiscoveryFailurePreservesTheCapturedMap() throws Exception {
+ MavenExecutionRequest request = request("cyclic-reference");
+ RequestObserver observer = observer();
+ Map captured;
+ try (AutoCloseable cleanup = capabilities.install(
+ DefaultOutputCapabilities.snapshot(Destination.UNKNOWN, StandardCharsets.ISO_8859_1))) {
+ captured = capabilities.asMap();
+ MavenExecutionResult result = maven.execute(request);
+ assertEquals(
+ ProjectCycleException.class, result.getExceptions().get(0).getClass());
+ assertSame(captured, observer.captured);
+ assertNull(observer.resolverMetadata);
+ }
+ assertSame(captured, request.getData().get(KEY));
+ assertEquals("UNKNOWN", captured.get("destination"));
+ assertEquals("ISO-8859-1", captured.get("encoding"));
+ assertEquals(Optional.empty(), capabilities.getEncoding());
+ }
+
+ private MavenExecutionRequest request(String project) throws Exception {
+ return createMavenExecutionRequest(getProject(project)).setGoals(Collections.singletonList("validate"));
+ }
+
+ private void executeSuccessfully(MavenExecutionRequest request) {
+ assertEquals(Collections.emptyList(), maven.execute(request).getExceptions());
+ }
+
+ private RequestObserver observer() throws Exception {
+ return (RequestObserver)
+ container.lookup(AbstractMavenLifecycleParticipant.class, "output-capabilities-request");
+ }
+
+ @Named("output-capabilities-request")
+ @Singleton
+ private static final class RequestObserver extends AbstractMavenLifecycleParticipant {
+ private MavenSession session;
+ private Map, ?> captured;
+ private Object resolverMetadata;
+
+ @Override
+ public void afterSessionStart(MavenSession session) {
+ this.session = session;
+ captured = (Map, ?>) session.getRequest().getData().get(KEY);
+ resolverMetadata = session.getRepositorySession().getData().get(KEY);
+ }
+ }
+}
diff --git a/maven-core/src/test/java/org/apache/maven/logging/internal/DefaultOutputCapabilitiesTest.java b/maven-core/src/test/java/org/apache/maven/logging/internal/DefaultOutputCapabilitiesTest.java
new file mode 100644
index 000000000000..8c3cca0dad66
--- /dev/null
+++ b/maven-core/src/test/java/org/apache/maven/logging/internal/DefaultOutputCapabilitiesTest.java
@@ -0,0 +1,98 @@
+/*
+ * 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.logging.internal;
+
+import javax.inject.Inject;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.maven.logging.OutputCapabilities;
+import org.apache.maven.logging.OutputCapabilities.Destination;
+import org.codehaus.plexus.PlexusContainer;
+import org.codehaus.plexus.testing.PlexusTest;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+@PlexusTest
+class DefaultOutputCapabilitiesTest {
+ @Inject
+ private OutputCapabilities capabilities;
+
+ @Inject
+ private PlexusContainer container;
+
+ @Test
+ void injectableSingletonWithoutSession() throws Exception {
+ assertSame(capabilities, container.lookup(OutputCapabilities.class));
+ assertEquals(Destination.UNKNOWN, capabilities.getDestination());
+ assertEquals(Optional.empty(), capabilities.getEncoding());
+ }
+
+ @Test
+ void updatesAreVisibleToParallelClientsAndRestoredOnClose() throws Exception {
+ DefaultOutputCapabilities component = (DefaultOutputCapabilities) capabilities;
+ try (AutoCloseable cleanup =
+ component.install(DefaultOutputCapabilities.snapshot(Destination.FILE, StandardCharsets.UTF_8))) {
+ assertEquals(
+ Destination.FILE,
+ CompletableFuture.supplyAsync(capabilities::getDestination).get(10, TimeUnit.SECONDS));
+ assertEquals(
+ Optional.of(StandardCharsets.UTF_8),
+ CompletableFuture.supplyAsync(capabilities::getEncoding).get(10, TimeUnit.SECONDS));
+ try (AutoCloseable nested = component.install(
+ DefaultOutputCapabilities.snapshot(Destination.CONSOLE, StandardCharsets.ISO_8859_1))) {
+ assertEquals(Destination.CONSOLE, capabilities.getDestination());
+ assertEquals(Optional.of(StandardCharsets.ISO_8859_1), capabilities.getEncoding());
+ }
+ assertEquals(Destination.FILE, capabilities.getDestination());
+ assertEquals(Optional.of(StandardCharsets.UTF_8), capabilities.getEncoding());
+ }
+ assertEquals(Destination.UNKNOWN, capabilities.getDestination());
+ assertEquals(Optional.empty(), capabilities.getEncoding());
+ }
+
+ @Test
+ void unknownDestinationCanHaveKnownEncoding() throws Exception {
+ try (AutoCloseable cleanup = ((DefaultOutputCapabilities) capabilities)
+ .install(DefaultOutputCapabilities.snapshot(Destination.UNKNOWN, StandardCharsets.UTF_8))) {
+ assertEquals(Destination.UNKNOWN, capabilities.getDestination());
+ assertEquals(Optional.of(StandardCharsets.UTF_8), capabilities.getEncoding());
+ }
+ }
+
+ @Test
+ void repeatedCleanupDoesNotResetNextInvocation() throws Exception {
+ DefaultOutputCapabilities component = (DefaultOutputCapabilities) capabilities;
+ AutoCloseable first =
+ component.install(DefaultOutputCapabilities.snapshot(Destination.FILE, StandardCharsets.UTF_8));
+ first.close();
+ try (AutoCloseable second = component.install(
+ DefaultOutputCapabilities.snapshot(Destination.REDIRECTED, StandardCharsets.US_ASCII))) {
+ first.close();
+ assertEquals(Destination.REDIRECTED, capabilities.getDestination());
+ assertEquals(Optional.of(StandardCharsets.US_ASCII), capabilities.getEncoding());
+ }
+ assertEquals(Destination.UNKNOWN, capabilities.getDestination());
+ }
+}
diff --git a/maven-core/src/test/java/org/apache/maven/logging/internal/OutputCapabilitiesMapTest.java b/maven-core/src/test/java/org/apache/maven/logging/internal/OutputCapabilitiesMapTest.java
new file mode 100644
index 000000000000..bdf69afb43e1
--- /dev/null
+++ b/maven-core/src/test/java/org/apache/maven/logging/internal/OutputCapabilitiesMapTest.java
@@ -0,0 +1,183 @@
+/*
+ * 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.logging.internal;
+
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.maven.logging.OutputCapabilities.Destination;
+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.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class OutputCapabilitiesMapTest {
+ private final DefaultOutputCapabilities component = new DefaultOutputCapabilities();
+
+ @Test
+ void destinationsAndCanonicalEncodingAreIndependent() throws Exception {
+ Map initial = component.asMap();
+ assertEquals(Collections.singletonMap("destination", "UNKNOWN"), initial);
+ for (Destination destination : Destination.values()) {
+ try (AutoCloseable cleanup = component.install(DefaultOutputCapabilities.snapshot(destination, null))) {
+ assertEquals(Collections.singletonMap("destination", destination.name()), component.asMap());
+ assertFalse(component.asMap().containsKey("encoding"));
+ }
+ try (AutoCloseable cleanup =
+ component.install(DefaultOutputCapabilities.snapshot(destination, Charset.forName("latin1")))) {
+ Map captured = component.asMap();
+ assertEquals(destination.name(), captured.get("destination"));
+ assertEquals("ISO-8859-1", captured.get("encoding"));
+ assertEquals(2, captured.size());
+ assertEquals(Collections.singletonMap("destination", "UNKNOWN"), initial);
+ }
+ }
+ assertSame(initial, component.asMap());
+ }
+
+ @Test
+ void retainedMapsAndCollectionsSurviveReconfigurationAndCleanup() throws Exception {
+ Map initial = component.asMap();
+ Map file;
+ Set> entries;
+ Set keys;
+ Collection values;
+ Iterator> iterator;
+ try (AutoCloseable cleanup =
+ component.install(DefaultOutputCapabilities.snapshot(Destination.FILE, StandardCharsets.UTF_8))) {
+ file = component.asMap();
+ entries = file.entrySet();
+ keys = file.keySet();
+ values = file.values();
+ iterator = entries.iterator();
+ assertNotSame(initial, file);
+ try (AutoCloseable nested = component.install(
+ DefaultOutputCapabilities.snapshot(Destination.CONSOLE, StandardCharsets.ISO_8859_1))) {
+ assertEquals("CONSOLE", component.asMap().get("destination"));
+ assertEquals("ISO-8859-1", component.asMap().get("encoding"));
+ assertEquals(Arrays.asList("FILE", "UTF-8"), new ArrayList<>(values));
+ }
+ assertSame(file, component.asMap());
+ }
+ assertSame(initial, component.asMap());
+ assertEquals(Collections.singletonMap("destination", "UNKNOWN"), initial);
+ assertEquals("FILE", file.get("destination"));
+ assertEquals("UTF-8", file.get("encoding"));
+ assertEquals(file.entrySet(), entries);
+ assertEquals(2, entries.size());
+ assertEquals(Arrays.asList("destination", "encoding"), new ArrayList<>(keys));
+ assertEquals(Arrays.asList("FILE", "UTF-8"), new ArrayList<>(values));
+ Map fromIterator = new HashMap<>();
+ iterator.forEachRemaining(entry -> fromIterator.put(entry.getKey(), entry.getValue()));
+ assertEquals(file, fromIterator);
+ }
+
+ @Test
+ void mutationIsRejectedThroughMapCollectionsAndEntries() throws Exception {
+ try (AutoCloseable cleanup =
+ component.install(DefaultOutputCapabilities.snapshot(Destination.FILE, StandardCharsets.UTF_8))) {
+ Map captured = component.asMap();
+ assertThrows(UnsupportedOperationException.class, () -> captured.put("destination", "CONSOLE"));
+ assertThrows(
+ UnsupportedOperationException.class, () -> captured.compute("encoding", (key, value) -> "ASCII"));
+ assertThrows(
+ UnsupportedOperationException.class,
+ () -> captured.entrySet().iterator().next().setValue("changed"));
+ for (Collection> collection : Arrays.asList(captured.entrySet(), captured.keySet(), captured.values())) {
+ assertThrows(UnsupportedOperationException.class, collection::clear);
+ assertThrows(
+ UnsupportedOperationException.class,
+ () -> collection.remove(collection.iterator().next()));
+ Iterator> iterator = collection.iterator();
+ iterator.next();
+ assertThrows(UnsupportedOperationException.class, iterator::remove);
+ }
+ }
+ }
+
+ @Test
+ void failureAndRepeatedCleanupDoNotChangeCapturedMaps() throws Exception {
+ AutoCloseable first =
+ component.install(DefaultOutputCapabilities.snapshot(Destination.FILE, StandardCharsets.UTF_8));
+ Map file = component.asMap();
+ first.close();
+ Map redirected;
+ List