You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by gn...@apache.org on 2026/08/28 10:22:00 UTC
(maven) 05/07: mvnlog: build log viewer, integration tests, script routing
This is an automated email from the ASF dual-hosted git repository.
gnodet pushed a commit to branch feature/mvnlog-web
in repository https://gitbox.apache.org/repos/asf/maven.git
commit d76d6c1de60c89e67b460bb965fc66c2afd2377d
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Sun Aug 9 10:10:32 2026 +0200
mvnlog: build log viewer, integration tests, script routing
- mvnlog / mvnlog --json CLI viewer for build-report JSON files
- BuildReportRenderer with ANSI colors, timing, failure details
- SimpleJsonReader dependency-free streaming JSON parser
- mvn --log routes to MavenLogCling, mvnlog shell scripts
- MavenITgh12571BuildReportTest: 8 ITs covering build report, console modes,
warning mode, version info on failure, and mvnlog viewer
---
apache-maven/src/assembly/component.xml | 1 +
apache-maven/src/assembly/maven/bin/mvn | 18 +
apache-maven/src/assembly/maven/bin/mvn.cmd | 2 +
apache-maven/src/assembly/maven/bin/mvnlog | 30 +
apache-maven/src/assembly/maven/bin/mvnlog.cmd | 39 ++
.../org/apache/maven/api/cli/ParserRequest.java | 24 +
.../main/java/org/apache/maven/api/cli/Tools.java | 3 +
.../apache/maven/api/cli/mvnlog/LogOptions.java | 115 ++++
.../java/org/apache/maven/cling/MavenLogCling.java | 95 +++
.../maven/cling/invoker/CommonsCliOptions.java | 5 +
.../cling/invoker/mvnlog/BuildReportFilter.java | 306 ++++++++++
.../cling/invoker/mvnlog/BuildReportRenderer.java | 636 +++++++++++++++++++++
.../cling/invoker/mvnlog/CommonsCliLogOptions.java | 217 +++++++
.../maven/cling/invoker/mvnlog/LogContext.java | 35 +-
.../maven/cling/invoker/mvnlog/LogInvoker.java | 199 +++++++
.../maven/cling/invoker/mvnlog/LogParser.java | 36 +-
.../cling/invoker/mvnlog/SimpleJsonReader.java | 276 +++++++++
.../cling/invoker/mvnlog/SimpleJsonWriter.java | 154 +++++
.../BuiltinShellCommandRegistryFactory.java | 26 +
.../invoker/mvnlog/BuildReportFilterTest.java | 242 ++++++++
.../invoker/mvnlog/BuildReportRendererTest.java | 250 ++++++++
.../cling/invoker/mvnlog/SimpleJsonReaderTest.java | 141 +++++
.../cling/invoker/mvnlog/SimpleJsonWriterTest.java | 116 ++++
its/core-it-suite/pom.xml | 6 +-
.../maven/it/MavenITgh12571BuildReportTest.java | 423 ++++++++++++++
.../maven/it/MavenITmng5760ResumeFeatureTest.java | 11 +
...enITmng5965ParallelBuildMultipliesWorkTest.java | 1 +
.../it/MavenITmng6057CheckReactorOrderTest.java | 1 +
.../maven/it/MavenITmng6065FailOnSeverityTest.java | 2 +
.../it/MavenITmng6118SubmoduleInvocation.java | 6 +
.../maven/it/MavenITmng6391PrintVersionTest.java | 2 +
...ecuteAnnotationShouldNotReExecuteGoalsTest.java | 3 +
...ng6981ProjectListShouldIncludeChildrenTest.java | 2 +
.../it/MavenITmng7353CliGoalInvocationTest.java | 1 +
.../it/MavenITmng7804PluginExecutionOrderTest.java | 1 +
.../apache/maven/it/MavenITmng8594AtFileTest.java | 1 +
.../test/resources/gh-12571-build-report/pom.xml | 28 +
.../gh-12571-multi-module/module-a/pom.xml | 31 +
.../gh-12571-multi-module/module-b/pom.xml | 31 +
.../test/resources/gh-12571-multi-module/pom.xml | 33 ++
40 files changed, 3503 insertions(+), 46 deletions(-)
diff --git a/apache-maven/src/assembly/component.xml b/apache-maven/src/assembly/component.xml
index 5f55a310c8..ce303ea496 100644
--- a/apache-maven/src/assembly/component.xml
+++ b/apache-maven/src/assembly/component.xml
@@ -87,6 +87,7 @@ under the License.
<includes>
<include>mvn</include>
<include>mvnenc</include>
+ <include>mvnlog</include>
<include>mvnsh</include>
<include>mvnup</include>
<include>mvnDebug</include>
diff --git a/apache-maven/src/assembly/maven/bin/mvn b/apache-maven/src/assembly/maven/bin/mvn
index 0adc4eabeb..93127f8d8e 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 74d4a5a984..7e3c8bdae8 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 0000000000..8170bdb167
--- /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 0000000000..7069255cd8
--- /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/ParserRequest.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/ParserRequest.java
index ee25ec63da..848151bc25 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<String> 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<String> 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 7559d7ffee..136268657a 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 0000000000..9d7a07c0aa
--- /dev/null
+++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/LogOptions.java
@@ -0,0 +1,115 @@
+/*
+ * 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<Boolean> 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<Boolean> 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<Boolean> 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<Boolean> 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<Boolean> 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<String> 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<String> 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<String> 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<String> 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<String> grep();
+}
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 0000000000..baeda01257
--- /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").
+ * <p>
+ * 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/invoker/CommonsCliOptions.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java
index 77d5faac61..d75f07b81a 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
@@ -349,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
@@ -498,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/mvnlog/BuildReportFilter.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/BuildReportFilter.java
new file mode 100644
index 0000000000..d48215ece3
--- /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<String, Object>}).
+ * <p>
+ * Filters are applied in order:
+ * <ol>
+ * <li>{@code --module}: keep only modules whose {@code artifactId} contains the pattern</li>
+ * <li>{@code --mojo}: within each module, keep only mojos whose {@code goal} contains the pattern</li>
+ * <li>{@code --level}: within each mojo and module, keep only log events at or above the level</li>
+ * <li>{@code --grep}: within each mojo and module, keep only log events whose message matches</li>
+ * </ol>
+ * 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<String, Integer> 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<String, Object> apply(Map<String, Object> report) {
+ if (!hasFilters()) {
+ return report;
+ }
+
+ Map<String, Object> result = new LinkedHashMap<>(report);
+
+ // Filter modules
+ Object modulesObj = result.get("modules");
+ if (modulesObj instanceof List) {
+ List<Map<String, Object>> modules = (List<Map<String, Object>>) modulesObj;
+ List<Map<String, Object>> filtered = new ArrayList<>();
+
+ for (Map<String, Object> module : modules) {
+ // --module filter: match on artifactId
+ if (modulePattern != null) {
+ String artifactId = getString(module, "artifactId");
+ if (artifactId == null
+ || !artifactId.toLowerCase(Locale.ROOT).contains(modulePattern)) {
+ continue;
+ }
+ }
+
+ Map<String, Object> filteredModule = new LinkedHashMap<>(module);
+
+ // --mojo filter: keep only matching mojos
+ if (mojoPattern != null) {
+ Object mojosObj = filteredModule.get("mojos");
+ if (mojosObj instanceof List) {
+ List<Map<String, Object>> mojos = (List<Map<String, Object>>) mojosObj;
+ List<Map<String, Object>> filteredMojos = new ArrayList<>();
+ for (Map<String, Object> mojo : mojos) {
+ String goal = getString(mojo, "goal");
+ if (goal != null && goal.toLowerCase(Locale.ROOT).contains(mojoPattern)) {
+ filteredMojos.add(filterMojoLogEvents(mojo));
+ }
+ }
+ filteredModule.put("mojos", filteredMojos);
+ }
+ } else if (hasLogFilters()) {
+ // Apply log filters to mojos even without --mojo
+ Object mojosObj = filteredModule.get("mojos");
+ if (mojosObj instanceof List) {
+ List<Map<String, Object>> mojos = (List<Map<String, Object>>) mojosObj;
+ List<Map<String, Object>> filteredMojos = new ArrayList<>();
+ for (Map<String, Object> mojo : mojos) {
+ filteredMojos.add(filterMojoLogEvents(mojo));
+ }
+ filteredModule.put("mojos", filteredMojos);
+ }
+ }
+
+ // Apply log filters to module-level output
+ if (hasLogFilters()) {
+ filteredModule.put("output", filterLogEvents(getLogEvents(filteredModule, "output")));
+ }
+
+ filtered.add(filteredModule);
+ }
+
+ result.put("modules", filtered);
+ }
+
+ // Apply log filters to build-level output
+ if (hasLogFilters()) {
+ result.put("output", filterLogEvents(getLogEvents(result, "output")));
+ }
+
+ return result;
+ }
+
+ /**
+ * Collect all log events matching the current filters from a report.
+ * Returns a flat list of log event maps, each annotated with a
+ * {@code "context"} field indicating where the event came from
+ * (module name, mojo goal, or build-level).
+ */
+ @SuppressWarnings("unchecked")
+ List<Map<String, Object>> collectMatchingLogEvents(Map<String, Object> report) {
+ List<Map<String, Object>> results = new ArrayList<>();
+
+ // Build-level log events (skip when filtering by module — build-level
+ // events don't belong to any module)
+ if (modulePattern == null) {
+ for (Map<String, Object> event : getLogEvents(report, "output")) {
+ if (matchesLogFilters(event)) {
+ Map<String, Object> annotated = new LinkedHashMap<>(event);
+ annotated.put("context", "build");
+ results.add(annotated);
+ }
+ }
+ }
+
+ // Module and mojo-level log events
+ Object modulesObj = report.get("modules");
+ if (modulesObj instanceof List) {
+ for (Map<String, Object> module : (List<Map<String, Object>>) modulesObj) {
+ String artifactId = getString(module, "artifactId");
+
+ // Check module filter
+ if (modulePattern != null) {
+ if (artifactId == null
+ || !artifactId.toLowerCase(Locale.ROOT).contains(modulePattern)) {
+ continue;
+ }
+ }
+
+ // Module-level output
+ for (Map<String, Object> event : getLogEvents(module, "output")) {
+ if (matchesLogFilters(event)) {
+ Map<String, Object> annotated = new LinkedHashMap<>(event);
+ annotated.put("context", artifactId != null ? artifactId : "unknown");
+ results.add(annotated);
+ }
+ }
+
+ // Mojo-level output
+ Object mojosObj = module.get("mojos");
+ if (mojosObj instanceof List) {
+ for (Map<String, Object> mojo : (List<Map<String, Object>>) mojosObj) {
+ String goal = getString(mojo, "goal");
+
+ // Check mojo filter
+ if (mojoPattern != null) {
+ if (goal == null || !goal.toLowerCase(Locale.ROOT).contains(mojoPattern)) {
+ continue;
+ }
+ }
+
+ String mojoLabel = (artifactId != null ? artifactId : "") + ":" + (goal != null ? goal : "");
+ for (Map<String, Object> event : getLogEvents(mojo, "output")) {
+ if (matchesLogFilters(event)) {
+ Map<String, Object> annotated = new LinkedHashMap<>(event);
+ annotated.put("context", mojoLabel);
+ results.add(annotated);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return results;
+ }
+
+ /**
+ * Filter the log events within a mojo entry.
+ */
+ private Map<String, Object> filterMojoLogEvents(Map<String, Object> mojo) {
+ if (!hasLogFilters()) {
+ return mojo;
+ }
+ Map<String, Object> filtered = new LinkedHashMap<>(mojo);
+ filtered.put("output", filterLogEvents(getLogEvents(mojo, "output")));
+ return filtered;
+ }
+
+ /**
+ * Filter a list of log events by level and grep pattern.
+ */
+ private List<Map<String, Object>> filterLogEvents(List<Map<String, Object>> events) {
+ List<Map<String, Object>> result = new ArrayList<>();
+ for (Map<String, Object> event : events) {
+ if (matchesLogFilters(event)) {
+ result.add(event);
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Check if a single log event matches the active level and grep filters.
+ */
+ private boolean matchesLogFilters(Map<String, Object> event) {
+ // Level filter
+ if (levelFilter != null) {
+ String eventLevel = getString(event, "level");
+ if (eventLevel == null || !isAtOrAbove(eventLevel, levelFilter)) {
+ return false;
+ }
+ }
+
+ // Grep filter
+ if (grepPattern != null) {
+ String message = getString(event, "message");
+ if (message == null || !message.toLowerCase(Locale.ROOT).contains(grepPattern)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns {@code true} if {@code eventLevel} is at or above {@code minLevel}
+ * in severity.
+ */
+ static boolean isAtOrAbove(String eventLevel, String minLevel) {
+ Integer eventOrd = LEVEL_ORDINALS.get(eventLevel.toUpperCase(Locale.ROOT));
+ Integer minOrd = LEVEL_ORDINALS.get(minLevel.toUpperCase(Locale.ROOT));
+ if (eventOrd == null || minOrd == null) {
+ return true; // unknown levels pass through
+ }
+ return eventOrd >= minOrd;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static List<Map<String, Object>> getLogEvents(Map<String, Object> container, String key) {
+ Object value = container.get(key);
+ if (value instanceof List) {
+ return (List<Map<String, Object>>) value;
+ }
+ return List.of();
+ }
+
+ private static String getString(Map<String, Object> map, String key) {
+ Object value = map.get(key);
+ return value != null ? value.toString() : null;
+ }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/BuildReportRenderer.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/BuildReportRenderer.java
new file mode 100644
index 0000000000..dc9e22c067
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/BuildReportRenderer.java
@@ -0,0 +1,636 @@
+/*
+ * 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.io.IOException;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+
+import org.apache.maven.api.services.MessageBuilder;
+import org.apache.maven.api.services.MessageBuilderFactory;
+
+/**
+ * Renders a build report (parsed from JSON into a {@code Map<String, Object>})
+ * as formatted terminal output.
+ * <p>
+ * Shared between the standalone {@code mvnlog} command and the {@code mvnsh} subcommand.
+ *
+ * @since 4.1.0
+ */
+public class BuildReportRenderer {
+
+ private static final int MAX_PADDED_BUILD_TIME_DURATION_LENGTH = 9;
+
+ private final MessageBuilderFactory messageBuilderFactory;
+ private final Consumer<String> output;
+
+ public BuildReportRenderer(MessageBuilderFactory messageBuilderFactory, Consumer<String> output) {
+ this.messageBuilderFactory = messageBuilderFactory;
+ this.output = output;
+ }
+
+ /**
+ * Render the default summary view of a build report.
+ */
+ @SuppressWarnings("unchecked")
+ public void renderSummary(Map<String, Object> report) {
+ renderHeader(report);
+
+ // Module summary
+ List<Map<String, Object>> modules = getList(report, "modules");
+ if (!modules.isEmpty()) {
+ for (Map<String, Object> module : modules) {
+ renderModuleLine(module);
+ }
+ output.accept("");
+
+ // Stats line
+ int passed = 0, failed = 0, skipped = 0;
+ for (Map<String, Object> module : modules) {
+ String status = getString(module, "status");
+ switch (status) {
+ case "SUCCESS":
+ passed++;
+ break;
+ case "FAILURE":
+ failed++;
+ break;
+ default:
+ skipped++;
+ break;
+ }
+ }
+ StringBuilder stats = new StringBuilder();
+ stats.append(modules.size()).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");
+ }
+ output.accept(stats.toString());
+ }
+
+ // Problems — show structured warnings/errors so the user doesn't have to re-run the build
+ List<Map<String, Object>> problems = getList(report, "problems");
+ if (!problems.isEmpty()) {
+ long warnings = problems.stream()
+ .filter(d -> "WARNING".equals(getString(d, "severity")))
+ .count();
+ long errors = problems.stream()
+ .filter(d -> "ERROR".equals(getString(d, "severity")))
+ .count();
+ if (warnings > 0 || errors > 0) {
+ output.accept("");
+ MessageBuilder header = messageBuilderFactory.builder();
+ header.a("Problems: ");
+ if (errors > 0) {
+ header.failure(errors + " error" + (errors > 1 ? "s" : ""));
+ }
+ if (errors > 0 && warnings > 0) {
+ header.a(", ");
+ }
+ if (warnings > 0) {
+ header.warning(warnings + " warning" + (warnings > 1 ? "s" : ""));
+ }
+ output.accept(header.toString());
+
+ // Show each problem with structured details
+ for (Map<String, Object> p : problems) {
+ renderProblemCompact(p);
+ }
+ }
+ }
+
+ // Failures count
+ List<Map<String, Object>> failures = getList(report, "failures");
+ if (!failures.isEmpty()) {
+ output.accept(messageBuilderFactory
+ .builder()
+ .failure(failures.size() + " failure" + (failures.size() > 1 ? "s" : ""))
+ .toString());
+ }
+
+ output.accept("");
+ String duration = getString(report, "duration");
+ output.accept("Total time: " + (duration != null ? formatDuration(duration) : "?"));
+ }
+
+ /**
+ * Render the detailed diagnostics view.
+ */
+ @SuppressWarnings("unchecked")
+ public void renderDiagnostics(Map<String, Object> report) {
+ renderHeader(report);
+
+ List<Map<String, Object>> problems = getList(report, "problems");
+ if (problems.isEmpty()) {
+ output.accept(messageBuilderFactory
+ .builder()
+ .success("No problems recorded.")
+ .toString());
+ return;
+ }
+
+ // Count by severity
+ long errors = problems.stream()
+ .filter(p -> "ERROR".equals(getString(p, "severity")))
+ .count();
+ long warnings = problems.stream()
+ .filter(p -> "WARNING".equals(getString(p, "severity")))
+ .count();
+ long infos = problems.size() - errors - warnings;
+
+ MessageBuilder header = messageBuilderFactory.builder();
+ header.strong("Problems (" + problems.size() + ")");
+ header.a(": ");
+ List<String> parts = new ArrayList<>();
+ if (errors > 0) {
+ parts.add(errors + " error" + (errors > 1 ? "s" : ""));
+ }
+ if (warnings > 0) {
+ parts.add(warnings + " warning" + (warnings > 1 ? "s" : ""));
+ }
+ if (infos > 0) {
+ parts.add(infos + " info");
+ }
+ header.a(String.join(", ", parts));
+ output.accept(header.toString());
+ output.accept("");
+
+ for (Map<String, Object> problem : problems) {
+ renderProblemDetailed(problem);
+ }
+ }
+
+ /**
+ * Render the detailed failures view.
+ */
+ @SuppressWarnings("unchecked")
+ public void renderFailures(Map<String, Object> report) {
+ renderHeader(report);
+
+ List<Map<String, Object>> failures = getList(report, "failures");
+ if (failures.isEmpty()) {
+ output.accept(messageBuilderFactory
+ .builder()
+ .success("No failures recorded.")
+ .toString());
+ return;
+ }
+
+ output.accept("Failures (" + failures.size() + "):");
+ for (Map<String, Object> failure : failures) {
+ output.accept("");
+ String module = getString(failure, "module");
+ String mojo = getString(failure, "mojo");
+ MessageBuilder mb = messageBuilderFactory.builder();
+ mb.failure(" [FAIL] ").a(module);
+ if (mojo != null && !mojo.isEmpty()) {
+ mb.a(" - ").a(mojo);
+ }
+ output.accept(mb.toString());
+
+ String message = getString(failure, "message");
+ if (message != null) {
+ output.accept(" " + message);
+ }
+
+ String stackTrace = getString(failure, "stackTrace");
+ if (stackTrace != null && !stackTrace.isEmpty()) {
+ // Show first few lines of stack trace
+ String[] lines = stackTrace.split("\n");
+ int limit = Math.min(lines.length, 10);
+ for (int i = 0; i < limit; i++) {
+ output.accept(" " + lines[i]);
+ }
+ if (lines.length > limit) {
+ output.accept(" ... " + (lines.length - limit) + " more lines");
+ }
+ }
+ }
+ }
+
+ /**
+ * Render the full per-mojo timing breakdown.
+ */
+ @SuppressWarnings("unchecked")
+ public void renderFull(Map<String, Object> report) {
+ renderHeader(report);
+
+ List<Map<String, Object>> modules = getList(report, "modules");
+ if (modules.isEmpty()) {
+ output.accept("No modules recorded.");
+ return;
+ }
+
+ for (Map<String, Object> module : modules) {
+ String artifactId = getString(module, "artifactId");
+ String status = getString(module, "status");
+ String duration = getString(module, "duration");
+
+ MessageBuilder mb = messageBuilderFactory.builder();
+ mb.strong("Module: " + artifactId);
+ mb.a(" (").a(duration != null ? formatDuration(duration) : "?").a(") ");
+ if ("SUCCESS".equals(status)) {
+ mb.success(status);
+ } else if ("FAILURE".equals(status)) {
+ mb.failure(status);
+ } else {
+ mb.warning(status);
+ }
+ output.accept(mb.toString());
+
+ List<Map<String, Object>> mojos = getList(module, "mojos");
+ for (Map<String, Object> mojo : mojos) {
+ String goal = getString(mojo, "goal");
+ String mojoArtifactId = getString(mojo, "artifactId");
+ String mojoDuration = getString(mojo, "duration");
+ String mojoStatus = getString(mojo, "status");
+ String executionId = getString(mojo, "executionId");
+
+ StringBuilder line = new StringBuilder(" ");
+ String prefix = mojoArtifactId != null
+ ? mojoArtifactId.replace("maven-", "").replace("-plugin", "")
+ : "";
+ line.append(prefix);
+ if (goal != null) {
+ line.append(":").append(goal);
+ }
+ if (executionId != null && !executionId.isEmpty()) {
+ line.append(" (").append(executionId).append(")");
+ }
+
+ // Pad with dots
+ int padTo = 50;
+ while (line.length() < padTo) {
+ line.append('.');
+ }
+ line.append(' ');
+
+ MessageBuilder mojoMb = messageBuilderFactory.builder();
+ mojoMb.a(line);
+ mojoMb.a(mojoDuration != null ? formatDuration(mojoDuration) : "?");
+ if ("FAILURE".equals(mojoStatus)) {
+ mojoMb.a(" ").failure("FAILED");
+ }
+ output.accept(mojoMb.toString());
+ }
+ output.accept("");
+ }
+ }
+
+ /**
+ * List all available build report files in the given directory.
+ */
+ public void listReports(Path buildReportsDir) throws IOException {
+ if (!Files.isDirectory(buildReportsDir)) {
+ output.accept("No build reports directory found at: " + buildReportsDir);
+ return;
+ }
+
+ List<Path> reports = new ArrayList<>();
+ try (DirectoryStream<Path> stream = Files.newDirectoryStream(buildReportsDir, "build-report-*.json")) {
+ for (Path entry : stream) {
+ if (Files.isRegularFile(entry)
+ && !entry.getFileName().toString().equals("build-report-latest.json")) {
+ reports.add(entry);
+ }
+ }
+ }
+
+ if (reports.isEmpty()) {
+ output.accept("No build reports found in: " + buildReportsDir);
+ return;
+ }
+
+ reports.sort(Comparator.comparing(Path::getFileName).reversed());
+
+ output.accept(messageBuilderFactory
+ .builder()
+ .strong("Available build reports:")
+ .toString());
+ output.accept("");
+
+ Path latestLink = buildReportsDir.resolve("build-report-latest.json");
+ Path latestTarget = null;
+ if (Files.isSymbolicLink(latestLink)) {
+ try {
+ latestTarget = Files.readSymbolicLink(latestLink).getFileName();
+ } catch (IOException e) {
+ // ignore
+ }
+ }
+
+ for (Path report : reports) {
+ String name = report.getFileName().toString();
+ StringBuilder line = new StringBuilder(" ");
+ line.append(name);
+ if (latestTarget != null && name.equals(latestTarget.toString())) {
+ line.append(" <- latest");
+ }
+ output.accept(line.toString());
+ }
+ }
+
+ // ---- Log event rendering ----
+
+ /**
+ * Render a list of log events (from filter results) as a log view.
+ * Each event shows the level, context (module/mojo), and message.
+ */
+ public void renderLogEvents(List<Map<String, Object>> events) {
+ if (events.isEmpty()) {
+ output.accept(messageBuilderFactory
+ .builder()
+ .warning("No matching log events found.")
+ .toString());
+ return;
+ }
+
+ output.accept(messageBuilderFactory
+ .builder()
+ .strong(events.size() + " matching log event" + (events.size() > 1 ? "s" : "") + ":")
+ .toString());
+ output.accept("");
+
+ for (Map<String, Object> event : events) {
+ String level = getString(event, "level");
+ String message = getString(event, "message");
+ String context = getString(event, "context");
+ String loggerName = getString(event, "loggerName");
+
+ MessageBuilder mb = messageBuilderFactory.builder();
+
+ // Level tag with color
+ if ("ERROR".equals(level)) {
+ mb.failure("[ERROR]");
+ } else if ("WARN".equals(level)) {
+ mb.warning("[WARN] ");
+ } else if ("DEBUG".equals(level)) {
+ mb.debug("[DEBUG]");
+ } else if ("TRACE".equals(level)) {
+ mb.a("[TRACE]");
+ } else {
+ mb.info("[INFO] ");
+ }
+
+ // Context (module:goal or just module)
+ if (context != null && !"build".equals(context)) {
+ mb.a(" ");
+ mb.strong(context);
+ }
+
+ // Logger name (shortened)
+ if (loggerName != null) {
+ mb.a(" ");
+ mb.a(shortenLoggerName(loggerName));
+ }
+
+ // Message
+ if (message != null) {
+ mb.a(" - ").a(message);
+ }
+
+ output.accept(mb.toString());
+ }
+ }
+
+ /**
+ * Shorten a fully-qualified logger name to its simple class name.
+ */
+ private static String shortenLoggerName(String loggerName) {
+ int lastDot = loggerName.lastIndexOf('.');
+ return lastDot >= 0 ? loggerName.substring(lastDot + 1) : loggerName;
+ }
+
+ // ---- Problem rendering ----
+
+ /**
+ * Compact problem rendering for the default summary view.
+ * Shows severity, message, source, and suggestion on two lines.
+ */
+ private void renderProblemCompact(Map<String, Object> problem) {
+ String severity = getString(problem, "severity");
+ String message = getString(problem, "message");
+ String source = getString(problem, "source");
+ String suggestion = getString(problem, "suggestion");
+
+ MessageBuilder mb = messageBuilderFactory.builder();
+ if ("ERROR".equals(severity)) {
+ mb.failure(" [ERROR] ");
+ } else if ("WARNING".equals(severity)) {
+ mb.warning(" [WARN] ");
+ } else {
+ mb.a(" [INFO] ");
+ }
+ mb.a(message);
+ if (source != null && !source.isEmpty()) {
+ mb.a(" ").a(messageBuilderFactory.builder().strong(source).toString());
+ }
+ output.accept(mb.toString());
+
+ if (suggestion != null && !suggestion.isEmpty()) {
+ output.accept(" suggestion: " + suggestion);
+ }
+ }
+
+ /**
+ * Detailed problem rendering for the {@code --diagnostics} view.
+ * Shows all available fields: key, severity, message, source, location,
+ * suggestion, and documentation URL.
+ */
+ private void renderProblemDetailed(Map<String, Object> problem) {
+ String severity = getString(problem, "severity");
+ String message = getString(problem, "message");
+ String key = getString(problem, "key");
+ String source = getString(problem, "source");
+ String suggestion = getString(problem, "suggestion");
+ String docUrl = getString(problem, "documentationUrl");
+
+ // Severity label + message
+ MessageBuilder mb = messageBuilderFactory.builder();
+ if ("ERROR".equals(severity)) {
+ mb.failure(" [ERROR] ");
+ } else if ("WARNING".equals(severity)) {
+ mb.warning(" [WARN] ");
+ } else {
+ mb.a(" [INFO] ");
+ }
+ mb.a(message);
+ output.accept(mb.toString());
+
+ // Key (diagnostic identifier for suppression)
+ if (key != null && !key.isEmpty()) {
+ output.accept(" key: " + key);
+ }
+
+ // Source + location
+ if (source != null && !source.isEmpty()) {
+ StringBuilder loc = new StringBuilder(" source: ");
+ loc.append(source);
+ Number line = getNumber(problem, "line");
+ if (line != null && line.intValue() > 0) {
+ loc.append(":").append(line.intValue());
+ Number column = getNumber(problem, "column");
+ if (column != null && column.intValue() > 0) {
+ loc.append(":").append(column.intValue());
+ }
+ }
+ output.accept(loc.toString());
+ }
+
+ // Suggestion
+ if (suggestion != null && !suggestion.isEmpty()) {
+ MessageBuilder sugMb = messageBuilderFactory.builder();
+ sugMb.a(" suggestion: ").success(suggestion);
+ output.accept(sugMb.toString());
+ }
+
+ // Documentation URL
+ if (docUrl != null && !docUrl.isEmpty()) {
+ output.accept(" docs: " + docUrl);
+ }
+
+ output.accept("");
+ }
+
+ // ---- Internal helpers ----
+
+ private void renderHeader(Map<String, Object> report) {
+ String mavenVersion = getString(report, "mavenVersion");
+ String startTime = getString(report, "startTime");
+
+ MessageBuilder header = messageBuilderFactory.builder();
+ header.strong("Build Report");
+ if (mavenVersion != null) {
+ header.a(" — Maven ").a(mavenVersion);
+ }
+ if (startTime != null) {
+ header.a(" — ").a(startTime);
+ }
+ output.accept(header.toString());
+
+ // Result line
+ String status = getString(report, "status");
+ MessageBuilder result = messageBuilderFactory.builder();
+ if ("FAILURE".equals(status)) {
+ result.failure("BUILD FAILURE");
+ } else {
+ result.success("BUILD SUCCESS");
+ }
+ output.accept(result.toString());
+ output.accept("");
+ }
+
+ private void renderModuleLine(Map<String, Object> module) {
+ String artifactId = getString(module, "artifactId");
+ String status = getString(module, "status");
+ String duration = getString(module, "duration");
+
+ StringBuilder buffer = new StringBuilder(128);
+
+ // Status marker
+ buffer.append(' ');
+
+ buffer.append(artifactId);
+ buffer.append(' ');
+
+ // Pad with dots
+ int maxLen = 60;
+ if (buffer.length() <= maxLen) {
+ while (buffer.length() < maxLen) {
+ buffer.append('.');
+ }
+ buffer.append(' ');
+ }
+
+ MessageBuilder mb = messageBuilderFactory.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 (duration != null) {
+ mb.a(" [").a(formatDuration(duration)).a("]");
+ }
+
+ output.accept(mb.toString());
+ }
+
+ /**
+ * Format an ISO-8601 duration string (e.g. "PT2.1S") into a human-readable form.
+ */
+ static String formatDuration(String isoDuration) {
+ try {
+ Duration d = Duration.parse(isoDuration);
+ long totalSeconds = d.getSeconds();
+ int millis = d.getNano() / 1_000_000;
+
+ if (totalSeconds >= 60) {
+ long minutes = totalSeconds / 60;
+ long seconds = totalSeconds % 60;
+ return String.format("%d:%02d min", minutes, seconds);
+ } else {
+ return String.format("%d.%03d s", totalSeconds, millis);
+ }
+ } catch (Exception e) {
+ return isoDuration; // fallback to raw string
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static List<Map<String, Object>> getList(Map<String, Object> map, String key) {
+ Object value = map.get(key);
+ if (value instanceof List) {
+ return (List<Map<String, Object>>) value;
+ }
+ return List.of();
+ }
+
+ private static String getString(Map<String, Object> map, String key) {
+ Object value = map.get(key);
+ return value != null ? value.toString() : null;
+ }
+
+ private static Number getNumber(Map<String, Object> map, String key) {
+ Object value = map.get(key);
+ if (value instanceof Number) {
+ return (Number) value;
+ }
+ return null;
+ }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/CommonsCliLogOptions.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/CommonsCliLogOptions.java
new file mode 100644
index 0000000000..a80192bf47
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/CommonsCliLogOptions.java
@@ -0,0 +1,217 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.cling.invoker.mvnlog;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Consumer;
+
+import org.apache.commons.cli.CommandLine;
+import org.apache.commons.cli.Option;
+import org.apache.commons.cli.ParseException;
+import org.apache.maven.api.cli.Options;
+import org.apache.maven.api.cli.ParserRequest;
+import org.apache.maven.api.cli.mvnlog.LogOptions;
+import org.apache.maven.cling.invoker.CommonsCliOptions;
+
+/**
+ * Implementation of {@link LogOptions} using Commons CLI.
+ */
+public class CommonsCliLogOptions extends CommonsCliOptions implements LogOptions {
+
+ public static CommonsCliLogOptions parse(String[] args) throws ParseException {
+ CLIManager cliManager = new CLIManager();
+ return new CommonsCliLogOptions(Options.SOURCE_CLI, cliManager, cliManager.parse(args));
+ }
+
+ protected CommonsCliLogOptions(String source, CLIManager cliManager, CommandLine commandLine) {
+ super(source, cliManager, commandLine);
+ }
+
+ @Override
+ public Optional<Boolean> diagnostics() {
+ if (commandLine.hasOption(CLIManager.DIAGNOSTICS)) {
+ return Optional.of(Boolean.TRUE);
+ }
+ return Optional.empty();
+ }
+
+ @Override
+ public Optional<Boolean> failures() {
+ if (commandLine.hasOption(CLIManager.FAILURES)) {
+ return Optional.of(Boolean.TRUE);
+ }
+ return Optional.empty();
+ }
+
+ @Override
+ public Optional<Boolean> full() {
+ if (commandLine.hasOption(CLIManager.FULL)) {
+ return Optional.of(Boolean.TRUE);
+ }
+ return Optional.empty();
+ }
+
+ @Override
+ public Optional<Boolean> list() {
+ if (commandLine.hasOption(CLIManager.LIST)) {
+ return Optional.of(Boolean.TRUE);
+ }
+ return Optional.empty();
+ }
+
+ @Override
+ public Optional<Boolean> json() {
+ if (commandLine.hasOption(CLIManager.JSON)) {
+ return Optional.of(Boolean.TRUE);
+ }
+ return Optional.empty();
+ }
+
+ @Override
+ public Optional<String> reportFile() {
+ List<String> args = commandLine.getArgList();
+ if (!args.isEmpty()) {
+ return Optional.of(args.get(0));
+ }
+ return Optional.empty();
+ }
+
+ @Override
+ public Optional<String> module() {
+ if (commandLine.hasOption(CLIManager.MODULE)) {
+ return Optional.of(commandLine.getOptionValue(CLIManager.MODULE));
+ }
+ return Optional.empty();
+ }
+
+ @Override
+ public Optional<String> mojo() {
+ if (commandLine.hasOption(CLIManager.MOJO)) {
+ return Optional.of(commandLine.getOptionValue(CLIManager.MOJO));
+ }
+ return Optional.empty();
+ }
+
+ @Override
+ public Optional<String> level() {
+ if (commandLine.hasOption(CLIManager.LEVEL)) {
+ return Optional.of(commandLine.getOptionValue(CLIManager.LEVEL));
+ }
+ return Optional.empty();
+ }
+
+ @Override
+ public Optional<String> grep() {
+ if (commandLine.hasOption(CLIManager.GREP)) {
+ return Optional.of(commandLine.getOptionValue(CLIManager.GREP));
+ }
+ return Optional.empty();
+ }
+
+ @Override
+ public void displayHelp(ParserRequest request, Consumer<String> printStream) {
+ super.displayHelp(request, printStream);
+ printStream.accept("");
+ printStream.accept("Usage: mvnlog [options] [report-file]");
+ printStream.accept("");
+ printStream.accept("Displays a formatted summary of the last Maven build report.");
+ printStream.accept("If no report-file is specified, reads target/build-reports/build-report-latest.json.");
+ printStream.accept("");
+ printStream.accept("Filters:");
+ printStream.accept(" mvnlog --module api Show only modules matching 'api'");
+ printStream.accept(" mvnlog --level WARN Show only WARN and ERROR log events");
+ printStream.accept(" mvnlog --grep 'deprecated' Search log messages for 'deprecated'");
+ printStream.accept(" mvnlog --mojo compile Show only 'compile' mojo executions");
+ printStream.accept(" mvnlog --module api --level WARN Combine filters (all must match)");
+ printStream.accept("");
+ printStream.accept("JSON output:");
+ printStream.accept(" mvnlog --json Output full JSON report");
+ printStream.accept(" mvnlog --json --module api Output filtered JSON");
+ printStream.accept(" mvnlog --json | jq '.modules' Pipe to jq for complex queries");
+ printStream.accept("");
+ }
+
+ @Override
+ protected CommonsCliLogOptions copy(
+ String source, CommonsCliOptions.CLIManager cliManager, CommandLine commandLine) {
+ return new CommonsCliLogOptions(source, (CLIManager) cliManager, commandLine);
+ }
+
+ protected static class CLIManager extends CommonsCliOptions.CLIManager {
+ public static final String DIAGNOSTICS = "d";
+ public static final String FAILURES = "f";
+ public static final String FULL = "F";
+ public static final String LIST = "L";
+ public static final String JSON = "j";
+ public static final String MODULE = "m";
+ public static final String MOJO = "M";
+ public static final String LEVEL = "l";
+ public static final String GREP = "g";
+
+ @Override
+ protected void prepareOptions(org.apache.commons.cli.Options options) {
+ super.prepareOptions(options);
+ options.addOption(Option.builder(DIAGNOSTICS)
+ .longOpt("diagnostics")
+ .desc("Show detailed warnings and errors from the build")
+ .get());
+ options.addOption(Option.builder(FAILURES)
+ .longOpt("failures")
+ .desc("Show detailed failure information including stack traces")
+ .get());
+ options.addOption(Option.builder(FULL)
+ .longOpt("full")
+ .desc("Show full per-mojo timing breakdown")
+ .get());
+ options.addOption(Option.builder(LIST)
+ .longOpt("list")
+ .desc("List all available build reports")
+ .get());
+ options.addOption(Option.builder(JSON)
+ .longOpt("json")
+ .desc("Output the raw JSON build report (useful for piping to jq)")
+ .get());
+ options.addOption(Option.builder(MODULE)
+ .longOpt("module")
+ .hasArg()
+ .argName("pattern")
+ .desc("Filter to modules whose artifactId contains <pattern> (case-insensitive)")
+ .get());
+ options.addOption(Option.builder(MOJO)
+ .longOpt("mojo")
+ .hasArg()
+ .argName("pattern")
+ .desc("Filter to mojos whose goal contains <pattern> (case-insensitive)")
+ .get());
+ options.addOption(Option.builder(LEVEL)
+ .longOpt("level")
+ .hasArg()
+ .argName("LEVEL")
+ .desc("Filter log events by minimum level (TRACE, DEBUG, INFO, WARN, ERROR)")
+ .get());
+ options.addOption(Option.builder(GREP)
+ .longOpt("grep")
+ .hasArg()
+ .argName("pattern")
+ .desc("Search log messages for <pattern> (case-insensitive)")
+ .get());
+ }
+ }
+}
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogContext.java
similarity index 51%
copy from api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
copy to impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogContext.java
index 7559d7ffee..cf6193e19a 100644
--- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogContext.java
@@ -16,30 +16,23 @@
* specific language governing permissions and limitations
* under the License.
*/
-package org.apache.maven.api.cli;
+package org.apache.maven.cling.invoker.mvnlog;
-import org.apache.maven.api.annotations.Experimental;
-import org.apache.maven.api.annotations.Immutable;
+import org.apache.maven.api.cli.InvokerRequest;
+import org.apache.maven.api.cli.mvnlog.LogOptions;
+import org.apache.maven.cling.invoker.LookupContext;
/**
- * Represents most common tools supported by CLIng.
- *
- * @since 4.0.0
+ * Context for the {@code mvnlog} build log viewer.
*/
-@Immutable
-@Experimental
-public final class Tools {
- private Tools() {}
-
- public static final String MVN_CMD = "mvn";
- public static final String MVN_NAME = "Maven";
-
- public static final String MVNENC_CMD = "mvnenc";
- public static final String MVNENC_NAME = "Maven Password Encrypting Tool";
-
- public static final String MVNSHELL_CMD = "mvnsh";
- public static final String MVNSHELL_NAME = "Maven Shell Tool";
+@SuppressWarnings("VisibilityModifier")
+public class LogContext extends LookupContext {
+ public LogContext(InvokerRequest invokerRequest, LogOptions logOptions) {
+ super(invokerRequest, true, logOptions);
+ }
- public static final String MVNUP_CMD = "mvnup";
- public static final String MVNUP_NAME = "Maven Upgrade Tool";
+ @Override
+ public LogOptions options() {
+ return (LogOptions) super.options();
+ }
}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogInvoker.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogInvoker.java
new file mode 100644
index 0000000000..bc194888fa
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogInvoker.java
@@ -0,0 +1,199 @@
+/*
+ * 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.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+import java.util.function.Consumer;
+
+import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.cli.InvokerRequest;
+import org.apache.maven.api.cli.mvnlog.LogOptions;
+import org.apache.maven.api.services.Lookup;
+import org.apache.maven.cling.invoker.LookupContext;
+import org.apache.maven.cling.invoker.LookupInvoker;
+
+/**
+ * Invoker for the {@code mvnlog} build log viewer.
+ * <p>
+ * This is a lightweight invoker that does NOT set up the DI container,
+ * Maven settings, or any build infrastructure. It only needs a terminal
+ * (for colors and width detection) and the parsed CLI options.
+ *
+ * @since 4.1.0
+ */
+public class LogInvoker extends LookupInvoker<LogContext> {
+
+ public static final int OK = 0;
+ public static final int ERROR = 1;
+ public static final int BAD_INPUT = 2;
+
+ private static final String DEFAULT_REPORT_DIR = "target/build-reports";
+ private static final String DEFAULT_REPORT_FILE = "build-report-latest.json";
+
+ public LogInvoker(Lookup protoLookup, @Nullable Consumer<LookupContext> contextConsumer) {
+ super(protoLookup, contextConsumer);
+ }
+
+ @Override
+ protected LogContext createContext(InvokerRequest invokerRequest) {
+ return new LogContext(
+ invokerRequest, (LogOptions) invokerRequest.options().orElse(null));
+ }
+
+ /**
+ * Override doInvoke to skip the heavyweight DI container, settings,
+ * and repository setup that mvnlog does not need.
+ */
+ @Override
+ protected int doInvoke(LogContext context) throws Exception {
+ validate(context);
+ pushCoreProperties(context);
+ configureLogging(context);
+ createTerminal(context);
+ activateLogging(context);
+ helpOrVersionAndMayExit(context);
+ return execute(context);
+ }
+
+ @Override
+ protected void lookup(LogContext context) throws Exception {
+ // No DI container needed for log viewing
+ }
+
+ @Override
+ protected int execute(LogContext context) throws Exception {
+ LogOptions options = context.options();
+ Consumer<String> output = line -> {
+ if (context.writer != null) {
+ context.writer.accept(line);
+ } else {
+ context.logger.info(line);
+ }
+ };
+
+ BuildReportRenderer renderer = new BuildReportRenderer(context.invokerRequest.messageBuilderFactory(), output);
+
+ // Handle --list: show available reports
+ if (options != null && options.list().orElse(false)) {
+ Path reportDir = resolveReportDir(context);
+ renderer.listReports(reportDir);
+ return OK;
+ }
+
+ // Resolve and read the report file
+ Path reportFile = resolveReportFile(context);
+ if (!Files.isRegularFile(reportFile)) {
+ context.logger.error("Build report not found: " + reportFile);
+ context.logger.error("Run a Maven build first, then use mvnlog to view the report.");
+ return BAD_INPUT;
+ }
+
+ String json;
+ try {
+ json = Files.readString(reportFile);
+ } catch (IOException e) {
+ context.logger.error("Failed to read report file: " + e.getMessage());
+ return ERROR;
+ }
+
+ Map<String, Object> report;
+ try {
+ report = SimpleJsonReader.parse(json);
+ } catch (IllegalArgumentException e) {
+ context.logger.error("Failed to parse report file: " + e.getMessage());
+ return ERROR;
+ }
+
+ // Build filter from options
+ BuildReportFilter filter = buildFilter(options);
+
+ // Handle --json: output JSON (filtered if applicable) and exit
+ if (options != null && options.json().orElse(false)) {
+ if (filter.hasFilters()) {
+ Map<String, Object> filtered = filter.apply(report);
+ output.accept(SimpleJsonWriter.toJson(filtered));
+ } else {
+ output.accept(json);
+ }
+ return OK;
+ }
+
+ // If log-level filters are active (--level, --grep), render a log view
+ // instead of the default summary.
+ if (filter.hasLogFilters()) {
+ java.util.List<Map<String, Object>> events = filter.collectMatchingLogEvents(report);
+ renderer.renderLogEvents(events);
+ return OK;
+ }
+
+ // Apply structural filters (--module, --mojo) to the report
+ Map<String, Object> filteredReport = filter.apply(report);
+
+ // Render based on flags
+ if (options != null && options.full().orElse(false)) {
+ renderer.renderFull(filteredReport);
+ } else if (options != null && options.failures().orElse(false)) {
+ renderer.renderFailures(filteredReport);
+ } else if (options != null && options.diagnostics().orElse(false)) {
+ renderer.renderDiagnostics(filteredReport);
+ } else {
+ renderer.renderSummary(filteredReport);
+ }
+
+ return OK;
+ }
+
+ private static BuildReportFilter buildFilter(LogOptions options) {
+ if (options == null) {
+ return new BuildReportFilter(null, null, null, null);
+ }
+ return new BuildReportFilter(
+ options.module().orElse(null),
+ options.mojo().orElse(null),
+ options.level().orElse(null),
+ options.grep().orElse(null));
+ }
+
+ private Path resolveReportDir(LogContext context) {
+ Path cwd = context.invokerRequest.cwd();
+ return cwd.resolve(DEFAULT_REPORT_DIR);
+ }
+
+ private Path resolveReportFile(LogContext context) {
+ LogOptions options = context.options();
+
+ // Explicit report file path from command line
+ if (options != null) {
+ String reportFile = options.reportFile().orElse(null);
+ if (reportFile != null) {
+ Path path = Path.of(reportFile);
+ if (path.isAbsolute()) {
+ return path;
+ }
+ return context.invokerRequest.cwd().resolve(path);
+ }
+ }
+
+ // Default: target/build-reports/build-report-latest.json
+ return resolveReportDir(context).resolve(DEFAULT_REPORT_FILE);
+ }
+}
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogParser.java
similarity index 51%
copy from api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
copy to impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogParser.java
index 7559d7ffee..a26219ec8a 100644
--- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogParser.java
@@ -16,30 +16,22 @@
* specific language governing permissions and limitations
* under the License.
*/
-package org.apache.maven.api.cli;
+package org.apache.maven.cling.invoker.mvnlog;
-import org.apache.maven.api.annotations.Experimental;
-import org.apache.maven.api.annotations.Immutable;
+import org.apache.commons.cli.ParseException;
+import org.apache.maven.api.cli.Options;
+import org.apache.maven.cling.invoker.BaseParser;
/**
- * Represents most common tools supported by CLIng.
- *
- * @since 4.0.0
+ * Parser for the {@code mvnlog} command-line arguments.
*/
-@Immutable
-@Experimental
-public final class Tools {
- private Tools() {}
-
- public static final String MVN_CMD = "mvn";
- public static final String MVN_NAME = "Maven";
-
- public static final String MVNENC_CMD = "mvnenc";
- public static final String MVNENC_NAME = "Maven Password Encrypting Tool";
-
- public static final String MVNSHELL_CMD = "mvnsh";
- public static final String MVNSHELL_NAME = "Maven Shell Tool";
-
- public static final String MVNUP_CMD = "mvnup";
- public static final String MVNUP_NAME = "Maven Upgrade Tool";
+public class LogParser extends BaseParser {
+ @Override
+ protected Options parseCliOptions(LocalContext context) {
+ try {
+ return CommonsCliLogOptions.parse(context.parserRequest.args().toArray(new String[0]));
+ } catch (ParseException e) {
+ throw new IllegalArgumentException("Failed to parse command line options: " + e.getMessage(), e);
+ }
+ }
}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonReader.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonReader.java
new file mode 100644
index 0000000000..818ad1cad1
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonReader.java
@@ -0,0 +1,276 @@
+/*
+ * 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.Map;
+
+/**
+ * Minimal recursive-descent JSON parser that reads a JSON string into
+ * {@code Map<String, Object>} / {@code List<Object>} / {@code String} / {@code Number} / {@code Boolean} / null.
+ * <p>
+ * No external dependencies. Companion to {@code BuildReportJsonWriter} which writes
+ * JSON without any library; this reader follows the same zero-dependency principle.
+ * <p>
+ * This parser handles the full JSON spec (objects, arrays, strings with escapes,
+ * numbers, booleans, null) and is sufficient for reading Maven build report files.
+ */
+final class SimpleJsonReader {
+
+ private final String json;
+ private int pos;
+
+ private SimpleJsonReader(String json) {
+ this.json = json;
+ this.pos = 0;
+ }
+
+ /**
+ * Parse a JSON string into a nested structure of Maps, Lists, and primitives.
+ *
+ * @param json the JSON string to parse
+ * @return the parsed value (typically a {@code Map<String, Object>} for a JSON object)
+ * @throws IllegalArgumentException if the JSON is malformed
+ */
+ @SuppressWarnings("unchecked")
+ static Map<String, Object> parse(String json) {
+ SimpleJsonReader reader = new SimpleJsonReader(json.strip());
+ Object result = reader.parseValue();
+ if (!(result instanceof Map)) {
+ throw new IllegalArgumentException("Expected JSON object at root");
+ }
+ return (Map<String, Object>) result;
+ }
+
+ private Object parseValue() {
+ skipWhitespace();
+ if (pos >= json.length()) {
+ throw error("Unexpected end of input");
+ }
+ char c = json.charAt(pos);
+ if (c == '{') {
+ return parseObject();
+ }
+ if (c == '[') {
+ return parseArray();
+ }
+ if (c == '"') {
+ return parseString();
+ }
+ if (c == 't' || c == 'f') {
+ return parseBoolean();
+ }
+ if (c == 'n') {
+ return parseNull();
+ }
+ if (c == '-' || (c >= '0' && c <= '9')) {
+ return parseNumber();
+ }
+ throw error("Unexpected character: " + c);
+ }
+
+ private Map<String, Object> parseObject() {
+ expect('{');
+ Map<String, Object> map = new LinkedHashMap<>();
+ skipWhitespace();
+ if (pos < json.length() && json.charAt(pos) == '}') {
+ pos++;
+ return map;
+ }
+ while (true) {
+ skipWhitespace();
+ String key = parseString();
+ skipWhitespace();
+ expect(':');
+ Object value = parseValue();
+ map.put(key, value);
+ skipWhitespace();
+ if (pos < json.length() && json.charAt(pos) == ',') {
+ pos++;
+ } else {
+ break;
+ }
+ }
+ skipWhitespace();
+ expect('}');
+ return map;
+ }
+
+ private List<Object> parseArray() {
+ expect('[');
+ List<Object> list = new ArrayList<>();
+ skipWhitespace();
+ if (pos < json.length() && json.charAt(pos) == ']') {
+ pos++;
+ return list;
+ }
+ while (true) {
+ list.add(parseValue());
+ skipWhitespace();
+ if (pos < json.length() && json.charAt(pos) == ',') {
+ pos++;
+ } else {
+ break;
+ }
+ }
+ skipWhitespace();
+ expect(']');
+ return list;
+ }
+
+ private String parseString() {
+ expect('"');
+ StringBuilder sb = new StringBuilder();
+ while (pos < json.length()) {
+ char c = json.charAt(pos++);
+ if (c == '"') {
+ return sb.toString();
+ }
+ if (c == '\\') {
+ if (pos >= json.length()) {
+ throw error("Unexpected end of string escape");
+ }
+ char escaped = json.charAt(pos++);
+ switch (escaped) {
+ case '"':
+ sb.append('"');
+ break;
+ 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;
+ case 'u':
+ if (pos + 4 > json.length()) {
+ throw error("Incomplete unicode escape");
+ }
+ String hex = json.substring(pos, pos + 4);
+ sb.append((char) Integer.parseInt(hex, 16));
+ pos += 4;
+ break;
+ default:
+ sb.append(escaped);
+ }
+ } else {
+ sb.append(c);
+ }
+ }
+ throw error("Unterminated string");
+ }
+
+ private Number parseNumber() {
+ int start = pos;
+ if (pos < json.length() && json.charAt(pos) == '-') {
+ pos++;
+ }
+ while (pos < json.length() && json.charAt(pos) >= '0' && json.charAt(pos) <= '9') {
+ pos++;
+ }
+ boolean isFloat = false;
+ if (pos < json.length() && json.charAt(pos) == '.') {
+ isFloat = true;
+ pos++;
+ while (pos < json.length() && json.charAt(pos) >= '0' && json.charAt(pos) <= '9') {
+ pos++;
+ }
+ }
+ if (pos < json.length() && (json.charAt(pos) == 'e' || json.charAt(pos) == 'E')) {
+ isFloat = true;
+ pos++;
+ if (pos < json.length() && (json.charAt(pos) == '+' || json.charAt(pos) == '-')) {
+ pos++;
+ }
+ while (pos < json.length() && json.charAt(pos) >= '0' && json.charAt(pos) <= '9') {
+ pos++;
+ }
+ }
+ String numStr = json.substring(start, pos);
+ if (isFloat) {
+ return Double.parseDouble(numStr);
+ }
+ long value = Long.parseLong(numStr);
+ if (value >= Integer.MIN_VALUE && value <= Integer.MAX_VALUE) {
+ return (int) value;
+ }
+ return value;
+ }
+
+ private Boolean parseBoolean() {
+ if (json.startsWith("true", pos)) {
+ pos += 4;
+ return Boolean.TRUE;
+ }
+ if (json.startsWith("false", pos)) {
+ pos += 5;
+ return Boolean.FALSE;
+ }
+ throw error("Expected boolean");
+ }
+
+ private Object parseNull() {
+ if (json.startsWith("null", pos)) {
+ pos += 4;
+ return null;
+ }
+ throw error("Expected null");
+ }
+
+ private void skipWhitespace() {
+ while (pos < json.length()) {
+ char c = json.charAt(pos);
+ if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
+ pos++;
+ } else {
+ break;
+ }
+ }
+ }
+
+ private void expect(char expected) {
+ if (pos >= json.length() || json.charAt(pos) != expected) {
+ throw error("Expected '" + expected + "'");
+ }
+ pos++;
+ }
+
+ private IllegalArgumentException error(String message) {
+ int contextStart = Math.max(0, pos - 20);
+ int contextEnd = Math.min(json.length(), pos + 20);
+ String context = json.substring(contextStart, contextEnd);
+ return new IllegalArgumentException(message + " at position " + pos + " near: ..." + context + "...");
+ }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonWriter.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonWriter.java
new file mode 100644
index 0000000000..3626d2631b
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonWriter.java
@@ -0,0 +1,154 @@
+/*
+ * 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.Iterator;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Minimal JSON writer for serializing {@code Map<String, Object>} structures
+ * back to well-formatted JSON. Used to output filtered build reports when
+ * {@code --json} is combined with filters.
+ * <p>
+ * Supports: {@code Map}, {@code List}, {@code String}, {@code Number},
+ * {@code Boolean}, and {@code null}.
+ *
+ * @since 4.1.0
+ */
+final class SimpleJsonWriter {
+
+ private static final String INDENT = " ";
+
+ private SimpleJsonWriter() {}
+
+ /**
+ * Serialize a value to a pretty-printed JSON string.
+ */
+ static String toJson(Object value) {
+ StringBuilder sb = new StringBuilder(4096);
+ writeValue(sb, value, 0);
+ return sb.toString();
+ }
+
+ @SuppressWarnings("unchecked")
+ private static void writeValue(StringBuilder sb, Object value, int depth) {
+ if (value == null) {
+ sb.append("null");
+ } else if (value instanceof Map) {
+ writeObject(sb, (Map<String, Object>) value, depth);
+ } else if (value instanceof List) {
+ writeArray(sb, (List<Object>) value, depth);
+ } else if (value instanceof String) {
+ writeString(sb, (String) value);
+ } else if (value instanceof Number) {
+ sb.append(value);
+ } else if (value instanceof Boolean) {
+ sb.append(value);
+ } else {
+ writeString(sb, value.toString());
+ }
+ }
+
+ private static void writeObject(StringBuilder sb, Map<String, Object> map, int depth) {
+ if (map.isEmpty()) {
+ sb.append("{}");
+ return;
+ }
+
+ sb.append("{\n");
+ Iterator<Map.Entry<String, Object>> it = map.entrySet().iterator();
+ while (it.hasNext()) {
+ Map.Entry<String, Object> entry = it.next();
+ indent(sb, depth + 1);
+ writeString(sb, entry.getKey());
+ sb.append(": ");
+ writeValue(sb, entry.getValue(), depth + 1);
+ if (it.hasNext()) {
+ sb.append(',');
+ }
+ sb.append('\n');
+ }
+ indent(sb, depth);
+ sb.append('}');
+ }
+
+ private static void writeArray(StringBuilder sb, List<Object> list, int depth) {
+ if (list.isEmpty()) {
+ sb.append("[]");
+ return;
+ }
+
+ sb.append("[\n");
+ Iterator<Object> it = list.iterator();
+ while (it.hasNext()) {
+ indent(sb, depth + 1);
+ writeValue(sb, it.next(), depth + 1);
+ if (it.hasNext()) {
+ sb.append(',');
+ }
+ sb.append('\n');
+ }
+ indent(sb, depth);
+ sb.append(']');
+ }
+
+ private static void writeString(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(String.format("\\u%04x", (int) c));
+ } else {
+ sb.append(c);
+ }
+ }
+ }
+ sb.append('"');
+ }
+
+ private static void indent(StringBuilder sb, int depth) {
+ for (int i = 0; i < depth; i++) {
+ sb.append(INDENT);
+ }
+ }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnsh/builtin/BuiltinShellCommandRegistryFactory.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnsh/builtin/BuiltinShellCommandRegistryFactory.java
index a221e1b5b1..18ee72f9d0 100644
--- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnsh/builtin/BuiltinShellCommandRegistryFactory.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnsh/builtin/BuiltinShellCommandRegistryFactory.java
@@ -43,6 +43,8 @@
import org.apache.maven.cling.invoker.mvnenc.EncryptInvoker;
import org.apache.maven.cling.invoker.mvnenc.EncryptParser;
import org.apache.maven.cling.invoker.mvnenc.Goal;
+import org.apache.maven.cling.invoker.mvnlog.LogInvoker;
+import org.apache.maven.cling.invoker.mvnlog.LogParser;
import org.apache.maven.cling.invoker.mvnsh.ShellCommandRegistryFactory;
import org.apache.maven.cling.invoker.mvnup.UpgradeInvoker;
import org.apache.maven.cling.invoker.mvnup.UpgradeParser;
@@ -75,6 +77,8 @@ private static class BuiltinShellCommandRegistry extends JlineCommandRegistry im
private final EncryptParser encryptParser;
private final UpgradeInvoker shellUpgradeInvoker;
private final UpgradeParser upgradeParser;
+ private final LogInvoker shellLogInvoker;
+ private final LogParser logParser;
private BuiltinShellCommandRegistry(LookupContext shellContext) {
this.shellContext = requireNonNull(shellContext, "shellContext");
@@ -84,12 +88,15 @@ private BuiltinShellCommandRegistry(LookupContext shellContext) {
this.encryptParser = new EncryptParser();
this.shellUpgradeInvoker = new UpgradeInvoker(shellContext.invokerRequest.lookup(), contextCopier());
this.upgradeParser = new UpgradeParser();
+ this.shellLogInvoker = new LogInvoker(shellContext.invokerRequest.lookup(), contextCopier());
+ this.logParser = new LogParser();
Map<String, CommandMethods> commandExecute = new HashMap<>();
commandExecute.put("!", new CommandMethods(this::shell, this::defaultCompleter));
commandExecute.put("cd", new CommandMethods(this::cd, this::cdCompleter));
commandExecute.put("pwd", new CommandMethods(this::pwd, this::defaultCompleter));
commandExecute.put("mvn", new CommandMethods(this::mvn, this::mvnCompleter));
commandExecute.put("mvnenc", new CommandMethods(this::mvnenc, this::mvnencCompleter));
+ commandExecute.put("mvnlog", new CommandMethods(this::mvnlog, this::mvnlogCompleter));
commandExecute.put("mvnup", new CommandMethods(this::mvnup, this::mvnupCompleter));
registerCommands(commandExecute);
}
@@ -121,6 +128,7 @@ private Consumer<LookupContext> contextCopier() {
public void close() throws Exception {
shellMavenInvoker.close();
shellEncryptInvoker.close();
+ shellLogInvoker.close();
shellUpgradeInvoker.close();
}
@@ -251,6 +259,24 @@ private List<Completer> mvnencCompleter(String name) {
shellContext.lookup.lookupMap(Goal.class).keySet())));
}
+ private void mvnlog(CommandInput input) {
+ try {
+ shellLogInvoker.invoke(logParser.parseInvocation(
+ ParserRequest.mvnlog(input.args(), shellContext.invokerRequest.messageBuilderFactory())
+ .cwd(shellContext.cwd.get())
+ .build()));
+ } catch (InvokerException.ExitException e) {
+ shellContext.logger.error("mvnlog command exited with exit code " + e.getExitCode());
+ } catch (Exception e) {
+ saveException(e);
+ }
+ }
+
+ private List<Completer> mvnlogCompleter(String name) {
+ return List.of(
+ new ArgumentCompleter(new StringsCompleter("--diagnostics", "--failures", "--full", "--list")));
+ }
+
private void mvnup(CommandInput input) {
try {
shellUpgradeInvoker.invoke(upgradeParser.parseInvocation(
diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/BuildReportFilterTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/BuildReportFilterTest.java
new file mode 100644
index 0000000000..db118b003d
--- /dev/null
+++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/BuildReportFilterTest.java
@@ -0,0 +1,242 @@
+/*
+ * 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.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class BuildReportFilterTest {
+
+ @Test
+ void noFiltersPassesThrough() {
+ BuildReportFilter filter = new BuildReportFilter(null, null, null, null);
+ assertFalse(filter.hasFilters());
+
+ Map<String, Object> report = sampleReport();
+ Map<String, Object> result = filter.apply(report);
+ assertEquals(report, result);
+ }
+
+ @Test
+ void moduleFilterKeepsMatchingModules() {
+ BuildReportFilter filter = new BuildReportFilter("api", null, null, null);
+ assertTrue(filter.hasFilters());
+
+ Map<String, Object> result = filter.apply(sampleReport());
+ @SuppressWarnings("unchecked")
+ List<Map<String, Object>> modules = (List<Map<String, Object>>) result.get("modules");
+ assertEquals(1, modules.size());
+ assertEquals("maven-api-core", modules.get(0).get("artifactId"));
+ }
+
+ @Test
+ void moduleFilterIsCaseInsensitive() {
+ BuildReportFilter filter = new BuildReportFilter("API", null, null, null);
+ Map<String, Object> result = filter.apply(sampleReport());
+ @SuppressWarnings("unchecked")
+ List<Map<String, Object>> modules = (List<Map<String, Object>>) result.get("modules");
+ assertEquals(1, modules.size());
+ }
+
+ @Test
+ void mojoFilterKeepsMatchingMojos() {
+ BuildReportFilter filter = new BuildReportFilter(null, "resources", null, null);
+ Map<String, Object> result = filter.apply(sampleReport());
+
+ @SuppressWarnings("unchecked")
+ List<Map<String, Object>> modules = (List<Map<String, Object>>) result.get("modules");
+ // Both modules should remain
+ assertEquals(2, modules.size());
+
+ // Module 1 (api) has no resources mojo
+ @SuppressWarnings("unchecked")
+ List<Map<String, Object>> mojos0 =
+ (List<Map<String, Object>>) modules.get(0).get("mojos");
+ assertEquals(0, mojos0.size());
+
+ // Module 2 (cli) has a resources mojo
+ @SuppressWarnings("unchecked")
+ List<Map<String, Object>> mojos1 =
+ (List<Map<String, Object>>) modules.get(1).get("mojos");
+ assertEquals(1, mojos1.size());
+ assertEquals("resources", mojos1.get(0).get("goal"));
+ }
+
+ @Test
+ void levelFilterKeepsEventsAtOrAbove() {
+ BuildReportFilter filter = new BuildReportFilter(null, null, "WARN", null);
+ assertTrue(filter.hasLogFilters());
+
+ Map<String, Object> report = sampleReport();
+ List<Map<String, Object>> events = filter.collectMatchingLogEvents(report);
+ // Should only include WARN and ERROR events
+ for (Map<String, Object> event : events) {
+ String level = (String) event.get("level");
+ assertTrue("WARN".equals(level) || "ERROR".equals(level), "Unexpected level: " + level);
+ }
+ assertFalse(events.isEmpty(), "Should find at least one WARN/ERROR event");
+ }
+
+ @Test
+ void grepFilterMatchesMessages() {
+ BuildReportFilter filter = new BuildReportFilter(null, null, null, "deprecated");
+ assertTrue(filter.hasLogFilters());
+
+ Map<String, Object> report = sampleReport();
+ List<Map<String, Object>> events = filter.collectMatchingLogEvents(report);
+ assertEquals(1, events.size());
+ assertTrue(((String) events.get(0).get("message")).contains("deprecated"));
+ }
+
+ @Test
+ void grepFilterIsCaseInsensitive() {
+ BuildReportFilter filter = new BuildReportFilter(null, null, null, "DEPRECATED");
+ List<Map<String, Object>> events = filter.collectMatchingLogEvents(sampleReport());
+ assertEquals(1, events.size());
+ }
+
+ @Test
+ void combinedModuleAndLevelFilter() {
+ BuildReportFilter filter = new BuildReportFilter("cli", null, "WARN", null);
+ List<Map<String, Object>> events = filter.collectMatchingLogEvents(sampleReport());
+ // Should only include WARN+ events from maven-cli module
+ for (Map<String, Object> event : events) {
+ String context = (String) event.get("context");
+ assertTrue(context.contains("cli"), "Event context should contain 'cli': " + context);
+ }
+ }
+
+ @Test
+ void isAtOrAboveLevelComparison() {
+ assertTrue(BuildReportFilter.isAtOrAbove("ERROR", "WARN"));
+ assertTrue(BuildReportFilter.isAtOrAbove("WARN", "WARN"));
+ assertFalse(BuildReportFilter.isAtOrAbove("INFO", "WARN"));
+ assertFalse(BuildReportFilter.isAtOrAbove("DEBUG", "WARN"));
+ assertTrue(BuildReportFilter.isAtOrAbove("INFO", "INFO"));
+ assertTrue(BuildReportFilter.isAtOrAbove("ERROR", "TRACE"));
+ }
+
+ @Test
+ void jsonFilteredOutputRemovesNonMatchingModules() {
+ BuildReportFilter filter = new BuildReportFilter("api", null, null, null);
+ Map<String, Object> result = filter.apply(sampleReport());
+ // The result should still have all top-level keys
+ assertTrue(result.containsKey("status"));
+ assertTrue(result.containsKey("modules"));
+
+ @SuppressWarnings("unchecked")
+ List<Map<String, Object>> modules = (List<Map<String, Object>>) result.get("modules");
+ assertEquals(1, modules.size());
+ }
+
+ @Test
+ void applyWithLevelFilterFiltersModuleOutput() {
+ BuildReportFilter filter = new BuildReportFilter(null, null, "ERROR", null);
+ Map<String, Object> result = filter.apply(sampleReport());
+
+ @SuppressWarnings("unchecked")
+ List<Map<String, Object>> modules = (List<Map<String, Object>>) result.get("modules");
+ for (Map<String, Object> module : modules) {
+ @SuppressWarnings("unchecked")
+ List<Map<String, Object>> output = (List<Map<String, Object>>) module.get("output");
+ for (Map<String, Object> event : output) {
+ assertEquals("ERROR", event.get("level"));
+ }
+ }
+ }
+
+ // ---- Test data ----
+
+ @SuppressWarnings("unchecked")
+ private static Map<String, Object> sampleReport() {
+ Map<String, Object> report = new LinkedHashMap<>();
+ report.put("status", "SUCCESS");
+ report.put("mavenVersion", "4.1.0-SNAPSHOT");
+ report.put("duration", "PT10.5S");
+ report.put(
+ "output",
+ List.of(
+ logEvent("INFO", "Building reactor..."),
+ logEvent("WARN", "Reactor build order could be optimized")));
+
+ // Module 1: maven-api-core
+ Map<String, Object> apiModule = new LinkedHashMap<>();
+ apiModule.put("artifactId", "maven-api-core");
+ apiModule.put("status", "SUCCESS");
+ apiModule.put("duration", "PT3.2S");
+ apiModule.put(
+ "output",
+ List.of(
+ logEvent("INFO", "Compiling 42 source files"),
+ logEvent("WARN", "Using deprecated API method")));
+ apiModule.put(
+ "mojos",
+ List.of(
+ mojo("compile", List.of(logEvent("INFO", "Compiling sources"))),
+ mojo("test-compile", List.of(logEvent("INFO", "Compiling test sources")))));
+
+ // Module 2: maven-cli
+ Map<String, Object> cliModule = new LinkedHashMap<>();
+ cliModule.put("artifactId", "maven-cli");
+ cliModule.put("status", "SUCCESS");
+ cliModule.put("duration", "PT4.1S");
+ cliModule.put(
+ "output",
+ List.of(
+ logEvent("INFO", "Compiling 30 source files"),
+ logEvent("WARN", "Some warning in CLI"),
+ logEvent("ERROR", "Critical error in module")));
+ cliModule.put(
+ "mojos",
+ List.of(
+ mojo("compile", List.of(logEvent("INFO", "Compiling sources"))),
+ mojo("resources", List.of(logEvent("DEBUG", "Copying resources")))));
+
+ report.put("modules", List.of(apiModule, cliModule));
+ report.put("problems", List.of());
+ report.put("failures", List.of());
+ return report;
+ }
+
+ private static Map<String, Object> logEvent(String level, String message) {
+ Map<String, Object> event = new LinkedHashMap<>();
+ event.put("level", level);
+ event.put("message", message);
+ event.put("loggerName", "org.apache.maven.TestLogger");
+ event.put("timestamp", "2026-08-08T10:00:00Z");
+ return event;
+ }
+
+ private static Map<String, Object> mojo(String goal, List<Map<String, Object>> output) {
+ Map<String, Object> mojo = new LinkedHashMap<>();
+ mojo.put("goal", goal);
+ mojo.put("artifactId", "maven-compiler-plugin");
+ mojo.put("status", "SUCCESS");
+ mojo.put("duration", "PT1.5S");
+ mojo.put("output", output);
+ return mojo;
+ }
+}
diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/BuildReportRendererTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/BuildReportRendererTest.java
new file mode 100644
index 0000000000..8e72867189
--- /dev/null
+++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/BuildReportRendererTest.java
@@ -0,0 +1,250 @@
+/*
+ * 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.List;
+import java.util.Map;
+
+import org.apache.maven.jline.JLineMessageBuilderFactory;
+import org.apache.maven.jline.MessageUtils;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class BuildReportRendererTest {
+
+ private final JLineMessageBuilderFactory messageBuilderFactory = new JLineMessageBuilderFactory();
+
+ @BeforeAll
+ static void setUp() {
+ MessageUtils.setColorEnabled(false);
+ }
+
+ @AfterAll
+ static void tearDown() {
+ MessageUtils.setColorEnabled(true);
+ }
+
+ private Map<String, Object> createSuccessReport() {
+ String json = """
+ {
+ "formatVersion": "1.0",
+ "status": "SUCCESS",
+ "duration": "PT6.7S",
+ "startTime": "2026-07-29T10:00:00Z",
+ "mavenVersion": "4.1.0-SNAPSHOT",
+ "javaVersion": "21.0.1",
+ "goals": ["clean", "install"],
+ "project": "org.example:root",
+ "multiModule": true,
+ "threads": 1,
+ "modules": [
+ {
+ "groupId": "org.example",
+ "artifactId": "api",
+ "version": "1.0",
+ "status": "SUCCESS",
+ "startTime": "2026-07-29T10:00:01Z",
+ "duration": "PT2.1S",
+ "mojos": [
+ {
+ "groupId": "org.apache.maven.plugins",
+ "artifactId": "maven-compiler-plugin",
+ "version": "3.15.0",
+ "goal": "compile",
+ "executionId": "default-compile",
+ "phase": "compile",
+ "status": "SUCCESS",
+ "startTime": "2026-07-29T10:00:01Z",
+ "duration": "PT1.5S",
+ "output": []
+ }
+ ],
+ "output": []
+ },
+ {
+ "groupId": "org.example",
+ "artifactId": "core",
+ "version": "1.0",
+ "status": "SUCCESS",
+ "startTime": "2026-07-29T10:00:03Z",
+ "duration": "PT3.4S",
+ "mojos": [],
+ "output": []
+ }
+ ],
+ "problems": [
+ {
+ "key": "deprecated-source-target",
+ "severity": "WARNING",
+ "message": "source/target value 8 is obsolete and will be removed in a future release",
+ "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/"
+ }
+ ],
+ "failures": [],
+ "output": []
+ }""";
+ return SimpleJsonReader.parse(json);
+ }
+
+ private Map<String, Object> createFailureReport() {
+ String json = """
+ {
+ "formatVersion": "1.0",
+ "status": "FAILURE",
+ "duration": "PT5.0S",
+ "startTime": "2026-07-29T10:00:00Z",
+ "mavenVersion": "4.1.0-SNAPSHOT",
+ "javaVersion": "21.0.1",
+ "goals": ["compile"],
+ "project": "org.example:root",
+ "multiModule": false,
+ "threads": 1,
+ "modules": [
+ {
+ "groupId": "org.example",
+ "artifactId": "core",
+ "version": "1.0",
+ "status": "FAILURE",
+ "startTime": "2026-07-29T10:00:01Z",
+ "duration": "PT5.0S",
+ "mojos": [],
+ "output": []
+ }
+ ],
+ "problems": [],
+ "failures": [
+ {
+ "module": "org.example:core",
+ "mojo": "compiler:compile",
+ "timestamp": "2026-07-29T10:00:05Z",
+ "message": "Compilation failure",
+ "stackTrace": "org.apache.maven.lifecycle.LifecycleExecutionException\\nat Lifecycle.java:42"
+ }
+ ],
+ "output": []
+ }""";
+ return SimpleJsonReader.parse(json);
+ }
+
+ @Test
+ void testRenderSummarySuccess() {
+ List<String> lines = new ArrayList<>();
+ BuildReportRenderer renderer = new BuildReportRenderer(messageBuilderFactory, lines::add);
+
+ renderer.renderSummary(createSuccessReport());
+
+ String output = String.join("\n", lines);
+ assertTrue(output.contains("Build Report"), "Should contain header");
+ assertTrue(output.contains("Maven 4.1.0-SNAPSHOT"), "Should contain Maven version");
+ assertTrue(output.contains("BUILD SUCCESS"), "Should contain BUILD SUCCESS");
+ assertTrue(output.contains("api"), "Should contain first module");
+ assertTrue(output.contains("core"), "Should contain second module");
+ assertTrue(output.contains("2 modules"), "Should contain module count");
+ assertTrue(output.contains("2 passed"), "Should contain passed count");
+ assertTrue(output.contains("1 warning"), "Should contain warning count");
+ assertTrue(output.contains("source/target value 8"), "Should show actual warning message in default view");
+ assertTrue(output.contains("maven-compiler-plugin"), "Should show warning source in default view");
+ assertTrue(output.contains("Update maven.compiler.source"), "Should show suggestion in default view");
+ assertTrue(output.contains("Total time: 6.700 s"), "Should contain formatted total time");
+ }
+
+ @Test
+ void testRenderSummaryFailure() {
+ List<String> lines = new ArrayList<>();
+ BuildReportRenderer renderer = new BuildReportRenderer(messageBuilderFactory, lines::add);
+
+ renderer.renderSummary(createFailureReport());
+
+ String output = String.join("\n", lines);
+ assertTrue(output.contains("BUILD FAILURE"), "Should contain BUILD FAILURE");
+ assertTrue(output.contains("1 failure"), "Should contain failure count");
+ }
+
+ @Test
+ void testRenderDiagnostics() {
+ List<String> lines = new ArrayList<>();
+ BuildReportRenderer renderer = new BuildReportRenderer(messageBuilderFactory, lines::add);
+
+ renderer.renderDiagnostics(createSuccessReport());
+
+ String output = String.join("\n", lines);
+ assertTrue(output.contains("Problems (1)"), "Should contain problems header");
+ assertTrue(output.contains("source/target value 8"), "Should contain warning message");
+ assertTrue(output.contains("deprecated-source-target"), "Should contain problem key");
+ assertTrue(output.contains("maven-compiler-plugin"), "Should contain source");
+ assertTrue(output.contains("Update maven.compiler.source"), "Should contain suggestion");
+ assertTrue(
+ output.contains("https://maven.apache.org/plugins/maven-compiler-plugin/"),
+ "Should contain documentation URL");
+ }
+
+ @Test
+ void testRenderDiagnosticsWhenEmpty() {
+ List<String> lines = new ArrayList<>();
+ BuildReportRenderer renderer = new BuildReportRenderer(messageBuilderFactory, lines::add);
+
+ renderer.renderDiagnostics(createFailureReport());
+
+ String output = String.join("\n", lines);
+ assertTrue(output.contains("No problems recorded"), "Should show empty message");
+ }
+
+ @Test
+ void testRenderFailures() {
+ List<String> lines = new ArrayList<>();
+ BuildReportRenderer renderer = new BuildReportRenderer(messageBuilderFactory, lines::add);
+
+ renderer.renderFailures(createFailureReport());
+
+ String output = String.join("\n", lines);
+ assertTrue(output.contains("Failures (1)"), "Should contain failures header");
+ assertTrue(output.contains("org.example:core"), "Should contain module name");
+ assertTrue(output.contains("compiler:compile"), "Should contain mojo");
+ assertTrue(output.contains("Compilation failure"), "Should contain error message");
+ }
+
+ @Test
+ void testRenderFull() {
+ List<String> lines = new ArrayList<>();
+ BuildReportRenderer renderer = new BuildReportRenderer(messageBuilderFactory, lines::add);
+
+ renderer.renderFull(createSuccessReport());
+
+ String output = String.join("\n", lines);
+ assertTrue(output.contains("Module: api"), "Should contain module name");
+ assertTrue(output.contains("compiler"), "Should contain mojo plugin");
+ assertTrue(output.contains("compile"), "Should contain mojo goal");
+ assertTrue(output.contains("default-compile"), "Should contain execution id");
+ }
+
+ @Test
+ void testFormatDuration() {
+ assertEquals("6.700 s", BuildReportRenderer.formatDuration("PT6.7S"));
+ assertEquals("0.100 s", BuildReportRenderer.formatDuration("PT0.1S"));
+ assertEquals("1:30 min", BuildReportRenderer.formatDuration("PT1M30S"));
+ assertEquals("PT-invalid", BuildReportRenderer.formatDuration("PT-invalid")); // fallback
+ }
+}
diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonReaderTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonReaderTest.java
new file mode 100644
index 0000000000..451151387c
--- /dev/null
+++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonReaderTest.java
@@ -0,0 +1,141 @@
+/*
+ * 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.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class SimpleJsonReaderTest {
+
+ @Test
+ void testParseEmptyObject() {
+ Map<String, Object> result = SimpleJsonReader.parse("{}");
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ void testParseSimpleObject() {
+ Map<String, Object> result = SimpleJsonReader.parse("""
+ {"name": "test", "version": "1.0"}""");
+ assertEquals("test", result.get("name"));
+ assertEquals("1.0", result.get("version"));
+ }
+
+ @Test
+ void testParseNumbers() {
+ Map<String, Object> result = SimpleJsonReader.parse("""
+ {"count": 42, "ratio": 3.14, "negative": -7}""");
+ assertEquals(42, result.get("count"));
+ assertEquals(3.14, result.get("ratio"));
+ assertEquals(-7, result.get("negative"));
+ }
+
+ @Test
+ void testParseBooleanAndNull() {
+ Map<String, Object> result = SimpleJsonReader.parse("""
+ {"active": true, "deleted": false, "extra": null}""");
+ assertEquals(true, result.get("active"));
+ assertEquals(false, result.get("deleted"));
+ assertNull(result.get("extra"));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void testParseArray() {
+ Map<String, Object> result = SimpleJsonReader.parse("""
+ {"goals": ["clean", "install"]}""");
+ List<Object> goals = (List<Object>) result.get("goals");
+ assertEquals(2, goals.size());
+ assertEquals("clean", goals.get(0));
+ assertEquals("install", goals.get(1));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void testParseNestedObject() {
+ Map<String, Object> result = SimpleJsonReader.parse("""
+ {"module": {"artifactId": "core", "status": "SUCCESS"}}""");
+ Map<String, Object> module = (Map<String, Object>) result.get("module");
+ assertEquals("core", module.get("artifactId"));
+ assertEquals("SUCCESS", module.get("status"));
+ }
+
+ @Test
+ void testParseStringEscapes() {
+ Map<String, Object> result = SimpleJsonReader.parse("""
+ {"msg": "line1\\nline2", "path": "C:\\\\Users"}""");
+ assertEquals("line1\nline2", result.get("msg"));
+ assertEquals("C:\\Users", result.get("path"));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void testParseBuildReportFragment() {
+ String json = """
+ {
+ "formatVersion": "1.0",
+ "status": "SUCCESS",
+ "duration": "PT6.7S",
+ "mavenVersion": "4.1.0-SNAPSHOT",
+ "modules": [
+ {
+ "artifactId": "maven-api-core",
+ "status": "SUCCESS",
+ "duration": "PT2.1S",
+ "mojos": []
+ },
+ {
+ "artifactId": "maven-core",
+ "status": "SUCCESS",
+ "duration": "PT3.4S",
+ "mojos": []
+ }
+ ],
+ "problems": [],
+ "failures": []
+ }""";
+
+ Map<String, Object> report = SimpleJsonReader.parse(json);
+ assertEquals("1.0", report.get("formatVersion"));
+ assertEquals("SUCCESS", report.get("status"));
+ assertEquals("PT6.7S", report.get("duration"));
+
+ List<Map<String, Object>> modules = (List<Map<String, Object>>) (List<?>) report.get("modules");
+ assertEquals(2, modules.size());
+ assertEquals("maven-api-core", modules.get(0).get("artifactId"));
+ assertEquals("maven-core", modules.get(1).get("artifactId"));
+ }
+
+ @Test
+ void testParseInvalidJson() {
+ assertThrows(IllegalArgumentException.class, () -> SimpleJsonReader.parse("not json"));
+ }
+
+ @Test
+ void testParseNonObjectRoot() {
+ assertThrows(IllegalArgumentException.class, () -> SimpleJsonReader.parse("[1, 2, 3]"));
+ }
+}
diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonWriterTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonWriterTest.java
new file mode 100644
index 0000000000..fdadf81b71
--- /dev/null
+++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonWriterTest.java
@@ -0,0 +1,116 @@
+/*
+ * 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.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class SimpleJsonWriterTest {
+
+ @Test
+ void emptyObject() {
+ assertEquals("{}", SimpleJsonWriter.toJson(Map.of()));
+ }
+
+ @Test
+ void emptyArray() {
+ assertEquals("[]", SimpleJsonWriter.toJson(List.of()));
+ }
+
+ @Test
+ void simpleString() {
+ assertEquals("\"hello\"", SimpleJsonWriter.toJson("hello"));
+ }
+
+ @Test
+ void stringWithEscapes() {
+ String json = SimpleJsonWriter.toJson("line1\nline2\t\"quoted\"");
+ assertEquals("\"line1\\nline2\\t\\\"quoted\\\"\"", json);
+ }
+
+ @Test
+ void numbers() {
+ assertEquals("42", SimpleJsonWriter.toJson(42));
+ assertEquals("3.14", SimpleJsonWriter.toJson(3.14));
+ }
+
+ @Test
+ void booleans() {
+ assertEquals("true", SimpleJsonWriter.toJson(true));
+ assertEquals("false", SimpleJsonWriter.toJson(false));
+ }
+
+ @Test
+ void nullValue() {
+ assertEquals("null", SimpleJsonWriter.toJson(null));
+ }
+
+ @Test
+ void simpleObject() {
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put("name", "test");
+ map.put("count", 42);
+
+ String json = SimpleJsonWriter.toJson(map);
+ assertTrue(json.contains("\"name\": \"test\""));
+ assertTrue(json.contains("\"count\": 42"));
+ }
+
+ @Test
+ void nestedObject() {
+ Map<String, Object> inner = new LinkedHashMap<>();
+ inner.put("key", "value");
+
+ Map<String, Object> outer = new LinkedHashMap<>();
+ outer.put("nested", inner);
+
+ String json = SimpleJsonWriter.toJson(outer);
+ assertTrue(json.contains("\"nested\": {"));
+ assertTrue(json.contains("\"key\": \"value\""));
+ }
+
+ @Test
+ void arrayOfStrings() {
+ String json = SimpleJsonWriter.toJson(List.of("a", "b", "c"));
+ assertTrue(json.contains("\"a\""));
+ assertTrue(json.contains("\"b\""));
+ assertTrue(json.contains("\"c\""));
+ }
+
+ @Test
+ void roundTripWithSimpleJsonReader() {
+ Map<String, Object> original = new LinkedHashMap<>();
+ original.put("status", "SUCCESS");
+ original.put("count", 42);
+ original.put("items", List.of("a", "b"));
+
+ String json = SimpleJsonWriter.toJson(original);
+ Map<String, Object> parsed = SimpleJsonReader.parse(json);
+
+ assertEquals("SUCCESS", parsed.get("status"));
+ // SimpleJsonReader parses small integers as Integer
+ assertEquals(42, ((Number) parsed.get("count")).intValue());
+ }
+}
diff --git a/its/core-it-suite/pom.xml b/its/core-it-suite/pom.xml
index 233f88d348..50e468d077 100644
--- a/its/core-it-suite/pom.xml
+++ b/its/core-it-suite/pom.xml
@@ -519,7 +519,7 @@ under the License.
<unzip dest="${mavenHome}" src="${sessionRootDirectory}/apache-maven/target/apache-maven-${project.version}-bin.zip">
<regexpmapper from="^([^/]+)/(.*)$" handledirsep="true" to="\2" />
</unzip>
- <chmod dir="${mavenHome}/bin" includes="mvn,mvnDebug,mvnenc" perm="ugo+rx" />
+ <chmod dir="${mavenHome}/bin" includes="mvn,mvnDebug,mvnenc,mvnlog" perm="ugo+rx" />
</target>
</configuration>
</execution>
@@ -749,7 +749,7 @@ under the License.
<unzip dest="${mavenHome}" src="${project.build.directory}/maven-bin.zip">
<globmapper from="apache-maven-${mavenVersion}/*" handledirsep="true" to="*" />
</unzip>
- <chmod dir="${mavenHome}/bin" includes="mvn,mvnDebug,mvnenc" perm="ugo+rx" />
+ <chmod dir="${mavenHome}/bin" includes="mvn,mvnDebug,mvnenc,mvnlog" perm="ugo+rx" />
</target>
</configuration>
</execution>
@@ -804,7 +804,7 @@ under the License.
<unzip dest="${mavenHome}" src="${mavenDistro}">
<regexpmapper from="^([^/]+)/(.*)$" handledirsep="true" to="\2" />
</unzip>
- <chmod dir="${mavenHome}/bin" includes="mvn,mvnDebug,mvnenc" perm="ugo+rx" />
+ <chmod dir="${mavenHome}/bin" includes="mvn,mvnDebug,mvnenc,mvnlog" perm="ugo+rx" />
</target>
</configuration>
</execution>
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12571BuildReportTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12571BuildReportTest.java
new file mode 100644
index 0000000000..dbb19974cb
--- /dev/null
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12571BuildReportTest.java
@@ -0,0 +1,423 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.it;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Integration tests for the Build Report Foundation feature.
+ * <p>
+ * Covers: build report JSON generation, console modes (plain, machine, verbose),
+ * warning mode, version info on failure, and the {@code mvnlog} viewer tool.
+ *
+ * @see <a href="https://github.com/apache/maven/issues/12571">gh-12571</a>
+ * @since 4.1.0
+ */
+class MavenITgh12571BuildReportTest extends AbstractMavenIntegrationTestCase {
+
+ // -------------------------------------------------------------------------
+ // Build report JSON generation
+ // -------------------------------------------------------------------------
+
+ /**
+ * Verify that a successful single-module build produces a JSON report file
+ * containing the expected top-level fields.
+ */
+ @Test
+ void testBuildReportJsonGenerated() throws Exception {
+ Path basedir = extractResources("gh-12571-build-report");
+
+ Verifier verifier = newVerifier(basedir);
+ verifier.setLogFileName("report-gen.txt");
+ verifier.addCliArgument("--console=verbose");
+ verifier.addCliArgument("validate");
+ verifier.execute();
+ verifier.verifyErrorFreeLog();
+
+ // Build report file must exist
+ Path reportFile = basedir.resolve("target/build-reports/build-report-latest.json");
+ verifier.verifyFilePresent(reportFile);
+
+ // Verify JSON structure
+ String json = Files.readString(reportFile);
+ assertTrue(json.contains("\"formatVersion\""), "Should contain formatVersion");
+ assertTrue(json.contains("\"status\""), "Should contain status");
+ assertTrue(json.contains("\"SUCCESS\""), "Status should be SUCCESS");
+ assertTrue(json.contains("\"duration\""), "Should contain duration");
+ assertTrue(json.contains("\"mavenVersion\""), "Should contain mavenVersion");
+ assertTrue(json.contains("\"javaVersion\""), "Should contain javaVersion");
+ assertTrue(json.contains("\"modules\""), "Should contain modules array");
+ assertTrue(json.contains("\"problems\""), "Should contain problems array");
+ assertTrue(json.contains("\"failures\""), "Should contain failures array");
+ assertTrue(json.contains("\"build-report-test\""), "Should contain artifactId");
+ }
+
+ /**
+ * Verify that a multi-module build produces a JSON report with all modules listed.
+ */
+ @Test
+ void testBuildReportMultiModule() throws Exception {
+ Path basedir = extractResources("gh-12571-multi-module");
+
+ Verifier verifier = newVerifier(basedir);
+ verifier.setLogFileName("multi-module.txt");
+ verifier.addCliArgument("--console=verbose");
+ verifier.addCliArgument("validate");
+ verifier.execute();
+ verifier.verifyErrorFreeLog();
+
+ Path reportFile = basedir.resolve("target/build-reports/build-report-latest.json");
+ verifier.verifyFilePresent(reportFile);
+
+ String json = Files.readString(reportFile);
+ assertTrue(json.contains("\"module-a\""), "Should contain module-a");
+ assertTrue(json.contains("\"module-b\""), "Should contain module-b");
+ assertTrue(json.contains("\"multi-module-parent\""), "Should contain parent");
+ assertTrue(json.contains("\"multiModule\""), "Should contain multiModule flag");
+ }
+
+ // -------------------------------------------------------------------------
+ // Console modes
+ // -------------------------------------------------------------------------
+
+ /**
+ * Verify that {@code --console=plain} produces compact output without
+ * the full mojo-level detail that verbose mode shows.
+ */
+ @Test
+ void testConsolePlainMode() throws Exception {
+ Path basedir = extractResources("gh-12571-multi-module");
+
+ Verifier verifier = newVerifier(basedir);
+ verifier.setLogFileName("plain.txt");
+ verifier.addCliArgument("--console=plain");
+ verifier.addCliArgument("validate");
+ verifier.execute();
+ verifier.verifyErrorFreeLog();
+
+ // Plain mode should still show BUILD SUCCESS and Total time
+ verifier.verifyTextInLog("BUILD SUCCESS");
+ verifier.verifyTextInLog("Total time:");
+
+ // Plain mode should NOT show the verbose "--- plugin:goal" lines
+ List<String> lines = verifier.loadLines("plain.txt");
+ boolean hasPluginLine = lines.stream().anyMatch(l -> l.matches(".*---.*:.*---.*"));
+ assertFalse(hasPluginLine, "Plain mode should not contain verbose mojo execution lines");
+ }
+
+ /**
+ * Verify that {@code --console=machine} produces JSON lines output
+ * with typed events.
+ */
+ @Test
+ void testConsoleMachineMode() throws Exception {
+ Path basedir = extractResources("gh-12571-build-report");
+
+ Verifier verifier = newVerifier(basedir);
+ verifier.setLogFileName("machine.txt");
+ verifier.addCliArgument("--console=machine");
+ verifier.addCliArgument("validate");
+ verifier.execute();
+
+ List<String> lines = verifier.loadLines("machine.txt");
+
+ // Machine mode should produce JSON lines with "event" fields
+ boolean hasBuildStarted = lines.stream().anyMatch(l -> l.contains("\"event\":\"build.started\""));
+ boolean hasBuildFinished = lines.stream().anyMatch(l -> l.contains("\"event\":\"build.finished\""));
+ boolean hasModuleStarted = lines.stream().anyMatch(l -> l.contains("\"event\":\"module.started\""));
+ boolean hasTimestamp = lines.stream().anyMatch(l -> l.contains("\"timestamp\""));
+
+ assertTrue(hasBuildStarted, "Should contain build.started event");
+ assertTrue(hasBuildFinished, "Should contain build.finished event");
+ assertTrue(hasModuleStarted, "Should contain module.started event");
+ assertTrue(hasTimestamp, "Events should contain timestamps");
+
+ // build.finished should report SUCCESS
+ boolean hasSuccess =
+ lines.stream().anyMatch(l -> l.contains("\"event\":\"build.finished\"") && l.contains("\"SUCCESS\""));
+ assertTrue(hasSuccess, "build.finished should report SUCCESS");
+ }
+
+ /**
+ * Verify that {@code --console=verbose} (the classic Maven output) includes
+ * the standard banner lines and mojo execution details.
+ */
+ @Test
+ void testConsoleVerboseMode() throws Exception {
+ Path basedir = extractResources("gh-12571-build-report");
+
+ Verifier verifier = newVerifier(basedir);
+ verifier.setLogFileName("verbose.txt");
+ verifier.addCliArgument("--console=verbose");
+ verifier.addCliArgument("validate");
+ verifier.execute();
+ verifier.verifyErrorFreeLog();
+
+ verifier.verifyTextInLog("BUILD SUCCESS");
+ verifier.verifyTextInLog("Total time:");
+ // Verbose mode shows the horizontal rule separator
+ verifier.verifyTextInLog("------------------------------------------------------------------------");
+ }
+
+ /**
+ * Verify that when the {@code CI} environment variable is set,
+ * {@code --console=auto} resolves to plain mode (no verbose mojo lines).
+ */
+ @Test
+ void testConsoleAutoDetectsCi() throws Exception {
+ Path basedir = extractResources("gh-12571-multi-module");
+
+ Verifier verifier = newVerifier(basedir);
+ verifier.setLogFileName("auto-ci.txt");
+ verifier.setEnvironmentVariable("CI", "true");
+ verifier.addCliArgument("--console=auto");
+ verifier.addCliArgument("validate");
+ verifier.execute();
+ verifier.verifyErrorFreeLog();
+
+ verifier.verifyTextInLog("BUILD SUCCESS");
+
+ // In CI mode (plain), should NOT show verbose mojo execution lines
+ List<String> lines = verifier.loadLines("auto-ci.txt");
+ boolean hasPluginLine = lines.stream().anyMatch(l -> l.matches(".*---.*:.*---.*"));
+ assertFalse(hasPluginLine, "Auto mode with CI=true should not produce verbose mojo lines");
+ }
+
+ // -------------------------------------------------------------------------
+ // Warning mode
+ // -------------------------------------------------------------------------
+
+ /**
+ * Verify that {@code --warning-mode=none} suppresses the diagnostic summary
+ * at the end of the build.
+ */
+ @Test
+ void testWarningModeNone() throws Exception {
+ Path basedir = extractResources("gh-12571-build-report");
+
+ Verifier verifier = newVerifier(basedir);
+ verifier.setLogFileName("warn-none.txt");
+ verifier.addCliArgument("--console=verbose");
+ verifier.addCliArgument("--warning-mode=none");
+ verifier.addCliArgument("validate");
+ verifier.execute();
+ verifier.verifyErrorFreeLog();
+
+ // With --warning-mode=none, the diagnostic summary line should not appear
+ verifier.verifyTextNotInLog("Diagnostics:");
+ }
+
+ // -------------------------------------------------------------------------
+ // Version info on failure (MNG-7372)
+ // -------------------------------------------------------------------------
+
+ /**
+ * Verify that on BUILD FAILURE the Maven version and Java version
+ * are printed in the summary output.
+ */
+ @Test
+ void testVersionInfoOnFailure() throws Exception {
+ Path basedir = extractResources("gh-12571-build-report");
+
+ Verifier verifier = newVerifier(basedir);
+ verifier.setLogFileName("fail-version.txt");
+ verifier.addCliArgument("--console=verbose");
+ // Invoke a non-existent goal to trigger a failure
+ verifier.addCliArgument("org.apache.maven.plugins:non-existent-plugin:1.0:goal");
+
+ boolean failed = false;
+ try {
+ verifier.execute();
+ } catch (VerificationException e) {
+ failed = true;
+ }
+
+ assertTrue(failed, "Build should have failed");
+ verifier.verifyTextInLog("BUILD FAILURE");
+ // The version info line should be present (e.g. "Maven 4.1.0-SNAPSHOT | Java 21.0.x")
+ verifier.verifyTextInLog("Maven");
+ verifier.verifyTextInLog("Java");
+ }
+
+ // -------------------------------------------------------------------------
+ // mvnlog viewer
+ // -------------------------------------------------------------------------
+
+ /**
+ * Verify that {@code mvnlog} displays a summary of the last build report
+ * after a successful build.
+ */
+ @Test
+ void testMvnlogShowsSummary() throws Exception {
+ Path basedir = extractResources("gh-12571-build-report");
+
+ // Step 1: run a build to generate the report
+ Verifier buildVerifier = newVerifier(basedir);
+ buildVerifier.setLogFileName("build-for-mvnlog.txt");
+ buildVerifier.addCliArgument("--console=verbose");
+ buildVerifier.addCliArgument("validate");
+ buildVerifier.execute();
+ buildVerifier.verifyErrorFreeLog();
+
+ // Verify the report was generated
+ Path reportFile = basedir.resolve("target/build-reports/build-report-latest.json");
+ buildVerifier.verifyFilePresent(reportFile);
+
+ // Step 2: run mvnlog to view the report (forked: embedded executor does not know mvnlog)
+ Verifier logVerifier = newVerifier(basedir);
+ logVerifier.setLogFileName("mvnlog-summary.txt");
+ logVerifier.setForkJvm(true);
+ logVerifier.setExecutable("mvnlog");
+ logVerifier.execute();
+
+ // mvnlog should display report content
+ logVerifier.verifyTextInLog("Build Report");
+ logVerifier.verifyTextInLog("SUCCESS");
+ }
+
+ /**
+ * Verify that {@code mvnlog --full} shows per-module detail.
+ */
+ @Test
+ void testMvnlogFullView() throws Exception {
+ Path basedir = extractResources("gh-12571-multi-module");
+
+ // Step 1: run a build to generate the report
+ Verifier buildVerifier = newVerifier(basedir);
+ buildVerifier.setLogFileName("build-for-full.txt");
+ buildVerifier.addCliArgument("--console=verbose");
+ buildVerifier.addCliArgument("validate");
+ buildVerifier.execute();
+ buildVerifier.verifyErrorFreeLog();
+
+ // Step 2: run mvnlog --full (forked: embedded executor does not know mvnlog)
+ Verifier logVerifier = newVerifier(basedir);
+ logVerifier.setLogFileName("mvnlog-full.txt");
+ logVerifier.setForkJvm(true);
+ logVerifier.setExecutable("mvnlog");
+ logVerifier.addCliArgument("--full");
+ logVerifier.execute();
+
+ // Full view should show individual module details
+ logVerifier.verifyTextInLog("module-a");
+ logVerifier.verifyTextInLog("module-b");
+ }
+
+ /**
+ * Verify that {@code mvnlog} reports an error when no build report exists.
+ */
+ @Test
+ void testMvnlogNoReport() throws Exception {
+ Path basedir = extractResources("gh-12571-build-report");
+
+ // Clean any prior reports
+ Path reportsDir = basedir.resolve("target/build-reports");
+ if (Files.isDirectory(reportsDir)) {
+ Files.walk(reportsDir)
+ .sorted(java.util.Comparator.reverseOrder())
+ .forEach(p -> {
+ try {
+ Files.deleteIfExists(p);
+ } catch (Exception e) {
+ // ignore
+ }
+ });
+ }
+
+ Verifier logVerifier = newVerifier(basedir);
+ logVerifier.setLogFileName("mvnlog-no-report.txt");
+ logVerifier.setForkJvm(true);
+ logVerifier.setExecutable("mvnlog");
+
+ boolean failed = false;
+ try {
+ logVerifier.execute();
+ } catch (VerificationException e) {
+ failed = true;
+ }
+
+ assertTrue(failed, "mvnlog should fail when no report exists");
+ logVerifier.verifyTextInLog("Build report not found");
+ }
+
+ /**
+ * Verify that {@code mvnlog --list} lists available reports.
+ */
+ @Test
+ void testMvnlogListReports() throws Exception {
+ Path basedir = extractResources("gh-12571-build-report");
+
+ // Step 1: run a build to generate at least one report
+ Verifier buildVerifier = newVerifier(basedir);
+ buildVerifier.setLogFileName("build-for-list.txt");
+ buildVerifier.addCliArgument("--console=verbose");
+ buildVerifier.addCliArgument("validate");
+ buildVerifier.execute();
+ buildVerifier.verifyErrorFreeLog();
+
+ // Step 2: run mvnlog --list (forked: embedded executor does not know mvnlog)
+ Verifier logVerifier = newVerifier(basedir);
+ logVerifier.setLogFileName("mvnlog-list.txt");
+ logVerifier.setForkJvm(true);
+ logVerifier.setExecutable("mvnlog");
+ logVerifier.addCliArgument("--list");
+ logVerifier.execute();
+
+ // Should list the report file(s)
+ logVerifier.verifyTextInLog("build-report");
+ }
+
+ /**
+ * Verify that {@code mvnlog --json} outputs the raw JSON build report,
+ * suitable for piping to tools like {@code jq}.
+ */
+ @Test
+ void testMvnlogJsonOutput() throws Exception {
+ Path basedir = extractResources("gh-12571-build-report");
+
+ // Step 1: run a build to generate the report
+ Verifier buildVerifier = newVerifier(basedir);
+ buildVerifier.setLogFileName("build-for-json.txt");
+ buildVerifier.addCliArgument("--console=verbose");
+ buildVerifier.addCliArgument("validate");
+ buildVerifier.execute();
+ buildVerifier.verifyErrorFreeLog();
+
+ // Step 2: run mvnlog --json (forked: embedded executor does not know mvnlog)
+ Verifier logVerifier = newVerifier(basedir);
+ logVerifier.setLogFileName("mvnlog-json.txt");
+ logVerifier.setForkJvm(true);
+ logVerifier.setExecutable("mvnlog");
+ logVerifier.addCliArgument("--json");
+ logVerifier.execute();
+
+ // Output should be valid JSON with expected fields
+ logVerifier.verifyTextInLog("\"formatVersion\"");
+ logVerifier.verifyTextInLog("\"status\"");
+ logVerifier.verifyTextInLog("\"modules\"");
+ logVerifier.verifyTextInLog("\"mavenVersion\"");
+ }
+}
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng5760ResumeFeatureTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng5760ResumeFeatureTest.java
index 010e39cc2d..695fd825d6 100644
--- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng5760ResumeFeatureTest.java
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng5760ResumeFeatureTest.java
@@ -63,6 +63,7 @@ public void testShouldSuggestToResumeWithoutArgs() throws Exception {
verifier.addCliArgument("-Dmodule-b.fail=true");
try {
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument("test");
verifier.execute();
fail("Expected this invocation to fail");
@@ -74,6 +75,7 @@ public void testShouldSuggestToResumeWithoutArgs() throws Exception {
// New build with -r should resume the build from module-b, skipping module-a since it has succeeded already.
verifier = newVerifier(parentDependentTestDir);
verifier.addCliArgument("-r");
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument("test");
verifier.execute();
verifier.verifyTextNotInLog("Building module-a 1.0");
@@ -88,6 +90,7 @@ public void testShouldSkipSuccessfulProjects() throws Exception {
verifier.addCliArgument("--fail-at-end");
try {
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument("test");
verifier.execute();
fail("Expected this invocation to fail");
@@ -102,6 +105,7 @@ public void testShouldSkipSuccessfulProjects() throws Exception {
// ... but adding -r should exclude those two from the build because the previous Maven invocation
// marked them as successfully built.
verifier.addCliArgument("-r");
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument("test");
verifier.execute();
}
@@ -116,6 +120,7 @@ public void testShouldSkipSuccessfulModulesWhenTheFirstModuleFailed() throws Exc
verifier.addCliArgument("--fail-at-end");
try {
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument("test");
verifier.execute();
fail("Expected this invocation to fail");
@@ -125,6 +130,7 @@ public void testShouldSkipSuccessfulModulesWhenTheFirstModuleFailed() throws Exc
verifier = newVerifier(parentIndependentTestDir);
verifier.addCliArgument("-r");
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument("test");
verifier.execute();
verifier.verifyTextInLog("Building module-a 1.0");
@@ -139,6 +145,7 @@ public void testShouldNotCrashWithoutProject() throws Exception {
// https://issues.apache.org/jira/browse/MNG-5760?focusedCommentId=17143795&page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel#comment-17143795)
final Verifier verifier = newVerifier(noProjectTestDir);
try {
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument("org.apache.maven.plugins:maven-resources-plugin:resources");
verifier.execute();
} catch (final VerificationException ve) {
@@ -164,6 +171,7 @@ public void testFailureWithParallelBuild() throws Exception {
verifier.addCliArgument("-Dmodule-a.fail=true");
verifier.addCliArgument("-Dmodule-c.fail=true");
try {
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument("verify");
verifier.execute();
fail("Expected this invocation to fail");
@@ -183,6 +191,7 @@ public void testFailureWithParallelBuild() throws Exception {
// c : success
// d : success
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument("verify");
verifier.execute();
}
@@ -204,6 +213,7 @@ public void testFailureAfterSkipWithParallelBuild() throws Exception {
verifier.addCliArgument("-Dmodule-b.delay=2000");
verifier.addCliArgument("-Dmodule-d.fail=true");
try {
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument("verify");
verifier.execute();
fail("Expected this invocation to fail");
@@ -223,6 +233,7 @@ public void testFailureAfterSkipWithParallelBuild() throws Exception {
// The result should be:
// c : success
// d : success
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument("verify");
verifier.execute();
}
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng5965ParallelBuildMultipliesWorkTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng5965ParallelBuildMultipliesWorkTest.java
index 9fc554a247..353944c616 100644
--- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng5965ParallelBuildMultipliesWorkTest.java
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng5965ParallelBuildMultipliesWorkTest.java
@@ -43,6 +43,7 @@ public void testItShouldOnlyRunEachTaskOnce() throws Exception {
verifier.setLogFileName("log-only.txt");
verifier.addCliArgument("-T1");
// include an aggregator task so that the two goals end up in different task segments
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArguments("clean", "install:help");
verifier.execute();
verifier.verifyErrorFreeLog();
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6057CheckReactorOrderTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6057CheckReactorOrderTest.java
index 44019dd9c4..c41109e265 100644
--- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6057CheckReactorOrderTest.java
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6057CheckReactorOrderTest.java
@@ -56,6 +56,7 @@ public void testitReactorShouldResultInExpectedOrder() throws Exception {
verifier.setLogFileName("log-only.txt");
verifier.addCliArgument("-Drevision=1.3.0-SNAPSHOT");
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument("clean");
verifier.execute();
verifier.verifyErrorFreeLog();
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6065FailOnSeverityTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6065FailOnSeverityTest.java
index 65a53dde32..807798e7ee 100644
--- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6065FailOnSeverityTest.java
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6065FailOnSeverityTest.java
@@ -45,6 +45,7 @@ public void testItShouldFailOnWarnLogMessages() throws Exception {
boolean failed = false;
try {
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument("compile");
verifier.execute();
} catch (VerificationException e) {
@@ -65,6 +66,7 @@ public void testItShouldSucceedOnWarnLogMessagesWhenFailLevelIsError() throws Ex
verifier.addCliArgument("--fail-on-severity");
verifier.addCliArgument("ERROR");
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument("compile");
verifier.execute();
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6118SubmoduleInvocation.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6118SubmoduleInvocation.java
index 6a85190348..25545f868d 100644
--- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6118SubmoduleInvocation.java
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6118SubmoduleInvocation.java
@@ -56,6 +56,7 @@ public MavenITmng6118SubmoduleInvocation() throws IOException {
public void testInSubModule() throws Exception {
// Compile the whole project first.
Verifier verifier = newVerifier(testDir.toString(), false);
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument("package");
verifier.execute();
@@ -63,6 +64,7 @@ public void testInSubModule() throws Exception {
verifier = newVerifier(submoduleDirectory.toString(), false);
verifier.setAutoclean(false);
verifier.setLogFileName("log-insubmodule.txt");
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument("compile");
verifier.execute();
}
@@ -76,6 +78,7 @@ public void testInSubModule() throws Exception {
public void testWithFile() throws Exception {
// Compile the whole project first.
Verifier verifier = newVerifier(testDir.toString(), false);
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument("package");
verifier.execute();
@@ -84,6 +87,7 @@ public void testWithFile() throws Exception {
verifier.setLogFileName("log-withfile.txt");
verifier.addCliArgument("-f");
verifier.addCliArgument("app/pom.xml");
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument("compile");
verifier.execute();
}
@@ -100,6 +104,7 @@ public void testWithFileAndAlsoMake() throws Exception {
verifier.addCliArgument("-f");
verifier.addCliArgument("app/pom.xml");
verifier.setLogFileName("log-withfilealsomake.txt");
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument("compile");
verifier.execute();
verifier.verifyTextInLog("Building Maven Integration Test :: MNG-6118 :: Library 1.0");
@@ -116,6 +121,7 @@ public void testInSubModuleWithAlsoMake() throws Exception {
Verifier verifier = newVerifier(submoduleDirectory, false);
verifier.addCliArgument("-am");
verifier.setLogFileName("log-insubmodulealsomake.txt");
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument("compile");
verifier.execute();
verifier.verifyTextInLog("Building Maven Integration Test :: MNG-6118 :: Library 1.0");
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6391PrintVersionTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6391PrintVersionTest.java
index 89c39470d1..0cac3cd993 100644
--- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6391PrintVersionTest.java
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6391PrintVersionTest.java
@@ -54,6 +54,7 @@ public void testitShouldPrintVersionAtTopAndAtBottom() throws Exception {
verifier.setAutoclean(false);
verifier.setLogFileName("version-log.txt");
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument("clean");
verifier.execute();
verifier.verifyErrorFreeLog();
@@ -95,6 +96,7 @@ public void testitShouldPrintVersionInAllLines() throws Exception {
verifier.setAutoclean(false);
verifier.setLogFileName("version-log.txt");
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArguments("clean");
verifier.execute();
verifier.verifyErrorFreeLog();
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6566ExecuteAnnotationShouldNotReExecuteGoalsTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6566ExecuteAnnotationShouldNotReExecuteGoalsTest.java
index 3321e0b5d8..334f25084c 100644
--- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6566ExecuteAnnotationShouldNotReExecuteGoalsTest.java
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6566ExecuteAnnotationShouldNotReExecuteGoalsTest.java
@@ -38,6 +38,7 @@ public void setUp() throws Exception {
Path pluginDir = testDir.resolve("plugin");
Verifier verifier = newVerifier(pluginDir);
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument("install");
verifier.execute();
verifier.verifyErrorFreeLog();
@@ -49,6 +50,7 @@ public void testRunsCompileGoalOnceWithDirectPluginInvocation() throws Exception
Verifier verifier = newVerifier(consumerDir);
verifier.setLogFileName("log-direct-plugin-invocation.txt");
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument(PLUGIN_KEY + ":require-compile-phase");
verifier.execute();
verifier.verifyErrorFreeLog();
@@ -68,6 +70,7 @@ public void testRunsCompileGoalOnceWithPhaseExecution() throws Exception {
Verifier verifier = newVerifier(consumerDir);
verifier.setLogFileName("log-phase-execution.txt");
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument("compile");
verifier.execute();
verifier.verifyErrorFreeLog();
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6981ProjectListShouldIncludeChildrenTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6981ProjectListShouldIncludeChildrenTest.java
index ba6864f2e9..19cade9d55 100644
--- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6981ProjectListShouldIncludeChildrenTest.java
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6981ProjectListShouldIncludeChildrenTest.java
@@ -33,6 +33,7 @@ public void testProjectListShouldIncludeChildrenByDefault() throws Exception {
verifier.addCliArgument("-pl");
verifier.addCliArgument(":module-a");
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument("compile");
verifier.execute();
verifier.verifyTextInLog("Building module-a-1 1.0");
@@ -52,6 +53,7 @@ public void testFileSwitchAllowsExcludeOfChildren() throws Exception {
verifier.addCliArgument("module-a");
verifier.addCliArgument("--non-recursive");
verifier.setLogFileName("log-non-recursive.txt");
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument("compile");
verifier.execute();
verifier.verifyTextNotInLog("Building module-a-1 1.0");
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng7353CliGoalInvocationTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng7353CliGoalInvocationTest.java
index b37994fd16..f825aeda4d 100644
--- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng7353CliGoalInvocationTest.java
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng7353CliGoalInvocationTest.java
@@ -32,6 +32,7 @@ private void run(String id, String goal, String expectedInvocation) throws Excep
Path basedir = extractResources("mng-7353-cli-goal-invocation");
Verifier verifier = newVerifier(basedir);
verifier.setLogFileName(id + ".txt");
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument(goal);
verifier.execute();
verifier.verifyTextInLog("[INFO] --- " + expectedInvocation);
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng7804PluginExecutionOrderTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng7804PluginExecutionOrderTest.java
index 10188f23e7..b5fedd0d5b 100644
--- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng7804PluginExecutionOrderTest.java
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng7804PluginExecutionOrderTest.java
@@ -46,6 +46,7 @@ void testOrder() throws Exception {
Path testDir = extractResources("mng-7804-plugin-execution-order");
Verifier verifier = newVerifier(testDir);
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument("clean");
verifier.execute();
verifier.verifyErrorFreeLog();
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8594AtFileTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8594AtFileTest.java
index bd1a6a21dd..718b28b639 100644
--- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8594AtFileTest.java
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8594AtFileTest.java
@@ -44,6 +44,7 @@ void testIt() throws Exception {
verifier.addCliArgument("cmd.txt");
verifier.addCliArgument("-Dcolor1=green");
verifier.addCliArgument("-Dcolor2=blue");
+ verifier.addCliArgument("--console=verbose");
verifier.addCliArgument("clean");
verifier.execute();
verifier.verifyErrorFreeLog();
diff --git a/its/core-it-suite/src/test/resources/gh-12571-build-report/pom.xml b/its/core-it-suite/src/test/resources/gh-12571-build-report/pom.xml
new file mode 100644
index 0000000000..d859b406d9
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/gh-12571-build-report/pom.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <groupId>org.apache.maven.its.gh12571</groupId>
+ <artifactId>build-report-test</artifactId>
+ <version>1.0</version>
+ <name>Maven IT :: gh-12571 :: Build Report</name>
+</project>
diff --git a/its/core-it-suite/src/test/resources/gh-12571-multi-module/module-a/pom.xml b/its/core-it-suite/src/test/resources/gh-12571-multi-module/module-a/pom.xml
new file mode 100644
index 0000000000..4e63f41a8a
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/gh-12571-multi-module/module-a/pom.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <parent>
+ <groupId>org.apache.maven.its.gh12571</groupId>
+ <artifactId>multi-module-parent</artifactId>
+ <version>1.0</version>
+ </parent>
+ <artifactId>module-a</artifactId>
+ <name>Module A</name>
+</project>
diff --git a/its/core-it-suite/src/test/resources/gh-12571-multi-module/module-b/pom.xml b/its/core-it-suite/src/test/resources/gh-12571-multi-module/module-b/pom.xml
new file mode 100644
index 0000000000..52f4965b05
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/gh-12571-multi-module/module-b/pom.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <parent>
+ <groupId>org.apache.maven.its.gh12571</groupId>
+ <artifactId>multi-module-parent</artifactId>
+ <version>1.0</version>
+ </parent>
+ <artifactId>module-b</artifactId>
+ <name>Module B</name>
+</project>
diff --git a/its/core-it-suite/src/test/resources/gh-12571-multi-module/pom.xml b/its/core-it-suite/src/test/resources/gh-12571-multi-module/pom.xml
new file mode 100644
index 0000000000..b3cabd7d39
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/gh-12571-multi-module/pom.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <groupId>org.apache.maven.its.gh12571</groupId>
+ <artifactId>multi-module-parent</artifactId>
+ <version>1.0</version>
+ <packaging>pom</packaging>
+ <name>Maven IT :: gh-12571 :: Multi Module</name>
+ <modules>
+ <module>module-a</module>
+ <module>module-b</module>
+ </modules>
+</project>