diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupContext.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupContext.java index bf156b69498d..defddb125499 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupContext.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupContext.java @@ -26,6 +26,7 @@ import java.util.Map; import java.util.Set; import java.util.function.Consumer; +import java.util.function.Supplier; import org.apache.maven.api.ProtoSession; import org.apache.maven.api.cli.InvokerException; @@ -38,6 +39,8 @@ import org.apache.maven.cling.logging.Slf4jConfiguration; import org.apache.maven.eventspy.internal.EventSpyDispatcher; import org.apache.maven.logging.BuildEventListener; +import org.apache.maven.logging.OutputCapabilities; +import org.apache.maven.logging.internal.DefaultOutputCapabilities; import org.jline.terminal.Terminal; import org.slf4j.ILoggerFactory; @@ -90,6 +93,9 @@ public LookupContext(InvokerRequest invokerRequest, boolean containerCapsuleMana public Terminal terminal; public Consumer writer; + // Set only by a known writer route; custom listeners and writers remain unknown. + public Supplier outputCapabilities = () -> DefaultOutputCapabilities.UNKNOWN; + public ContainerCapsule containerCapsule; public Lookup lookup; public EventSpyDispatcher eventSpyDispatcher; diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java index 3e027ef4bbf2..304cbb804100 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java @@ -24,6 +24,7 @@ import java.io.OutputStream; import java.io.PrintStream; import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; @@ -81,9 +82,12 @@ import org.apache.maven.logging.AsyncDrainWriter; import org.apache.maven.logging.BuildEventListener; import org.apache.maven.logging.LoggingOutputStream; +import org.apache.maven.logging.OutputCapabilities.Destination; import org.apache.maven.logging.ProjectBuildLogAppender; import org.apache.maven.logging.SimpleBuildEventListener; import org.apache.maven.logging.api.LogLevelRecorder; +import org.apache.maven.logging.internal.DefaultOutputCapabilities; +import org.apache.maven.logging.internal.TerminalOutputCapabilities; import org.apache.maven.slf4j.MavenSimpleLogger; import org.codehaus.plexus.PlexusContainer; import org.jline.terminal.Terminal; @@ -427,10 +431,18 @@ protected Consumer doDetermineWriter(C context) { PrintWriter printWriter = new PrintWriter(Files.newBufferedWriter(logFile), true); context.closeables.add(printWriter); raw = printWriter::println; + context.outputCapabilities = + () -> DefaultOutputCapabilities.snapshot(Destination.FILE, StandardCharsets.UTF_8); } catch (IOException e) { throw new MavenException("Unable to redirect logging to " + logFile, e); } } else { + // Resolve this after terminal initialization, when the container is ready. + context.outputCapabilities = () -> DefaultOutputCapabilities.snapshot( + context.invokerRequest.embedded() + ? Destination.UNKNOWN + : TerminalOutputCapabilities.destination(context.terminal), + context.terminal.outputEncoding()); // Given the terminal creation has been offloaded to a different thread, // do not pass directly the terminal writer raw = msg -> { diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/PlexusContainerCapsule.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/PlexusContainerCapsule.java index 99effbf4e260..9964066ccd49 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/PlexusContainerCapsule.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/PlexusContainerCapsule.java @@ -22,6 +22,9 @@ import org.apache.maven.api.services.Lookup; import org.apache.maven.internal.impl.DefaultLookup; +import org.apache.maven.logging.OutputCapabilities; +import org.apache.maven.logging.internal.DefaultOutputCapabilities; +import org.apache.maven.slf4j.MavenLoggerFactory; import org.codehaus.plexus.DefaultPlexusContainer; import static java.util.Objects.requireNonNull; @@ -46,6 +49,13 @@ public PlexusContainerCapsule( @Override public void updateLogging(LookupContext context) { plexusContainer.getLoggerManager().setThresholds(toPlexusLoggingLevel(context.loggerLevel)); + OutputCapabilities capabilities = lookup.lookup(OutputCapabilities.class); + if (capabilities instanceof DefaultOutputCapabilities defaultCapabilities) { + context.closeables.add(defaultCapabilities.install( + context.loggerFactory instanceof MavenLoggerFactory + ? context.outputCapabilities.get() + : DefaultOutputCapabilities.UNKNOWN)); + } } @Override diff --git a/impl/maven-cli/src/main/java/org/apache/maven/logging/internal/TerminalOutputCapabilities.java b/impl/maven-cli/src/main/java/org/apache/maven/logging/internal/TerminalOutputCapabilities.java new file mode 100644 index 000000000000..ac01909b43d3 --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/logging/internal/TerminalOutputCapabilities.java @@ -0,0 +1,138 @@ +/* + * 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.util.List; + +import org.apache.maven.jline.FastTerminal; +import org.apache.maven.logging.OutputCapabilities.Destination; +import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; +import org.jline.terminal.impl.DumbTerminalProvider; +import org.jline.terminal.impl.exec.ExecTerminalProvider; +import org.jline.terminal.spi.SystemStream; +import org.jline.terminal.spi.TerminalExt; +import org.jline.terminal.spi.TerminalProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Internal terminal attachment detection for the CLI's system-output route. */ +public final class TerminalOutputCapabilities { + private static final Logger LOGGER = LoggerFactory.getLogger(TerminalOutputCapabilities.class); + + private TerminalOutputCapabilities() {} + + public static Destination destination(Terminal terminal) { + try { + if (terminal instanceof FastTerminal fastTerminal) { + LOGGER.debug("Resolving asynchronous terminal for logging output detection"); + terminal = fastTerminal.getTerminal(); + } + if (!(terminal instanceof TerminalExt extended)) { + LOGGER.debug( + "Logging destination is UNKNOWN: terminal {} does not expose its provider and system stream", + terminal == null ? null : terminal.getClass().getName()); + return Destination.UNKNOWN; + } + SystemStream stream = extended.getSystemStream(); + TerminalProvider provider = extended.getProvider(); + LOGGER.debug( + "Inspecting logging output: terminal={}, provider={}, stream={}", + terminal.getClass().getName(), + provider == null ? null : provider.getClass().getName(), + stream); + if (provider != null && !(provider instanceof DumbTerminalProvider)) { + // A non-system terminal may be backed by arbitrary embedder streams. + if (stream == null) { + LOGGER.debug("Logging destination is UNKNOWN: terminal provider has no system stream"); + return Destination.UNKNOWN; + } + return probe(provider, stream); + } + if (!(provider instanceof DumbTerminalProvider)) { + LOGGER.debug("Logging destination is UNKNOWN: terminal has no provider"); + return Destination.UNKNOWN; + } + // A dumb terminal can also result from redirected stdin or failed terminal + // creation. Ask the configured providers about output, not terminal type. + SystemStream outputStream = stream != null ? stream : SystemStream.Output; + String configuredProviders = System.getProperty(TerminalBuilder.PROP_PROVIDER); + LOGGER.debug( + "Dumb terminal provider: probing configured providers {} for logging stream {}", + configuredProviders, + outputStream); + IllegalStateException failure = new IllegalStateException("Unable to inspect terminal output"); + List providers = TerminalBuilder.builder().getProviders(configuredProviders, failure); + if (failure.getSuppressed().length > 0) { + LOGGER.debug("Some terminal providers could not be loaded for logging output detection", failure); + } + return probe(providers, outputStream); + } catch (RuntimeException | LinkageError e) { + LOGGER.debug("Logging destination is UNKNOWN: terminal output detection failed", e); + return Destination.UNKNOWN; + } + } + + static Destination probe(List providers, SystemStream stream) { + boolean redirected = false; + for (TerminalProvider provider : providers) { + if (provider instanceof DumbTerminalProvider) { + LOGGER.debug("Skipping dumb terminal provider when probing logging stream {}", stream); + continue; + } + Destination result = probe(provider, stream); + if (result == Destination.CONSOLE) { + return result; + } + redirected |= result == Destination.REDIRECTED; + } + Destination result = redirected ? Destination.REDIRECTED : Destination.UNKNOWN; + LOGGER.debug("Logging destination for stream {} after probing configured providers: {}", stream, result); + return result; + } + + private static Destination probe(TerminalProvider provider, SystemStream stream) { + String providerClass = provider == null ? null : provider.getClass().getName(); + try { + Destination result; + if (provider.isSystemStream(stream)) { + result = Destination.CONSOLE; + } else if (provider instanceof ExecTerminalProvider) { + // The exec provider also returns false when its external probe fails. + // A negative result cannot distinguish redirection from failed detection. + LOGGER.debug( + "Logging destination is UNKNOWN: exec terminal provider {} returned an inconclusive negative result for stream {}", + providerClass, + stream); + return Destination.UNKNOWN; + } else { + result = Destination.REDIRECTED; + } + LOGGER.debug("Terminal provider {} reports logging stream {} as {}", providerClass, stream, result); + return result; + } catch (RuntimeException | LinkageError e) { + LOGGER.debug( + "Logging destination is UNKNOWN: terminal provider {} failed to inspect stream {}", + providerClass, + stream, + e); + return Destination.UNKNOWN; + } + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/LookupInvokerOutputCapabilitiesTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/LookupInvokerOutputCapabilitiesTest.java new file mode 100644 index 000000000000..1d75d1abf432 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/LookupInvokerOutputCapabilitiesTest.java @@ -0,0 +1,129 @@ +/* + * 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; + +import java.io.ByteArrayOutputStream; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.function.Consumer; + +import org.apache.maven.api.cli.InvokerRequest; +import org.apache.maven.api.cli.ParserRequest; +import org.apache.maven.api.cli.mvn.MavenOptions; +import org.apache.maven.api.services.MavenException; +import org.apache.maven.cling.invoker.mvn.MavenContext; +import org.apache.maven.cling.invoker.mvn.MavenInvoker; +import org.apache.maven.logging.OutputCapabilities; +import org.apache.maven.logging.OutputCapabilities.Destination; +import org.jline.terminal.Terminal; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LookupInvokerOutputCapabilitiesTest { + @TempDir + Path directory; + + @Test + void logFileUsesUtf8AndDrainsBeforeClosing() throws Exception { + Path file = directory.resolve("build.log"); + try (MavenContext context = context(Optional.of(file.toString()))) { + Consumer writer = new TestInvoker().writer(context); + OutputCapabilities capabilities = context.outputCapabilities.get(); + assertEquals(Destination.FILE, capabilities.getDestination()); + assertEquals(Optional.of(StandardCharsets.UTF_8), capabilities.getEncoding()); + writer.accept("caf\u00e9"); + } + assertArrayEquals( + ("caf\u00e9" + System.lineSeparator()).getBytes(StandardCharsets.UTF_8), Files.readAllBytes(file)); + } + + @Test + void failedFileDoesNotPublishFileCapabilities() throws Exception { + try (MavenContext context = context(Optional.of(directory.toString()))) { + assertThrows(MavenException.class, () -> new TestInvoker().writer(context)); + assertEquals(Destination.UNKNOWN, context.outputCapabilities.get().getDestination()); + assertEquals(Optional.empty(), context.outputCapabilities.get().getEncoding()); + } + } + + @Test + void embeddedWriterHasKnownEncodingButUnknownDestination() throws Exception { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (MavenContext context = context(Optional.empty())) { + Terminal terminal = mock(Terminal.class); + when(terminal.outputEncoding()).thenReturn(StandardCharsets.ISO_8859_1); + when(terminal.writer()) + .thenReturn(new PrintWriter(new OutputStreamWriter(bytes, StandardCharsets.ISO_8859_1))); + context.terminal = terminal; + Consumer writer = new TestInvoker().writer(context); + assertEquals(Destination.UNKNOWN, context.outputCapabilities.get().getDestination()); + assertEquals( + Optional.of(StandardCharsets.ISO_8859_1), + context.outputCapabilities.get().getEncoding()); + writer.accept("caf\u00e9"); + } + assertArrayEquals( + ("caf\u00e9" + System.lineSeparator()).getBytes(StandardCharsets.ISO_8859_1), bytes.toByteArray()); + } + + @Test + void customWriterRemainsUnknown() throws Exception { + try (MavenContext context = context(Optional.empty())) { + Consumer custom = ignored -> {}; + context.writer = custom; + assertSame(custom, new TestInvoker().writer(context)); + assertEquals(Destination.UNKNOWN, context.outputCapabilities.get().getDestination()); + assertEquals(Optional.empty(), context.outputCapabilities.get().getEncoding()); + } + } + + private MavenContext context(Optional logFile) { + InvokerRequest request = mock(InvokerRequest.class); + when(request.cwd()).thenReturn(directory); + when(request.installationDirectory()).thenReturn(directory); + when(request.userHomeDirectory()).thenReturn(directory); + when(request.topDirectory()).thenReturn(directory); + when(request.parserRequest()).thenReturn(mock(ParserRequest.class)); + when(request.embedded()).thenReturn(true); + MavenOptions options = mock(MavenOptions.class); + when(options.logFile()).thenReturn(logFile); + return new MavenContext(request, false, options); + } + + private static class TestInvoker extends MavenInvoker { + TestInvoker() { + super(ProtoLookup.builder().build(), null); + } + + Consumer writer(MavenContext context) { + return determineWriter(context); + } + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvn/resident/ResidentOutputCapabilitiesTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvn/resident/ResidentOutputCapabilitiesTest.java new file mode 100644 index 000000000000..aa7f8be54090 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvn/resident/ResidentOutputCapabilitiesTest.java @@ -0,0 +1,177 @@ +/* + * 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.mvn.resident; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.apache.maven.api.cli.InvokerException; +import org.apache.maven.api.cli.InvokerRequest; +import org.apache.maven.api.cli.ParserRequest; +import org.apache.maven.cling.invoker.ProtoLookup; +import org.apache.maven.cling.invoker.mvn.MavenContext; +import org.apache.maven.cling.invoker.mvn.MavenParser; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.jline.JLineMessageBuilderFactory; +import org.apache.maven.logging.OutputCapabilities; +import org.apache.maven.logging.OutputCapabilities.Destination; +import org.apache.maven.logging.SimpleBuildEventListener; +import org.apache.maven.logging.internal.DefaultOutputCapabilities; +import org.codehaus.plexus.classworlds.ClassWorld; +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.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ResidentOutputCapabilitiesTest { + @TempDir + Path directory; + + @Test + void reusedContainerRefreshesCapabilitiesButPreservesEarlierMaps() throws Exception { + Files.createDirectories(directory.resolve(".mvn")); + Files.writeString( + directory.resolve("pom.xml"), + "4.0.0test" + + "output-capabilities1" + + "pom"); + Path file = directory.resolve("build.log"); + try (ClassWorld world = new ClassWorld("plexus.core", getClass().getClassLoader()); + InspectingInvoker invoker = new InspectingInvoker(world)) { + assertEquals(0, invoker.invoke(request(List.of("-l", file.toString(), "--color=always", "validate")))); + OutputCapabilities component = invoker.component; + Map fileMap = invoker.captured; + assertEquals("FILE", invoker.captured.get("destination")); + assertEquals("UTF-8", invoker.captured.get("encoding")); + assertEquals(Destination.FILE, invoker.destination); + assertEquals(Optional.of(StandardCharsets.UTF_8), invoker.encoding); + assertEquals(Destination.UNKNOWN, component.getDestination()); + assertEquals(Optional.empty(), component.getEncoding()); + assertEquals("FILE", fileMap.get("destination")); + assertEquals("UTF-8", fileMap.get("encoding")); + assertTrue(Files.readString(file).contains("output-capabilities-marker")); + + assertEquals(0, invoker.invoke(request(List.of("validate")))); + assertSame(component, invoker.component); + assertNotSame(fileMap, invoker.captured); + assertEquals(Destination.UNKNOWN, invoker.destination); // embedded byte stream + assertTrue(invoker.encoding.isPresent()); + Map embeddedMap = invoker.captured; + assertEquals("UNKNOWN", embeddedMap.get("destination")); + assertEquals(invoker.encoding.get().name(), embeddedMap.get("encoding")); + assertEquals(Optional.empty(), component.getEncoding()); + assertEquals("FILE", fileMap.get("destination")); + assertEquals("UTF-8", fileMap.get("encoding")); + + invoker.fail = true; + InvokerException.ExitException failure = assertThrows( + InvokerException.ExitException.class, + () -> invoker.invoke(request(List.of("-l", file.toString(), "validate")))); + assertEquals(2, failure.getExitCode()); + assertSame(component, invoker.component); + assertNotSame(fileMap, invoker.captured); + assertEquals(Destination.FILE, invoker.destination); + assertEquals(fileMap, invoker.captured); + assertEquals("UNKNOWN", embeddedMap.get("destination")); + assertTrue(embeddedMap.containsKey("encoding")); + assertEquals(Destination.UNKNOWN, component.getDestination()); + assertEquals(Optional.empty(), component.getEncoding()); + assertEquals("FILE", fileMap.get("destination")); + assertEquals("UTF-8", fileMap.get("encoding")); + } + } + + @Test + void customBuildListenerDoesNotInheritTerminalMetadata() throws Exception { + Files.createDirectories(directory.resolve(".mvn")); + Files.writeString( + directory.resolve("pom.xml"), + "4.0.0test" + + "output-capabilities1" + + "pom"); + try (ClassWorld world = new ClassWorld("plexus.core", getClass().getClassLoader()); + InspectingInvoker invoker = new InspectingInvoker(world)) { + invoker.customListener = true; + assertEquals(0, invoker.invoke(request(List.of("validate")))); + assertEquals(Destination.UNKNOWN, invoker.destination); + assertEquals(Optional.empty(), invoker.encoding); + } + } + + private InvokerRequest request(List arguments) throws Exception { + return new MavenParser() + .parseInvocation(ParserRequest.mvn(arguments, new JLineMessageBuilderFactory()) + .cwd(directory) + .userHome(directory) + .stdOut(new ByteArrayOutputStream()) + .stdErr(new ByteArrayOutputStream()) + .embedded(true) + .build()); + } + + private static class InspectingInvoker extends ResidentMavenInvoker { + private OutputCapabilities component; + private Destination destination; + private Optional encoding; + private Map captured; + private boolean fail; + private boolean customListener; + + InspectingInvoker(ClassWorld world) { + super(ProtoLookup.builder().addMapping(ClassWorld.class, world).build(), null); + } + + @Override + protected MavenContext createContext(InvokerRequest request) { + MavenContext context = super.createContext(request); + if (customListener) { + context.buildEventListener = new SimpleBuildEventListener(ignored -> {}); + } + return context; + } + + @Override + protected int doExecute(MavenContext context, MavenExecutionRequest request) throws Exception { + component = context.lookup.lookup(OutputCapabilities.class); + destination = component.getDestination(); + encoding = component.getEncoding(); + request.setLocalRepositoryPath(context.cwd.resolve("repository").toFile()); + int result = super.doExecute(context, request); + assertEquals(0, result); + captured = (Map) request.getData().get("maven.logging.outputCapabilities"); + assertSame(((DefaultOutputCapabilities) component).asMap(), captured); + assertEquals(destination.name(), captured.get("destination")); + assertEquals(encoding.map(java.nio.charset.Charset::name).orElse(null), captured.get("encoding")); + context.logger.info("output-capabilities-marker"); + if (fail) { + throw new IllegalStateException("test invocation failure"); + } + return result; + } + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/logging/internal/ExecTerminalProbe.java b/impl/maven-cli/src/test/java/org/apache/maven/logging/internal/ExecTerminalProbe.java new file mode 100644 index 000000000000..a4d634107198 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/logging/internal/ExecTerminalProbe.java @@ -0,0 +1,54 @@ +/* + * 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.io.IOException; +import java.util.Arrays; +import java.util.Collections; + +import org.apache.maven.logging.OutputCapabilities.Destination; +import org.jline.terminal.impl.exec.ExecTerminalProvider; +import org.jline.terminal.spi.SystemStream; +import org.jline.utils.OSUtils; + +/** Subprocess entry point for testing the real exec provider with an empty PATH. */ +public final class ExecTerminalProbe { + private ExecTerminalProbe() {} + + public static void main(String[] args) throws Exception { + ExecTerminalProvider provider = new ExecTerminalProvider(); + for (SystemStream stream : Arrays.asList(SystemStream.Output, SystemStream.Error)) { + try { + Process unexpected = + new ProcessBuilder(OSUtils.TEST_COMMAND, "-t", Integer.toString(stream.ordinal())).start(); + unexpected.destroyForcibly(); + throw new AssertionError("Expected the external terminal probe command to be unavailable"); + } catch (IOException expected) { + // The provider swallows this failure and returns false instead of throwing. + } + if (provider.isSystemStream(stream)) { + throw new AssertionError("Expected an inconclusive negative exec probe for " + stream); + } + Destination destination = TerminalOutputCapabilities.probe(Collections.singletonList(provider), stream); + if (destination != Destination.UNKNOWN) { + throw new AssertionError(stream + ": expected UNKNOWN, got " + destination); + } + } + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/logging/internal/TerminalOutputCapabilitiesTest.java b/impl/maven-cli/src/test/java/org/apache/maven/logging/internal/TerminalOutputCapabilitiesTest.java new file mode 100644 index 000000000000..706f67c7968a --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/logging/internal/TerminalOutputCapabilitiesTest.java @@ -0,0 +1,158 @@ +/* + * 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.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +import org.apache.maven.logging.OutputCapabilities.Destination; +import org.jline.terminal.Terminal; +import org.jline.terminal.impl.DumbTerminalProvider; +import org.jline.terminal.impl.exec.ExecTerminalProvider; +import org.jline.terminal.spi.SystemStream; +import org.jline.terminal.spi.TerminalExt; +import org.jline.terminal.spi.TerminalProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class TerminalOutputCapabilitiesTest { + @TempDir + Path directory; + + @Test + void negativeExecProbesAreInconclusive() { + for (SystemStream stream : Arrays.asList(SystemStream.Output, SystemStream.Error)) { + ExecTerminalProvider provider = mock(ExecTerminalProvider.class); + TerminalExt terminal = mock(TerminalExt.class); + when(terminal.getProvider()).thenReturn(provider); + when(terminal.getSystemStream()).thenReturn(stream); + when(provider.isSystemStream(stream)).thenReturn(true); + assertEquals(Destination.CONSOLE, TerminalOutputCapabilities.destination(terminal)); + assertEquals( + Destination.CONSOLE, TerminalOutputCapabilities.probe(Collections.singletonList(provider), stream)); + when(provider.isSystemStream(stream)).thenReturn(false); + assertEquals(Destination.UNKNOWN, TerminalOutputCapabilities.destination(terminal)); + assertEquals( + Destination.UNKNOWN, TerminalOutputCapabilities.probe(Collections.singletonList(provider), stream)); + } + } + + @Test + void inconclusiveExecProbesDoNotOverrideOtherProviders() { + for (SystemStream stream : Arrays.asList(SystemStream.Output, SystemStream.Error)) { + ExecTerminalProvider exec = mock(ExecTerminalProvider.class); + TerminalProvider other = mock(TerminalProvider.class); + for (boolean console : Arrays.asList(false, true)) { + when(other.isSystemStream(stream)).thenReturn(console); + Destination expected = console ? Destination.CONSOLE : Destination.REDIRECTED; + assertEquals(expected, TerminalOutputCapabilities.probe(Arrays.asList(exec, other), stream)); + assertEquals(expected, TerminalOutputCapabilities.probe(Arrays.asList(other, exec), stream)); + } + when(other.isSystemStream(stream)).thenThrow(new UnsatisfiedLinkError()); + assertEquals(Destination.UNKNOWN, TerminalOutputCapabilities.probe(Arrays.asList(exec, other), stream)); + assertEquals(Destination.UNKNOWN, TerminalOutputCapabilities.probe(Arrays.asList(other, exec), stream)); + } + } + + @Test + @EnabledOnOs({OS.LINUX, OS.MAC}) + void missingExecCommandDoesNotMeanRedirection() throws Exception { + Path emptyPath = Files.createDirectory(directory.resolve("empty-path")); + Path output = directory.resolve("probe.log"); + String java = Paths.get(System.getProperty("java.home"), "bin", "java").toString(); + String classpath = System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")); + ProcessBuilder builder = new ProcessBuilder(java, "-cp", classpath, ExecTerminalProbe.class.getName()); + builder.environment().put("PATH", emptyPath.toString()); + builder.redirectErrorStream(true).redirectOutput(output.toFile()); + Process process = builder.start(); + try { + process.getOutputStream().close(); + boolean exited = process.waitFor(30, TimeUnit.SECONDS); + String diagnostics = new String(Files.readAllBytes(output), StandardCharsets.UTF_8); + assertTrue(exited, "Terminal probe timed out: " + diagnostics); + assertEquals(0, process.exitValue(), diagnostics); + } finally { + if (process.isAlive()) { + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + } + } + } + + @Test + void unavailableProvidersDoNotMeanRedirection() { + assertEquals( + Destination.UNKNOWN, TerminalOutputCapabilities.probe(Collections.emptyList(), SystemStream.Output)); + assertEquals( + Destination.UNKNOWN, + TerminalOutputCapabilities.probe( + Collections.singletonList(new DumbTerminalProvider()), SystemStream.Output)); + TerminalProvider failing = mock(TerminalProvider.class); + when(failing.isSystemStream(SystemStream.Output)).thenThrow(new UnsatisfiedLinkError()); + assertEquals( + Destination.UNKNOWN, + TerminalOutputCapabilities.probe(Collections.singletonList(failing), SystemStream.Output)); + } + + @Test + void probesTheOutputStreamEvenForDumbTerminalType() { + TerminalExt terminal = mock(TerminalExt.class); + TerminalProvider provider = mock(TerminalProvider.class); + when(terminal.getProvider()).thenReturn(provider); + when(terminal.getSystemStream()).thenReturn(SystemStream.Error); + when(terminal.getType()).thenReturn(Terminal.TYPE_DUMB); + when(provider.isSystemStream(SystemStream.Error)).thenReturn(true); + assertEquals(Destination.CONSOLE, TerminalOutputCapabilities.destination(terminal)); + when(provider.isSystemStream(SystemStream.Error)).thenReturn(false); + assertEquals(Destination.REDIRECTED, TerminalOutputCapabilities.destination(terminal)); + } + + @Test + void customTerminalDoesNotImplyConsole() { + TerminalExt terminal = mock(TerminalExt.class); + when(terminal.getProvider()).thenReturn(mock(TerminalProvider.class)); + assertEquals(Destination.UNKNOWN, TerminalOutputCapabilities.destination(terminal)); + assertEquals(Destination.UNKNOWN, TerminalOutputCapabilities.destination(mock(Terminal.class))); + } + + @Test + void oneSuccessfulProviderCanEstablishConsoleAttachment() { + TerminalProvider redirected = mock(TerminalProvider.class); + TerminalProvider console = mock(TerminalProvider.class); + when(console.isSystemStream(SystemStream.Output)).thenReturn(true); + assertEquals( + Destination.CONSOLE, + TerminalOutputCapabilities.probe(Arrays.asList(redirected, console), SystemStream.Output)); + assertEquals( + Destination.REDIRECTED, + TerminalOutputCapabilities.probe(Collections.singletonList(redirected), SystemStream.Output)); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java b/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java index ba5ea30d1db0..cae5ef99160c 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java +++ b/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java @@ -64,6 +64,7 @@ import org.apache.maven.lifecycle.LifecycleExecutionException; 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.building.ModelProblem; import org.apache.maven.model.building.Result; import org.apache.maven.model.superpom.SuperPomProvider; @@ -109,6 +110,8 @@ public class DefaultMaven implements Maven { private final DefaultSessionFactory defaultSessionFactory; + private final DefaultOutputCapabilities outputCapabilities; + private final WorkspaceReader ideWorkspaceReader; private final ProjectSelector projectSelector; @@ -126,6 +129,7 @@ public DefaultMaven( BuildResumptionDataRepository buildResumptionDataRepository, SuperPomProvider superPomProvider, DefaultSessionFactory defaultSessionFactory, + DefaultOutputCapabilities outputCapabilities, @Nullable @Named("ide") WorkspaceReader ideWorkspaceReader) { this.lookup = lookup; this.eventCatapult = eventCatapult; @@ -138,6 +142,7 @@ public DefaultMaven( this.superPomProvider = superPomProvider; this.ideWorkspaceReader = ideWorkspaceReader; this.defaultSessionFactory = defaultSessionFactory; + this.outputCapabilities = outputCapabilities; this.projectSelector = new ProjectSelector(); // if necessary switch to DI } @@ -146,6 +151,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/impl/maven-core/src/main/java/org/apache/maven/logging/OutputCapabilities.java b/impl/maven-core/src/main/java/org/apache/maven/logging/OutputCapabilities.java new file mode 100644 index 000000000000..d7b1189cdc61 --- /dev/null +++ b/impl/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/impl/maven-core/src/main/java/org/apache/maven/logging/internal/DefaultOutputCapabilities.java b/impl/maven-core/src/main/java/org/apache/maven/logging/internal/DefaultOutputCapabilities.java new file mode 100644 index 000000000000..917c3557f024 --- /dev/null +++ b/impl/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/impl/maven-core/src/main/resources/META-INF/maven/extension.xml b/impl/maven-core/src/main/resources/META-INF/maven/extension.xml index 56667ed69e35..da0f5d703e7e 100644 --- a/impl/maven-core/src/main/resources/META-INF/maven/extension.xml +++ b/impl/maven-core/src/main/resources/META-INF/maven/extension.xml @@ -35,6 +35,7 @@ under the License. org.apache.maven.feature 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/impl/maven-core/src/test/java/org/apache/maven/DefaultMavenSessionScopeTest.java b/impl/maven-core/src/test/java/org/apache/maven/DefaultMavenSessionScopeTest.java index 67d3eb3e6d21..c13374459216 100644 --- a/impl/maven-core/src/test/java/org/apache/maven/DefaultMavenSessionScopeTest.java +++ b/impl/maven-core/src/test/java/org/apache/maven/DefaultMavenSessionScopeTest.java @@ -29,6 +29,7 @@ import org.apache.maven.graph.GraphBuilder; import org.apache.maven.internal.impl.DefaultSessionFactory; import org.apache.maven.lifecycle.internal.ExecutionEventCatapult; +import org.apache.maven.logging.internal.DefaultOutputCapabilities; import org.apache.maven.plugin.LegacySupport; import org.apache.maven.resolver.RepositorySystemSessionFactory; import org.apache.maven.session.scope.internal.SessionScope; @@ -77,6 +78,7 @@ void testSessionScopeIsExitedOnWorkspaceReaderError() throws Exception { mock(BuildResumptionDataRepository.class), null, mock(DefaultSessionFactory.class), + new DefaultOutputCapabilities(), null); MavenExecutionResult result = defaultMaven.execute(request); diff --git a/impl/maven-core/src/test/java/org/apache/maven/OutputCapabilitiesRequestTest.java b/impl/maven-core/src/test/java/org/apache/maven/OutputCapabilitiesRequestTest.java new file mode 100644 index 000000000000..f530d4bfcc66 --- /dev/null +++ b/impl/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/impl/maven-core/src/test/java/org/apache/maven/logging/internal/DefaultOutputCapabilitiesTest.java b/impl/maven-core/src/test/java/org/apache/maven/logging/internal/DefaultOutputCapabilitiesTest.java new file mode 100644 index 000000000000..100c2c213799 --- /dev/null +++ b/impl/maven-core/src/test/java/org/apache/maven/logging/internal/DefaultOutputCapabilitiesTest.java @@ -0,0 +1,111 @@ +/* + * 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 availableThroughMaven4LookupWithoutSession() throws Exception { + assertSame( + capabilities, + container.lookup(org.apache.maven.di.Injector.class).getInstance(DefaultOutputCapabilities.class)); + assertSame( + capabilities, + container.lookup(org.apache.maven.api.services.Lookup.class).lookup(OutputCapabilities.class)); + assertSame( + capabilities, + container.lookup(org.apache.maven.di.Injector.class).getInstance(OutputCapabilities.class)); + } + + @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/impl/maven-core/src/test/java/org/apache/maven/logging/internal/OutputCapabilitiesMapTest.java b/impl/maven-core/src/test/java/org/apache/maven/logging/internal/OutputCapabilitiesMapTest.java new file mode 100644 index 000000000000..bdf69afb43e1 --- /dev/null +++ b/impl/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> failed = new ArrayList<>(); + try (AutoCloseable second = component.install( + DefaultOutputCapabilities.snapshot(Destination.REDIRECTED, StandardCharsets.US_ASCII))) { + redirected = component.asMap(); + first.close(); + assertSame(redirected, component.asMap()); + assertThrows(IllegalStateException.class, () -> { + try (AutoCloseable nested = component.install( + DefaultOutputCapabilities.snapshot(Destination.CONSOLE, StandardCharsets.ISO_8859_1))) { + failed.add(component.asMap()); + throw new IllegalStateException("logging setup failed"); + } + }); + assertSame(redirected, component.asMap()); + } + assertEquals(Collections.singletonMap("destination", "UNKNOWN"), component.asMap()); + assertEquals("FILE", file.get("destination")); + assertEquals("UTF-8", file.get("encoding")); + assertEquals("REDIRECTED", redirected.get("destination")); + assertEquals("US-ASCII", redirected.get("encoding")); + assertEquals("CONSOLE", failed.get(0).get("destination")); + assertEquals("ISO-8859-1", failed.get(0).get("encoding")); + } + + @Test + void parallelReadersRetainTheirOriginalInformation() throws Exception { + Map file; + try (AutoCloseable cleanup = + component.install(DefaultOutputCapabilities.snapshot(Destination.FILE, StandardCharsets.UTF_8))) { + file = component.asMap(); + } + CompletableFuture ready = new CompletableFuture<>(); + List>> readers = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + readers.add(ready.thenApplyAsync(ignored -> new HashMap<>(file))); + } + try (AutoCloseable cleanup = component.install( + DefaultOutputCapabilities.snapshot(Destination.CONSOLE, StandardCharsets.ISO_8859_1))) { + ready.complete(null); + for (CompletableFuture> reader : readers) { + Map copy = reader.get(10, TimeUnit.SECONDS); + assertEquals("FILE", copy.get("destination")); + assertEquals("UTF-8", copy.get("encoding")); + } + assertEquals("CONSOLE", component.asMap().get("destination")); + assertEquals("ISO-8859-1", component.asMap().get("encoding")); + } + } +} diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITOutputCapabilitiesTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITOutputCapabilitiesTest.java new file mode 100644 index 000000000000..ebaa857bbd04 --- /dev/null +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITOutputCapabilitiesTest.java @@ -0,0 +1,61 @@ +/* + * 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.Path; +import java.util.Properties; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class MavenITOutputCapabilitiesTest extends AbstractMavenIntegrationTestCase { + @Test + void injectsCapabilitiesIntoLegacyPluginsInParallelReactor() throws Exception { + Path directory = extractResources("output-capabilities"); + Verifier plugin = newVerifier(directory.resolve("plugin")); + plugin.addCliArgument("install"); + plugin.execute(); + plugin.verifyErrorFreeLog(); + Verifier legacyPlugin = newVerifier(directory.resolve("legacy-plugin")); + legacyPlugin.addCliArgument("install"); + legacyPlugin.execute(); + legacyPlugin.verifyErrorFreeLog(); + + for (String color : new String[] {"never", "always"}) { + Verifier consumer = newVerifier(directory.resolve("consumer")); + consumer.addCliArgument("-T2"); + consumer.addCliArgument("-B"); + consumer.addCliArgument("--color=" + color); + consumer.addCliArgument("validate"); + consumer.execute(); + consumer.verifyErrorFreeLog(); + for (String prefix : new String[] {"", "child/"}) { + Properties properties = consumer.loadProperties(prefix + "target/output-capabilities.properties"); + Properties legacy = consumer.loadProperties(prefix + "target/legacy-output-capabilities.properties"); + assertEquals("true", legacy.getProperty("available")); + assertEquals(properties.getProperty("destination"), legacy.getProperty("destination")); + assertEquals(properties.getProperty("encoding"), legacy.getProperty("encoding")); + // Maven 4's verifier directs logging through -l. + assertEquals("FILE", properties.getProperty("destination")); + assertEquals("UTF-8", properties.getProperty("encoding")); + } + } + } +} diff --git a/its/core-it-suite/src/test/resources/output-capabilities/consumer/child/pom.xml b/its/core-it-suite/src/test/resources/output-capabilities/consumer/child/pom.xml new file mode 100644 index 000000000000..6a927442ef71 --- /dev/null +++ b/its/core-it-suite/src/test/resources/output-capabilities/consumer/child/pom.xml @@ -0,0 +1,25 @@ + + + + 4.0.0 + org.apache.maven.its.outputconsumer1.0 + child + pom + diff --git a/its/core-it-suite/src/test/resources/output-capabilities/consumer/pom.xml b/its/core-it-suite/src/test/resources/output-capabilities/consumer/pom.xml new file mode 100644 index 000000000000..db1b0fce14f0 --- /dev/null +++ b/its/core-it-suite/src/test/resources/output-capabilities/consumer/pom.xml @@ -0,0 +1,56 @@ + + + + 4.0.0 + org.apache.maven.its.output + consumer + 1.0 + pom + child + + + + org.apache.maven.its.output + legacy-output-capabilities-maven-plugin + 1.0 + validatereport + + + + + + typed + true + + + + org.apache.maven.its.output + output-capabilities-maven-plugin + 1.0 + validatereport + + + + + + legacy-only + + + diff --git a/its/core-it-suite/src/test/resources/output-capabilities/legacy-plugin/pom.xml b/its/core-it-suite/src/test/resources/output-capabilities/legacy-plugin/pom.xml new file mode 100644 index 000000000000..36f1cccb73e1 --- /dev/null +++ b/its/core-it-suite/src/test/resources/output-capabilities/legacy-plugin/pom.xml @@ -0,0 +1,63 @@ + + + + 4.0.0 + org.apache.maven.its.output + legacy-output-capabilities-maven-plugin + 1.0 + maven-plugin + + 3.6.3 + 8 + UTF-8 + + + + org.apache.maven + maven-core + ${maven.version} + provided + + + org.apache.maven + maven-plugin-api + ${maven.version} + provided + + + org.apache.maven.plugin-tools + maven-plugin-annotations + 3.15.2 + provided + + + + + + maven-compiler-plugin + 3.14.1 + + + maven-plugin-plugin + 3.15.2 + + + + diff --git a/its/core-it-suite/src/test/resources/output-capabilities/legacy-plugin/src/main/java/org/apache/maven/its/output/LegacyOutputMojo.java b/its/core-it-suite/src/test/resources/output-capabilities/legacy-plugin/src/main/java/org/apache/maven/its/output/LegacyOutputMojo.java new file mode 100644 index 000000000000..e8df6b5479db --- /dev/null +++ b/its/core-it-suite/src/test/resources/output-capabilities/legacy-plugin/src/main/java/org/apache/maven/its/output/LegacyOutputMojo.java @@ -0,0 +1,82 @@ +/* + * 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.its.output; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +import org.apache.maven.execution.MavenSession; +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; + +/** This fixture must compile and run using only Maven 3.6.3 APIs. */ +@Mojo(name = "report", threadSafe = true) +public class LegacyOutputMojo extends AbstractMojo { + @Parameter(defaultValue = "${session}", readonly = true) + private MavenSession session; + + @Parameter(defaultValue = "${project.build.directory}/legacy-output-capabilities.properties", readonly = true) + private File output; + + @Parameter(property = "capabilities.available", defaultValue = "true") + private boolean expectedAvailable; + + @Override + public void execute() throws MojoExecutionException { + Object value = session.getRequest().getData().get("maven.logging.outputCapabilities"); + boolean available = value instanceof Map; + if (expectedAvailable != available) { + throw new MojoExecutionException("Unexpected compatibility service availability: " + available); + } + Properties properties = new Properties(); + properties.setProperty("available", Boolean.toString(available)); + properties.setProperty("destination", "UNKNOWN"); + properties.setProperty("encoding", "unknown"); + properties.setProperty("defaultEncoding", Charset.defaultCharset().name()); + if (available) { + Map snapshot = new HashMap<>((Map) value); + Object destination = snapshot.get("destination"); + if (Arrays.asList("CONSOLE", "FILE", "REDIRECTED", "UNKNOWN").contains(destination)) { + properties.setProperty("destination", (String) destination); + } + Object encoding = snapshot.get("encoding"); + if (encoding instanceof String) { + properties.setProperty("encoding", (String) encoding); + } + } + try { + Files.createDirectories(output.toPath().getParent()); + try (OutputStream stream = Files.newOutputStream(output.toPath())) { + properties.store(stream, "Maven logging compatibility information"); + } + } catch (IOException e) { + throw new MojoExecutionException("Unable to write capabilities", e); + } + getLog().info("legacy-output-capabilities-marker caf\u00e9 \u251c\u2500"); + } +} diff --git a/its/core-it-suite/src/test/resources/output-capabilities/plugin/pom.xml b/its/core-it-suite/src/test/resources/output-capabilities/plugin/pom.xml new file mode 100644 index 000000000000..9bf9a525ddf6 --- /dev/null +++ b/its/core-it-suite/src/test/resources/output-capabilities/plugin/pom.xml @@ -0,0 +1,69 @@ + + + + 4.0.0 + org.apache.maven.its.output + output-capabilities-maven-plugin + 1.0 + maven-plugin + + 4.1.0-SNAPSHOT + 8 + UTF-8 + + + + org.apache.maven + maven-core + ${maven.version} + provided + + + org.apache.maven + maven-plugin-api + ${maven.version} + provided + + + javax.inject + javax.inject + 1 + provided + + + org.apache.maven.plugin-tools + maven-plugin-annotations + 3.15.2 + provided + + + + + + maven-compiler-plugin + 3.14.1 + + + maven-plugin-plugin + 3.15.2 + + + + diff --git a/its/core-it-suite/src/test/resources/output-capabilities/plugin/src/main/java/org/apache/maven/its/output/OutputMojo.java b/its/core-it-suite/src/test/resources/output-capabilities/plugin/src/main/java/org/apache/maven/its/output/OutputMojo.java new file mode 100644 index 000000000000..8fab5aac658a --- /dev/null +++ b/its/core-it-suite/src/test/resources/output-capabilities/plugin/src/main/java/org/apache/maven/its/output/OutputMojo.java @@ -0,0 +1,60 @@ +/* + * 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.its.output; + +import javax.inject.Inject; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.util.Properties; + +import org.apache.maven.logging.OutputCapabilities; +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; + +@Mojo(name = "report", threadSafe = true) +public class OutputMojo extends AbstractMojo { + @Inject + private OutputCapabilities capabilities; + + @Parameter(defaultValue = "${project.build.directory}/output-capabilities.properties", readonly = true) + private File output; + + @Override + public void execute() throws MojoExecutionException { + Properties properties = new Properties(); + properties.setProperty("destination", capabilities.getDestination().name()); + properties.setProperty("encoding", capabilities.getEncoding().map(Charset::name).orElse("unknown")); + properties.setProperty("defaultEncoding", Charset.defaultCharset().name()); + try { + Files.createDirectories(output.toPath().getParent()); + try (OutputStream stream = Files.newOutputStream(output.toPath())) { + properties.store(stream, "Maven logging capabilities"); + } + } catch (IOException e) { + throw new MojoExecutionException("Unable to write capabilities", e); + } + getLog().info("output-capabilities-marker caf\u00e9 \u251c\u2500"); + } +}