{@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/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()})
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/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/BuildStatus.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/BuildStatus.java
index bbd487fa5f87..37d5dd9fe488 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/BuildStatus.java
@@ -16,29 +16,31 @@
* 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.
+ * The outcome of a build, module, or mojo execution.
*
- * @since 3.1.0
+ * @since 4.1.0
*/
-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);
- }
+@Experimental
+@Immutable
+public enum BuildStatus {
+ /**
+ * Completed successfully.
+ */
+ SUCCESS,
- @Override
- public void activate() {
- // no op
- }
+ /**
+ * 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/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/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java
new file mode 100644
index 000000000000..cb1de663f2c8
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java
@@ -0,0 +1,38 @@
+/*
+ * 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;
+
+/**
+ * Log severity levels, mirroring the standard SLF4J levels.
+ *
+ * @since 4.1.0
+ * @see LogEvent#level()
+ */
+@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/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
new file mode 100644
index 000000000000..dd7d0572dd40
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java
@@ -0,0 +1,38 @@
+/*
+ * 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.
+ *
+ * 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
+ */
+@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.jlinejansi-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.slf4jslf4j-api
-
- org.slf4j
- jul-to-slf4j
- commons-clicommons-cli
-
- ch.qos.logback
- logback-classic
- true
- org.junit.jupiter
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.
+ *
+ *
+ * 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.
+ *
+ *
+ * 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.
+ *
+ *
+ * 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 3e027ef4bbf2..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
@@ -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;
@@ -78,12 +80,12 @@
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;
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 +94,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;
@@ -156,6 +157,7 @@ protected int doInvoke(C context) throws Exception {
pushUserProperties(context);
setupGuiceClassLoading(context);
configureLogging(context);
+ preliminaryInteractiveDetection(context);
createTerminal(context);
activateLogging(context);
helpOrVersionAndMayExit(context);
@@ -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
@@ -302,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);
@@ -420,39 +460,54 @@ 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 {
- 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 +530,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()));
@@ -527,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 e6372ccfd818..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;
@@ -257,6 +263,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));
@@ -343,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.
+ *
{@code --console=auto} (or unset) — selects mode based on environment:
+ *
+ *
CI detected → "plain"
+ *
Interactive TTY → "rich"
+ *
Otherwise → "verbose"
+ *
+ *
+ *
+ */
+ 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/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-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;
+ }
+}
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/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-cli/src/main/java/org/apache/maven/cling/logging/impl/LogbackConfiguration.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultModuleReport.java
similarity index 51%
rename from impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/LogbackConfiguration.java
rename to impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultModuleReport.java
index 67ee429d82ab..64c79636ebd1 100644
--- a/impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/LogbackConfiguration.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultModuleReport.java
@@ -16,31 +16,38 @@
* specific language governing permissions and limitations
* under the License.
*/
-package org.apache.maven.cling.logging.impl;
+package org.apache.maven.internal.build;
-import org.apache.maven.cling.logging.BaseSlf4jConfiguration;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
+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;
/**
- * Configuration for slf4j-logback.
- *
- * @since 3.1.0
+ * Internal immutable implementation of {@link ModuleReport}.
*/
-public class LogbackConfiguration extends BaseSlf4jConfiguration {
+record DefaultModuleReport(
+ String groupId,
+ String artifactId,
+ String version,
+ BuildStatus status,
+ Instant startTime,
+ Duration duration,
+ List mojos,
+ List output)
+ implements ModuleReport {
+
@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);
+ public List mojos() {
+ return List.copyOf(mojos);
}
@Override
- public void activate() {
- // no op
+ 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/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/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/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/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));
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");
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.
+ *
+ * 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-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-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..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
@@ -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);
}
}
@@ -87,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;
@@ -97,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);
@@ -116,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) {
@@ -133,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
new file mode 100644
index 000000000000..587638534414
--- /dev/null
+++ b/impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenJulHandlerTest.java
@@ -0,0 +1,155 @@
+/*
+ * 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.ArrayList;
+import java.util.List;
+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);
+ }
+ }
+
+ /**
+ * 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.
+ */
+ 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/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