From abd26c76a9f477fd6848954117e28ab210d535bc Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Sun, 20 Sep 2026 21:34:13 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20logging=20foundation=20=E2=80=93=20?= =?UTF-8?q?structured=20LogEvent,=20JUL=20handler,=20Log=20API=20enhanceme?= =?UTF-8?q?nts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add LogEvent interface (maven-api-core) with projectId/mojoId context fields - Add DefaultLogEvent record (maven-core) implementing LogEvent - Add MavenJulHandler (maven-logging): bridge JUL→SLF4J for plugin logging - Enhance DefaultLog (maven-core): carry LOG_API_METADATA for mojo log capture - Add ProjectBuildLogAppender (maven-core): MDC-aware log sink feeding LogEvent stream - Suppress JLine terminal-init DEBUG logs before activateLogging in quiet mode - Gate StackWalker behind hasReportCapture() for zero overhead in normal builds - Fix warn(Supplier, Throwable) incorrectly calling logger.info() - Add @param tags on trace(Supplier) overloads; fix sequenceNumber() @return javadoc - Fix LogEvent.message() @return javadoc copy-paste from formattedMessage() - Strengthen logApiMetadataIsClearedAfterCall() test to exercise the remove() path --- apache-maven/pom.xml | 7 - .../maven/api/build/report/LogEvent.java | 189 +++++++++++ .../maven/api/build/report/LogLevel.java | 34 +- .../maven/api/build/report/package-info.java | 33 ++ compat/maven-embedder/pom.xml | 5 - impl/maven-cli/pom.xml | 9 - .../maven/cling/invoker/LookupInvoker.java | 52 ++- .../logging/impl/LogbackConfiguration.java | 46 --- .../maven/slf4j-configuration.properties | 2 - .../maven/internal/build/DefaultLogEvent.java | 81 +++++ .../maven/internal/impl/DefaultLog.java | 114 +++++-- .../maven/logging/BuildEventListener.java | 3 +- .../logging/ProjectBuildLogAppender.java | 123 ++++++- .../logging/SimpleBuildEventListener.java | 6 +- .../maven/internal/impl/DefaultLogTest.java | 37 +- .../jline/JLineMessageBuilderFactory.java | 13 +- .../apache/maven/slf4j/MavenBaseLogger.java | 19 +- .../apache/maven/slf4j/MavenJulHandler.java | 318 ++++++++++++++++++ .../apache/maven/slf4j/MavenSimpleLogger.java | 52 ++- .../maven/slf4j/MavenJulHandlerTest.java | 128 +++++++ pom.xml | 12 - 21 files changed, 1136 insertions(+), 147 deletions(-) create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java rename impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/Log4j2Configuration.java => api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java (56%) create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java delete mode 100644 impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/LogbackConfiguration.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java create mode 100644 impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenJulHandler.java create mode 100644 impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenJulHandlerTest.java diff --git a/apache-maven/pom.xml b/apache-maven/pom.xml index 927ba48766ca..1d51f405f9e2 100644 --- a/apache-maven/pom.xml +++ b/apache-maven/pom.xml @@ -78,13 +78,6 @@ under the License. ${slf4jVersion} runtime - - - org.slf4j - jul-to-slf4j - ${slf4jVersion} - runtime - org.apache.maven.resolver maven-resolver-connector-basic diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java new file mode 100644 index 000000000000..64b232171d29 --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java @@ -0,0 +1,189 @@ +/* + * 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.api.build.report; + +import java.time.Instant; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Immutable; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.Nullable; + +/** + * A structured log event captured during the build. + *

+ * Each event carries the log level, timestamp, message, and optionally + * the logger name and a stack trace. This replaces raw log line strings + * in the build report, enabling programmatic filtering by level and + * correlation by timestamp. + *

+ * Events originating from the Maven Log API or from JUL + * ({@code java.util.logging}) carry additional source metadata: the + * source class name, source method name, and thread identifier. + * For Log API events the source class name is the mojo implementation + * FQCN; for JUL events it comes from {@code LogRecord}. Events from + * direct SLF4J logging have these fields set to {@code null}. + * + * @since 4.1.0 + */ +@Experimental +@Immutable +public interface LogEvent { + + /** + * When this log event was produced (wall-clock time). + * + * @return the event instant, never {@code null} + */ + @Nonnull + Instant timestamp(); + + /** + * The severity level of this log event. + * + * @return the log level, never {@code null} + */ + @Nonnull + LogLevel level(); + + /** + * The log message, without level prefix or timestamp formatting. + * + * @return the log message, never {@code null} + */ + @Nonnull + String message(); + + /** + * The name of the logger that produced this event + * (e.g. {@code "org.apache.maven.plugins.compiler.CompilerMojo"}). + * + * @return the logger name, or {@code null} if unavailable + */ + @Nullable + String loggerName(); + + /** + * The stack trace associated with this event, if an exception was logged. + *

+ * The trace is formatted as a multi-line string and may be truncated + * for very deep stack traces. + * + * @return the stack trace string, or {@code null} if no exception was logged + */ + @Nullable + String stackTrace(); + + /** + * The fully formatted log line as rendered for console output, including + * the level prefix, timestamp, and any ANSI styling applied by the logger. + *

+ * This is the string that would be printed to the terminal in verbose mode. + * Console renderers that just need pass-through output can use this directly, + * while renderers that apply custom formatting (e.g. rich mode) can use the + * structured fields ({@link #level()}, {@link #message()}) instead. + *

+ * May be {@code null} if the event was created outside the SLF4J pipeline + * (e.g. in tests or by programmatic construction). + * + * @return the formatted log line, or {@code null} + */ + @Nullable + String formattedMessage(); + + // ---- Source metadata (populated for Log API and JUL events) ---- + + /** + * The fully qualified class name of the source that issued the log call. + *

+ * For Maven Log API events this is the mojo implementation class name. + * For JUL events it is the value from {@code LogRecord.getSourceClassName()}. + * For direct SLF4J logging it is {@code null}. + * + * @return the source class name, or {@code null} + */ + @Nullable + default String sourceClassName() { + return null; + } + + /** + * The method name of the source that issued the log call. + *

+ * For Maven Log API events this is resolved via {@link StackWalker}. + * For JUL events it is the value from {@code LogRecord.getSourceMethodName()}. + * For direct SLF4J logging it is {@code null}. + * + * @return the source method name, or {@code null} + */ + @Nullable + default String sourceMethodName() { + return null; + } + + /** + * The thread identifier from which this log event originated. + *

+ * Populated for both Log API and JUL events. Returns {@code -1} + * if the thread ID is not available (i.e. for direct SLF4J events). + * + * @return the thread ID, or {@code -1} if unavailable + */ + default long threadId() { + return -1; + } + + /** + * A monotonically increasing sequence number for total ordering of + * log events, useful when multiple events share the same timestamp. + *

+ * Assigned by the logging pipeline when the event is captured, + * providing a global ordering across all event sources (Log API, + * JUL, and direct SLF4J). + * + * @return the sequence number, or {@code -1} if unavailable + */ + default long sequenceNumber() { + return -1; + } + + /** + * The project this log event belongs to + * (e.g. {@code "org.apache.maven:maven-core:4.1.0-SNAPSHOT"}), or {@code null} + * if the event was not produced in the context of a specific project. + * + * @return the project identifier, or {@code null} + */ + @Nullable + default String projectId() { + return null; + } + + /** + * The mojo execution that produced this event + * (e.g. {@code "compiler:compile@default-compile"}), or {@code null} + * if the event was logged outside a mojo execution. + * + * @return the mojo execution identifier, or {@code null} + */ + @Nullable + default String mojoId() { + return null; + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/Log4j2Configuration.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java similarity index 56% rename from impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/Log4j2Configuration.java rename to api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java index bbd487fa5f87..cb1de663f2c8 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/Log4j2Configuration.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java @@ -16,29 +16,23 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.maven.cling.logging.impl; +package org.apache.maven.api.build.report; -import org.apache.maven.cling.logging.BaseSlf4jConfiguration; +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Immutable; /** - * Configuration for slf4j-log4j2. + * Log severity levels, mirroring the standard SLF4J levels. * - * @since 3.1.0 + * @since 4.1.0 + * @see LogEvent#level() */ -public class Log4j2Configuration extends BaseSlf4jConfiguration { - @Override - public void setRootLoggerLevel(Level level) { - String value = - switch (level) { - case DEBUG -> "debug"; - case INFO -> "info"; - default -> "error"; - }; - System.setProperty("maven.logging.root.level", value); - } - - @Override - public void activate() { - // no op - } +@Experimental +@Immutable +public enum LogLevel { + TRACE, + DEBUG, + INFO, + WARN, + ERROR } diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java new file mode 100644 index 000000000000..8aee68ff901e --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java @@ -0,0 +1,33 @@ +/* + * 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. + */ + +/** + * Structured build report data model. + *

+ * This package provides structured representations of build execution + * data, including log events and (in future) full build reports. + * {@link org.apache.maven.api.build.report.LogEvent} is the foundational + * type representing a single structured log entry captured during the build. + * + * @since 4.1.0 + */ +@Experimental +package org.apache.maven.api.build.report; + +import org.apache.maven.api.annotations.Experimental; diff --git a/compat/maven-embedder/pom.xml b/compat/maven-embedder/pom.xml index 2df8588ec116..56730ccb4326 100644 --- a/compat/maven-embedder/pom.xml +++ b/compat/maven-embedder/pom.xml @@ -163,11 +163,6 @@ under the License. commons-cli - - ch.qos.logback - logback-classic - true - org.jline jansi-core diff --git a/impl/maven-cli/pom.xml b/impl/maven-cli/pom.xml index 5d7304af5e3a..56ac62ed6f3e 100644 --- a/impl/maven-cli/pom.xml +++ b/impl/maven-cli/pom.xml @@ -195,19 +195,10 @@ under the License. org.slf4j slf4j-api - - org.slf4j - jul-to-slf4j - commons-cli commons-cli - - ch.qos.logback - logback-classic - true - org.junit.jupiter 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..c4dcde7bfd61 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 @@ -37,6 +37,8 @@ import java.util.Properties; import java.util.function.Consumer; import java.util.function.UnaryOperator; +import java.util.logging.Level; +import java.util.logging.LogManager; import org.apache.maven.api.Constants; import org.apache.maven.api.ProtoSession; @@ -84,6 +86,7 @@ import org.apache.maven.logging.ProjectBuildLogAppender; import org.apache.maven.logging.SimpleBuildEventListener; import org.apache.maven.logging.api.LogLevelRecorder; +import org.apache.maven.slf4j.MavenJulHandler; import org.apache.maven.slf4j.MavenSimpleLogger; import org.codehaus.plexus.PlexusContainer; import org.jline.terminal.Terminal; @@ -92,7 +95,6 @@ import org.jline.terminal.spi.TerminalExt; import org.jline.utils.OSUtils; import org.slf4j.LoggerFactory; -import org.slf4j.bridge.SLF4JBridgeHandler; import org.slf4j.spi.LocationAwareLogger; import static java.util.Objects.requireNonNull; @@ -295,6 +297,20 @@ protected void configureLogging(C context) throws Exception { } else if (context.options().quiet().orElse(false)) { context.loggerLevel = Slf4jConfiguration.Level.ERROR; context.slf4jConfiguration.setRootLoggerLevel(context.loggerLevel); + // Install the JUL handler early and clamp the JUL root to SEVERE + // so that JLine terminal-init DEBUG events (emitted via java.util.logging + // before activateLogging() runs) are suppressed at source. + // createTerminal() executes after configureLogging() but before + // activateLogging(), so without this guard those DEBUG lines leak + // into the build output even when -q is in effect, causing + // MavenITmng4387QuietLoggingTest to fail on Windows. + // Setting SEVERE here is safe: it only blocks events, so it + // cannot trigger the SLF4J-bootstrap reentrancy flood that + // Level.ALL would cause. + if (!MavenJulHandler.isInstalled()) { + MavenJulHandler.install(); + } + LogManager.getLogManager().getLogger("").setLevel(Level.SEVERE); } else { // fall back to default log level specified in conf // see https://issues.apache.org/jira/browse/MNG-2570 and https://github.com/apache/maven/issues/11199 @@ -447,12 +463,33 @@ protected Consumer doDetermineWriter(C context) { } protected void activateLogging(C context) throws Exception { - if (!SLF4JBridgeHandler.isInstalled()) { - SLF4JBridgeHandler.removeHandlersForRootLogger(); - SLF4JBridgeHandler.install(); + if (!MavenJulHandler.isInstalled()) { + MavenJulHandler.install(); } context.slf4jConfiguration.activate(); + + // Now that SLF4J is fully initialized, set the JUL root logger level + // to match the effective log level. This must happen AFTER install() + // + activate() to avoid flooding JUL events during SLF4J bootstrap + // (ConcurrentHashMap.computeIfAbsent reentrancy). + // In quiet mode keep the JUL root at SEVERE so that INFO/WARN/DEBUG JUL + // events are suppressed at source — relying solely on the SLF4J-level + // check in MavenJulHandler.isLevelEnabled() is racy: newly created + // SLF4J loggers may briefly see the default INFO level before + // quiet-mode propagation completes, leaking output that + // MavenITmng4387QuietLoggingTest detects as a flaky failure. + // SEVERE matches the SLF4J ERROR threshold exactly, blocking both + // INFO and WARNING JUL events at source during the race window. + Level julRootLevel; + if (context.options().quiet().orElse(false)) { + julRootLevel = Level.SEVERE; + } else if (context.invokerRequest.effectiveVerbose()) { + julRootLevel = Level.ALL; + } else { + julRootLevel = Level.INFO; + } + LogManager.getLogManager().getLogger("").setLevel(julRootLevel); if (context.options().failOnSeverity().isPresent()) { String logLevelThreshold = context.options().failOnSeverity().get(); if (context.loggerFactory instanceof LogLevelRecorder recorder) { @@ -475,7 +512,12 @@ protected void activateLogging(C context) throws Exception { } } - // at this point logging is set up, reply so far accumulated logs, if any and swap logger with real one + // At this point logging is set up and createTerminal() has already run + // (doInvoke calls createTerminal before activateLogging), so + // ProjectBuildLogAppender has already installed the MavenSimpleLogger + // logSink and wired up any -l log-file writer. We can drain the + // accumulated early log queue directly into the new Slf4jLogger — + // messages will be routed through the logSink to the correct output. Logger logger = new Slf4jLogger(context.loggerFactory.getLogger(getClass().getName())); context.logger.drain().forEach(e -> logger.log(e.level(), e.message(), e.error())); diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/LogbackConfiguration.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/LogbackConfiguration.java deleted file mode 100644 index 67ee429d82ab..000000000000 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/LogbackConfiguration.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * 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.logging.impl; - -import org.apache.maven.cling.logging.BaseSlf4jConfiguration; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Configuration for slf4j-logback. - * - * @since 3.1.0 - */ -public class LogbackConfiguration extends BaseSlf4jConfiguration { - @Override - public void setRootLoggerLevel(Level level) { - ch.qos.logback.classic.Level value = - switch (level) { - case DEBUG -> ch.qos.logback.classic.Level.DEBUG; - case INFO -> ch.qos.logback.classic.Level.INFO; - default -> ch.qos.logback.classic.Level.ERROR; - }; - ((ch.qos.logback.classic.Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME)).setLevel(value); - } - - @Override - public void activate() { - // no op - } -} diff --git a/impl/maven-cli/src/main/resources/META-INF/maven/slf4j-configuration.properties b/impl/maven-cli/src/main/resources/META-INF/maven/slf4j-configuration.properties index 369b0e6a266b..9580d5071ac9 100644 --- a/impl/maven-cli/src/main/resources/META-INF/maven/slf4j-configuration.properties +++ b/impl/maven-cli/src/main/resources/META-INF/maven/slf4j-configuration.properties @@ -19,5 +19,3 @@ # value = corresponding o.a.m.cli.logging.Slf4jConfiguration class org.slf4j.impl.SimpleLoggerFactory=org.apache.maven.cling.logging.impl.MavenSimpleConfiguration org.apache.maven.slf4j.MavenLoggerFactory=org.apache.maven.cling.logging.impl.MavenSimpleConfiguration -org.apache.logging.slf4j.Log4jLoggerFactory=org.apache.maven.cling.logging.impl.Log4j2Configuration -ch.qos.logback.classic.LoggerContext=org.apache.maven.cling.logging.impl.LogbackConfiguration diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java new file mode 100644 index 000000000000..f49c51aff3f3 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java @@ -0,0 +1,81 @@ +/* + * 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.internal.build; + +import java.time.Instant; + +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.LogLevel; + +/** + * Immutable implementation of {@link LogEvent}. + *

+ * Public to allow construction from other packages within the Maven + * implementation (e.g. {@code ProjectBuildLogAppender}). + * + * @param timestamp when the event was produced + * @param level the severity level + * @param message the clean log message (without level prefix or ANSI) + * @param loggerName the name of the logger, or {@code null} + * @param stackTrace the stack trace string, or {@code null} + * @param formattedMessage the fully formatted console line, or {@code null} + * @param sourceClassName the source class name (Log API mojo FQCN or JUL source), or {@code null} + * @param sourceMethodName the source method name (via StackWalker or JUL), or {@code null} + * @param threadId the originating thread ID, or {@code -1} if unavailable + * @param sequenceNumber the JUL sequence number for ordering, or {@code -1} if unavailable + * @param projectId the project this event belongs to, or {@code null} + * @param mojoId the mojo execution that produced this event, or {@code null} + */ +public record DefaultLogEvent( + Instant timestamp, + LogLevel level, + String message, + String loggerName, + String stackTrace, + String formattedMessage, + String sourceClassName, + String sourceMethodName, + long threadId, + long sequenceNumber, + String projectId, + String mojoId) + implements LogEvent { + + /** + * Convenience constructor for events without source metadata + * (i.e. direct SLF4J events). + */ + public DefaultLogEvent( + Instant timestamp, + LogLevel level, + String message, + String loggerName, + String stackTrace, + String formattedMessage) { + this(timestamp, level, message, loggerName, stackTrace, formattedMessage, null, null, -1, -1, null, null); + } + + /** + * Convenience constructor for events created without a formatted message + * (e.g. in tests or programmatic construction). + */ + DefaultLogEvent(Instant timestamp, LogLevel level, String message, String loggerName, String stackTrace) { + this(timestamp, level, message, loggerName, stackTrace, null, null, null, -1, -1, null, null); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java index b1cf40cc4059..6f17f2f74c25 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java @@ -18,21 +18,85 @@ */ package org.apache.maven.internal.impl; +import java.lang.StackWalker.StackFrame; import java.util.function.Supplier; import org.apache.maven.api.plugin.Log; +import org.apache.maven.logging.ProjectBuildLogAppender; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import static java.util.Objects.requireNonNull; public class DefaultLog implements Log { + + /** + * Metadata captured from Log API calls, mirroring the JUL metadata + * pattern in {@code MavenJulHandler}. + * + * @param sourceClassName the fully qualified class name of the caller + * @param sourceMethodName the method that issued the log call + * @param threadId the originating thread ID + */ + public record LogApiMetadata(String sourceClassName, String sourceMethodName, long threadId) {} + + private static final ThreadLocal LOG_API_METADATA = new ThreadLocal<>(); + private static final StackWalker WALKER = StackWalker.getInstance(); + private static final String THIS_CLASS = DefaultLog.class.getName(); + + /** + * Returns the Log API metadata for the current log event being processed, + * or {@code null} if the current event did not originate from the Log API. + *

+ * Called from {@code ProjectBuildLogAppender.accept()} to populate + * {@code LogEvent.sourceClassName()} and {@code LogEvent.sourceMethodName()}. + * + * @return the current Log API metadata, or {@code null} + */ + public static LogApiMetadata getLogApiMetadata() { + return LOG_API_METADATA.get(); + } + private final Logger logger; public DefaultLog(Logger logger) { this.logger = requireNonNull(logger); } + /** + * Wraps a logging call with Log API metadata: when build report capture is + * active, sets the ThreadLocal with source class name, caller method name, + * and thread ID, executes the actual SLF4J call, and clears the ThreadLocal. + *

+ * The source class name is taken from the SLF4J logger name (which + * is the mojo implementation FQCN, set at injection time). The + * source method name is resolved via {@link StackWalker} only when + * build report capture is active (to avoid the ~1-5μs per-call cost + * on every enabled log statement during normal builds). + *

+ * When build report capture is not active, the logAction is invoked + * directly with zero ThreadLocal cost. + */ + private void withMetadata(Runnable logAction) { + if (ProjectBuildLogAppender.hasReportCapture()) { + // Only pay the StackWalker + ThreadLocal cost when metadata is needed + String callerMethodName = WALKER.walk(frames -> frames.dropWhile(f -> THIS_CLASS.equals(f.getClassName())) + .findFirst() + .map(StackFrame::getMethodName) + .orElse(null)); + @SuppressWarnings("deprecation") // Thread.getId() — threadId() requires Java 19+ + long threadId = Thread.currentThread().getId(); + LOG_API_METADATA.set(new LogApiMetadata(logger.getName(), callerMethodName, threadId)); + try { + logAction.run(); + } finally { + LOG_API_METADATA.remove(); + } + } else { + logAction.run(); + } + } + @Override public boolean isTraceEnabled() { return logger.isTraceEnabled(); @@ -41,175 +105,175 @@ public boolean isTraceEnabled() { @Override public void trace(CharSequence content) { if (isTraceEnabled()) { - logger.trace(toString(content)); + withMetadata(() -> logger.trace(toString(content))); } } @Override public void trace(CharSequence content, Throwable error) { if (isTraceEnabled()) { - logger.trace(toString(content), error); + withMetadata(() -> logger.trace(toString(content), error)); } } @Override public void trace(Throwable error) { if (isTraceEnabled()) { - logger.trace("", error); + withMetadata(() -> logger.trace("", error)); } } @Override public void trace(Supplier content) { if (isTraceEnabled()) { - logger.trace(content.get()); + withMetadata(() -> logger.trace(content.get())); } } @Override public void trace(Supplier content, Throwable error) { if (isTraceEnabled()) { - logger.trace(content.get(), error); + withMetadata(() -> logger.trace(content.get(), error)); } } @Override public void debug(CharSequence content) { if (isDebugEnabled()) { - logger.debug(toString(content)); + withMetadata(() -> logger.debug(toString(content))); } } @Override public void debug(CharSequence content, Throwable error) { if (isDebugEnabled()) { - logger.debug(toString(content), error); + withMetadata(() -> logger.debug(toString(content), error)); } } @Override public void debug(Throwable error) { if (isDebugEnabled()) { - logger.debug("", error); + withMetadata(() -> logger.debug("", error)); } } @Override public void debug(Supplier content) { if (isDebugEnabled()) { - logger.debug(content.get()); + withMetadata(() -> logger.debug(content.get())); } } @Override public void debug(Supplier content, Throwable error) { if (isDebugEnabled()) { - logger.debug(content.get(), error); + withMetadata(() -> logger.debug(content.get(), error)); } } @Override public void info(CharSequence content) { if (isInfoEnabled()) { - logger.info(toString(content)); + withMetadata(() -> logger.info(toString(content))); } } @Override public void info(CharSequence content, Throwable error) { if (isInfoEnabled()) { - logger.info(toString(content), error); + withMetadata(() -> logger.info(toString(content), error)); } } @Override public void info(Throwable error) { if (isInfoEnabled()) { - logger.info("", error); + withMetadata(() -> logger.info("", error)); } } @Override public void info(Supplier content) { if (isInfoEnabled()) { - logger.info(content.get()); + withMetadata(() -> logger.info(content.get())); } } @Override public void info(Supplier content, Throwable error) { if (isInfoEnabled()) { - logger.info(content.get(), error); + withMetadata(() -> logger.info(content.get(), error)); } } @Override public void warn(CharSequence content) { if (isWarnEnabled()) { - logger.warn(toString(content)); + withMetadata(() -> logger.warn(toString(content))); } } @Override public void warn(CharSequence content, Throwable error) { if (isWarnEnabled()) { - logger.warn(toString(content), error); + withMetadata(() -> logger.warn(toString(content), error)); } } @Override public void warn(Throwable error) { if (isWarnEnabled()) { - logger.warn("", error); + withMetadata(() -> logger.warn("", error)); } } @Override public void warn(Supplier content) { if (isWarnEnabled()) { - logger.warn(content.get()); + withMetadata(() -> logger.warn(content.get())); } } @Override public void warn(Supplier content, Throwable error) { if (isWarnEnabled()) { - logger.warn(content.get(), error); + withMetadata(() -> logger.warn(content.get(), error)); } } @Override public void error(CharSequence content) { if (isErrorEnabled()) { - logger.error(toString(content)); + withMetadata(() -> logger.error(toString(content))); } } @Override public void error(CharSequence content, Throwable error) { if (isErrorEnabled()) { - logger.error(toString(content), error); + withMetadata(() -> logger.error(toString(content), error)); } } @Override public void error(Throwable error) { if (isErrorEnabled()) { - logger.error("", error); + withMetadata(() -> logger.error("", error)); } } @Override public void error(Supplier content) { if (isErrorEnabled()) { - logger.error(content.get()); + withMetadata(() -> logger.error(content.get())); } } @Override public void error(Supplier content, Throwable error) { if (isErrorEnabled()) { - logger.error(content.get(), error); + withMetadata(() -> logger.error(content.get(), error)); } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java b/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java index 39573d061cd0..c1d771b5c11b 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java +++ b/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java @@ -18,6 +18,7 @@ */ package org.apache.maven.logging; +import org.apache.maven.api.build.report.LogEvent; import org.apache.maven.execution.ExecutionEvent; import org.eclipse.aether.transfer.TransferEvent; @@ -30,7 +31,7 @@ public interface BuildEventListener { void projectStarted(String projectId); - void projectLogMessage(String projectId, String event); + void projectLogMessage(String projectId, LogEvent event); void projectFinished(String projectId); diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java b/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java index dc82a2f89848..a60f66df62b4 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java +++ b/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java @@ -18,11 +18,29 @@ */ package org.apache.maven.logging; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.time.Instant; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.LogLevel; +import org.apache.maven.internal.build.DefaultLogEvent; +import org.apache.maven.internal.impl.DefaultLog; +import org.apache.maven.slf4j.MavenJulHandler; import org.apache.maven.slf4j.MavenSimpleLogger; import org.slf4j.MDC; +import org.slf4j.spi.LocationAwareLogger; /** - * Forwards log messages to the client. + * Forwards log messages to the client as structured {@link LogEvent} objects. + *

+ * Installs itself as a {@link MavenSimpleLogger.LogSink} to intercept all + * SLF4J log output, enrich it with structured metadata (level, logger name, + * clean message, formatted output), and forward to the active + * {@link BuildEventListener}. */ public class ProjectBuildLogAppender implements AutoCloseable { @@ -118,6 +136,43 @@ public static void updateMdc() { } } + /** + * Global sequence counter for total ordering of log events across + * all sources (Log API, JUL, direct SLF4J). Incremented atomically + * in {@link #accept} which is called synchronously on the logging thread. + */ + private static final AtomicLong SEQUENCE = new AtomicLong(); + + /** + * Callback for build report log capture. Receives the fully-formed + * {@link LogEvent} produced by {@link #accept}, eliminating the need + * for a second capture pipeline in {@code MavenSimpleLogger}. + *

+ * Set by {@code BuildReportCollector} at session start, cleared at + * session end. The callback runs synchronously on the logging thread. + */ + private static volatile Consumer reportCapture; + + /** + * Sets the report capture callback. + * + * @param capture the callback, or {@code null} to remove + */ + public static void setReportCapture(Consumer capture) { + ProjectBuildLogAppender.reportCapture = capture; + } + + /** + * Returns {@code true} if a report capture callback is currently active. + * Used by {@link org.apache.maven.internal.impl.DefaultLog} to decide + * whether to pay the {@link StackWalker} cost for source method names. + * + * @return {@code true} if report capture is active + */ + public static boolean hasReportCapture() { + return reportCapture != null; + } + private final BuildEventListener buildEventListener; public ProjectBuildLogAppender(BuildEventListener buildEventListener) { @@ -125,13 +180,75 @@ public ProjectBuildLogAppender(BuildEventListener buildEventListener) { MavenSimpleLogger.setLogSink(this::accept); } - protected void accept(String message) { + protected void accept( + int level, String loggerName, String cleanMessage, String formattedMessage, Throwable throwable) { String projectId = MDC.get(KEY_PROJECT_ID); - buildEventListener.projectLogMessage(projectId, message); + Instant timestamp = MonotonicClock.now(); + LogLevel logLevel = toLogLevel(level); + String stackTrace = throwable != null ? formatStackTrace(throwable) : null; + + long seq = SEQUENCE.getAndIncrement(); + + // Read source metadata: JUL events carry it via MavenJulHandler, + // Log API events carry it via DefaultLog's ThreadLocal. + MavenJulHandler.JulMetadata julMeta = MavenJulHandler.getJulMetadata(); + DefaultLog.LogApiMetadata logApiMeta = DefaultLog.getLogApiMetadata(); + String sourceClassName; + String sourceMethodName; + long threadId; + if (julMeta != null) { + sourceClassName = julMeta.sourceClassName(); + sourceMethodName = julMeta.sourceMethodName(); + threadId = julMeta.threadId(); + } else if (logApiMeta != null) { + sourceClassName = logApiMeta.sourceClassName(); + sourceMethodName = logApiMeta.sourceMethodName(); + threadId = logApiMeta.threadId(); + } else { + sourceClassName = null; + sourceMethodName = null; + threadId = -1; + } + LogEvent event = new DefaultLogEvent( + timestamp, + logLevel, + cleanMessage, + loggerName, + stackTrace, + formattedMessage, + sourceClassName, + sourceMethodName, + threadId, + seq, + projectId, + MOJO_ID.get()); + buildEventListener.projectLogMessage(projectId, event); + + // Forward to build report collector (if active) + Consumer capture = reportCapture; + if (capture != null) { + capture.accept(event); + } } @Override public void close() throws Exception { MavenSimpleLogger.setLogSink(null); } + + private static LogLevel toLogLevel(int level) { + return switch (level) { + case LocationAwareLogger.TRACE_INT -> LogLevel.TRACE; + case LocationAwareLogger.DEBUG_INT -> LogLevel.DEBUG; + case LocationAwareLogger.INFO_INT -> LogLevel.INFO; + case LocationAwareLogger.WARN_INT -> LogLevel.WARN; + default -> LogLevel.ERROR; + }; + } + + private static String formatStackTrace(Throwable t) { + StringWriter sw = new StringWriter(); + t.printStackTrace(new PrintWriter(sw)); + return sw.toString(); + } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java b/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java index 87f7baa1fd59..37c49a92c1c4 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java +++ b/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java @@ -20,6 +20,7 @@ import java.util.function.Consumer; +import org.apache.maven.api.build.report.LogEvent; import org.apache.maven.execution.ExecutionEvent; import org.eclipse.aether.transfer.TransferEvent; @@ -38,8 +39,9 @@ public void sessionStarted(ExecutionEvent event) {} public void projectStarted(String projectId) {} @Override - public void projectLogMessage(String projectId, String event) { - log(event); + public void projectLogMessage(String projectId, LogEvent event) { + String formatted = event.formattedMessage(); + log(formatted != null ? formatted : event.message()); } @Override diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java index eb290734fb98..a167bce34d1c 100644 --- a/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java +++ b/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java @@ -19,11 +19,13 @@ package org.apache.maven.internal.impl; import org.apache.maven.api.plugin.Log; +import org.apache.maven.logging.ProjectBuildLogAppender; import org.junit.jupiter.api.Test; import org.slf4j.Logger; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -31,7 +33,8 @@ import static org.mockito.Mockito.when; /** - * Tests for {@link DefaultLog}. + * Tests for {@link DefaultLog}, focused on verifying the bug fix for + * {@code warn(Supplier, Throwable)} and the Log API metadata contract. */ class DefaultLogTest { @@ -43,6 +46,7 @@ class DefaultLogTest { void warnWithSupplierAndThrowableDelegatesToWarn() { Logger mockLogger = mock(Logger.class); when(mockLogger.isWarnEnabled()).thenReturn(true); + when(mockLogger.getName()).thenReturn("test.logger"); DefaultLog log = new DefaultLog(mockLogger); RuntimeException ex = new RuntimeException("test"); @@ -51,6 +55,35 @@ void warnWithSupplierAndThrowableDelegatesToWarn() { verify(mockLogger).warn("warning message", ex); } + /** + * Verify that Log API metadata is set during the log call and + * cleared afterwards — no leakage across calls. + *

+ * Report capture must be active for the metadata path to execute; + * without it {@code withMetadata()} takes the fast-path and the + * ThreadLocal is never set. + */ + @Test + void logApiMetadataIsClearedAfterCall() { + Logger mockLogger = mock(Logger.class); + when(mockLogger.isInfoEnabled()).thenReturn(true); + when(mockLogger.getName()).thenReturn("com.example.MyMojo"); + + DefaultLog log = new DefaultLog(mockLogger); + + // Activate the report-capture path so withMetadata() actually sets the ThreadLocal + ProjectBuildLogAppender.setReportCapture(event -> {}); + try { + log.info("test message"); + + // After the call completes, metadata should be cleared (finally block ran) + assertNull(DefaultLog.getLogApiMetadata(), "Log API metadata should be cleared after the log call"); + } finally { + // Restore: do not leave a capture installed across tests + ProjectBuildLogAppender.setReportCapture(null); + } + } + /** * Verify trace methods delegate to the SLF4J logger correctly. */ @@ -58,6 +91,7 @@ void warnWithSupplierAndThrowableDelegatesToWarn() { void traceMethodsDelegateToSlf4jTrace() { Logger mockLogger = mock(Logger.class); when(mockLogger.isTraceEnabled()).thenReturn(true); + when(mockLogger.getName()).thenReturn("test.logger"); DefaultLog log = new DefaultLog(mockLogger); log.trace("trace message"); @@ -77,6 +111,7 @@ void traceIsNoOpWhenDisabled() { log.trace("should not be logged"); verify(mockLogger).isTraceEnabled(); + // trace() should NOT have been called on the underlying logger verifyNoMoreInteractions(mockLogger); } diff --git a/impl/maven-jline/src/main/java/org/apache/maven/jline/JLineMessageBuilderFactory.java b/impl/maven-jline/src/main/java/org/apache/maven/jline/JLineMessageBuilderFactory.java index cfae3c000c3a..f02504809cad 100644 --- a/impl/maven-jline/src/main/java/org/apache/maven/jline/JLineMessageBuilderFactory.java +++ b/impl/maven-jline/src/main/java/org/apache/maven/jline/JLineMessageBuilderFactory.java @@ -136,7 +136,18 @@ private MavenStyleResolver() { @Override public AttributedStyle resolve(String spec) { - return styles.computeIfAbsent(spec, this::doResolve); + try { + return styles.computeIfAbsent(spec, this::doResolve); + } catch (IllegalStateException e) { + // ConcurrentHashMap.computeIfAbsent throws IllegalStateException("Recursive update") + // when the same map is re-entered from within a computeIfAbsent call on the same + // thread. This can happen during FastTerminal initialization: JLine's StyleResolver + // logs via JUL, MavenJulHandler routes to SLF4J, MavenSimpleLogger.renderLevel() + // lazily initialises styled level strings by calling style() → resolve() here, + // re-entering the same computeIfAbsent. Fall back to DEFAULT for this event; + // subsequent calls will hit the populated cache and succeed normally. + return AttributedStyle.DEFAULT; + } } @Override diff --git a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenBaseLogger.java b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenBaseLogger.java index 20a5e7ab666b..6faeba5feacd 100644 --- a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenBaseLogger.java +++ b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenBaseLogger.java @@ -232,6 +232,23 @@ protected void write(StringBuilder buf, Throwable t) { } } + /** + * Context-aware write that includes the log level, logger name, and + * clean message alongside the formatted output. Subclasses can override + * to forward structured data to a log sink. + *

+ * The default implementation delegates to {@link #write(StringBuilder, Throwable)}. + * + * @param level the SLF4J log level constant + * @param loggerName the name of the logger + * @param cleanMessage the formatted message without level/timestamp prefix + * @param formattedBuf the fully formatted log line + * @param t the throwable, may be {@code null} + */ + protected void write(int level, String loggerName, String cleanMessage, StringBuilder formattedBuf, Throwable t) { + write(formattedBuf, t); + } + protected void writeThrowable(Throwable t, PrintStream targetStream) { if (t != null) { t.printStackTrace(targetStream); @@ -375,7 +392,7 @@ private void innerHandleNormalizedLoggingCall( // Append the message buf.append(formattedMessage); - write(buf, t); + write(level.toInt(), name, formattedMessage, buf, t); } protected String renderLevel(int levelInt) { diff --git a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenJulHandler.java b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenJulHandler.java new file mode 100644 index 000000000000..747accaf5182 --- /dev/null +++ b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenJulHandler.java @@ -0,0 +1,318 @@ +/* + * 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.slf4j; + +import java.text.MessageFormat; +import java.util.MissingResourceException; +import java.util.ResourceBundle; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogManager; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +import org.slf4j.LoggerFactory; +import org.slf4j.spi.LocationAwareLogger; + +/** + * A JUL {@link Handler} that routes {@code java.util.logging} events into + * Maven's structured logging pipeline, preserving the rich {@link LogRecord} + * metadata that the standard {@code SLF4JBridgeHandler} silently drops + * (source class name, source method name, thread ID). + *

+ * All JUL events are routed through SLF4J so that {@link MavenSimpleLogger} + * produces a consistent {@code formattedMessage} (with timestamp, logger name, + * and ANSI styling) regardless of the event's origin. The JUL metadata is + * stashed in a thread-local before the SLF4J call so that downstream + * consumers (e.g. {@code ProjectBuildLogAppender}) can read it when + * constructing a structured {@code LogEvent}. + *

+ * Usage — replace the standard SLF4J bridge in {@code LookupInvoker}: + *

+ *     MavenJulHandler.install();
+ * 
+ * + * @since 4.1.0 + * @see #install() + * @see #getJulMetadata() + */ +public class MavenJulHandler extends Handler { + + /** + * JUL metadata captured from a {@link LogRecord} that would otherwise + * be lost when bridging to SLF4J. + * + * @param sourceClassName the source class, or {@code null} + * @param sourceMethodName the source method, or {@code null} + * @param threadId the originating thread ID + */ + public record JulMetadata(String sourceClassName, String sourceMethodName, long threadId) {} + + private static final ThreadLocal METADATA = new ThreadLocal<>(); + + /** + * Private SLF4J logger cache using {@link ConcurrentMap#putIfAbsent} + * instead of {@link ConcurrentMap#computeIfAbsent}. This avoids the + * {@code ConcurrentHashMap.computeIfAbsent} reentrancy bug + * ({@code IllegalStateException("Recursive update")}) that occurs + * when a JUL event fires during SLF4J logger initialization: the + * handler's {@code publish()} calls {@code LoggerFactory.getLogger()}, + * which internally uses {@code computeIfAbsent}, and if that triggers + * another JUL event whose logger name hashes to the same bucket, + * {@code ConcurrentHashMap} throws. {@code putIfAbsent} is safe + * against reentrancy — worst case, two threads create the same + * logger and one is discarded. + */ + private static final ConcurrentMap LOGGER_CACHE = new ConcurrentHashMap<>(); + + /** + * Re-entrancy guard: set to {@code true} while {@link #publish} is routing + * a JUL event through SLF4J on this thread. Prevents recursive JUL events + * (e.g. JLine's {@code StyleResolver} calling {@code java.util.logging.Logger} + * while inside {@link MavenSimpleLogger#renderLevel} lazy-initialisation, + * which in turn is triggered by a JUL event during terminal construction) + * from re-entering {@code publish} and crashing with + * {@code ConcurrentHashMap.computeIfAbsent IllegalStateException("Recursive update")}. + */ + private static final ThreadLocal IN_PUBLISH = new ThreadLocal<>(); + + /** + * Returns the JUL metadata for the current log event being processed, + * or {@code null} if the current log event did not originate from JUL. + *

+ * This method is intended to be called from within a + * {@link MavenSimpleLogger.LogSink} callback (e.g. in + * {@code ProjectBuildLogAppender.accept()}). + * + * @return the current JUL metadata, or {@code null} + */ + public static JulMetadata getJulMetadata() { + return METADATA.get(); + } + + /** + * Package-private test hook: sets or clears the re-entrancy flag on + * the current thread without reflection. Tests in the same package + * can call this instead of using {@code getDeclaredField("IN_PUBLISH")}. + * + * @param inPublish {@code true} to simulate being inside {@link #publish}, + * {@code false} (or pass {@code null} via the {@code remove} + * path) to clear the flag + */ + static void setInPublishForTest(boolean inPublish) { + if (inPublish) { + IN_PUBLISH.set(Boolean.TRUE); + } else { + IN_PUBLISH.remove(); + } + } + + /** + * Installs this handler on the JUL root logger, removing any + * previously installed handlers. This replaces the standard + * {@code SLF4JBridgeHandler.install()} call. + */ + public static void install() { + Logger rootLogger = LogManager.getLogManager().getLogger(""); + // Remove all existing handlers (including any SLF4JBridgeHandler) + for (Handler handler : rootLogger.getHandlers()) { + rootLogger.removeHandler(handler); + } + rootLogger.addHandler(new MavenJulHandler()); + // Note: we intentionally do NOT set rootLogger.setLevel(Level.ALL) + // here. Setting it eagerly floods JUL events during SLF4J bootstrap, + // triggering ConcurrentHashMap.computeIfAbsent reentrancy in the + // SLF4J logger factory ("Recursive update"). The JUL root default + // (INFO) is fine — callers that need FINE/FINEST events (e.g. -X + // debug mode) should set the JUL root level after SLF4J is fully + // initialized. + } + + /** + * Returns {@code true} if a {@code MavenJulHandler} is installed + * on the JUL root logger. + */ + public static boolean isInstalled() { + Logger rootLogger = LogManager.getLogManager().getLogger(""); + for (Handler handler : rootLogger.getHandlers()) { + if (handler instanceof MavenJulHandler) { + return true; + } + } + return false; + } + + @Override + public void publish(LogRecord record) { + if (record == null) { + return; + } + + // Honour any Filter registered on this Handler (java.util.logging.Handler contract). + if (!isLoggable(record)) { + return; + } + + // Re-entrancy guard: drop recursive JUL events that originate from + // within SLF4J/JLine processing triggered by this very publish() call. + // Example: MavenSimpleLogger.renderLevel() lazily initialises ANSI + // colour strings by calling JLine's StyleResolver, which logs DEBUG + // events via java.util.logging — re-entering publish() on the same + // thread and crashing ConcurrentHashMap.computeIfAbsent with + // IllegalStateException("Recursive update"). + if (Boolean.TRUE.equals(IN_PUBLISH.get())) { + return; + } + + // Guard against null logger name (allowed by JUL spec) + String loggerName = record.getLoggerName(); + if (loggerName == null) { + loggerName = ""; + } + + // Look up the SLF4J logger from our private cache, bypassing + // LoggerFactory.getLogger() on the hot path to avoid the + // ConcurrentHashMap.computeIfAbsent reentrancy problem. + org.slf4j.Logger slf4jLogger = LOGGER_CACHE.get(loggerName); + if (slf4jLogger == null) { + // Cold path: create the logger via SLF4J. Guard against + // the ConcurrentHashMap.computeIfAbsent reentrancy bug: + // LoggerFactory.getLogger() uses computeIfAbsent internally, + // so if a JUL event fires during SLF4J initialization and + // the logger name hashes to the same bucket, CHM throws + // IllegalStateException("Recursive update"). We catch it + // and silently drop the event — it's a bootstrap race, and + // subsequent events will hit the cache. + try { + slf4jLogger = LoggerFactory.getLogger(loggerName); + } catch (IllegalStateException e) { + // ConcurrentHashMap reentrancy — drop this event + return; + } + LOGGER_CACHE.putIfAbsent(loggerName, slf4jLogger); + } + int slf4jLevel = julLevelToSlf4j(record.getLevel()); + + // Quick exit if this level is not enabled + if (!isLevelEnabled(slf4jLogger, slf4jLevel)) { + return; + } + + String message = formatMessage(record); + Throwable throwable = record.getThrown(); + + // Set the JUL metadata before routing through SLF4J so that + // downstream consumers (e.g. ProjectBuildLogAppender) can read + // it when constructing a structured LogEvent. By always going + // through SLF4J, the formattedMessage is produced by + // MavenSimpleLogger (with proper timestamp, logger name, and + // ANSI styling) regardless of whether the event originated from + // JUL or SLF4J — fixing the format inconsistency. + METADATA.set( + new JulMetadata(record.getSourceClassName(), record.getSourceMethodName(), record.getLongThreadID())); + IN_PUBLISH.set(Boolean.TRUE); + try { + logToSlf4j(slf4jLogger, slf4jLevel, message, throwable); + } finally { + IN_PUBLISH.remove(); + METADATA.remove(); + } + } + + @Override + public void flush() { + // nothing to flush + } + + @Override + public void close() throws SecurityException { + // nothing to close + } + + /** + * Formats the log message, applying i18n resource bundle lookup and + * {@link MessageFormat} parameter substitution, matching the behavior + * of {@code SLF4JBridgeHandler}. + */ + private static String formatMessage(LogRecord record) { + String message = record.getMessage(); + if (message == null) { + return ""; + } + + // Try resource bundle lookup + ResourceBundle bundle = record.getResourceBundle(); + if (bundle != null) { + try { + message = bundle.getString(message); + } catch (MissingResourceException e) { + // use raw message + } + } + + // Apply MessageFormat parameters + Object[] params = record.getParameters(); + if (params != null && params.length > 0) { + try { + message = MessageFormat.format(message, params); + } catch (IllegalArgumentException e) { + // use message as-is if formatting fails + } + } + + return message; + } + + private static int julLevelToSlf4j(Level julLevel) { + int value = julLevel.intValue(); + if (value <= Level.FINEST.intValue()) { + return LocationAwareLogger.TRACE_INT; + } else if (value <= Level.FINE.intValue()) { + return LocationAwareLogger.DEBUG_INT; + } else if (value <= Level.INFO.intValue()) { + return LocationAwareLogger.INFO_INT; + } else if (value <= Level.WARNING.intValue()) { + return LocationAwareLogger.WARN_INT; + } else { + return LocationAwareLogger.ERROR_INT; + } + } + + private static boolean isLevelEnabled(org.slf4j.Logger logger, int level) { + return switch (level) { + case LocationAwareLogger.TRACE_INT -> logger.isTraceEnabled(); + case LocationAwareLogger.DEBUG_INT -> logger.isDebugEnabled(); + case LocationAwareLogger.INFO_INT -> logger.isInfoEnabled(); + case LocationAwareLogger.WARN_INT -> logger.isWarnEnabled(); + default -> logger.isErrorEnabled(); + }; + } + + private static void logToSlf4j(org.slf4j.Logger logger, int level, String message, Throwable throwable) { + switch (level) { + case LocationAwareLogger.TRACE_INT -> logger.trace(message, throwable); + case LocationAwareLogger.DEBUG_INT -> logger.debug(message, throwable); + case LocationAwareLogger.INFO_INT -> logger.info(message, throwable); + case LocationAwareLogger.WARN_INT -> logger.warn(message, throwable); + default -> logger.error(message, throwable); + } + } +} diff --git a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java index 02767987e2a1..2060d5bb7953 100644 --- a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java +++ b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java @@ -39,14 +39,45 @@ public class MavenSimpleLogger extends MavenBaseLogger { private String warnRenderedLevel; private String errorRenderedLevel; - static Consumer logSink; + /** + * Structured log sink that receives level, logger name, clean message, + * formatted console output, and throwable for each log event. + *

+ * This replaces the previous {@code Consumer} sink to enable + * console renderers (e.g. rich mode) to filter by log level and access + * the clean message independently of ANSI formatting. + * + * @since 4.1.0 + */ + @FunctionalInterface + public interface LogSink { + void accept(int level, String loggerName, String cleanMessage, String formattedMessage, Throwable throwable); + } + + static volatile LogSink logSink; public static final String DEFAULT_LOG_LEVEL_KEY = "org.slf4j.simpleLogger.defaultLogLevel"; - public static void setLogSink(Consumer logSink) { + /** + * Sets the structured log sink. + * + * @param logSink the sink, or {@code null} to remove + * @since 4.1.0 + */ + public static void setLogSink(LogSink logSink) { MavenSimpleLogger.logSink = logSink; } + /** + * Returns the current log sink, or {@code null} if none is set. + * + * @return the current log sink, or {@code null} + * @since 4.1.0 + */ + public static LogSink getLogSink() { + return logSink; + } + MavenSimpleLogger(String name) { super(name); } @@ -70,15 +101,22 @@ protected String renderLevel(int level) { } @Override - protected void write(StringBuilder buf, Throwable t) { - Consumer sink = logSink; + protected void write(int level, String loggerName, String cleanMessage, StringBuilder formattedBuf, Throwable t) { + LogSink sink = logSink; if (sink != null) { - sink.accept(buf.toString()); + // Build the full formatted output including throwable rendering, + // reusing the existing writeThrowable/printStackTrace methods + // to keep a single rendering path for throwables. + String formatted = formattedBuf.toString(); if (t != null) { - writeThrowable(t, sink); + StringBuilder full = new StringBuilder(formatted); + full.append(System.lineSeparator()); + writeThrowable(t, line -> full.append(line).append(System.lineSeparator())); + formatted = full.toString(); } + sink.accept(level, loggerName, cleanMessage, formatted, t); } else { - super.write(buf, t); + super.write(formattedBuf, t); } } diff --git a/impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenJulHandlerTest.java b/impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenJulHandlerTest.java new file mode 100644 index 000000000000..58c23c92031e --- /dev/null +++ b/impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenJulHandlerTest.java @@ -0,0 +1,128 @@ +/* + * 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.slf4j; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.logging.Level; +import java.util.logging.LogRecord; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.slf4j.spi.LocationAwareLogger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Tests for {@link MavenJulHandler}, focused on the JUL→SLF4J level + * mapping and metadata lifecycle. + */ +class MavenJulHandlerTest { + + /** + * Table test for JUL level → SLF4J level mapping. + * Verifies the mapping documented in the class Javadoc. + */ + @ParameterizedTest(name = "JUL {0} -> SLF4J level {1}") + @CsvSource({ + "FINEST, 0", // TRACE_INT = 0 + "FINER, 10", // DEBUG_INT = 10 + "FINE, 10", // DEBUG_INT = 10 + "CONFIG, 20", // INFO_INT = 20 + "INFO, 20", // INFO_INT = 20 + "WARNING, 30", // WARN_INT = 30 + "SEVERE, 40", // ERROR_INT = 40 + }) + void julLevelMapsToCorrectSlf4jLevel(String julLevelName, int expectedSlf4jLevel) throws Exception { + Level julLevel = Level.parse(julLevelName); + int actual = invokeJulLevelToSlf4j(julLevel); + assertEquals( + expectedSlf4jLevel, actual, "JUL " + julLevelName + " should map to SLF4J level " + expectedSlf4jLevel); + } + + /** + * Verify that FINEST maps to TRACE (not DEBUG) — this is the key + * distinction for the TRACE/DEBUG separation. + */ + @Test + void finestMapsToTrace() throws Exception { + assertEquals( + LocationAwareLogger.TRACE_INT, + invokeJulLevelToSlf4j(Level.FINEST), + "FINEST should map to TRACE, not DEBUG"); + } + + /** + * Verify that CONFIG maps to INFO (not DEBUG) — CONFIG is JUL's + * informational level for static configuration, not a debug level. + */ + @Test + void configMapsToInfo() throws Exception { + assertEquals(LocationAwareLogger.INFO_INT, invokeJulLevelToSlf4j(Level.CONFIG), "CONFIG should map to INFO"); + } + + /** + * Verify that JUL metadata is null when no log event is being processed. + */ + @Test + void julMetadataIsNullOutsidePublish() { + assertNull(MavenJulHandler.getJulMetadata(), "JUL metadata should be null outside a publish() call"); + } + + /** + * Verify that a recursive call to {@link MavenJulHandler#publish} from + * within a {@code publish()} call on the same thread is silently dropped + * instead of crashing with {@code IllegalStateException("Recursive update")}. + *

+ * This is the reentrancy scenario that occurs when JLine's + * {@code StyleResolver} logs a JUL DEBUG event while + * {@code MavenSimpleLogger.renderLevel()} is lazily initialising ANSI + * colour strings during terminal construction. + */ + @Test + void publishIsReentrantSafe() { + MavenJulHandler handler = new MavenJulHandler(); + LogRecord record = new LogRecord(Level.INFO, "nested"); + + // Simulate re-entrance: use the package-private test hook instead of + // reflection so the test does not break if IN_PUBLISH is renamed. + MavenJulHandler.setInPublishForTest(true); + try { + // A nested call must be silently dropped without throwing. + handler.publish(record); + } finally { + MavenJulHandler.setInPublishForTest(false); + } + } + + /** + * Invoke the private julLevelToSlf4j method via reflection for testing. + */ + private static int invokeJulLevelToSlf4j(Level julLevel) throws Exception { + try { + Method method = MavenJulHandler.class.getDeclaredMethod("julLevelToSlf4j", Level.class); + method.setAccessible(true); + return (int) method.invoke(null, julLevel); + } catch (InvocationTargetException e) { + throw (Exception) e.getCause(); + } + } +} diff --git a/pom.xml b/pom.xml index 49101f6196cd..b03308e65864 100644 --- a/pom.xml +++ b/pom.xml @@ -157,7 +157,6 @@ under the License. 1.37 6.1.3 1.4.0 - 1.6.3 5.23.0 1.6.0 1.30.0 @@ -508,17 +507,6 @@ under the License. ${slf4jVersion} true - - org.slf4j - jul-to-slf4j - ${slf4jVersion} - - - ch.qos.logback - logback-classic - ${logbackClassicVersion} - true - org.apache.maven.wagon From 92a187fbaf83fa9ba9eb9cecc787d77850922ac1 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 21 Sep 2026 07:43:54 +0000 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20build=20report=20=E2=80=93=20struct?= =?UTF-8?q?ured=20JSON=20report=20with=20per-mojo=20log=20capture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add BuildReport, ModuleReport, MojoReport, FailureReport, BuildStatus API types (maven-api-core) - Add BuildEnvironment API type and Session.buildEnvironment() accessor - Implement BuildReportCollector in maven-core: captures per-mojo log output, timing, failures - Implement BuildReportJsonWriter: writes structured JSON report to file at build end - Integrate into MavenInvoker and DefaultMavenExecutionRequest - Extend MavenSimpleLogger and MavenJulHandler with LogSink support for log capture - Add SessionStub.buildEnvironment() stub for maven-testing - Add BuildReportCollectorTest, BuildReportJsonWriterTest, BuildReportIntegrationTest - Handle MojoSkipped events in BuildReportCollector - Fix ConcurrentModificationException in buildReport() log buffer iteration --- .../apache/maven/api/BuildEnvironment.java | 203 +++++ .../java/org/apache/maven/api/Session.java | 13 + .../maven/api/build/report/BuildReport.java | 208 ++++++ .../maven/api/build/report/BuildStatus.java | 46 ++ .../maven/api/build/report/FailureReport.java | 91 +++ .../maven/api/build/report/ModuleReport.java | 136 ++++ .../maven/api/build/report/MojoReport.java | 140 ++++ .../maven/api/build/report/package-info.java | 13 +- .../maven/cling/invoker/mvn/MavenInvoker.java | 1 + .../DefaultMavenExecutionRequest.java | 14 + .../execution/MavenExecutionRequest.java | 5 + .../internal/build/BuildReportCollector.java | 698 ++++++++++++++++++ .../internal/build/BuildReportJsonWriter.java | 435 +++++++++++ .../build/DefaultBuildEnvironment.java | 69 ++ .../internal/build/DefaultBuildReport.java | 84 +++ .../internal/build/DefaultFailureReport.java | 30 + .../internal/build/DefaultModuleReport.java | 53 ++ .../internal/build/DefaultMojoReport.java | 49 ++ .../maven/internal/impl/DefaultSession.java | 16 + .../build/BuildReportCollectorTest.java | 392 ++++++++++ .../build/BuildReportIntegrationTest.java | 313 ++++++++ .../build/BuildReportJsonWriterTest.java | 438 +++++++++++ .../maven/impl/standalone/ApiRunner.java | 77 ++ .../apache/maven/slf4j/MavenSimpleLogger.java | 30 +- .../maven/slf4j/MavenJulHandlerTest.java | 27 + .../maven/slf4j/MavenSimpleLoggerTest.java | 88 +++ .../testing/plugin/stubs/SessionStub.java | 75 ++ 27 files changed, 3732 insertions(+), 12 deletions(-) create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/BuildEnvironment.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildReport.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildStatus.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/report/FailureReport.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/report/ModuleReport.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/report/MojoReport.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportJsonWriter.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildEnvironment.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildReport.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultFailureReport.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultModuleReport.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultMojoReport.java create mode 100644 impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportCollectorTest.java create mode 100644 impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportIntegrationTest.java create mode 100644 impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportJsonWriterTest.java diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/BuildEnvironment.java b/api/maven-api-core/src/main/java/org/apache/maven/api/BuildEnvironment.java new file mode 100644 index 000000000000..37d6dc596a6e --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/BuildEnvironment.java @@ -0,0 +1,203 @@ +/* + * 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.api; + +import java.util.List; +import java.util.Map; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Immutable; +import org.apache.maven.api.annotations.Nonnull; + +/** + * Describes the invocation context of a Maven build: the flags, properties, and + * environment settings that were active when the build started. + * + *

An instance is available via {@link Session#buildEnvironment()} during the build, + * and is also recorded in the structured build report for post-mortem analysis and + * reproducibility. + * + *

What is captured

+ *
    + *
  • Goals and lifecycle phases requested ({@link #goals()})
  • + *
  • User properties passed via {@code -Dkey=value} ({@link #userProperties()}), + * with sensitive keys redacted — see {@link #userProperties()} for the denylist
  • + *
  • A curated subset of system properties relevant to reproducibility + * ({@link #systemInfo()}): OS name/arch/version, Java vendor and VM name/version, + * Maven home, and available processors
  • + *
  • Local repository path ({@link #localRepository()})
  • + *
  • Explicitly activated or deactivated profiles ({@link #activeProfiles()})
  • + *
  • Selected projects ({@link #selectedProjects()}) and resume-from + * ({@link #resumeFrom()})
  • + *
  • Reactor failure behavior ({@link #reactorFailureBehavior()})
  • + *
  • Offline mode ({@link #offline()}) and snapshot update policy + * ({@link #updateSnapshots()})
  • + *
  • Degree of concurrency ({@link #threads()})
  • + *
  • Transfer-progress suppression ({@link #noTransferProgress()}) and batch mode ({@link #batchMode()})
  • + *
+ * + *

What is not yet captured

+ *

The following information is not currently available through the Maven 4 API + * and therefore cannot be recorded here. It may be added in future versions as + * the API evolves: + *

    + *
  • Raw command-line arguments ({@code args[]}): the CLI bootstrap layer + * does not surface these through {@code MavenExecutionRequest} or the session
  • + *
  • Implicitly activated profiles: profiles activated by OS, JDK, or + * property conditions rather than by explicit {@code -P} flag; computing + * these requires per-project activation and is not available at session level
  • + *
+ * + * @since 4.1.0 + * @see Session#buildEnvironment() + */ +@Experimental +@Immutable +public interface BuildEnvironment { + + /** + * The goals or lifecycle phases that were requested on the command line. + * + * @return the requested goals/phases, never {@code null} + */ + @Nonnull + List goals(); + + /** + * User properties passed via {@code -Dkey=value} on the command line or via + * {@code --define}. + * + *

Sensitive keys are redacted and replaced with {@code "***"}. A key is + * considered sensitive if its lower-case form contains any of: + * {@code password}, {@code passwd}, {@code secret}, {@code token}, + * {@code apikey}, {@code api_key}, {@code credential}, {@code passphrase}. + * + * @return the user properties, never {@code null}; values of sensitive keys + * are replaced with {@code "***"} + */ + @Nonnull + Map userProperties(); + + /** + * A curated subset of system properties capturing platform and JVM identity, + * relevant for build reproducibility analysis. + * + *

The following keys are included when present: + * {@code os.name}, {@code os.arch}, {@code os.version}, + * {@code java.vendor}, {@code java.vm.name}, {@code java.vm.version}, + * {@code maven.home}, {@code user.home}, {@code user.name}, + * and {@code available.processors} (from {@code Runtime.getRuntime()}). + * + *

The full {@code System.getProperties()} map is intentionally not captured: + * it is large, mostly irrelevant, and may contain sensitive values. + * + * @return the curated system info map, never {@code null} + */ + @Nonnull + Map systemInfo(); + + /** + * The path to the local repository used for this build. + * + * @return the local repository path string, never {@code null} + */ + @Nonnull + String localRepository(); + + /** + * Profiles explicitly activated ({@code -P profileId}) or deactivated + * ({@code -P !profileId}) on the command line. + * + *

This list reflects only explicit {@code -P} selections. Profiles activated + * implicitly by OS, JDK, or property conditions are not included here — see + * the class-level javadoc for details. + * + * @return the explicitly selected profiles, never {@code null}; may be empty + */ + @Nonnull + List activeProfiles(); + + /** + * Projects explicitly selected via {@code -pl} / {@code --projects}. + * + * @return the selected project selectors (e.g. {@code ":my-module"}), + * never {@code null}; empty for a full reactor build + */ + @Nonnull + List selectedProjects(); + + /** + * The project to resume from, as specified via {@code -rf} / {@code --resume-from}. + * + * @return the resume-from selector, or {@code null} if not specified + */ + String resumeFrom(); + + /** + * The reactor failure behavior, corresponding to the {@code -ff} / {@code -fae} / + * {@code -fn} flags. + * + *

Possible values (matching {@code MavenExecutionRequest} constants): + *

    + *
  • {@code "FAIL_FAST"} — stop at first failure ({@code -ff}, default)
  • + *
  • {@code "FAIL_AT_END"} — build all, report failures at end ({@code -fae})
  • + *
  • {@code "FAIL_NEVER"} — always exit with success ({@code -fn})
  • + *
+ * + * @return the failure behavior string, never {@code null} + */ + @Nonnull + String reactorFailureBehavior(); + + /** + * Whether the build was invoked in offline mode ({@code -o} / {@code --offline}). + * + * @return {@code true} if offline mode was active + */ + boolean offline(); + + /** + * Whether snapshot updates were forced ({@code -U} / {@code --update-snapshots}). + * + * @return {@code true} if snapshot updates were forced + */ + boolean updateSnapshots(); + + /** + * Whether transfer progress output was suppressed ({@code --no-transfer-progress} / {@code -ntp}). + * + * @return {@code true} if transfer progress was disabled + */ + boolean noTransferProgress(); + + /** + * Whether the build was invoked in non-interactive (batch) mode ({@code --batch-mode} / {@code -B}). + * Equivalent to {@code !isInteractiveMode()} on {@code MavenExecutionRequest}. + * + * @return {@code true} if batch mode was active + */ + boolean batchMode(); + + /** + * The degree of concurrency ({@code -T} flag), or {@code 1} for sequential builds. + * + * @return the thread count + */ + int threads(); +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/Session.java b/api/maven-api-core/src/main/java/org/apache/maven/api/Session.java index 0138f461b1aa..ce08cb61bec6 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/Session.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/Session.java @@ -46,6 +46,19 @@ @ThreadSafe public interface Session extends ProtoSession { + /** + * Returns the environment context of this build: the flags, properties, and + * platform settings that were active when the session started. + * + *

The returned instance is immutable and captures a snapshot of the invocation + * context (offline mode, user properties, selected projects, etc.). + * + * @return the build environment, never {@code null} + * @since 4.1.0 + */ + @Nonnull + BuildEnvironment buildEnvironment(); + /** * Returns the current maven version. * diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildReport.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildReport.java new file mode 100644 index 000000000000..f8d6088ae4aa --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildReport.java @@ -0,0 +1,208 @@ +/* + * 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.api.build.report; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.apache.maven.api.BuildEnvironment; +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Immutable; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.services.BuilderProblem; + +/** + * A structured report of a Maven build execution, persisted to + * {@code target/build-report.json} at the end of every build. + *

+ * The report captures metadata, per-module results (including mojo execution + * timings), and any failures. It is intended to be consumed by tools, IDEs, + * CI systems, and LLM agents without having to re-run the build or parse + * console output. + * + * @since 4.1.0 + * @see ModuleReport + * @see FailureReport + */ +@Experimental +@Immutable +public interface BuildReport { + + /** + * The invocation context of this build: flags, properties, and platform settings + * that were active when the session started. + * + * @return the build environment, never {@code null} + * @since 4.1.0 + */ + @Nonnull + BuildEnvironment environment(); + + /** + * Schema version of the report format. Consumers should check this + * to handle forward compatibility. + * + * @return the format version, currently {@code 1} + */ + int formatVersion(); + + /** + * The overall build status. + * + * @return the build outcome, never {@code null} + */ + @Nonnull + BuildStatus status(); + + /** + * Wall-clock duration of the entire build. + * + * @return the total duration, never {@code null} + */ + @Nonnull + Duration duration(); + + /** + * When the build started (wall-clock time). + * + * @return the start instant, never {@code null} + */ + @Nonnull + Instant startTime(); + + /** + * The Maven version that produced this report. + * + * @return the Maven version string, never {@code null} + */ + @Nonnull + String mavenVersion(); + + /** + * The Java version used for the build. + * + * @return the Java version string, never {@code null} + */ + @Nonnull + String javaVersion(); + + /** + * The goals or phases that were requested. + * + * @return the list of goals, never {@code null} + */ + @Nonnull + List goals(); + + /** + * The GAV of the top-level project ({@code groupId:artifactId:version}). + * + * @return the project identifier, never {@code null} + */ + @Nonnull + String project(); + + /** + * Whether this was a multi-module (reactor) build. + * + * @return {@code true} for multi-module builds + */ + boolean multiModule(); + + /** + * The degree of concurrency ({@code -T} flag), or 1 for sequential builds. + * + * @return the thread count + */ + int threads(); + + /** + * Per-module build results, in reactor execution order. + * + * @return the module reports, never {@code null} + */ + @Nonnull + List modules(); + + /** + * Failures that occurred during the build, if any. + * + * @return the failure reports, never {@code null}; empty if the build succeeded + */ + @Nonnull + List failures(); + + /** + * Structured problems (warnings, errors) reported during the build by + * Maven itself or by plugins. + * + * @return the problems, never {@code null}; empty if none were reported + * @since 4.1.0 + */ + @Nonnull + List problems(); + + /** + * Structured log events captured outside of any module's lifecycle — + * Maven startup messages, reactor ordering, and the final reactor summary. + *

+ * For per-module events see {@link ModuleReport#output()}, and for + * per-mojo events see {@link MojoReport#output()}. + *

+ * Together, {@code BuildReport.output()}, {@code ModuleReport.output()}, + * and {@code MojoReport.output()} form a non-overlapping partition of + * the full build log. + * + * @return the captured log events, never {@code null}; may be empty + */ + @Nonnull + List output(); + + /** + * Find a module report by its GAV identifier. + *

+ * The identifier format is {@code "groupId:artifactId:version"}, matching + * the format returned by {@link ModuleReport#id()} and used in + * {@link FailureReport#module()}. + * + * @param moduleId the module GAV string + * (e.g. {@code "org.apache.maven:maven-core:4.1.0-SNAPSHOT"}) + * @return the matching module report, or empty if not found + */ + @Nonnull + default Optional findModule(String moduleId) { + Objects.requireNonNull(moduleId); + return modules().stream().filter(m -> moduleId.equals(m.id())).findFirst(); + } + + /** + * Find the module report that corresponds to a given failure. + * + * @param failure the failure report + * @return the matching module report, or empty if not found + */ + @Nonnull + default Optional findModule(FailureReport failure) { + Objects.requireNonNull(failure); + return findModule(failure.module()); + } +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildStatus.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildStatus.java new file mode 100644 index 000000000000..37d5dd9fe488 --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildStatus.java @@ -0,0 +1,46 @@ +/* + * 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.api.build.report; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Immutable; + +/** + * The outcome of a build, module, or mojo execution. + * + * @since 4.1.0 + */ +@Experimental +@Immutable +public enum BuildStatus { + /** + * Completed successfully. + */ + SUCCESS, + + /** + * Failed with an error. + */ + FAILURE, + + /** + * Skipped (e.g. because a dependency failed). + */ + SKIPPED +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/FailureReport.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/FailureReport.java new file mode 100644 index 000000000000..115301c7067a --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/FailureReport.java @@ -0,0 +1,91 @@ +/* + * 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.api.build.report; + +import java.time.Instant; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Immutable; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.Nullable; + +/** + * Details about a build failure. + * + * @since 4.1.0 + * @see BuildReport#failures() + */ +@Experimental +@Immutable +public interface FailureReport { + + /** + * The GAV of the module where the failure occurred + * ({@code groupId:artifactId:version}). + * + * @return the module identifier, never {@code null} + */ + @Nonnull + String module(); + + /** + * The mojo that failed, formatted as {@code artifactId:version:goal} + * (e.g. {@code "maven-compiler-plugin:3.15.0:compile"}). + * + * @return the mojo identifier, or {@code null} if the failure was not mojo-specific + */ + @Nullable + String mojo(); + + /** + * When the failure occurred (wall-clock time). + * + * @return the failure instant, never {@code null} + */ + @Nonnull + Instant timestamp(); + + /** + * The simple class name of the root cause exception + * (e.g. {@code "MojoFailureException"}, {@code "LifecycleExecutionException"}). + *

+ * Useful for programmatic triage — tools can pattern-match on known + * exception types without parsing the message. + * + * @return the exception type name, or {@code null} if unavailable + */ + @Nullable + String exceptionType(); + + /** + * The exception message. + * + * @return the error message, never {@code null} + */ + @Nonnull + String message(); + + /** + * The exception stack trace, truncated to a reasonable length. + * + * @return the stack trace string, or {@code null} if unavailable + */ + @Nullable + String stackTrace(); +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/ModuleReport.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/ModuleReport.java new file mode 100644 index 000000000000..cd0fea00086a --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/ModuleReport.java @@ -0,0 +1,136 @@ +/* + * 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.api.build.report; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Immutable; +import org.apache.maven.api.annotations.Nonnull; + +/** + * Build results for a single module in a reactor build. + * + * @since 4.1.0 + * @see BuildReport#modules() + */ +@Experimental +@Immutable +public interface ModuleReport { + + /** + * The module's group ID. + * + * @return the group ID, never {@code null} + */ + @Nonnull + String groupId(); + + /** + * The module's artifact ID. + * + * @return the artifact ID, never {@code null} + */ + @Nonnull + String artifactId(); + + /** + * The module's version. + * + * @return the version string, never {@code null} + */ + @Nonnull + String version(); + + /** + * The build outcome for this module. + * + * @return the status, never {@code null} + */ + @Nonnull + BuildStatus status(); + + /** + * When this module started building (wall-clock time). + * + * @return the start instant, never {@code null} + */ + @Nonnull + Instant startTime(); + + /** + * How long this module took to build. + * + * @return the duration, never {@code null} + */ + @Nonnull + Duration duration(); + + /** + * The mojo executions that ran within this module, in execution order. + * + * @return the mojo reports, never {@code null} + */ + @Nonnull + List mojos(); + + /** + * Structured log events captured during this module's build lifecycle + * but outside any mojo execution — dependency resolution messages, + * resource copying, and other Maven infrastructure output. + *

+ * For per-mojo events see {@link MojoReport#output()}. + * + * @return the captured log events, never {@code null}; may be empty + */ + @Nonnull + List output(); + + /** + * The module identifier formatted as {@code "groupId:artifactId:version"}. + *

+ * This matches the format used by {@link FailureReport#module()}, allowing + * direct lookup from a failure report. + * + * @return the GAV string, never {@code null} + */ + @Nonnull + default String id() { + return groupId() + ":" + artifactId() + ":" + version(); + } + + /** + * Find a mojo execution by its identifier string. + *

+ * The identifier format is {@code "artifactId:version:goal"}, matching + * the format used by {@link FailureReport#mojo()}. + * + * @param mojoId the mojo identifier (e.g. {@code "maven-compiler-plugin:3.15.0:compile"}) + * @return the matching mojo report, or empty if not found + */ + @Nonnull + default Optional findMojo(String mojoId) { + Objects.requireNonNull(mojoId); + return mojos().stream().filter(m -> mojoId.equals(m.id())).findFirst(); + } +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/MojoReport.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/MojoReport.java new file mode 100644 index 000000000000..8829243fa17f --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/MojoReport.java @@ -0,0 +1,140 @@ +/* + * 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.api.build.report; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Immutable; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.Nullable; + +/** + * Report for a single mojo (plugin goal) execution within a module. + * + * @since 4.1.0 + * @see ModuleReport#mojos() + */ +@Experimental +@Immutable +public interface MojoReport { + + /** + * The plugin's group ID. + * + * @return the group ID, never {@code null} + */ + @Nonnull + String groupId(); + + /** + * The plugin's artifact ID. + * + * @return the artifact ID, never {@code null} + */ + @Nonnull + String artifactId(); + + /** + * The plugin version. + * + * @return the version string, never {@code null} + */ + @Nonnull + String version(); + + /** + * The goal that was executed (e.g. {@code "compile"}, {@code "test"}). + * + * @return the goal name, never {@code null} + */ + @Nonnull + String goal(); + + /** + * The execution ID (e.g. {@code "default-compile"}). + * + * @return the execution ID, or {@code null} if not set + */ + @Nullable + String executionId(); + + /** + * The lifecycle phase this mojo was bound to (e.g. {@code "compile"}, {@code "test"}). + * + * @return the phase name, or {@code null} if invoked directly + */ + @Nullable + String phase(); + + /** + * The outcome of this mojo execution. + * + * @return the status, never {@code null} + */ + @Nonnull + BuildStatus status(); + + /** + * When this mojo execution started (wall-clock time). + * + * @return the start instant, never {@code null} + */ + @Nonnull + Instant startTime(); + + /** + * How long this mojo execution took. + * + * @return the duration, never {@code null} + */ + @Nonnull + Duration duration(); + + /** + * Structured log events captured during this mojo's execution. + *

+ * The list may be truncated if the mojo produced excessive output. + *

+ * This captures all SLF4J output that occurred on the mojo's execution + * thread between the mojo's start and finish events, regardless of + * whether the mojo used the legacy {@code Mojo.getLog()}, the Maven 4 + * injected {@code Log}, or plain SLF4J. + * + * @return the captured log events, never {@code null}; may be empty + * @since 4.1.0 + */ + @Nonnull + List output(); + + /** + * The mojo identifier formatted as {@code "artifactId:version:goal"}. + *

+ * This matches the format used by {@link FailureReport#mojo()}, allowing + * direct lookup via {@link ModuleReport#findMojo(String)}. + * + * @return the mojo identifier string, never {@code null} + */ + @Nonnull + default String id() { + return artifactId() + ":" + version() + ":" + goal(); + } +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java index 8aee68ff901e..dd7d0572dd40 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java @@ -20,10 +20,15 @@ /** * Structured build report data model. *

- * This package provides structured representations of build execution - * data, including log events and (in future) full build reports. - * {@link org.apache.maven.api.build.report.LogEvent} is the foundational - * type representing a single structured log entry captured during the build. + * The {@link org.apache.maven.api.build.report.BuildReport} is the root of a structured + * representation of a Maven build execution. It is persisted to + * {@code target/build-report.json} at the end of every build and can be consumed + * by tools, CI systems, IDEs, and LLM agents without re-running the build or + * parsing console output. + *

+ * Build problems (warnings, errors) are represented as + * {@link org.apache.maven.api.services.BuilderProblem} instances and included + * in the report for downstream analysis. * * @since 4.1.0 */ diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java index e6372ccfd818..0cf448e32562 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java @@ -257,6 +257,7 @@ protected void populateRequest(MavenContext context, Lookup lookup, MavenExecuti } } + request.setNoTransferProgress(context.options().noTransferProgress().orElse(false)); request.setTransferListener(determineTransferListener( context, context.options().noTransferProgress().orElse(false))); request.setExecutionListener(determineExecutionListener(context)); diff --git a/impl/maven-core/src/main/java/org/apache/maven/execution/DefaultMavenExecutionRequest.java b/impl/maven-core/src/main/java/org/apache/maven/execution/DefaultMavenExecutionRequest.java index eedfe23016a6..11bfdc32270d 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/execution/DefaultMavenExecutionRequest.java +++ b/impl/maven-core/src/main/java/org/apache/maven/execution/DefaultMavenExecutionRequest.java @@ -64,6 +64,8 @@ public class DefaultMavenExecutionRequest implements MavenExecutionRequest { private boolean interactiveMode = true; + private boolean noTransferProgress = false; + private boolean cacheTransferError = false; private boolean cacheNotFound = false; @@ -180,6 +182,7 @@ public static MavenExecutionRequest copy(MavenExecutionRequest original) { copy.setLocalRepositoryPath(original.getLocalRepositoryPath()); copy.setOffline(original.isOffline()); copy.setInteractiveMode(original.isInteractiveMode()); + copy.setNoTransferProgress(original.isNoTransferProgress()); copy.setCacheNotFound(original.isCacheNotFound()); copy.setCacheTransferError(original.isCacheTransferError()); copy.setIgnoreMissingArtifactDescriptor(original.isIgnoreMissingArtifactDescriptor()); @@ -627,6 +630,17 @@ public MavenExecutionRequest setInteractiveMode(boolean interactive) { return this; } + @Override + public boolean isNoTransferProgress() { + return noTransferProgress; + } + + @Override + public MavenExecutionRequest setNoTransferProgress(boolean noTransferProgress) { + this.noTransferProgress = noTransferProgress; + return this; + } + @Override public MavenExecutionRequest setTransferListener(TransferListener transferListener) { this.transferListener = transferListener; diff --git a/impl/maven-core/src/main/java/org/apache/maven/execution/MavenExecutionRequest.java b/impl/maven-core/src/main/java/org/apache/maven/execution/MavenExecutionRequest.java index baf008017751..0ac6957e54b6 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/execution/MavenExecutionRequest.java +++ b/impl/maven-core/src/main/java/org/apache/maven/execution/MavenExecutionRequest.java @@ -279,6 +279,11 @@ public interface MavenExecutionRequest { boolean isInteractiveMode(); + // Transfer progress + MavenExecutionRequest setNoTransferProgress(boolean noTransferProgress); + + boolean isNoTransferProgress(); + // Offline MavenExecutionRequest setOffline(boolean offline); diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java new file mode 100644 index 000000000000..b958484f8689 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java @@ -0,0 +1,698 @@ +/* + * 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.internal.build; + +import javax.inject.Named; +import javax.inject.Singleton; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.ConcurrentHashMap; + +import org.apache.maven.api.BuildEnvironment; +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.BuildReport; +import org.apache.maven.api.build.report.BuildStatus; +import org.apache.maven.api.build.report.FailureReport; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.ModuleReport; +import org.apache.maven.api.build.report.MojoReport; +import org.apache.maven.eventspy.AbstractEventSpy; +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.BuildSummary; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.logging.ProjectBuildLogAppender; +import org.apache.maven.plugin.MojoExecution; +import org.apache.maven.project.MavenProject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Collects build lifecycle events and produces a structured {@link BuildReport} + * at the end of the session. + *

+ * Registered as an {@link org.apache.maven.eventspy.EventSpy} via {@code @Named}/{@code @Singleton}, + * following the same pattern as {@code DefaultPluginValidationManager}. + *

+ * Thread-safe: concurrent module builds (with {@code -T}) each write to their + * own entry in a {@link ConcurrentHashMap}. + *

+ * Log capture: registers a callback on {@link ProjectBuildLogAppender} to + * receive the already-formed {@link LogEvent} objects produced by the main + * logging pipeline. Uses thread-based tracking to associate events with + * the currently-executing mojo or module. + * + * @since 4.1.0 + */ +@Singleton +@Named +public final class BuildReportCollector extends AbstractEventSpy { + + private static final Logger LOGGER = LoggerFactory.getLogger(BuildReportCollector.class); + + static final String REPORT_DIR = "build-reports"; + static final String REPORT_LATEST = "build-report-latest.json"; + + private static final int MAX_STACKTRACE_LINES = 30; + + /** + * Maximum number of log events captured per scope (mojo, module, or build). + * Beyond this, events are silently dropped to prevent unbounded memory growth. + */ + static final int MAX_LOG_EVENTS_PER_SCOPE = 500; + + // ---- Mutable state, populated during the build ---- + + /** Per-project mojo tracking: project key -> list of in-flight/completed mojos. */ + private final Map> mojoTimings = new ConcurrentHashMap<>(); + + /** Per-project start instants for duration computation. */ + private final Map projectStartTimes = new ConcurrentHashMap<>(); + + /** Per-mojo start instants for duration computation. */ + private final Map mojoStartTimes = new ConcurrentHashMap<>(); + + /** Session-level state - set once on SessionStarted. */ + private volatile MavenSession session; + + /** Build environment - captured once at SessionStarted, immutable thereafter. */ + private volatile BuildEnvironment buildEnvironment; + + // ---- Log capture state ---- + + /** + * Maps thread ID -> mojo key for the currently-executing mojo on that thread. + * Lifecycle events and mojo execution run on the same thread, so this is safe + * for parallel builds with {@code -T}. + */ + private final Map currentMojoByThread = new ConcurrentHashMap<>(); + + /** Per-mojo log buffers: mojo key -> captured log events. */ + private final Map> mojoLogBuffers = new ConcurrentHashMap<>(); + + /** + * Maps thread ID -> project key for the currently-building project on that thread. + * Used to route log events that occur between mojo executions to the module-level buffer. + */ + private final Map currentProjectByThread = new ConcurrentHashMap<>(); + + /** Per-module log buffers: project key -> events captured outside any mojo. */ + private final Map> moduleLogBuffers = new ConcurrentHashMap<>(); + + /** Build-level log buffer: events captured outside any module lifecycle. */ + private final List buildLogBuffer = Collections.synchronizedList(new ArrayList<>()); + + @Override + public void onEvent(Object event) { + if (event instanceof ExecutionEvent executionEvent) { + switch (executionEvent.getType()) { + case SessionStarted: + onSessionStarted(executionEvent); + break; + case SessionEnded: + onSessionEnded(executionEvent); + break; + case ProjectStarted: + onProjectStarted(executionEvent); + break; + case ProjectSucceeded: + case ProjectFailed: + case ProjectSkipped: + onProjectFinished(executionEvent); + break; + case MojoStarted: + onMojoStarted(executionEvent); + break; + case MojoSucceeded: + case MojoFailed: + onMojoFinished(executionEvent); + break; + case MojoSkipped: + onMojoSkipped(executionEvent); + break; + default: + break; + } + } + } + + // ---- Event handlers ---- + + private void onSessionStarted(ExecutionEvent event) { + this.session = event.getSession(); + this.buildEnvironment = buildEnvironment(this.session); + installLogCapture(); + } + + private void onSessionEnded(ExecutionEvent event) { + removeLogCapture(); + + MavenSession endSession = event.getSession(); + if (endSession == null) { + return; + } + + try { + BuildReport report = buildReport(endSession); + writeReport(report, endSession); + } catch (Exception e) { + // Never let the report collector crash the build + LOGGER.debug("Failed to produce build report: {}", e.getMessage(), e); + } + } + + private void onProjectStarted(ExecutionEvent event) { + String key = projectKey(event.getProject()); + projectStartTimes.put(key, MonotonicClock.now()); + mojoTimings.putIfAbsent(key, Collections.synchronizedList(new ArrayList<>())); + moduleLogBuffers.put(key, Collections.synchronizedList(new ArrayList<>())); + currentProjectByThread.put(Thread.currentThread().getId(), key); + } + + private void onProjectFinished(ExecutionEvent event) { + // Unregister the project from this thread so subsequent log events + // fall through to the build-level buffer + currentProjectByThread.remove(Thread.currentThread().getId()); + } + + private void onMojoStarted(ExecutionEvent event) { + String mKey = mojoKey(event.getProject(), event.getMojoExecution()); + mojoStartTimes.put(mKey, MonotonicClock.now()); + + // Register the current mojo for this thread so the log event sink + // can associate events with this mojo execution + currentMojoByThread.put(Thread.currentThread().getId(), mKey); + mojoLogBuffers.put(mKey, Collections.synchronizedList(new ArrayList<>())); + } + + private void onMojoFinished(ExecutionEvent event) { + MojoExecution mojo = event.getMojoExecution(); + MavenProject project = event.getProject(); + String mKey = mojoKey(project, mojo); + String pKey = projectKey(project); + + // Unregister the mojo from this thread + currentMojoByThread.remove(Thread.currentThread().getId()); + + Instant now = MonotonicClock.now(); + Instant startInstant = mojoStartTimes.remove(mKey); + if (startInstant == null) { + startInstant = now; + } + Duration duration = Duration.between(startInstant, now); + + BuildStatus status = + event.getType() == ExecutionEvent.Type.MojoSucceeded ? BuildStatus.SUCCESS : BuildStatus.FAILURE; + + // Drain the log buffer for this mojo + List logBuffer = mojoLogBuffers.remove(mKey); + List output = logBuffer != null ? List.copyOf(logBuffer) : List.of(); + + MojoTiming timing = new MojoTiming( + mojo.getGroupId(), + mojo.getArtifactId(), + mojo.getVersion(), + mojo.getGoal(), + mojo.getExecutionId(), + mojo.getLifecyclePhase(), + status, + startInstant, + duration, + output); + + mojoTimings + .computeIfAbsent(pKey, k -> Collections.synchronizedList(new ArrayList<>())) + .add(timing); + } + + private void onMojoSkipped(ExecutionEvent event) { + MojoExecution mojo = event.getMojoExecution(); + MavenProject project = event.getProject(); + String pKey = projectKey(project); + + Instant now = MonotonicClock.now(); + + MojoTiming timing = new MojoTiming( + mojo.getGroupId(), + mojo.getArtifactId(), + mojo.getVersion(), + mojo.getGoal(), + mojo.getExecutionId(), + mojo.getLifecyclePhase(), + BuildStatus.SKIPPED, + now, + Duration.ZERO, + List.of()); + + mojoTimings + .computeIfAbsent(pKey, k -> Collections.synchronizedList(new ArrayList<>())) + .add(timing); + } + + // ---- Structured log capture ---- + + /** + * Registers a callback on {@link ProjectBuildLogAppender} to receive the + * already-formed {@link LogEvent} objects from the main logging pipeline. + * This eliminates the need for a separate capture path and ensures the + * build report captures the same enriched events (with sequence number, + * source metadata) as the console output. + */ + private void installLogCapture() { + ProjectBuildLogAppender.setReportCapture(this::captureLogEvent); + } + + private void removeLogCapture() { + ProjectBuildLogAppender.setReportCapture(null); + } + + /** + * Routes a pre-formed {@link LogEvent} to the appropriate buffer + * (mojo, module, or build-level) based on the current thread's + * lifecycle context. + */ + private void captureLogEvent(LogEvent event) { + long threadId = Thread.currentThread().getId(); + + // 1. Mojo-level: event belongs to the currently-executing mojo on this thread + String mKey = currentMojoByThread.get(threadId); + if (mKey != null) { + List buffer = mojoLogBuffers.get(mKey); + if (buffer != null && buffer.size() < MAX_LOG_EVENTS_PER_SCOPE) { + buffer.add(event); + } + return; + } + + // 2. Module-level: project is active but no mojo is running + String pKey = currentProjectByThread.get(threadId); + if (pKey != null) { + List buffer = moduleLogBuffers.get(pKey); + if (buffer != null && buffer.size() < MAX_LOG_EVENTS_PER_SCOPE) { + buffer.add(event); + } + return; + } + + // 3. Build-level: no project active (startup, reactor summary, post-build) + // synchronizedList requires external synchronization for the compound check-then-add + // to avoid TOCTOU: two threads can both observe size() < MAX and both add(). + synchronized (buildLogBuffer) { + if (buildLogBuffer.size() < MAX_LOG_EVENTS_PER_SCOPE) { + buildLogBuffer.add(event); + } + } + } + + // ---- Report assembly ---- + + BuildReport buildReport(MavenSession endSession) { + Instant now = MonotonicClock.now(); + Instant startInstant = endSession.getRequest().getStartInstant(); + if (startInstant == null) { + startInstant = now; + } + Duration totalDuration = Duration.between(startInstant, now); + + MavenExecutionResult result = endSession.getResult(); + boolean hasFailures = result != null && result.hasExceptions(); + BuildStatus overallStatus = hasFailures ? BuildStatus.FAILURE : BuildStatus.SUCCESS; + + // Collect module reports + List moduleReports = new ArrayList<>(); + for (MavenProject project : endSession.getProjects()) { + moduleReports.add(buildModuleReport(project, endSession)); + } + + // Collect failures + List failureReports = new ArrayList<>(); + if (result != null) { + for (MavenProject project : endSession.getProjects()) { + BuildSummary summary = result.getBuildSummary(project); + if (summary instanceof BuildFailure buildFailure) { + failureReports.add(buildFailureReport(project, buildFailure)); + } + } + } + + // Metadata + String mavenVersion = endSession.getSystemProperties().getProperty("maven.version", "unknown"); + String javaVersion = System.getProperty("java.version", "unknown"); + List goals = endSession.getGoals(); + MavenProject topProject = endSession.getTopLevelProject(); + String projectId = topProject != null + ? topProject.getGroupId() + ":" + topProject.getArtifactId() + ":" + topProject.getVersion() + : "unknown"; + boolean multiModule = endSession.getProjects().size() > 1; + int threads = endSession.getRequest().getDegreeOfConcurrency(); + + // Build-level log events (outside any module lifecycle) + List buildOutput; + synchronized (buildLogBuffer) { + buildOutput = List.copyOf(buildLogBuffer); + } + + return new DefaultBuildReport( + buildEnvironment != null ? buildEnvironment : buildEnvironment(endSession), + overallStatus, + totalDuration, + startInstant, + mavenVersion, + javaVersion, + goals, + projectId, + multiModule, + threads, + moduleReports, + failureReports, + List.of(), + buildOutput); + } + + /** + * Captures the build environment from the session at the point of invocation. + * Called once at {@code SessionStarted} so the snapshot reflects the original + * invocation context, not any session mutations that may happen during the build. + */ + public static BuildEnvironment buildEnvironment(MavenSession session) { + var request = session.getRequest(); + + // Goals + List goals = request.getGoals() != null ? List.copyOf(request.getGoals()) : List.of(); + + // User properties — filter sensitive keys + Map userProperties = new LinkedHashMap<>(); + if (request.getUserProperties() != null) { + request.getUserProperties().forEach((k, v) -> { + String key = String.valueOf(k); + String value = isSensitiveKey(key) ? "***" : String.valueOf(v); + userProperties.put(key, value); + }); + } + + // Curated system info + Map systemInfo = new TreeMap<>(); + addSystemProp(systemInfo, "os.name"); + addSystemProp(systemInfo, "os.arch"); + addSystemProp(systemInfo, "os.version"); + addSystemProp(systemInfo, "java.vendor"); + addSystemProp(systemInfo, "java.vm.name"); + addSystemProp(systemInfo, "java.vm.version"); + addSystemProp(systemInfo, "maven.home"); + addSystemProp(systemInfo, "user.home"); + addSystemProp(systemInfo, "user.name"); + systemInfo.put( + "available.processors", String.valueOf(Runtime.getRuntime().availableProcessors())); + + // Local repository path + String localRepo = request.getLocalRepositoryPath() != null + ? request.getLocalRepositoryPath().getAbsolutePath() + : ""; + + // Explicitly activated profiles (-P) + List activeProfiles = + request.getActiveProfiles() != null ? List.copyOf(request.getActiveProfiles()) : List.of(); + + // Selected projects (-pl) + List selectedProjects = + request.getSelectedProjects() != null ? List.copyOf(request.getSelectedProjects()) : List.of(); + + // Resume from (-rf) + String resumeFrom = request.getResumeFrom(); + + // Failure behavior + String failureBehavior = + request.getReactorFailureBehavior() != null ? request.getReactorFailureBehavior() : "FAIL_FAST"; + + return new DefaultBuildEnvironment( + goals, + userProperties, + systemInfo, + localRepo, + activeProfiles, + selectedProjects, + resumeFrom, + failureBehavior, + request.isOffline(), + request.isUpdateSnapshots(), + request.isNoTransferProgress(), + !request.isInteractiveMode(), + request.getDegreeOfConcurrency()); + } + + private static final Set SENSITIVE_KEY_FRAGMENTS = + Set.of("password", "passwd", "secret", "token", "apikey", "api_key", "credential", "passphrase"); + + private static boolean isSensitiveKey(String key) { + String lower = key.toLowerCase(java.util.Locale.ROOT); + for (String fragment : SENSITIVE_KEY_FRAGMENTS) { + if (lower.contains(fragment)) { + return true; + } + } + return false; + } + + private static void addSystemProp(Map map, String key) { + String value = System.getProperty(key); + if (value != null) { + map.put(key, value); + } + } + + private ModuleReport buildModuleReport(MavenProject project, MavenSession endSession) { + String key = projectKey(project); + + // Duration from BuildSummary (preferred) or fallback to our own tracking + MavenExecutionResult result = endSession.getResult(); + Duration duration = Duration.ZERO; + BuildStatus status = BuildStatus.SKIPPED; + Instant moduleStartTime = + projectStartTimes.getOrDefault(key, endSession.getRequest().getStartInstant()); + + if (result != null) { + BuildSummary summary = result.getBuildSummary(project); + if (summary instanceof BuildSuccess) { + status = BuildStatus.SUCCESS; + duration = summary.getExecTime(); + } else if (summary instanceof BuildFailure) { + status = BuildStatus.FAILURE; + duration = summary.getExecTime(); + } else if (summary != null) { + // Unknown summary type - use its timing + duration = summary.getExecTime(); + } else { + // No summary means skipped + Instant start = projectStartTimes.get(key); + if (start != null) { + duration = Duration.between(start, MonotonicClock.now()); + } + } + } + + // Mojo reports + List timings = mojoTimings.getOrDefault(key, Collections.emptyList()); + List mojoReports; + synchronized (timings) { + mojoReports = timings.stream() + .map(t -> (MojoReport) new DefaultMojoReport( + t.groupId, + t.artifactId, + t.version, + t.goal, + t.executionId, + t.phase, + t.status, + t.startTime, + t.duration, + t.output)) + .toList(); + } + + // Module-level log events (between mojos) + List moduleLogBuffer = moduleLogBuffers.getOrDefault(key, Collections.emptyList()); + List moduleOutput; + synchronized (moduleLogBuffer) { + moduleOutput = List.copyOf(moduleLogBuffer); + } + + return new DefaultModuleReport( + project.getGroupId(), + project.getArtifactId(), + project.getVersion(), + status, + moduleStartTime, + duration, + mojoReports, + moduleOutput); + } + + private FailureReport buildFailureReport(MavenProject project, BuildFailure buildFailure) { + String module = project.getGroupId() + ":" + project.getArtifactId() + ":" + project.getVersion(); + + // Try to find which mojo failed + String mojoId = null; + List timings = mojoTimings.getOrDefault(projectKey(project), Collections.emptyList()); + synchronized (timings) { + for (MojoTiming t : timings) { + if (t.status == BuildStatus.FAILURE) { + mojoId = t.artifactId + ":" + t.version + ":" + t.goal; + break; + } + } + } + + Throwable cause = buildFailure.getCause(); + String message = cause != null ? cause.getMessage() : "Unknown error"; + String stackTrace = cause != null ? truncateStackTrace(cause) : null; + + Instant failureTimestamp = MonotonicClock.now(); + String exceptionType = cause != null ? cause.getClass().getSimpleName() : null; + + return new DefaultFailureReport( + module, + mojoId, + failureTimestamp, + exceptionType, + message != null ? message : "Unknown error", + stackTrace); + } + + // ---- JSON persistence ---- + + void writeReport(BuildReport report, MavenSession endSession) { + Path topDirectory = endSession.getTopDirectory(); + if (topDirectory == null) { + LOGGER.debug("No top directory available, skipping build report"); + return; + } + + Path reportsDir = topDirectory.resolve("target").resolve(REPORT_DIR); + + try { + Files.createDirectories(reportsDir); + String json = BuildReportJsonWriter.toJson(report); + + // Timestamped file: build-report-20250729T143000Z.json + String timestamp = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'") + .withZone(ZoneOffset.UTC) + .format(report.startTime()); + Path timestampedFile = reportsDir.resolve("build-report-" + timestamp + ".json"); + + // Write to a temp file, then atomic-move into place so a crash + // never leaves a half-written report on disk. + Path tmpFile = Files.createTempFile(reportsDir, ".build-report-", ".tmp"); + try { + Files.writeString(tmpFile, json); + atomicMove(tmpFile, timestampedFile); + } catch (IOException e) { + Files.deleteIfExists(tmpFile); + throw e; + } + + // Latest symlink (or copy on filesystems that don't support symlinks) + Path latestFile = reportsDir.resolve(REPORT_LATEST); + try { + // Atomic symlink swap: create new link, then rename over the old one + Path tmpLink = Files.createTempFile(reportsDir, ".latest-", ".tmp"); + Files.delete(tmpLink); // createTempFile creates a regular file + Files.createSymbolicLink(tmpLink, timestampedFile.getFileName()); + atomicMove(tmpLink, latestFile); + } catch (UnsupportedOperationException | IOException symEx) { + // Windows or restricted filesystem - fall back to a plain copy + Files.writeString(latestFile, json); + } + + LOGGER.debug("Build report written to {}", timestampedFile); + } catch (IOException e) { + LOGGER.warn("Failed to write build report to {}: {}", reportsDir, e.getMessage()); + } + } + + /** + * Attempts an atomic move; falls back to a plain move if the filesystem + * does not support {@code ATOMIC_MOVE}. + */ + private static void atomicMove(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + // ---- Utility methods ---- + + private static String projectKey(MavenProject project) { + return project.getGroupId() + ":" + project.getArtifactId(); + } + + private static String mojoKey(MavenProject project, MojoExecution mojo) { + return projectKey(project) + "#" + mojo.getGoal() + "@" + mojo.getExecutionId(); + } + + static String truncateStackTrace(Throwable t) { + StringWriter sw = new StringWriter(); + t.printStackTrace(new PrintWriter(sw)); + String full = sw.toString(); + String[] lines = full.split("\n"); + if (lines.length <= MAX_STACKTRACE_LINES) { + return full; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < MAX_STACKTRACE_LINES; i++) { + sb.append(lines[i]).append('\n'); + } + sb.append("... ").append(lines.length - MAX_STACKTRACE_LINES).append(" more lines truncated\n"); + return sb.toString(); + } + + // ---- Internal records ---- + + record MojoTiming( + String groupId, + String artifactId, + String version, + String goal, + String executionId, + String phase, + BuildStatus status, + Instant startTime, + Duration duration, + List output) {} +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportJsonWriter.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportJsonWriter.java new file mode 100644 index 000000000000..b94d8cb20280 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportJsonWriter.java @@ -0,0 +1,435 @@ +/* + * 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.internal.build; + +import org.apache.maven.api.BuildEnvironment; +import org.apache.maven.api.build.report.BuildReport; +import org.apache.maven.api.build.report.FailureReport; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.ModuleReport; +import org.apache.maven.api.build.report.MojoReport; +import org.apache.maven.api.services.BuilderProblem; + +/** + * Serializes a {@link BuildReport} to JSON without any external library dependency. + *

+ * The output is human-readable (indented with 2 spaces) and designed to be + * stable across Maven versions — field order is fixed, and new fields are + * appended at the end of each object. + */ +final class BuildReportJsonWriter { + + private BuildReportJsonWriter() {} + + /** + * Serialize the given report to a pretty-printed JSON string. + */ + static String toJson(BuildReport report) { + StringBuilder sb = new StringBuilder(4096); + writeReport(sb, report, 0); + sb.append('\n'); + return sb.toString(); + } + + private static void writeReport(StringBuilder sb, BuildReport report, int indent) { + sb.append("{\n"); + writeField(sb, indent + 1, "formatVersion", report.formatVersion()); + writeField(sb, indent + 1, "status", report.status().name()); + writeField(sb, indent + 1, "duration", report.duration().toString()); + writeField(sb, indent + 1, "startTime", report.startTime().toString()); + writeField(sb, indent + 1, "mavenVersion", report.mavenVersion()); + writeField(sb, indent + 1, "javaVersion", report.javaVersion()); + writeStringArray(sb, indent + 1, "goals", report.goals()); + writeField(sb, indent + 1, "project", report.project()); + writeField(sb, indent + 1, "multiModule", report.multiModule()); + writeField(sb, indent + 1, "threads", report.threads()); + + // environment object + writeIndent(sb, indent + 1); + sb.append("\"environment\": "); + writeEnvironment(sb, report.environment(), indent + 1); + sb.append(",\n"); + + // modules array + writeIndent(sb, indent + 1); + sb.append("\"modules\": "); + if (report.modules().isEmpty()) { + sb.append("[]"); + } else { + sb.append("[\n"); + for (int i = 0; i < report.modules().size(); i++) { + writeIndent(sb, indent + 2); + writeModule(sb, report.modules().get(i), indent + 2); + if (i < report.modules().size() - 1) { + sb.append(','); + } + sb.append('\n'); + } + writeIndent(sb, indent + 1); + sb.append(']'); + } + sb.append(",\n"); + + // failures array + writeIndent(sb, indent + 1); + sb.append("\"failures\": "); + if (report.failures().isEmpty()) { + sb.append("[]"); + } else { + sb.append("[\n"); + for (int i = 0; i < report.failures().size(); i++) { + writeIndent(sb, indent + 2); + writeFailure(sb, report.failures().get(i), indent + 2); + if (i < report.failures().size() - 1) { + sb.append(','); + } + sb.append('\n'); + } + writeIndent(sb, indent + 1); + sb.append(']'); + } + sb.append(",\n"); + + // problems array + writeIndent(sb, indent + 1); + sb.append("\"problems\": "); + if (report.problems().isEmpty()) { + sb.append("[]"); + } else { + sb.append("[\n"); + for (int i = 0; i < report.problems().size(); i++) { + writeIndent(sb, indent + 2); + writeProblem(sb, report.problems().get(i), indent + 2); + if (i < report.problems().size() - 1) { + sb.append(','); + } + sb.append('\n'); + } + writeIndent(sb, indent + 1); + sb.append(']'); + } + sb.append(",\n"); + + // output array — build-level log lines (outside any module) + writeOutputArray(sb, indent + 1, report.output()); + sb.append('\n'); + + writeIndent(sb, indent); + sb.append('}'); + } + + private static void writeEnvironment(StringBuilder sb, BuildEnvironment env, int indent) { + sb.append("{\n"); + writeStringArray(sb, indent + 1, "goals", env.goals()); + writeStringMap(sb, indent + 1, "userProperties", env.userProperties()); + writeStringMap(sb, indent + 1, "systemInfo", env.systemInfo()); + writeField(sb, indent + 1, "localRepository", env.localRepository()); + writeStringArray(sb, indent + 1, "activeProfiles", env.activeProfiles()); + writeStringArray(sb, indent + 1, "selectedProjects", env.selectedProjects()); + writeNullableField(sb, indent + 1, "resumeFrom", env.resumeFrom()); + writeField(sb, indent + 1, "reactorFailureBehavior", env.reactorFailureBehavior()); + writeField(sb, indent + 1, "offline", env.offline()); + writeField(sb, indent + 1, "updateSnapshots", env.updateSnapshots()); + writeField(sb, indent + 1, "noTransferProgress", env.noTransferProgress()); + writeField(sb, indent + 1, "batchMode", env.batchMode()); + writeField(sb, indent + 1, "threads", env.threads()); + removeTrailingComma(sb); + writeIndent(sb, indent); + sb.append('}'); + } + + private static void writeStringMap(StringBuilder sb, int indent, String key, java.util.Map map) { + writeIndent(sb, indent); + sb.append('"').append(key).append("\": "); + if (map.isEmpty()) { + sb.append("{}"); + } else { + sb.append("{\n"); + var entries = new java.util.ArrayList<>(map.entrySet()); + for (int i = 0; i < entries.size(); i++) { + var entry = entries.get(i); + writeIndent(sb, indent + 1); + writeJsonString(sb, entry.getKey()); + sb.append(": "); + writeJsonString(sb, entry.getValue()); + if (i < entries.size() - 1) { + sb.append(','); + } + sb.append('\n'); + } + writeIndent(sb, indent); + sb.append('}'); + } + sb.append(",\n"); + } + + private static void writeProblem(StringBuilder sb, BuilderProblem problem, int indent) { + sb.append("{\n"); + writeField(sb, indent + 1, "severity", problem.getSeverity().name()); + writeField(sb, indent + 1, "message", problem.getMessage()); + String source = problem.getSource(); + if (source != null && !source.isEmpty()) { + writeField(sb, indent + 1, "source", source); + } + if (problem.getLineNumber() > 0) { + writeField(sb, indent + 1, "line", problem.getLineNumber()); + } + if (problem.getColumnNumber() > 0) { + writeField(sb, indent + 1, "column", problem.getColumnNumber()); + } + // Remove the trailing comma from the last written field + removeTrailingComma(sb); + writeIndent(sb, indent); + sb.append('}'); + } + + private static void writeModule(StringBuilder sb, ModuleReport module, int indent) { + sb.append("{\n"); + writeField(sb, indent + 1, "groupId", module.groupId()); + writeField(sb, indent + 1, "artifactId", module.artifactId()); + writeField(sb, indent + 1, "version", module.version()); + writeField(sb, indent + 1, "status", module.status().name()); + writeField(sb, indent + 1, "startTime", module.startTime().toString()); + writeField(sb, indent + 1, "duration", module.duration().toString()); + + // mojos array + writeIndent(sb, indent + 1); + sb.append("\"mojos\": "); + if (module.mojos().isEmpty()) { + sb.append("[]"); + } else { + sb.append("[\n"); + for (int i = 0; i < module.mojos().size(); i++) { + writeIndent(sb, indent + 2); + writeMojo(sb, module.mojos().get(i), indent + 2); + if (i < module.mojos().size() - 1) { + sb.append(','); + } + sb.append('\n'); + } + writeIndent(sb, indent + 1); + sb.append(']'); + } + sb.append(",\n"); + + // output array — module-level log lines (between mojos) + writeOutputArray(sb, indent + 1, module.output()); + sb.append('\n'); + + writeIndent(sb, indent); + sb.append('}'); + } + + private static void writeMojo(StringBuilder sb, MojoReport mojo, int indent) { + sb.append("{\n"); + writeField(sb, indent + 1, "groupId", mojo.groupId()); + writeField(sb, indent + 1, "artifactId", mojo.artifactId()); + writeField(sb, indent + 1, "version", mojo.version()); + writeField(sb, indent + 1, "goal", mojo.goal()); + writeNullableField(sb, indent + 1, "executionId", mojo.executionId()); + writeNullableField(sb, indent + 1, "phase", mojo.phase()); + writeField(sb, indent + 1, "status", mojo.status().name()); + writeField(sb, indent + 1, "startTime", mojo.startTime().toString()); + writeField(sb, indent + 1, "duration", mojo.duration().toString()); + + // output array — captured log lines + writeOutputArray(sb, indent + 1, mojo.output()); + sb.append('\n'); + + writeIndent(sb, indent); + sb.append('}'); + } + + private static void writeFailure(StringBuilder sb, FailureReport failure, int indent) { + sb.append("{\n"); + writeField(sb, indent + 1, "module", failure.module()); + writeNullableField(sb, indent + 1, "mojo", failure.mojo()); + writeField(sb, indent + 1, "timestamp", failure.timestamp().toString()); + writeNullableField(sb, indent + 1, "exceptionType", failure.exceptionType()); + if (failure.stackTrace() != null) { + writeField(sb, indent + 1, "message", failure.message()); + writeLastField(sb, indent + 1, "stackTrace", failure.stackTrace()); + } else { + writeLastField(sb, indent + 1, "message", failure.message()); + } + writeIndent(sb, indent); + sb.append('}'); + } + + /** + * Writes an {@code "output": [...]} array of structured log events + * (used by report, module, and mojo). + * This is always the last field in its object, so no trailing comma. + */ + private static void writeOutputArray(StringBuilder sb, int indent, java.util.List events) { + writeIndent(sb, indent); + sb.append("\"output\": "); + if (events.isEmpty()) { + sb.append("[]"); + } else { + sb.append("[\n"); + for (int i = 0; i < events.size(); i++) { + writeIndent(sb, indent + 1); + writeLogEvent(sb, events.get(i), indent + 1); + if (i < events.size() - 1) { + sb.append(','); + } + sb.append('\n'); + } + writeIndent(sb, indent); + sb.append(']'); + } + } + + private static void writeLogEvent(StringBuilder sb, LogEvent event, int indent) { + sb.append("{\n"); + writeField(sb, indent + 1, "timestamp", event.timestamp().toString()); + writeField(sb, indent + 1, "level", event.level().name()); + if (event.loggerName() != null) { + writeField(sb, indent + 1, "loggerName", event.loggerName()); + } + writeField(sb, indent + 1, "message", event.message()); + if (event.stackTrace() != null) { + writeField(sb, indent + 1, "stackTrace", event.stackTrace()); + } + // Source metadata — present for Log API and JUL events + if (event.sourceClassName() != null) { + writeField(sb, indent + 1, "sourceClassName", event.sourceClassName()); + } + if (event.sourceMethodName() != null) { + writeField(sb, indent + 1, "sourceMethodName", event.sourceMethodName()); + } + if (event.threadId() >= 0) { + writeField(sb, indent + 1, "threadId", event.threadId()); + } + if (event.sequenceNumber() >= 0) { + writeField(sb, indent + 1, "sequenceNumber", event.sequenceNumber()); + } + removeTrailingComma(sb); + writeIndent(sb, indent); + sb.append('}'); + } + + // ---- Low-level JSON writing helpers ---- + + private static void writeField(StringBuilder sb, int indent, String key, String value) { + writeIndent(sb, indent); + sb.append('"').append(key).append("\": "); + writeJsonString(sb, value); + sb.append(",\n"); + } + + private static void writeField(StringBuilder sb, int indent, String key, int value) { + writeIndent(sb, indent); + sb.append('"').append(key).append("\": ").append(value).append(",\n"); + } + + private static void writeField(StringBuilder sb, int indent, String key, long value) { + writeIndent(sb, indent); + sb.append('"').append(key).append("\": ").append(value).append(",\n"); + } + + private static void writeField(StringBuilder sb, int indent, String key, boolean value) { + writeIndent(sb, indent); + sb.append('"').append(key).append("\": ").append(value).append(",\n"); + } + + /** + * Removes the trailing comma from the last field in a JSON object. + * Turns {@code "field": value,\n} into {@code "field": value\n}. + */ + private static void removeTrailingComma(StringBuilder sb) { + int len = sb.length(); + if (len >= 2 && sb.charAt(len - 2) == ',' && sb.charAt(len - 1) == '\n') { + sb.deleteCharAt(len - 2); + } + } + + private static void writeLastField(StringBuilder sb, int indent, String key, String value) { + writeIndent(sb, indent); + sb.append('"').append(key).append("\": "); + writeJsonString(sb, value); + sb.append('\n'); + } + + private static void writeNullableField(StringBuilder sb, int indent, String key, String value) { + writeIndent(sb, indent); + sb.append('"').append(key).append("\": "); + if (value != null) { + writeJsonString(sb, value); + } else { + sb.append("null"); + } + sb.append(",\n"); + } + + private static void writeStringArray(StringBuilder sb, int indent, String key, java.util.List values) { + writeIndent(sb, indent); + sb.append('"').append(key).append("\": ["); + for (int i = 0; i < values.size(); i++) { + writeJsonString(sb, values.get(i)); + if (i < values.size() - 1) { + sb.append(", "); + } + } + sb.append("],\n"); + } + + private static void writeJsonString(StringBuilder sb, String value) { + sb.append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + case '\b': + sb.append("\\b"); + break; + case '\f': + sb.append("\\f"); + break; + default: + if (c < 0x20) { + sb.append("\\u"); + sb.append(String.format("%04x", (int) c)); + } else { + sb.append(c); + } + } + } + sb.append('"'); + } + + private static void writeIndent(StringBuilder sb, int level) { + sb.append(" ".repeat(level)); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildEnvironment.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildEnvironment.java new file mode 100644 index 000000000000..ac956a66f803 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildEnvironment.java @@ -0,0 +1,69 @@ +/* + * 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.internal.build; + +import java.util.List; +import java.util.Map; + +import org.apache.maven.api.BuildEnvironment; + +/** + * Internal immutable implementation of {@link BuildEnvironment}. + */ +record DefaultBuildEnvironment( + List goals, + Map userProperties, + Map systemInfo, + String localRepository, + List activeProfiles, + List selectedProjects, + String resumeFrom, + String reactorFailureBehavior, + boolean offline, + boolean updateSnapshots, + boolean noTransferProgress, + boolean batchMode, + int threads) + implements BuildEnvironment { + + @Override + public List goals() { + return List.copyOf(goals); + } + + @Override + public Map userProperties() { + return Map.copyOf(userProperties); + } + + @Override + public Map systemInfo() { + return Map.copyOf(systemInfo); + } + + @Override + public List activeProfiles() { + return List.copyOf(activeProfiles); + } + + @Override + public List selectedProjects() { + return List.copyOf(selectedProjects); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildReport.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildReport.java new file mode 100644 index 000000000000..0eed279fb68e --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildReport.java @@ -0,0 +1,84 @@ +/* + * 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.internal.build; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import org.apache.maven.api.BuildEnvironment; +import org.apache.maven.api.build.report.BuildReport; +import org.apache.maven.api.build.report.BuildStatus; +import org.apache.maven.api.build.report.FailureReport; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.ModuleReport; +import org.apache.maven.api.services.BuilderProblem; + +/** + * Internal immutable implementation of {@link BuildReport}. + */ +record DefaultBuildReport( + BuildEnvironment environment, + BuildStatus status, + Duration duration, + Instant startTime, + String mavenVersion, + String javaVersion, + List goals, + String project, + boolean multiModule, + int threads, + List modules, + List failures, + List problems, + List output) + implements BuildReport { + + private static final int FORMAT_VERSION = 1; + + @Override + public int formatVersion() { + return FORMAT_VERSION; + } + + @Override + public List modules() { + return List.copyOf(modules); + } + + @Override + public List failures() { + return List.copyOf(failures); + } + + @Override + public List problems() { + return List.copyOf(problems); + } + + @Override + public List goals() { + return List.copyOf(goals); + } + + @Override + public List output() { + return List.copyOf(output); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultFailureReport.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultFailureReport.java new file mode 100644 index 000000000000..b0bc075d34f3 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultFailureReport.java @@ -0,0 +1,30 @@ +/* + * 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.internal.build; + +import java.time.Instant; + +import org.apache.maven.api.build.report.FailureReport; + +/** + * Internal immutable implementation of {@link FailureReport}. + */ +record DefaultFailureReport( + String module, String mojo, Instant timestamp, String exceptionType, String message, String stackTrace) + implements FailureReport {} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultModuleReport.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultModuleReport.java new file mode 100644 index 000000000000..64c79636ebd1 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultModuleReport.java @@ -0,0 +1,53 @@ +/* + * 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.internal.build; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import org.apache.maven.api.build.report.BuildStatus; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.ModuleReport; +import org.apache.maven.api.build.report.MojoReport; + +/** + * Internal immutable implementation of {@link ModuleReport}. + */ +record DefaultModuleReport( + String groupId, + String artifactId, + String version, + BuildStatus status, + Instant startTime, + Duration duration, + List mojos, + List output) + implements ModuleReport { + + @Override + public List mojos() { + return List.copyOf(mojos); + } + + @Override + public List output() { + return List.copyOf(output); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultMojoReport.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultMojoReport.java new file mode 100644 index 000000000000..5270d9c11fe3 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultMojoReport.java @@ -0,0 +1,49 @@ +/* + * 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.internal.build; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import org.apache.maven.api.build.report.BuildStatus; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.MojoReport; + +/** + * Internal immutable implementation of {@link MojoReport}. + */ +record DefaultMojoReport( + String groupId, + String artifactId, + String version, + String goal, + String executionId, + String phase, + BuildStatus status, + Instant startTime, + Duration duration, + List output) + implements MojoReport { + + @Override + public List output() { + return List.copyOf(output); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultSession.java b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultSession.java index fbbd64fa2cac..b7e49973dc9b 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultSession.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultSession.java @@ -28,6 +28,7 @@ import java.util.concurrent.ConcurrentHashMap; import org.apache.maven.RepositoryUtils; +import org.apache.maven.api.BuildEnvironment; import org.apache.maven.api.Project; import org.apache.maven.api.RemoteRepository; import org.apache.maven.api.Session; @@ -46,6 +47,7 @@ import org.apache.maven.impl.AbstractSession; import org.apache.maven.impl.DefaultRemoteRepository; import org.apache.maven.impl.PropertiesAsMap; +import org.apache.maven.internal.build.BuildReportCollector; import org.apache.maven.plugin.MojoExecution; import org.apache.maven.plugin.descriptor.MojoDescriptor; import org.apache.maven.plugin.descriptor.PluginDescriptor; @@ -63,6 +65,7 @@ public class DefaultSession extends AbstractSession implements InternalMavenSess private final MavenRepositorySystem mavenRepositorySystem; private final RuntimeInformation runtimeInformation; private final Map allProjects = new ConcurrentHashMap<>(); + private volatile BuildEnvironment buildEnvironment; @SuppressWarnings("checkstyle:ParameterNumber") public DefaultSession( @@ -172,6 +175,19 @@ public int getDegreeOfConcurrency() { return getMavenSession().getRequest().getDegreeOfConcurrency(); } + @Nonnull + @Override + public BuildEnvironment buildEnvironment() { + if (buildEnvironment == null) { + synchronized (this) { + if (buildEnvironment == null) { + buildEnvironment = BuildReportCollector.buildEnvironment(getMavenSession()); + } + } + } + return buildEnvironment; + } + @Nonnull @Override public Instant getStartTime() { diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportCollectorTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportCollectorTest.java new file mode 100644 index 000000000000..ee38aed9307c --- /dev/null +++ b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportCollectorTest.java @@ -0,0 +1,392 @@ +/* + * 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.internal.build; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.Properties; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.BuildReport; +import org.apache.maven.api.build.report.BuildStatus; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.DefaultMavenExecutionRequest; +import org.apache.maven.execution.DefaultMavenExecutionResult; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.plugin.MojoExecution; +import org.apache.maven.plugin.descriptor.MojoDescriptor; +import org.apache.maven.plugin.descriptor.PluginDescriptor; +import org.apache.maven.project.MavenProject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BuildReportCollectorTest { + + @TempDir + Path tempDir; + + private BuildReportCollector collector; + + @BeforeEach + void setUp() { + collector = new BuildReportCollector(); + } + + @Test + void testBuildReportAssembly() { + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + MavenExecutionResult result = session.getResult(); + + // Simulate: session started -> project started -> mojo started -> mojo succeeded -> project succeeded -> + // session + // ended + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null)); + + MojoExecution mojo = createMojoExecution( + "org.apache.maven.plugins", "maven-compiler-plugin", "3.15.0", "compile", "default-compile", "compile"); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, project, mojo)); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoSucceeded, session, project, mojo)); + + // Record build success in the result + result.addBuildSummary(new BuildSuccess(project, 5000)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null)); + + // Build the report + BuildReport report = collector.buildReport(session); + + assertNotNull(report); + assertEquals(BuildStatus.SUCCESS, report.status()); + assertEquals(1, report.formatVersion()); + assertEquals("1.0.0", report.mavenVersion()); + assertFalse(report.multiModule()); + assertEquals(1, report.threads()); + assertEquals(1, report.modules().size()); + assertEquals("org.example", report.modules().get(0).groupId()); + assertEquals("my-app", report.modules().get(0).artifactId()); + assertEquals(BuildStatus.SUCCESS, report.modules().get(0).status()); + assertEquals(1, report.modules().get(0).mojos().size()); + assertEquals("compile", report.modules().get(0).mojos().get(0).goal()); + assertEquals(BuildStatus.SUCCESS, report.modules().get(0).mojos().get(0).status()); + assertTrue(report.failures().isEmpty()); + assertTrue(report.problems().isEmpty()); + + // environment assertions + assertNotNull(report.environment()); + assertEquals(List.of("clean", "install"), report.environment().goals()); + assertFalse(report.environment().offline()); + assertFalse(report.environment().updateSnapshots()); + assertNotNull(report.environment().systemInfo()); + assertFalse(report.environment().systemInfo().isEmpty()); + assertTrue(report.environment().systemInfo().containsKey("available.processors")); + } + + @Test + void testBuildReportWithFailure() { + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + MavenExecutionResult result = session.getResult(); + + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null)); + + MojoExecution mojo = createMojoExecution( + "org.apache.maven.plugins", "maven-compiler-plugin", "3.15.0", "compile", "default-compile", "compile"); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, project, mojo)); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoFailed, session, project, mojo)); + + RuntimeException failure = new RuntimeException("Compilation failure: 3 errors"); + result.addBuildSummary(new org.apache.maven.execution.BuildFailure(project, 3000, failure)); + result.addException(failure); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectFailed, session, project, null)); + + BuildReport report = collector.buildReport(session); + + assertEquals(BuildStatus.FAILURE, report.status()); + assertEquals(1, report.failures().size()); + assertEquals("org.example:my-app:1.0.0", report.failures().get(0).module()); + assertTrue(report.failures().get(0).message().contains("Compilation failure")); + assertNotNull(report.failures().get(0).timestamp(), "failure should have a timestamp"); + assertEquals("RuntimeException", report.failures().get(0).exceptionType(), "exceptionType from cause"); + + // Navigate from failure -> module -> mojo using lookup methods + var failureReport = report.failures().get(0); + var moduleOpt = report.findModule(failureReport); + assertTrue(moduleOpt.isPresent(), "findModule(FailureReport) should find the module"); + assertEquals("my-app", moduleOpt.get().artifactId()); + assertEquals("org.example:my-app:1.0.0", moduleOpt.get().id()); + + assertNotNull(failureReport.mojo(), "failure should reference a mojo"); + var mojoOpt = moduleOpt.get().findMojo(failureReport.mojo()); + assertTrue(mojoOpt.isPresent(), "findMojo should find the failed mojo"); + assertEquals("compile", mojoOpt.get().goal()); + assertEquals(BuildStatus.FAILURE, mojoOpt.get().status()); + assertEquals("maven-compiler-plugin:3.15.0:compile", mojoOpt.get().id()); + } + + @Test + void testMojoSkippedIsRecorded() { + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + MavenExecutionResult result = session.getResult(); + + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null)); + + // A skipped mojo has no MojoStarted event — MojoSkipped fires directly + MojoExecution mojo = createMojoExecution( + "org.apache.maven.plugins", "maven-surefire-plugin", "3.0.0", "test", "default-test", "test"); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoSkipped, session, project, mojo)); + + result.addBuildSummary(new BuildSuccess(project, 1000)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null)); + + BuildReport report = collector.buildReport(session); + + assertEquals(1, report.modules().size()); + var module = report.modules().get(0); + assertEquals(1, module.mojos().size(), "skipped mojo should appear in the report"); + var mojoReport = module.mojos().get(0); + assertEquals("test", mojoReport.goal()); + assertEquals(BuildStatus.SKIPPED, mojoReport.status()); + assertEquals(Duration.ZERO, mojoReport.duration()); + assertTrue(mojoReport.output().isEmpty()); + } + + @Test + void testWriteReportToFile() throws IOException { + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + session.getResult().addBuildSummary(new BuildSuccess(project, 1000)); + + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null)); + + BuildReport report = collector.buildReport(session); + collector.writeReport(report, session); + + Path reportsDir = tempDir.resolve("target").resolve(BuildReportCollector.REPORT_DIR); + Path latestFile = reportsDir.resolve(BuildReportCollector.REPORT_LATEST); + assertTrue(Files.exists(latestFile), "build-report-latest.json should exist"); + + String content = Files.readString(latestFile); + assertTrue(content.contains("\"formatVersion\": 1")); + assertTrue(content.contains("\"status\": \"SUCCESS\"")); + assertTrue(content.contains("\"artifactId\": \"my-app\"")); + } + + @Test + void testMultiModuleBuild() { + MavenProject parent = createProject("org.example", "parent", "1.0.0"); + MavenProject child1 = createProject("org.example", "child-api", "1.0.0"); + MavenProject child2 = createProject("org.example", "child-impl", "1.0.0"); + + MavenSession session = createSession(parent, child1, child2); + MavenExecutionResult result = session.getResult(); + + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, parent, null)); + + // Build each module + for (MavenProject p : List.of(parent, child1, child2)) { + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, p, null)); + result.addBuildSummary(new BuildSuccess(p, 1000)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, p, null)); + } + + BuildReport report = collector.buildReport(session); + + assertEquals(BuildStatus.SUCCESS, report.status()); + assertTrue(report.multiModule()); + assertEquals(3, report.modules().size()); + assertEquals("parent", report.modules().get(0).artifactId()); + assertEquals("child-api", report.modules().get(1).artifactId()); + assertEquals("child-impl", report.modules().get(2).artifactId()); + } + + @Test + void testStackTraceIsTruncated() { + // Build a throwable with a deep stack trace + RuntimeException deep = createDeepException(50); + String truncated = BuildReportCollector.truncateStackTrace(deep); + + // Should contain the truncation notice + assertTrue(truncated.contains("more lines truncated"), "deep stack traces should be truncated"); + } + + @Test + void testShortStackTraceIsNotTruncated() { + RuntimeException shallow = new RuntimeException("short"); + // Trim the stack to a known-small size so it's guaranteed under the limit + shallow.setStackTrace( + new StackTraceElement[] {new StackTraceElement("com.example.Foo", "bar", "Foo.java", 42)}); + String result = BuildReportCollector.truncateStackTrace(shallow); + + // Short stack traces should NOT contain the truncation notice + assertFalse(result.contains("more lines truncated"), "short stack traces should not be truncated"); + } + + @Test + void testBuildEnvironmentSensitiveKeyRedaction() { + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + + // Inject user properties with sensitive and non-sensitive keys + Properties userProps = new Properties(); + userProps.setProperty("my.password", "s3cr3t"); + userProps.setProperty("db.secret", "hunter2"); + userProps.setProperty("api.token", "abc123"); + userProps.setProperty("maven.compiler.release", "21"); + session.getRequest().setUserProperties(userProps); + + var env = BuildReportCollector.buildEnvironment(session); + + assertEquals("***", env.userProperties().get("my.password"), "password key must be redacted"); + assertEquals("***", env.userProperties().get("db.secret"), "secret key must be redacted"); + assertEquals("***", env.userProperties().get("api.token"), "token key must be redacted"); + assertEquals("21", env.userProperties().get("maven.compiler.release"), "non-sensitive key must be kept"); + } + + @Test + void testBuildEnvironmentOfflineAndProfiles() { + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + session.getRequest().setOffline(true); + session.getRequest().setUpdateSnapshots(true); + session.getRequest().setActiveProfiles(List.of("ci", "!slow-tests")); + session.getRequest().setSelectedProjects(List.of(":my-app")); + session.getRequest().setResumeFrom(":other-module"); + + var env = BuildReportCollector.buildEnvironment(session); + + assertTrue(env.offline()); + assertTrue(env.updateSnapshots()); + assertEquals(List.of("ci", "!slow-tests"), env.activeProfiles()); + assertEquals(List.of(":my-app"), env.selectedProjects()); + assertEquals(":other-module", env.resumeFrom()); + } + + // ---- Test helpers ---- + + private MavenProject createProject(String groupId, String artifactId, String version) { + MavenProject project = new MavenProject(); + project.setGroupId(groupId); + project.setArtifactId(artifactId); + project.setVersion(version); + return project; + } + + private MavenSession createSession(MavenProject... projects) { + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setStartInstant(MonotonicClock.now()); + request.setGoals(List.of("clean", "install")); + request.setTopDirectory(tempDir); + + Properties systemProperties = new Properties(); + systemProperties.setProperty("maven.version", "1.0.0"); + request.setSystemProperties(systemProperties); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + + @SuppressWarnings("deprecation") + MavenSession session = new MavenSession(null, null, request, result); + session.setProjects(List.of(projects)); + return session; + } + + private MojoExecution createMojoExecution( + String groupId, String artifactId, String version, String goal, String executionId, String phase) { + @SuppressWarnings("deprecation") + PluginDescriptor pluginDescriptor = new PluginDescriptor(); + pluginDescriptor.setGroupId(groupId); + pluginDescriptor.setArtifactId(artifactId); + pluginDescriptor.setVersion(version); + + MojoDescriptor mojoDescriptor = new MojoDescriptor(); + mojoDescriptor.setGoal(goal); + mojoDescriptor.setPluginDescriptor(pluginDescriptor); + + MojoExecution execution = new MojoExecution(mojoDescriptor, executionId); + execution.setLifecyclePhase(phase); + + return execution; + } + + private ExecutionEvent createEvent( + ExecutionEvent.Type type, MavenSession session, MavenProject project, MojoExecution mojo) { + return new ExecutionEvent() { + @Override + public Type getType() { + return type; + } + + @Override + public MavenSession getSession() { + return session; + } + + @Override + public MavenProject getProject() { + return project; + } + + @Override + public MojoExecution getMojoExecution() { + return mojo; + } + + @Override + public Exception getException() { + return null; + } + }; + } + + /** + * Creates an exception with a stack trace of at least {@code depth} lines. + */ + private static RuntimeException createDeepException(int depth) { + try { + throwDeep(depth); + } catch (RuntimeException e) { + return e; + } + throw new AssertionError("unreachable"); + } + + private static void throwDeep(int remaining) { + if (remaining <= 0) { + throw new RuntimeException("deep exception"); + } + throwDeep(remaining - 1); + } +} diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportIntegrationTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportIntegrationTest.java new file mode 100644 index 000000000000..cc48ed27d7e7 --- /dev/null +++ b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportIntegrationTest.java @@ -0,0 +1,313 @@ +/* + * 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.internal.build; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Properties; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.BuildReport; +import org.apache.maven.api.build.report.BuildStatus; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.DefaultMavenExecutionRequest; +import org.apache.maven.execution.DefaultMavenExecutionResult; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.plugin.MojoExecution; +import org.apache.maven.plugin.descriptor.MojoDescriptor; +import org.apache.maven.plugin.descriptor.PluginDescriptor; +import org.apache.maven.project.MavenProject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Integration test that exercises the full build report pipeline: + * BuildReportCollector -> BuildReportJsonWriter -> file. + *

+ * This test simulates a complete multi-module build lifecycle with mojo + * executions and failures, then verifies the resulting JSON report file + * contains all expected data. + */ +class BuildReportIntegrationTest { + + @TempDir + Path tempDir; + + private BuildReportCollector collector; + + @BeforeEach + void setUp() { + collector = new BuildReportCollector(); + } + + /** + * Full lifecycle: multi-module build with mojos and JSON persistence. + */ + @Test + void testFullMultiModuleBuild() throws IOException { + MavenProject parent = createProject("com.example", "parent", "2.0.0"); + MavenProject api = createProject("com.example", "api", "2.0.0"); + MavenProject impl = createProject("com.example", "impl", "2.0.0"); + + MavenSession session = createSession(parent, api, impl); + MavenExecutionResult result = session.getResult(); + + // Session starts + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, parent, null)); + + // --- Module: parent --- + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, parent, null)); + result.addBuildSummary(new BuildSuccess(parent, 500)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, parent, null)); + + // --- Module: api --- + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, api, null)); + + MojoExecution compileApi = createMojoExecution( + "org.apache.maven.plugins", "maven-compiler-plugin", "3.15.0", "compile", "default-compile", "compile"); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, api, compileApi)); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoSucceeded, session, api, compileApi)); + + MojoExecution testApi = createMojoExecution( + "org.apache.maven.plugins", "maven-surefire-plugin", "3.5.0", "test", "default-test", "test"); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, api, testApi)); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoSucceeded, session, api, testApi)); + + result.addBuildSummary(new BuildSuccess(api, 3000)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, api, null)); + + // --- Module: impl --- + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, impl, null)); + + MojoExecution compileImpl = createMojoExecution( + "org.apache.maven.plugins", "maven-compiler-plugin", "3.15.0", "compile", "default-compile", "compile"); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, impl, compileImpl)); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoSucceeded, session, impl, compileImpl)); + + result.addBuildSummary(new BuildSuccess(impl, 2000)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, impl, null)); + + // --- Build report --- + BuildReport report = collector.buildReport(session); + + // Basic assertions + assertNotNull(report); + assertEquals(BuildStatus.SUCCESS, report.status()); + assertTrue(report.multiModule()); + assertEquals(3, report.modules().size()); + + // Problems should always be empty in this simplified collector + assertTrue(report.problems().isEmpty()); + + // Module reports + assertEquals("parent", report.modules().get(0).artifactId()); + assertEquals("api", report.modules().get(1).artifactId()); + assertEquals(2, report.modules().get(1).mojos().size()); + assertEquals("compile", report.modules().get(1).mojos().get(0).goal()); + assertEquals("test", report.modules().get(1).mojos().get(1).goal()); + assertEquals("impl", report.modules().get(2).artifactId()); + + // Write to JSON and verify + collector.writeReport(report, session); + + Path reportsDir = tempDir.resolve("target").resolve(BuildReportCollector.REPORT_DIR); + Path latestFile = reportsDir.resolve(BuildReportCollector.REPORT_LATEST); + assertTrue(Files.exists(latestFile), "build-report-latest.json should exist"); + + String json = Files.readString(latestFile); + + // Verify JSON structure + assertTrue(json.contains("\"formatVersion\": 1")); + assertTrue(json.contains("\"status\": \"SUCCESS\"")); + assertTrue(json.contains("\"multiModule\": true")); + assertTrue(json.contains("\"threads\": 1")); + + // Modules in JSON + assertTrue(json.contains("\"artifactId\": \"parent\"")); + assertTrue(json.contains("\"artifactId\": \"api\"")); + assertTrue(json.contains("\"artifactId\": \"impl\"")); + + // Mojos in JSON + assertTrue(json.contains("\"goal\": \"compile\"")); + assertTrue(json.contains("\"goal\": \"test\"")); + + // Problems and failures should be empty + assertTrue(json.contains("\"problems\": []")); + assertTrue(json.contains("\"failures\": []")); + + // Output arrays should be present (even if empty in unit test -- no SLF4J sink) + assertTrue(json.contains("\"output\": [")); + } + + /** + * Tests navigation methods on the built report. + */ + @Test + void testReportNavigationMethods() { + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + MavenExecutionResult result = session.getResult(); + + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null)); + + MojoExecution mojo = createMojoExecution( + "org.apache.maven.plugins", "maven-compiler-plugin", "3.15.0", "compile", "default-compile", "compile"); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, project, mojo)); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoSucceeded, session, project, mojo)); + + result.addBuildSummary(new BuildSuccess(project, 5000)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null)); + + BuildReport report = collector.buildReport(session); + + // findModule by GAV + var moduleOpt = report.findModule("org.example:my-app:1.0.0"); + assertTrue(moduleOpt.isPresent()); + assertEquals("my-app", moduleOpt.get().artifactId()); + assertEquals("org.example:my-app:1.0.0", moduleOpt.get().id()); + + // findMojo by id + var mojoOpt = moduleOpt.get().findMojo("maven-compiler-plugin:3.15.0:compile"); + assertTrue(mojoOpt.isPresent()); + assertEquals("compile", mojoOpt.get().goal()); + + // Not found + assertFalse(report.findModule("nonexistent:module:1.0").isPresent()); + } + + /** + * Tests that timestamped report files are written alongside the latest symlink. + */ + @Test + void testTimestampedReportFile() throws IOException { + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + session.getResult().addBuildSummary(new BuildSuccess(project, 1000)); + + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null)); + + BuildReport report = collector.buildReport(session); + collector.writeReport(report, session); + + Path reportsDir = tempDir.resolve("target").resolve(BuildReportCollector.REPORT_DIR); + assertTrue(Files.exists(reportsDir), "reports directory should exist"); + + // Should have at least two files: the timestamped one and the latest link/copy + long fileCount = Files.list(reportsDir) + .filter(p -> p.getFileName().toString().startsWith("build-report-")) + .count(); + assertTrue(fileCount >= 2, "should have both timestamped and latest report files, found " + fileCount); + + // The latest file should contain valid JSON + Path latestFile = reportsDir.resolve(BuildReportCollector.REPORT_LATEST); + String json = Files.readString(latestFile); + assertTrue(json.startsWith("{"), "report should start with JSON object"); + assertTrue(json.contains("\"formatVersion\": 1"), "report should contain format version"); + } + + // ---- Test helpers ---- + + private MavenProject createProject(String groupId, String artifactId, String version) { + MavenProject project = new MavenProject(); + project.setGroupId(groupId); + project.setArtifactId(artifactId); + project.setVersion(version); + return project; + } + + private MavenSession createSession(MavenProject... projects) { + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setStartInstant(MonotonicClock.now()); + request.setGoals(List.of("clean", "install")); + request.setTopDirectory(tempDir); + + Properties systemProperties = new Properties(); + systemProperties.setProperty("maven.version", "4.1.0-SNAPSHOT"); + request.setSystemProperties(systemProperties); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + + @SuppressWarnings("deprecation") + MavenSession session = new MavenSession(null, null, request, result); + session.setProjects(List.of(projects)); + return session; + } + + private MojoExecution createMojoExecution( + String groupId, String artifactId, String version, String goal, String executionId, String phase) { + @SuppressWarnings("deprecation") + PluginDescriptor pluginDescriptor = new PluginDescriptor(); + pluginDescriptor.setGroupId(groupId); + pluginDescriptor.setArtifactId(artifactId); + pluginDescriptor.setVersion(version); + + MojoDescriptor mojoDescriptor = new MojoDescriptor(); + mojoDescriptor.setGoal(goal); + mojoDescriptor.setPluginDescriptor(pluginDescriptor); + + MojoExecution execution = new MojoExecution(mojoDescriptor, executionId); + execution.setLifecyclePhase(phase); + + return execution; + } + + private ExecutionEvent createEvent( + ExecutionEvent.Type type, MavenSession session, MavenProject project, MojoExecution mojo) { + return new ExecutionEvent() { + @Override + public Type getType() { + return type; + } + + @Override + public MavenSession getSession() { + return session; + } + + @Override + public MavenProject getProject() { + return project; + } + + @Override + public MojoExecution getMojoExecution() { + return mojo; + } + + @Override + public Exception getException() { + return null; + } + }; + } +} diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportJsonWriterTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportJsonWriterTest.java new file mode 100644 index 000000000000..f75f501fdbb5 --- /dev/null +++ b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportJsonWriterTest.java @@ -0,0 +1,438 @@ +/* + * 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.internal.build; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import org.apache.maven.api.BuildEnvironment; +import org.apache.maven.api.build.report.BuildReport; +import org.apache.maven.api.build.report.BuildStatus; +import org.apache.maven.api.build.report.FailureReport; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.LogLevel; +import org.apache.maven.api.build.report.ModuleReport; +import org.apache.maven.api.build.report.MojoReport; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BuildReportJsonWriterTest { + + private static final BuildEnvironment EMPTY_ENV = new DefaultBuildEnvironment( + List.of(), Map.of(), Map.of(), "", List.of(), List.of(), null, "FAIL_FAST", false, false, false, false, 1); + private static final Instant BASE_TIME = Instant.parse("2025-01-15T10:30:00Z"); + + @Test + void testSuccessfulBuildReport() { + List mojoOutput = List.of( + new DefaultLogEvent( + BASE_TIME.plusSeconds(6), LogLevel.INFO, "Compiling 42 source files", "o.a.m.compiler", null), + new DefaultLogEvent(BASE_TIME.plusSeconds(7), LogLevel.INFO, "BUILD SUCCESS", "o.a.m.compiler", null)); + + MojoReport mojo = new DefaultMojoReport( + "org.apache.maven.plugins", + "maven-compiler-plugin", + "3.15.0", + "compile", + "default-compile", + "compile", + BuildStatus.SUCCESS, + BASE_TIME.plusSeconds(5), + Duration.ofMillis(2100), + mojoOutput); + + List moduleOutput = List.of(new DefaultLogEvent( + BASE_TIME.plusSeconds(2), + LogLevel.INFO, + "Resolving dependencies for maven-core", + "o.a.m.resolver", + null)); + + ModuleReport module = new DefaultModuleReport( + "org.apache.maven", + "maven-core", + "4.1.0-SNAPSHOT", + BuildStatus.SUCCESS, + BASE_TIME.plusSeconds(1), + Duration.ofMillis(12345), + List.of(mojo), + moduleOutput); + + List buildOutput = List.of( + new DefaultLogEvent(BASE_TIME, LogLevel.INFO, "Reactor Build Order:", "o.a.m.reactor", null), + new DefaultLogEvent(BASE_TIME, LogLevel.INFO, "Maven Core", "o.a.m.reactor", null)); + + BuildReport report = new DefaultBuildReport( + EMPTY_ENV, + BuildStatus.SUCCESS, + Duration.ofMillis(30000), + BASE_TIME, + "4.1.0-SNAPSHOT", + "21.0.1", + List.of("clean", "install"), + "org.apache.maven:maven:4.1.0-SNAPSHOT", + true, + 4, + List.of(module), + List.of(), + List.of(), + buildOutput); + + String json = BuildReportJsonWriter.toJson(report); + + assertTrue(json.contains("\"formatVersion\": 1")); + assertTrue(json.contains("\"status\": \"SUCCESS\"")); + assertTrue(json.contains("\"mavenVersion\": \"4.1.0-SNAPSHOT\"")); + assertTrue(json.contains("\"javaVersion\": \"21.0.1\"")); + assertTrue(json.contains("\"goals\": [\"clean\", \"install\"]")); + assertTrue(json.contains("\"multiModule\": true")); + assertTrue(json.contains("\"threads\": 4")); + assertTrue(json.contains("\"groupId\": \"org.apache.maven\"")); + assertTrue(json.contains("\"artifactId\": \"maven-core\"")); + assertTrue(json.contains("\"goal\": \"compile\"")); + assertTrue(json.contains("\"executionId\": \"default-compile\"")); + assertTrue(json.contains("\"failures\": []")); + // Module and mojo start times + assertTrue(json.contains("\"startTime\": \"2025-01-15T10:30:01Z\""), "module startTime"); + assertTrue(json.contains("\"startTime\": \"2025-01-15T10:30:05Z\""), "mojo startTime"); + // Structured log events at all three levels + assertTrue(json.contains("\"message\": \"Compiling 42 source files\""), "mojo-level log event"); + assertTrue(json.contains("\"message\": \"Resolving dependencies for maven-core\""), "module-level log event"); + assertTrue(json.contains("\"message\": \"Reactor Build Order:\""), "build-level log event"); + // Log event structure + assertTrue(json.contains("\"level\": \"INFO\""), "log level"); + assertTrue(json.contains("\"loggerName\": \"o.a.m.compiler\""), "logger name"); + } + + @Test + void testFailedBuildReport() { + FailureReport failure = new DefaultFailureReport( + "org.apache.maven:maven-core:4.1.0-SNAPSHOT", + "maven-compiler-plugin:3.15.0:compile", + BASE_TIME.plusSeconds(3), + "CompilationFailureException", + "Compilation failure: 3 errors", + "org.apache.maven.plugin.compiler.CompilationFailureException: ...\n\tat ...\n"); + + BuildReport report = new DefaultBuildReport( + EMPTY_ENV, + BuildStatus.FAILURE, + Duration.ofMillis(5000), + BASE_TIME, + "4.1.0-SNAPSHOT", + "21.0.1", + List.of("compile"), + "org.apache.maven:maven-core:4.1.0-SNAPSHOT", + false, + 1, + List.of(), + List.of(failure), + List.of(), + List.of()); + + String json = BuildReportJsonWriter.toJson(report); + + assertTrue(json.contains("\"status\": \"FAILURE\"")); + assertTrue(json.contains("\"module\": \"org.apache.maven:maven-core:4.1.0-SNAPSHOT\"")); + assertTrue(json.contains("\"mojo\": \"maven-compiler-plugin:3.15.0:compile\"")); + assertTrue(json.contains("\"message\": \"Compilation failure: 3 errors\"")); + assertTrue(json.contains("\"stackTrace\"")); + // Enriched fields + assertTrue(json.contains("\"timestamp\": \"2025-01-15T10:30:03Z\""), "failure timestamp"); + assertTrue(json.contains("\"exceptionType\": \"CompilationFailureException\""), "failure exceptionType"); + } + + @Test + void testJsonStringEscaping() { + FailureReport failure = new DefaultFailureReport( + "com.example:test:1.0", + null, + BASE_TIME, + null, + "Error: \"unexpected\" value\nwith newline\tand tab", + null); + + BuildReport report = new DefaultBuildReport( + EMPTY_ENV, + BuildStatus.FAILURE, + Duration.ofMillis(100), + BASE_TIME, + "4.1.0", + "21", + List.of(), + "com.example:test:1.0", + false, + 1, + List.of(), + List.of(failure), + List.of(), + List.of()); + + String json = BuildReportJsonWriter.toJson(report); + + // Check proper JSON escaping + assertTrue(json.contains("\\\"unexpected\\\"")); + assertTrue(json.contains("\\n")); + assertTrue(json.contains("\\t")); + } + + @Test + void testEmptyModulesAndFailures() { + BuildReport report = new DefaultBuildReport( + EMPTY_ENV, + BuildStatus.SUCCESS, + Duration.ofMillis(100), + BASE_TIME, + "4.1.0", + "21", + List.of(), + "com.example:test:1.0", + false, + 1, + List.of(), + List.of(), + List.of(), + List.of()); + + String json = BuildReportJsonWriter.toJson(report); + + assertTrue(json.contains("\"modules\": []")); + assertTrue(json.contains("\"failures\": []")); + } + + @Test + void testFormatVersion() { + BuildReport report = new DefaultBuildReport( + EMPTY_ENV, + BuildStatus.SUCCESS, + Duration.ZERO, + BASE_TIME, + "4.1.0", + "21", + List.of(), + "test:test:1.0", + false, + 1, + List.of(), + List.of(), + List.of(), + List.of()); + + assertEquals(1, report.formatVersion()); + } + + @Test + void testMultipleModules() { + ModuleReport mod1 = new DefaultModuleReport( + "com.example", + "api", + "1.0", + BuildStatus.SUCCESS, + BASE_TIME.plusSeconds(1), + Duration.ofSeconds(5), + List.of(), + List.of()); + ModuleReport mod2 = new DefaultModuleReport( + "com.example", + "impl", + "1.0", + BuildStatus.SUCCESS, + BASE_TIME.plusSeconds(6), + Duration.ofSeconds(10), + List.of(), + List.of()); + ModuleReport mod3 = new DefaultModuleReport( + "com.example", + "web", + "1.0", + BuildStatus.SKIPPED, + BASE_TIME.plusSeconds(16), + Duration.ZERO, + List.of(), + List.of()); + + BuildReport report = new DefaultBuildReport( + EMPTY_ENV, + BuildStatus.SUCCESS, + Duration.ofSeconds(15), + BASE_TIME, + "4.1.0", + "21", + List.of("install"), + "com.example:parent:1.0", + true, + 1, + List.of(mod1, mod2, mod3), + List.of(), + List.of(), + List.of()); + + String json = BuildReportJsonWriter.toJson(report); + + // All modules present + assertTrue(json.contains("\"artifactId\": \"api\"")); + assertTrue(json.contains("\"artifactId\": \"impl\"")); + assertTrue(json.contains("\"artifactId\": \"web\"")); + assertTrue(json.contains("\"status\": \"SKIPPED\"")); + } + + @Test + void testNullMojoFields() { + // mojo with null executionId and phase (direct invocation) + MojoReport mojo = new DefaultMojoReport( + "org.apache.maven.plugins", + "maven-help-plugin", + "3.4.1", + "effective-pom", + null, + null, + BuildStatus.SUCCESS, + BASE_TIME.plusSeconds(1), + Duration.ofMillis(500), + List.of()); + + String json = BuildReportJsonWriter.toJson(new DefaultBuildReport( + EMPTY_ENV, + BuildStatus.SUCCESS, + Duration.ofSeconds(1), + BASE_TIME, + "4.1.0", + "21", + List.of("help:effective-pom"), + "test:test:1.0", + false, + 1, + List.of(new DefaultModuleReport( + "test", + "test", + "1.0", + BuildStatus.SUCCESS, + BASE_TIME, + Duration.ofSeconds(1), + List.of(mojo), + List.of())), + List.of(), + List.of(), + List.of())); + + assertTrue(json.contains("\"executionId\": null")); + assertTrue(json.contains("\"phase\": null")); + assertFalse(json.contains("\"executionId\": \"null\"")); + } + + @Test + void testLogEventWithJulMetadata() { + // LogEvent with JUL source class, method, thread ID, and sequence number + LogEvent julEvent = new DefaultLogEvent( + BASE_TIME.plusSeconds(1), + LogLevel.WARN, + "Unsupported class file major version 65", + "org.apache.maven.plugins.compiler", + null, + null, + "com.sun.tools.javac.processing.JavacProcessingEnvironment", + "doProcessing", + 42L, + 1001L, + null, + null); + + // LogEvent without JUL metadata (from SLF4J) + LogEvent slf4jEvent = new DefaultLogEvent( + BASE_TIME.plusSeconds(2), LogLevel.INFO, "Compiling 10 files", "o.a.m.compiler", null); + + MojoReport mojo = new DefaultMojoReport( + "org.apache.maven.plugins", + "maven-compiler-plugin", + "3.15.0", + "compile", + "default-compile", + "compile", + BuildStatus.SUCCESS, + BASE_TIME, + Duration.ofMillis(1000), + List.of(julEvent, slf4jEvent)); + + BuildReport report = new DefaultBuildReport( + EMPTY_ENV, + BuildStatus.SUCCESS, + Duration.ofSeconds(1), + BASE_TIME, + "4.1.0", + "21", + List.of("compile"), + "test:test:1.0", + false, + 1, + List.of(new DefaultModuleReport( + "test", + "test", + "1.0", + BuildStatus.SUCCESS, + BASE_TIME, + Duration.ofSeconds(1), + List.of(mojo), + List.of())), + List.of(), + List.of(), + List.of()); + + String json = BuildReportJsonWriter.toJson(report); + + // JUL event should have sourceClassName, sourceMethodName, threadId, and sequenceNumber + assertTrue( + json.contains("\"sourceClassName\": \"com.sun.tools.javac.processing.JavacProcessingEnvironment\""), + "sourceClassName"); + assertTrue(json.contains("\"sourceMethodName\": \"doProcessing\""), "sourceMethodName"); + assertTrue(json.contains("\"threadId\": 42"), "threadId"); + assertTrue(json.contains("\"sequenceNumber\": 1001"), "sequenceNumber"); + // SLF4J event should NOT have JUL fields + // (the second log event in the output array has no sourceClassName) + assertFalse( + json.contains("\"sourceClassName\": \"o.a.m.compiler\""), + "SLF4J event should not have sourceClassName"); + } + + @Test + void testEmptyProblems() { + BuildReport report = new DefaultBuildReport( + EMPTY_ENV, + BuildStatus.SUCCESS, + Duration.ofMillis(100), + BASE_TIME, + "4.1.0", + "21", + List.of(), + "com.example:test:1.0", + false, + 1, + List.of(), + List.of(), + List.of(), + List.of()); + + String json = BuildReportJsonWriter.toJson(report); + assertTrue(json.contains("\"problems\": []"), "empty problems array"); + } +} diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/standalone/ApiRunner.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/standalone/ApiRunner.java index 98ecb75b9c74..37471ad261ba 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/impl/standalone/ApiRunner.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/standalone/ApiRunner.java @@ -39,6 +39,7 @@ import java.util.stream.Collectors; import org.apache.maven.api.Artifact; +import org.apache.maven.api.BuildEnvironment; import org.apache.maven.api.Constants; import org.apache.maven.api.Lifecycle; import org.apache.maven.api.MonotonicClock; @@ -284,6 +285,75 @@ interface LocalRepoProvider { */ static class DefaultSession extends AbstractSession { + // NOTE: If BuildEnvironment gains new abstract methods, update this constant. + // DefaultBuildEnvironment (maven-core) cannot be used here — module boundary. + private static final BuildEnvironment EMPTY_BUILD_ENVIRONMENT = new BuildEnvironment() { + @Override + public List goals() { + return List.of(); + } + + @Override + public Map userProperties() { + return Map.of(); + } + + @Override + public Map systemInfo() { + return Map.of(); + } + + @Override + public String localRepository() { + return ""; + } + + @Override + public List activeProfiles() { + return List.of(); + } + + @Override + public List selectedProjects() { + return List.of(); + } + + @Override + public String resumeFrom() { + return null; + } + + @Override + public String reactorFailureBehavior() { + return "FAIL_FAST"; + } + + @Override + public boolean offline() { + return false; + } + + @Override + public boolean updateSnapshots() { + return false; + } + + @Override + public boolean noTransferProgress() { + return false; + } + + @Override + public boolean batchMode() { + return false; + } + + @Override + public int threads() { + return 1; + } + }; + private final Map systemProperties; private final Instant startTime = MonotonicClock.now(); private Settings settings; @@ -381,6 +451,13 @@ public int getDegreeOfConcurrency() { return 0; } + @Override + public BuildEnvironment buildEnvironment() { + // ApiRunner is a standalone/embedded session with no MavenExecutionRequest; + // return a minimal environment reflecting defaults. + return EMPTY_BUILD_ENVIRONMENT; + } + @Override public Instant getStartTime() { return startTime; diff --git a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java index 2060d5bb7953..d41c3d6d171d 100644 --- a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java +++ b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java @@ -125,6 +125,8 @@ protected void writeThrowable(Throwable t, PrintStream stream) { writeThrowable(t, stream::println); } + private static final int MAX_THROWABLE_DEPTH = 20; + protected void writeThrowable(Throwable t, Consumer stream) { if (t == null) { return; @@ -135,10 +137,14 @@ protected void writeThrowable(Throwable t, Consumer stream) { } stream.accept(builder.toString()); - printStackTrace(t, stream, ""); + printStackTrace(t, stream, "", 0); } protected void printStackTrace(Throwable t, Consumer stream, String prefix) { + printStackTrace(t, stream, prefix, 0); + } + + private void printStackTrace(Throwable t, Consumer stream, String prefix, int depth) { MessageBuilder builder = builder(); for (StackTraceElement e : t.getStackTrace()) { builder.a(prefix); @@ -154,16 +160,24 @@ protected void printStackTrace(Throwable t, Consumer stream, String pref stream.accept(builder.toString()); builder.setLength(0); } - for (Throwable se : t.getSuppressed()) { - writeThrowable(se, stream, "Suppressed", prefix + " "); - } - Throwable cause = t.getCause(); - if (cause != null && t != cause) { - writeThrowable(cause, stream, "Caused by", prefix); + if (depth < MAX_THROWABLE_DEPTH) { + for (Throwable se : t.getSuppressed()) { + writeThrowable(se, stream, "Suppressed", prefix + " ", depth + 1); + } + Throwable cause = t.getCause(); + if (cause != null && t != cause) { + writeThrowable(cause, stream, "Caused by", prefix, depth + 1); + } + } else { + stream.accept(prefix + " [...cause/suppressed chain truncated at depth " + MAX_THROWABLE_DEPTH + "]"); } } protected void writeThrowable(Throwable t, Consumer stream, String caption, String prefix) { + writeThrowable(t, stream, caption, prefix, 0); + } + + private void writeThrowable(Throwable t, Consumer stream, String caption, String prefix, int depth) { MessageBuilder builder = builder().a(prefix).strong(caption).a(": ").a(t.getClass().getName()); if (t.getMessage() != null) { @@ -171,7 +185,7 @@ protected void writeThrowable(Throwable t, Consumer stream, String capti } stream.accept(builder.toString()); - printStackTrace(t, stream, prefix); + printStackTrace(t, stream, prefix, depth); } protected String getLocation(final StackTraceElement e) { diff --git a/impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenJulHandlerTest.java b/impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenJulHandlerTest.java index 58c23c92031e..587638534414 100644 --- a/impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenJulHandlerTest.java +++ b/impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenJulHandlerTest.java @@ -20,6 +20,8 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; import java.util.logging.Level; import java.util.logging.LogRecord; @@ -113,6 +115,31 @@ void publishIsReentrantSafe() { } } + /** + * Verify that a {@link java.util.logging.Filter} installed on the handler is respected: + * records rejected by the filter must be silently dropped without being forwarded. + * This exercises the {@code isLoggable(record)} guard added to {@link MavenJulHandler#publish}. + */ + @Test + void publishRespectsHandlerFilter() { + MavenJulHandler handler = new MavenJulHandler(); + + // Install a filter that rejects every record, tracking whether it was consulted. + List filtered = new ArrayList<>(); + handler.setFilter(record -> { + filtered.add(record); + return false; // reject all + }); + + LogRecord record = new LogRecord(Level.INFO, "should be filtered"); + // publish() must return without throwing — record is rejected by the filter. + handler.publish(record); + + // The filter must have been consulted exactly once. + assertEquals(1, filtered.size(), "Filter should have been consulted for the published record"); + assertEquals(record, filtered.get(0), "Filter should have been given the exact record"); + } + /** * Invoke the private julLevelToSlf4j method via reflection for testing. */ diff --git a/impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenSimpleLoggerTest.java b/impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenSimpleLoggerTest.java index e26ea811830d..d3a3fe49a7e8 100644 --- a/impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenSimpleLoggerTest.java +++ b/impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenSimpleLoggerTest.java @@ -20,9 +20,11 @@ import java.io.ByteArrayOutputStream; import java.io.PrintStream; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.NoSuchElementException; +import java.util.concurrent.atomic.AtomicReference; import org.apache.maven.jline.MessageUtils; import org.junit.jupiter.api.AfterEach; @@ -31,7 +33,11 @@ import org.junit.jupiter.api.TestInfo; import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertLinesMatch; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; class MavenSimpleLoggerTest { @@ -91,4 +97,86 @@ void includesCauseAndSuppressedExceptionsWhenWritingThrowables(TestInfo testInfo assertLinesMatch(expectedLines, actualLines); } + + /** + * Verify that when a {@link MavenSimpleLogger.LogSink} is installed, + * {@code write()} routes the event to the sink instead of stdout. + */ + @Test + void writeRoutesToSinkWhenInstalled() { + // Capture sink invocations + List captured = new ArrayList<>(); + MavenSimpleLogger.LogSink sink = (level, loggerName, cleanMessage, formatted, t) -> + captured.add(new Object[] {level, loggerName, cleanMessage, formatted, t}); + + MavenSimpleLogger.setLogSink(sink); + try { + MavenSimpleLogger logger = new MavenSimpleLogger("test.logger"); + logger.info("hello sink"); + + assertEquals(1, captured.size(), "sink should have been called exactly once"); + Object[] call = captured.get(0); + // level corresponds to SLF4J INFO_INT = 20 + assertEquals(org.slf4j.spi.LocationAwareLogger.INFO_INT, call[0]); + assertEquals("test.logger", call[1]); + assertEquals("hello sink", call[2]); + assertNotNull(call[3], "formatted message must not be null"); + assertTrue(((String) call[3]).contains("hello sink"), "formatted must contain the message"); + assertNull(call[4], "no throwable expected"); + } finally { + MavenSimpleLogger.setLogSink(null); + } + } + + /** + * Verify that a deeply nested cause chain is truncated at MAX_THROWABLE_DEPTH + * and that a truncation notice is emitted. + */ + @Test + void writeThrowableTruncatesAtMaxDepth() { + // Build a cause chain of depth 21 — one beyond the limit of 20 + RuntimeException root = new RuntimeException("root"); + RuntimeException current = root; + for (int i = 0; i < 21; i++) { + RuntimeException next = new RuntimeException("cause-" + i); + current.initCause(next); + current = next; + } + + List lines = new ArrayList<>(); + new MavenSimpleLogger("test").writeThrowable(root, lines::add); + + assertTrue( + lines.stream().anyMatch(l -> l.contains("truncated at depth")), + "Expected a truncation notice after reaching MAX_THROWABLE_DEPTH"); + } + + /** + * Verify that when a throwable is present, the sink receives a formatted + * string that includes the exception class and message. + */ + @Test + void writeRoutesThrowableToSink() { + AtomicReference formattedRef = new AtomicReference<>(); + AtomicReference throwableRef = new AtomicReference<>(); + MavenSimpleLogger.LogSink sink = (level, loggerName, cleanMessage, formatted, t) -> { + formattedRef.set(formatted); + throwableRef.set(t); + }; + + MavenSimpleLogger.setLogSink(sink); + try { + MavenSimpleLogger logger = new MavenSimpleLogger("test.logger"); + RuntimeException ex = new RuntimeException("boom"); + logger.error("oops", ex); + + String formatted = formattedRef.get(); + assertNotNull(formatted, "formatted must not be null"); + assertTrue(formatted.contains("oops"), "formatted must contain the log message"); + assertTrue(formatted.contains("java.lang.RuntimeException"), "formatted must contain the exception class"); + assertEquals(ex, throwableRef.get(), "sink must receive the original throwable"); + } finally { + MavenSimpleLogger.setLogSink(null); + } + } } diff --git a/impl/maven-testing/src/main/java/org/apache/maven/testing/plugin/stubs/SessionStub.java b/impl/maven-testing/src/main/java/org/apache/maven/testing/plugin/stubs/SessionStub.java index 6299fa8abe95..93b191716d83 100644 --- a/impl/maven-testing/src/main/java/org/apache/maven/testing/plugin/stubs/SessionStub.java +++ b/impl/maven-testing/src/main/java/org/apache/maven/testing/plugin/stubs/SessionStub.java @@ -28,6 +28,7 @@ import org.apache.maven.api.Artifact; import org.apache.maven.api.ArtifactCoordinates; +import org.apache.maven.api.BuildEnvironment; import org.apache.maven.api.Dependency; import org.apache.maven.api.DependencyCoordinates; import org.apache.maven.api.DependencyScope; @@ -68,6 +69,75 @@ */ public class SessionStub implements Session { + // NOTE: If BuildEnvironment gains new abstract methods, update this constant + // (same as ApiRunner.EMPTY_BUILD_ENVIRONMENT — keep both in sync). + private static final BuildEnvironment EMPTY_BUILD_ENVIRONMENT = new BuildEnvironment() { + @Override + public java.util.List goals() { + return java.util.List.of(); + } + + @Override + public java.util.Map userProperties() { + return java.util.Map.of(); + } + + @Override + public java.util.Map systemInfo() { + return java.util.Map.of(); + } + + @Override + public String localRepository() { + return ""; + } + + @Override + public java.util.List activeProfiles() { + return java.util.List.of(); + } + + @Override + public java.util.List selectedProjects() { + return java.util.List.of(); + } + + @Override + public String resumeFrom() { + return null; + } + + @Override + public String reactorFailureBehavior() { + return "FAIL_FAST"; + } + + @Override + public boolean offline() { + return false; + } + + @Override + public boolean updateSnapshots() { + return false; + } + + @Override + public boolean noTransferProgress() { + return false; + } + + @Override + public boolean batchMode() { + return false; + } + + @Override + public int threads() { + return 1; + } + }; + private Map userProperties; private Map systemProperties; @@ -153,6 +223,11 @@ public SessionData getData() { return null; } + @Override + public BuildEnvironment buildEnvironment() { + return EMPTY_BUILD_ENVIRONMENT; + } + @Override public Version getMavenVersion() { return null; From 88c4e95c0f8e08370960733d77cfa03b3c57ce57 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 21 Sep 2026 16:35:58 +0000 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20console=20modes=20=E2=80=93=20--con?= =?UTF-8?q?sole=3Dplain/rich/verbose/machine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add --console=plain/rich/verbose/machine option to select output mode - plain: compact one-line-per-module output with structured summary - rich: JLine status bar with live reactor progress and mojo tracking - verbose: full mojo-level output (Maven 4.0 default behaviour) - machine: JSON lines stream of lifecycle events for CI/LLM/IDE tooling - MachineBuildEventListener: structured JSON log/transfer events - MachineExecutionEventLogger: JSON lifecycle events (build/module/mojo) - RichBuildEventListener: JLine terminal status bar with parallel progress - RichExecutionEventLogger: minimal per-module logger for rich mode - PlainExecutionEventLogger: compact logger for plain mode - BUILD FAILURE logged at ERROR level; per-module FAILURE lines at ERROR - Unknown --console values emit a warning and fall back to auto - mojoKey uses groupId to avoid collision in multi-module builds - Use AtomicInteger for thread-safe counters (completedProjects, etc.) - Apply spotless formatting to all modified files --- .../org/apache/maven/api/cli/Options.java | 20 + .../cling/event/ExecutionEventLogger.java | 79 +- .../event/MachineBuildEventListener.java | 273 ++++++ .../event/MachineExecutionEventLogger.java | 307 +++++++ .../event/PlainExecutionEventLogger.java | 325 ++++++++ .../cling/event/RichBuildEventListener.java | 785 ++++++++++++++++++ .../cling/event/RichExecutionEventLogger.java | 377 +++++++++ .../cling/invoker/CommonsCliOptions.java | 24 + .../maven/cling/invoker/LayeredOptions.java | 5 + .../maven/cling/invoker/LookupInvoker.java | 38 +- .../maven/cling/invoker/mvn/MavenInvoker.java | 95 ++- .../cling/event/ExecutionEventLoggerTest.java | 17 +- .../event/MachineBuildEventListenerTest.java | 239 ++++++ .../MachineExecutionEventLoggerTest.java | 445 ++++++++++ .../event/PlainExecutionEventLoggerTest.java | 317 +++++++ .../event/RichBuildEventListenerTest.java | 332 ++++++++ .../event/RichExecutionEventLoggerTest.java | 424 ++++++++++ 17 files changed, 4038 insertions(+), 64 deletions(-) create mode 100644 impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineBuildEventListener.java create mode 100644 impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineExecutionEventLogger.java create mode 100644 impl/maven-cli/src/main/java/org/apache/maven/cling/event/PlainExecutionEventLogger.java create mode 100644 impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichBuildEventListener.java create mode 100644 impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichExecutionEventLogger.java create mode 100644 impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineBuildEventListenerTest.java create mode 100644 impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineExecutionEventLoggerTest.java create mode 100644 impl/maven-cli/src/test/java/org/apache/maven/cling/event/PlainExecutionEventLoggerTest.java create mode 100644 impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichBuildEventListenerTest.java create mode 100644 impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichExecutionEventLoggerTest.java diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Options.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Options.java index d2bf596cd916..b7a05ffc2178 100644 --- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Options.java +++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Options.java @@ -182,6 +182,26 @@ public interface Options { @Nonnull Optional color(); + /** + * Returns the console output mode. + *

+ * Supported modes: + *

    + *
  • {@code "auto"} — selects {@code "plain"} in CI, {@code "rich"} on interactive TTYs, + * {@code "verbose"} otherwise
  • + *
  • {@code "plain"} — compact one-line-per-module output with structured summary
  • + *
  • {@code "rich"} — JLine status bar with live reactor progress (requires TTY)
  • + *
  • {@code "verbose"} — full mojo-level output (Maven 4.0 default behavior)
  • + *
  • {@code "machine"} — JSON lines: one typed JSON object per lifecycle event, + * designed for piping to external tools, CI systems, and LLM agents
  • + *
+ * + * @return an {@link Optional} containing the console mode, or empty if not set + * @since 4.1.0 + */ + @Nonnull + Optional console(); + /** * Indicates whether Maven should operate in offline mode. * diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/ExecutionEventLogger.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/ExecutionEventLogger.java index 1168ddc5039d..3152eae35740 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/ExecutionEventLogger.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/ExecutionEventLogger.java @@ -25,13 +25,13 @@ import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; -import java.util.ArrayList; import java.util.List; import java.util.Objects; import org.apache.maven.api.MonotonicClock; import org.apache.maven.api.services.MessageBuilder; import org.apache.maven.api.services.MessageBuilderFactory; +import org.apache.maven.cling.utils.CLIReportingUtils; import org.apache.maven.execution.AbstractExecutionListener; import org.apache.maven.execution.BuildFailure; import org.apache.maven.execution.BuildSuccess; @@ -205,53 +205,44 @@ private void logReactorSummary(MavenSession session) { List projects = session.getProjects(); + StringBuilder buffer = new StringBuilder(128); + String skippedMessage = builder().warning("SKIPPED").build(); String successMessage = builder().success("SUCCESS").build(); String failureMessage = builder().failure("FAILURE").build(); String unknownMessage = builder().warning("UNKNOWN").build(); - List entries = new ArrayList<>(projects.size()); + boolean lastWasSkipped = false; for (MavenProject project : projects) { BuildSummary buildSummary = result.getBuildSummary(project); String statusMessage; - int group; - if (buildSummary instanceof BuildSuccess) { + boolean shouldSkip = result.hasExceptions(); + if (buildSummary == null) { + statusMessage = skippedMessage; + } else if (buildSummary instanceof BuildSuccess) { statusMessage = successMessage; - group = 1; } else if (buildSummary instanceof BuildFailure) { statusMessage = failureMessage; - group = 2; - } else if (buildSummary == null) { - statusMessage = skippedMessage; - group = 0; + shouldSkip = false; } else { statusMessage = unknownMessage; - group = 2; } - entries.add(new ReactorSummaryEntry(project, buildSummary, group, statusMessage)); - } - - ReactorSummaryRequest request = new ReactorSummaryRequest(entries, isSingleVersion); - logReactorSummaryGroup(request, 0); - logReactorSummaryGroup(request, 1); - logReactorSummaryGroup(request, 2); - } - - private void logReactorSummaryGroup(ReactorSummaryRequest request, int group) { - StringBuilder buffer = new StringBuilder(128); - - for (ReactorSummaryEntry entry : request.entries()) { - if (entry.group() != group) { + if (shouldSkip) { + lastWasSkipped = true; continue; } + if (lastWasSkipped) { + logger.info("..."); + lastWasSkipped = false; + } - buffer.append(entry.project().getName()); + buffer.append(project.getName()); buffer.append(' '); - if (!request.isSingleVersion()) { - buffer.append(entry.project().getVersion()); + if (!isSingleVersion) { + buffer.append(project.getVersion()); buffer.append(' '); } @@ -262,24 +253,23 @@ private void logReactorSummaryGroup(ReactorSummaryRequest request, int group) { buffer.append(' '); } - buffer.append(entry.statusMessage()); - if (entry.buildSummary() != null) { - formatBuildTime(buffer, entry.buildSummary()); + buffer.append(statusMessage); + if (buildSummary != null) { + formatBuildTime(buffer, buildSummary); } - if (entry.buildSummary() instanceof BuildFailure) { + if (buildSummary instanceof BuildFailure) { logger.error(buffer.toString()); } else { logger.info(buffer.toString()); } buffer.setLength(0); } - } - - private record ReactorSummaryRequest(List entries, boolean isSingleVersion) {} - private record ReactorSummaryEntry( - MavenProject project, BuildSummary buildSummary, int group, String statusMessage) {} + if (lastWasSkipped) { + logger.info("..."); + } + } private void formatBuildTime(StringBuilder buffer, BuildSummary buildSummary) { buffer.append(" ["); @@ -296,15 +286,11 @@ private void logResult(MavenSession session) { infoLine('-'); MessageBuilder buffer = builder(); - boolean failure = session.getResult().hasExceptions(); - if (failure) { + if (session.getResult().hasExceptions()) { buffer.failure("BUILD FAILURE"); - } else { - buffer.success("BUILD SUCCESS"); - } - if (failure) { logger.error(buffer.toString()); } else { + buffer.success("BUILD SUCCESS"); logger.info(buffer.toString()); } } @@ -324,6 +310,15 @@ private void logStats(MavenSession session) { logger.info("Total time: {}{}", formatDuration(time), wallClock); + // On failure, show Maven and Java version to help with bug reports (MNG-7372) + if (session.getResult().hasExceptions()) { + logger.info("Maven: {}", CLIReportingUtils.showVersionMinimal()); + logger.info( + "Java: {} ({})", + System.getProperty("java.version", ""), + System.getProperty("java.vendor", "")); + } + ZonedDateTime rounded = finish.truncatedTo(ChronoUnit.SECONDS).atZone(ZoneId.systemDefault()); logger.info("Finished at: {}", formatTimestamp(rounded)); } diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineBuildEventListener.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineBuildEventListener.java new file mode 100644 index 000000000000..c2c4e4585e7b --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineBuildEventListener.java @@ -0,0 +1,273 @@ +/* + * 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.event; + +import java.util.function.Consumer; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.logging.BuildEventListener; +import org.eclipse.aether.transfer.TransferEvent; + +/** + * A machine-readable build event listener that outputs one JSON object per line + * to the configured writer. Each line is a self-contained JSON object with an + * {@code "event"} field identifying its type. + *

+ * This listener handles the {@link BuildEventListener} events: log messages, + * transfer progress, and execution failures. Session and project lifecycle events + * are emitted by the companion {@link MachineExecutionEventLogger}. + *

+ * The JSON lines format is designed for piping to external tools (CI systems, + * LLM agents, IDE integrations) that consume structured build events in real time. + *

+ * Example output: + *

+ * {"event":"log","timestamp":"...","message":"Compiling 42 source files"}
+ * {"event":"transfer.started","timestamp":"...","artifact":"core-4.1.0.jar","size":524288}
+ * {"event":"transfer.progressed","timestamp":"...","artifact":"core-4.1.0.jar","transferred":262144,"total":524288}
+ * {"event":"transfer.completed","timestamp":"...","artifact":"core-4.1.0.jar","transferred":524288}
+ * 
+ * + * Selected via {@code --console=machine}. + * + * @since 4.1.0 + * @see MachineExecutionEventLogger + */ +public class MachineBuildEventListener implements BuildEventListener { + + private final Consumer output; + + /** + * Creates a new MachineBuildEventListener. + * + * @param output the consumer that receives each JSON line (typically writes to terminal/stdout) + */ + public MachineBuildEventListener(Consumer output) { + this.output = output; + } + + /** + * Emit a pre-built JSON line to the output. Thread-safe — output is serialized + * to prevent interleaved lines from parallel builds. + * + * @param json the complete JSON object string (no trailing newline) + */ + public synchronized void emitEvent(String json) { + output.accept(json); + } + + @Override + public void sessionStarted(ExecutionEvent event) { + // Handled by MachineExecutionEventLogger.sessionStarted() + } + + @Override + public void projectStarted(String projectId) { + // Handled by MachineExecutionEventLogger.projectStarted() + } + + @Override + public void projectLogMessage(String projectId, LogEvent event) { + emitEvent(new JsonLine("log") + .field("level", event.level().name()) + .field("module", projectId) + .field("logger", event.loggerName()) + .field("message", event.message()) + .build()); + } + + @Override + public void projectFinished(String projectId) { + // Handled by MachineExecutionEventLogger.projectSucceeded/Failed/Skipped() + } + + @Override + public void executionFailure(String projectId, boolean halted, String exception) { + emitEvent(new JsonLine("execution.failure") + .field("module", projectId) + .field("halted", halted) + .field("error", exception) + .build()); + } + + @Override + public void mojoStarted(ExecutionEvent event) { + // Handled by MachineExecutionEventLogger.mojoStarted() + } + + @Override + public void finish(int exitCode) throws Exception { + // No-op — build.finished is emitted by MachineExecutionEventLogger.sessionEnded() + } + + @Override + public void fail(Throwable t) throws Exception { + // No-op — build.finished is emitted by MachineExecutionEventLogger.sessionEnded() + } + + @Override + public void log(String msg) { + emitEvent(new JsonLine("log").field("message", msg).build()); + } + + @Override + public void transfer(String projectId, TransferEvent event) { + String resource = event.getResource().getResourceName(); + String artifactName = extractArtifactName(resource); + long contentLength = event.getResource().getContentLength(); + + switch (event.getType()) { + case INITIATED: + case STARTED: + JsonLine started = new JsonLine("transfer.started").field("artifact", artifactName); + if (projectId != null) { + started.field("module", projectId); + } + if (contentLength > 0) { + started.field("size", contentLength); + } + started.field("url", resource); + emitEvent(started.build()); + break; + case PROGRESSED: + JsonLine progressed = new JsonLine("transfer.progressed").field("artifact", artifactName); + progressed.field("transferred", event.getTransferredBytes()); + if (contentLength > 0) { + progressed.field("total", contentLength); + } + emitEvent(progressed.build()); + break; + case SUCCEEDED: + JsonLine succeeded = new JsonLine("transfer.completed").field("artifact", artifactName); + succeeded.field("transferred", event.getTransferredBytes()); + emitEvent(succeeded.build()); + break; + case FAILED: + JsonLine failed = new JsonLine("transfer.failed").field("artifact", artifactName); + if (event.getException() != null) { + failed.field("error", event.getException().getMessage()); + } + emitEvent(failed.build()); + break; + default: + break; + } + } + + // ---- Helpers ---- + + private static String extractArtifactName(String resourceName) { + if (resourceName == null) { + return "unknown"; + } + int lastSlash = resourceName.lastIndexOf('/'); + return lastSlash >= 0 ? resourceName.substring(lastSlash + 1) : resourceName; + } + + // ---- JSON line builder ---- + + /** + * Lightweight builder for single-line JSON objects. Builds a flat JSON object + * with an {@code "event"} type and a {@code "timestamp"} field, plus any + * additional fields. Thread-safe when used within a single thread per instance. + */ + static class JsonLine { + private final StringBuilder sb; + + JsonLine(String eventType) { + sb = new StringBuilder(256); + sb.append("{\"event\":\""); + sb.append(eventType); + sb.append("\",\"timestamp\":\""); + sb.append(MonotonicClock.now().toString()); + sb.append('"'); + } + + JsonLine field(String key, String value) { + if (value != null) { + sb.append(",\"").append(key).append("\":"); + writeJsonString(sb, value); + } + return this; + } + + JsonLine field(String key, long value) { + sb.append(",\"").append(key).append("\":").append(value); + return this; + } + + JsonLine field(String key, double value) { + sb.append(",\"").append(key).append("\":").append(value); + return this; + } + + JsonLine field(String key, boolean value) { + sb.append(",\"").append(key).append("\":").append(value); + return this; + } + + String build() { + sb.append('}'); + return sb.toString(); + } + + /** + * Write a JSON-escaped string value (with surrounding quotes) to the builder. + */ + private static void writeJsonString(StringBuilder sb, String value) { + sb.append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + case '\b': + sb.append("\\b"); + break; + case '\f': + sb.append("\\f"); + break; + default: + if (c < 0x20) { + sb.append("\\u"); + sb.append(String.format("%04x", (int) c)); + } else { + sb.append(c); + } + } + } + sb.append('"'); + } + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineExecutionEventLogger.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineExecutionEventLogger.java new file mode 100644 index 000000000000..f75bee5a0297 --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineExecutionEventLogger.java @@ -0,0 +1,307 @@ +/* + * 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.event; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.cling.event.MachineBuildEventListener.JsonLine; +import org.apache.maven.execution.AbstractExecutionListener; +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.BuildSummary; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.plugin.MojoExecution; +import org.apache.maven.project.MavenProject; + +/** + * Execution event logger for machine-readable output ({@code --console=machine}). + *

+ * Emits one JSON line per lifecycle event to the shared + * {@link MachineBuildEventListener#emitEvent(String)} writer. This logger + * handles the {@link org.apache.maven.execution.ExecutionListener} events: + * session start/end, project start/success/failure/skip, and mojo + * start/success/failure/skip. + *

+ * Together with {@link MachineBuildEventListener} (which handles log messages, + * transfers, and execution failures), this provides a complete, typed event + * stream suitable for piping to external tools, CI systems, and LLM agents. + *

+ * Example output: + *

+ * {"event":"build.started","timestamp":"...","projectCount":12,"goals":"clean install"}
+ * {"event":"module.started","timestamp":"...","module":"maven-core","groupId":"org.apache.maven","artifactId":"maven-core","version":"4.1.0-SNAPSHOT","index":1,"total":12}
+ * {"event":"mojo.started","timestamp":"...","module":"maven-core","plugin":"maven-compiler-plugin","goal":"compile","phase":"compile"}
+ * {"event":"mojo.succeeded","timestamp":"...","module":"maven-core","plugin":"maven-compiler-plugin","goal":"compile","duration":1.2}
+ * {"event":"module.succeeded","timestamp":"...","module":"maven-core","duration":2.1}
+ * {"event":"build.finished","timestamp":"...","status":"SUCCESS","duration":32.1,"total":12,"passed":12,"failed":0,"skipped":0}
+ * 
+ * + * Selected via {@code --console=machine}. + * + * @since 4.1.0 + * @see MachineBuildEventListener + */ +public class MachineExecutionEventLogger extends AbstractExecutionListener { + + private final MachineBuildEventListener machineBel; + + // Track mojo start times for duration calculation + private final Map mojoStartTimes = new ConcurrentHashMap<>(); + + // Reactor state + private final AtomicInteger totalProjects = new AtomicInteger(); + private final AtomicInteger currentVisitedProjectCount = new AtomicInteger(); + private volatile Instant buildStartTime; + + public MachineExecutionEventLogger(MachineBuildEventListener machineBel) { + this.machineBel = Objects.requireNonNull(machineBel, "machineBel cannot be null"); + } + + // ---- Session lifecycle ---- + + @Override + public void sessionStarted(ExecutionEvent event) { + MavenSession session = event.getSession(); + List projects = session.getProjects(); + List allProjects = session.getAllProjects(); + + totalProjects.set(allProjects.size()); + currentVisitedProjectCount.set(allProjects.size() - projects.size()); + buildStartTime = MonotonicClock.now(); + + String goals = session.getRequest().getGoals().stream().collect(Collectors.joining(" ")); + + JsonLine line = new JsonLine("build.started") + .field("projectCount", totalProjects.get()) + .field("goals", goals); + + List profiles = session.getRequest().getActiveProfiles(); + if (profiles != null && !profiles.isEmpty()) { + line.field("profiles", String.join(",", profiles)); + } + + machineBel.emitEvent(line.build()); + } + + @Override + public void sessionEnded(ExecutionEvent event) { + MavenSession session = event.getSession(); + + int passed = 0; + int failed = 0; + int skipped = 0; + for (MavenProject project : session.getProjects()) { + BuildSummary summary = session.getResult().getBuildSummary(project); + if (summary instanceof BuildSuccess) { + passed++; + } else if (summary instanceof BuildFailure) { + failed++; + } else { + skipped++; + } + } + + String status = session.getResult().hasExceptions() ? "FAILURE" : "SUCCESS"; + double duration = 0; + if (buildStartTime != null) { + duration = Duration.between(buildStartTime, MonotonicClock.now()).toMillis() / 1000.0; + } + + machineBel.emitEvent(new JsonLine("build.finished") + .field("status", status) + .field("duration", duration) + .field("total", totalProjects.get()) + .field("passed", passed) + .field("failed", failed) + .field("skipped", skipped) + .build()); + } + + // ---- Module lifecycle ---- + + @Override + public void projectStarted(ExecutionEvent event) { + MavenProject project = event.getProject(); + int index = currentVisitedProjectCount.incrementAndGet(); + + machineBel.emitEvent(new JsonLine("module.started") + .field("module", project.getName()) + .field("groupId", project.getGroupId()) + .field("artifactId", project.getArtifactId()) + .field("version", project.getVersion()) + .field("index", index) + .field("total", totalProjects.get()) + .build()); + } + + @Override + public void projectSucceeded(ExecutionEvent event) { + logModuleFinished(event, "module.succeeded"); + } + + @Override + public void projectFailed(ExecutionEvent event) { + logModuleFinished(event, "module.failed"); + } + + @Override + public void projectSkipped(ExecutionEvent event) { + MavenProject project = event.getProject(); + machineBel.emitEvent(new JsonLine("module.skipped") + .field("module", project.getName()) + .build()); + } + + // ---- Mojo lifecycle ---- + + @Override + public void mojoStarted(ExecutionEvent event) { + MavenProject project = event.getProject(); + MojoExecution mojo = event.getMojoExecution(); + + String mojoKey = project.getGroupId() + ":" + project.getArtifactId() + ":" + mojo.getExecutionId() + ":" + + mojo.getGoal(); + mojoStartTimes.put(mojoKey, MonotonicClock.now()); + + machineBel.emitEvent(new JsonLine("mojo.started") + .field("module", project.getName()) + .field("plugin", mojo.getArtifactId()) + .field("goal", mojo.getGoal()) + .field("phase", mojo.getLifecyclePhase()) + .field("executionId", mojo.getExecutionId()) + .build()); + } + + @Override + public void mojoSucceeded(ExecutionEvent event) { + logMojoFinished(event, "mojo.succeeded"); + } + + @Override + public void mojoFailed(ExecutionEvent event) { + MavenProject project = event.getProject(); + MojoExecution mojo = event.getMojoExecution(); + + String mojoKey = project.getGroupId() + ":" + project.getArtifactId() + ":" + mojo.getExecutionId() + ":" + + mojo.getGoal(); + Instant start = mojoStartTimes.remove(mojoKey); + + JsonLine line = new JsonLine("mojo.failed") + .field("module", project.getName()) + .field("plugin", mojo.getArtifactId()) + .field("goal", mojo.getGoal()); + if (start != null) { + double duration = Duration.between(start, MonotonicClock.now()).toMillis() / 1000.0; + line.field("duration", duration); + } + if (event.getException() != null) { + line.field("error", event.getException().getMessage()); + } + machineBel.emitEvent(line.build()); + } + + @Override + public void mojoSkipped(ExecutionEvent event) { + MavenProject project = event.getProject(); + MojoExecution mojo = event.getMojoExecution(); + + machineBel.emitEvent(new JsonLine("mojo.skipped") + .field("module", project.getName()) + .field("plugin", mojo.getArtifactId()) + .field("goal", mojo.getGoal()) + .build()); + } + + // ---- Fork lifecycle (machine mode emits these for completeness) ---- + + @Override + public void forkStarted(ExecutionEvent event) { + MavenProject project = event.getProject(); + MojoExecution mojo = event.getMojoExecution(); + + machineBel.emitEvent(new JsonLine("fork.started") + .field("module", project.getName()) + .field("plugin", mojo.getArtifactId()) + .field("goal", mojo.getGoal()) + .build()); + } + + @Override + public void forkSucceeded(ExecutionEvent event) { + MavenProject project = event.getProject(); + machineBel.emitEvent(new JsonLine("fork.succeeded") + .field("module", project.getName()) + .build()); + } + + @Override + public void forkFailed(ExecutionEvent event) { + MavenProject project = event.getProject(); + JsonLine line = new JsonLine("fork.failed").field("module", project.getName()); + if (event.getException() != null) { + line.field("error", event.getException().getMessage()); + } + machineBel.emitEvent(line.build()); + } + + // ---- Helpers ---- + + private void logModuleFinished(ExecutionEvent event, String eventType) { + MavenProject project = event.getProject(); + MavenSession session = event.getSession(); + BuildSummary summary = session.getResult().getBuildSummary(project); + + JsonLine line = new JsonLine(eventType).field("module", project.getName()); + if (summary != null) { + line.field("duration", summary.getExecTime().toMillis() / 1000.0); + } + if ("module.failed".equals(eventType) && event.getException() != null) { + line.field("error", event.getException().getMessage()); + } + machineBel.emitEvent(line.build()); + } + + private void logMojoFinished(ExecutionEvent event, String eventType) { + MavenProject project = event.getProject(); + MojoExecution mojo = event.getMojoExecution(); + + String mojoKey = project.getGroupId() + ":" + project.getArtifactId() + ":" + mojo.getExecutionId() + ":" + + mojo.getGoal(); + Instant start = mojoStartTimes.remove(mojoKey); + + JsonLine line = new JsonLine(eventType) + .field("module", project.getName()) + .field("plugin", mojo.getArtifactId()) + .field("goal", mojo.getGoal()); + if (start != null) { + double duration = Duration.between(start, MonotonicClock.now()).toMillis() / 1000.0; + line.field("duration", duration); + } + machineBel.emitEvent(line.build()); + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/PlainExecutionEventLogger.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/PlainExecutionEventLogger.java new file mode 100644 index 000000000000..19de0fb2a9f3 --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/PlainExecutionEventLogger.java @@ -0,0 +1,325 @@ +/* + * 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.event; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.services.MessageBuilder; +import org.apache.maven.api.services.MessageBuilderFactory; +import org.apache.maven.cling.utils.CLIReportingUtils; +import org.apache.maven.execution.AbstractExecutionListener; +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.BuildSummary; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.project.MavenProject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.apache.maven.cling.utils.CLIReportingUtils.formatDuration; + +/** + * Compact execution event logger for CI and batch environments. + *

+ * Produces one line per completed module instead of the verbose per-mojo + * output of {@link ExecutionEventLogger}. Designed for CI log viewers + * and LLM-based tools where signal density matters more than verbosity. + *

+ * Example output: + *

+ * [INFO] maven-api-core ................................ SUCCESS [  2.1s]
+ * [INFO] maven-core ..................................... FAILURE [  5.3s]
+ * [INFO]
+ * [INFO] BUILD FAILURE
+ * [INFO] Total time:  32.1s
+ * 
+ * + * Selected via {@code --console=plain} or automatically in CI environments. + * + * @since 4.1.0 + * @see ExecutionEventLogger + */ +public class PlainExecutionEventLogger extends AbstractExecutionListener { + + private static final int MAX_LOG_PREFIX_SIZE = 8; // "[ERROR] " + private static final int PROJECT_STATUS_SUFFIX_SIZE = 20; // "SUCCESS [ 0.000 s]" + private static final int MIN_TERMINAL_WIDTH = 60; + private static final int DEFAULT_TERMINAL_WIDTH = 80; + private static final int MAX_TERMINAL_WIDTH = 130; + private static final int MAX_PADDED_BUILD_TIME_DURATION_LENGTH = 9; + + private final MessageBuilderFactory messageBuilderFactory; + private final Logger logger; + private int terminalWidth; + private int lineLength; + private int maxProjectNameLength; + private int totalProjects; + private int currentVisitedProjectCount; + + public PlainExecutionEventLogger(MessageBuilderFactory messageBuilderFactory) { + this(messageBuilderFactory, LoggerFactory.getLogger(PlainExecutionEventLogger.class)); + } + + public PlainExecutionEventLogger(MessageBuilderFactory messageBuilderFactory, Logger logger) { + this(messageBuilderFactory, logger, -1); + } + + public PlainExecutionEventLogger(MessageBuilderFactory messageBuilderFactory, Logger logger, int terminalWidth) { + this.logger = Objects.requireNonNull(logger, "logger cannot be null"); + this.messageBuilderFactory = messageBuilderFactory; + this.terminalWidth = terminalWidth; + } + + private void init() { + if (maxProjectNameLength == 0) { + if (terminalWidth < 0) { + terminalWidth = messageBuilderFactory.getTerminalWidth(); + } + terminalWidth = Math.min( + MAX_TERMINAL_WIDTH, + Math.max(terminalWidth <= 0 ? DEFAULT_TERMINAL_WIDTH : terminalWidth, MIN_TERMINAL_WIDTH)); + lineLength = terminalWidth - MAX_LOG_PREFIX_SIZE; + maxProjectNameLength = lineLength - PROJECT_STATUS_SUFFIX_SIZE; + } + } + + private MessageBuilder builder() { + return messageBuilderFactory.builder(); + } + + private static String chars(char c, int count) { + return String.valueOf(c).repeat(Math.max(0, count)); + } + + private void infoMain(String msg) { + logger.info(builder().strong(msg).toString()); + } + + // ---- Session lifecycle ---- + + @Override + public void projectDiscoveryStarted(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logger.info("Scanning for projects..."); + } + } + + @Override + public void sessionStarted(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + List projects = event.getSession().getProjects(); + List allProjects = event.getSession().getAllProjects(); + + currentVisitedProjectCount = allProjects.size() - projects.size(); + totalProjects = allProjects.size(); + } + } + + @Override + public void sessionEnded(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logger.info(""); + logResult(event.getSession()); + logStats(event.getSession()); + } + } + + // ---- Module lifecycle: one line per completed module ---- + + @Override + public void projectStarted(ExecutionEvent event) { + // In plain mode, we only log when a project finishes (succeeded/failed/skipped) + } + + @Override + public void projectSucceeded(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logProjectLine(event, "SUCCESS"); + } + } + + @Override + public void projectFailed(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logProjectLine(event, "FAILURE"); + } + } + + @Override + public void projectSkipped(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logProjectLine(event, "SKIPPED"); + } + } + + // ---- Mojo lifecycle: suppressed in plain mode ---- + + @Override + public void mojoStarted(ExecutionEvent event) { + // Suppressed in plain mode — plugin execution details go to build report + } + + @Override + public void mojoSkipped(ExecutionEvent event) { + if (logger.isWarnEnabled()) { + logger.warn( + "Goal '{}' requires online mode for execution but Maven is currently offline, skipping", + event.getMojoExecution().getGoal()); + } + } + + @Override + public void forkStarted(ExecutionEvent event) { + // Suppressed in plain mode + } + + @Override + public void forkSucceeded(ExecutionEvent event) { + // Suppressed in plain mode + } + + // ---- Formatting helpers ---- + + private void logProjectLine(ExecutionEvent event, String status) { + MavenProject project = event.getProject(); + MavenSession session = event.getSession(); + MavenExecutionResult result = session.getResult(); + BuildSummary buildSummary = result.getBuildSummary(project); + + StringBuilder buffer = new StringBuilder(128); + buffer.append(project.getName()); + buffer.append(' '); + + if (totalProjects > 1) { + int number; + synchronized (this) { + number = ++currentVisitedProjectCount; + } + String progress = "[" + number + "/" + totalProjects + "]"; + buffer.append(progress); + buffer.append(' '); + } + + // Pad with dots to align status + if (buffer.length() <= maxProjectNameLength) { + while (buffer.length() < maxProjectNameLength) { + buffer.append('.'); + } + buffer.append(' '); + } + + // Status with color + MessageBuilder mb = builder(); + mb.a(buffer); + switch (status) { + case "SUCCESS": + mb.success(status); + break; + case "FAILURE": + mb.failure(status); + break; + default: + mb.warning(status); + break; + } + + // Duration + if (buildSummary != null) { + mb.a(" ["); + String duration = formatDuration(buildSummary.getExecTime()); + int padSize = MAX_PADDED_BUILD_TIME_DURATION_LENGTH - duration.length(); + if (padSize > 0) { + mb.a(chars(' ', padSize)); + } + mb.a(duration); + mb.a(']'); + } + + logger.info(mb.toString()); + } + + private void logResult(MavenSession session) { + MessageBuilder buffer = builder(); + if (session.getResult().hasExceptions()) { + buffer.failure("BUILD FAILURE"); + } else { + buffer.success("BUILD SUCCESS"); + } + + int passed = 0; + int failed = 0; + int skipped = 0; + for (MavenProject project : session.getProjects()) { + BuildSummary summary = session.getResult().getBuildSummary(project); + if (summary instanceof BuildSuccess) { + passed++; + } else if (summary instanceof BuildFailure) { + failed++; + } else { + skipped++; + } + } + + if (session.getResult().hasExceptions()) { + logger.error(buffer.toString()); + } else { + logger.info(buffer.toString()); + } + if (totalProjects > 1) { + StringBuilder stats = new StringBuilder(); + stats.append(totalProjects).append(" modules"); + stats.append(" | ").append(passed).append(" passed"); + if (failed > 0) { + stats.append(" | ").append(failed).append(" failed"); + } + if (skipped > 0) { + stats.append(" | ").append(skipped).append(" skipped"); + } + logger.info(stats.toString()); + } + } + + private void logStats(MavenSession session) { + Duration time = Duration.between(session.getRequest().getStartInstant(), MonotonicClock.now()); + String wallClock = session.getRequest().getDegreeOfConcurrency() > 1 ? " (Wall Clock)" : ""; + logger.info("Total time: {}{}", formatDuration(time), wallClock); + + // On failure, show Maven and Java version to help with bug reports (MNG-7372) + if (session.getResult().hasExceptions()) { + logger.info("Maven: {}", CLIReportingUtils.showVersionMinimal()); + logger.info( + "Java: {} ({})", + System.getProperty("java.version", ""), + System.getProperty("java.vendor", "")); + } + + logger.info("Full report: target/build-reports/build-report-latest.json"); + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichBuildEventListener.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichBuildEventListener.java new file mode 100644 index 000000000000..0532cc4d907d --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichBuildEventListener.java @@ -0,0 +1,785 @@ +/* + * 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.event; + +import java.io.PrintWriter; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.LogLevel; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.logging.BuildEventListener; +import org.apache.maven.project.MavenProject; +import org.eclipse.aether.transfer.TransferEvent; +import org.jline.terminal.Terminal; +import org.jline.utils.Display; + +/** + * A rich terminal build event listener using JLine's {@link Display} in + * non-fullscreen mode — the same approach as mvnd. + *

+ * The status area is rendered at the current cursor position using + * {@link Display#updateAnsi}. When log output arrives, the display is + * cleared (updated with empty lines), the log line is printed normally, + * and then the status is redrawn below it. JLine handles all the cursor + * math (moving up, erasing changed lines, etc.) and only repaints what + * actually changed. + *

+ * At the end of the build, the display is cleared and nothing remains + * on screen — the summary then prints as normal scrolling text. + *

+ * The status area has a fixed height based on the degree of concurrency, + * so the separator and summary line stay anchored at the bottom. Active + * projects are packed to the top of the slot area; empty lines fill the + * gap between the last active project and the separator. + *

+ * Falls back to simple log passthrough on dumb terminals. + * + * @since 4.1.0 + * @see PlainExecutionEventLogger + * @see ExecutionEventLogger + */ +public class RichBuildEventListener implements BuildEventListener { + + // ---- ANSI colors ---- + + private static final String ESC = "\033["; + private static final String CYAN = ESC + "36m"; + private static final String YELLOW = ESC + "33m"; + private static final String BLUE = ESC + "34m"; + private static final String GREEN = ESC + "32m"; + private static final String RED = ESC + "31m"; + private static final String BOLD = ESC + "1m"; + private static final String DIM = ESC + "2m"; + private static final String RESET = ESC + "0m"; + + // ---- Terminal & output ---- + + private final Terminal terminal; + private final PrintWriter writer; + private final boolean supported; + + // ---- JLine Display ---- + + /** JLine display in non-fullscreen mode — handles cursor math. */ + private volatile Display display; + /** Whether the display is currently active. */ + private volatile boolean displayActive; + /** Fixed number of lines in the status area (set once in initReactor). */ + private volatile int statusHeight; + + // ---- Reactor state ---- + + private volatile int totalProjects; + private final AtomicInteger completedProjects = new AtomicInteger(); + private volatile Instant buildStartTime; + /** One-line header shown at the top of the status area. */ + private volatile String headerLine; + + // ---- Project display ---- + + private final Map activeProjects = new ConcurrentHashMap<>(); + private final List projectOrder = new ArrayList<>(); + private final Map projectNames = new ConcurrentHashMap<>(); + + // ---- Active downloads ---- + + private final Map activeTransfers = new ConcurrentHashMap<>(); + + // ---- Synchronization ---- + + /** Guards all terminal output and slot mutations. */ + private final Object outputLock = new Object(); + + // ---- Periodic refresh ---- + + /** Scheduler for 1-second display refresh so elapsed timers stay live. */ + private volatile ScheduledExecutorService refreshScheduler; + /** Handle for the periodic refresh task. */ + private volatile ScheduledFuture refreshFuture; + + // ---- Warning tracking ---- + + /** Number of WARN-level messages seen during the build. */ + private final AtomicInteger warningCount = new AtomicInteger(); + + /** Number of ERROR-level messages seen during the build. */ + private final AtomicInteger errorCount = new AtomicInteger(); + + // ---- Constructor ---- + + /** + * Creates a new RichBuildEventListener. + * + * @param terminal the JLine terminal for output + * @param output fallback output consumer (unused — kept for API compat) + */ + public RichBuildEventListener(Terminal terminal, java.util.function.Consumer output) { + this.terminal = terminal; + this.writer = terminal.writer(); + // Support ANSI if terminal type is not "dumb" and has reasonable size + String type = terminal.getType(); + this.supported = type != null && !Terminal.TYPE_DUMB.equals(type) && terminal.getWidth() > 0; + } + + // ---- Reactor lifecycle ---- + + /** + * Initialize reactor state from the session. Called by {@link RichExecutionEventLogger} + * during {@code sessionStarted}. + */ + public void initReactor(MavenSession session) { + List allProjects = session.getAllProjects(); + List projects = session.getProjects(); + + this.totalProjects = allProjects.size(); + this.completedProjects.set(allProjects.size() - projects.size()); + this.buildStartTime = MonotonicClock.now(); + + for (MavenProject project : allProjects) { + projectOrder.add(project.getArtifactId()); + projectNames.put(project.getArtifactId(), project.getName()); + } + + // Build header line + this.headerLine = buildHeaderLine(session); + + // Slot count = degree of concurrency (capped for sanity) + int concurrency = 1; + try { + concurrency = Math.max(1, session.getRequest().getDegreeOfConcurrency()); + } catch (Exception e) { + // fallback to 1 + } + int slotCount = Math.min(concurrency, 8); + // Fixed height: 1 header + N project slots + 1 separator + 1 summary + this.statusHeight = slotCount + 3; + + if (supported) { + setupDisplay(); + } + } + + private String buildHeaderLine(MavenSession session) { + StringBuilder h = new StringBuilder(); + h.append(' ').append(BOLD); + + // Maven version + String mavenVersion = null; + if (session.getSystemProperties() != null) { + mavenVersion = session.getSystemProperties().getProperty("maven.version"); + } + if (mavenVersion != null) { + h.append("Maven ").append(mavenVersion); + } else { + h.append("Maven"); + } + h.append(RESET); + + // Project name + MavenProject top = session.getTopLevelProject(); + if (top != null) { + h.append(DIM).append(" ─ ").append(RESET); + h.append("building "); + h.append(CYAN).append(top.getName()).append(RESET); + if (top.getVersion() != null) { + h.append(' ').append(DIM).append(top.getVersion()).append(RESET); + } + } + + // Goals + List goals = session.getGoals(); + if (goals != null && !goals.isEmpty()) { + h.append(DIM).append(" ─ ").append(RESET); + h.append(YELLOW).append(String.join(" ", goals)).append(RESET); + } + + return h.toString(); + } + + /** + * Set up the JLine Display in non-fullscreen mode. + */ + private void setupDisplay() { + synchronized (outputLock) { + display = new Display(terminal, false); + display.resize(statusHeight, terminal.getWidth()); + displayActive = true; + display.updateAnsi(buildStatusLines(), 0); + } + + // Start a 1-second periodic refresh so that elapsed-time counters + // stay live even when no build events are arriving (e.g. during + // a slow mojo execution with no log output). + ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1, r -> { + Thread t = new Thread(r, "maven-rich-display-refresh"); + t.setDaemon(true); + return t; + }); + executor.setRemoveOnCancelPolicy(true); + refreshScheduler = executor; + refreshFuture = refreshScheduler.scheduleAtFixedRate(this::redraw, 1, 1, TimeUnit.SECONDS); + } + + /** + * Tear down the status display. Called by {@link RichExecutionEventLogger} + * during {@code sessionEnded} before printing the summary. + *

+ * The flush at the end is critical: {@link Display} writes through + * {@link Terminal#writer()} (a {@code PrintWriter} that does not auto-flush), + * while subsequent log output from SLF4J goes through {@code System.out} + * (which does auto-flush on {@code println}). Without the flush, + * the clear sequences sit in the writer's buffer while the summary text + * reaches the terminal first via {@code System.out} — then the belated + * clear erases the summary the user was supposed to see. + */ + public void tearDown() { + // Stop the periodic refresh first (outside outputLock to avoid deadlock) + if (refreshFuture != null) { + refreshFuture.cancel(false); + refreshFuture = null; + } + if (refreshScheduler != null) { + refreshScheduler.shutdownNow(); + refreshScheduler = null; + } + + synchronized (outputLock) { + if (!displayActive) { + return; + } + displayActive = false; + + // Clear the display area: update with empty lines, cursor at top + display.updateAnsi(Collections.nCopies(statusHeight, ""), 0); + // Erase from cursor to end of screen — removes any leftover artifacts + writer.print("\033[J"); + // Flush immediately so the clear reaches the terminal BEFORE + // any subsequent log output that goes through System.out + writer.flush(); + } + } + + // ---- BuildEventListener interface ---- + + @Override + public void sessionStarted(ExecutionEvent event) { + // Reactor init is handled via initReactor() called from RichExecutionEventLogger + } + + @Override + public void projectStarted(String projectId) { + activeProjects.put(projectId, new ProjectState(projectId, MonotonicClock.now())); + redraw(); + } + + @Override + public void projectFinished(String projectId) { + activeProjects.remove(projectId); + completedProjects.incrementAndGet(); + redraw(); + } + + @Override + public void projectLogMessage(String projectId, LogEvent event) { + // In rich mode, suppress INFO/DEBUG/TRACE/WARN — the status bar provides + // live progress and warnings are summarized at the end of the build. + // Only ERROR passes through to the terminal immediately. + if (event.level() == LogLevel.WARN) { + warningCount.incrementAndGet(); + return; + } + if (event.level() == LogLevel.INFO || event.level() == LogLevel.DEBUG || event.level() == LogLevel.TRACE) { + return; + } + if (event.level() == LogLevel.ERROR) { + errorCount.incrementAndGet(); + } + String output = event.formattedMessage(); + if (output == null) { + output = event.message(); + } + printAboveStatus(output); + } + + /** + * Returns the number of WARN-level log messages seen during the build. + */ + public int getWarningCount() { + return warningCount.get(); + } + + /** + * Returns the number of ERROR-level log messages seen during the build. + */ + public int getErrorCount() { + return errorCount.get(); + } + + @Override + public void log(String msg) { + printAboveStatus(msg); + } + + @Override + public void mojoStarted(ExecutionEvent event) { + String projectId = event.getProject().getArtifactId(); + ProjectState state = activeProjects.get(projectId); + if (state != null) { + state.currentMojo = event.getMojoExecution().getArtifactId() + ":" + + event.getMojoExecution().getGoal(); + } + synchronized (outputLock) { + if (displayActive) { + display.updateAnsi(buildStatusLines(), 0); + } + } + } + + @Override + public void executionFailure(String projectId, boolean halted, String exception) { + ProjectState state = activeProjects.get(projectId); + if (state != null) { + state.failed = true; + } + synchronized (outputLock) { + if (displayActive) { + display.updateAnsi(buildStatusLines(), 0); + } + } + } + + @Override + public void transfer(String projectId, TransferEvent event) { + String resource = event.getResource().getResourceName(); + + switch (event.getType()) { + case INITIATED: + case STARTED: + String artifactName = extractArtifactName(resource); + activeTransfers.put( + resource, + new TransferInfo(artifactName, 0, event.getResource().getContentLength())); + redraw(); + break; + case PROGRESSED: + TransferInfo info = activeTransfers.get(resource); + if (info != null) { + info.transferred = event.getTransferredBytes(); + // Only update every ~50KB to avoid too-frequent redraws + if (info.transferred - info.lastUpdateBytes > 51200) { + info.lastUpdateBytes = info.transferred; + redraw(); + } + } + break; + case SUCCEEDED: + case FAILED: + activeTransfers.remove(resource); + redraw(); + break; + default: + break; + } + } + + @Override + public void finish(int exitCode) throws Exception { + tearDown(); + } + + @Override + public void fail(Throwable t) throws Exception { + tearDown(); + } + + // ---- Display helpers ---- + + /** + * Print a message above the status area: clear the display, print + * the message as normal scrolling text, then redraw the status below. + */ + private void printAboveStatus(String msg) { + synchronized (outputLock) { + if (displayActive) { + // Clear status area so the message prints where it was + display.updateAnsi(Collections.nCopies(statusHeight, ""), 0); + display.reset(); + // Print the log message (scrolls normally) + writer.println(msg); + writer.flush(); + // Redraw status below the new output + display.updateAnsi(buildStatusLines(), 0); + } else { + writer.println(msg); + writer.flush(); + } + } + } + + private void redraw() { + synchronized (outputLock) { + if (displayActive) { + display.updateAnsi(buildStatusLines(), 0); + } + } + } + + // ---- Status line building ---- + + /** + * Build exactly {@link #statusHeight} status lines. + *

+ * For small reactors (modules fit as individual indicators): + * Layout: header + active slots + padding + separator + summary (indicators + counter). + *

+ * For large reactors (progress bar mode): + * Layout: header + active slots + padding + progress bar (full-width, acts as separator + counter). + * The progress bar replaces the separator — no redundant horizontal rule. + */ + private List buildStatusLines() { + int termWidth = Math.max(terminal.getWidth(), 40); + + // Collect active projects sorted by start time for visual stability + List active = new ArrayList<>(activeProjects.values()); + active.sort((a, b) -> a.startTime.compareTo(b.startTime)); + + // Determine if we're in progress bar mode (reactor too large for per-module indicators) + int maxIndicators = Math.min(projectOrder.size(), (termWidth - 40) / 3); + boolean useProgressBar = totalProjects > 1 && maxIndicators > 0 && totalProjects > maxIndicators; + + // Number of project slot lines: subtract header (1) + bottom lines (2 for separator+summary, 1 for bar) + int slotCount = statusHeight - (useProgressBar ? 2 : 3); + + List lines = new ArrayList<>(statusHeight); + + // Header line + lines.add(headerLine != null ? headerLine : ""); + + // Active projects packed to the top (up to slotCount) + int projectsShown = 0; + for (ProjectState state : active) { + if (projectsShown >= slotCount) { + break; + } + lines.add(formatProjectSlot(state)); + projectsShown++; + } + + // Empty padding lines at the bottom of the slot area + for (int i = projectsShown; i < slotCount; i++) { + lines.add(""); + } + + if (useProgressBar) { + // Progress bar replaces separator + summary as a single full-width line + lines.add(buildProgressBarLine(termWidth)); + } else { + // Separator line (always at the same position) + lines.add(DIM + "─".repeat(Math.min(termWidth, 120)) + RESET); + // Summary line (per-module indicators + counter + elapsed + downloads) + lines.add(buildSummaryLine(termWidth)); + } + + return lines; + } + + private String formatProjectSlot(ProjectState state) { + StringBuilder b = new StringBuilder(); + if (state.failed) { + b.append(RED).append(" ✗ ").append(RESET); + } else { + b.append(CYAN).append(" ● ").append(RESET); + } + b.append(BOLD); + b.append(projectNames.getOrDefault(state.projectId, state.projectId)); + b.append(RESET); + if (state.currentMojo != null) { + b.append(" ").append(YELLOW).append(state.currentMojo).append(RESET); + } + Duration elapsed = Duration.between(state.startTime, MonotonicClock.now()); + b.append(" ").append(DIM).append(formatCompactDuration(elapsed)).append(RESET); + return b.toString(); + } + + /** + * Build a full-width progress bar line for large reactors. + * Replaces both the separator and summary — one line with the proportional + * bar, counter, elapsed time, and download status. + */ + private String buildProgressBarLine(int termWidth) { + // Build the suffix first so we know how much width the bar can use + StringBuilder suffix = new StringBuilder(); + suffix.append(" ["); + suffix.append(completedProjects.get()).append('/').append(totalProjects); + suffix.append(']'); + if (buildStartTime != null) { + Duration elapsed = Duration.between(buildStartTime, MonotonicClock.now()); + suffix.append(" ").append(formatCompactDuration(elapsed)); + } + if (!activeTransfers.isEmpty()) { + suffix.append(" ↓ "); + if (activeTransfers.size() == 1) { + TransferInfo ti = activeTransfers.values().iterator().next(); + suffix.append(ti.artifactName); + if (ti.totalBytes > 0) { + suffix.append(' ') + .append(formatBytes(ti.transferred)) + .append('/') + .append(formatBytes(ti.totalBytes)); + } + } else { + suffix.append(activeTransfers.size()).append(" artifacts"); + } + } + int suffixLen = suffix.length(); + + // Bar fills from column 0 to (lineWidth - suffixLen) + int lineWidth = Math.min(termWidth, 120); + int barWidth = Math.max(10, lineWidth - suffixLen); + + int activeCount = activeProjects.size(); + int doneChars = (int) ((long) completedProjects.get() * barWidth / totalProjects); + int activeChars = (int) ((long) activeCount * barWidth / totalProjects); + if (activeCount > 0 && activeChars < 1) { + activeChars = 1; + } + if (doneChars + activeChars > barWidth) { + activeChars = barWidth - doneChars; + } + int remainChars = barWidth - doneChars - activeChars; + + StringBuilder s = new StringBuilder(); + s.append(GREEN).append("━".repeat(doneChars)).append(RESET); + s.append(YELLOW).append("━".repeat(activeChars)).append(RESET); + s.append(DIM).append("─".repeat(remainChars)).append(RESET); + + // Append suffix with styling + s.append(" ["); + s.append(BOLD) + .append(completedProjects.get()) + .append('/') + .append(totalProjects) + .append(RESET); + s.append(']'); + if (buildStartTime != null) { + Duration elapsed = Duration.between(buildStartTime, MonotonicClock.now()); + s.append(" ").append(DIM).append(formatCompactDuration(elapsed)).append(RESET); + } + if (!activeTransfers.isEmpty()) { + s.append(" ").append(BLUE).append("↓ ").append(RESET); + if (activeTransfers.size() == 1) { + TransferInfo ti = activeTransfers.values().iterator().next(); + s.append(ti.artifactName); + if (ti.totalBytes > 0) { + s.append(' ') + .append(DIM) + .append(formatBytes(ti.transferred)) + .append('/') + .append(formatBytes(ti.totalBytes)) + .append(RESET); + } + } else { + s.append(activeTransfers.size()).append(" artifacts"); + } + } + + return s.toString(); + } + + /** + * Build the summary line for small reactors (per-module indicators + counter). + */ + private String buildSummaryLine(int termWidth) { + StringBuilder s = new StringBuilder(" "); + + // Per-module indicators — each module gets its own symbol + if (totalProjects > 1) { + for (String pid : projectOrder) { + if (activeProjects.containsKey(pid)) { + ProjectState ps = activeProjects.get(pid); + if (ps != null && ps.failed) { + s.append(RED).append("✗ ").append(RESET); + } else { + s.append(YELLOW).append("● ").append(RESET); + } + } else if (projectOrder.indexOf(pid) < completedProjects.get()) { + s.append(GREEN).append("✓ ").append(RESET); + } else { + s.append(DIM).append("○ ").append(RESET); + } + } + } + + // Progress counter + s.append('['); + s.append(BOLD) + .append(completedProjects.get()) + .append('/') + .append(totalProjects) + .append(RESET); + s.append(']'); + + // Elapsed time + if (buildStartTime != null) { + Duration elapsed = Duration.between(buildStartTime, MonotonicClock.now()); + s.append(" ").append(DIM).append(formatCompactDuration(elapsed)).append(RESET); + } + + // Downloads (merged into summary line to keep height fixed) + if (!activeTransfers.isEmpty()) { + s.append(" ").append(BLUE).append("↓ ").append(RESET); + if (activeTransfers.size() == 1) { + TransferInfo ti = activeTransfers.values().iterator().next(); + s.append(ti.artifactName); + if (ti.totalBytes > 0) { + s.append(' ') + .append(DIM) + .append(formatBytes(ti.transferred)) + .append('/') + .append(formatBytes(ti.totalBytes)) + .append(RESET); + } + } else { + s.append(activeTransfers.size()).append(" artifacts"); + } + } + + return s.toString(); + } + + // ---- Helpers ---- + + /** + * Truncate a string containing ANSI escape sequences to + * {@code maxVisible} visible characters. If truncation occurs, + * a RESET is appended to close any open styling. + */ + static String truncateAnsi(String s, int maxVisible) { + StringBuilder out = new StringBuilder(s.length()); + int visible = 0; + int i = 0; + while (i < s.length()) { + char c = s.charAt(i); + if (c == '\033') { + // Start of escape sequence — copy through without counting + out.append(c); + i++; + if (i < s.length()) { + char next = s.charAt(i); + if (next == '[') { + // CSI sequence: ESC [ ... + out.append(next); + i++; + while (i < s.length()) { + char cc = s.charAt(i); + out.append(cc); + i++; + if (Character.isLetter(cc)) { + break; + } + } + } else { + // Two-char escape (DECSC, DECRC, etc.) + out.append(next); + i++; + } + } + } else { + if (visible >= maxVisible) { + out.append(RESET); + break; + } + out.append(c); + visible++; + i++; + } + } + return out.toString(); + } + + private static String extractArtifactName(String resourceName) { + if (resourceName == null) { + return "unknown"; + } + int lastSlash = resourceName.lastIndexOf('/'); + return lastSlash >= 0 ? resourceName.substring(lastSlash + 1) : resourceName; + } + + private static String formatCompactDuration(Duration duration) { + long totalSeconds = duration.getSeconds(); + if (totalSeconds < 60) { + return totalSeconds + "s"; + } else if (totalSeconds < 3600) { + return (totalSeconds / 60) + "m " + (totalSeconds % 60) + "s"; + } else { + return (totalSeconds / 3600) + "h " + ((totalSeconds % 3600) / 60) + "m"; + } + } + + private static String formatBytes(long bytes) { + if (bytes < 1024) { + return bytes + " B"; + } else if (bytes < 1024 * 1024) { + return String.format("%.0f KB", bytes / 1024.0); + } else { + return String.format("%.1f MB", bytes / (1024.0 * 1024.0)); + } + } + + // ---- Inner state classes ---- + + private static class ProjectState { + final String projectId; + final Instant startTime; + volatile String currentMojo; + volatile boolean failed; + + ProjectState(String projectId, Instant startTime) { + this.projectId = projectId; + this.startTime = startTime; + } + } + + private static class TransferInfo { + final String artifactName; + final long totalBytes; + volatile long transferred; + volatile long lastUpdateBytes; + + TransferInfo(String artifactName, long transferred, long totalBytes) { + this.artifactName = artifactName; + this.transferred = transferred; + this.totalBytes = totalBytes; + } + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichExecutionEventLogger.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichExecutionEventLogger.java new file mode 100644 index 000000000000..f87e2125a2a9 --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichExecutionEventLogger.java @@ -0,0 +1,377 @@ +/* + * 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.event; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.services.MessageBuilder; +import org.apache.maven.api.services.MessageBuilderFactory; +import org.apache.maven.cling.utils.CLIReportingUtils; +import org.apache.maven.execution.AbstractExecutionListener; +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.BuildSummary; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.project.MavenProject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.apache.maven.cling.utils.CLIReportingUtils.formatDuration; + +/** + * Execution event logger for the rich terminal mode ({@code --console=rich}). + *

+ * In rich mode, the {@link RichBuildEventListener} manages a JLine status bar at + * the bottom of the terminal showing live reactor progress. This logger is deliberately + * minimal — it suppresses the verbose per-mojo and per-project banners that + * {@link ExecutionEventLogger} produces, since the status bar replaces them. + *

+ * What this logger DOES print (above the status bar): + *

    + *
  • One line per completed module (like {@link PlainExecutionEventLogger})
  • + *
  • Build result summary ({@code BUILD SUCCESS/FAILURE})
  • + *
  • Compact module statistics and timing
  • + *
  • Pointer to the structured build report
  • + *
+ *

+ * What the status bar shows (managed by {@link RichBuildEventListener}): + *

    + *
  • Currently building modules with active mojo name
  • + *
  • Reactor progress ({@code [n/total]}) and elapsed time
  • + *
  • Active downloads with progress
  • + *
+ * + * @since 4.1.0 + * @see RichBuildEventListener + * @see PlainExecutionEventLogger + */ +public class RichExecutionEventLogger extends AbstractExecutionListener { + + private static final int MAX_LOG_PREFIX_SIZE = 8; // "[ERROR] " + private static final int PROJECT_STATUS_SUFFIX_SIZE = 20; // "SUCCESS [ 0.000 s]" + private static final int MIN_TERMINAL_WIDTH = 60; + private static final int DEFAULT_TERMINAL_WIDTH = 80; + private static final int MAX_TERMINAL_WIDTH = 130; + private static final int MAX_PADDED_BUILD_TIME_DURATION_LENGTH = 9; + + private final MessageBuilderFactory messageBuilderFactory; + private final Logger logger; + private final RichBuildEventListener buildEventListener; + private int terminalWidth; + private int lineLength; + private int maxProjectNameLength; + private int totalProjects; + private int currentVisitedProjectCount; + + public RichExecutionEventLogger( + MessageBuilderFactory messageBuilderFactory, RichBuildEventListener buildEventListener) { + this(messageBuilderFactory, buildEventListener, LoggerFactory.getLogger(RichExecutionEventLogger.class)); + } + + public RichExecutionEventLogger( + MessageBuilderFactory messageBuilderFactory, RichBuildEventListener buildEventListener, Logger logger) { + this(messageBuilderFactory, buildEventListener, logger, -1); + } + + public RichExecutionEventLogger( + MessageBuilderFactory messageBuilderFactory, + RichBuildEventListener buildEventListener, + Logger logger, + int terminalWidth) { + this.logger = Objects.requireNonNull(logger, "logger cannot be null"); + this.messageBuilderFactory = messageBuilderFactory; + this.buildEventListener = Objects.requireNonNull(buildEventListener, "buildEventListener cannot be null"); + this.terminalWidth = terminalWidth; + } + + private void init() { + if (maxProjectNameLength == 0) { + if (terminalWidth < 0) { + terminalWidth = messageBuilderFactory.getTerminalWidth(); + } + terminalWidth = Math.min( + MAX_TERMINAL_WIDTH, + Math.max(terminalWidth <= 0 ? DEFAULT_TERMINAL_WIDTH : terminalWidth, MIN_TERMINAL_WIDTH)); + lineLength = terminalWidth - MAX_LOG_PREFIX_SIZE; + maxProjectNameLength = lineLength - PROJECT_STATUS_SUFFIX_SIZE; + } + } + + private MessageBuilder builder() { + return messageBuilderFactory.builder(); + } + + private static String chars(char c, int count) { + return String.valueOf(c).repeat(Math.max(0, count)); + } + + // ---- Session lifecycle ---- + + @Override + public void projectDiscoveryStarted(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logger.info("Scanning for projects..."); + } + } + + @Override + public void sessionStarted(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + MavenSession session = event.getSession(); + List projects = session.getProjects(); + List allProjects = session.getAllProjects(); + + currentVisitedProjectCount = allProjects.size() - projects.size(); + totalProjects = allProjects.size(); + + // Initialize the status bar + buildEventListener.initReactor(session); + } + } + + @Override + public void sessionEnded(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + + // Tear down the status bar before printing summary + buildEventListener.tearDown(); + + // Write summary directly through the terminal writer (via buildEventListener.log) + // rather than logger.info() — all SLF4J output is routed through + // ProjectBuildLogAppender → projectLogMessage which filters INFO in rich mode. + buildEventListener.log(""); + logResult(event.getSession()); + logStats(event.getSession()); + } + } + + // ---- Module lifecycle ---- + // In rich mode, per-module success lines are suppressed — the status bar already + // shows ✓/●/○ indicators and the [n/total] counter for every module. + // Only FAILURE and SKIPPED scroll above the status bar since they're actionable. + + @Override + public void projectStarted(ExecutionEvent event) { + // Suppressed — the status bar shows active modules + } + + @Override + public void projectSucceeded(ExecutionEvent event) { + // Suppressed — the status bar checkmarks already indicate completion + } + + @Override + public void projectFailed(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logProjectLine(event, "FAILURE"); + } + } + + @Override + public void projectSkipped(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logProjectLine(event, "SKIPPED"); + } + } + + // ---- Mojo lifecycle: suppressed (status bar shows active mojo) ---- + + @Override + public void mojoStarted(ExecutionEvent event) { + // Suppressed — the status bar shows the active mojo + } + + @Override + public void mojoSkipped(ExecutionEvent event) { + if (logger.isWarnEnabled()) { + logger.warn( + "Goal '{}' requires online mode for execution but Maven is currently offline, skipping", + event.getMojoExecution().getGoal()); + } + } + + @Override + public void forkStarted(ExecutionEvent event) { + // Suppressed in rich mode + } + + @Override + public void forkSucceeded(ExecutionEvent event) { + // Suppressed in rich mode + } + + // ---- Formatting helpers (reuses PlainExecutionEventLogger patterns) ---- + + private void logProjectLine(ExecutionEvent event, String status) { + MavenProject project = event.getProject(); + MavenSession session = event.getSession(); + MavenExecutionResult result = session.getResult(); + BuildSummary buildSummary = result.getBuildSummary(project); + + StringBuilder buffer = new StringBuilder(128); + + // Status icon + switch (status) { + case "SUCCESS": + buffer.append(" ✓ "); + break; + case "FAILURE": + buffer.append(" ✗ "); + break; + default: + buffer.append(" ○ "); + break; + } + + buffer.append(project.getName()); + buffer.append(' '); + + if (totalProjects > 1) { + int number; + synchronized (this) { + number = ++currentVisitedProjectCount; + } + String progress = "[" + number + "/" + totalProjects + "]"; + buffer.append(progress); + buffer.append(' '); + } + + // Pad with dots to align status + int effectiveMax = maxProjectNameLength - 3; // account for status icon + if (buffer.length() <= effectiveMax) { + while (buffer.length() < effectiveMax) { + buffer.append('.'); + } + buffer.append(' '); + } + + // Status with color + MessageBuilder mb = builder(); + mb.a(buffer); + switch (status) { + case "SUCCESS": + mb.success(status); + break; + case "FAILURE": + mb.failure(status); + break; + default: + mb.warning(status); + break; + } + + // Duration + if (buildSummary != null) { + mb.a(" ["); + String duration = formatDuration(buildSummary.getExecTime()); + int padSize = MAX_PADDED_BUILD_TIME_DURATION_LENGTH - duration.length(); + if (padSize > 0) { + mb.a(chars(' ', padSize)); + } + mb.a(duration); + mb.a(']'); + } + + buildEventListener.log(mb.toString()); + } + + private void logResult(MavenSession session) { + MessageBuilder buffer = builder(); + if (session.getResult().hasExceptions()) { + buffer.failure("BUILD FAILURE"); + } else { + buffer.success("BUILD SUCCESS"); + } + + int passed = 0; + int failed = 0; + int skipped = 0; + for (MavenProject project : session.getProjects()) { + BuildSummary summary = session.getResult().getBuildSummary(project); + if (summary instanceof BuildSuccess) { + passed++; + } else if (summary instanceof BuildFailure) { + failed++; + } else { + skipped++; + } + } + + buildEventListener.log(buffer.toString()); + + // Compact stats line + if (totalProjects > 1) { + StringBuilder stats = new StringBuilder(); + stats.append(totalProjects).append(" modules"); + stats.append(" | ").append(passed).append(" passed"); + if (failed > 0) { + stats.append(" | ").append(failed).append(" failed"); + } + if (skipped > 0) { + stats.append(" | ").append(skipped).append(" skipped"); + } + buildEventListener.log(stats.toString()); + } + + // Warning/error summary — warnings are suppressed inline in rich mode, + // so the count and a command hint help the user find them. + int warnings = buildEventListener.getWarningCount(); + int errors = buildEventListener.getErrorCount(); + if (warnings > 0 || errors > 0) { + MessageBuilder diag = builder(); + diag.a("Diagnostics: "); + if (warnings > 0) { + diag.warning(warnings + " warning" + (warnings > 1 ? "s" : "")); + } + if (warnings > 0 && errors > 0) { + diag.a(", "); + } + if (errors > 0) { + diag.failure(errors + " error" + (errors > 1 ? "s" : "")); + } + diag.a(" — run ").strong("mvnlog").a(" to see details"); + buildEventListener.log(diag.toString()); + } + } + + private void logStats(MavenSession session) { + Duration time = Duration.between(session.getRequest().getStartInstant(), MonotonicClock.now()); + String wallClock = session.getRequest().getDegreeOfConcurrency() > 1 ? " (Wall Clock)" : ""; + buildEventListener.log("Total time: " + formatDuration(time) + wallClock); + + // On failure, show Maven and Java version to help with bug reports (MNG-7372) + if (session.getResult().hasExceptions()) { + buildEventListener.log("Maven: " + CLIReportingUtils.showVersionMinimal()); + buildEventListener.log("Java: " + System.getProperty("java.version", "") + " (" + + System.getProperty("java.vendor", "") + ")"); + } + + buildEventListener.log("Full report: target/build-reports/build-report-latest.json"); + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java index 7f5768715ae4..9f3983a50660 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java @@ -212,6 +212,18 @@ public Optional color() { return Optional.empty(); } + @Override + public Optional console() { + if (commandLine.hasOption(CLIManager.CONSOLE)) { + if (commandLine.getOptionValue(CLIManager.CONSOLE) != null) { + return Optional.of(commandLine.getOptionValue(CLIManager.CONSOLE)); + } else { + return Optional.of("auto"); + } + } + return Optional.empty(); + } + @Override public Optional offline() { if (commandLine.hasOption(CLIManager.OFFLINE)) { @@ -315,6 +327,7 @@ protected static class CLIManager { public static final String LOG_FILE = "l"; public static final String RAW_STREAMS = "raw-streams"; public static final String COLOR = "color"; + public static final String CONSOLE = "console"; public static final String OFFLINE = "o"; public static final String HELP = "h"; @@ -344,6 +357,7 @@ protected CLIManager() { prepareOptions(options); } + @SuppressWarnings("checkstyle:MethodLength") protected void prepareOptions(org.apache.commons.cli.Options options) { options.addOption(Option.builder(HELP) .longOpt("help") @@ -433,6 +447,16 @@ protected void prepareOptions(org.apache.commons.cli.Options options) { .optionalArg(true) .desc("Defines the color mode of the output. Supported are 'auto', 'always', 'never'.") .get()); + options.addOption(Option.builder() + .longOpt(CONSOLE) + .hasArg() + .optionalArg(true) + .desc("Defines the console output mode. Supported are 'auto' (default)," + + " 'plain', 'rich', 'verbose', 'machine'." + + " In 'auto' mode, CI environments use 'plain'," + + " interactive TTYs use 'rich' (status bar)." + + " 'machine' outputs one JSON line per lifecycle event.") + .get()); options.addOption(Option.builder(OFFLINE) .longOpt("offline") .desc("Work offline") diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LayeredOptions.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LayeredOptions.java index 2f9c367c4786..0b6dbda78756 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LayeredOptions.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LayeredOptions.java @@ -132,6 +132,11 @@ public Optional color() { return returnFirstPresentOrEmpty(Options::color); } + @Override + public Optional console() { + return returnFirstPresentOrEmpty(Options::console); + } + @Override public Optional offline() { return returnFirstPresentOrEmpty(Options::offline); 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 c4dcde7bfd61..2493fa764783 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 @@ -80,7 +80,6 @@ import org.apache.maven.impl.SettingsUtilsV4; import org.apache.maven.jline.FastTerminal; import org.apache.maven.jline.MessageUtils; -import org.apache.maven.logging.AsyncDrainWriter; import org.apache.maven.logging.BuildEventListener; import org.apache.maven.logging.LoggingOutputStream; import org.apache.maven.logging.ProjectBuildLogAppender; @@ -158,6 +157,7 @@ protected int doInvoke(C context) throws Exception { pushUserProperties(context); setupGuiceClassLoading(context); configureLogging(context); + preliminaryInteractiveDetection(context); createTerminal(context); activateLogging(context); helpOrVersionAndMayExit(context); @@ -318,6 +318,30 @@ protected void configureLogging(C context) throws Exception { } } + /** + * Sets {@code context.interactive} based on CLI flags and CI detection before + * {@link #createTerminal(LookupContext)} runs. This is necessary because + * {@code createTerminal} caches the {@link BuildEventListener} (via + * {@link #determineBuildEventListener}), and the console-mode auto-detection + * in subclasses reads {@code context.interactive} to decide between rich/plain/verbose. + * + *

The full settings-based interactive-mode resolution still runs later in + * {@link #settings}, so this is a best-effort early pass using only CLI flags and + * CI environment detection — which is sufficient for the console-mode decision.

+ */ + protected void preliminaryInteractiveDetection(C context) { + if (context.options().forceInteractive().orElse(false)) { + context.interactive = true; + } else if (context.options().nonInteractive().orElse(false)) { + context.interactive = false; + } else if (context.invokerRequest.ciInfo().isPresent()) { + context.interactive = false; + } else { + // Default: assume interactive (settings may refine later) + context.interactive = true; + } + } + protected BuildEventListener determineBuildEventListener(C context) { if (context.buildEventListener == null) { context.buildEventListener = doDetermineBuildEventListener(context); @@ -436,30 +460,24 @@ protected Consumer determineWriter(C context) { } protected Consumer doDetermineWriter(C context) { - Consumer raw; if (context.options().logFile().isPresent()) { Path logFile = context.cwd.resolve(context.options().logFile().get()); try { PrintWriter printWriter = new PrintWriter(Files.newBufferedWriter(logFile), true); context.closeables.add(printWriter); - raw = printWriter::println; + return printWriter::println; } catch (IOException e) { throw new MavenException("Unable to redirect logging to " + logFile, e); } } else { // Given the terminal creation has been offloaded to a different thread, // do not pass directly the terminal writer - raw = msg -> { + return msg -> { PrintWriter pw = context.terminal.writer(); pw.println(msg); pw.flush(); }; } - // Wrap with lock-free async drain to eliminate PrintWriter synchronized contention - // when multiple PhasingExecutor threads log concurrently during parallel model building. - AsyncDrainWriter asyncWriter = new AsyncDrainWriter(raw); - context.closeables.add(asyncWriter); - return asyncWriter; } protected void activateLogging(C context) throws Exception { @@ -569,7 +587,7 @@ protected String describe(Terminal terminal) { protected void preCommands(C context) throws Exception { boolean verbose = context.invokerRequest.effectiveVerbose(); - boolean version = context.options().showVersion().orElse(false) && !Boolean.getBoolean("maven.version.printed"); + boolean version = context.options().showVersion().orElse(false); if (verbose || version) { showVersion(context); } diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java index 0cf448e32562..d624c4363546 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java @@ -48,6 +48,11 @@ import org.apache.maven.api.services.model.ModelProcessor; import org.apache.maven.api.toolchain.PersistedToolchains; import org.apache.maven.cling.event.ExecutionEventLogger; +import org.apache.maven.cling.event.MachineBuildEventListener; +import org.apache.maven.cling.event.MachineExecutionEventLogger; +import org.apache.maven.cling.event.PlainExecutionEventLogger; +import org.apache.maven.cling.event.RichBuildEventListener; +import org.apache.maven.cling.event.RichExecutionEventLogger; import org.apache.maven.cling.invoker.CliUtils; import org.apache.maven.cling.invoker.LookupContext; import org.apache.maven.cling.invoker.LookupInvoker; @@ -66,6 +71,7 @@ import org.apache.maven.execution.ProjectActivation; import org.apache.maven.jline.MessageUtils; import org.apache.maven.lifecycle.LifecycleExecutionException; +import org.apache.maven.logging.BuildEventListener; import org.apache.maven.logging.LoggingExecutionListener; import org.apache.maven.logging.MavenTransferListener; import org.apache.maven.project.MavenProject; @@ -344,21 +350,106 @@ protected String determineGlobalChecksumPolicy(MavenContext context) { } protected ExecutionListener determineExecutionListener(MavenContext context) { - ExecutionListener listener = new ExecutionEventLogger(context.invokerRequest.messageBuilderFactory()); + ExecutionListener listener; + String consoleMode = determineConsoleMode(context); + switch (consoleMode) { + case "machine": + BuildEventListener machineBel = determineBuildEventListener(context); + if (machineBel instanceof MachineBuildEventListener machineListener) { + listener = new MachineExecutionEventLogger(machineListener); + } else { + // Fallback if machine listener couldn't be created + listener = new PlainExecutionEventLogger(context.invokerRequest.messageBuilderFactory()); + } + break; + case "rich": + BuildEventListener richBel = determineBuildEventListener(context); + if (richBel instanceof RichBuildEventListener richListener) { + listener = + new RichExecutionEventLogger(context.invokerRequest.messageBuilderFactory(), richListener); + } else { + // Fallback if status bar couldn't be created + listener = new PlainExecutionEventLogger(context.invokerRequest.messageBuilderFactory()); + } + break; + case "plain": + listener = new PlainExecutionEventLogger(context.invokerRequest.messageBuilderFactory()); + break; + default: + listener = new ExecutionEventLogger(context.invokerRequest.messageBuilderFactory()); + break; + } if (context.eventSpyDispatcher != null) { listener = context.eventSpyDispatcher.chainListener(listener); } return new LoggingExecutionListener(listener, determineBuildEventListener(context)); } + @Override + protected BuildEventListener doDetermineBuildEventListener(MavenContext context) { + String consoleMode = determineConsoleMode(context); + if ("machine".equals(consoleMode)) { + return new MachineBuildEventListener(determineWriter(context)); + } + if ("rich".equals(consoleMode) && context.terminal != null) { + return new RichBuildEventListener(context.terminal, determineWriter(context)); + } + return super.doDetermineBuildEventListener(context); + } + + /** + * Resolves the effective console mode from the {@code --console} flag and CI/TTY detection. + *

+ * Resolution order: + *

    + *
  1. Explicit {@code --console=plain}, {@code --console=verbose}, {@code --console=rich}, + * or {@code --console=machine} — always honored
  2. + *
  3. {@code --console=auto} (or unset) — selects mode based on environment: + *
      + *
    • CI detected → "plain"
    • + *
    • Interactive TTY → "rich"
    • + *
    • Otherwise → "verbose"
    • + *
    + *
  4. + *
+ */ + String determineConsoleMode(MavenContext context) { + String consoleMode = context.options().console().orElse("auto"); + if ("plain".equalsIgnoreCase(consoleMode) + || "verbose".equalsIgnoreCase(consoleMode) + || "rich".equalsIgnoreCase(consoleMode) + || "machine".equalsIgnoreCase(consoleMode)) { + return consoleMode.toLowerCase(); + } + // "auto" mode: CI → plain, interactive TTY → rich, otherwise → verbose + if (!"auto".equalsIgnoreCase(consoleMode)) { + context.logger.warn("Unknown --console value '" + consoleMode + + "'; expected one of: plain, rich, verbose, machine, auto. Falling back to auto-detection."); + } + if (context.invokerRequest.ciInfo().isPresent() + && !context.options().forceInteractive().orElse(false)) { + return "plain"; + } + if (context.interactive && context.terminal != null && !context.invokerRequest.embedded()) { + return "rich"; + } + return "verbose"; + } + protected TransferListener determineTransferListener(MavenContext context, boolean noTransferProgress) { boolean quiet = context.options().quiet().orElse(false); boolean logFile = context.options().logFile().isPresent(); boolean quietCI = context.invokerRequest.ciInfo().isPresent() && !context.options().forceInteractive().orElse(false); + String mode = determineConsoleMode(context); + boolean richMode = "rich".equals(mode); + boolean machineMode = "machine".equals(mode); TransferListener delegate; - if (quiet || noTransferProgress || quietCI) { + if (quiet || noTransferProgress || quietCI || richMode || machineMode) { + // In rich mode, transfer progress is shown in the JLine status bar. + // In machine mode, transfer events are emitted as JSON lines. + // In both cases, suppress the console transfer listener. delegate = new QuietMavenTransferListener(); } else if (context.interactive && !logFile) { if (context.simplexTransferListener == null) { diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/event/ExecutionEventLoggerTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/ExecutionEventLoggerTest.java index bf863990283c..4b7eab152743 100644 --- a/impl/maven-cli/src/test/java/org/apache/maven/cling/event/ExecutionEventLoggerTest.java +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/ExecutionEventLoggerTest.java @@ -377,8 +377,8 @@ void testSessionEndedSuccessWithSkippedModules() { inOrder.verify(logger).info("------------------------------------------------------------------------"); inOrder.verify(logger).info("Reactor Summary for Maven Project artifact1 3.5.4-SNAPSHOT:"); inOrder.verify(logger).info(""); - inOrder.verify(logger).info("Maven Project artifact2 ............................ SKIPPED"); inOrder.verify(logger).info("Maven Project artifact1 ............................ SUCCESS [ 1.000 s]"); + inOrder.verify(logger).info("Maven Project artifact2 ............................ SKIPPED"); inOrder.verify(logger).info("Maven Project artifact3 ............................ SUCCESS [ 3.000 s]"); inOrder.verify(logger).info("------------------------------------------------------------------------"); inOrder.verify(logger).info("BUILD SUCCESS"); @@ -423,9 +423,7 @@ void testSessionEndedFailureMixedWithSkippedModules() { inOrder.verify(logger).info("------------------------------------------------------------------------"); inOrder.verify(logger).info("Reactor Summary for Maven Project artifact1 3.5.4-SNAPSHOT:"); inOrder.verify(logger).info(""); - inOrder.verify(logger).info("Maven Project artifact2 ............................ SKIPPED"); - inOrder.verify(logger).info("Maven Project artifact1 ............................ SUCCESS [ 1.000 s]"); - inOrder.verify(logger).info("Maven Project artifact3 ............................ SUCCESS [ 3.000 s]"); + inOrder.verify(logger).info("..."); inOrder.verify(logger).info("------------------------------------------------------------------------"); inOrder.verify(logger).error("BUILD FAILURE"); inOrder.verify(logger).info("------------------------------------------------------------------------"); @@ -470,9 +468,9 @@ void testSessionEndedFailureMultimodule() { inOrder.verify(logger).info("------------------------------------------------------------------------"); inOrder.verify(logger).info("Reactor Summary for Maven Project artifact1 3.5.4-SNAPSHOT:"); inOrder.verify(logger).info(""); - inOrder.verify(logger).info("Maven Project artifact3 ............................ SKIPPED"); - inOrder.verify(logger).info("Maven Project artifact1 ............................ SUCCESS [ 1.000 s]"); + inOrder.verify(logger).info("..."); inOrder.verify(logger).error("Maven Project artifact2 ............................ FAILURE [ 2.000 s]"); + inOrder.verify(logger).info("..."); inOrder.verify(logger).info("------------------------------------------------------------------------"); inOrder.verify(logger).error("BUILD FAILURE"); inOrder.verify(logger).info("------------------------------------------------------------------------"); @@ -525,12 +523,11 @@ void testSessionEndedFailureMultimoduleWithSeparatedFailures() { inOrder.verify(logger).info("------------------------------------------------------------------------"); inOrder.verify(logger).info("Reactor Summary for Maven Project artifact1 3.5.4-SNAPSHOT:"); inOrder.verify(logger).info(""); - inOrder.verify(logger).info("Maven Project artifact6 ............................ SKIPPED"); - inOrder.verify(logger).info("Maven Project artifact1 ............................ SUCCESS [ 1.000 s]"); - inOrder.verify(logger).info("Maven Project artifact3 ............................ SUCCESS [ 3.000 s]"); - inOrder.verify(logger).info("Maven Project artifact4 ............................ SUCCESS [ 4.000 s]"); + inOrder.verify(logger).info("..."); inOrder.verify(logger).error("Maven Project artifact2 ............................ FAILURE [ 2.000 s]"); + inOrder.verify(logger).info("..."); inOrder.verify(logger).error("Maven Project artifact5 ............................ FAILURE [ 5.000 s]"); + inOrder.verify(logger).info("..."); inOrder.verify(logger).info("------------------------------------------------------------------------"); inOrder.verify(logger).error("BUILD FAILURE"); inOrder.verify(logger).info("------------------------------------------------------------------------"); diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineBuildEventListenerTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineBuildEventListenerTest.java new file mode 100644 index 000000000000..67bbe36ff3bc --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineBuildEventListenerTest.java @@ -0,0 +1,239 @@ +/* + * 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.event; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.LogLevel; +import org.apache.maven.internal.build.DefaultLogEvent; +import org.eclipse.aether.transfer.TransferEvent; +import org.eclipse.aether.transfer.TransferResource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.MockitoSession; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link MachineBuildEventListener}. + */ +class MachineBuildEventListenerTest { + + private MockitoSession mockitoSession; + private List capturedOutput; + private MachineBuildEventListener listener; + + @BeforeEach + void beforeEach() { + mockitoSession = Mockito.mockitoSession().startMocking(); + capturedOutput = new ArrayList<>(); + listener = new MachineBuildEventListener(capturedOutput::add); + } + + @org.junit.jupiter.api.AfterEach + void afterEach() { + mockitoSession.finishMocking(); + } + + @Test + void testLogEmitsJsonLine() { + listener.log("Hello world"); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.startsWith("{"), "Should be JSON object"); + assertTrue(json.endsWith("}"), "Should be JSON object"); + assertTrue(json.contains("\"event\":\"log\""), "Should have event type"); + assertTrue(json.contains("\"message\":\"Hello world\""), "Should have message"); + assertTrue(json.contains("\"timestamp\":\""), "Should have timestamp"); + } + + @Test + void testProjectLogMessageIncludesModule() { + listener.projectLogMessage( + "my-core", + new DefaultLogEvent( + MonotonicClock.now(), + LogLevel.INFO, + "Compiling 42 source files", + "compiler", + null, + "[INFO] Compiling 42 source files")); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"log\"")); + assertTrue(json.contains("\"level\":\"INFO\"")); + assertTrue(json.contains("\"module\":\"my-core\"")); + assertTrue(json.contains("\"message\":\"Compiling 42 source files\"")); + } + + @Test + void testExecutionFailureEmitsJsonLine() { + listener.executionFailure("my-core", true, "Compilation failed"); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"execution.failure\"")); + assertTrue(json.contains("\"module\":\"my-core\"")); + assertTrue(json.contains("\"halted\":true")); + assertTrue(json.contains("\"error\":\"Compilation failed\"")); + } + + @Test + void testTransferStartedEmitsJsonLine() { + TransferResource resource = mock(TransferResource.class); + when(resource.getResourceName()).thenReturn("org/apache/maven/core/4.1.0/core-4.1.0.jar"); + when(resource.getContentLength()).thenReturn(524288L); + + TransferEvent event = mock(TransferEvent.class); + when(event.getType()).thenReturn(TransferEvent.EventType.STARTED); + when(event.getResource()).thenReturn(resource); + + listener.transfer("my-core", event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"transfer.started\"")); + assertTrue(json.contains("\"artifact\":\"core-4.1.0.jar\"")); + assertTrue(json.contains("\"size\":524288")); + assertTrue(json.contains("\"module\":\"my-core\"")); + } + + @Test + void testTransferProgressedEmitsJsonLine() { + TransferResource resource = mock(TransferResource.class); + when(resource.getResourceName()).thenReturn("org/apache/maven/core/4.1.0/core-4.1.0.jar"); + when(resource.getContentLength()).thenReturn(524288L); + + TransferEvent event = mock(TransferEvent.class); + when(event.getType()).thenReturn(TransferEvent.EventType.PROGRESSED); + when(event.getResource()).thenReturn(resource); + when(event.getTransferredBytes()).thenReturn(262144L); + + listener.transfer("my-core", event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"transfer.progressed\"")); + assertTrue(json.contains("\"transferred\":262144")); + assertTrue(json.contains("\"total\":524288")); + } + + @Test + void testTransferCompletedEmitsJsonLine() { + TransferResource resource = mock(TransferResource.class); + when(resource.getResourceName()).thenReturn("org/apache/maven/core/4.1.0/core-4.1.0.jar"); + when(resource.getContentLength()).thenReturn(524288L); + + TransferEvent event = mock(TransferEvent.class); + when(event.getType()).thenReturn(TransferEvent.EventType.SUCCEEDED); + when(event.getResource()).thenReturn(resource); + when(event.getTransferredBytes()).thenReturn(524288L); + + listener.transfer("my-core", event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"transfer.completed\"")); + assertTrue(json.contains("\"transferred\":524288")); + } + + @Test + void testTransferFailedEmitsJsonLine() { + TransferResource resource = mock(TransferResource.class); + when(resource.getResourceName()).thenReturn("org/apache/maven/core/4.1.0/core-4.1.0.jar"); + when(resource.getContentLength()).thenReturn(524288L); + + TransferEvent event = mock(TransferEvent.class); + when(event.getType()).thenReturn(TransferEvent.EventType.FAILED); + when(event.getResource()).thenReturn(resource); + when(event.getException()).thenReturn(new RuntimeException("Connection timed out")); + + listener.transfer("my-core", event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"transfer.failed\"")); + assertTrue(json.contains("\"error\":\"Connection timed out\"")); + } + + @Test + void testJsonEscapingInMessages() { + listener.log("Message with \"quotes\" and \\backslash and\nnewline"); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + // Verify the JSON is properly escaped + assertTrue(json.contains("\\\"quotes\\\""), "Quotes should be escaped"); + assertTrue(json.contains("\\\\backslash"), "Backslash should be escaped"); + assertTrue(json.contains("\\n"), "Newline should be escaped"); + // Verify it's parseable as a single line (no raw newlines) + assertFalse(json.contains("\n"), "JSON line should not contain raw newlines"); + } + + @Test + void testEmitEventIsSynchronized() throws Exception { + // Verify that concurrent calls to emitEvent don't interleave + List output = new ArrayList<>(); + MachineBuildEventListener concurrentListener = new MachineBuildEventListener(output::add); + + Thread t1 = new Thread(() -> { + for (int i = 0; i < 100; i++) { + concurrentListener.log("Thread1-" + i); + } + }); + Thread t2 = new Thread(() -> { + for (int i = 0; i < 100; i++) { + concurrentListener.log("Thread2-" + i); + } + }); + + t1.start(); + t2.start(); + t1.join(); + t2.join(); + + assertEquals(200, output.size(), "All events should be emitted"); + // Each line should be a complete JSON object + for (String line : output) { + assertTrue(line.startsWith("{") && line.endsWith("}"), "Each line should be a complete JSON object"); + } + } + + @Test + void testNoOpMethods() throws Exception { + // These should not produce any output (handled by MachineExecutionEventLogger) + listener.sessionStarted(null); + listener.projectStarted("my-core"); + listener.projectFinished("my-core"); + listener.mojoStarted(null); + listener.finish(0); + listener.fail(new RuntimeException("error")); + + assertEquals(0, capturedOutput.size(), "No-op methods should not produce output"); + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineExecutionEventLoggerTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineExecutionEventLoggerTest.java new file mode 100644 index 000000000000..825f81aa3af0 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineExecutionEventLoggerTest.java @@ -0,0 +1,445 @@ +/* + * 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.event; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.DefaultMavenExecutionRequest; +import org.apache.maven.execution.DefaultMavenExecutionResult; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.plugin.MojoExecution; +import org.apache.maven.project.MavenProject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.MockitoSession; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link MachineExecutionEventLogger}. + */ +class MachineExecutionEventLoggerTest { + + private MockitoSession mockitoSession; + private List capturedOutput; + private MachineBuildEventListener machineBel; + private MachineExecutionEventLogger logger; + + @BeforeEach + void beforeEach() { + mockitoSession = Mockito.mockitoSession().startMocking(); + capturedOutput = new ArrayList<>(); + machineBel = new MachineBuildEventListener(capturedOutput::add); + logger = new MachineExecutionEventLogger(machineBel); + } + + @AfterEach + void afterEach() { + mockitoSession.finishMocking(); + } + + @Test + void testSessionStartedEmitsBuildStarted() { + MavenProject project1 = createProject("API", "api"); + MavenProject project2 = createProject("Core", "core"); + + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setGoals(List.of("clean", "install")); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project1, project2)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2)); + when(session.getRequest()).thenReturn(request); + + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getSession()).thenReturn(session); + + logger.sessionStarted(event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"build.started\"")); + assertTrue(json.contains("\"projectCount\":2")); + assertTrue(json.contains("\"goals\":\"clean install\"")); + } + + @Test + void testSessionEndedEmitsBuildFinished() { + MavenProject project1 = createProject("API", "api"); + MavenProject project2 = createProject("Core", "core"); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + result.addBuildSummary(new BuildSuccess(project1, 1000)); + result.addBuildSummary(new BuildSuccess(project2, 2000)); + + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setGoals(List.of("install")); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project1, project2)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2)); + when(session.getResult()).thenReturn(result); + when(session.getRequest()).thenReturn(request); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + logger.sessionStarted(sessionEvent); + capturedOutput.clear(); + + logger.sessionEnded(sessionEvent); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"build.finished\"")); + assertTrue(json.contains("\"status\":\"SUCCESS\"")); + assertTrue(json.contains("\"total\":2")); + assertTrue(json.contains("\"passed\":2")); + assertTrue(json.contains("\"failed\":0")); + assertTrue(json.contains("\"skipped\":0")); + assertTrue(json.contains("\"duration\":")); + } + + @Test + void testSessionEndedWithFailures() { + MavenProject project1 = createProject("API", "api"); + MavenProject project2 = createProject("Core", "core"); + MavenProject project3 = createProject("CLI", "cli"); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + result.addBuildSummary(new BuildSuccess(project1, 1000)); + result.addBuildSummary(new BuildFailure(project2, 2000, new Exception("Compile error"))); + result.addException(new Exception("Compile error")); + + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setGoals(List.of("install")); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getResult()).thenReturn(result); + when(session.getRequest()).thenReturn(request); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + logger.sessionStarted(sessionEvent); + capturedOutput.clear(); + + logger.sessionEnded(sessionEvent); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"status\":\"FAILURE\"")); + assertTrue(json.contains("\"passed\":1")); + assertTrue(json.contains("\"failed\":1")); + assertTrue(json.contains("\"skipped\":1")); + } + + @Test + void testProjectStartedEmitsModuleStarted() { + MavenProject project = createProject("Maven Core", "maven-core"); + setupSessionForProjectEvents(project); + + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + + logger.projectStarted(event); + + // build.started + module.started + assertEquals(2, capturedOutput.size()); + String json = capturedOutput.get(1); + assertTrue(json.contains("\"event\":\"module.started\"")); + assertTrue(json.contains("\"module\":\"Maven Core\"")); + assertTrue(json.contains("\"groupId\":\"org.apache.maven\"")); + assertTrue(json.contains("\"artifactId\":\"maven-core\"")); + assertTrue(json.contains("\"version\":\"4.1.0-SNAPSHOT\"")); + assertTrue(json.contains("\"index\":1")); + assertTrue(json.contains("\"total\":1")); + } + + @Test + void testProjectSucceededEmitsModuleSucceeded() { + MavenProject project = createProject("Core", "core"); + MavenSession session = setupSessionForProjectEvents(project); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + result.addBuildSummary(new BuildSuccess(project, 2100)); + when(session.getResult()).thenReturn(result); + + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + when(event.getSession()).thenReturn(session); + + logger.projectSucceeded(event); + + // build.started + module.succeeded + assertEquals(2, capturedOutput.size()); + String json = capturedOutput.get(1); + assertTrue(json.contains("\"event\":\"module.succeeded\"")); + assertTrue(json.contains("\"module\":\"Core\"")); + assertTrue(json.contains("\"duration\":2.1")); + } + + @Test + void testProjectFailedEmitsModuleFailed() { + MavenProject project = createProject("Core", "core"); + MavenSession session = setupSessionForProjectEvents(project); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + result.addBuildSummary(new BuildFailure(project, 5000, new Exception("Compile error"))); + when(session.getResult()).thenReturn(result); + + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + when(event.getSession()).thenReturn(session); + when(event.getException()).thenReturn(new Exception("Compile error")); + + logger.projectFailed(event); + + assertEquals(2, capturedOutput.size()); + String json = capturedOutput.get(1); + assertTrue(json.contains("\"event\":\"module.failed\"")); + assertTrue(json.contains("\"duration\":5.0")); + assertTrue(json.contains("\"error\":\"Compile error\"")); + } + + @Test + void testMojoStartedEmitsJsonLine() { + MavenProject project = createProject("Core", "core"); + MojoExecution mojo = createMojoExecution("maven-compiler-plugin", "compile", "compile", "default-compile"); + + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + when(event.getMojoExecution()).thenReturn(mojo); + + logger.mojoStarted(event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"mojo.started\"")); + assertTrue(json.contains("\"module\":\"Core\"")); + assertTrue(json.contains("\"plugin\":\"maven-compiler-plugin\"")); + assertTrue(json.contains("\"goal\":\"compile\"")); + assertTrue(json.contains("\"phase\":\"compile\"")); + assertTrue(json.contains("\"executionId\":\"default-compile\"")); + } + + @Test + void testMojoSucceededIncludesDuration() { + MavenProject project = createProject("Core", "core"); + MojoExecution mojo = createMojoExecution("maven-compiler-plugin", "compile", "compile", "default-compile"); + + ExecutionEvent startEvent = mock(ExecutionEvent.class); + when(startEvent.getProject()).thenReturn(project); + when(startEvent.getMojoExecution()).thenReturn(mojo); + + ExecutionEvent endEvent = mock(ExecutionEvent.class); + when(endEvent.getProject()).thenReturn(project); + when(endEvent.getMojoExecution()).thenReturn(mojo); + + logger.mojoStarted(startEvent); + capturedOutput.clear(); + + logger.mojoSucceeded(endEvent); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"mojo.succeeded\"")); + assertTrue(json.contains("\"duration\":")); + } + + @Test + void testMojoFailedIncludesError() { + MavenProject project = createProject("Core", "core"); + MojoExecution mojo = createMojoExecution("maven-compiler-plugin", "compile", "compile", "default-compile"); + + ExecutionEvent startEvent = mock(ExecutionEvent.class); + when(startEvent.getProject()).thenReturn(project); + when(startEvent.getMojoExecution()).thenReturn(mojo); + + ExecutionEvent endEvent = mock(ExecutionEvent.class); + when(endEvent.getProject()).thenReturn(project); + when(endEvent.getMojoExecution()).thenReturn(mojo); + when(endEvent.getException()).thenReturn(new Exception("Cannot find symbol: class Foo")); + + logger.mojoStarted(startEvent); + capturedOutput.clear(); + + logger.mojoFailed(endEvent); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"mojo.failed\"")); + assertTrue(json.contains("\"error\":\"Cannot find symbol: class Foo\"")); + } + + @Test + void testMojoSkippedEmitsJsonLine() { + MavenProject project = createProject("Core", "core"); + MojoExecution mojo = createMojoExecution("maven-deploy-plugin", "deploy", "deploy", "default-deploy"); + + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + when(event.getMojoExecution()).thenReturn(mojo); + + logger.mojoSkipped(event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"mojo.skipped\"")); + assertTrue(json.contains("\"plugin\":\"maven-deploy-plugin\"")); + assertTrue(json.contains("\"goal\":\"deploy\"")); + } + + @Test + void testMultiModuleLifecycle() { + MavenProject project1 = createProject("API", "api"); + MavenProject project2 = createProject("Core", "core"); + MavenProject project3 = createProject("CLI", "cli"); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + result.addBuildSummary(new BuildSuccess(project1, 1000)); + result.addBuildSummary(new BuildSuccess(project2, 3000)); + result.addBuildSummary(new BuildSuccess(project3, 2000)); + + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setGoals(List.of("install")); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getResult()).thenReturn(result); + when(session.getRequest()).thenReturn(request); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + // Full lifecycle + logger.sessionStarted(sessionEvent); + + for (MavenProject p : List.of(project1, project2, project3)) { + ExecutionEvent pe = mock(ExecutionEvent.class); + when(pe.getProject()).thenReturn(p); + when(pe.getSession()).thenReturn(session); + logger.projectStarted(pe); + logger.projectSucceeded(pe); + } + + logger.sessionEnded(sessionEvent); + + // build.started + 3*(module.started + module.succeeded) + build.finished = 8 + assertEquals(8, capturedOutput.size()); + + // First event is build.started + assertTrue(capturedOutput.get(0).contains("\"event\":\"build.started\"")); + // Check module indices + assertTrue(capturedOutput.get(1).contains("\"index\":1")); + assertTrue(capturedOutput.get(3).contains("\"index\":2")); + assertTrue(capturedOutput.get(5).contains("\"index\":3")); + // Last event is build.finished + assertTrue(capturedOutput.get(7).contains("\"event\":\"build.finished\"")); + } + + @Test + void testAllOutputIsValidJsonLines() { + MavenProject project = createProject("Core", "core"); + MavenSession session = setupSessionForProjectEvents(project); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + result.addBuildSummary(new BuildSuccess(project, 1000)); + when(session.getResult()).thenReturn(result); + + MojoExecution mojo = createMojoExecution("maven-compiler-plugin", "compile", "compile", "default-compile"); + + ExecutionEvent projectEvent = mock(ExecutionEvent.class); + when(projectEvent.getProject()).thenReturn(project); + when(projectEvent.getSession()).thenReturn(session); + when(projectEvent.getMojoExecution()).thenReturn(mojo); + + // Generate various events + logger.projectStarted(projectEvent); + logger.mojoStarted(projectEvent); + machineBel.log("Some log message"); + logger.mojoSucceeded(projectEvent); + logger.projectSucceeded(projectEvent); + + // All lines should be valid JSON (start with { and end with }) + for (String line : capturedOutput) { + assertTrue(line.startsWith("{"), "Should start with {: " + line); + assertTrue(line.endsWith("}"), "Should end with }: " + line); + // Should not contain raw newlines + assertFalse(line.contains("\n"), "Should not contain raw newlines: " + line); + assertFalse(line.contains("\r"), "Should not contain raw carriage returns: " + line); + } + } + + // ---- Helpers ---- + + private static MavenProject createProject(String name, String artifactId) { + MavenProject project = mock(MavenProject.class); + lenient().when(project.getName()).thenReturn(name); + lenient().when(project.getArtifactId()).thenReturn(artifactId); + lenient().when(project.getGroupId()).thenReturn("org.apache.maven"); + lenient().when(project.getVersion()).thenReturn("4.1.0-SNAPSHOT"); + lenient().when(project.getPackaging()).thenReturn("jar"); + return project; + } + + private static MojoExecution createMojoExecution(String artifactId, String goal, String phase, String executionId) { + MojoExecution mojo = mock(MojoExecution.class); + lenient().when(mojo.getArtifactId()).thenReturn(artifactId); + lenient().when(mojo.getGoal()).thenReturn(goal); + lenient().when(mojo.getLifecyclePhase()).thenReturn(phase); + lenient().when(mojo.getExecutionId()).thenReturn(executionId); + return mojo; + } + + private MavenSession setupSessionForProjectEvents(MavenProject project) { + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setGoals(List.of("install")); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + lenient().when(session.getResult()).thenReturn(result); + when(session.getRequest()).thenReturn(request); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + logger.sessionStarted(sessionEvent); + + return session; + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/event/PlainExecutionEventLoggerTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/PlainExecutionEventLoggerTest.java new file mode 100644 index 000000000000..d1cf8e231e32 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/PlainExecutionEventLoggerTest.java @@ -0,0 +1,317 @@ +/* + * 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.event; + +import java.util.List; + +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.DefaultMavenExecutionRequest; +import org.apache.maven.execution.DefaultMavenExecutionResult; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.jline.JLineMessageBuilderFactory; +import org.apache.maven.jline.MessageUtils; +import org.apache.maven.project.MavenProject; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.mockito.Mockito; +import org.mockito.MockitoSession; +import org.slf4j.Logger; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.matches; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link PlainExecutionEventLogger} — the compact one-line-per-module renderer. + */ +class PlainExecutionEventLoggerTest { + + private MockitoSession mockitoSession; + + private Logger logger; + private PlainExecutionEventLogger plainLogger; + private final JLineMessageBuilderFactory messageBuilderFactory = new JLineMessageBuilderFactory(); + + @BeforeAll + static void setUp() { + MessageUtils.setColorEnabled(false); + } + + @AfterAll + static void tearDown() { + MessageUtils.setColorEnabled(true); + } + + @BeforeEach + void beforeEach() { + mockitoSession = Mockito.mockitoSession().startMocking(); + logger = mock(Logger.class); + lenient().when(logger.isInfoEnabled()).thenReturn(true); + lenient().when(logger.isWarnEnabled()).thenReturn(true); + plainLogger = new PlainExecutionEventLogger(messageBuilderFactory, logger); + } + + @AfterEach + void afterEach() { + mockitoSession.finishMocking(); + } + + @Test + void testProjectStartedSuppressed() { + // In plain mode, projectStarted should produce NO output + ExecutionEvent event = mock(ExecutionEvent.class); + + plainLogger.projectStarted(event); + + // Verify no logger calls were made + verify(logger, never()).info(anyString()); + } + + @Test + void testMojoStartedSuppressed() { + // In plain mode, mojoStarted should produce NO output + ExecutionEvent event = mock(ExecutionEvent.class); + + plainLogger.mojoStarted(event); + + verify(logger, never()).info(anyString()); + } + + @Test + void testSingleProjectSucceeded() { + MavenProject project = generateMavenProject("Maven Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project, 2100)); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + ExecutionEvent projectEvent = mock(ExecutionEvent.class); + when(projectEvent.getProject()).thenReturn(project); + when(projectEvent.getSession()).thenReturn(session); + + plainLogger.sessionStarted(sessionEvent); + plainLogger.projectSucceeded(projectEvent); + + // Single project: no progress counter + verify(logger).info(matches("Maven Core.*SUCCESS.*2\\.1")); + } + + @Test + void testMultiModuleProjectOneLinePerModule() { + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + MavenProject project3 = generateMavenProject("CLI"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project1, 1000)); + executionResult.addBuildSummary(new BuildSuccess(project2, 3000)); + executionResult.addBuildSummary(new BuildSuccess(project3, 2000)); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + plainLogger.sessionStarted(sessionEvent); + + // Simulate lifecycle + ExecutionEvent event1 = mockProjectEvent(project1, session); + ExecutionEvent event2 = mockProjectEvent(project2, session); + ExecutionEvent event3 = mockProjectEvent(project3, session); + + plainLogger.projectSucceeded(event1); + plainLogger.projectSucceeded(event2); + plainLogger.projectSucceeded(event3); + + // Multi-module: each line has progress counter [n/total] + InOrder inOrder = inOrder(logger); + inOrder.verify(logger).info(matches("API.*\\[1/3\\].*SUCCESS.*1\\.0")); + inOrder.verify(logger).info(matches("Core.*\\[2/3\\].*SUCCESS.*3\\.0")); + inOrder.verify(logger).info(matches("CLI.*\\[3/3\\].*SUCCESS.*2\\.0")); + } + + @Test + void testProjectFailedShowsFailure() { + MavenProject project = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildFailure(project, 5000, new Exception("Compile error"))); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + plainLogger.sessionStarted(sessionEvent); + + ExecutionEvent event = mockProjectEvent(project, session); + plainLogger.projectFailed(event); + + verify(logger).info(matches("Core.*FAILURE.*5\\.0")); + } + + @Test + void testSessionEndedShowsSummary() { + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project1, 1000)); + executionResult.addBuildSummary(new BuildSuccess(project2, 2000)); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + when(session.getProjects()).thenReturn(List.of(project1, project2)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2)); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + plainLogger.sessionStarted(sessionEvent); + plainLogger.sessionEnded(sessionEvent); + + // Verify BUILD SUCCESS and stats + InOrder inOrder = inOrder(logger); + inOrder.verify(logger).info(""); + inOrder.verify(logger).info("BUILD SUCCESS"); + inOrder.verify(logger).info(matches("2 modules.*2 passed")); + inOrder.verify(logger).info(eq("Total time: {}{}"), anyString(), anyString()); + inOrder.verify(logger).info("Full report: target/build-reports/build-report-latest.json"); + } + + @Test + void testSessionEndedWithFailures() { + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + MavenProject project3 = generateMavenProject("CLI"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project1, 1000)); + executionResult.addBuildSummary(new BuildFailure(project2, 2000, new Exception("Error"))); + executionResult.addException(new Exception("Error")); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + when(session.getProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + plainLogger.sessionStarted(sessionEvent); + plainLogger.sessionEnded(sessionEvent); + + InOrder inOrder = inOrder(logger); + inOrder.verify(logger).info(""); + inOrder.verify(logger).error("BUILD FAILURE"); + inOrder.verify(logger).info(matches("3 modules.*1 passed.*1 failed.*1 skipped")); + } + + @Test + void testMojoSkippedStillWarns() { + ExecutionEvent event = mock(ExecutionEvent.class); + var mojoExec = mock(org.apache.maven.plugin.MojoExecution.class); + when(mojoExec.getGoal()).thenReturn("deploy"); + when(event.getMojoExecution()).thenReturn(mojoExec); + + plainLogger.mojoSkipped(event); + + verify(logger).warn(anyString(), eq("deploy")); + } + + @Test + void testResumeFromProgress() { + // When resuming, allProjects > projects (some already built) + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + MavenProject project3 = generateMavenProject("CLI"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project2, 1000)); + executionResult.addBuildSummary(new BuildSuccess(project3, 2000)); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project2, project3)); // resumed from project2 + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + plainLogger.sessionStarted(sessionEvent); + + ExecutionEvent event2 = mockProjectEvent(project2, session); + ExecutionEvent event3 = mockProjectEvent(project3, session); + + plainLogger.projectSucceeded(event2); + plainLogger.projectSucceeded(event3); + + // Progress should start from 2/3, not 1/3 + InOrder inOrder = inOrder(logger); + inOrder.verify(logger).info(matches("Core.*\\[2/3\\].*SUCCESS")); + inOrder.verify(logger).info(matches("CLI.*\\[3/3\\].*SUCCESS")); + } + + // ---- Helpers ---- + + private static MavenProject generateMavenProject(String projectName) { + MavenProject project = mock(MavenProject.class); + lenient().when(project.getPackaging()).thenReturn("jar"); + lenient().when(project.getVersion()).thenReturn("4.1.0-SNAPSHOT"); + lenient().when(project.getName()).thenReturn(projectName); + return project; + } + + private static ExecutionEvent mockProjectEvent(MavenProject project, MavenSession session) { + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + when(event.getSession()).thenReturn(session); + return event; + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichBuildEventListenerTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichBuildEventListenerTest.java new file mode 100644 index 000000000000..1c7dafe2e950 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichBuildEventListenerTest.java @@ -0,0 +1,332 @@ +/* + * 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.event; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.List; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.LogLevel; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.internal.build.DefaultLogEvent; +import org.apache.maven.project.MavenProject; +import org.eclipse.aether.transfer.TransferEvent; +import org.eclipse.aether.transfer.TransferResource; +import org.jline.terminal.Size; +import org.jline.terminal.Terminal; +import org.jline.terminal.impl.DumbTerminal; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.MockitoSession; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link RichBuildEventListener}. + */ +class RichBuildEventListenerTest { + + private MockitoSession mockitoSession; + private Terminal terminal; + private ByteArrayOutputStream terminalOutput; + private RichBuildEventListener listener; + + @BeforeEach + void beforeEach() throws Exception { + mockitoSession = Mockito.mockitoSession().startMocking(); + terminalOutput = new ByteArrayOutputStream(); + // DumbTerminal: supported=false (fallback mode), output goes to terminalOutput + terminal = new DumbTerminal(new ByteArrayInputStream(new byte[0]), terminalOutput); + terminal.setSize(new Size(120, 40)); + listener = new RichBuildEventListener(terminal, msg -> {}); + } + + @AfterEach + void afterEach() throws Exception { + terminal.close(); + mockitoSession.finishMocking(); + } + + @Test + void testProjectStartedAndFinished() { + MavenSession session = + createSession(List.of(createProject("api"), createProject("core"), createProject("cli"))); + + listener.initReactor(session); + + listener.projectStarted("api"); + listener.projectFinished("api"); + } + + @Test + void testLogMessagePassthrough() { + listener.log("Test log message"); + String output = terminalOutput.toString(); + assertTrue(output.contains("Test log message"), "Expected log message in output: " + output); + } + + @Test + void testProjectLogMessageFiltersInfo() { + LogEvent infoEvent = new DefaultLogEvent( + MonotonicClock.now(), + LogLevel.INFO, + "Compiling 42 source files", + "compiler", + null, + "[INFO] Compiling 42 source files"); + listener.projectLogMessage("api", infoEvent); + String output = terminalOutput.toString(); + assertFalse( + output.contains("Compiling 42 source files"), + "INFO messages should be suppressed in rich mode: " + output); + } + + @Test + void testProjectLogMessageSuppressesWarningInline() { + // In rich mode, warnings are suppressed inline and only counted for the + // end-of-build summary — they don't scroll above the status bar. + LogEvent warnEvent = new DefaultLogEvent( + MonotonicClock.now(), + LogLevel.WARN, + "Deprecated API usage", + "compiler", + null, + "[WARNING] Deprecated API usage"); + listener.projectLogMessage("api", warnEvent); + String output = terminalOutput.toString(); + assertFalse( + output.contains("[WARNING] Deprecated API usage"), + "WARN messages should be suppressed inline in rich mode: " + output); + assertEquals(1, listener.getWarningCount(), "Warning count should be tracked"); + } + + @Test + void testProjectLogMessageShowsError() { + LogEvent errorEvent = new DefaultLogEvent( + MonotonicClock.now(), + LogLevel.ERROR, + "Compilation failure", + "compiler", + null, + "[ERROR] Compilation failure"); + listener.projectLogMessage("api", errorEvent); + String output = terminalOutput.toString(); + assertTrue(output.contains("[ERROR] Compilation failure"), "ERROR messages should pass through: " + output); + } + + @Test + void testMojoStartedUpdatesState() { + MavenSession session = createSession(List.of(createProject("api"), createProject("core"))); + listener.initReactor(session); + + listener.projectStarted("api"); + + ExecutionEvent mojoEvent = mock(ExecutionEvent.class); + MavenProject project = createProject("api"); + when(mojoEvent.getProject()).thenReturn(project); + var mojoExec = mock(org.apache.maven.plugin.MojoExecution.class); + when(mojoExec.getArtifactId()).thenReturn("maven-compiler-plugin"); + when(mojoExec.getGoal()).thenReturn("compile"); + when(mojoEvent.getMojoExecution()).thenReturn(mojoExec); + + listener.mojoStarted(mojoEvent); + + listener.projectFinished("api"); + } + + @Test + void testTransferEvents() { + MavenSession session = createSession(List.of(createProject("api"))); + listener.initReactor(session); + + TransferResource resource = mock(TransferResource.class); + when(resource.getResourceName()).thenReturn("org/apache/maven/core/4.1.0/core-4.1.0.jar"); + when(resource.getContentLength()).thenReturn(524288L); + + TransferEvent startEvent = mock(TransferEvent.class); + when(startEvent.getType()).thenReturn(TransferEvent.EventType.STARTED); + when(startEvent.getResource()).thenReturn(resource); + + listener.transfer("api", startEvent); + + TransferEvent progressEvent = mock(TransferEvent.class); + when(progressEvent.getType()).thenReturn(TransferEvent.EventType.PROGRESSED); + when(progressEvent.getResource()).thenReturn(resource); + when(progressEvent.getTransferredBytes()).thenReturn(262144L); + + listener.transfer("api", progressEvent); + + TransferEvent doneEvent = mock(TransferEvent.class); + when(doneEvent.getType()).thenReturn(TransferEvent.EventType.SUCCEEDED); + when(doneEvent.getResource()).thenReturn(resource); + + listener.transfer("api", doneEvent); + } + + @Test + void testParallelProjects() { + MavenSession session = + createSession(List.of(createProject("api"), createProject("core"), createProject("cli"))); + listener.initReactor(session); + + listener.projectStarted("api"); + listener.projectStarted("core"); + + listener.projectFinished("api"); + + listener.projectStarted("cli"); + + listener.projectFinished("core"); + listener.projectFinished("cli"); + } + + @Test + void testExecutionFailureUpdatesState() { + MavenSession session = createSession(List.of(createProject("api"))); + listener.initReactor(session); + + listener.projectStarted("api"); + listener.executionFailure("api", true, "Compilation error"); + listener.projectFinished("api"); + } + + @Test + void testTearDown() throws Exception { + MavenSession session = createSession(List.of(createProject("api"))); + listener.initReactor(session); + listener.projectStarted("api"); + + listener.tearDown(); + } + + @Test + void testFinishCallsTearDown() throws Exception { + MavenSession session = createSession(List.of(createProject("api"))); + listener.initReactor(session); + + listener.finish(0); + } + + @Test + void testFailCallsTearDown() throws Exception { + MavenSession session = createSession(List.of(createProject("api"))); + listener.initReactor(session); + + listener.fail(new RuntimeException("build error")); + } + + @Test + void testLargeReactorProgressBar() { + // With 50 modules on a 120-char terminal, maxIndicators = (120-40)/3 = 26. + // 50 > 26, so the summary line should use a progress bar instead of + // per-module ✓/●/○ indicators. + List projects = new java.util.ArrayList<>(); + for (int i = 0; i < 50; i++) { + projects.add(createProject("module-" + i)); + } + MavenSession session = createSession(projects); + listener.initReactor(session); + + // Start and finish some modules to exercise the progress bar rendering + for (int i = 0; i < 10; i++) { + listener.projectStarted("module-" + i); + } + for (int i = 0; i < 5; i++) { + listener.projectFinished("module-" + i); + } + // 5 completed, 5 active, 40 pending — should render without error + for (int i = 5; i < 10; i++) { + listener.projectFinished("module-" + i); + } + } + + @Test + void testSmallReactorUsesIndicators() { + // With 3 modules on a 120-char terminal, maxIndicators = 26 > 3, + // so the summary line should use per-module ✓/●/○ indicators. + MavenSession session = + createSession(List.of(createProject("api"), createProject("core"), createProject("cli"))); + listener.initReactor(session); + + listener.projectStarted("api"); + listener.projectFinished("api"); + listener.projectStarted("core"); + listener.projectFinished("core"); + listener.projectStarted("cli"); + listener.projectFinished("cli"); + } + + @Test + void testTruncateAnsiPlainText() { + String truncated = RichBuildEventListener.truncateAnsi("hello world", 5); + // When truncated, a RESET escape is appended to close any open styling + assertTrue(truncated.startsWith("hello"), "Should start with 'hello': " + truncated); + // Should not contain characters beyond "hello" (except ANSI reset) + String stripped = truncated.replaceAll("\033\\[[^a-zA-Z]*[a-zA-Z]", ""); + assertEquals("hello", stripped); + } + + @Test + void testTruncateAnsiPreservesEscapeSequences() { + // ANSI color codes should not count toward visible length + String colored = "\033[1mhello\033[0m world"; + String truncated = RichBuildEventListener.truncateAnsi(colored, 5); + // Should keep "hello" (5 visible chars) with the bold prefix + assertTrue(truncated.contains("hello"), "Truncated should contain 'hello': " + truncated); + assertTrue(truncated.contains("\033[1m"), "Truncated should preserve ANSI prefix"); + } + + @Test + void testTruncateAnsiNoTruncationNeeded() { + String s = "short"; + assertEquals(s, RichBuildEventListener.truncateAnsi(s, 100)); + } + + // ---- Helpers ---- + + private static MavenProject createProject(String artifactId) { + MavenProject project = mock(MavenProject.class); + lenient().when(project.getArtifactId()).thenReturn(artifactId); + lenient().when(project.getName()).thenReturn(artifactId); + lenient().when(project.getPackaging()).thenReturn("jar"); + lenient().when(project.getVersion()).thenReturn("4.1.0-SNAPSHOT"); + return project; + } + + private static MavenSession createSession(List projects) { + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(projects); + when(session.getAllProjects()).thenReturn(projects); + MavenExecutionRequest request = mock(MavenExecutionRequest.class); + lenient().when(request.getDegreeOfConcurrency()).thenReturn(1); + lenient().when(session.getRequest()).thenReturn(request); + return session; + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichExecutionEventLoggerTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichExecutionEventLoggerTest.java new file mode 100644 index 000000000000..762d71548430 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichExecutionEventLoggerTest.java @@ -0,0 +1,424 @@ +/* + * 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.event; + +import java.io.ByteArrayOutputStream; +import java.time.Instant; +import java.util.List; + +import org.apache.maven.api.build.report.LogLevel; +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.DefaultMavenExecutionRequest; +import org.apache.maven.execution.DefaultMavenExecutionResult; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.internal.build.DefaultLogEvent; +import org.apache.maven.jline.JLineMessageBuilderFactory; +import org.apache.maven.jline.MessageUtils; +import org.apache.maven.project.MavenProject; +import org.jline.terminal.Size; +import org.jline.terminal.Terminal; +import org.jline.terminal.impl.DumbTerminal; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.MockitoSession; +import org.slf4j.Logger; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link RichExecutionEventLogger}. + *

+ * In rich mode, all output goes directly to the terminal writer via + * {@link RichBuildEventListener#log(String)}, bypassing SLF4J entirely. + * Tests capture the terminal output to verify content. + */ +class RichExecutionEventLoggerTest { + + private MockitoSession mockitoSession; + private Logger logger; + private Terminal terminal; + private ByteArrayOutputStream terminalOutput; + private RichBuildEventListener buildEventListener; + private RichExecutionEventLogger richLogger; + private final JLineMessageBuilderFactory messageBuilderFactory = new JLineMessageBuilderFactory(); + + @BeforeAll + static void setUp() { + MessageUtils.setColorEnabled(false); + } + + @AfterAll + static void tearDown() { + MessageUtils.setColorEnabled(true); + } + + @BeforeEach + void beforeEach() throws Exception { + mockitoSession = Mockito.mockitoSession().startMocking(); + logger = mock(Logger.class); + lenient().when(logger.isInfoEnabled()).thenReturn(true); + lenient().when(logger.isWarnEnabled()).thenReturn(true); + terminalOutput = new ByteArrayOutputStream(); + terminal = new DumbTerminal(System.in, terminalOutput); + terminal.setSize(new Size(120, 40)); + buildEventListener = new RichBuildEventListener(terminal, msg -> {}); + richLogger = new RichExecutionEventLogger(messageBuilderFactory, buildEventListener, logger); + } + + @AfterEach + void afterEach() throws Exception { + terminal.close(); + mockitoSession.finishMocking(); + } + + @Test + void testProjectStartedSuppressed() { + // In rich mode, projectStarted should produce NO output (status bar handles it) + ExecutionEvent event = mock(ExecutionEvent.class); + + richLogger.projectStarted(event); + + verify(logger, never()).info(anyString()); + assertTrue(terminalOutput.toString().isEmpty(), "No terminal output expected"); + } + + @Test + void testMojoStartedSuppressed() { + // In rich mode, mojoStarted should produce NO output (status bar handles it) + ExecutionEvent event = mock(ExecutionEvent.class); + + richLogger.mojoStarted(event); + + verify(logger, never()).info(anyString()); + assertTrue(terminalOutput.toString().isEmpty(), "No terminal output expected"); + } + + @Test + void testProjectSucceededSuppressed() { + // In rich mode, projectSucceeded produces NO scrolling output — + // the status bar checkmarks already indicate completion. + MavenProject project = generateMavenProject("Maven Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project, 2100)); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + lenient().when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + richLogger.sessionStarted(sessionEvent); + + // Clear terminal output accumulated from sessionStarted (status bar init) + terminalOutput.reset(); + + ExecutionEvent projectEvent = mock(ExecutionEvent.class); + lenient().when(projectEvent.getProject()).thenReturn(project); + lenient().when(projectEvent.getSession()).thenReturn(session); + + richLogger.projectSucceeded(projectEvent); + + // No output — SUCCESS lines are suppressed in rich mode + String output = terminalOutput.toString(); + assertFalse(output.contains("SUCCESS"), "SUCCESS line should be suppressed in rich mode"); + } + + @Test + void testProjectFailedShowsCross() { + MavenProject project = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildFailure(project, 5000, new Exception("Compile error"))); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + richLogger.sessionStarted(sessionEvent); + + ExecutionEvent event = mockProjectEvent(project, session); + richLogger.projectFailed(event); + + String output = terminalOutput.toString(); + assertTrue(output.contains("Core"), "Should contain project name"); + assertTrue(output.contains("FAILURE"), "Should contain FAILURE status"); + } + + @Test + void testMultiModuleSuccessSuppressed() { + // In rich mode, per-module SUCCESS lines are suppressed — the status bar + // already shows ✓/●/○ indicators and the [n/total] counter. + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + MavenProject project3 = generateMavenProject("CLI"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project1, 1000)); + executionResult.addBuildSummary(new BuildSuccess(project2, 3000)); + executionResult.addBuildSummary(new BuildSuccess(project3, 2000)); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + lenient().when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + richLogger.sessionStarted(sessionEvent); + + // Clear terminal output from sessionStarted + terminalOutput.reset(); + + // mockProjectEvent stubs are unused since projectSucceeded is a no-op; + // call with a plain mock to avoid UnnecessaryStubbing errors. + richLogger.projectSucceeded(mock(ExecutionEvent.class)); + richLogger.projectSucceeded(mock(ExecutionEvent.class)); + richLogger.projectSucceeded(mock(ExecutionEvent.class)); + + String output = terminalOutput.toString(); + // No per-module SUCCESS lines should appear + assertFalse(output.contains("SUCCESS"), "SUCCESS lines should be suppressed in rich mode"); + assertFalse(output.contains("[1/3]"), "Progress counters should not appear"); + } + + @Test + void testSessionEndedShowsSummary() { + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project1, 1000)); + executionResult.addBuildSummary(new BuildSuccess(project2, 2000)); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + when(session.getProjects()).thenReturn(List.of(project1, project2)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2)); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + richLogger.sessionStarted(sessionEvent); + richLogger.sessionEnded(sessionEvent); + + // Summary goes to terminal writer, not logger + String output = terminalOutput.toString(); + assertTrue(output.contains("BUILD SUCCESS"), "Should contain BUILD SUCCESS"); + assertTrue(output.contains("2 modules"), "Should contain module count"); + assertTrue(output.contains("2 passed"), "Should contain passed count"); + assertTrue(output.contains("Total time:"), "Should contain total time"); + assertTrue( + output.contains("Full report: target/build-reports/build-report-latest.json"), + "Should contain report path"); + // MNG-7372: version info only on failure, not success + assertFalse(output.contains("Maven:"), "Should NOT contain Maven version on success"); + } + + @Test + void testSessionEndedWithFailures() { + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + MavenProject project3 = generateMavenProject("CLI"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project1, 1000)); + executionResult.addBuildSummary(new BuildFailure(project2, 2000, new Exception("Error"))); + executionResult.addException(new Exception("Error")); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + when(session.getProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + richLogger.sessionStarted(sessionEvent); + richLogger.sessionEnded(sessionEvent); + + String output = terminalOutput.toString(); + assertTrue(output.contains("BUILD FAILURE"), "Should contain BUILD FAILURE"); + assertTrue(output.contains("3 modules"), "Should contain module count"); + assertTrue(output.contains("1 passed"), "Should contain passed count"); + assertTrue(output.contains("1 failed"), "Should contain failed count"); + assertTrue(output.contains("1 skipped"), "Should contain skipped count"); + // MNG-7372: version info shown on failure + assertTrue(output.contains("Maven:"), "Should contain Maven version on failure"); + assertTrue(output.contains("Java:"), "Should contain Java version on failure"); + } + + @Test + void testMojoSkippedStillWarns() { + // mojoSkipped still uses logger.warn (goes through SLF4J for WARN level) + ExecutionEvent event = mock(ExecutionEvent.class); + var mojoExec = mock(org.apache.maven.plugin.MojoExecution.class); + when(mojoExec.getGoal()).thenReturn("deploy"); + when(event.getMojoExecution()).thenReturn(mojoExec); + + richLogger.mojoSkipped(event); + + verify(logger).warn(anyString(), eq("deploy")); + } + + @Test + void testOutputBypassesSLF4J() { + // Verify that summary output does NOT go through logger.info + MavenProject project = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project, 1000)); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + richLogger.sessionStarted(sessionEvent); + richLogger.sessionEnded(sessionEvent); + + // No logger.info calls — all output goes through terminal writer + verify(logger, never()).info(anyString()); + + // But the output IS in the terminal + String output = terminalOutput.toString(); + assertFalse(output.isEmpty(), "Terminal should have output"); + assertTrue(output.contains("BUILD SUCCESS"), "Terminal should contain BUILD SUCCESS"); + } + + @Test + void testWarningSummaryShown() { + MavenProject project = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project, 1000)); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + richLogger.sessionStarted(sessionEvent); + + // Simulate 3 warnings arriving during the build + Instant now = Instant.now(); + buildEventListener.projectLogMessage( + "core", + new DefaultLogEvent(now, LogLevel.WARN, "unchecked cast", "javac", null, "[WARNING] unchecked cast")); + buildEventListener.projectLogMessage( + "core", + new DefaultLogEvent(now, LogLevel.WARN, "deprecated API", "javac", null, "[WARNING] deprecated API")); + buildEventListener.projectLogMessage( + "core", + new DefaultLogEvent(now, LogLevel.WARN, "unused import", "javac", null, "[WARNING] unused import")); + + richLogger.sessionEnded(sessionEvent); + + String output = terminalOutput.toString(); + assertTrue(output.contains("BUILD SUCCESS"), "Should contain BUILD SUCCESS"); + assertTrue(output.contains("3 warning"), "Should contain warning count"); + assertTrue(output.contains("Diagnostics:"), "Should contain Diagnostics label"); + assertTrue(output.contains("mvnlog"), "Should hint how to see warning details"); + } + + @Test + void testNoWarningSummaryWhenClean() { + MavenProject project = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project, 1000)); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + richLogger.sessionStarted(sessionEvent); + richLogger.sessionEnded(sessionEvent); + + String output = terminalOutput.toString(); + assertTrue(output.contains("BUILD SUCCESS"), "Should contain BUILD SUCCESS"); + assertFalse(output.contains("Diagnostics:"), "Should NOT contain Diagnostics when no warnings"); + } + + // ---- Helpers ---- + + private static MavenProject generateMavenProject(String projectName) { + MavenProject project = mock(MavenProject.class); + lenient() + .when(project.getArtifactId()) + .thenReturn(projectName.toLowerCase().replace(" ", "-")); + lenient().when(project.getPackaging()).thenReturn("jar"); + lenient().when(project.getVersion()).thenReturn("4.1.0-SNAPSHOT"); + lenient().when(project.getName()).thenReturn(projectName); + return project; + } + + private static ExecutionEvent mockProjectEvent(MavenProject project, MavenSession session) { + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + when(event.getSession()).thenReturn(session); + return event; + } +} From 3d83d556b55e06d8ce58e43acc3b73c06c24478f Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 21 Sep 2026 17:44:52 +0000 Subject: [PATCH 4/6] fix: restore getWorkspaceReader() hook in AbstractCoreMavenComponentTestCase The console-modes squash removed the getWorkspaceReader() extension point that was added by 25be7b837f, breaking the compilation of WorkspaceParentProjectBuilderTest which overrides that method. --- .../apache/maven/AbstractCoreMavenComponentTestCase.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/impl/maven-core/src/test/java/org/apache/maven/AbstractCoreMavenComponentTestCase.java b/impl/maven-core/src/test/java/org/apache/maven/AbstractCoreMavenComponentTestCase.java index fd35d876018e..bc796ec8696d 100644 --- a/impl/maven-core/src/test/java/org/apache/maven/AbstractCoreMavenComponentTestCase.java +++ b/impl/maven-core/src/test/java/org/apache/maven/AbstractCoreMavenComponentTestCase.java @@ -57,6 +57,7 @@ import org.eclipse.aether.RepositorySystem; import org.eclipse.aether.RepositorySystemSession; import org.eclipse.aether.repository.LocalRepository; +import org.eclipse.aether.repository.WorkspaceReader; import static org.codehaus.plexus.testing.PlexusExtension.getBasedir; @@ -172,6 +173,7 @@ protected void initRepoSession( RepositorySystemSession session = new MavenSessionBuilderSupplier(repositorySystem, true) .get() .withLocalRepositories(localRepo) + .setWorkspaceReader(getWorkspaceReader()) .build(); projectBuildingRequest.setRepositorySession(session); @@ -194,6 +196,13 @@ protected void initRepoSession( sessionScope.seed(InternalMavenSession.class, InternalMavenSession.from(iSession)); } + /** + * The workspace reader of the repository session, none by default. + */ + protected WorkspaceReader getWorkspaceReader() { + return null; + } + protected MavenProject createStubMavenProject() { Model model = new Model(); model.setGroupId("org.apache.maven.test"); From 01cc23e2fe53f68bbbd71206ee62d07f719006d7 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 21 Sep 2026 18:21:01 +0000 Subject: [PATCH 5/6] fix: restore isProjectPom() check to prevent project-local-repo artifacts from getting a basedir The console-modes squash accidentally removed the isProjectPom() helper and the associated check in resolveProjectSource(). This caused any POM served by the workspace reader (including those from the project-local repository) to be treated as a local checkout with a basedir, breaking the invariant that only actual session projects should have a basedir. Restore the original logic: localProject = isWorkspaceRepo && isProjectPom(). Fixes WorkspaceParentProjectBuilderTest.parentFromProjectLocalRepositoryHasNoBasedir. --- .../maven/project/DefaultProjectBuilder.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectBuilder.java b/impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectBuilder.java index 72d8adf460f3..a8bf58f4ff0e 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectBuilder.java +++ b/impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectBuilder.java @@ -536,6 +536,17 @@ void close() { } } + private boolean isProjectPom(Path pomFile) { + List projects = session.getProjects(); + if (pomFile == null || projects == null) { // no reactor yet while the session is being set up + return false; + } + Path path = pomFile.toAbsolutePath().normalize(); + return projects.stream() + .filter(Objects::nonNull) + .anyMatch(p -> p.getPomPath().toAbsolutePath().normalize().equals(path)); + } + ProjectBuildingResult build( boolean parent, Artifact artifact, boolean allowStubModel, List repositories) throws ProjectBuildingException { @@ -569,7 +580,10 @@ private ProjectSource resolveProjectSource( ArtifactResolverResult.ResultItem resItem = res.getResult(coordinates); pomArtifact = InternalMavenSession.from(session).toArtifact(resItem.getArtifact()); - localProject = resItem.getRepository() instanceof org.apache.maven.api.WorkspaceRepository; + // the workspace also serves copies from the project-local repository; only a POM that belongs to + // a project of this session is a checkout with a basedir, the rest is treated like a repository + localProject = resItem.getRepository() instanceof org.apache.maven.api.WorkspaceRepository + && isProjectPom(pomArtifact.getPath()); } catch (ArtifactResolverException e) { if (e.getResult().getResults().values().iterator().next().isMissing() && allowStubModel) { return new ProjectSource(null, createStubModelSource(artifact)); From 512629a5c108ff86f684a53c58a75d14aac2f93d Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 21 Sep 2026 19:08:42 +0000 Subject: [PATCH 6/6] fix: isolate ITs from CI env to prevent plain console mode suppressing output When running on GitHub Actions, CI=true is inherited by child Maven processes spawned by the IT framework. With the new console-modes feature, CI detection causes auto mode to select plain, which suppresses mojo-start banners ([INFO] --- plugin:version:goal @ module) and 'Reactor Summary' lines that many ITs rely on. Fix: call removeCIEnvironmentVariables() in AbstractMavenIntegrationTestCase. newVerifier() so all ITs run in verbose mode by default (the same output format they were written against). ITs that test CI-specific behaviour can opt back in with verifier.setEnvironmentVariable("CI", "true"). Also add --console=verbose to MavenITmng8655SettingsParserTest.coreRealmParserReadsBootstrapSettings which uses ForkedMavenExecutor directly (bypasses the Verifier) and inherits the CI env from the parent process. --- .../apache/maven/it/MavenITmng8655SettingsParserTest.java | 3 ++- .../apache/maven/it/AbstractMavenIntegrationTestCase.java | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8655SettingsParserTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8655SettingsParserTest.java index a705b804025f..2a5d189f3c54 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8655SettingsParserTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8655SettingsParserTest.java @@ -93,7 +93,8 @@ void coreRealmParserReadsBootstrapSettings() throws Exception { var result = new ForkedMavenExecutor(installation).execute(ExecutorRequest.mavenBuilder() .cwd(project) .userHomeDirectory(Files.createDirectories(directory.resolve("home"))) - .arguments(List.of("-B", "-ntp", "-s", "settings.properties", "-Dparser.input=bootstrap-settings", + .arguments(List.of("-B", "-ntp", "--console=verbose", "-s", "settings.properties", + "-Dparser.input=bootstrap-settings", "-Dmaven.repo.local=" + localRepository, "validate")) .skipMavenRc(true) .grabOutputAsString(true) diff --git a/its/core-it-support/maven-it-helper/src/main/java/org/apache/maven/it/AbstractMavenIntegrationTestCase.java b/its/core-it-support/maven-it-helper/src/main/java/org/apache/maven/it/AbstractMavenIntegrationTestCase.java index 27f43ea78184..0286cad05fd1 100644 --- a/its/core-it-support/maven-it-helper/src/main/java/org/apache/maven/it/AbstractMavenIntegrationTestCase.java +++ b/its/core-it-support/maven-it-helper/src/main/java/org/apache/maven/it/AbstractMavenIntegrationTestCase.java @@ -139,6 +139,12 @@ protected Verifier newVerifier(Path basedir, String settings, boolean createDotM verifier.setAutoclean(false); + // Isolate ITs from the host CI environment so that console mode auto-detection + // (CI → plain) does not suppress output that ITs rely on (e.g. mojo-start banners, + // Reactor Summary lines). ITs that specifically test CI-mode behaviour must opt back + // in by calling verifier.setEnvironmentVariable("CI", "true") explicitly. + verifier.removeCIEnvironmentVariables(); + if (settings != null) { Path settingsPath; if (!settings.isEmpty()) {