diff --git a/apache-maven/pom.xml b/apache-maven/pom.xml
index 25864c17d8d7..3605d353aa74 100644
--- a/apache-maven/pom.xml
+++ b/apache-maven/pom.xml
@@ -78,13 +78,6 @@ under the License.
${slf4jVersion}runtime
-
-
- org.slf4j
- jul-to-slf4j
- ${slf4jVersion}
- runtime
- org.apache.maven.resolvermaven-resolver-connector-basic
diff --git a/apache-maven/src/assembly/component.xml b/apache-maven/src/assembly/component.xml
index 5f55a310c8bd..ce303ea4960d 100644
--- a/apache-maven/src/assembly/component.xml
+++ b/apache-maven/src/assembly/component.xml
@@ -87,6 +87,7 @@ under the License.
mvnmvnenc
+ mvnlogmvnshmvnupmvnDebug
diff --git a/apache-maven/src/assembly/maven/bin/mvn b/apache-maven/src/assembly/maven/bin/mvn
index 0adc4eabeb2b..93127f8d8e9f 100755
--- a/apache-maven/src/assembly/maven/bin/mvn
+++ b/apache-maven/src/assembly/maven/bin/mvn
@@ -303,6 +303,9 @@ handle_args() {
--up)
MAVEN_MAIN_CLASS="org.apache.maven.cling.MavenUpCling"
;;
+ --log)
+ MAVEN_MAIN_CLASS="org.apache.maven.cling.MavenLogCling"
+ ;;
*)
;;
esac
@@ -311,6 +314,21 @@ handle_args() {
}
handle_args "$@"
+
+# Strip routing flags (--debug, --yjp, --enc, --shell, --up, --log) from $@
+# so they are not passed to the Java process where they may collide with
+# Commons CLI option-prefix matching (e.g. --log matches --log-file).
+_argc=$#
+_i=0
+while [ $_i -lt $_argc ]; do
+ _arg="$1"
+ shift
+ case $_arg in
+ --debug|--yjp|--enc|--shell|--up|--log) ;;
+ *) set -- "$@" "$_arg" ;;
+ esac
+ _i=$((_i + 1))
+done
MAVEN_MAIN_CLASS=${MAVEN_MAIN_CLASS:=org.apache.maven.cling.MavenCling}
# Build base command string for eval (only contains Maven-controlled values)
diff --git a/apache-maven/src/assembly/maven/bin/mvn.cmd b/apache-maven/src/assembly/maven/bin/mvn.cmd
index 74d4a5a984d2..7e3c8bdae83b 100644
--- a/apache-maven/src/assembly/maven/bin/mvn.cmd
+++ b/apache-maven/src/assembly/maven/bin/mvn.cmd
@@ -275,6 +275,8 @@ if "%~1"=="--debug" (
set "MAVEN_MAIN_CLASS=org.apache.maven.cling.MavenShellCling"
) else if "%~1"=="--up" (
set "MAVEN_MAIN_CLASS=org.apache.maven.cling.MavenUpCling"
+) else if "%~1"=="--log" (
+ set "MAVEN_MAIN_CLASS=org.apache.maven.cling.MavenLogCling"
)
exit /b 0
diff --git a/apache-maven/src/assembly/maven/bin/mvnlog b/apache-maven/src/assembly/maven/bin/mvnlog
new file mode 100755
index 000000000000..8170bdb16785
--- /dev/null
+++ b/apache-maven/src/assembly/maven/bin/mvnlog
@@ -0,0 +1,30 @@
+#!/bin/sh
+
+# 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.
+
+# -----------------------------------------------------------------------------
+# Apache Maven Build Log Viewer Script
+#
+# Environment Variable Prerequisites
+#
+# JAVA_HOME (Optional) Points to a Java installation.
+# MAVEN_OPTS (Optional) Java runtime options used when Maven is executed.
+# MAVEN_SKIP_RC (Optional) Flag to disable loading of mavenrc files.
+# -----------------------------------------------------------------------------
+
+"`dirname "$0"`/mvn" --log "$@"
diff --git a/apache-maven/src/assembly/maven/bin/mvnlog.cmd b/apache-maven/src/assembly/maven/bin/mvnlog.cmd
new file mode 100644
index 000000000000..7069255cd817
--- /dev/null
+++ b/apache-maven/src/assembly/maven/bin/mvnlog.cmd
@@ -0,0 +1,39 @@
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+
+@REM -----------------------------------------------------------------------------
+@REM Apache Maven Build Log Viewer Script
+@REM
+@REM Environment Variable Prerequisites
+@REM
+@REM JAVA_HOME (Optional) Points to a Java installation.
+@REM MAVEN_BATCH_ECHO (Optional) Set to 'on' to enable the echoing of the batch commands.
+@REM MAVEN_BATCH_PAUSE (Optional) set to 'on' to wait for a key stroke before ending.
+@REM MAVEN_OPTS (Optional) Java runtime options used when Maven is executed.
+@REM MAVEN_SKIP_RC (Optional) Flag to disable loading of mavenrc files.
+@REM -----------------------------------------------------------------------------
+
+@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on'
+@echo off
+@REM set title of command window
+title %0
+@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on'
+@if "%MAVEN_BATCH_ECHO%"=="on" echo %MAVEN_BATCH_ECHO%
+
+@setlocal
+
+@call "%~dp0"mvn.cmd --log %*
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Options.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Options.java
index d2bf596cd916..93c720fc37e8 100644
--- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Options.java
+++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Options.java
@@ -182,6 +182,43 @@ public interface Options {
@Nonnull
Optional color();
+ /**
+ * Returns the console output mode.
+ *
+ * Supported modes:
+ *
+ *
{@code "auto"} — selects {@code "plain"} in CI, {@code "rich"} on interactive TTYs,
+ * {@code "verbose"} otherwise
+ *
{@code "plain"} — compact one-line-per-module output with structured summary
+ *
{@code "rich"} — JLine status bar with live reactor progress (requires TTY)
+ *
{@code "verbose"} — full mojo-level output (Maven 4.0 default behavior)
+ *
{@code "machine"} — JSON lines: one typed JSON object per lifecycle event,
+ * designed for piping to external tools, CI systems, and LLM agents
+ *
+ *
+ * @return an {@link Optional} containing the console mode, or empty if not set
+ * @since 4.1.0
+ */
+ @Nonnull
+ Optional console();
+
+ /**
+ * Returns the warning display mode.
+ *
+ * Controls how build warnings (diagnostics) are displayed:
+ *
+ *
{@code "summary"} (default) — collect warnings, show deduplicated summary at end of build
+ *
{@code "all"} — show warnings inline as they occur AND show summary at end
+ *
{@code "none"} — suppress the diagnostic summary entirely
+ *
{@code "fail"} — show summary AND fail the build if any warnings exist
+ *
+ *
+ * @return an {@link Optional} containing the warning mode, or empty if not set
+ * @since 4.1.0
+ */
+ @Nonnull
+ Optional warningMode();
+
/**
* Indicates whether Maven should operate in offline mode.
*
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/ParserRequest.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/ParserRequest.java
index ee25ec63dab3..848151bc2511 100644
--- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/ParserRequest.java
+++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/ParserRequest.java
@@ -253,6 +253,30 @@ static Builder mvnup(@Nonnull List args, @Nonnull MessageBuilderFactory
return builder(Tools.MVNUP_CMD, Tools.MVNUP_NAME, args, messageBuilderFactory);
}
+ /**
+ * Creates a new Builder instance for constructing a Maven Build Log Viewer ParserRequest.
+ *
+ * @param args the command-line arguments
+ * @param messageBuilderFactory the factory for creating message builders
+ * @return a new Builder instance
+ */
+ @Nonnull
+ static Builder mvnlog(@Nonnull String[] args, @Nonnull MessageBuilderFactory messageBuilderFactory) {
+ return mvnlog(Arrays.asList(args), messageBuilderFactory);
+ }
+
+ /**
+ * Creates a new Builder instance for constructing a Maven Build Log Viewer ParserRequest.
+ *
+ * @param args the command-line arguments
+ * @param messageBuilderFactory the factory for creating message builders
+ * @return a new Builder instance
+ */
+ @Nonnull
+ static Builder mvnlog(@Nonnull List args, @Nonnull MessageBuilderFactory messageBuilderFactory) {
+ return builder(Tools.MVNLOG_CMD, Tools.MVNLOG_NAME, args, messageBuilderFactory);
+ }
+
/**
* Creates a new Builder instance for constructing a ParserRequest.
*
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
index 7559d7ffee06..136268657a91 100644
--- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
+++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
@@ -42,4 +42,7 @@ private Tools() {}
public static final String MVNUP_CMD = "mvnup";
public static final String MVNUP_NAME = "Maven Upgrade Tool";
+
+ public static final String MVNLOG_CMD = "mvnlog";
+ public static final String MVNLOG_NAME = "Maven Build Log Viewer";
}
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/LogOptions.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/LogOptions.java
new file mode 100644
index 000000000000..2049b3f20bcc
--- /dev/null
+++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/LogOptions.java
@@ -0,0 +1,131 @@
+/*
+ * 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.cli.mvnlog;
+
+import java.util.Optional;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.cli.Options;
+
+/**
+ * Defines the options specific to the Maven build log viewer tool ({@code mvnlog}).
+ * This interface extends the general {@link Options} interface, adding log-viewing options.
+ *
+ * @since 4.1.0
+ */
+@Experimental
+public interface LogOptions extends Options {
+ /**
+ * Whether to show detailed diagnostics (warnings and errors) from the build.
+ *
+ * @return an {@link Optional} containing {@code true} if diagnostics should be shown
+ */
+ Optional diagnostics();
+
+ /**
+ * Whether to show detailed failure information including stack traces.
+ *
+ * @return an {@link Optional} containing {@code true} if failures should be shown in detail
+ */
+ Optional failures();
+
+ /**
+ * Whether to show a full per-mojo timing breakdown.
+ *
+ * @return an {@link Optional} containing {@code true} if the full breakdown should be shown
+ */
+ Optional full();
+
+ /**
+ * Whether to list all available build reports instead of showing one.
+ *
+ * @return an {@link Optional} containing {@code true} if reports should be listed
+ */
+ Optional list();
+
+ /**
+ * Whether to output the raw JSON build report instead of formatted text.
+ * Useful for piping to tools like {@code jq} or for programmatic consumption.
+ *
+ * @return an {@link Optional} containing {@code true} if raw JSON should be output
+ */
+ Optional json();
+
+ /**
+ * Returns the path to a specific build report file to display.
+ * If not specified, defaults to {@code target/build-reports/build-report-latest.json}.
+ *
+ * @return an {@link Optional} containing the report file path, or empty if not specified
+ */
+ Optional reportFile();
+
+ /**
+ * Filter output to a specific module (by artifactId substring or glob pattern).
+ * When specified, only modules whose {@code artifactId} contains the given string
+ * (case-insensitive) are shown. With {@code --json}, only matching modules
+ * are included in the output.
+ *
+ * @return an {@link Optional} containing the module filter pattern
+ */
+ Optional module();
+
+ /**
+ * Filter output to a specific mojo (by goal substring or glob pattern).
+ * When specified, only mojos whose {@code goal} contains the given string
+ * (case-insensitive) are shown.
+ *
+ * @return an {@link Optional} containing the mojo filter pattern
+ */
+ Optional mojo();
+
+ /**
+ * Filter log events by minimum level ({@code TRACE}, {@code DEBUG}, {@code INFO},
+ * {@code WARN}, {@code ERROR}). Only events at or above the given severity are shown.
+ * In text mode, renders matching log lines from the build report.
+ * With {@code --json}, filters the log event arrays in the output.
+ *
+ * @return an {@link Optional} containing the minimum log level
+ */
+ Optional level();
+
+ /**
+ * Search through log messages for a substring (case-insensitive).
+ * In text mode, renders matching log lines from the build report.
+ * With {@code --json}, filters the log event arrays in the output.
+ *
+ * @return an {@link Optional} containing the grep pattern
+ */
+ Optional grep();
+
+ /**
+ * Whether to launch the web-based report viewer instead of terminal output.
+ * When enabled, starts a local HTTP server serving an interactive build report viewer.
+ *
+ * @return an {@link Optional} containing {@code true} if the web viewer should be launched
+ */
+ Optional web();
+
+ /**
+ * The port number for the web server when {@link #web()} is enabled.
+ * If not specified, defaults to port 8080 with automatic fallback to an available port.
+ *
+ * @return an {@link Optional} containing the port number, or empty for automatic selection
+ */
+ Optional port();
+}
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/package-info.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/package-info.java
new file mode 100644
index 000000000000..26e66a2af6fd
--- /dev/null
+++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/package-info.java
@@ -0,0 +1,27 @@
+/*
+ * 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.
+ */
+
+/**
+ * Provides the API for the Maven Log Viewer tool ({@code mvnlog}).
+ *
+ * @see org.apache.maven.api.cli.Tools#MVNLOG_CMD
+ * @see org.apache.maven.api.cli.Tools#MVNLOG_NAME
+ * @since 4.1.0
+ */
+package org.apache.maven.api.cli.mvnlog;
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..7cd7975b8871
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildReport.java
@@ -0,0 +1,195 @@
+/*
+ * 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.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
+public interface BuildReport {
+
+ /**
+ * Schema version of the report format. Consumers should check this
+ * to handle forward compatibility.
+ *
+ * @return the format version, currently {@code 1}
+ */
+ int formatVersion();
+
+ /**
+ * The overall build status.
+ *
+ * @return the build outcome, never {@code null}
+ */
+ @Nonnull
+ BuildStatus status();
+
+ /**
+ * Wall-clock duration of the entire build.
+ *
+ * @return the total duration, never {@code null}
+ */
+ @Nonnull
+ Duration duration();
+
+ /**
+ * When the build started (wall-clock time).
+ *
+ * @return the start instant, never {@code null}
+ */
+ @Nonnull
+ Instant startTime();
+
+ /**
+ * The Maven version that produced this report.
+ *
+ * @return the Maven version string, never {@code null}
+ */
+ @Nonnull
+ String mavenVersion();
+
+ /**
+ * The Java version used for the build.
+ *
+ * @return the Java version string, never {@code null}
+ */
+ @Nonnull
+ String javaVersion();
+
+ /**
+ * The goals or phases that were requested.
+ *
+ * @return the list of goals, never {@code null}
+ */
+ @Nonnull
+ List goals();
+
+ /**
+ * The GAV of the top-level project ({@code groupId:artifactId:version}).
+ *
+ * @return the project identifier, never {@code null}
+ */
+ @Nonnull
+ String project();
+
+ /**
+ * Whether this was a multi-module (reactor) build.
+ *
+ * @return {@code true} for multi-module builds
+ */
+ boolean multiModule();
+
+ /**
+ * The degree of concurrency ({@code -T} flag), or 1 for sequential builds.
+ *
+ * @return the thread count
+ */
+ int threads();
+
+ /**
+ * Per-module build results, in reactor execution order.
+ *
+ * @return the module reports, never {@code null}
+ */
+ @Nonnull
+ List modules();
+
+ /**
+ * Failures that occurred during the build, if any.
+ *
+ * @return the failure reports, never {@code null}; empty if the build succeeded
+ */
+ @Nonnull
+ List failures();
+
+ /**
+ * Structured problems (warnings, errors) reported during the build by
+ * Maven itself or by plugins.
+ *
+ * @return the problems, never {@code null}; empty if none were reported
+ * @since 4.1.0
+ */
+ @Nonnull
+ List problems();
+
+ /**
+ * Structured log events captured outside of any module's lifecycle —
+ * Maven startup messages, reactor ordering, and the final reactor summary.
+ *
+ * For per-module events see {@link ModuleReport#output()}, and for
+ * per-mojo events see {@link MojoReport#output()}.
+ *
+ * Together, {@code BuildReport.output()}, {@code ModuleReport.output()},
+ * and {@code MojoReport.output()} form a non-overlapping partition of
+ * the full build log.
+ *
+ * @return the captured log events, never {@code null}; may be empty
+ */
+ @Nonnull
+ List output();
+
+ /**
+ * Find a module report by its GAV identifier.
+ *
+ * The identifier format is {@code "groupId:artifactId:version"}, matching
+ * the format returned by {@link ModuleReport#id()} and used in
+ * {@link FailureReport#module()}.
+ *
+ * @param moduleId the module GAV string
+ * (e.g. {@code "org.apache.maven:maven-core:4.1.0-SNAPSHOT"})
+ * @return the matching module report, or empty if not found
+ */
+ @Nonnull
+ default Optional findModule(String moduleId) {
+ Objects.requireNonNull(moduleId);
+ return modules().stream().filter(m -> moduleId.equals(m.id())).findFirst();
+ }
+
+ /**
+ * Find the module report that corresponds to a given failure.
+ *
+ * @param failure the failure report
+ * @return the matching module report, or empty if not found
+ */
+ @Nonnull
+ default Optional findModule(FailureReport failure) {
+ Objects.requireNonNull(failure);
+ return findModule(failure.module());
+ }
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildStatus.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildStatus.java
new file mode 100644
index 000000000000..1ee25f8cb3a6
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildStatus.java
@@ -0,0 +1,44 @@
+/*
+ * 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;
+
+/**
+ * The outcome of a build, module, or mojo execution.
+ *
+ * @since 4.1.0
+ */
+@Experimental
+public enum BuildStatus {
+ /**
+ * Completed successfully.
+ */
+ SUCCESS,
+
+ /**
+ * Failed with an error.
+ */
+ FAILURE,
+
+ /**
+ * Skipped (e.g. because a dependency failed).
+ */
+ SKIPPED
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/FailureReport.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/FailureReport.java
new file mode 100644
index 000000000000..20e5d95b8aea
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/FailureReport.java
@@ -0,0 +1,89 @@
+/*
+ * 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.Nonnull;
+import org.apache.maven.api.annotations.Nullable;
+
+/**
+ * Details about a build failure.
+ *
+ * @since 4.1.0
+ * @see BuildReport#failures()
+ */
+@Experimental
+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..e8575ded3d01
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java
@@ -0,0 +1,167 @@
+/*
+ * 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.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
+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 formatted 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}
+ * @since 4.1.0
+ */
+ @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}
+ * @since 4.1.0
+ */
+ @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
+ * @since 4.1.0
+ */
+ 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, always non-negative
+ * @since 4.1.0
+ */
+ default long sequenceNumber() {
+ return -1;
+ }
+}
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..684ea610a5bc
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java
@@ -0,0 +1,36 @@
+/*
+ * 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;
+
+/**
+ * Log severity levels, mirroring the standard SLF4J levels.
+ *
+ * @since 4.1.0
+ * @see LogEvent#level()
+ */
+@Experimental
+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..7746545a4217
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/ModuleReport.java
@@ -0,0 +1,134 @@
+/*
+ * 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.Nonnull;
+
+/**
+ * Build results for a single module in a reactor build.
+ *
+ * @since 4.1.0
+ * @see BuildReport#modules()
+ */
+@Experimental
+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..76001babbd6b
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/MojoReport.java
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.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.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
+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/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java b/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java
index 50627efd86e3..9c020506f151 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java
@@ -36,6 +36,51 @@
@Experimental
@Provider
public interface Log {
+ /**
+ * {@return true if the trace error level is enabled}
+ * @since 4.1.0
+ */
+ boolean isTraceEnabled();
+
+ /**
+ * Sends a message to the user in the trace error level.
+ *
+ * Trace is intended for Maven core internals and low-level framework
+ * diagnostics. Plugin authors should normally use {@link #debug} for
+ * developer-facing diagnostic output.
+ *
+ * @param content the message to log
+ * @since 4.1.0
+ */
+ void trace(CharSequence content);
+
+ /**
+ * Sends a message (and accompanying exception) to the user at the trace error level.
+ *
+ * @param content the message to log
+ * @param error the error that caused this log
+ * @since 4.1.0
+ */
+ void trace(CharSequence content, Throwable error);
+
+ /**
+ * Sends an exception to the user in the trace error level.
+ *
+ * @param error the error that caused this log
+ * @since 4.1.0
+ */
+ void trace(Throwable error);
+
+ /**
+ * @since 4.1.0
+ */
+ void trace(Supplier content);
+
+ /**
+ * @since 4.1.0
+ */
+ void trace(Supplier content, Throwable error);
+
/**
* {@return true if the debug error level is enabled}
*/
@@ -43,6 +88,10 @@ public interface Log {
/**
* Sends a message to the user in the debug error level.
+ *
+ * Debug is the recommended level for diagnostic output that helps
+ * plugin users troubleshoot build problems (e.g. resolved paths,
+ * computed values). For Maven core internals, prefer {@link #trace}.
*
* @param content the message to log
*/
@@ -167,4 +216,21 @@ public interface Log {
void error(Supplier content);
void error(Supplier content, Throwable error);
+
+ /**
+ * Returns a child logger whose name is derived from this logger's name
+ * by appending {@code "." + name}. This allows plugins to create
+ * sub-loggers for different concerns while keeping hierarchical level
+ * control (e.g. setting the level for the parent silences the children).
+ *
+ * The default implementation returns {@code this} so that existing
+ * implementations continue to work without changes.
+ *
+ * @param name the child logger name segment (must not be {@code null})
+ * @return a child {@code Log}, never {@code null}
+ * @since 4.1.0
+ */
+ default Log child(String name) {
+ return this;
+ }
}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/services/BuilderProblem.java b/api/maven-api-core/src/main/java/org/apache/maven/api/services/BuilderProblem.java
index 9e4cb45ae9dc..724bbd2f2a93 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/services/BuilderProblem.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/services/BuilderProblem.java
@@ -22,15 +22,25 @@
import org.apache.maven.api.annotations.Immutable;
import org.apache.maven.api.annotations.Nonnull;
import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.annotations.ThreadSafe;
/**
- * Describes a problem that was encountered during project building. A problem can either be an exception that was
- * thrown or a simple string message. In addition, a problem carries a hint about its source.
+ * Describes a problem that was encountered during project building or
+ * build execution. A problem can either be an exception that was thrown
+ * or a simple string message. In addition, a problem carries a hint
+ * about its source.
+ *
+ * Since 4.1.0, problems can optionally carry a deduplication
+ * {@link #getKey() key}, an actionable {@link #getSuggestion() suggestion},
+ * and a {@link #getDocumentationUrl() documentation URL}. These fields
+ * enable richer build reports and a deduplicated warning summary at
+ * the end of the build.
*
* @since 4.0.0
*/
@Experimental
@Immutable
+@ThreadSafe
public interface BuilderProblem {
/**
@@ -94,6 +104,68 @@ public interface BuilderProblem {
@Nonnull
Severity getSeverity();
+ /**
+ * Gets a stable deduplication key for this problem.
+ *
+ * When multiple modules produce the same warning (e.g. a deprecated
+ * POM element), reporting it with the same key allows the build report
+ * to count occurrences instead of repeating the message. A key such as
+ * {@code "deprecated-modules"} or {@code "compiler.unchecked:Foo.java:42"}
+ * should be unique per logical problem but identical across modules
+ * that encounter the same issue.
+ *
+ * If this returns {@code null}, the problem is not deduplicated.
+ *
+ * @return the deduplication key, or {@code null} if not applicable
+ * @since 4.1.0
+ */
+ @Nullable
+ default String getKey() {
+ return null;
+ }
+
+ /**
+ * Gets an actionable suggestion for resolving this problem.
+ *
+ * For example, a deprecation warning for {@code } might
+ * suggest {@code "Use instead of "}.
+ *
+ * @return the suggestion text, or {@code null} if no suggestion is available
+ * @since 4.1.0
+ */
+ @Nullable
+ default String getSuggestion() {
+ return null;
+ }
+
+ /**
+ * Gets a URL pointing to documentation relevant to this problem.
+ *
+ * For example, a warning about the deprecated {@code system} scope
+ * might link to the Maven dependency scope migration guide.
+ *
+ * @return the documentation URL, or {@code null} if not available
+ * @since 4.1.0
+ */
+ @Nullable
+ default String getDocumentationUrl() {
+ return null;
+ }
+
+ /**
+ * Creates a new builder for constructing {@link BuilderProblem} instances.
+ *
+ * This is the recommended way for plugins and extensions to create
+ * structured problems to report via {@link DiagnosticReporter}.
+ *
+ * @return a new builder, never {@code null}
+ * @since 4.1.0
+ */
+ @Nonnull
+ static Builder builder() {
+ return new Builder();
+ }
+
/**
* The different severity levels for a problem, in decreasing order.
*
@@ -103,6 +175,206 @@ public interface BuilderProblem {
enum Severity {
FATAL, //
ERROR, //
- WARNING //
+ WARNING, //
+ INFO //
+ }
+
+ /**
+ * A builder for constructing immutable {@link BuilderProblem} instances.
+ *
+ * Example usage:
+ *
{@code
+ * BuilderProblem problem = BuilderProblem.builder()
+ * .severity(Severity.WARNING)
+ * .message("source/target value 8 is obsolete")
+ * .key("compiler:obsolete-source-target")
+ * .source("maven-compiler-plugin:3.15.0:compile")
+ * .suggestion("Update maven.compiler.source to 11 or higher")
+ * .documentationUrl("https://maven.apache.org/plugins/maven-compiler-plugin/")
+ * .build();
+ * }
+ *
+ * @since 4.1.0
+ */
+ final class Builder {
+ private String source = "";
+ private int lineNumber = -1;
+ private int columnNumber = -1;
+ private Exception exception;
+ private String message = "";
+ private Severity severity = Severity.WARNING;
+ private String key;
+ private String suggestion;
+ private String documentationUrl;
+
+ Builder() {}
+
+ @Nonnull
+ public Builder source(@Nullable String source) {
+ this.source = source != null ? source : "";
+ return this;
+ }
+
+ @Nonnull
+ public Builder lineNumber(int lineNumber) {
+ this.lineNumber = lineNumber;
+ return this;
+ }
+
+ @Nonnull
+ public Builder columnNumber(int columnNumber) {
+ this.columnNumber = columnNumber;
+ return this;
+ }
+
+ @Nonnull
+ public Builder exception(@Nullable Exception exception) {
+ this.exception = exception;
+ return this;
+ }
+
+ @Nonnull
+ public Builder message(@Nonnull String message) {
+ this.message = message;
+ return this;
+ }
+
+ @Nonnull
+ public Builder severity(@Nonnull Severity severity) {
+ this.severity = severity;
+ return this;
+ }
+
+ @Nonnull
+ public Builder key(@Nullable String key) {
+ this.key = key;
+ return this;
+ }
+
+ @Nonnull
+ public Builder suggestion(@Nullable String suggestion) {
+ this.suggestion = suggestion;
+ return this;
+ }
+
+ @Nonnull
+ public Builder documentationUrl(@Nullable String documentationUrl) {
+ this.documentationUrl = documentationUrl;
+ return this;
+ }
+
+ @Nonnull
+ public BuilderProblem build() {
+ return new DefaultProblem(
+ source, lineNumber, columnNumber, exception, message, severity, key, suggestion, documentationUrl);
+ }
+
+ /**
+ * Immutable problem implementation returned by the builder.
+ * This is intentionally package-private — callers use the
+ * {@link BuilderProblem} interface.
+ */
+ @SuppressWarnings("checkstyle:ParameterNumber")
+ private record DefaultProblem(
+ String source,
+ int lineNumber,
+ int columnNumber,
+ Exception exception,
+ String message,
+ Severity severity,
+ String key,
+ String suggestion,
+ String documentationUrl)
+ implements BuilderProblem {
+
+ @Override
+ @Nonnull
+ public String getSource() {
+ return source != null ? source : "";
+ }
+
+ @Override
+ public int getLineNumber() {
+ return lineNumber;
+ }
+
+ @Override
+ public int getColumnNumber() {
+ return columnNumber;
+ }
+
+ @Override
+ @Nonnull
+ public String getLocation() {
+ StringBuilder buffer = new StringBuilder(256);
+ if (source != null && !source.isEmpty()) {
+ buffer.append(source);
+ }
+ if (lineNumber > 0) {
+ if (!buffer.isEmpty()) {
+ buffer.append(", ");
+ }
+ buffer.append("line ").append(lineNumber);
+ }
+ if (columnNumber > 0) {
+ if (!buffer.isEmpty()) {
+ buffer.append(", ");
+ }
+ buffer.append("column ").append(columnNumber);
+ }
+ return buffer.toString();
+ }
+
+ @Override
+ @Nullable
+ public Exception getException() {
+ return exception;
+ }
+
+ @Override
+ @Nonnull
+ public String getMessage() {
+ return message != null ? message : "";
+ }
+
+ @Override
+ @Nonnull
+ public Severity getSeverity() {
+ return severity != null ? severity : Severity.WARNING;
+ }
+
+ @Override
+ @Nullable
+ public String getKey() {
+ return key;
+ }
+
+ @Override
+ @Nullable
+ public String getSuggestion() {
+ return suggestion;
+ }
+
+ @Override
+ @Nullable
+ public String getDocumentationUrl() {
+ return documentationUrl;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder buffer = new StringBuilder(128);
+ buffer.append('[').append(getSeverity()).append("]");
+ String msg = getMessage();
+ if (!msg.isEmpty()) {
+ buffer.append(" ").append(msg);
+ }
+ String loc = getLocation();
+ if (!loc.isEmpty()) {
+ buffer.append(" @ ").append(loc);
+ }
+ return buffer.toString();
+ }
+ }
}
}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/services/DiagnosticReporter.java b/api/maven-api-core/src/main/java/org/apache/maven/api/services/DiagnosticReporter.java
new file mode 100644
index 000000000000..b361455019af
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/services/DiagnosticReporter.java
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.api.services;
+
+import org.apache.maven.api.Service;
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.annotations.ThreadSafe;
+
+/**
+ * Service for reporting structured build diagnostics (warnings, errors,
+ * informational messages) that will appear in the build report and in
+ * the {@code mvnlog} output.
+ *
+ * This is the recommended way for Maven 4 plugins and extensions to
+ * report build problems with structured metadata (deduplication key,
+ * actionable suggestion, documentation URL). Problems reported through
+ * this service are:
+ *
+ *
Deduplicated by {@link BuilderProblem#getKey() key} across modules
+ *
Included in the JSON build report ({@code build-report-*.json})
+ *
Shown in the warning summary at the end of the build
+ *
Displayed with structured details by {@code mvnlog --diagnostics}
+ *
+ *
+ * Plugins can inject this service via {@code @Inject} or retrieve it
+ * from the session:
+ *
+ * If the problem has a non-null {@link BuilderProblem#getKey() key}
+ * and a problem with the same key has already been reported, the
+ * duplicate is counted but not stored again.
+ *
+ * @param problem the problem to report; must not be {@code null}
+ */
+ void report(@Nonnull BuilderProblem problem);
+
+ /**
+ * Convenience method to report a warning with all structured fields.
+ *
+ * @param message the warning message
+ * @param key deduplication key, or {@code null}
+ * @param source source hint (e.g. plugin GAV), or {@code null}
+ * @param suggestion actionable fix suggestion, or {@code null}
+ * @param documentationUrl URL to relevant documentation, or {@code null}
+ */
+ default void warning(
+ @Nonnull String message,
+ @Nullable String key,
+ @Nullable String source,
+ @Nullable String suggestion,
+ @Nullable String documentationUrl) {
+ report(BuilderProblem.builder()
+ .severity(BuilderProblem.Severity.WARNING)
+ .message(message)
+ .key(key)
+ .source(source)
+ .suggestion(suggestion)
+ .documentationUrl(documentationUrl)
+ .build());
+ }
+
+ /**
+ * Convenience method to report a simple warning message.
+ *
+ * @param message the warning message
+ */
+ default void warning(@Nonnull String message) {
+ warning(message, null, null, null, null);
+ }
+}
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/MavenLogCling.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/MavenLogCling.java
new file mode 100644
index 000000000000..baeda012571c
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/MavenLogCling.java
@@ -0,0 +1,95 @@
+/*
+ * 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;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.cli.Invoker;
+import org.apache.maven.api.cli.Parser;
+import org.apache.maven.api.cli.ParserRequest;
+import org.apache.maven.cling.invoker.ProtoLookup;
+import org.apache.maven.cling.invoker.mvnlog.LogInvoker;
+import org.apache.maven.cling.invoker.mvnlog.LogParser;
+import org.codehaus.plexus.classworlds.ClassWorld;
+
+/**
+ * Maven build log viewer CLI ("new-gen").
+ *
+ * Displays formatted summaries of previous Maven build reports.
+ * Invoked via {@code mvnlog} or {@code mvn --log}.
+ *
+ * @since 4.1.0
+ */
+public class MavenLogCling extends ClingSupport {
+ /**
+ * "Normal" Java entry point. Note: Maven uses ClassWorld Launcher and this entry point is NOT used under normal
+ * circumstances.
+ */
+ public static void main(String[] args) throws IOException {
+ int exitCode = new MavenLogCling().run(args, null, null, null, false);
+ System.exit(exitCode);
+ }
+
+ /**
+ * ClassWorld Launcher "enhanced" entry point: returning exitCode and accepts Class World.
+ */
+ public static int main(String[] args, ClassWorld world) throws IOException {
+ return new MavenLogCling(world).run(args, null, null, null, false);
+ }
+
+ /**
+ * ClassWorld Launcher "embedded" entry point: returning exitCode and accepts Class World and streams.
+ */
+ public static int main(
+ String[] args,
+ ClassWorld world,
+ @Nullable InputStream stdIn,
+ @Nullable OutputStream stdOut,
+ @Nullable OutputStream stdErr)
+ throws IOException {
+ return new MavenLogCling(world).run(args, stdIn, stdOut, stdErr, true);
+ }
+
+ public MavenLogCling() {
+ super();
+ }
+
+ public MavenLogCling(ClassWorld classWorld) {
+ super(classWorld);
+ }
+
+ @Override
+ protected Invoker createInvoker() {
+ return new LogInvoker(
+ ProtoLookup.builder().addMapping(ClassWorld.class, classWorld).build(), null);
+ }
+
+ @Override
+ protected Parser createParser() {
+ return new LogParser();
+ }
+
+ @Override
+ protected ParserRequest.Builder createParserRequestBuilder(String[] args) {
+ return ParserRequest.mvnlog(args, createMessageBuilderFactory());
+ }
+}
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 b33cb1f6543e..e08621fa600c 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
@@ -31,6 +31,7 @@
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;
@@ -304,6 +305,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..f44e9fa91ad1
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineExecutionEventLogger.java
@@ -0,0 +1,306 @@
+/*
+ * 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.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 volatile int totalProjects;
+ private volatile int currentVisitedProjectCount;
+ 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 = allProjects.size();
+ currentVisitedProjectCount = 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)
+ .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)
+ .field("passed", passed)
+ .field("failed", failed)
+ .field("skipped", skipped)
+ .build());
+ }
+
+ // ---- Module lifecycle ----
+
+ @Override
+ public void projectStarted(ExecutionEvent event) {
+ MavenProject project = event.getProject();
+ int index;
+ synchronized (this) {
+ index = ++currentVisitedProjectCount;
+ }
+
+ 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)
+ .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.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.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.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..f35588ff2e9e
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/PlainExecutionEventLogger.java
@@ -0,0 +1,323 @@
+/*
+ * 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 volatile 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++;
+ }
+ }
+
+ logger.info(buffer.toString());
+
+ // Compact stats line: "12 modules | 11 passed | 1 failed | 0 skipped"
+ 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..dc22c07d3b94
--- /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 volatile int completedProjects;
+ 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 = 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++;
+ 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).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 * 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)
+ .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) {
+ s.append(GREEN).append("✓ ").append(RESET);
+ } else {
+ s.append(DIM).append("○ ").append(RESET);
+ }
+ }
+ }
+
+ // Progress counter
+ s.append('[');
+ s.append(BOLD)
+ .append(completedProjects)
+ .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..87213a088a2c
--- /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 volatile 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 c417f24f40f0..d75f07b81aec 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,26 @@ 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 warningMode() {
+ if (commandLine.hasOption(CLIManager.WARNING_MODE)) {
+ return Optional.of(commandLine.getOptionValue(CLIManager.WARNING_MODE));
+ }
+ return Optional.empty();
+ }
+
@Override
public Optional offline() {
if (commandLine.hasOption(CLIManager.OFFLINE)) {
@@ -315,6 +335,8 @@ 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 WARNING_MODE = "warning-mode";
public static final String OFFLINE = "o";
public static final String HELP = "h";
@@ -327,6 +349,7 @@ protected static class CLIManager {
public static final String UPGRADE = "up";
public static final String SHELL = "shell";
public static final String YJP = "yjp";
+ public static final String LOG = "log";
// deprecated ones
@Deprecated
@@ -344,6 +367,7 @@ protected CLIManager() {
prepareOptions(options);
}
+ @SuppressWarnings("checkstyle:MethodLength")
protected void prepareOptions(org.apache.commons.cli.Options options) {
options.addOption(Option.builder(HELP)
.longOpt("help")
@@ -432,6 +456,23 @@ 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()
+ .longOpt(WARNING_MODE)
+ .hasArg()
+ .desc("Controls how build warnings are displayed."
+ + " Supported modes: 'summary' (default, deduplicated summary at end),"
+ + " 'all' (inline + summary), 'none' (suppress), 'fail' (treat warnings as errors).")
+ .get());
options.addOption(Option.builder(OFFLINE)
.longOpt("offline")
.desc("Work offline")
@@ -458,6 +499,10 @@ protected void prepareOptions(org.apache.commons.cli.Options options) {
.longOpt(YJP)
.desc("Launch the JVM with Yourkit profiler (script option).")
.get());
+ options.addOption(Option.builder()
+ .longOpt(LOG)
+ .desc("Launch the Maven Build Log Viewer (script option).")
+ .get());
// Deprecated
options.addOption(Option.builder(ALTERNATE_GLOBAL_SETTINGS)
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..09081eb5dd0f 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,16 @@ public Optional color() {
return returnFirstPresentOrEmpty(Options::color);
}
+ @Override
+ public Optional console() {
+ return returnFirstPresentOrEmpty(Options::console);
+ }
+
+ @Override
+ public Optional warningMode() {
+ return returnFirstPresentOrEmpty(Options::warningMode);
+ }
+
@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 633b0b8b4f5c..4ad85457b01b 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
@@ -83,6 +83,7 @@
import org.apache.maven.logging.ProjectBuildLogAppender;
import org.apache.maven.logging.SimpleBuildEventListener;
import org.apache.maven.logging.api.LogLevelRecorder;
+import org.apache.maven.slf4j.MavenJulHandler;
import org.apache.maven.slf4j.MavenSimpleLogger;
import org.codehaus.plexus.PlexusContainer;
import org.jline.terminal.Terminal;
@@ -91,7 +92,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;
@@ -155,6 +155,7 @@ protected int doInvoke(C context) throws Exception {
pushUserProperties(context);
setupGuiceClassLoading(context);
configureLogging(context);
+ preliminaryInteractiveDetection(context);
createTerminal(context);
activateLogging(context);
helpOrVersionAndMayExit(context);
@@ -301,6 +302,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);
@@ -440,9 +465,8 @@ protected Consumer doDetermineWriter(C context) {
}
protected void activateLogging(C context) throws Exception {
- if (!SLF4JBridgeHandler.isInstalled()) {
- SLF4JBridgeHandler.removeHandlersForRootLogger();
- SLF4JBridgeHandler.install();
+ if (!MavenJulHandler.isInstalled()) {
+ MavenJulHandler.install();
}
context.slf4jConfiguration.activate();
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..25bcf8ab60c0 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,11 @@ protected void populateRequest(MavenContext context, Lookup lookup, MavenExecuti
}
}
+ // Propagate warning mode and diagnostic suppression to the session so
+ // BuildReportCollector (an EventSpy) can read them from user properties
+ String warningMode = context.options().warningMode().orElse("summary");
+ request.getUserProperties().put("maven.build.warningMode", warningMode);
+
request.setTransferListener(determineTransferListener(
context, context.options().noTransferProgress().orElse(false)));
request.setExecutionListener(determineExecutionListener(context));
@@ -343,21 +354,102 @@ 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 (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/java/org/apache/maven/cling/invoker/mvnlog/BuildReportFilter.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/BuildReportFilter.java
new file mode 100644
index 000000000000..d48215ece342
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/BuildReportFilter.java
@@ -0,0 +1,306 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.cling.invoker.mvnlog;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * Applies structural filters to a parsed build report (JSON as {@code Map}).
+ *
+ * Filters are applied in order:
+ *
+ *
{@code --module}: keep only modules whose {@code artifactId} contains the pattern
+ *
{@code --mojo}: within each module, keep only mojos whose {@code goal} contains the pattern
+ *
{@code --level}: within each mojo and module, keep only log events at or above the level
+ *
{@code --grep}: within each mojo and module, keep only log events whose message matches
+ *
+ * All string matching is case-insensitive.
+ *
+ * @since 4.1.0
+ */
+final class BuildReportFilter {
+
+ /**
+ * Log level ordinals for severity comparison.
+ * Higher value = more severe.
+ */
+ private static final Map LEVEL_ORDINALS = Map.of(
+ "TRACE", 0,
+ "DEBUG", 1,
+ "INFO", 2,
+ "WARN", 3,
+ "WARNING", 3,
+ "ERROR", 4);
+
+ private final String modulePattern;
+ private final String mojoPattern;
+ private final String levelFilter;
+ private final String grepPattern;
+
+ BuildReportFilter(String modulePattern, String mojoPattern, String levelFilter, String grepPattern) {
+ this.modulePattern = modulePattern != null ? modulePattern.toLowerCase(Locale.ROOT) : null;
+ this.mojoPattern = mojoPattern != null ? mojoPattern.toLowerCase(Locale.ROOT) : null;
+ this.levelFilter = levelFilter != null ? levelFilter.toUpperCase(Locale.ROOT) : null;
+ this.grepPattern = grepPattern != null ? grepPattern.toLowerCase(Locale.ROOT) : null;
+ }
+
+ /**
+ * Returns {@code true} if any filter is active.
+ */
+ boolean hasFilters() {
+ return modulePattern != null || mojoPattern != null || levelFilter != null || grepPattern != null;
+ }
+
+ /**
+ * Returns {@code true} if log-event-level filters are active
+ * ({@code --level} or {@code --grep}).
+ */
+ boolean hasLogFilters() {
+ return levelFilter != null || grepPattern != null;
+ }
+
+ /**
+ * Apply all active filters to the report, returning a new report map
+ * with only the matching entries. The original map is not modified.
+ */
+ @SuppressWarnings("unchecked")
+ Map apply(Map report) {
+ if (!hasFilters()) {
+ return report;
+ }
+
+ Map result = new LinkedHashMap<>(report);
+
+ // Filter modules
+ Object modulesObj = result.get("modules");
+ if (modulesObj instanceof List) {
+ List