diff --git a/apache-maven/src/assembly/component.xml b/apache-maven/src/assembly/component.xml index 5f55a310c8bd..ce303ea4960d 100644 --- a/apache-maven/src/assembly/component.xml +++ b/apache-maven/src/assembly/component.xml @@ -87,6 +87,7 @@ under the License. mvn mvnenc + mvnlog mvnsh mvnup mvnDebug diff --git a/apache-maven/src/assembly/maven/bin/mvn b/apache-maven/src/assembly/maven/bin/mvn index 0adc4eabeb2b..93127f8d8e9f 100755 --- a/apache-maven/src/assembly/maven/bin/mvn +++ b/apache-maven/src/assembly/maven/bin/mvn @@ -303,6 +303,9 @@ handle_args() { --up) MAVEN_MAIN_CLASS="org.apache.maven.cling.MavenUpCling" ;; + --log) + MAVEN_MAIN_CLASS="org.apache.maven.cling.MavenLogCling" + ;; *) ;; esac @@ -311,6 +314,21 @@ handle_args() { } handle_args "$@" + +# Strip routing flags (--debug, --yjp, --enc, --shell, --up, --log) from $@ +# so they are not passed to the Java process where they may collide with +# Commons CLI option-prefix matching (e.g. --log matches --log-file). +_argc=$# +_i=0 +while [ $_i -lt $_argc ]; do + _arg="$1" + shift + case $_arg in + --debug|--yjp|--enc|--shell|--up|--log) ;; + *) set -- "$@" "$_arg" ;; + esac + _i=$((_i + 1)) +done MAVEN_MAIN_CLASS=${MAVEN_MAIN_CLASS:=org.apache.maven.cling.MavenCling} # Build base command string for eval (only contains Maven-controlled values) diff --git a/apache-maven/src/assembly/maven/bin/mvn.cmd b/apache-maven/src/assembly/maven/bin/mvn.cmd index 74d4a5a984d2..3d6852b413eb 100644 --- a/apache-maven/src/assembly/maven/bin/mvn.cmd +++ b/apache-maven/src/assembly/maven/bin/mvn.cmd @@ -107,24 +107,77 @@ set "WDIR=%EXEC_DIR%" @REM POM location, if supplied. set FILE_ARG= +set "FILTERED_ARGS=" +if "%MAVEN_DEBUG_ADDRESS%"=="" set MAVEN_DEBUG_ADDRESS=localhost:8000 + +@REM Single pass through all arguments at the top level. +@REM At top level, %%1 preserves '=' signs (unlike 'call :label %%*' which +@REM re-tokenizes and splits on '=', breaking -Dkey=value and --flag=value). :arg_loop +if "%~1" == "" goto argLoopDone + +@REM --- Flags consumed by the script (NOT passed to Java) --- +if "%~1" == "--debug" ( + if "%MAVEN_DEBUG_OPTS%" == "" ( + set "MAVEN_DEBUG_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=%MAVEN_DEBUG_ADDRESS%" + ) + shift + goto arg_loop +) +if "%~1" == "--yjp" ( + if not exist "%YJPLIB%" ( + echo Error: Unable to autodetect the YJP library location. Please set YJPLIB variable >&2 + exit /b 1 + ) + set "INTERNAL_MAVEN_OPTS=-agentpath:%YJPLIB%=onexit=snapshot,onexit=memory,tracing,onlylocal %INTERNAL_MAVEN_OPTS%" + shift + goto arg_loop +) +if "%~1" == "--enc" ( + set "MAVEN_MAIN_CLASS=org.apache.maven.cling.MavenEncCling" + shift + goto arg_loop +) +if "%~1" == "--shell" ( + set "MAVEN_MAIN_CLASS=org.apache.maven.cling.MavenShellCling" + shift + goto arg_loop +) +if "%~1" == "--up" ( + set "MAVEN_MAIN_CLASS=org.apache.maven.cling.MavenUpCling" + shift + goto arg_loop +) +if "%~1" == "--log" ( + set "MAVEN_MAIN_CLASS=org.apache.maven.cling.MavenLogCling" + shift + goto arg_loop +) + +@REM --- Extract -f/--file for basedir detection (still passed through to Java) --- if "%~1" == "-f" ( - set "FILE_ARG=%~2" - shift - goto process_file_arg + set "FILE_ARG=%~2" + set "FILTERED_ARGS=%FILTERED_ARGS% %1 %2" + shift + shift + goto arg_loop ) if "%~1" == "--file" ( - set "FILE_ARG=%~2" - shift - goto process_file_arg + set "FILE_ARG=%~2" + set "FILTERED_ARGS=%FILTERED_ARGS% %1 %2" + shift + shift + goto arg_loop ) -@REM If none of the above, skip the argument + +@REM --- All other arguments pass through to Java --- +set "FILTERED_ARGS=%FILTERED_ARGS% %1" shift -if not "%~1" == "" ( - goto arg_loop -) else ( - goto findBaseDir -) +goto arg_loop + +:argLoopDone +if not "%FILE_ARG%" == "" goto process_file_arg +goto findBaseDir :process_file_arg if "%FILE_ARG%" == "" ( @@ -255,37 +308,11 @@ if defined MAVEN_DEBUG_SCRIPT ( @REM do not let MAVEN_PROJECTBASEDIR end with a single backslash which would escape the double quote. This happens when .mvn at drive root. if "_%MAVEN_PROJECTBASEDIR:~-1%"=="_\" set "MAVEN_PROJECTBASEDIR=%MAVEN_PROJECTBASEDIR%\" -if "%MAVEN_DEBUG_ADDRESS%"=="" set MAVEN_DEBUG_ADDRESS=localhost:8000 - -goto endHandleArgs -:handleArgs -if "%~1"=="--debug" ( - if "%MAVEN_DEBUG_OPTS%"=="" ( - set "MAVEN_DEBUG_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=%MAVEN_DEBUG_ADDRESS%" - ) -) else if "%~1"=="--yjp" ( - if not exist "%YJPLIB%" ( - echo Error: Unable to autodetect the YJP library location. Please set YJPLIB variable >&2 - exit /b 1 - ) - set "INTERNAL_MAVEN_OPTS=-agentpath:%YJPLIB%=onexit=snapshot,onexit=memory,tracing,onlylocal %INTERNAL_MAVEN_OPTS%" -) else if "%~1"=="--enc" ( - set "MAVEN_MAIN_CLASS=org.apache.maven.cling.MavenEncCling" -) else if "%~1"=="--shell" ( - set "MAVEN_MAIN_CLASS=org.apache.maven.cling.MavenShellCling" -) else if "%~1"=="--up" ( - set "MAVEN_MAIN_CLASS=org.apache.maven.cling.MavenUpCling" -) -exit /b 0 - -:processArgs -if "%~1"=="" exit /b 0 -call :handleArgs %1 -shift -goto processArgs - +@REM All arg processing (--debug, --yjp, --enc, --shell, --up, --log, -f/--file) +@REM and FILTERED_ARGS building was done in the arg_loop above. +@REM This avoids 'call :subroutine %%*' which re-tokenizes and splits on '=', +@REM breaking -Dkey=value arguments on Windows. :endHandleArgs -call :processArgs %* for %%i in ("%MAVEN_HOME%"\boot\plexus-classworlds-*) do set LAUNCHER_JAR="%%i" set LAUNCHER_CLASS=org.codehaus.plexus.classworlds.launcher.Launcher @@ -297,7 +324,7 @@ if not "%MAVEN_MAIN_CLASS%"=="org.apache.maven.cling.MavenCling" set "MAVEN_ARGS if defined MAVEN_DEBUG_SCRIPT ( echo [DEBUG] Launching JVM with command: - echo [DEBUG] "%JAVACMD%" %INTERNAL_MAVEN_OPTS% %MAVEN_OPTS% %JVM_CONFIG_MAVEN_OPTS% %MAVEN_DEBUG_OPTS% --enable-native-access=ALL-UNNAMED -classpath %LAUNCHER_JAR% "-Dclassworlds.conf=%CLASSWORLDS_CONF%" "-Dmaven.home=%MAVEN_HOME%" "-Dmaven.mainClass=%MAVEN_MAIN_CLASS%" "-Dlibrary.jline.path=%MAVEN_HOME%\lib\jline-native" "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %LAUNCHER_CLASS% %MAVEN_ARGS% %* + echo [DEBUG] "%JAVACMD%" %INTERNAL_MAVEN_OPTS% %MAVEN_OPTS% %JVM_CONFIG_MAVEN_OPTS% %MAVEN_DEBUG_OPTS% --enable-native-access=ALL-UNNAMED -classpath %LAUNCHER_JAR% "-Dclassworlds.conf=%CLASSWORLDS_CONF%" "-Dmaven.home=%MAVEN_HOME%" "-Dmaven.mainClass=%MAVEN_MAIN_CLASS%" "-Dlibrary.jline.path=%MAVEN_HOME%\lib\jline-native" "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %LAUNCHER_CLASS% %MAVEN_ARGS% %FILTERED_ARGS% ) "%JAVACMD%" ^ @@ -314,7 +341,7 @@ if defined MAVEN_DEBUG_SCRIPT ( "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ %LAUNCHER_CLASS% ^ %MAVEN_ARGS% ^ - %* + %FILTERED_ARGS% if ERRORLEVEL 1 goto error goto end diff --git a/apache-maven/src/assembly/maven/bin/mvnlog b/apache-maven/src/assembly/maven/bin/mvnlog new file mode 100644 index 000000000000..8170bdb16785 --- /dev/null +++ b/apache-maven/src/assembly/maven/bin/mvnlog @@ -0,0 +1,30 @@ +#!/bin/sh + +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# ----------------------------------------------------------------------------- +# Apache Maven Build Log Viewer Script +# +# Environment Variable Prerequisites +# +# JAVA_HOME (Optional) Points to a Java installation. +# MAVEN_OPTS (Optional) Java runtime options used when Maven is executed. +# MAVEN_SKIP_RC (Optional) Flag to disable loading of mavenrc files. +# ----------------------------------------------------------------------------- + +"`dirname "$0"`/mvn" --log "$@" diff --git a/apache-maven/src/assembly/maven/bin/mvnlog.cmd b/apache-maven/src/assembly/maven/bin/mvnlog.cmd new file mode 100644 index 000000000000..7069255cd817 --- /dev/null +++ b/apache-maven/src/assembly/maven/bin/mvnlog.cmd @@ -0,0 +1,39 @@ +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. + +@REM ----------------------------------------------------------------------------- +@REM Apache Maven Build Log Viewer Script +@REM +@REM Environment Variable Prerequisites +@REM +@REM JAVA_HOME (Optional) Points to a Java installation. +@REM MAVEN_BATCH_ECHO (Optional) Set to 'on' to enable the echoing of the batch commands. +@REM MAVEN_BATCH_PAUSE (Optional) set to 'on' to wait for a key stroke before ending. +@REM MAVEN_OPTS (Optional) Java runtime options used when Maven is executed. +@REM MAVEN_SKIP_RC (Optional) Flag to disable loading of mavenrc files. +@REM ----------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%"=="on" echo %MAVEN_BATCH_ECHO% + +@setlocal + +@call "%~dp0"mvn.cmd --log %* diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Options.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Options.java index d2bf596cd916..93c720fc37e8 100644 --- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Options.java +++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Options.java @@ -182,6 +182,43 @@ public interface Options { @Nonnull Optional color(); + /** + * Returns the console output mode. + *

+ * Supported modes: + *

+ * + * @return an {@link Optional} containing the console mode, or empty if not set + * @since 4.1.0 + */ + @Nonnull + Optional console(); + + /** + * Returns the warning display mode. + *

+ * Controls how build warnings (diagnostics) are displayed: + *

    + *
  • {@code "summary"} (default) — collect warnings, show deduplicated summary at end of build
  • + *
  • {@code "all"} — show warnings inline as they occur AND show summary at end
  • + *
  • {@code "none"} — suppress the diagnostic summary entirely
  • + *
  • {@code "fail"} — show summary AND fail the build if any warnings exist
  • + *
+ * + * @return an {@link Optional} containing the warning mode, or empty if not set + * @since 4.1.0 + */ + @Nonnull + Optional warningMode(); + /** * Indicates whether Maven should operate in offline mode. * diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/ParserRequest.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/ParserRequest.java index ee25ec63dab3..848151bc2511 100644 --- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/ParserRequest.java +++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/ParserRequest.java @@ -253,6 +253,30 @@ static Builder mvnup(@Nonnull List args, @Nonnull MessageBuilderFactory return builder(Tools.MVNUP_CMD, Tools.MVNUP_NAME, args, messageBuilderFactory); } + /** + * Creates a new Builder instance for constructing a Maven Build Log Viewer ParserRequest. + * + * @param args the command-line arguments + * @param messageBuilderFactory the factory for creating message builders + * @return a new Builder instance + */ + @Nonnull + static Builder mvnlog(@Nonnull String[] args, @Nonnull MessageBuilderFactory messageBuilderFactory) { + return mvnlog(Arrays.asList(args), messageBuilderFactory); + } + + /** + * Creates a new Builder instance for constructing a Maven Build Log Viewer ParserRequest. + * + * @param args the command-line arguments + * @param messageBuilderFactory the factory for creating message builders + * @return a new Builder instance + */ + @Nonnull + static Builder mvnlog(@Nonnull List args, @Nonnull MessageBuilderFactory messageBuilderFactory) { + return builder(Tools.MVNLOG_CMD, Tools.MVNLOG_NAME, args, messageBuilderFactory); + } + /** * Creates a new Builder instance for constructing a ParserRequest. * diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java index 7559d7ffee06..136268657a91 100644 --- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java +++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java @@ -42,4 +42,7 @@ private Tools() {} public static final String MVNUP_CMD = "mvnup"; public static final String MVNUP_NAME = "Maven Upgrade Tool"; + + public static final String MVNLOG_CMD = "mvnlog"; + public static final String MVNLOG_NAME = "Maven Build Log Viewer"; } diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/LogOptions.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/LogOptions.java new file mode 100644 index 000000000000..f5cd4de8cb30 --- /dev/null +++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/LogOptions.java @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.api.cli.mvnlog; + +import java.util.Optional; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.cli.Options; + +/** + * Defines the options specific to the Maven build log viewer tool ({@code mvnlog}). + * This interface extends the general {@link Options} interface, adding log-viewing options. + * + * @since 4.1.0 + */ +@Experimental +public interface LogOptions extends Options { + /** + * Whether to show detailed diagnostics (warnings and errors) from the build. + * + * @return an {@link Optional} containing {@code true} if diagnostics should be shown + */ + Optional diagnostics(); + + /** + * Whether to show detailed failure information including stack traces. + * + * @return an {@link Optional} containing {@code true} if failures should be shown in detail + */ + Optional failures(); + + /** + * Whether to show a full per-mojo timing breakdown. + * + * @return an {@link Optional} containing {@code true} if the full breakdown should be shown + */ + Optional full(); + + /** + * Whether to list all available build reports instead of showing one. + * + * @return an {@link Optional} containing {@code true} if reports should be listed + */ + Optional list(); + + /** + * Whether to output the raw JSON build report instead of formatted text. + * Useful for piping to tools like {@code jq} or for programmatic consumption. + * + * @return an {@link Optional} containing {@code true} if raw JSON should be output + */ + Optional json(); + + /** + * Returns the path to a specific build report file to display. + * If not specified, defaults to {@code target/build-reports/build-report-latest.json}. + * + * @return an {@link Optional} containing the report file path, or empty if not specified + */ + Optional reportFile(); +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildReport.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildReport.java new file mode 100644 index 000000000000..dbb18d989772 --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildReport.java @@ -0,0 +1,198 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.api.build.report; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Nonnull; + +/** + * A structured report of a Maven build execution, persisted to + * {@code target/build-report.json} at the end of every build. + *

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

+ * Diagnostics are deduplicated by {@link Diagnostic#key()} — the list + * contains only unique entries. For occurrence counts, use the + * {@link DiagnosticCollector#getSummary()} method. + * + * @return the diagnostics, never {@code null}; empty if none were reported + * @see DiagnosticCollector + */ + @Nonnull + List diagnostics(); + + /** + * Structured log events captured outside of any module's lifecycle — + * Maven startup messages, reactor ordering, and the final reactor summary. + *

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

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

+ * The identifier format is {@code "groupId:artifactId:version"}, matching + * the format returned by {@link ModuleReport#id()} and used in + * {@link FailureReport#module()}. + * + * @param moduleId the module GAV string + * (e.g. {@code "org.apache.maven:maven-core:4.1.0-SNAPSHOT"}) + * @return the matching module report, or empty if not found + */ + @Nonnull + default Optional findModule(String moduleId) { + Objects.requireNonNull(moduleId); + return modules().stream().filter(m -> moduleId.equals(m.id())).findFirst(); + } + + /** + * Find the module report that corresponds to a given failure. + * + * @param failure the failure report + * @return the matching module report, or empty if not found + */ + @Nonnull + default Optional findModule(FailureReport failure) { + Objects.requireNonNull(failure); + return findModule(failure.module()); + } +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildStatus.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildStatus.java new file mode 100644 index 000000000000..1ee25f8cb3a6 --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildStatus.java @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.api.build.report; + +import org.apache.maven.api.annotations.Experimental; + +/** + * The outcome of a build, module, or mojo execution. + * + * @since 4.1.0 + */ +@Experimental +public enum BuildStatus { + /** + * Completed successfully. + */ + SUCCESS, + + /** + * Failed with an error. + */ + FAILURE, + + /** + * Skipped (e.g. because a dependency failed). + */ + SKIPPED +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/Diagnostic.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/Diagnostic.java new file mode 100644 index 000000000000..81a7ab2bd230 --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/Diagnostic.java @@ -0,0 +1,147 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.api.build.report; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Immutable; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.Nullable; + +/** + * A structured diagnostic message (warning, error, or informational note) + * reported during a build by Maven itself or by a plugin. + *

+ * Unlike raw log lines, diagnostics carry a {@link #key() deduplication key} + * that lets consumers group identical warnings across modules and a + * {@link #suggestion() suggestion} that gives the user an actionable fix. + *

+ * Diagnostics are collected by the {@link DiagnosticCollector} and included + * in the {@link BuildReport}. At the end of the build, a deduplicated summary + * of warnings is printed so that important messages are not lost in scrollback. + * + *

Examples

+ *
+ *   // Plugin reports a deprecation warning:
+ *   collector.report(Diagnostic.warning(
+ *       "deprecated-source-target",
+ *       "source/target value 8 is deprecated, will be removed in a future release",
+ *       "maven-compiler-plugin:3.15.0:compile")
+ *       .suggestion("Update <maven.compiler.source> to 11 or higher")
+ *       .build());
+ * 
+ * + * @since 4.1.0 + * @see DiagnosticCollector + * @see BuildReport#diagnostics() + */ +@Experimental +@Immutable +public interface Diagnostic { + + /** + * Severity levels for diagnostics. + * Ordered from least to most severe. + */ + enum Severity { + /** Informational note — no action required. */ + INFO, + /** Warning — the build continues but the user should address this. */ + WARNING, + /** Error — indicates a problem that contributed to build failure. */ + ERROR + } + + /** + * A stable deduplication key for this diagnostic. + *

+ * Diagnostics with the same key are considered duplicates across modules. + * Example keys: {@code "deprecated-source-target"}, {@code "unused-dependency"}. + * + * @return the deduplication key, never {@code null} + */ + @Nonnull + String key(); + + /** + * The severity of this diagnostic. + * + * @return the severity, never {@code null} + */ + @Nonnull + Severity severity(); + + /** + * The human-readable diagnostic message. + * + * @return the message, never {@code null} + */ + @Nonnull + String message(); + + /** + * The source that produced this diagnostic — typically a plugin GAV + * (e.g. {@code "maven-compiler-plugin:3.15.0:compile"}) or a Maven + * subsystem name (e.g. {@code "reactor"}). + * + * @return the source identifier, or {@code null} if unknown + */ + @Nullable + String source(); + + /** + * The file path related to this diagnostic, if applicable. + * For compilation warnings this would be the source file. + * + * @return the file path, or {@code null} if not file-specific + */ + @Nullable + String file(); + + /** + * The one-based line number in the file, or {@code -1} if unknown. + * + * @return the line number, or {@code -1} + */ + int line(); + + /** + * The one-based column number in the file, or {@code -1} if unknown. + * + * @return the column number, or {@code -1} + */ + int column(); + + /** + * An actionable suggestion for how to fix this diagnostic. + *

+ * Example: {@code "Update to 11 or higher"}. + * + * @return the suggestion text, or {@code null} if none + */ + @Nullable + String suggestion(); + + /** + * A URL pointing to documentation about this diagnostic. + * + * @return the documentation URL, or {@code null} if none + */ + @Nullable + String documentationUrl(); +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/DiagnosticCollector.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/DiagnosticCollector.java new file mode 100644 index 000000000000..e7c73424c3cd --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/DiagnosticCollector.java @@ -0,0 +1,102 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.api.build.report; + +import java.util.List; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.ThreadSafe; + +/** + * Collects structured {@link Diagnostic} messages reported during a build. + *

+ * This service is session-scoped and thread-safe — it can be used safely + * from parallel module builds ({@code -T}). Diagnostics are deduplicated + * by {@link Diagnostic#key()}: the first occurrence is preserved, and + * subsequent duplicates only increment the count returned by + * {@link DiagnosticSummary#count()}. + *

+ * At the end of the build, the collector's contents are: + *

    + *
  • Included in the persisted {@code target/build-report.json}
  • + *
  • Printed as a deduplicated warning summary on the console
  • + *
+ *

+ * For Maven 4 plugins: inject this service and report structured + * diagnostics with deduplication keys, suggestions, and documentation URLs. + *

+ * Backward compatibility: Maven 3-style {@code getLog().warn()} calls + * are not automatically captured here. Plugins must opt in by using this + * service directly. + * + * @since 4.1.0 + * @see Diagnostic + * @see DiagnosticSummary + * @see BuildReport#diagnostics() + */ +@Experimental +@ThreadSafe +public interface DiagnosticCollector { + + /** + * Reports a diagnostic. + *

+ * If a diagnostic with the same {@link Diagnostic#key()} has already been + * reported, the duplicate is counted but not stored again. + * + * @param diagnostic the diagnostic to report + */ + void report(@Nonnull Diagnostic diagnostic); + + /** + * Returns all unique diagnostics reported so far, in the order they + * were first reported. + * + * @return an unmodifiable list of unique diagnostics, never {@code null} + */ + @Nonnull + List getDiagnostics(); + + /** + * Returns a deduplicated summary of all reported diagnostics. + * Each entry contains the diagnostic and the number of times it was + * reported (across all modules). + * + * @return an unmodifiable list of summaries, never {@code null} + */ + @Nonnull + List getSummary(); + + /** + * Returns {@code true} if at least one diagnostic with severity + * {@link Diagnostic.Severity#WARNING WARNING} or higher has been reported. + * + * @return whether any warnings or errors exist + */ + boolean hasWarnings(); + + /** + * Returns {@code true} if at least one diagnostic with severity + * {@link Diagnostic.Severity#ERROR ERROR} has been reported. + * + * @return whether any errors exist + */ + boolean hasErrors(); +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/DiagnosticSummary.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/DiagnosticSummary.java new file mode 100644 index 000000000000..a9d4597304d9 --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/DiagnosticSummary.java @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.api.build.report; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Immutable; +import org.apache.maven.api.annotations.Nonnull; + +/** + * A deduplicated summary entry pairing a unique {@link Diagnostic} with the + * number of times it was reported across the build. + *

+ * For example, if the same deprecation warning fires in 23 modules, there + * will be one {@code DiagnosticSummary} with {@code count() == 23}. + * + * @since 4.1.0 + * @see DiagnosticCollector#getSummary() + */ +@Experimental +@Immutable +public interface DiagnosticSummary { + + /** + * The unique diagnostic. + * + * @return the diagnostic, never {@code null} + */ + @Nonnull + Diagnostic diagnostic(); + + /** + * The number of times this diagnostic was reported (including the + * first occurrence). + * + * @return the occurrence count, always {@code >= 1} + */ + int count(); +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/FailureReport.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/FailureReport.java new file mode 100644 index 000000000000..20e5d95b8aea --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/FailureReport.java @@ -0,0 +1,89 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.api.build.report; + +import java.time.Instant; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.Nullable; + +/** + * Details about a build failure. + * + * @since 4.1.0 + * @see BuildReport#failures() + */ +@Experimental +public interface FailureReport { + + /** + * The GAV of the module where the failure occurred + * ({@code groupId:artifactId:version}). + * + * @return the module identifier, never {@code null} + */ + @Nonnull + String module(); + + /** + * The mojo that failed, formatted as {@code artifactId:version:goal} + * (e.g. {@code "maven-compiler-plugin:3.15.0:compile"}). + * + * @return the mojo identifier, or {@code null} if the failure was not mojo-specific + */ + @Nullable + String mojo(); + + /** + * When the failure occurred (wall-clock time). + * + * @return the failure instant, never {@code null} + */ + @Nonnull + Instant timestamp(); + + /** + * The simple class name of the root cause exception + * (e.g. {@code "MojoFailureException"}, {@code "LifecycleExecutionException"}). + *

+ * Useful for programmatic triage — tools can pattern-match on known + * exception types without parsing the message. + * + * @return the exception type name, or {@code null} if unavailable + */ + @Nullable + String exceptionType(); + + /** + * The exception message. + * + * @return the error message, never {@code null} + */ + @Nonnull + String message(); + + /** + * The exception stack trace, truncated to a reasonable length. + * + * @return the stack trace string, or {@code null} if unavailable + */ + @Nullable + String stackTrace(); +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java new file mode 100644 index 000000000000..70384fad4fdb --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java @@ -0,0 +1,108 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.api.build.report; + +import java.time.Instant; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.Nullable; + +/** + * A structured log event captured during the build. + *

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

+ * Log events are captured at three levels forming a non-overlapping + * partition of the full build log: + *

    + *
  • {@link BuildReport#output()} — events outside any module lifecycle
  • + *
  • {@link ModuleReport#output()} — events during a module build but outside any mojo
  • + *
  • {@link MojoReport#output()} — events during a mojo execution
  • + *
+ * + * @since 4.1.0 + */ +@Experimental +public interface LogEvent { + + /** + * When this log event was produced (wall-clock time). + * + * @return the event instant, never {@code null} + */ + @Nonnull + Instant timestamp(); + + /** + * The severity level of this log event. + * + * @return the log level, never {@code null} + */ + @Nonnull + LogLevel level(); + + /** + * The log message, without level prefix or timestamp formatting. + * + * @return the formatted message, never {@code null} + */ + @Nonnull + String message(); + + /** + * The name of the logger that produced this event + * (e.g. {@code "org.apache.maven.plugins.compiler.CompilerMojo"}). + * + * @return the logger name, or {@code null} if unavailable + */ + @Nullable + String loggerName(); + + /** + * The stack trace associated with this event, if an exception was logged. + *

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

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

+ * May be {@code null} if the event was created outside the SLF4J pipeline + * (e.g. in tests or by programmatic construction). + * + * @return the formatted log line, or {@code null} + */ + @Nullable + String formattedMessage(); +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java new file mode 100644 index 000000000000..684ea610a5bc --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.api.build.report; + +import org.apache.maven.api.annotations.Experimental; + +/** + * Log severity levels, mirroring the standard SLF4J levels. + * + * @since 4.1.0 + * @see LogEvent#level() + */ +@Experimental +public enum LogLevel { + TRACE, + DEBUG, + INFO, + WARN, + ERROR +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/ModuleReport.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/ModuleReport.java new file mode 100644 index 000000000000..7746545a4217 --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/ModuleReport.java @@ -0,0 +1,134 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.api.build.report; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Nonnull; + +/** + * Build results for a single module in a reactor build. + * + * @since 4.1.0 + * @see BuildReport#modules() + */ +@Experimental +public interface ModuleReport { + + /** + * The module's group ID. + * + * @return the group ID, never {@code null} + */ + @Nonnull + String groupId(); + + /** + * The module's artifact ID. + * + * @return the artifact ID, never {@code null} + */ + @Nonnull + String artifactId(); + + /** + * The module's version. + * + * @return the version string, never {@code null} + */ + @Nonnull + String version(); + + /** + * The build outcome for this module. + * + * @return the status, never {@code null} + */ + @Nonnull + BuildStatus status(); + + /** + * When this module started building (wall-clock time). + * + * @return the start instant, never {@code null} + */ + @Nonnull + Instant startTime(); + + /** + * How long this module took to build. + * + * @return the duration, never {@code null} + */ + @Nonnull + Duration duration(); + + /** + * The mojo executions that ran within this module, in execution order. + * + * @return the mojo reports, never {@code null} + */ + @Nonnull + List mojos(); + + /** + * Structured log events captured during this module's build lifecycle + * but outside any mojo execution — dependency resolution messages, + * resource copying, and other Maven infrastructure output. + *

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

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

+ * The identifier format is {@code "artifactId:version:goal"}, matching + * the format used by {@link FailureReport#mojo()}. + * + * @param mojoId the mojo identifier (e.g. {@code "maven-compiler-plugin:3.15.0:compile"}) + * @return the matching mojo report, or empty if not found + */ + @Nonnull + default Optional findMojo(String mojoId) { + Objects.requireNonNull(mojoId); + return mojos().stream().filter(m -> mojoId.equals(m.id())).findFirst(); + } +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/MojoReport.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/MojoReport.java new file mode 100644 index 000000000000..76001babbd6b --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/MojoReport.java @@ -0,0 +1,138 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.api.build.report; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.Nullable; + +/** + * Report for a single mojo (plugin goal) execution within a module. + * + * @since 4.1.0 + * @see ModuleReport#mojos() + */ +@Experimental +public interface MojoReport { + + /** + * The plugin's group ID. + * + * @return the group ID, never {@code null} + */ + @Nonnull + String groupId(); + + /** + * The plugin's artifact ID. + * + * @return the artifact ID, never {@code null} + */ + @Nonnull + String artifactId(); + + /** + * The plugin version. + * + * @return the version string, never {@code null} + */ + @Nonnull + String version(); + + /** + * The goal that was executed (e.g. {@code "compile"}, {@code "test"}). + * + * @return the goal name, never {@code null} + */ + @Nonnull + String goal(); + + /** + * The execution ID (e.g. {@code "default-compile"}). + * + * @return the execution ID, or {@code null} if not set + */ + @Nullable + String executionId(); + + /** + * The lifecycle phase this mojo was bound to (e.g. {@code "compile"}, {@code "test"}). + * + * @return the phase name, or {@code null} if invoked directly + */ + @Nullable + String phase(); + + /** + * The outcome of this mojo execution. + * + * @return the status, never {@code null} + */ + @Nonnull + BuildStatus status(); + + /** + * When this mojo execution started (wall-clock time). + * + * @return the start instant, never {@code null} + */ + @Nonnull + Instant startTime(); + + /** + * How long this mojo execution took. + * + * @return the duration, never {@code null} + */ + @Nonnull + Duration duration(); + + /** + * Structured log events captured during this mojo's execution. + *

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

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

+ * This matches the format used by {@link FailureReport#mojo()}, allowing + * direct lookup via {@link ModuleReport#findMojo(String)}. + * + * @return the mojo identifier string, never {@code null} + */ + @Nonnull + default String id() { + return artifactId() + ":" + version() + ":" + goal(); + } +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java new file mode 100644 index 000000000000..2763f6ade0ee --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Structured build report data model and diagnostic collection. + *

+ * The {@link org.apache.maven.api.build.report.BuildReport} is the root of a structured + * representation of a Maven build execution. It is persisted to + * {@code target/build-report.json} at the end of every build and can be consumed + * by tools, CI systems, IDEs, and LLM agents without re-running the build or + * parsing console output. + *

+ * The {@link org.apache.maven.api.build.report.DiagnosticCollector} collects structured + * {@link org.apache.maven.api.build.report.Diagnostic} messages (warnings, errors, + * informational notes) reported by plugins and Maven itself. Diagnostics are + * deduplicated by key and summarized at the end of the build. + * + * @since 4.1.0 + */ +@Experimental +package org.apache.maven.api.build.report; + +import org.apache.maven.api.annotations.Experimental; diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/MavenLogCling.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/MavenLogCling.java new file mode 100644 index 000000000000..baeda012571c --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/MavenLogCling.java @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import org.apache.maven.api.annotations.Nullable; +import org.apache.maven.api.cli.Invoker; +import org.apache.maven.api.cli.Parser; +import org.apache.maven.api.cli.ParserRequest; +import org.apache.maven.cling.invoker.ProtoLookup; +import org.apache.maven.cling.invoker.mvnlog.LogInvoker; +import org.apache.maven.cling.invoker.mvnlog.LogParser; +import org.codehaus.plexus.classworlds.ClassWorld; + +/** + * Maven build log viewer CLI ("new-gen"). + *

+ * Displays formatted summaries of previous Maven build reports. + * Invoked via {@code mvnlog} or {@code mvn --log}. + * + * @since 4.1.0 + */ +public class MavenLogCling extends ClingSupport { + /** + * "Normal" Java entry point. Note: Maven uses ClassWorld Launcher and this entry point is NOT used under normal + * circumstances. + */ + public static void main(String[] args) throws IOException { + int exitCode = new MavenLogCling().run(args, null, null, null, false); + System.exit(exitCode); + } + + /** + * ClassWorld Launcher "enhanced" entry point: returning exitCode and accepts Class World. + */ + public static int main(String[] args, ClassWorld world) throws IOException { + return new MavenLogCling(world).run(args, null, null, null, false); + } + + /** + * ClassWorld Launcher "embedded" entry point: returning exitCode and accepts Class World and streams. + */ + public static int main( + String[] args, + ClassWorld world, + @Nullable InputStream stdIn, + @Nullable OutputStream stdOut, + @Nullable OutputStream stdErr) + throws IOException { + return new MavenLogCling(world).run(args, stdIn, stdOut, stdErr, true); + } + + public MavenLogCling() { + super(); + } + + public MavenLogCling(ClassWorld classWorld) { + super(classWorld); + } + + @Override + protected Invoker createInvoker() { + return new LogInvoker( + ProtoLookup.builder().addMapping(ClassWorld.class, classWorld).build(), null); + } + + @Override + protected Parser createParser() { + return new LogParser(); + } + + @Override + protected ParserRequest.Builder createParserRequestBuilder(String[] args) { + return ParserRequest.mvnlog(args, createMessageBuilderFactory()); + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/ExecutionEventLogger.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/ExecutionEventLogger.java index d85cc7188483..b42697e97b29 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/ExecutionEventLogger.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/ExecutionEventLogger.java @@ -31,6 +31,7 @@ import org.apache.maven.api.MonotonicClock; import org.apache.maven.api.services.MessageBuilder; import org.apache.maven.api.services.MessageBuilderFactory; +import org.apache.maven.cling.utils.CLIReportingUtils; import org.apache.maven.execution.AbstractExecutionListener; import org.apache.maven.execution.BuildFailure; import org.apache.maven.execution.BuildSuccess; @@ -304,6 +305,15 @@ private void logStats(MavenSession session) { logger.info("Total time: {}{}", formatDuration(time), wallClock); + // On failure, show Maven and Java version to help with bug reports (MNG-7372) + if (session.getResult().hasExceptions()) { + logger.info("Maven: {}", CLIReportingUtils.showVersionMinimal()); + logger.info( + "Java: {} ({})", + System.getProperty("java.version", ""), + System.getProperty("java.vendor", "")); + } + ZonedDateTime rounded = finish.truncatedTo(ChronoUnit.SECONDS).atZone(ZoneId.systemDefault()); logger.info("Finished at: {}", formatTimestamp(rounded)); } diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineBuildEventListener.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineBuildEventListener.java new file mode 100644 index 000000000000..79280ac4fa76 --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineBuildEventListener.java @@ -0,0 +1,275 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.event; + +import java.util.function.Consumer; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.logging.BuildEventListener; +import org.eclipse.aether.transfer.TransferEvent; + +/** + * A machine-readable build event listener that outputs one JSON object per line + * to the configured writer. Each line is a self-contained JSON object with an + * {@code "event"} field identifying its type. + *

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

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

+ * Example output: + *

+ * {"event":"log","timestamp":"...","message":"Compiling 42 source files"}
+ * {"event":"transfer.started","timestamp":"...","artifact":"core-4.1.0.jar","size":524288}
+ * {"event":"transfer.progressed","timestamp":"...","artifact":"core-4.1.0.jar","transferred":262144,"total":524288}
+ * {"event":"transfer.completed","timestamp":"...","artifact":"core-4.1.0.jar","transferred":524288}
+ * 
+ * + * Selected via {@code --console=machine}. + * + * @since 4.1.0 + * @see MachineExecutionEventLogger + */ +public class MachineBuildEventListener implements BuildEventListener { + + private final Consumer output; + + /** + * Creates a new MachineBuildEventListener. + * + * @param output the consumer that receives each JSON line (typically writes to terminal/stdout) + */ + public MachineBuildEventListener(Consumer output) { + this.output = output; + } + + /** + * Emit a pre-built JSON line to the output. Thread-safe — output is serialized + * to prevent interleaved lines from parallel builds. + * + * @param json the complete JSON object string (no trailing newline) + */ + public synchronized void emitEvent(String json) { + output.accept(json); + } + + @Override + public void sessionStarted(ExecutionEvent event) { + // Handled by MachineExecutionEventLogger.sessionStarted() + } + + @Override + public void projectStarted(String projectId) { + // Handled by MachineExecutionEventLogger.projectStarted() + } + + @Override + public void projectLogMessage(String projectId, LogEvent event) { + emitEvent(new JsonLine("log") + .field("level", event.level().name()) + .field("module", projectId) + .field("logger", event.loggerName()) + .field("message", event.message()) + .build()); + } + + @Override + public void projectFinished(String projectId) { + // Handled by MachineExecutionEventLogger.projectSucceeded/Failed/Skipped() + } + + @Override + public void executionFailure(String projectId, boolean halted, String exception) { + emitEvent(new JsonLine("execution.failure") + .field("module", projectId) + .field("halted", halted) + .field("error", exception) + .build()); + } + + @Override + public void mojoStarted(ExecutionEvent event) { + // Handled by MachineExecutionEventLogger.mojoStarted() + } + + @Override + public void finish(int exitCode) throws Exception { + // No-op — build.finished is emitted by MachineExecutionEventLogger.sessionEnded() + } + + @Override + public void fail(Throwable t) throws Exception { + // No-op — build.finished is emitted by MachineExecutionEventLogger.sessionEnded() + } + + @Override + public void log(String msg) { + emitEvent(new JsonLine("log").field("message", msg).build()); + } + + @Override + public void transfer(String projectId, TransferEvent event) { + String resource = event.getResource().getResourceName(); + String artifactName = extractArtifactName(resource); + long contentLength = event.getResource().getContentLength(); + + switch (event.getType()) { + case INITIATED: + case STARTED: + JsonLine started = new JsonLine("transfer.started").field("artifact", artifactName); + if (projectId != null) { + started.field("module", projectId); + } + if (contentLength > 0) { + started.field("size", contentLength); + } + started.field("url", resource); + emitEvent(started.build()); + break; + case PROGRESSED: + JsonLine progressed = new JsonLine("transfer.progressed").field("artifact", artifactName); + progressed.field("transferred", event.getTransferredBytes()); + if (contentLength > 0) { + progressed.field("total", contentLength); + } + emitEvent(progressed.build()); + break; + case SUCCEEDED: + JsonLine succeeded = new JsonLine("transfer.completed").field("artifact", artifactName); + succeeded.field("transferred", event.getTransferredBytes()); + emitEvent(succeeded.build()); + break; + case FAILED: + JsonLine failed = new JsonLine("transfer.failed").field("artifact", artifactName); + if (event.getException() != null) { + failed.field("error", event.getException().getMessage()); + } + emitEvent(failed.build()); + break; + default: + break; + } + } + + // ---- Helpers ---- + + private static String extractArtifactName(String resourceName) { + if (resourceName == null) { + return "unknown"; + } + int lastSlash = resourceName.lastIndexOf('/'); + return lastSlash >= 0 ? resourceName.substring(lastSlash + 1) : resourceName; + } + + // ---- JSON line builder ---- + + /** + * Lightweight builder for single-line JSON objects. Builds a flat JSON object + * with an {@code "event"} type and a {@code "timestamp"} field, plus any + * additional fields. Thread-safe when used within a single thread per instance. + */ + static class JsonLine { + private final StringBuilder sb; + private boolean hasFields; + + JsonLine(String eventType) { + sb = new StringBuilder(256); + sb.append("{\"event\":\""); + sb.append(eventType); + sb.append("\",\"timestamp\":\""); + sb.append(MonotonicClock.now().toString()); + sb.append('"'); + hasFields = true; + } + + JsonLine field(String key, String value) { + if (value != null) { + sb.append(",\"").append(key).append("\":"); + writeJsonString(sb, value); + } + return this; + } + + JsonLine field(String key, long value) { + sb.append(",\"").append(key).append("\":").append(value); + return this; + } + + JsonLine field(String key, double value) { + sb.append(",\"").append(key).append("\":").append(value); + return this; + } + + JsonLine field(String key, boolean value) { + sb.append(",\"").append(key).append("\":").append(value); + return this; + } + + String build() { + sb.append('}'); + return sb.toString(); + } + + /** + * Write a JSON-escaped string value (with surrounding quotes) to the builder. + */ + private static void writeJsonString(StringBuilder sb, String value) { + sb.append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + case '\b': + sb.append("\\b"); + break; + case '\f': + sb.append("\\f"); + break; + default: + if (c < 0x20) { + sb.append("\\u"); + sb.append(String.format("%04x", (int) c)); + } else { + sb.append(c); + } + } + } + sb.append('"'); + } + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineExecutionEventLogger.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineExecutionEventLogger.java new file mode 100644 index 000000000000..f44e9fa91ad1 --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineExecutionEventLogger.java @@ -0,0 +1,306 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.event; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.cling.event.MachineBuildEventListener.JsonLine; +import org.apache.maven.execution.AbstractExecutionListener; +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.BuildSummary; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.plugin.MojoExecution; +import org.apache.maven.project.MavenProject; + +/** + * Execution event logger for machine-readable output ({@code --console=machine}). + *

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

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

+ * Example output: + *

+ * {"event":"build.started","timestamp":"...","projectCount":12,"goals":"clean install"}
+ * {"event":"module.started","timestamp":"...","module":"maven-core","groupId":"org.apache.maven","artifactId":"maven-core","version":"4.1.0-SNAPSHOT","index":1,"total":12}
+ * {"event":"mojo.started","timestamp":"...","module":"maven-core","plugin":"maven-compiler-plugin","goal":"compile","phase":"compile"}
+ * {"event":"mojo.succeeded","timestamp":"...","module":"maven-core","plugin":"maven-compiler-plugin","goal":"compile","duration":1.2}
+ * {"event":"module.succeeded","timestamp":"...","module":"maven-core","duration":2.1}
+ * {"event":"build.finished","timestamp":"...","status":"SUCCESS","duration":32.1,"total":12,"passed":12,"failed":0,"skipped":0}
+ * 
+ * + * Selected via {@code --console=machine}. + * + * @since 4.1.0 + * @see MachineBuildEventListener + */ +public class MachineExecutionEventLogger extends AbstractExecutionListener { + + private final MachineBuildEventListener machineBel; + + // Track mojo start times for duration calculation + private final Map mojoStartTimes = new ConcurrentHashMap<>(); + + // Reactor state + private volatile int totalProjects; + private volatile int currentVisitedProjectCount; + private volatile Instant buildStartTime; + + public MachineExecutionEventLogger(MachineBuildEventListener machineBel) { + this.machineBel = Objects.requireNonNull(machineBel, "machineBel cannot be null"); + } + + // ---- Session lifecycle ---- + + @Override + public void sessionStarted(ExecutionEvent event) { + MavenSession session = event.getSession(); + List projects = session.getProjects(); + List allProjects = session.getAllProjects(); + + totalProjects = allProjects.size(); + currentVisitedProjectCount = allProjects.size() - projects.size(); + buildStartTime = MonotonicClock.now(); + + String goals = session.getRequest().getGoals().stream().collect(Collectors.joining(" ")); + + JsonLine line = new JsonLine("build.started") + .field("projectCount", totalProjects) + .field("goals", goals); + + List profiles = session.getRequest().getActiveProfiles(); + if (profiles != null && !profiles.isEmpty()) { + line.field("profiles", String.join(",", profiles)); + } + + machineBel.emitEvent(line.build()); + } + + @Override + public void sessionEnded(ExecutionEvent event) { + MavenSession session = event.getSession(); + + int passed = 0; + int failed = 0; + int skipped = 0; + for (MavenProject project : session.getProjects()) { + BuildSummary summary = session.getResult().getBuildSummary(project); + if (summary instanceof BuildSuccess) { + passed++; + } else if (summary instanceof BuildFailure) { + failed++; + } else { + skipped++; + } + } + + String status = session.getResult().hasExceptions() ? "FAILURE" : "SUCCESS"; + double duration = 0; + if (buildStartTime != null) { + duration = Duration.between(buildStartTime, MonotonicClock.now()).toMillis() / 1000.0; + } + + machineBel.emitEvent(new JsonLine("build.finished") + .field("status", status) + .field("duration", duration) + .field("total", totalProjects) + .field("passed", passed) + .field("failed", failed) + .field("skipped", skipped) + .build()); + } + + // ---- Module lifecycle ---- + + @Override + public void projectStarted(ExecutionEvent event) { + MavenProject project = event.getProject(); + int index; + synchronized (this) { + index = ++currentVisitedProjectCount; + } + + machineBel.emitEvent(new JsonLine("module.started") + .field("module", project.getName()) + .field("groupId", project.getGroupId()) + .field("artifactId", project.getArtifactId()) + .field("version", project.getVersion()) + .field("index", index) + .field("total", totalProjects) + .build()); + } + + @Override + public void projectSucceeded(ExecutionEvent event) { + logModuleFinished(event, "module.succeeded"); + } + + @Override + public void projectFailed(ExecutionEvent event) { + logModuleFinished(event, "module.failed"); + } + + @Override + public void projectSkipped(ExecutionEvent event) { + MavenProject project = event.getProject(); + machineBel.emitEvent(new JsonLine("module.skipped") + .field("module", project.getName()) + .build()); + } + + // ---- Mojo lifecycle ---- + + @Override + public void mojoStarted(ExecutionEvent event) { + MavenProject project = event.getProject(); + MojoExecution mojo = event.getMojoExecution(); + + String mojoKey = project.getArtifactId() + ":" + mojo.getExecutionId() + ":" + mojo.getGoal(); + mojoStartTimes.put(mojoKey, MonotonicClock.now()); + + machineBel.emitEvent(new JsonLine("mojo.started") + .field("module", project.getName()) + .field("plugin", mojo.getArtifactId()) + .field("goal", mojo.getGoal()) + .field("phase", mojo.getLifecyclePhase()) + .field("executionId", mojo.getExecutionId()) + .build()); + } + + @Override + public void mojoSucceeded(ExecutionEvent event) { + logMojoFinished(event, "mojo.succeeded"); + } + + @Override + public void mojoFailed(ExecutionEvent event) { + MavenProject project = event.getProject(); + MojoExecution mojo = event.getMojoExecution(); + + String mojoKey = project.getArtifactId() + ":" + mojo.getExecutionId() + ":" + mojo.getGoal(); + Instant start = mojoStartTimes.remove(mojoKey); + + JsonLine line = new JsonLine("mojo.failed") + .field("module", project.getName()) + .field("plugin", mojo.getArtifactId()) + .field("goal", mojo.getGoal()); + if (start != null) { + double duration = Duration.between(start, MonotonicClock.now()).toMillis() / 1000.0; + line.field("duration", duration); + } + if (event.getException() != null) { + line.field("error", event.getException().getMessage()); + } + machineBel.emitEvent(line.build()); + } + + @Override + public void mojoSkipped(ExecutionEvent event) { + MavenProject project = event.getProject(); + MojoExecution mojo = event.getMojoExecution(); + + machineBel.emitEvent(new JsonLine("mojo.skipped") + .field("module", project.getName()) + .field("plugin", mojo.getArtifactId()) + .field("goal", mojo.getGoal()) + .build()); + } + + // ---- Fork lifecycle (machine mode emits these for completeness) ---- + + @Override + public void forkStarted(ExecutionEvent event) { + MavenProject project = event.getProject(); + MojoExecution mojo = event.getMojoExecution(); + + machineBel.emitEvent(new JsonLine("fork.started") + .field("module", project.getName()) + .field("plugin", mojo.getArtifactId()) + .field("goal", mojo.getGoal()) + .build()); + } + + @Override + public void forkSucceeded(ExecutionEvent event) { + MavenProject project = event.getProject(); + machineBel.emitEvent(new JsonLine("fork.succeeded") + .field("module", project.getName()) + .build()); + } + + @Override + public void forkFailed(ExecutionEvent event) { + MavenProject project = event.getProject(); + JsonLine line = new JsonLine("fork.failed").field("module", project.getName()); + if (event.getException() != null) { + line.field("error", event.getException().getMessage()); + } + machineBel.emitEvent(line.build()); + } + + // ---- Helpers ---- + + private void logModuleFinished(ExecutionEvent event, String eventType) { + MavenProject project = event.getProject(); + MavenSession session = event.getSession(); + BuildSummary summary = session.getResult().getBuildSummary(project); + + JsonLine line = new JsonLine(eventType).field("module", project.getName()); + if (summary != null) { + line.field("duration", summary.getExecTime().toMillis() / 1000.0); + } + if ("module.failed".equals(eventType) && event.getException() != null) { + line.field("error", event.getException().getMessage()); + } + machineBel.emitEvent(line.build()); + } + + private void logMojoFinished(ExecutionEvent event, String eventType) { + MavenProject project = event.getProject(); + MojoExecution mojo = event.getMojoExecution(); + + String mojoKey = project.getArtifactId() + ":" + mojo.getExecutionId() + ":" + mojo.getGoal(); + Instant start = mojoStartTimes.remove(mojoKey); + + JsonLine line = new JsonLine(eventType) + .field("module", project.getName()) + .field("plugin", mojo.getArtifactId()) + .field("goal", mojo.getGoal()); + if (start != null) { + double duration = Duration.between(start, MonotonicClock.now()).toMillis() / 1000.0; + line.field("duration", duration); + } + machineBel.emitEvent(line.build()); + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/PlainExecutionEventLogger.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/PlainExecutionEventLogger.java new file mode 100644 index 000000000000..f35588ff2e9e --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/PlainExecutionEventLogger.java @@ -0,0 +1,323 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.event; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.services.MessageBuilder; +import org.apache.maven.api.services.MessageBuilderFactory; +import org.apache.maven.cling.utils.CLIReportingUtils; +import org.apache.maven.execution.AbstractExecutionListener; +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.BuildSummary; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.project.MavenProject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.apache.maven.cling.utils.CLIReportingUtils.formatDuration; + +/** + * Compact execution event logger for CI and batch environments. + *

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

+ * Example output: + *

+ * [INFO] maven-api-core ................................ SUCCESS [  2.1s]
+ * [INFO] maven-core ..................................... FAILURE [  5.3s]
+ * [INFO]
+ * [INFO] BUILD FAILURE
+ * [INFO] Total time:  32.1s
+ * 
+ * + * Selected via {@code --console=plain} or automatically in CI environments. + * + * @since 4.1.0 + * @see ExecutionEventLogger + */ +public class PlainExecutionEventLogger extends AbstractExecutionListener { + + private static final int MAX_LOG_PREFIX_SIZE = 8; // "[ERROR] " + private static final int PROJECT_STATUS_SUFFIX_SIZE = 20; // "SUCCESS [ 0.000 s]" + private static final int MIN_TERMINAL_WIDTH = 60; + private static final int DEFAULT_TERMINAL_WIDTH = 80; + private static final int MAX_TERMINAL_WIDTH = 130; + private static final int MAX_PADDED_BUILD_TIME_DURATION_LENGTH = 9; + + private final MessageBuilderFactory messageBuilderFactory; + private final Logger logger; + private int terminalWidth; + private int lineLength; + private int maxProjectNameLength; + private int totalProjects; + private volatile int currentVisitedProjectCount; + + public PlainExecutionEventLogger(MessageBuilderFactory messageBuilderFactory) { + this(messageBuilderFactory, LoggerFactory.getLogger(PlainExecutionEventLogger.class)); + } + + public PlainExecutionEventLogger(MessageBuilderFactory messageBuilderFactory, Logger logger) { + this(messageBuilderFactory, logger, -1); + } + + public PlainExecutionEventLogger(MessageBuilderFactory messageBuilderFactory, Logger logger, int terminalWidth) { + this.logger = Objects.requireNonNull(logger, "logger cannot be null"); + this.messageBuilderFactory = messageBuilderFactory; + this.terminalWidth = terminalWidth; + } + + private void init() { + if (maxProjectNameLength == 0) { + if (terminalWidth < 0) { + terminalWidth = messageBuilderFactory.getTerminalWidth(); + } + terminalWidth = Math.min( + MAX_TERMINAL_WIDTH, + Math.max(terminalWidth <= 0 ? DEFAULT_TERMINAL_WIDTH : terminalWidth, MIN_TERMINAL_WIDTH)); + lineLength = terminalWidth - MAX_LOG_PREFIX_SIZE; + maxProjectNameLength = lineLength - PROJECT_STATUS_SUFFIX_SIZE; + } + } + + private MessageBuilder builder() { + return messageBuilderFactory.builder(); + } + + private static String chars(char c, int count) { + return String.valueOf(c).repeat(Math.max(0, count)); + } + + private void infoMain(String msg) { + logger.info(builder().strong(msg).toString()); + } + + // ---- Session lifecycle ---- + + @Override + public void projectDiscoveryStarted(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logger.info("Scanning for projects..."); + } + } + + @Override + public void sessionStarted(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + List projects = event.getSession().getProjects(); + List allProjects = event.getSession().getAllProjects(); + + currentVisitedProjectCount = allProjects.size() - projects.size(); + totalProjects = allProjects.size(); + } + } + + @Override + public void sessionEnded(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logger.info(""); + logResult(event.getSession()); + logStats(event.getSession()); + } + } + + // ---- Module lifecycle: one line per completed module ---- + + @Override + public void projectStarted(ExecutionEvent event) { + // In plain mode, we only log when a project finishes (succeeded/failed/skipped) + } + + @Override + public void projectSucceeded(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logProjectLine(event, "SUCCESS"); + } + } + + @Override + public void projectFailed(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logProjectLine(event, "FAILURE"); + } + } + + @Override + public void projectSkipped(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logProjectLine(event, "SKIPPED"); + } + } + + // ---- Mojo lifecycle: suppressed in plain mode ---- + + @Override + public void mojoStarted(ExecutionEvent event) { + // Suppressed in plain mode — plugin execution details go to build report + } + + @Override + public void mojoSkipped(ExecutionEvent event) { + if (logger.isWarnEnabled()) { + logger.warn( + "Goal '{}' requires online mode for execution but Maven is currently offline, skipping", + event.getMojoExecution().getGoal()); + } + } + + @Override + public void forkStarted(ExecutionEvent event) { + // Suppressed in plain mode + } + + @Override + public void forkSucceeded(ExecutionEvent event) { + // Suppressed in plain mode + } + + // ---- Formatting helpers ---- + + private void logProjectLine(ExecutionEvent event, String status) { + MavenProject project = event.getProject(); + MavenSession session = event.getSession(); + MavenExecutionResult result = session.getResult(); + BuildSummary buildSummary = result.getBuildSummary(project); + + StringBuilder buffer = new StringBuilder(128); + buffer.append(project.getName()); + buffer.append(' '); + + if (totalProjects > 1) { + int number; + synchronized (this) { + number = ++currentVisitedProjectCount; + } + String progress = "[" + number + "/" + totalProjects + "]"; + buffer.append(progress); + buffer.append(' '); + } + + // Pad with dots to align status + if (buffer.length() <= maxProjectNameLength) { + while (buffer.length() < maxProjectNameLength) { + buffer.append('.'); + } + buffer.append(' '); + } + + // Status with color + MessageBuilder mb = builder(); + mb.a(buffer); + switch (status) { + case "SUCCESS": + mb.success(status); + break; + case "FAILURE": + mb.failure(status); + break; + default: + mb.warning(status); + break; + } + + // Duration + if (buildSummary != null) { + mb.a(" ["); + String duration = formatDuration(buildSummary.getExecTime()); + int padSize = MAX_PADDED_BUILD_TIME_DURATION_LENGTH - duration.length(); + if (padSize > 0) { + mb.a(chars(' ', padSize)); + } + mb.a(duration); + mb.a(']'); + } + + logger.info(mb.toString()); + } + + private void logResult(MavenSession session) { + MessageBuilder buffer = builder(); + if (session.getResult().hasExceptions()) { + buffer.failure("BUILD FAILURE"); + } else { + buffer.success("BUILD SUCCESS"); + } + + int passed = 0; + int failed = 0; + int skipped = 0; + for (MavenProject project : session.getProjects()) { + BuildSummary summary = session.getResult().getBuildSummary(project); + if (summary instanceof BuildSuccess) { + passed++; + } else if (summary instanceof BuildFailure) { + failed++; + } else { + skipped++; + } + } + + logger.info(buffer.toString()); + + // Compact stats line: "12 modules | 11 passed | 1 failed | 0 skipped" + if (totalProjects > 1) { + StringBuilder stats = new StringBuilder(); + stats.append(totalProjects).append(" modules"); + stats.append(" | ").append(passed).append(" passed"); + if (failed > 0) { + stats.append(" | ").append(failed).append(" failed"); + } + if (skipped > 0) { + stats.append(" | ").append(skipped).append(" skipped"); + } + logger.info(stats.toString()); + } + } + + private void logStats(MavenSession session) { + Duration time = Duration.between(session.getRequest().getStartInstant(), MonotonicClock.now()); + String wallClock = session.getRequest().getDegreeOfConcurrency() > 1 ? " (Wall Clock)" : ""; + logger.info("Total time: {}{}", formatDuration(time), wallClock); + + // On failure, show Maven and Java version to help with bug reports (MNG-7372) + if (session.getResult().hasExceptions()) { + logger.info("Maven: {}", CLIReportingUtils.showVersionMinimal()); + logger.info( + "Java: {} ({})", + System.getProperty("java.version", ""), + System.getProperty("java.vendor", "")); + } + + logger.info("Full report: target/build-reports/build-report-latest.json"); + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichBuildEventListener.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichBuildEventListener.java new file mode 100644 index 000000000000..6f03423b3241 --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichBuildEventListener.java @@ -0,0 +1,658 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.event; + +import java.io.PrintWriter; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.LogLevel; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.logging.BuildEventListener; +import org.apache.maven.project.MavenProject; +import org.eclipse.aether.transfer.TransferEvent; +import org.jline.terminal.Terminal; +import org.jline.utils.Display; + +/** + * A rich terminal build event listener using JLine's {@link Display} in + * non-fullscreen mode — the same approach as mvnd. + *

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

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

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

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

+ * The flush at the end is critical: {@link Display} writes through + * {@link Terminal#writer()} (a {@code PrintWriter} that does not auto-flush), + * while subsequent log output from SLF4J goes through {@code System.out} + * (which does auto-flush on {@code println}). Without the flush, + * the clear sequences sit in the writer's buffer while the summary text + * reaches the terminal first via {@code System.out} — then the belated + * clear erases the summary the user was supposed to see. + */ + public void tearDown() { + synchronized (outputLock) { + if (!displayActive) { + return; + } + displayActive = false; + + // Clear the display area: update with empty lines, cursor at top + display.updateAnsi(Collections.nCopies(statusHeight, ""), 0); + // Erase from cursor to end of screen — removes any leftover artifacts + writer.print("\033[J"); + // Flush immediately so the clear reaches the terminal BEFORE + // any subsequent log output that goes through System.out + writer.flush(); + } + } + + // ---- BuildEventListener interface ---- + + @Override + public void sessionStarted(ExecutionEvent event) { + // Reactor init is handled via initReactor() called from RichExecutionEventLogger + } + + @Override + public void projectStarted(String projectId) { + activeProjects.put(projectId, new ProjectState(projectId, MonotonicClock.now())); + redraw(); + } + + @Override + public void projectFinished(String projectId) { + activeProjects.remove(projectId); + completedProjects++; + redraw(); + } + + @Override + public void projectLogMessage(String projectId, LogEvent event) { + // In rich mode, suppress INFO/DEBUG/TRACE/WARN — the status bar provides + // live progress and warnings are summarized at the end of the build. + // Only ERROR passes through to the terminal immediately. + if (event.level() == LogLevel.WARN) { + warningCount.incrementAndGet(); + return; + } + if (event.level() == LogLevel.INFO || event.level() == LogLevel.DEBUG || event.level() == LogLevel.TRACE) { + return; + } + if (event.level() == LogLevel.ERROR) { + errorCount.incrementAndGet(); + } + String output = event.formattedMessage(); + if (output == null) { + output = event.message(); + } + printAboveStatus(output); + } + + /** + * Returns the number of WARN-level log messages seen during the build. + */ + public int getWarningCount() { + return warningCount.get(); + } + + /** + * Returns the number of ERROR-level log messages seen during the build. + */ + public int getErrorCount() { + return errorCount.get(); + } + + @Override + public void log(String msg) { + printAboveStatus(msg); + } + + @Override + public void mojoStarted(ExecutionEvent event) { + String projectId = event.getProject().getArtifactId(); + ProjectState state = activeProjects.get(projectId); + if (state != null) { + state.currentMojo = event.getMojoExecution().getArtifactId() + ":" + + event.getMojoExecution().getGoal(); + } + synchronized (outputLock) { + if (displayActive) { + display.updateAnsi(buildStatusLines(), 0); + } + } + } + + @Override + public void executionFailure(String projectId, boolean halted, String exception) { + ProjectState state = activeProjects.get(projectId); + if (state != null) { + state.failed = true; + } + synchronized (outputLock) { + if (displayActive) { + display.updateAnsi(buildStatusLines(), 0); + } + } + } + + @Override + public void transfer(String projectId, TransferEvent event) { + String resource = event.getResource().getResourceName(); + + switch (event.getType()) { + case INITIATED: + case STARTED: + String artifactName = extractArtifactName(resource); + activeTransfers.put( + resource, + new TransferInfo(artifactName, 0, event.getResource().getContentLength())); + redraw(); + break; + case PROGRESSED: + TransferInfo info = activeTransfers.get(resource); + if (info != null) { + info.transferred = event.getTransferredBytes(); + // Only update every ~50KB to avoid too-frequent redraws + if (info.transferred - info.lastUpdateBytes > 51200) { + info.lastUpdateBytes = info.transferred; + redraw(); + } + } + break; + case SUCCEEDED: + case FAILED: + activeTransfers.remove(resource); + redraw(); + break; + default: + break; + } + } + + @Override + public void finish(int exitCode) throws Exception { + tearDown(); + } + + @Override + public void fail(Throwable t) throws Exception { + tearDown(); + } + + // ---- Display helpers ---- + + /** + * Print a message above the status area: clear the display, print + * the message as normal scrolling text, then redraw the status below. + */ + private void printAboveStatus(String msg) { + synchronized (outputLock) { + if (displayActive) { + // Clear status area so the message prints where it was + display.updateAnsi(Collections.nCopies(statusHeight, ""), 0); + display.reset(); + // Print the log message (scrolls normally) + writer.println(msg); + writer.flush(); + // Redraw status below the new output + display.updateAnsi(buildStatusLines(), 0); + } else { + writer.println(msg); + writer.flush(); + } + } + } + + private void redraw() { + synchronized (outputLock) { + if (displayActive) { + display.updateAnsi(buildStatusLines(), 0); + } + } + } + + // ---- Status line building ---- + + /** + * Build exactly {@link #statusHeight} status lines. + * Layout: 1 header + active projects (packed top) + empty padding + 1 separator + 1 summary. + * The separator and summary are always at the bottom — they never move. + */ + private List buildStatusLines() { + int termWidth = Math.max(terminal.getWidth(), 40); + + // Collect active projects sorted by start time for visual stability + List active = new ArrayList<>(activeProjects.values()); + active.sort((a, b) -> a.startTime.compareTo(b.startTime)); + + // Number of project slot lines = statusHeight - 3 (header, separator, summary) + int slotCount = statusHeight - 3; + + List lines = new ArrayList<>(statusHeight); + + // Header line + lines.add(headerLine != null ? headerLine : ""); + + // Active projects packed to the top (up to slotCount) + int projectsShown = 0; + for (ProjectState state : active) { + if (projectsShown >= slotCount) { + break; + } + lines.add(formatProjectSlot(state)); + projectsShown++; + } + + // Empty padding lines at the bottom of the slot area + for (int i = projectsShown; i < slotCount; i++) { + lines.add(""); + } + + // Separator line (always at the same position) + lines.add(DIM + "─".repeat(Math.min(termWidth, 120)) + RESET); + + // Summary line (progress indicators + counter + elapsed + downloads) + lines.add(buildSummaryLine(termWidth)); + + return lines; + } + + private String formatProjectSlot(ProjectState state) { + StringBuilder b = new StringBuilder(); + if (state.failed) { + b.append(RED).append(" ✗ ").append(RESET); + } else { + b.append(CYAN).append(" ● ").append(RESET); + } + b.append(BOLD); + b.append(projectNames.getOrDefault(state.projectId, state.projectId)); + b.append(RESET); + if (state.currentMojo != null) { + b.append(" ").append(YELLOW).append(state.currentMojo).append(RESET); + } + Duration elapsed = Duration.between(state.startTime, MonotonicClock.now()); + b.append(" ").append(DIM).append(formatCompactDuration(elapsed)).append(RESET); + return b.toString(); + } + + private String buildSummaryLine(int termWidth) { + StringBuilder s = new StringBuilder(" "); + + // Module status indicators (✓ done, ● active, ○ pending) + int maxIndicators = Math.min(projectOrder.size(), (termWidth - 40) / 3); + if (totalProjects > 1 && maxIndicators > 0) { + int shown = 0; + for (String pid : projectOrder) { + if (shown >= maxIndicators) { + s.append("… "); + break; + } + if (activeProjects.containsKey(pid)) { + ProjectState ps = activeProjects.get(pid); + if (ps != null && ps.failed) { + s.append(RED).append("✗ ").append(RESET); + } else { + s.append(YELLOW).append("● ").append(RESET); + } + } else if (projectOrder.indexOf(pid) < completedProjects) { + s.append(GREEN).append("✓ ").append(RESET); + } else { + s.append(DIM).append("○ ").append(RESET); + } + shown++; + } + } + + // Progress counter + s.append('['); + s.append(BOLD) + .append(completedProjects) + .append('/') + .append(totalProjects) + .append(RESET); + s.append(']'); + + // Elapsed time + if (buildStartTime != null) { + Duration elapsed = Duration.between(buildStartTime, MonotonicClock.now()); + s.append(" ").append(DIM).append(formatCompactDuration(elapsed)).append(RESET); + } + + // Downloads (merged into summary line to keep height fixed) + if (!activeTransfers.isEmpty()) { + s.append(" ").append(BLUE).append("↓ ").append(RESET); + if (activeTransfers.size() == 1) { + TransferInfo ti = activeTransfers.values().iterator().next(); + s.append(ti.artifactName); + if (ti.totalBytes > 0) { + s.append(' ') + .append(DIM) + .append(formatBytes(ti.transferred)) + .append('/') + .append(formatBytes(ti.totalBytes)) + .append(RESET); + } + } else { + s.append(activeTransfers.size()).append(" artifacts"); + } + } + + return s.toString(); + } + + // ---- Helpers ---- + + /** + * Truncate a string containing ANSI escape sequences to + * {@code maxVisible} visible characters. If truncation occurs, + * a RESET is appended to close any open styling. + */ + static String truncateAnsi(String s, int maxVisible) { + StringBuilder out = new StringBuilder(s.length()); + int visible = 0; + int i = 0; + while (i < s.length()) { + char c = s.charAt(i); + if (c == '\033') { + // Start of escape sequence — copy through without counting + out.append(c); + i++; + if (i < s.length()) { + char next = s.charAt(i); + if (next == '[') { + // CSI sequence: ESC [ ... + out.append(next); + i++; + while (i < s.length()) { + char cc = s.charAt(i); + out.append(cc); + i++; + if (Character.isLetter(cc)) { + break; + } + } + } else { + // Two-char escape (DECSC, DECRC, etc.) + out.append(next); + i++; + } + } + } else { + if (visible >= maxVisible) { + out.append(RESET); + break; + } + out.append(c); + visible++; + i++; + } + } + return out.toString(); + } + + private static String extractArtifactName(String resourceName) { + if (resourceName == null) { + return "unknown"; + } + int lastSlash = resourceName.lastIndexOf('/'); + return lastSlash >= 0 ? resourceName.substring(lastSlash + 1) : resourceName; + } + + private static String formatCompactDuration(Duration duration) { + long totalSeconds = duration.getSeconds(); + if (totalSeconds < 60) { + return totalSeconds + "s"; + } else if (totalSeconds < 3600) { + return (totalSeconds / 60) + "m " + (totalSeconds % 60) + "s"; + } else { + return (totalSeconds / 3600) + "h " + ((totalSeconds % 3600) / 60) + "m"; + } + } + + private static String formatBytes(long bytes) { + if (bytes < 1024) { + return bytes + " B"; + } else if (bytes < 1024 * 1024) { + return String.format("%.0f KB", bytes / 1024.0); + } else { + return String.format("%.1f MB", bytes / (1024.0 * 1024.0)); + } + } + + // ---- Inner state classes ---- + + private static class ProjectState { + final String projectId; + final Instant startTime; + volatile String currentMojo; + volatile boolean failed; + + ProjectState(String projectId, Instant startTime) { + this.projectId = projectId; + this.startTime = startTime; + } + } + + private static class TransferInfo { + final String artifactName; + final long totalBytes; + volatile long transferred; + volatile long lastUpdateBytes; + + TransferInfo(String artifactName, long transferred, long totalBytes) { + this.artifactName = artifactName; + this.transferred = transferred; + this.totalBytes = totalBytes; + } + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichExecutionEventLogger.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichExecutionEventLogger.java new file mode 100644 index 000000000000..87213a088a2c --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichExecutionEventLogger.java @@ -0,0 +1,377 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.event; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.services.MessageBuilder; +import org.apache.maven.api.services.MessageBuilderFactory; +import org.apache.maven.cling.utils.CLIReportingUtils; +import org.apache.maven.execution.AbstractExecutionListener; +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.BuildSummary; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.project.MavenProject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.apache.maven.cling.utils.CLIReportingUtils.formatDuration; + +/** + * Execution event logger for the rich terminal mode ({@code --console=rich}). + *

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

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

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

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

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

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

+ */ + protected void preliminaryInteractiveDetection(C context) { + if (context.options().forceInteractive().orElse(false)) { + context.interactive = true; + } else if (context.options().nonInteractive().orElse(false)) { + context.interactive = false; + } else if (context.invokerRequest.ciInfo().isPresent()) { + context.interactive = false; + } else { + // Default: assume interactive (settings may refine later) + context.interactive = true; + } + } + protected BuildEventListener determineBuildEventListener(C context) { if (context.buildEventListener == null) { context.buildEventListener = doDetermineBuildEventListener(context); diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java index e6372ccfd818..3924977e56e3 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java @@ -48,6 +48,11 @@ import org.apache.maven.api.services.model.ModelProcessor; import org.apache.maven.api.toolchain.PersistedToolchains; import org.apache.maven.cling.event.ExecutionEventLogger; +import org.apache.maven.cling.event.MachineBuildEventListener; +import org.apache.maven.cling.event.MachineExecutionEventLogger; +import org.apache.maven.cling.event.PlainExecutionEventLogger; +import org.apache.maven.cling.event.RichBuildEventListener; +import org.apache.maven.cling.event.RichExecutionEventLogger; import org.apache.maven.cling.invoker.CliUtils; import org.apache.maven.cling.invoker.LookupContext; import org.apache.maven.cling.invoker.LookupInvoker; @@ -66,6 +71,7 @@ import org.apache.maven.execution.ProjectActivation; import org.apache.maven.jline.MessageUtils; import org.apache.maven.lifecycle.LifecycleExecutionException; +import org.apache.maven.logging.BuildEventListener; import org.apache.maven.logging.LoggingExecutionListener; import org.apache.maven.logging.MavenTransferListener; import org.apache.maven.project.MavenProject; @@ -257,6 +263,14 @@ protected void populateRequest(MavenContext context, Lookup lookup, MavenExecuti } } + // Propagate warning mode and diagnostic suppression to the session so + // BuildReportCollector (an EventSpy) can read them from user properties + String warningMode = context.options().warningMode().orElse("summary"); + request.getUserProperties().put("maven.build.warningMode", warningMode); + + // Diagnostic suppression: forward -Dmaven.diagnostic.suppress if set + // (already in user properties via -D, just ensure it's visible) + request.setTransferListener(determineTransferListener( context, context.options().noTransferProgress().orElse(false))); request.setExecutionListener(determineExecutionListener(context)); @@ -343,21 +357,102 @@ protected String determineGlobalChecksumPolicy(MavenContext context) { } protected ExecutionListener determineExecutionListener(MavenContext context) { - ExecutionListener listener = new ExecutionEventLogger(context.invokerRequest.messageBuilderFactory()); + ExecutionListener listener; + String consoleMode = determineConsoleMode(context); + switch (consoleMode) { + case "machine": + BuildEventListener machineBel = determineBuildEventListener(context); + if (machineBel instanceof MachineBuildEventListener machineListener) { + listener = new MachineExecutionEventLogger(machineListener); + } else { + // Fallback if machine listener couldn't be created + listener = new PlainExecutionEventLogger(context.invokerRequest.messageBuilderFactory()); + } + break; + case "rich": + BuildEventListener richBel = determineBuildEventListener(context); + if (richBel instanceof RichBuildEventListener richListener) { + listener = + new RichExecutionEventLogger(context.invokerRequest.messageBuilderFactory(), richListener); + } else { + // Fallback if status bar couldn't be created + listener = new PlainExecutionEventLogger(context.invokerRequest.messageBuilderFactory()); + } + break; + case "plain": + listener = new PlainExecutionEventLogger(context.invokerRequest.messageBuilderFactory()); + break; + default: + listener = new ExecutionEventLogger(context.invokerRequest.messageBuilderFactory()); + break; + } if (context.eventSpyDispatcher != null) { listener = context.eventSpyDispatcher.chainListener(listener); } return new LoggingExecutionListener(listener, determineBuildEventListener(context)); } + @Override + protected BuildEventListener doDetermineBuildEventListener(MavenContext context) { + String consoleMode = determineConsoleMode(context); + if ("machine".equals(consoleMode)) { + return new MachineBuildEventListener(determineWriter(context)); + } + if ("rich".equals(consoleMode) && context.terminal != null) { + return new RichBuildEventListener(context.terminal, determineWriter(context)); + } + return super.doDetermineBuildEventListener(context); + } + + /** + * Resolves the effective console mode from the {@code --console} flag and CI/TTY detection. + *

+ * Resolution order: + *

    + *
  1. Explicit {@code --console=plain}, {@code --console=verbose}, {@code --console=rich}, + * or {@code --console=machine} — always honored
  2. + *
  3. {@code --console=auto} (or unset) — selects mode based on environment: + *
      + *
    • CI detected → "plain"
    • + *
    • Interactive TTY → "rich"
    • + *
    • Otherwise → "verbose"
    • + *
    + *
  4. + *
+ */ + String determineConsoleMode(MavenContext context) { + String consoleMode = context.options().console().orElse("auto"); + if ("plain".equalsIgnoreCase(consoleMode) + || "verbose".equalsIgnoreCase(consoleMode) + || "rich".equalsIgnoreCase(consoleMode) + || "machine".equalsIgnoreCase(consoleMode)) { + return consoleMode.toLowerCase(); + } + // "auto" mode: CI → plain, interactive TTY → rich, otherwise → verbose + if (context.invokerRequest.ciInfo().isPresent() + && !context.options().forceInteractive().orElse(false)) { + return "plain"; + } + if (context.interactive && context.terminal != null && !context.invokerRequest.embedded()) { + return "rich"; + } + return "verbose"; + } + protected TransferListener determineTransferListener(MavenContext context, boolean noTransferProgress) { boolean quiet = context.options().quiet().orElse(false); boolean logFile = context.options().logFile().isPresent(); boolean quietCI = context.invokerRequest.ciInfo().isPresent() && !context.options().forceInteractive().orElse(false); + String mode = determineConsoleMode(context); + boolean richMode = "rich".equals(mode); + boolean machineMode = "machine".equals(mode); TransferListener delegate; - if (quiet || noTransferProgress || quietCI) { + if (quiet || noTransferProgress || quietCI || richMode || machineMode) { + // In rich mode, transfer progress is shown in the JLine status bar. + // In machine mode, transfer events are emitted as JSON lines. + // In both cases, suppress the console transfer listener. delegate = new QuietMavenTransferListener(); } else if (context.interactive && !logFile) { if (context.simplexTransferListener == null) { diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/BuildReportRenderer.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/BuildReportRenderer.java new file mode 100644 index 000000000000..b443ad4aaf2e --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/BuildReportRenderer.java @@ -0,0 +1,514 @@ +/* + * 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}) + * as formatted terminal output. + *

+ * 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 output; + + public BuildReportRenderer(MessageBuilderFactory messageBuilderFactory, Consumer output) { + this.messageBuilderFactory = messageBuilderFactory; + this.output = output; + } + + /** + * Render the default summary view of a build report. + */ + @SuppressWarnings("unchecked") + public void renderSummary(Map report) { + renderHeader(report); + + // Module summary + List> modules = getList(report, "modules"); + if (!modules.isEmpty()) { + for (Map module : modules) { + renderModuleLine(module); + } + output.accept(""); + + // Stats line + int passed = 0, failed = 0, skipped = 0; + for (Map 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()); + } + + // Diagnostics — show the actual messages so the user doesn't have to re-run the build + List> diagnostics = getList(report, "diagnostics"); + if (!diagnostics.isEmpty()) { + long warnings = diagnostics.stream() + .filter(d -> "WARNING".equals(getString(d, "severity"))) + .count(); + long errors = diagnostics.stream() + .filter(d -> "ERROR".equals(getString(d, "severity"))) + .count(); + if (warnings > 0 || errors > 0) { + MessageBuilder diag = messageBuilderFactory.builder(); + diag.a("Diagnostics: "); + if (warnings > 0) { + diag.warning(warnings + " warning" + (warnings > 1 ? "s" : "")); + } + if (warnings > 0 && errors > 0) { + diag.a(", "); + } + if (errors > 0) { + diag.failure(errors + " error" + (errors > 1 ? "s" : "")); + } + output.accept(diag.toString()); + + // Show each diagnostic message + for (Map d : diagnostics) { + String severity = getString(d, "severity"); + String message = getString(d, "message"); + String source = getString(d, "source"); + String file = getString(d, "file"); + + MessageBuilder mb = messageBuilderFactory.builder(); + if ("ERROR".equals(severity)) { + mb.failure(" [ERROR] "); + } else { + mb.warning(" [WARN] "); + } + mb.a(message); + if (source != null && !source.isEmpty()) { + mb.a(" (").a(source).a(")"); + } + output.accept(mb.toString()); + + if (file != null && !file.isEmpty()) { + StringBuilder loc = new StringBuilder(" at "); + loc.append(file); + Number line = getNumber(d, "line"); + if (line != null && line.intValue() > 0) { + loc.append(":").append(line.intValue()); + } + output.accept(loc.toString()); + } + } + } + } + + // Failures count + List> 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 report) { + renderHeader(report); + + List> diagnostics = getList(report, "diagnostics"); + if (diagnostics.isEmpty()) { + output.accept(messageBuilderFactory + .builder() + .success("No diagnostics recorded.") + .toString()); + return; + } + + output.accept("Diagnostics (" + diagnostics.size() + "):"); + output.accept(""); + for (Map diag : diagnostics) { + String severity = getString(diag, "severity"); + String message = getString(diag, "message"); + String source = getString(diag, "source"); + String file = getString(diag, "file"); + + MessageBuilder mb = messageBuilderFactory.builder(); + if ("ERROR".equals(severity)) { + mb.failure("[ERROR] "); + } else { + mb.warning("[WARN] "); + } + mb.a(message); + if (source != null && !source.isEmpty()) { + mb.a(" (").a(source).a(")"); + } + output.accept(mb.toString()); + + if (file != null && !file.isEmpty()) { + StringBuilder loc = new StringBuilder(" at "); + loc.append(file); + Number line = getNumber(diag, "line"); + if (line != null && line.intValue() > 0) { + loc.append(":").append(line.intValue()); + } + output.accept(loc.toString()); + } + + String suggestion = getString(diag, "suggestion"); + if (suggestion != null && !suggestion.isEmpty()) { + output.accept(" -> " + suggestion); + } + } + } + + /** + * Render the detailed failures view. + */ + @SuppressWarnings("unchecked") + public void renderFailures(Map report) { + renderHeader(report); + + List> failures = getList(report, "failures"); + if (failures.isEmpty()) { + output.accept(messageBuilderFactory + .builder() + .success("No failures recorded.") + .toString()); + return; + } + + output.accept("Failures (" + failures.size() + "):"); + for (Map failure : failures) { + output.accept(""); + String module = getString(failure, "module"); + String mojo = getString(failure, "mojo"); + MessageBuilder mb = messageBuilderFactory.builder(); + mb.failure(" ✗ ").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 report) { + renderHeader(report); + + List> modules = getList(report, "modules"); + if (modules.isEmpty()) { + output.accept("No modules recorded."); + return; + } + + for (Map 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> mojos = getList(module, "mojos"); + for (Map 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 reports = new ArrayList<>(); + try (DirectoryStream 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()); + } + } + + // ---- Internal helpers ---- + + private void renderHeader(Map 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 module) { + String artifactId = getString(module, "artifactId"); + String status = getString(module, "status"); + String duration = getString(module, "duration"); + + StringBuilder buffer = new StringBuilder(128); + + // Status icon + switch (status) { + case "SUCCESS": + buffer.append(" ✓ "); + break; + case "FAILURE": + buffer.append(" ✗ "); + break; + default: + buffer.append(" ○ "); + break; + } + + buffer.append(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> getList(Map map, String key) { + Object value = map.get(key); + if (value instanceof List) { + return (List>) value; + } + return List.of(); + } + + private static String getString(Map map, String key) { + Object value = map.get(key); + return value != null ? value.toString() : null; + } + + private static Number getNumber(Map 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 000000000000..5f551644d35a --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/CommonsCliLogOptions.java @@ -0,0 +1,147 @@ +/* + * 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 diagnostics() { + if (commandLine.hasOption(CLIManager.DIAGNOSTICS)) { + return Optional.of(Boolean.TRUE); + } + return Optional.empty(); + } + + @Override + public Optional failures() { + if (commandLine.hasOption(CLIManager.FAILURES)) { + return Optional.of(Boolean.TRUE); + } + return Optional.empty(); + } + + @Override + public Optional full() { + if (commandLine.hasOption(CLIManager.FULL)) { + return Optional.of(Boolean.TRUE); + } + return Optional.empty(); + } + + @Override + public Optional list() { + if (commandLine.hasOption(CLIManager.LIST)) { + return Optional.of(Boolean.TRUE); + } + return Optional.empty(); + } + + @Override + public Optional json() { + if (commandLine.hasOption(CLIManager.JSON)) { + return Optional.of(Boolean.TRUE); + } + return Optional.empty(); + } + + @Override + public Optional reportFile() { + List args = commandLine.getArgList(); + if (!args.isEmpty()) { + return Optional.of(args.get(0)); + } + return Optional.empty(); + } + + @Override + public void displayHelp(ParserRequest request, Consumer 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("Use --json to output the raw JSON report (e.g. mvnlog --json | jq '.modules')."); + 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"; + + @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()); + } + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogContext.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogContext.java new file mode 100644 index 000000000000..cf6193e19a67 --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogContext.java @@ -0,0 +1,38 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.invoker.mvnlog; + +import org.apache.maven.api.cli.InvokerRequest; +import org.apache.maven.api.cli.mvnlog.LogOptions; +import org.apache.maven.cling.invoker.LookupContext; + +/** + * Context for the {@code mvnlog} build log viewer. + */ +@SuppressWarnings("VisibilityModifier") +public class LogContext extends LookupContext { + public LogContext(InvokerRequest invokerRequest, LogOptions logOptions) { + super(invokerRequest, true, logOptions); + } + + @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 000000000000..e7549944f06f --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogInvoker.java @@ -0,0 +1,169 @@ +/* + * 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. + *

+ * 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 { + + 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 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 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; + } + + // Handle --json: output the raw JSON and exit + if (options != null && options.json().orElse(false)) { + output.accept(json); + return OK; + } + + Map report; + try { + report = SimpleJsonReader.parse(json); + } catch (IllegalArgumentException e) { + context.logger.error("Failed to parse report file: " + e.getMessage()); + return ERROR; + } + + // Render based on flags + if (options != null && options.full().orElse(false)) { + renderer.renderFull(report); + } else if (options != null && options.failures().orElse(false)) { + renderer.renderFailures(report); + } else if (options != null && options.diagnostics().orElse(false)) { + renderer.renderDiagnostics(report); + } else { + renderer.renderSummary(report); + } + + return OK; + } + + 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/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogParser.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogParser.java new file mode 100644 index 000000000000..a26219ec8a01 --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogParser.java @@ -0,0 +1,37 @@ +/* + * 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 org.apache.commons.cli.ParseException; +import org.apache.maven.api.cli.Options; +import org.apache.maven.cling.invoker.BaseParser; + +/** + * Parser for the {@code mvnlog} command-line arguments. + */ +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 000000000000..818ad1cad136 --- /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} / {@code List} / {@code String} / {@code Number} / {@code Boolean} / null. + *

+ * No external dependencies. Companion to {@code BuildReportJsonWriter} which writes + * JSON without any library; this reader follows the same zero-dependency principle. + *

+ * 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} for a JSON object) + * @throws IllegalArgumentException if the JSON is malformed + */ + @SuppressWarnings("unchecked") + static Map 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) 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 parseObject() { + expect('{'); + Map 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 parseArray() { + expect('['); + List 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/mvnsh/builtin/BuiltinShellCommandRegistryFactory.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnsh/builtin/BuiltinShellCommandRegistryFactory.java index a221e1b5b152..18ee72f9d0f9 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 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 contextCopier() { public void close() throws Exception { shellMavenInvoker.close(); shellEncryptInvoker.close(); + shellLogInvoker.close(); shellUpgradeInvoker.close(); } @@ -251,6 +259,24 @@ private List 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 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/event/MachineBuildEventListenerTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineBuildEventListenerTest.java new file mode 100644 index 000000000000..67bbe36ff3bc --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineBuildEventListenerTest.java @@ -0,0 +1,239 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.event; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.LogLevel; +import org.apache.maven.internal.build.DefaultLogEvent; +import org.eclipse.aether.transfer.TransferEvent; +import org.eclipse.aether.transfer.TransferResource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.MockitoSession; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link MachineBuildEventListener}. + */ +class MachineBuildEventListenerTest { + + private MockitoSession mockitoSession; + private List capturedOutput; + private MachineBuildEventListener listener; + + @BeforeEach + void beforeEach() { + mockitoSession = Mockito.mockitoSession().startMocking(); + capturedOutput = new ArrayList<>(); + listener = new MachineBuildEventListener(capturedOutput::add); + } + + @org.junit.jupiter.api.AfterEach + void afterEach() { + mockitoSession.finishMocking(); + } + + @Test + void testLogEmitsJsonLine() { + listener.log("Hello world"); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.startsWith("{"), "Should be JSON object"); + assertTrue(json.endsWith("}"), "Should be JSON object"); + assertTrue(json.contains("\"event\":\"log\""), "Should have event type"); + assertTrue(json.contains("\"message\":\"Hello world\""), "Should have message"); + assertTrue(json.contains("\"timestamp\":\""), "Should have timestamp"); + } + + @Test + void testProjectLogMessageIncludesModule() { + listener.projectLogMessage( + "my-core", + new DefaultLogEvent( + MonotonicClock.now(), + LogLevel.INFO, + "Compiling 42 source files", + "compiler", + null, + "[INFO] Compiling 42 source files")); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"log\"")); + assertTrue(json.contains("\"level\":\"INFO\"")); + assertTrue(json.contains("\"module\":\"my-core\"")); + assertTrue(json.contains("\"message\":\"Compiling 42 source files\"")); + } + + @Test + void testExecutionFailureEmitsJsonLine() { + listener.executionFailure("my-core", true, "Compilation failed"); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"execution.failure\"")); + assertTrue(json.contains("\"module\":\"my-core\"")); + assertTrue(json.contains("\"halted\":true")); + assertTrue(json.contains("\"error\":\"Compilation failed\"")); + } + + @Test + void testTransferStartedEmitsJsonLine() { + TransferResource resource = mock(TransferResource.class); + when(resource.getResourceName()).thenReturn("org/apache/maven/core/4.1.0/core-4.1.0.jar"); + when(resource.getContentLength()).thenReturn(524288L); + + TransferEvent event = mock(TransferEvent.class); + when(event.getType()).thenReturn(TransferEvent.EventType.STARTED); + when(event.getResource()).thenReturn(resource); + + listener.transfer("my-core", event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"transfer.started\"")); + assertTrue(json.contains("\"artifact\":\"core-4.1.0.jar\"")); + assertTrue(json.contains("\"size\":524288")); + assertTrue(json.contains("\"module\":\"my-core\"")); + } + + @Test + void testTransferProgressedEmitsJsonLine() { + TransferResource resource = mock(TransferResource.class); + when(resource.getResourceName()).thenReturn("org/apache/maven/core/4.1.0/core-4.1.0.jar"); + when(resource.getContentLength()).thenReturn(524288L); + + TransferEvent event = mock(TransferEvent.class); + when(event.getType()).thenReturn(TransferEvent.EventType.PROGRESSED); + when(event.getResource()).thenReturn(resource); + when(event.getTransferredBytes()).thenReturn(262144L); + + listener.transfer("my-core", event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"transfer.progressed\"")); + assertTrue(json.contains("\"transferred\":262144")); + assertTrue(json.contains("\"total\":524288")); + } + + @Test + void testTransferCompletedEmitsJsonLine() { + TransferResource resource = mock(TransferResource.class); + when(resource.getResourceName()).thenReturn("org/apache/maven/core/4.1.0/core-4.1.0.jar"); + when(resource.getContentLength()).thenReturn(524288L); + + TransferEvent event = mock(TransferEvent.class); + when(event.getType()).thenReturn(TransferEvent.EventType.SUCCEEDED); + when(event.getResource()).thenReturn(resource); + when(event.getTransferredBytes()).thenReturn(524288L); + + listener.transfer("my-core", event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"transfer.completed\"")); + assertTrue(json.contains("\"transferred\":524288")); + } + + @Test + void testTransferFailedEmitsJsonLine() { + TransferResource resource = mock(TransferResource.class); + when(resource.getResourceName()).thenReturn("org/apache/maven/core/4.1.0/core-4.1.0.jar"); + when(resource.getContentLength()).thenReturn(524288L); + + TransferEvent event = mock(TransferEvent.class); + when(event.getType()).thenReturn(TransferEvent.EventType.FAILED); + when(event.getResource()).thenReturn(resource); + when(event.getException()).thenReturn(new RuntimeException("Connection timed out")); + + listener.transfer("my-core", event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"transfer.failed\"")); + assertTrue(json.contains("\"error\":\"Connection timed out\"")); + } + + @Test + void testJsonEscapingInMessages() { + listener.log("Message with \"quotes\" and \\backslash and\nnewline"); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + // Verify the JSON is properly escaped + assertTrue(json.contains("\\\"quotes\\\""), "Quotes should be escaped"); + assertTrue(json.contains("\\\\backslash"), "Backslash should be escaped"); + assertTrue(json.contains("\\n"), "Newline should be escaped"); + // Verify it's parseable as a single line (no raw newlines) + assertFalse(json.contains("\n"), "JSON line should not contain raw newlines"); + } + + @Test + void testEmitEventIsSynchronized() throws Exception { + // Verify that concurrent calls to emitEvent don't interleave + List output = new ArrayList<>(); + MachineBuildEventListener concurrentListener = new MachineBuildEventListener(output::add); + + Thread t1 = new Thread(() -> { + for (int i = 0; i < 100; i++) { + concurrentListener.log("Thread1-" + i); + } + }); + Thread t2 = new Thread(() -> { + for (int i = 0; i < 100; i++) { + concurrentListener.log("Thread2-" + i); + } + }); + + t1.start(); + t2.start(); + t1.join(); + t2.join(); + + assertEquals(200, output.size(), "All events should be emitted"); + // Each line should be a complete JSON object + for (String line : output) { + assertTrue(line.startsWith("{") && line.endsWith("}"), "Each line should be a complete JSON object"); + } + } + + @Test + void testNoOpMethods() throws Exception { + // These should not produce any output (handled by MachineExecutionEventLogger) + listener.sessionStarted(null); + listener.projectStarted("my-core"); + listener.projectFinished("my-core"); + listener.mojoStarted(null); + listener.finish(0); + listener.fail(new RuntimeException("error")); + + assertEquals(0, capturedOutput.size(), "No-op methods should not produce output"); + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineExecutionEventLoggerTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineExecutionEventLoggerTest.java new file mode 100644 index 000000000000..825f81aa3af0 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineExecutionEventLoggerTest.java @@ -0,0 +1,445 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.event; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.DefaultMavenExecutionRequest; +import org.apache.maven.execution.DefaultMavenExecutionResult; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.plugin.MojoExecution; +import org.apache.maven.project.MavenProject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.MockitoSession; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link MachineExecutionEventLogger}. + */ +class MachineExecutionEventLoggerTest { + + private MockitoSession mockitoSession; + private List capturedOutput; + private MachineBuildEventListener machineBel; + private MachineExecutionEventLogger logger; + + @BeforeEach + void beforeEach() { + mockitoSession = Mockito.mockitoSession().startMocking(); + capturedOutput = new ArrayList<>(); + machineBel = new MachineBuildEventListener(capturedOutput::add); + logger = new MachineExecutionEventLogger(machineBel); + } + + @AfterEach + void afterEach() { + mockitoSession.finishMocking(); + } + + @Test + void testSessionStartedEmitsBuildStarted() { + MavenProject project1 = createProject("API", "api"); + MavenProject project2 = createProject("Core", "core"); + + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setGoals(List.of("clean", "install")); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project1, project2)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2)); + when(session.getRequest()).thenReturn(request); + + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getSession()).thenReturn(session); + + logger.sessionStarted(event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"build.started\"")); + assertTrue(json.contains("\"projectCount\":2")); + assertTrue(json.contains("\"goals\":\"clean install\"")); + } + + @Test + void testSessionEndedEmitsBuildFinished() { + MavenProject project1 = createProject("API", "api"); + MavenProject project2 = createProject("Core", "core"); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + result.addBuildSummary(new BuildSuccess(project1, 1000)); + result.addBuildSummary(new BuildSuccess(project2, 2000)); + + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setGoals(List.of("install")); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project1, project2)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2)); + when(session.getResult()).thenReturn(result); + when(session.getRequest()).thenReturn(request); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + logger.sessionStarted(sessionEvent); + capturedOutput.clear(); + + logger.sessionEnded(sessionEvent); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"build.finished\"")); + assertTrue(json.contains("\"status\":\"SUCCESS\"")); + assertTrue(json.contains("\"total\":2")); + assertTrue(json.contains("\"passed\":2")); + assertTrue(json.contains("\"failed\":0")); + assertTrue(json.contains("\"skipped\":0")); + assertTrue(json.contains("\"duration\":")); + } + + @Test + void testSessionEndedWithFailures() { + MavenProject project1 = createProject("API", "api"); + MavenProject project2 = createProject("Core", "core"); + MavenProject project3 = createProject("CLI", "cli"); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + result.addBuildSummary(new BuildSuccess(project1, 1000)); + result.addBuildSummary(new BuildFailure(project2, 2000, new Exception("Compile error"))); + result.addException(new Exception("Compile error")); + + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setGoals(List.of("install")); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getResult()).thenReturn(result); + when(session.getRequest()).thenReturn(request); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + logger.sessionStarted(sessionEvent); + capturedOutput.clear(); + + logger.sessionEnded(sessionEvent); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"status\":\"FAILURE\"")); + assertTrue(json.contains("\"passed\":1")); + assertTrue(json.contains("\"failed\":1")); + assertTrue(json.contains("\"skipped\":1")); + } + + @Test + void testProjectStartedEmitsModuleStarted() { + MavenProject project = createProject("Maven Core", "maven-core"); + setupSessionForProjectEvents(project); + + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + + logger.projectStarted(event); + + // build.started + module.started + assertEquals(2, capturedOutput.size()); + String json = capturedOutput.get(1); + assertTrue(json.contains("\"event\":\"module.started\"")); + assertTrue(json.contains("\"module\":\"Maven Core\"")); + assertTrue(json.contains("\"groupId\":\"org.apache.maven\"")); + assertTrue(json.contains("\"artifactId\":\"maven-core\"")); + assertTrue(json.contains("\"version\":\"4.1.0-SNAPSHOT\"")); + assertTrue(json.contains("\"index\":1")); + assertTrue(json.contains("\"total\":1")); + } + + @Test + void testProjectSucceededEmitsModuleSucceeded() { + MavenProject project = createProject("Core", "core"); + MavenSession session = setupSessionForProjectEvents(project); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + result.addBuildSummary(new BuildSuccess(project, 2100)); + when(session.getResult()).thenReturn(result); + + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + when(event.getSession()).thenReturn(session); + + logger.projectSucceeded(event); + + // build.started + module.succeeded + assertEquals(2, capturedOutput.size()); + String json = capturedOutput.get(1); + assertTrue(json.contains("\"event\":\"module.succeeded\"")); + assertTrue(json.contains("\"module\":\"Core\"")); + assertTrue(json.contains("\"duration\":2.1")); + } + + @Test + void testProjectFailedEmitsModuleFailed() { + MavenProject project = createProject("Core", "core"); + MavenSession session = setupSessionForProjectEvents(project); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + result.addBuildSummary(new BuildFailure(project, 5000, new Exception("Compile error"))); + when(session.getResult()).thenReturn(result); + + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + when(event.getSession()).thenReturn(session); + when(event.getException()).thenReturn(new Exception("Compile error")); + + logger.projectFailed(event); + + assertEquals(2, capturedOutput.size()); + String json = capturedOutput.get(1); + assertTrue(json.contains("\"event\":\"module.failed\"")); + assertTrue(json.contains("\"duration\":5.0")); + assertTrue(json.contains("\"error\":\"Compile error\"")); + } + + @Test + void testMojoStartedEmitsJsonLine() { + MavenProject project = createProject("Core", "core"); + MojoExecution mojo = createMojoExecution("maven-compiler-plugin", "compile", "compile", "default-compile"); + + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + when(event.getMojoExecution()).thenReturn(mojo); + + logger.mojoStarted(event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"mojo.started\"")); + assertTrue(json.contains("\"module\":\"Core\"")); + assertTrue(json.contains("\"plugin\":\"maven-compiler-plugin\"")); + assertTrue(json.contains("\"goal\":\"compile\"")); + assertTrue(json.contains("\"phase\":\"compile\"")); + assertTrue(json.contains("\"executionId\":\"default-compile\"")); + } + + @Test + void testMojoSucceededIncludesDuration() { + MavenProject project = createProject("Core", "core"); + MojoExecution mojo = createMojoExecution("maven-compiler-plugin", "compile", "compile", "default-compile"); + + ExecutionEvent startEvent = mock(ExecutionEvent.class); + when(startEvent.getProject()).thenReturn(project); + when(startEvent.getMojoExecution()).thenReturn(mojo); + + ExecutionEvent endEvent = mock(ExecutionEvent.class); + when(endEvent.getProject()).thenReturn(project); + when(endEvent.getMojoExecution()).thenReturn(mojo); + + logger.mojoStarted(startEvent); + capturedOutput.clear(); + + logger.mojoSucceeded(endEvent); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"mojo.succeeded\"")); + assertTrue(json.contains("\"duration\":")); + } + + @Test + void testMojoFailedIncludesError() { + MavenProject project = createProject("Core", "core"); + MojoExecution mojo = createMojoExecution("maven-compiler-plugin", "compile", "compile", "default-compile"); + + ExecutionEvent startEvent = mock(ExecutionEvent.class); + when(startEvent.getProject()).thenReturn(project); + when(startEvent.getMojoExecution()).thenReturn(mojo); + + ExecutionEvent endEvent = mock(ExecutionEvent.class); + when(endEvent.getProject()).thenReturn(project); + when(endEvent.getMojoExecution()).thenReturn(mojo); + when(endEvent.getException()).thenReturn(new Exception("Cannot find symbol: class Foo")); + + logger.mojoStarted(startEvent); + capturedOutput.clear(); + + logger.mojoFailed(endEvent); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"mojo.failed\"")); + assertTrue(json.contains("\"error\":\"Cannot find symbol: class Foo\"")); + } + + @Test + void testMojoSkippedEmitsJsonLine() { + MavenProject project = createProject("Core", "core"); + MojoExecution mojo = createMojoExecution("maven-deploy-plugin", "deploy", "deploy", "default-deploy"); + + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + when(event.getMojoExecution()).thenReturn(mojo); + + logger.mojoSkipped(event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"mojo.skipped\"")); + assertTrue(json.contains("\"plugin\":\"maven-deploy-plugin\"")); + assertTrue(json.contains("\"goal\":\"deploy\"")); + } + + @Test + void testMultiModuleLifecycle() { + MavenProject project1 = createProject("API", "api"); + MavenProject project2 = createProject("Core", "core"); + MavenProject project3 = createProject("CLI", "cli"); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + result.addBuildSummary(new BuildSuccess(project1, 1000)); + result.addBuildSummary(new BuildSuccess(project2, 3000)); + result.addBuildSummary(new BuildSuccess(project3, 2000)); + + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setGoals(List.of("install")); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getResult()).thenReturn(result); + when(session.getRequest()).thenReturn(request); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + // Full lifecycle + logger.sessionStarted(sessionEvent); + + for (MavenProject p : List.of(project1, project2, project3)) { + ExecutionEvent pe = mock(ExecutionEvent.class); + when(pe.getProject()).thenReturn(p); + when(pe.getSession()).thenReturn(session); + logger.projectStarted(pe); + logger.projectSucceeded(pe); + } + + logger.sessionEnded(sessionEvent); + + // build.started + 3*(module.started + module.succeeded) + build.finished = 8 + assertEquals(8, capturedOutput.size()); + + // First event is build.started + assertTrue(capturedOutput.get(0).contains("\"event\":\"build.started\"")); + // Check module indices + assertTrue(capturedOutput.get(1).contains("\"index\":1")); + assertTrue(capturedOutput.get(3).contains("\"index\":2")); + assertTrue(capturedOutput.get(5).contains("\"index\":3")); + // Last event is build.finished + assertTrue(capturedOutput.get(7).contains("\"event\":\"build.finished\"")); + } + + @Test + void testAllOutputIsValidJsonLines() { + MavenProject project = createProject("Core", "core"); + MavenSession session = setupSessionForProjectEvents(project); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + result.addBuildSummary(new BuildSuccess(project, 1000)); + when(session.getResult()).thenReturn(result); + + MojoExecution mojo = createMojoExecution("maven-compiler-plugin", "compile", "compile", "default-compile"); + + ExecutionEvent projectEvent = mock(ExecutionEvent.class); + when(projectEvent.getProject()).thenReturn(project); + when(projectEvent.getSession()).thenReturn(session); + when(projectEvent.getMojoExecution()).thenReturn(mojo); + + // Generate various events + logger.projectStarted(projectEvent); + logger.mojoStarted(projectEvent); + machineBel.log("Some log message"); + logger.mojoSucceeded(projectEvent); + logger.projectSucceeded(projectEvent); + + // All lines should be valid JSON (start with { and end with }) + for (String line : capturedOutput) { + assertTrue(line.startsWith("{"), "Should start with {: " + line); + assertTrue(line.endsWith("}"), "Should end with }: " + line); + // Should not contain raw newlines + assertFalse(line.contains("\n"), "Should not contain raw newlines: " + line); + assertFalse(line.contains("\r"), "Should not contain raw carriage returns: " + line); + } + } + + // ---- Helpers ---- + + private static MavenProject createProject(String name, String artifactId) { + MavenProject project = mock(MavenProject.class); + lenient().when(project.getName()).thenReturn(name); + lenient().when(project.getArtifactId()).thenReturn(artifactId); + lenient().when(project.getGroupId()).thenReturn("org.apache.maven"); + lenient().when(project.getVersion()).thenReturn("4.1.0-SNAPSHOT"); + lenient().when(project.getPackaging()).thenReturn("jar"); + return project; + } + + private static MojoExecution createMojoExecution(String artifactId, String goal, String phase, String executionId) { + MojoExecution mojo = mock(MojoExecution.class); + lenient().when(mojo.getArtifactId()).thenReturn(artifactId); + lenient().when(mojo.getGoal()).thenReturn(goal); + lenient().when(mojo.getLifecyclePhase()).thenReturn(phase); + lenient().when(mojo.getExecutionId()).thenReturn(executionId); + return mojo; + } + + private MavenSession setupSessionForProjectEvents(MavenProject project) { + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setGoals(List.of("install")); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + lenient().when(session.getResult()).thenReturn(result); + when(session.getRequest()).thenReturn(request); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + logger.sessionStarted(sessionEvent); + + return session; + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/event/PlainExecutionEventLoggerTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/PlainExecutionEventLoggerTest.java new file mode 100644 index 000000000000..4503ba4e2615 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/PlainExecutionEventLoggerTest.java @@ -0,0 +1,317 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.event; + +import java.util.List; + +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.DefaultMavenExecutionRequest; +import org.apache.maven.execution.DefaultMavenExecutionResult; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.jline.JLineMessageBuilderFactory; +import org.apache.maven.jline.MessageUtils; +import org.apache.maven.project.MavenProject; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.mockito.Mockito; +import org.mockito.MockitoSession; +import org.slf4j.Logger; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.matches; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link PlainExecutionEventLogger} — the compact one-line-per-module renderer. + */ +class PlainExecutionEventLoggerTest { + + private MockitoSession mockitoSession; + + private Logger logger; + private PlainExecutionEventLogger plainLogger; + private final JLineMessageBuilderFactory messageBuilderFactory = new JLineMessageBuilderFactory(); + + @BeforeAll + static void setUp() { + MessageUtils.setColorEnabled(false); + } + + @AfterAll + static void tearDown() { + MessageUtils.setColorEnabled(true); + } + + @BeforeEach + void beforeEach() { + mockitoSession = Mockito.mockitoSession().startMocking(); + logger = mock(Logger.class); + lenient().when(logger.isInfoEnabled()).thenReturn(true); + lenient().when(logger.isWarnEnabled()).thenReturn(true); + plainLogger = new PlainExecutionEventLogger(messageBuilderFactory, logger); + } + + @AfterEach + void afterEach() { + mockitoSession.finishMocking(); + } + + @Test + void testProjectStartedSuppressed() { + // In plain mode, projectStarted should produce NO output + ExecutionEvent event = mock(ExecutionEvent.class); + + plainLogger.projectStarted(event); + + // Verify no logger calls were made + verify(logger, never()).info(anyString()); + } + + @Test + void testMojoStartedSuppressed() { + // In plain mode, mojoStarted should produce NO output + ExecutionEvent event = mock(ExecutionEvent.class); + + plainLogger.mojoStarted(event); + + verify(logger, never()).info(anyString()); + } + + @Test + void testSingleProjectSucceeded() { + MavenProject project = generateMavenProject("Maven Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project, 2100)); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + ExecutionEvent projectEvent = mock(ExecutionEvent.class); + when(projectEvent.getProject()).thenReturn(project); + when(projectEvent.getSession()).thenReturn(session); + + plainLogger.sessionStarted(sessionEvent); + plainLogger.projectSucceeded(projectEvent); + + // Single project: no progress counter + verify(logger).info(matches("Maven Core.*SUCCESS.*2\\.1")); + } + + @Test + void testMultiModuleProjectOneLinePerModule() { + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + MavenProject project3 = generateMavenProject("CLI"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project1, 1000)); + executionResult.addBuildSummary(new BuildSuccess(project2, 3000)); + executionResult.addBuildSummary(new BuildSuccess(project3, 2000)); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + plainLogger.sessionStarted(sessionEvent); + + // Simulate lifecycle + ExecutionEvent event1 = mockProjectEvent(project1, session); + ExecutionEvent event2 = mockProjectEvent(project2, session); + ExecutionEvent event3 = mockProjectEvent(project3, session); + + plainLogger.projectSucceeded(event1); + plainLogger.projectSucceeded(event2); + plainLogger.projectSucceeded(event3); + + // Multi-module: each line has progress counter [n/total] + InOrder inOrder = inOrder(logger); + inOrder.verify(logger).info(matches("API.*\\[1/3\\].*SUCCESS.*1\\.0")); + inOrder.verify(logger).info(matches("Core.*\\[2/3\\].*SUCCESS.*3\\.0")); + inOrder.verify(logger).info(matches("CLI.*\\[3/3\\].*SUCCESS.*2\\.0")); + } + + @Test + void testProjectFailedShowsFailure() { + MavenProject project = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildFailure(project, 5000, new Exception("Compile error"))); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + plainLogger.sessionStarted(sessionEvent); + + ExecutionEvent event = mockProjectEvent(project, session); + plainLogger.projectFailed(event); + + verify(logger).info(matches("Core.*FAILURE.*5\\.0")); + } + + @Test + void testSessionEndedShowsSummary() { + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project1, 1000)); + executionResult.addBuildSummary(new BuildSuccess(project2, 2000)); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + when(session.getProjects()).thenReturn(List.of(project1, project2)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2)); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + plainLogger.sessionStarted(sessionEvent); + plainLogger.sessionEnded(sessionEvent); + + // Verify BUILD SUCCESS and stats + InOrder inOrder = inOrder(logger); + inOrder.verify(logger).info(""); + inOrder.verify(logger).info("BUILD SUCCESS"); + inOrder.verify(logger).info(matches("2 modules.*2 passed")); + inOrder.verify(logger).info(eq("Total time: {}{}"), anyString(), anyString()); + inOrder.verify(logger).info("Full report: target/build-reports/build-report-latest.json"); + } + + @Test + void testSessionEndedWithFailures() { + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + MavenProject project3 = generateMavenProject("CLI"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project1, 1000)); + executionResult.addBuildSummary(new BuildFailure(project2, 2000, new Exception("Error"))); + executionResult.addException(new Exception("Error")); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + when(session.getProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + plainLogger.sessionStarted(sessionEvent); + plainLogger.sessionEnded(sessionEvent); + + InOrder inOrder = inOrder(logger); + inOrder.verify(logger).info(""); + inOrder.verify(logger).info("BUILD FAILURE"); + inOrder.verify(logger).info(matches("3 modules.*1 passed.*1 failed.*1 skipped")); + } + + @Test + void testMojoSkippedStillWarns() { + ExecutionEvent event = mock(ExecutionEvent.class); + var mojoExec = mock(org.apache.maven.plugin.MojoExecution.class); + when(mojoExec.getGoal()).thenReturn("deploy"); + when(event.getMojoExecution()).thenReturn(mojoExec); + + plainLogger.mojoSkipped(event); + + verify(logger).warn(anyString(), eq("deploy")); + } + + @Test + void testResumeFromProgress() { + // When resuming, allProjects > projects (some already built) + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + MavenProject project3 = generateMavenProject("CLI"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project2, 1000)); + executionResult.addBuildSummary(new BuildSuccess(project3, 2000)); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project2, project3)); // resumed from project2 + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + plainLogger.sessionStarted(sessionEvent); + + ExecutionEvent event2 = mockProjectEvent(project2, session); + ExecutionEvent event3 = mockProjectEvent(project3, session); + + plainLogger.projectSucceeded(event2); + plainLogger.projectSucceeded(event3); + + // Progress should start from 2/3, not 1/3 + InOrder inOrder = inOrder(logger); + inOrder.verify(logger).info(matches("Core.*\\[2/3\\].*SUCCESS")); + inOrder.verify(logger).info(matches("CLI.*\\[3/3\\].*SUCCESS")); + } + + // ---- Helpers ---- + + private static MavenProject generateMavenProject(String projectName) { + MavenProject project = mock(MavenProject.class); + lenient().when(project.getPackaging()).thenReturn("jar"); + lenient().when(project.getVersion()).thenReturn("4.1.0-SNAPSHOT"); + lenient().when(project.getName()).thenReturn(projectName); + return project; + } + + private static ExecutionEvent mockProjectEvent(MavenProject project, MavenSession session) { + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + when(event.getSession()).thenReturn(session); + return event; + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichBuildEventListenerTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichBuildEventListenerTest.java new file mode 100644 index 000000000000..5548dd53fa60 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichBuildEventListenerTest.java @@ -0,0 +1,291 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.event; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.List; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.LogLevel; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.internal.build.DefaultLogEvent; +import org.apache.maven.project.MavenProject; +import org.eclipse.aether.transfer.TransferEvent; +import org.eclipse.aether.transfer.TransferResource; +import org.jline.terminal.Size; +import org.jline.terminal.Terminal; +import org.jline.terminal.impl.DumbTerminal; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.MockitoSession; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link RichBuildEventListener}. + */ +class RichBuildEventListenerTest { + + private MockitoSession mockitoSession; + private Terminal terminal; + private ByteArrayOutputStream terminalOutput; + private RichBuildEventListener listener; + + @BeforeEach + void beforeEach() throws Exception { + mockitoSession = Mockito.mockitoSession().startMocking(); + terminalOutput = new ByteArrayOutputStream(); + // DumbTerminal: supported=false (fallback mode), output goes to terminalOutput + terminal = new DumbTerminal(new ByteArrayInputStream(new byte[0]), terminalOutput); + terminal.setSize(new Size(120, 40)); + listener = new RichBuildEventListener(terminal, msg -> {}); + } + + @AfterEach + void afterEach() throws Exception { + terminal.close(); + mockitoSession.finishMocking(); + } + + @Test + void testProjectStartedAndFinished() { + MavenSession session = + createSession(List.of(createProject("api"), createProject("core"), createProject("cli"))); + + listener.initReactor(session); + + listener.projectStarted("api"); + listener.projectFinished("api"); + } + + @Test + void testLogMessagePassthrough() { + listener.log("Test log message"); + String output = terminalOutput.toString(); + assertTrue(output.contains("Test log message"), "Expected log message in output: " + output); + } + + @Test + void testProjectLogMessageFiltersInfo() { + LogEvent infoEvent = new DefaultLogEvent( + MonotonicClock.now(), + LogLevel.INFO, + "Compiling 42 source files", + "compiler", + null, + "[INFO] Compiling 42 source files"); + listener.projectLogMessage("api", infoEvent); + String output = terminalOutput.toString(); + assertFalse( + output.contains("Compiling 42 source files"), + "INFO messages should be suppressed in rich mode: " + output); + } + + @Test + void testProjectLogMessageSuppressesWarningInline() { + // In rich mode, warnings are suppressed inline and only counted for the + // end-of-build summary — they don't scroll above the status bar. + LogEvent warnEvent = new DefaultLogEvent( + MonotonicClock.now(), + LogLevel.WARN, + "Deprecated API usage", + "compiler", + null, + "[WARNING] Deprecated API usage"); + listener.projectLogMessage("api", warnEvent); + String output = terminalOutput.toString(); + assertFalse( + output.contains("[WARNING] Deprecated API usage"), + "WARN messages should be suppressed inline in rich mode: " + output); + assertEquals(1, listener.getWarningCount(), "Warning count should be tracked"); + } + + @Test + void testProjectLogMessageShowsError() { + LogEvent errorEvent = new DefaultLogEvent( + MonotonicClock.now(), + LogLevel.ERROR, + "Compilation failure", + "compiler", + null, + "[ERROR] Compilation failure"); + listener.projectLogMessage("api", errorEvent); + String output = terminalOutput.toString(); + assertTrue(output.contains("[ERROR] Compilation failure"), "ERROR messages should pass through: " + output); + } + + @Test + void testMojoStartedUpdatesState() { + MavenSession session = createSession(List.of(createProject("api"), createProject("core"))); + listener.initReactor(session); + + listener.projectStarted("api"); + + ExecutionEvent mojoEvent = mock(ExecutionEvent.class); + MavenProject project = createProject("api"); + when(mojoEvent.getProject()).thenReturn(project); + var mojoExec = mock(org.apache.maven.plugin.MojoExecution.class); + when(mojoExec.getArtifactId()).thenReturn("maven-compiler-plugin"); + when(mojoExec.getGoal()).thenReturn("compile"); + when(mojoEvent.getMojoExecution()).thenReturn(mojoExec); + + listener.mojoStarted(mojoEvent); + + listener.projectFinished("api"); + } + + @Test + void testTransferEvents() { + MavenSession session = createSession(List.of(createProject("api"))); + listener.initReactor(session); + + TransferResource resource = mock(TransferResource.class); + when(resource.getResourceName()).thenReturn("org/apache/maven/core/4.1.0/core-4.1.0.jar"); + when(resource.getContentLength()).thenReturn(524288L); + + TransferEvent startEvent = mock(TransferEvent.class); + when(startEvent.getType()).thenReturn(TransferEvent.EventType.STARTED); + when(startEvent.getResource()).thenReturn(resource); + + listener.transfer("api", startEvent); + + TransferEvent progressEvent = mock(TransferEvent.class); + when(progressEvent.getType()).thenReturn(TransferEvent.EventType.PROGRESSED); + when(progressEvent.getResource()).thenReturn(resource); + when(progressEvent.getTransferredBytes()).thenReturn(262144L); + + listener.transfer("api", progressEvent); + + TransferEvent doneEvent = mock(TransferEvent.class); + when(doneEvent.getType()).thenReturn(TransferEvent.EventType.SUCCEEDED); + when(doneEvent.getResource()).thenReturn(resource); + + listener.transfer("api", doneEvent); + } + + @Test + void testParallelProjects() { + MavenSession session = + createSession(List.of(createProject("api"), createProject("core"), createProject("cli"))); + listener.initReactor(session); + + listener.projectStarted("api"); + listener.projectStarted("core"); + + listener.projectFinished("api"); + + listener.projectStarted("cli"); + + listener.projectFinished("core"); + listener.projectFinished("cli"); + } + + @Test + void testExecutionFailureUpdatesState() { + MavenSession session = createSession(List.of(createProject("api"))); + listener.initReactor(session); + + listener.projectStarted("api"); + listener.executionFailure("api", true, "Compilation error"); + listener.projectFinished("api"); + } + + @Test + void testTearDown() throws Exception { + MavenSession session = createSession(List.of(createProject("api"))); + listener.initReactor(session); + listener.projectStarted("api"); + + listener.tearDown(); + } + + @Test + void testFinishCallsTearDown() throws Exception { + MavenSession session = createSession(List.of(createProject("api"))); + listener.initReactor(session); + + listener.finish(0); + } + + @Test + void testFailCallsTearDown() throws Exception { + MavenSession session = createSession(List.of(createProject("api"))); + listener.initReactor(session); + + listener.fail(new RuntimeException("build error")); + } + + @Test + void testTruncateAnsiPlainText() { + String truncated = RichBuildEventListener.truncateAnsi("hello world", 5); + // When truncated, a RESET escape is appended to close any open styling + assertTrue(truncated.startsWith("hello"), "Should start with 'hello': " + truncated); + // Should not contain characters beyond "hello" (except ANSI reset) + String stripped = truncated.replaceAll("\033\\[[^a-zA-Z]*[a-zA-Z]", ""); + assertEquals("hello", stripped); + } + + @Test + void testTruncateAnsiPreservesEscapeSequences() { + // ANSI color codes should not count toward visible length + String colored = "\033[1mhello\033[0m world"; + String truncated = RichBuildEventListener.truncateAnsi(colored, 5); + // Should keep "hello" (5 visible chars) with the bold prefix + assertTrue(truncated.contains("hello"), "Truncated should contain 'hello': " + truncated); + assertTrue(truncated.contains("\033[1m"), "Truncated should preserve ANSI prefix"); + } + + @Test + void testTruncateAnsiNoTruncationNeeded() { + String s = "short"; + assertEquals(s, RichBuildEventListener.truncateAnsi(s, 100)); + } + + // ---- Helpers ---- + + private static MavenProject createProject(String artifactId) { + MavenProject project = mock(MavenProject.class); + lenient().when(project.getArtifactId()).thenReturn(artifactId); + lenient().when(project.getName()).thenReturn(artifactId); + lenient().when(project.getPackaging()).thenReturn("jar"); + lenient().when(project.getVersion()).thenReturn("4.1.0-SNAPSHOT"); + return project; + } + + private static MavenSession createSession(List projects) { + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(projects); + when(session.getAllProjects()).thenReturn(projects); + MavenExecutionRequest request = mock(MavenExecutionRequest.class); + lenient().when(request.getDegreeOfConcurrency()).thenReturn(1); + lenient().when(session.getRequest()).thenReturn(request); + return session; + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichExecutionEventLoggerTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichExecutionEventLoggerTest.java new file mode 100644 index 000000000000..762d71548430 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichExecutionEventLoggerTest.java @@ -0,0 +1,424 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.event; + +import java.io.ByteArrayOutputStream; +import java.time.Instant; +import java.util.List; + +import org.apache.maven.api.build.report.LogLevel; +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.DefaultMavenExecutionRequest; +import org.apache.maven.execution.DefaultMavenExecutionResult; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.internal.build.DefaultLogEvent; +import org.apache.maven.jline.JLineMessageBuilderFactory; +import org.apache.maven.jline.MessageUtils; +import org.apache.maven.project.MavenProject; +import org.jline.terminal.Size; +import org.jline.terminal.Terminal; +import org.jline.terminal.impl.DumbTerminal; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.MockitoSession; +import org.slf4j.Logger; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link RichExecutionEventLogger}. + *

+ * In rich mode, all output goes directly to the terminal writer via + * {@link RichBuildEventListener#log(String)}, bypassing SLF4J entirely. + * Tests capture the terminal output to verify content. + */ +class RichExecutionEventLoggerTest { + + private MockitoSession mockitoSession; + private Logger logger; + private Terminal terminal; + private ByteArrayOutputStream terminalOutput; + private RichBuildEventListener buildEventListener; + private RichExecutionEventLogger richLogger; + private final JLineMessageBuilderFactory messageBuilderFactory = new JLineMessageBuilderFactory(); + + @BeforeAll + static void setUp() { + MessageUtils.setColorEnabled(false); + } + + @AfterAll + static void tearDown() { + MessageUtils.setColorEnabled(true); + } + + @BeforeEach + void beforeEach() throws Exception { + mockitoSession = Mockito.mockitoSession().startMocking(); + logger = mock(Logger.class); + lenient().when(logger.isInfoEnabled()).thenReturn(true); + lenient().when(logger.isWarnEnabled()).thenReturn(true); + terminalOutput = new ByteArrayOutputStream(); + terminal = new DumbTerminal(System.in, terminalOutput); + terminal.setSize(new Size(120, 40)); + buildEventListener = new RichBuildEventListener(terminal, msg -> {}); + richLogger = new RichExecutionEventLogger(messageBuilderFactory, buildEventListener, logger); + } + + @AfterEach + void afterEach() throws Exception { + terminal.close(); + mockitoSession.finishMocking(); + } + + @Test + void testProjectStartedSuppressed() { + // In rich mode, projectStarted should produce NO output (status bar handles it) + ExecutionEvent event = mock(ExecutionEvent.class); + + richLogger.projectStarted(event); + + verify(logger, never()).info(anyString()); + assertTrue(terminalOutput.toString().isEmpty(), "No terminal output expected"); + } + + @Test + void testMojoStartedSuppressed() { + // In rich mode, mojoStarted should produce NO output (status bar handles it) + ExecutionEvent event = mock(ExecutionEvent.class); + + richLogger.mojoStarted(event); + + verify(logger, never()).info(anyString()); + assertTrue(terminalOutput.toString().isEmpty(), "No terminal output expected"); + } + + @Test + void testProjectSucceededSuppressed() { + // In rich mode, projectSucceeded produces NO scrolling output — + // the status bar checkmarks already indicate completion. + MavenProject project = generateMavenProject("Maven Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project, 2100)); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + lenient().when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + richLogger.sessionStarted(sessionEvent); + + // Clear terminal output accumulated from sessionStarted (status bar init) + terminalOutput.reset(); + + ExecutionEvent projectEvent = mock(ExecutionEvent.class); + lenient().when(projectEvent.getProject()).thenReturn(project); + lenient().when(projectEvent.getSession()).thenReturn(session); + + richLogger.projectSucceeded(projectEvent); + + // No output — SUCCESS lines are suppressed in rich mode + String output = terminalOutput.toString(); + assertFalse(output.contains("SUCCESS"), "SUCCESS line should be suppressed in rich mode"); + } + + @Test + void testProjectFailedShowsCross() { + MavenProject project = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildFailure(project, 5000, new Exception("Compile error"))); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + richLogger.sessionStarted(sessionEvent); + + ExecutionEvent event = mockProjectEvent(project, session); + richLogger.projectFailed(event); + + String output = terminalOutput.toString(); + assertTrue(output.contains("Core"), "Should contain project name"); + assertTrue(output.contains("FAILURE"), "Should contain FAILURE status"); + } + + @Test + void testMultiModuleSuccessSuppressed() { + // In rich mode, per-module SUCCESS lines are suppressed — the status bar + // already shows ✓/●/○ indicators and the [n/total] counter. + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + MavenProject project3 = generateMavenProject("CLI"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project1, 1000)); + executionResult.addBuildSummary(new BuildSuccess(project2, 3000)); + executionResult.addBuildSummary(new BuildSuccess(project3, 2000)); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + lenient().when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + richLogger.sessionStarted(sessionEvent); + + // Clear terminal output from sessionStarted + terminalOutput.reset(); + + // mockProjectEvent stubs are unused since projectSucceeded is a no-op; + // call with a plain mock to avoid UnnecessaryStubbing errors. + richLogger.projectSucceeded(mock(ExecutionEvent.class)); + richLogger.projectSucceeded(mock(ExecutionEvent.class)); + richLogger.projectSucceeded(mock(ExecutionEvent.class)); + + String output = terminalOutput.toString(); + // No per-module SUCCESS lines should appear + assertFalse(output.contains("SUCCESS"), "SUCCESS lines should be suppressed in rich mode"); + assertFalse(output.contains("[1/3]"), "Progress counters should not appear"); + } + + @Test + void testSessionEndedShowsSummary() { + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project1, 1000)); + executionResult.addBuildSummary(new BuildSuccess(project2, 2000)); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + when(session.getProjects()).thenReturn(List.of(project1, project2)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2)); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + richLogger.sessionStarted(sessionEvent); + richLogger.sessionEnded(sessionEvent); + + // Summary goes to terminal writer, not logger + String output = terminalOutput.toString(); + assertTrue(output.contains("BUILD SUCCESS"), "Should contain BUILD SUCCESS"); + assertTrue(output.contains("2 modules"), "Should contain module count"); + assertTrue(output.contains("2 passed"), "Should contain passed count"); + assertTrue(output.contains("Total time:"), "Should contain total time"); + assertTrue( + output.contains("Full report: target/build-reports/build-report-latest.json"), + "Should contain report path"); + // MNG-7372: version info only on failure, not success + assertFalse(output.contains("Maven:"), "Should NOT contain Maven version on success"); + } + + @Test + void testSessionEndedWithFailures() { + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + MavenProject project3 = generateMavenProject("CLI"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project1, 1000)); + executionResult.addBuildSummary(new BuildFailure(project2, 2000, new Exception("Error"))); + executionResult.addException(new Exception("Error")); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + when(session.getProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + richLogger.sessionStarted(sessionEvent); + richLogger.sessionEnded(sessionEvent); + + String output = terminalOutput.toString(); + assertTrue(output.contains("BUILD FAILURE"), "Should contain BUILD FAILURE"); + assertTrue(output.contains("3 modules"), "Should contain module count"); + assertTrue(output.contains("1 passed"), "Should contain passed count"); + assertTrue(output.contains("1 failed"), "Should contain failed count"); + assertTrue(output.contains("1 skipped"), "Should contain skipped count"); + // MNG-7372: version info shown on failure + assertTrue(output.contains("Maven:"), "Should contain Maven version on failure"); + assertTrue(output.contains("Java:"), "Should contain Java version on failure"); + } + + @Test + void testMojoSkippedStillWarns() { + // mojoSkipped still uses logger.warn (goes through SLF4J for WARN level) + ExecutionEvent event = mock(ExecutionEvent.class); + var mojoExec = mock(org.apache.maven.plugin.MojoExecution.class); + when(mojoExec.getGoal()).thenReturn("deploy"); + when(event.getMojoExecution()).thenReturn(mojoExec); + + richLogger.mojoSkipped(event); + + verify(logger).warn(anyString(), eq("deploy")); + } + + @Test + void testOutputBypassesSLF4J() { + // Verify that summary output does NOT go through logger.info + MavenProject project = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project, 1000)); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + richLogger.sessionStarted(sessionEvent); + richLogger.sessionEnded(sessionEvent); + + // No logger.info calls — all output goes through terminal writer + verify(logger, never()).info(anyString()); + + // But the output IS in the terminal + String output = terminalOutput.toString(); + assertFalse(output.isEmpty(), "Terminal should have output"); + assertTrue(output.contains("BUILD SUCCESS"), "Terminal should contain BUILD SUCCESS"); + } + + @Test + void testWarningSummaryShown() { + MavenProject project = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project, 1000)); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + richLogger.sessionStarted(sessionEvent); + + // Simulate 3 warnings arriving during the build + Instant now = Instant.now(); + buildEventListener.projectLogMessage( + "core", + new DefaultLogEvent(now, LogLevel.WARN, "unchecked cast", "javac", null, "[WARNING] unchecked cast")); + buildEventListener.projectLogMessage( + "core", + new DefaultLogEvent(now, LogLevel.WARN, "deprecated API", "javac", null, "[WARNING] deprecated API")); + buildEventListener.projectLogMessage( + "core", + new DefaultLogEvent(now, LogLevel.WARN, "unused import", "javac", null, "[WARNING] unused import")); + + richLogger.sessionEnded(sessionEvent); + + String output = terminalOutput.toString(); + assertTrue(output.contains("BUILD SUCCESS"), "Should contain BUILD SUCCESS"); + assertTrue(output.contains("3 warning"), "Should contain warning count"); + assertTrue(output.contains("Diagnostics:"), "Should contain Diagnostics label"); + assertTrue(output.contains("mvnlog"), "Should hint how to see warning details"); + } + + @Test + void testNoWarningSummaryWhenClean() { + MavenProject project = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project, 1000)); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + richLogger.sessionStarted(sessionEvent); + richLogger.sessionEnded(sessionEvent); + + String output = terminalOutput.toString(); + assertTrue(output.contains("BUILD SUCCESS"), "Should contain BUILD SUCCESS"); + assertFalse(output.contains("Diagnostics:"), "Should NOT contain Diagnostics when no warnings"); + } + + // ---- Helpers ---- + + private static MavenProject generateMavenProject(String projectName) { + MavenProject project = mock(MavenProject.class); + lenient() + .when(project.getArtifactId()) + .thenReturn(projectName.toLowerCase().replace(" ", "-")); + lenient().when(project.getPackaging()).thenReturn("jar"); + lenient().when(project.getVersion()).thenReturn("4.1.0-SNAPSHOT"); + lenient().when(project.getName()).thenReturn(projectName); + return project; + } + + private static ExecutionEvent mockProjectEvent(MavenProject project, MavenSession session) { + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + when(event.getSession()).thenReturn(session); + return event; + } +} diff --git a/impl/maven-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 000000000000..992b5f920754 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/BuildReportRendererTest.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.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 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": [] + } + ], + "diagnostics": [ + { + "key": "javac:unchecked", + "severity": "WARNING", + "message": "unchecked cast", + "source": "javac" + } + ], + "failures": [], + "output": [] + }"""; + return SimpleJsonReader.parse(json); + } + + private Map 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": [] + } + ], + "diagnostics": [], + "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 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("unchecked cast"), "Should show actual warning message in default view"); + assertTrue(output.contains("javac"), "Should show warning source in default view"); + assertTrue(output.contains("Total time: 6.700 s"), "Should contain formatted total time"); + } + + @Test + void testRenderSummaryFailure() { + List 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 lines = new ArrayList<>(); + BuildReportRenderer renderer = new BuildReportRenderer(messageBuilderFactory, lines::add); + + renderer.renderDiagnostics(createSuccessReport()); + + String output = String.join("\n", lines); + assertTrue(output.contains("Diagnostics (1)"), "Should contain diagnostics header"); + assertTrue(output.contains("unchecked cast"), "Should contain warning message"); + assertTrue(output.contains("javac"), "Should contain source"); + } + + @Test + void testRenderDiagnosticsWhenEmpty() { + List lines = new ArrayList<>(); + BuildReportRenderer renderer = new BuildReportRenderer(messageBuilderFactory, lines::add); + + renderer.renderDiagnostics(createFailureReport()); + + String output = String.join("\n", lines); + assertTrue(output.contains("No diagnostics recorded"), "Should show empty message"); + } + + @Test + void testRenderFailures() { + List 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 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 000000000000..ab862bc7fb2e --- /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 result = SimpleJsonReader.parse("{}"); + assertTrue(result.isEmpty()); + } + + @Test + void testParseSimpleObject() { + Map result = SimpleJsonReader.parse(""" + {"name": "test", "version": "1.0"}"""); + assertEquals("test", result.get("name")); + assertEquals("1.0", result.get("version")); + } + + @Test + void testParseNumbers() { + Map 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 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 result = SimpleJsonReader.parse(""" + {"goals": ["clean", "install"]}"""); + List goals = (List) result.get("goals"); + assertEquals(2, goals.size()); + assertEquals("clean", goals.get(0)); + assertEquals("install", goals.get(1)); + } + + @Test + @SuppressWarnings("unchecked") + void testParseNestedObject() { + Map result = SimpleJsonReader.parse(""" + {"module": {"artifactId": "core", "status": "SUCCESS"}}"""); + Map module = (Map) result.get("module"); + assertEquals("core", module.get("artifactId")); + assertEquals("SUCCESS", module.get("status")); + } + + @Test + void testParseStringEscapes() { + Map 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": [] + } + ], + "diagnostics": [], + "failures": [] + }"""; + + Map report = SimpleJsonReader.parse(json); + assertEquals("1.0", report.get("formatVersion")); + assertEquals("SUCCESS", report.get("status")); + assertEquals("PT6.7S", report.get("duration")); + + List> modules = (List>) (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-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java new file mode 100644 index 000000000000..7772d4a81b2f --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java @@ -0,0 +1,747 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.internal.build; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.BuildReport; +import org.apache.maven.api.build.report.BuildStatus; +import org.apache.maven.api.build.report.Diagnostic; +import org.apache.maven.api.build.report.DiagnosticSummary; +import org.apache.maven.api.build.report.FailureReport; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.LogLevel; +import org.apache.maven.api.build.report.ModuleReport; +import org.apache.maven.api.build.report.MojoReport; +import org.apache.maven.eventspy.AbstractEventSpy; +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.BuildSummary; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.plugin.MojoExecution; +import org.apache.maven.project.MavenProject; +import org.apache.maven.slf4j.MavenSimpleLogger; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.spi.LocationAwareLogger; + +/** + * Collects build lifecycle events and produces a structured {@link BuildReport} + * at the end of the session. + *

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

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

+ * Log capture: installs a structured {@link MavenSimpleLogger.LogEventSink} + * that receives level, logger name, message, and throwable independently of + * the formatted console output. Uses thread-based tracking to associate events + * with the currently-executing mojo or module. + * + * @since 4.1.0 + */ +@Singleton +@Named +public final class BuildReportCollector extends AbstractEventSpy { + + private static final Logger LOGGER = LoggerFactory.getLogger(BuildReportCollector.class); + + static final String REPORT_DIR = "build-reports"; + static final String REPORT_LATEST = "build-report-latest.json"; + + private static final int MAX_STACKTRACE_LINES = 30; + + private final DefaultDiagnosticCollector diagnosticCollector; + + @Inject + public BuildReportCollector(DefaultDiagnosticCollector diagnosticCollector) { + this.diagnosticCollector = diagnosticCollector; + } + + /** + * No-arg constructor for tests that don't need diagnostic collection. + */ + BuildReportCollector() { + this(new DefaultDiagnosticCollector()); + } + + /** + * Returns the diagnostic collector used by this build report collector. + * Plugins can inject {@link DefaultDiagnosticCollector} directly, but this + * accessor is provided for internal use and testing. + */ + DefaultDiagnosticCollector getDiagnosticCollector() { + return diagnosticCollector; + } + + /** + * Maximum number of log events captured per scope (mojo, module, or build). + * Beyond this, events are dropped and a truncation notice is appended. + */ + static final int MAX_LOG_EVENTS_PER_SCOPE = 500; + + // ---- Mutable state, populated during the build ---- + + /** Per-project mojo tracking: project key → list of in-flight/completed mojos. */ + private final Map> mojoTimings = new ConcurrentHashMap<>(); + + /** Per-project start instants for duration computation. */ + private final Map projectStartTimes = new ConcurrentHashMap<>(); + + /** Per-mojo start instants for duration computation. */ + private final Map mojoStartTimes = new ConcurrentHashMap<>(); + + /** Session-level state — set once on SessionStarted. */ + private volatile MavenSession session; + + // ---- Log capture state ---- + + /** + * Maps thread ID → mojo key for the currently-executing mojo on that thread. + * Lifecycle events and mojo execution run on the same thread, so this is safe + * for parallel builds with {@code -T}. + */ + private final Map currentMojoByThread = new ConcurrentHashMap<>(); + + /** Per-mojo log buffers: mojo key → captured log events. */ + private final Map> mojoLogBuffers = new ConcurrentHashMap<>(); + + /** + * Maps thread ID → project key for the currently-building project on that thread. + * Used to route log events that occur between mojo executions to the module-level buffer. + */ + private final Map currentProjectByThread = new ConcurrentHashMap<>(); + + /** Per-module log buffers: project key → events captured outside any mojo. */ + private final Map> moduleLogBuffers = new ConcurrentHashMap<>(); + + /** Build-level log buffer: events captured outside any module lifecycle. */ + private final List buildLogBuffer = Collections.synchronizedList(new ArrayList<>()); + + @Override + public void onEvent(Object event) { + if (event instanceof ExecutionEvent executionEvent) { + switch (executionEvent.getType()) { + case SessionStarted: + onSessionStarted(executionEvent); + break; + case SessionEnded: + onSessionEnded(executionEvent); + break; + case ProjectStarted: + onProjectStarted(executionEvent); + break; + case ProjectSucceeded: + case ProjectFailed: + case ProjectSkipped: + onProjectFinished(executionEvent); + break; + case MojoStarted: + onMojoStarted(executionEvent); + break; + case MojoSucceeded: + case MojoFailed: + onMojoFinished(executionEvent); + break; + default: + break; + } + } + } + + // ---- Event handlers ---- + + private void onSessionStarted(ExecutionEvent event) { + this.session = event.getSession(); + configureDiagnosticSuppression(); + installLogCapture(); + } + + /** + * Reads the {@code maven.diagnostic.suppress} user property and configures + * the diagnostic collector to suppress matching keys. The property accepts + * a comma-separated list of keys or patterns: + *

    + *
  • Exact keys: {@code -Dmaven.diagnostic.suppress=deprecated-source-target}
  • + *
  • Prefix wildcards: {@code -Dmaven.diagnostic.suppress=auto:*} (suppresses all + * auto-collected warnings from Maven 3 plugins)
  • + *
  • Multiple: {@code -Dmaven.diagnostic.suppress=key1,key2,auto:*}
  • + *
+ */ + private void configureDiagnosticSuppression() { + if (session == null) { + return; + } + String suppressProp = session.getUserProperties().getProperty("maven.diagnostic.suppress"); + if (suppressProp == null || suppressProp.isBlank()) { + return; + } + Set keys = new LinkedHashSet<>(); + for (String token : suppressProp.split(",")) { + String trimmed = token.trim(); + if (!trimmed.isEmpty()) { + keys.add(trimmed); + } + } + if (!keys.isEmpty()) { + diagnosticCollector.setSuppressedKeys(keys); + LOGGER.debug("Diagnostic suppression configured: {}", keys); + } + } + + private void onSessionEnded(ExecutionEvent event) { + removeLogCapture(); + + MavenSession endSession = event.getSession(); + if (endSession == null) { + return; + } + + // Read warning mode from user properties (set by MavenInvoker from --warning-mode) + String warningMode = endSession.getUserProperties().getProperty("maven.build.warningMode", "summary"); + + BuildReport report = buildReport(endSession); + writeReport(report, endSession); + + if (!"none".equalsIgnoreCase(warningMode)) { + printDiagnosticSummary(); + } + + // --warning-mode=fail: fail the build if any warnings were collected + if ("fail".equalsIgnoreCase(warningMode) && diagnosticCollector.hasWarnings()) { + int warningCount = 0; + for (DiagnosticSummary entry : diagnosticCollector.getSummary()) { + if (entry.diagnostic().severity() == Diagnostic.Severity.WARNING) { + warningCount += entry.count(); + } + } + endSession + .getResult() + .addException(new RuntimeException( + "Build has " + warningCount + " warning(s) and --warning-mode=fail is set")); + } + } + + private void onProjectStarted(ExecutionEvent event) { + String key = projectKey(event.getProject()); + projectStartTimes.put(key, MonotonicClock.now()); + mojoTimings.putIfAbsent(key, Collections.synchronizedList(new ArrayList<>())); + moduleLogBuffers.put(key, Collections.synchronizedList(new ArrayList<>())); + currentProjectByThread.put(Thread.currentThread().getId(), key); + } + + private void onProjectFinished(ExecutionEvent event) { + // Unregister the project from this thread so subsequent log events + // fall through to the build-level buffer + currentProjectByThread.remove(Thread.currentThread().getId()); + } + + private void onMojoStarted(ExecutionEvent event) { + String mKey = mojoKey(event.getProject(), event.getMojoExecution()); + mojoStartTimes.put(mKey, MonotonicClock.now()); + + // Register the current mojo for this thread so the log event sink + // can associate events with this mojo execution + currentMojoByThread.put(Thread.currentThread().getId(), mKey); + mojoLogBuffers.put(mKey, Collections.synchronizedList(new ArrayList<>())); + } + + private void onMojoFinished(ExecutionEvent event) { + MojoExecution mojo = event.getMojoExecution(); + MavenProject project = event.getProject(); + String mKey = mojoKey(project, mojo); + String pKey = projectKey(project); + + // Unregister the mojo from this thread + currentMojoByThread.remove(Thread.currentThread().getId()); + + Instant now = MonotonicClock.now(); + Instant startInstant = mojoStartTimes.remove(mKey); + if (startInstant == null) { + startInstant = now; + } + Duration duration = Duration.between(startInstant, now); + + BuildStatus status = + event.getType() == ExecutionEvent.Type.MojoSucceeded ? BuildStatus.SUCCESS : BuildStatus.FAILURE; + + // Drain the log buffer for this mojo + List logBuffer = mojoLogBuffers.remove(mKey); + List output = logBuffer != null ? List.copyOf(logBuffer) : List.of(); + + MojoTiming timing = new MojoTiming( + mojo.getGroupId(), + mojo.getArtifactId(), + mojo.getVersion(), + mojo.getGoal(), + mojo.getExecutionId(), + mojo.getLifecyclePhase(), + status, + startInstant, + duration, + output); + + mojoTimings + .computeIfAbsent(pKey, k -> Collections.synchronizedList(new ArrayList<>())) + .add(timing); + } + + // ---- Structured log capture ---- + + /** + * Installs a structured {@link MavenSimpleLogger.LogEventSink} to capture + * log events with level, logger name, message, and throwable. This runs + * in parallel to the existing console output pipeline — no wrapping or + * forwarding needed. + */ + private void installLogCapture() { + MavenSimpleLogger.setLogEventSink(this::captureLogEvent); + } + + private void removeLogCapture() { + MavenSimpleLogger.setLogEventSink(null); + } + + private void captureLogEvent(int level, String loggerName, String message, Throwable throwable) { + long threadId = Thread.currentThread().getId(); + + LogLevel logLevel = toLogLevel(level); + Instant timestamp = MonotonicClock.now(); + String stackTrace = throwable != null ? truncateStackTrace(throwable) : null; + LogEvent logEvent = new DefaultLogEvent(timestamp, logLevel, message, loggerName, stackTrace); + + // Auto-collect WARN-level log events as diagnostics, giving Maven 3 plugins + // automatic deduplication and summary at end of build without code changes. + // Skip our own logger to avoid feedback loops from diagnostic summary printing. + if (level == LocationAwareLogger.WARN_INT + && message != null + && !loggerName.equals(BuildReportCollector.class.getName())) { + String syntheticKey = syntheticDiagnosticKey(loggerName, message); + diagnosticCollector.report(DefaultDiagnostic.warning(syntheticKey, message, loggerName)); + } + + // 1. Mojo-level: event belongs to the currently-executing mojo on this thread + String mKey = currentMojoByThread.get(threadId); + if (mKey != null) { + List buffer = mojoLogBuffers.get(mKey); + if (buffer != null && buffer.size() < MAX_LOG_EVENTS_PER_SCOPE) { + buffer.add(logEvent); + } + return; + } + + // 2. Module-level: project is active but no mojo is running + String pKey = currentProjectByThread.get(threadId); + if (pKey != null) { + List buffer = moduleLogBuffers.get(pKey); + if (buffer != null && buffer.size() < MAX_LOG_EVENTS_PER_SCOPE) { + buffer.add(logEvent); + } + return; + } + + // 3. Build-level: no project active (startup, reactor summary, post-build) + if (buildLogBuffer.size() < MAX_LOG_EVENTS_PER_SCOPE) { + buildLogBuffer.add(logEvent); + } + } + + private static LogLevel toLogLevel(int level) { + return switch (level) { + case LocationAwareLogger.TRACE_INT -> LogLevel.TRACE; + case LocationAwareLogger.DEBUG_INT -> LogLevel.DEBUG; + case LocationAwareLogger.INFO_INT -> LogLevel.INFO; + case LocationAwareLogger.WARN_INT -> LogLevel.WARN; + default -> LogLevel.ERROR; + }; + } + + // ---- Report assembly ---- + + BuildReport buildReport(MavenSession endSession) { + Instant now = MonotonicClock.now(); + Instant startInstant = endSession.getRequest().getStartInstant(); + Duration totalDuration = Duration.between(startInstant, now); + + MavenExecutionResult result = endSession.getResult(); + boolean hasFailures = result != null && result.hasExceptions(); + BuildStatus overallStatus = hasFailures ? BuildStatus.FAILURE : BuildStatus.SUCCESS; + + // Collect module reports + List moduleReports = new ArrayList<>(); + for (MavenProject project : endSession.getProjects()) { + moduleReports.add(buildModuleReport(project, endSession)); + } + + // Collect failures + List failureReports = new ArrayList<>(); + if (result != null) { + for (MavenProject project : endSession.getProjects()) { + BuildSummary summary = result.getBuildSummary(project); + if (summary instanceof BuildFailure buildFailure) { + failureReports.add(buildFailureReport(project, buildFailure)); + } + } + } + + // Metadata + String mavenVersion = endSession.getSystemProperties().getProperty("maven.version", "unknown"); + String javaVersion = System.getProperty("java.version", "unknown"); + List goals = endSession.getGoals(); + MavenProject topProject = endSession.getTopLevelProject(); + String projectId = topProject != null + ? topProject.getGroupId() + ":" + topProject.getArtifactId() + ":" + topProject.getVersion() + : "unknown"; + boolean multiModule = endSession.getProjects().size() > 1; + int threads = endSession.getRequest().getDegreeOfConcurrency(); + + // Diagnostics (deduplicated) + List diagnostics = diagnosticCollector.getDiagnostics(); + + // Build-level log events (outside any module lifecycle) + List buildOutput = List.copyOf(buildLogBuffer); + + return new DefaultBuildReport( + overallStatus, + totalDuration, + startInstant, + mavenVersion, + javaVersion, + goals, + projectId, + multiModule, + threads, + moduleReports, + failureReports, + diagnostics, + buildOutput); + } + + private ModuleReport buildModuleReport(MavenProject project, MavenSession endSession) { + String key = projectKey(project); + + // Duration from BuildSummary (preferred) or fallback to our own tracking + MavenExecutionResult result = endSession.getResult(); + Duration duration = Duration.ZERO; + BuildStatus status = BuildStatus.SKIPPED; + Instant moduleStartTime = + projectStartTimes.getOrDefault(key, endSession.getRequest().getStartInstant()); + + if (result != null) { + BuildSummary summary = result.getBuildSummary(project); + if (summary instanceof BuildSuccess) { + status = BuildStatus.SUCCESS; + duration = summary.getExecTime(); + } else if (summary instanceof BuildFailure) { + status = BuildStatus.FAILURE; + duration = summary.getExecTime(); + } else if (summary != null) { + // Unknown summary type — use its timing + duration = summary.getExecTime(); + } else { + // No summary means skipped + Instant start = projectStartTimes.get(key); + if (start != null) { + duration = Duration.between(start, MonotonicClock.now()); + } + } + } + + // Mojo reports + List timings = mojoTimings.getOrDefault(key, Collections.emptyList()); + List mojoReports; + synchronized (timings) { + mojoReports = timings.stream() + .map(t -> (MojoReport) new DefaultMojoReport( + t.groupId, + t.artifactId, + t.version, + t.goal, + t.executionId, + t.phase, + t.status, + t.startTime, + t.duration, + t.output)) + .toList(); + } + + // Module-level log events (between mojos) + List moduleLogBuffer = moduleLogBuffers.getOrDefault(key, Collections.emptyList()); + List moduleOutput; + synchronized (moduleLogBuffer) { + moduleOutput = List.copyOf(moduleLogBuffer); + } + + return new DefaultModuleReport( + project.getGroupId(), + project.getArtifactId(), + project.getVersion(), + status, + moduleStartTime, + duration, + mojoReports, + moduleOutput); + } + + private FailureReport buildFailureReport(MavenProject project, BuildFailure buildFailure) { + String module = project.getGroupId() + ":" + project.getArtifactId() + ":" + project.getVersion(); + + // Try to find which mojo failed + String mojoId = null; + List timings = mojoTimings.getOrDefault(projectKey(project), Collections.emptyList()); + synchronized (timings) { + for (MojoTiming t : timings) { + if (t.status == BuildStatus.FAILURE) { + mojoId = t.artifactId + ":" + t.version + ":" + t.goal; + break; + } + } + } + + Throwable cause = buildFailure.getCause(); + String message = cause != null ? cause.getMessage() : "Unknown error"; + String stackTrace = cause != null ? truncateStackTrace(cause) : null; + + Instant failureTimestamp = MonotonicClock.now(); + String exceptionType = cause != null ? cause.getClass().getSimpleName() : null; + + return new DefaultFailureReport( + module, + mojoId, + failureTimestamp, + exceptionType, + message != null ? message : "Unknown error", + stackTrace); + } + + // ---- JSON persistence ---- + + void writeReport(BuildReport report, MavenSession endSession) { + Path topDirectory = endSession.getTopDirectory(); + if (topDirectory == null) { + LOGGER.debug("No top directory available, skipping build report"); + return; + } + + Path reportsDir = topDirectory.resolve("target").resolve(REPORT_DIR); + + try { + Files.createDirectories(reportsDir); + String json = BuildReportJsonWriter.toJson(report); + + // Timestamped file: build-report-20250729T143000Z.json + String timestamp = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'") + .withZone(ZoneOffset.UTC) + .format(report.startTime()); + Path timestampedFile = reportsDir.resolve("build-report-" + timestamp + ".json"); + + // Write to a temp file, then atomic-move into place so a crash + // never leaves a half-written report on disk. + Path tmpFile = Files.createTempFile(reportsDir, ".build-report-", ".tmp"); + try { + Files.writeString(tmpFile, json); + atomicMove(tmpFile, timestampedFile); + } catch (IOException e) { + Files.deleteIfExists(tmpFile); + throw e; + } + + // Latest symlink (or copy on filesystems that don't support symlinks) + Path latestFile = reportsDir.resolve(REPORT_LATEST); + try { + // Atomic symlink swap: create new link, then rename over the old one + Path tmpLink = Files.createTempFile(reportsDir, ".latest-", ".tmp"); + Files.delete(tmpLink); // createTempFile creates a regular file + Files.createSymbolicLink(tmpLink, timestampedFile.getFileName()); + atomicMove(tmpLink, latestFile); + } catch (UnsupportedOperationException | IOException symEx) { + // Windows or restricted filesystem — fall back to a plain copy + Files.writeString(latestFile, json); + } + + LOGGER.debug("Build report written to {}", timestampedFile); + } catch (IOException e) { + LOGGER.warn("Failed to write build report to {}: {}", reportsDir, e.getMessage()); + } + } + + /** + * Attempts an atomic move; falls back to a plain move if the filesystem + * does not support {@code ATOMIC_MOVE}. + */ + private static void atomicMove(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + // ---- Diagnostic summary ---- + + /** + * Prints a deduplicated summary of diagnostics (warnings and errors) at the + * end of the build. This ensures important messages are not lost in scrollback. + */ + void printDiagnosticSummary() { + List summary = diagnosticCollector.getSummary(); + if (summary.isEmpty()) { + return; + } + + // Count unique warnings and total occurrences + int uniqueWarnings = 0; + int totalOccurrences = 0; + int uniqueErrors = 0; + for (DiagnosticSummary entry : summary) { + Diagnostic.Severity sev = entry.diagnostic().severity(); + if (sev == Diagnostic.Severity.WARNING) { + uniqueWarnings++; + totalOccurrences += entry.count(); + } else if (sev == Diagnostic.Severity.ERROR) { + uniqueErrors++; + totalOccurrences += entry.count(); + } + } + + if (uniqueWarnings == 0 && uniqueErrors == 0) { + return; + } + + // Print header + StringBuilder header = new StringBuilder(); + if (uniqueWarnings > 0) { + header.append(uniqueWarnings).append(" warning"); + if (uniqueWarnings > 1) { + header.append('s'); + } + } + if (uniqueErrors > 0) { + if (header.length() > 0) { + header.append(", "); + } + header.append(uniqueErrors).append(" error"); + if (uniqueErrors > 1) { + header.append('s'); + } + } + if (totalOccurrences > (uniqueWarnings + uniqueErrors)) { + header.append(" (").append(totalOccurrences).append(" total occurrences)"); + } + + // Print the summary at INFO level. We intentionally do NOT re-print individual + // warning messages here — they were already logged inline at WARN level. Re-printing + // the raw message text would double warning counts in log parsers, trigger + // --fail-on-severity WARN again, and confuse tools that search for specific text. + // Full details are available in target/build-reports/. + LOGGER.info(""); + LOGGER.info("Diagnostics: {} — see target/{}/{} for details", header, REPORT_DIR, REPORT_LATEST); + } + + // ---- Utility methods ---- + + private static String projectKey(MavenProject project) { + return project.getGroupId() + ":" + project.getArtifactId(); + } + + private static String mojoKey(MavenProject project, MojoExecution mojo) { + return projectKey(project) + "#" + mojo.getGoal() + "@" + mojo.getExecutionId(); + } + + /** + * Generates a stable deduplication key for a warning intercepted from a + * Maven 3 plugin's {@code Log.warn()} call. The key is derived from the + * logger name and a normalized hash of the message text, so that the same + * warning from different modules or files deduplicates correctly. + *

+ * File-specific coordinates (paths, line numbers) are stripped before hashing + * so that "Foo.java:42: unchecked cast" and "Bar.java:99: unchecked cast" + * map to the same key. + */ + static String syntheticDiagnosticKey(String loggerName, String message) { + // Strip file coordinates for dedup: remove paths and line/column numbers + String normalized = message.replaceAll("\\S+\\.java:\\d+(:\\d+)?:?\\s*", "") + .replaceAll("\\S+[\\\\/][\\w.]+:\\d+", "") + .trim(); + // Use a short logger suffix to namespace the key + String loggerSuffix = loggerName; + int lastDot = loggerName.lastIndexOf('.'); + if (lastDot >= 0 && lastDot < loggerName.length() - 1) { + loggerSuffix = loggerName.substring(lastDot + 1); + } + return "auto:" + loggerSuffix + ":" + Integer.toHexString(normalized.hashCode()); + } + + static String truncateStackTrace(Throwable t) { + StringWriter sw = new StringWriter(); + t.printStackTrace(new PrintWriter(sw)); + String full = sw.toString(); + String[] lines = full.split("\n"); + if (lines.length <= MAX_STACKTRACE_LINES) { + return full; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < MAX_STACKTRACE_LINES; i++) { + sb.append(lines[i]).append('\n'); + } + sb.append("... ").append(lines.length - MAX_STACKTRACE_LINES).append(" more lines truncated\n"); + return sb.toString(); + } + + // ---- Internal records ---- + + record MojoTiming( + String groupId, + String artifactId, + String version, + String goal, + String executionId, + String phase, + BuildStatus status, + Instant startTime, + Duration duration, + List output) {} +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportJsonWriter.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportJsonWriter.java new file mode 100644 index 000000000000..ab22bc0f6dc5 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportJsonWriter.java @@ -0,0 +1,371 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.internal.build; + +import org.apache.maven.api.build.report.BuildReport; +import org.apache.maven.api.build.report.Diagnostic; +import org.apache.maven.api.build.report.FailureReport; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.ModuleReport; +import org.apache.maven.api.build.report.MojoReport; + +/** + * Serializes a {@link BuildReport} to JSON without any external library dependency. + *

+ * The output is human-readable (indented with 2 spaces) and designed to be + * stable across Maven versions — field order is fixed, and new fields are + * appended at the end of each object. + */ +final class BuildReportJsonWriter { + + private BuildReportJsonWriter() {} + + /** + * Serialize the given report to a pretty-printed JSON string. + */ + static String toJson(BuildReport report) { + StringBuilder sb = new StringBuilder(4096); + writeReport(sb, report, 0); + sb.append('\n'); + return sb.toString(); + } + + private static void writeReport(StringBuilder sb, BuildReport report, int indent) { + sb.append("{\n"); + writeField(sb, indent + 1, "formatVersion", report.formatVersion()); + writeField(sb, indent + 1, "status", report.status().name()); + writeField(sb, indent + 1, "duration", report.duration().toString()); + writeField(sb, indent + 1, "startTime", report.startTime().toString()); + writeField(sb, indent + 1, "mavenVersion", report.mavenVersion()); + writeField(sb, indent + 1, "javaVersion", report.javaVersion()); + writeStringArray(sb, indent + 1, "goals", report.goals()); + writeField(sb, indent + 1, "project", report.project()); + writeField(sb, indent + 1, "multiModule", report.multiModule()); + writeField(sb, indent + 1, "threads", report.threads()); + + // modules array + writeIndent(sb, indent + 1); + sb.append("\"modules\": "); + if (report.modules().isEmpty()) { + sb.append("[]"); + } else { + sb.append("[\n"); + for (int i = 0; i < report.modules().size(); i++) { + writeIndent(sb, indent + 2); + writeModule(sb, report.modules().get(i), indent + 2); + if (i < report.modules().size() - 1) { + sb.append(','); + } + sb.append('\n'); + } + writeIndent(sb, indent + 1); + sb.append(']'); + } + sb.append(",\n"); + + // failures array + writeIndent(sb, indent + 1); + sb.append("\"failures\": "); + if (report.failures().isEmpty()) { + sb.append("[]"); + } else { + sb.append("[\n"); + for (int i = 0; i < report.failures().size(); i++) { + writeIndent(sb, indent + 2); + writeFailure(sb, report.failures().get(i), indent + 2); + if (i < report.failures().size() - 1) { + sb.append(','); + } + sb.append('\n'); + } + writeIndent(sb, indent + 1); + sb.append(']'); + } + sb.append(",\n"); + + // diagnostics array + writeIndent(sb, indent + 1); + sb.append("\"diagnostics\": "); + if (report.diagnostics().isEmpty()) { + sb.append("[]"); + } else { + sb.append("[\n"); + for (int i = 0; i < report.diagnostics().size(); i++) { + writeIndent(sb, indent + 2); + writeDiagnostic(sb, report.diagnostics().get(i), indent + 2); + if (i < report.diagnostics().size() - 1) { + sb.append(','); + } + sb.append('\n'); + } + writeIndent(sb, indent + 1); + sb.append(']'); + } + sb.append(",\n"); + + // output array — build-level log lines (outside any module) + writeOutputArray(sb, indent + 1, report.output()); + sb.append('\n'); + + writeIndent(sb, indent); + sb.append('}'); + } + + private static void writeDiagnostic(StringBuilder sb, Diagnostic diagnostic, int indent) { + sb.append("{\n"); + writeField(sb, indent + 1, "key", diagnostic.key()); + writeField(sb, indent + 1, "severity", diagnostic.severity().name()); + writeField(sb, indent + 1, "message", diagnostic.message()); + if (diagnostic.source() != null) { + writeField(sb, indent + 1, "source", diagnostic.source()); + } + if (diagnostic.file() != null) { + writeField(sb, indent + 1, "file", diagnostic.file()); + } + if (diagnostic.line() > 0) { + writeField(sb, indent + 1, "line", diagnostic.line()); + } + if (diagnostic.column() > 0) { + writeField(sb, indent + 1, "column", diagnostic.column()); + } + if (diagnostic.suggestion() != null) { + writeField(sb, indent + 1, "suggestion", diagnostic.suggestion()); + } + if (diagnostic.documentationUrl() != null) { + // This is the last optional field — but message is always present, + // so we still need a trailing comma handling strategy. + writeField(sb, indent + 1, "documentationUrl", diagnostic.documentationUrl()); + } + // Remove the trailing comma from the last written field + // by finding and removing the last ",\n" pattern + int lastComma = sb.lastIndexOf(",\n"); + if (lastComma > 0) { + sb.replace(lastComma, lastComma + 1, ""); + } + writeIndent(sb, indent); + sb.append('}'); + } + + private static void writeModule(StringBuilder sb, ModuleReport module, int indent) { + sb.append("{\n"); + writeField(sb, indent + 1, "groupId", module.groupId()); + writeField(sb, indent + 1, "artifactId", module.artifactId()); + writeField(sb, indent + 1, "version", module.version()); + writeField(sb, indent + 1, "status", module.status().name()); + writeField(sb, indent + 1, "startTime", module.startTime().toString()); + writeField(sb, indent + 1, "duration", module.duration().toString()); + + // mojos array + writeIndent(sb, indent + 1); + sb.append("\"mojos\": "); + if (module.mojos().isEmpty()) { + sb.append("[]"); + } else { + sb.append("[\n"); + for (int i = 0; i < module.mojos().size(); i++) { + writeIndent(sb, indent + 2); + writeMojo(sb, module.mojos().get(i), indent + 2); + if (i < module.mojos().size() - 1) { + sb.append(','); + } + sb.append('\n'); + } + writeIndent(sb, indent + 1); + sb.append(']'); + } + sb.append(",\n"); + + // output array — module-level log lines (between mojos) + writeOutputArray(sb, indent + 1, module.output()); + sb.append('\n'); + + writeIndent(sb, indent); + sb.append('}'); + } + + private static void writeMojo(StringBuilder sb, MojoReport mojo, int indent) { + sb.append("{\n"); + writeField(sb, indent + 1, "groupId", mojo.groupId()); + writeField(sb, indent + 1, "artifactId", mojo.artifactId()); + writeField(sb, indent + 1, "version", mojo.version()); + writeField(sb, indent + 1, "goal", mojo.goal()); + writeNullableField(sb, indent + 1, "executionId", mojo.executionId(), true); + writeNullableField(sb, indent + 1, "phase", mojo.phase(), true); + writeField(sb, indent + 1, "status", mojo.status().name()); + writeField(sb, indent + 1, "startTime", mojo.startTime().toString()); + writeField(sb, indent + 1, "duration", mojo.duration().toString()); + + // output array — captured log lines + writeOutputArray(sb, indent + 1, mojo.output()); + sb.append('\n'); + + writeIndent(sb, indent); + sb.append('}'); + } + + private static void writeFailure(StringBuilder sb, FailureReport failure, int indent) { + sb.append("{\n"); + writeField(sb, indent + 1, "module", failure.module()); + writeNullableField(sb, indent + 1, "mojo", failure.mojo(), true); + writeField(sb, indent + 1, "timestamp", failure.timestamp().toString()); + writeNullableField(sb, indent + 1, "exceptionType", failure.exceptionType(), true); + if (failure.stackTrace() != null) { + writeField(sb, indent + 1, "message", failure.message()); + writeLastField(sb, indent + 1, "stackTrace", failure.stackTrace()); + } else { + writeLastField(sb, indent + 1, "message", failure.message()); + } + writeIndent(sb, indent); + sb.append('}'); + } + + /** + * Writes an {@code "output": [...]} array of structured log events + * (used by report, module, and mojo). + * This is always the last field in its object, so no trailing comma. + */ + private static void writeOutputArray(StringBuilder sb, int indent, java.util.List events) { + writeIndent(sb, indent); + sb.append("\"output\": "); + if (events.isEmpty()) { + sb.append("[]"); + } else { + sb.append("[\n"); + for (int i = 0; i < events.size(); i++) { + writeIndent(sb, indent + 1); + writeLogEvent(sb, events.get(i), indent + 1); + if (i < events.size() - 1) { + sb.append(','); + } + sb.append('\n'); + } + writeIndent(sb, indent); + sb.append(']'); + } + } + + private static void writeLogEvent(StringBuilder sb, LogEvent event, int indent) { + sb.append("{\n"); + writeField(sb, indent + 1, "timestamp", event.timestamp().toString()); + writeField(sb, indent + 1, "level", event.level().name()); + if (event.loggerName() != null) { + writeField(sb, indent + 1, "loggerName", event.loggerName()); + } + if (event.stackTrace() != null) { + writeField(sb, indent + 1, "message", event.message()); + writeLastField(sb, indent + 1, "stackTrace", event.stackTrace()); + } else { + writeLastField(sb, indent + 1, "message", event.message()); + } + writeIndent(sb, indent); + sb.append('}'); + } + + // ---- Low-level JSON writing helpers ---- + + private static void writeField(StringBuilder sb, int indent, String key, String value) { + writeIndent(sb, indent); + sb.append('"').append(key).append("\": "); + writeJsonString(sb, value); + sb.append(",\n"); + } + + private static void writeField(StringBuilder sb, int indent, String key, int value) { + writeIndent(sb, indent); + sb.append('"').append(key).append("\": ").append(value).append(",\n"); + } + + private static void writeField(StringBuilder sb, int indent, String key, boolean value) { + writeIndent(sb, indent); + sb.append('"').append(key).append("\": ").append(value).append(",\n"); + } + + private static void writeLastField(StringBuilder sb, int indent, String key, String value) { + writeIndent(sb, indent); + sb.append('"').append(key).append("\": "); + writeJsonString(sb, value); + sb.append('\n'); + } + + private static void writeNullableField( + StringBuilder sb, int indent, String key, String value, @SuppressWarnings("unused") boolean hasMore) { + writeIndent(sb, indent); + sb.append('"').append(key).append("\": "); + if (value != null) { + writeJsonString(sb, value); + } else { + sb.append("null"); + } + sb.append(",\n"); + } + + private static void writeStringArray(StringBuilder sb, int indent, String key, java.util.List values) { + writeIndent(sb, indent); + sb.append('"').append(key).append("\": ["); + for (int i = 0; i < values.size(); i++) { + writeJsonString(sb, values.get(i)); + if (i < values.size() - 1) { + sb.append(", "); + } + } + sb.append("],\n"); + } + + private static void writeJsonString(StringBuilder sb, String value) { + sb.append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + case '\b': + sb.append("\\b"); + break; + case '\f': + sb.append("\\f"); + break; + default: + if (c < 0x20) { + sb.append("\\u"); + sb.append(String.format("%04x", (int) c)); + } else { + sb.append(c); + } + } + } + sb.append('"'); + } + + private static void writeIndent(StringBuilder sb, int level) { + sb.append(" ".repeat(level)); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildReport.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildReport.java new file mode 100644 index 000000000000..d74ffc8c114f --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildReport.java @@ -0,0 +1,82 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.internal.build; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import org.apache.maven.api.build.report.BuildReport; +import org.apache.maven.api.build.report.BuildStatus; +import org.apache.maven.api.build.report.Diagnostic; +import org.apache.maven.api.build.report.FailureReport; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.ModuleReport; + +/** + * Internal immutable implementation of {@link BuildReport}. + */ +record DefaultBuildReport( + BuildStatus status, + Duration duration, + Instant startTime, + String mavenVersion, + String javaVersion, + List goals, + String project, + boolean multiModule, + int threads, + List modules, + List failures, + List diagnostics, + List output) + implements BuildReport { + + private static final int FORMAT_VERSION = 1; + + @Override + public int formatVersion() { + return FORMAT_VERSION; + } + + @Override + public List modules() { + return List.copyOf(modules); + } + + @Override + public List failures() { + return List.copyOf(failures); + } + + @Override + public List diagnostics() { + return List.copyOf(diagnostics); + } + + @Override + public List goals() { + return List.copyOf(goals); + } + + @Override + public List output() { + return List.copyOf(output); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultDiagnostic.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultDiagnostic.java new file mode 100644 index 000000000000..7d00c887c5ea --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultDiagnostic.java @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.internal.build; + +import org.apache.maven.api.build.report.Diagnostic; + +import static java.util.Objects.requireNonNull; + +/** + * Immutable implementation of {@link Diagnostic}. + */ +record DefaultDiagnostic( + String key, + Severity severity, + String message, + String source, + String file, + int line, + int column, + String suggestion, + String documentationUrl) + implements Diagnostic { + + DefaultDiagnostic { + requireNonNull(key, "key"); + requireNonNull(severity, "severity"); + requireNonNull(message, "message"); + } + + /** + * Convenience factory for common diagnostic patterns. + */ + static DefaultDiagnostic warning(String key, String message, String source) { + return new DefaultDiagnostic(key, Severity.WARNING, message, source, null, -1, -1, null, null); + } + + static DefaultDiagnostic error(String key, String message, String source) { + return new DefaultDiagnostic(key, Severity.ERROR, message, source, null, -1, -1, null, null); + } + + static DefaultDiagnostic info(String key, String message, String source) { + return new DefaultDiagnostic(key, Severity.INFO, message, source, null, -1, -1, null, null); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultDiagnosticCollector.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultDiagnosticCollector.java new file mode 100644 index 000000000000..0d0971427b04 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultDiagnosticCollector.java @@ -0,0 +1,187 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.internal.build; + +import javax.inject.Named; +import javax.inject.Singleton; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.LongAdder; + +import org.apache.maven.api.build.report.Diagnostic; +import org.apache.maven.api.build.report.DiagnosticCollector; +import org.apache.maven.api.build.report.DiagnosticSummary; + +import static java.util.Objects.requireNonNull; + +/** + * Thread-safe implementation of {@link DiagnosticCollector}. + *

+ * Diagnostics are deduplicated by {@link Diagnostic#key()}: the first + * occurrence is stored, subsequent duplicates only increment the counter. + * The internal ordering preserves insertion order of first occurrences. + *

+ * This implementation is safe for use from parallel module builds + * ({@code -T}) and from any thread within a plugin execution. + * + * @since 4.1.0 + */ +@Named +@Singleton +public final class DefaultDiagnosticCollector implements DiagnosticCollector { + + /** + * Maximum number of unique diagnostics to store. + * Protects against runaway plugins that produce unbounded diagnostics. + */ + static final int MAX_DIAGNOSTICS = 1000; + + /** + * Preserves insertion order: key → first diagnostic. + * Using ConcurrentHashMap for thread safety; insertion order is tracked + * separately in {@link #orderedKeys}. + */ + private final Map uniqueDiagnostics = new ConcurrentHashMap<>(); + + /** Counts per key (including the first occurrence). */ + private final Map counts = new ConcurrentHashMap<>(); + + /** + * Insertion-order tracking. Synchronized on itself for ordered access. + * The key list mirrors {@link #uniqueDiagnostics} keys in insertion order. + */ + private final List orderedKeys = Collections.synchronizedList(new ArrayList<>()); + + /** + * Keys to suppress. Diagnostics with a key in this set are silently dropped. + * Configured via {@link #setSuppressedKeys(Set)}, typically from the + * {@code maven.diagnostic.suppress} user property. + */ + private volatile Set suppressedKeys = Set.of(); + + /** + * Sets the keys to suppress. Diagnostics with a matching key will be + * silently dropped from {@link #report(Diagnostic)}. + *

+ * Supports both exact keys ({@code "deprecated-source-target"}) and + * prefix matching with wildcard ({@code "auto:*"} to suppress all + * auto-collected warnings from Maven 3 plugins). + * + * @param keys the set of keys to suppress; must not be null + */ + public void setSuppressedKeys(Set keys) { + this.suppressedKeys = Set.copyOf(requireNonNull(keys, "keys")); + } + + @Override + public void report(Diagnostic diagnostic) { + requireNonNull(diagnostic, "diagnostic"); + String key = diagnostic.key(); + + // Check suppression: exact match or prefix wildcard (e.g. "auto:*") + if (isSuppressed(key)) { + return; + } + + // Always increment the count + counts.computeIfAbsent(key, k -> new LongAdder()).increment(); + + // Store the first occurrence only (if within cap) + if (uniqueDiagnostics.putIfAbsent(key, diagnostic) == null) { + if (uniqueDiagnostics.size() <= MAX_DIAGNOSTICS) { + orderedKeys.add(key); + } else { + // Over cap — remove the entry we just added + uniqueDiagnostics.remove(key); + } + } + } + + private boolean isSuppressed(String key) { + Set suppressed = this.suppressedKeys; + if (suppressed.isEmpty()) { + return false; + } + if (suppressed.contains(key)) { + return true; + } + // Check prefix wildcards: "auto:*" matches "auto:Compiler:1a2b3c" + for (String pattern : suppressed) { + if (pattern.endsWith("*") && key.startsWith(pattern.substring(0, pattern.length() - 1))) { + return true; + } + } + return false; + } + + @Override + public List getDiagnostics() { + List result; + synchronized (orderedKeys) { + result = new ArrayList<>(orderedKeys.size()); + for (String key : orderedKeys) { + Diagnostic d = uniqueDiagnostics.get(key); + if (d != null) { + result.add(d); + } + } + } + return Collections.unmodifiableList(result); + } + + @Override + public List getSummary() { + List result; + synchronized (orderedKeys) { + result = new ArrayList<>(orderedKeys.size()); + for (String key : orderedKeys) { + Diagnostic d = uniqueDiagnostics.get(key); + LongAdder counter = counts.get(key); + if (d != null && counter != null) { + result.add(new DefaultDiagnosticSummary(d, counter.intValue())); + } + } + } + return Collections.unmodifiableList(result); + } + + @Override + public boolean hasWarnings() { + return hasAtSeverity(Diagnostic.Severity.WARNING); + } + + @Override + public boolean hasErrors() { + return hasAtSeverity(Diagnostic.Severity.ERROR); + } + + private boolean hasAtSeverity(Diagnostic.Severity minSeverity) { + for (Diagnostic d : uniqueDiagnostics.values()) { + if (d.severity().ordinal() >= minSeverity.ordinal()) { + return true; + } + } + return false; + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultDiagnosticSummary.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultDiagnosticSummary.java new file mode 100644 index 000000000000..133b6080d311 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultDiagnosticSummary.java @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.internal.build; + +import org.apache.maven.api.build.report.Diagnostic; +import org.apache.maven.api.build.report.DiagnosticSummary; + +import static java.util.Objects.requireNonNull; + +/** + * Immutable implementation of {@link DiagnosticSummary}. + */ +record DefaultDiagnosticSummary(Diagnostic diagnostic, int count) implements DiagnosticSummary { + + DefaultDiagnosticSummary { + requireNonNull(diagnostic, "diagnostic"); + if (count < 1) { + throw new IllegalArgumentException("count must be >= 1"); + } + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultFailureReport.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultFailureReport.java new file mode 100644 index 000000000000..b0bc075d34f3 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultFailureReport.java @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.internal.build; + +import java.time.Instant; + +import org.apache.maven.api.build.report.FailureReport; + +/** + * Internal immutable implementation of {@link FailureReport}. + */ +record DefaultFailureReport( + String module, String mojo, Instant timestamp, String exceptionType, String message, String stackTrace) + implements FailureReport {} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java new file mode 100644 index 000000000000..5dda07716343 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java @@ -0,0 +1,55 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.internal.build; + +import java.time.Instant; + +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.LogLevel; + +/** + * Immutable implementation of {@link LogEvent}. + *

+ * Public to allow construction from other packages within the Maven + * implementation (e.g. {@code ProjectBuildLogAppender}). + * + * @param timestamp when the event was produced + * @param level the severity level + * @param message the clean log message (without level prefix or ANSI) + * @param loggerName the name of the logger, or {@code null} + * @param stackTrace the stack trace string, or {@code null} + * @param formattedMessage the fully formatted console line, or {@code null} + */ +public record DefaultLogEvent( + Instant timestamp, + LogLevel level, + String message, + String loggerName, + String stackTrace, + String formattedMessage) + implements LogEvent { + + /** + * Convenience constructor for events created without a formatted message + * (e.g. in tests or programmatic construction). + */ + DefaultLogEvent(Instant timestamp, LogLevel level, String message, String loggerName, String stackTrace) { + this(timestamp, level, message, loggerName, stackTrace, null); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultModuleReport.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultModuleReport.java new file mode 100644 index 000000000000..64c79636ebd1 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultModuleReport.java @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.internal.build; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import org.apache.maven.api.build.report.BuildStatus; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.ModuleReport; +import org.apache.maven.api.build.report.MojoReport; + +/** + * Internal immutable implementation of {@link ModuleReport}. + */ +record DefaultModuleReport( + String groupId, + String artifactId, + String version, + BuildStatus status, + Instant startTime, + Duration duration, + List mojos, + List output) + implements ModuleReport { + + @Override + public List mojos() { + return List.copyOf(mojos); + } + + @Override + public List output() { + return List.copyOf(output); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultMojoReport.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultMojoReport.java new file mode 100644 index 000000000000..5270d9c11fe3 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultMojoReport.java @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.internal.build; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import org.apache.maven.api.build.report.BuildStatus; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.MojoReport; + +/** + * Internal immutable implementation of {@link MojoReport}. + */ +record DefaultMojoReport( + String groupId, + String artifactId, + String version, + String goal, + String executionId, + String phase, + BuildStatus status, + Instant startTime, + Duration duration, + List output) + implements MojoReport { + + @Override + public List output() { + return List.copyOf(output); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java b/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java index 39573d061cd0..c1d771b5c11b 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java +++ b/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java @@ -18,6 +18,7 @@ */ package org.apache.maven.logging; +import org.apache.maven.api.build.report.LogEvent; import org.apache.maven.execution.ExecutionEvent; import org.eclipse.aether.transfer.TransferEvent; @@ -30,7 +31,7 @@ public interface BuildEventListener { void projectStarted(String projectId); - void projectLogMessage(String projectId, String event); + void projectLogMessage(String projectId, LogEvent event); void projectFinished(String projectId); diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java b/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java index 8465df0cf060..44339eae8c8c 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java +++ b/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java @@ -18,11 +18,25 @@ */ package org.apache.maven.logging; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.time.Instant; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.LogLevel; +import org.apache.maven.internal.build.DefaultLogEvent; import org.apache.maven.slf4j.MavenSimpleLogger; import org.slf4j.MDC; +import org.slf4j.spi.LocationAwareLogger; /** - * Forwards log messages to the client. + * Forwards log messages to the client as structured {@link LogEvent} objects. + *

+ * Installs itself as a {@link MavenSimpleLogger.LogSink} to intercept all + * SLF4J log output, enrich it with structured metadata (level, logger name, + * clean message, formatted output), and forward to the active + * {@link BuildEventListener}. */ public class ProjectBuildLogAppender implements AutoCloseable { @@ -76,13 +90,35 @@ public ProjectBuildLogAppender(BuildEventListener buildEventListener) { MavenSimpleLogger.setLogSink(this::accept); } - protected void accept(String message) { + protected void accept( + int level, String loggerName, String cleanMessage, String formattedMessage, Throwable throwable) { String projectId = MDC.get(KEY_PROJECT_ID); - buildEventListener.projectLogMessage(projectId, message); + Instant timestamp = MonotonicClock.now(); + LogLevel logLevel = toLogLevel(level); + String stackTrace = throwable != null ? formatStackTrace(throwable) : null; + LogEvent event = + new DefaultLogEvent(timestamp, logLevel, cleanMessage, loggerName, stackTrace, formattedMessage); + buildEventListener.projectLogMessage(projectId, event); } @Override public void close() throws Exception { MavenSimpleLogger.setLogSink(null); } + + private static LogLevel toLogLevel(int level) { + return switch (level) { + case LocationAwareLogger.TRACE_INT -> LogLevel.TRACE; + case LocationAwareLogger.DEBUG_INT -> LogLevel.DEBUG; + case LocationAwareLogger.INFO_INT -> LogLevel.INFO; + case LocationAwareLogger.WARN_INT -> LogLevel.WARN; + default -> LogLevel.ERROR; + }; + } + + private static String formatStackTrace(Throwable t) { + StringWriter sw = new StringWriter(); + t.printStackTrace(new PrintWriter(sw)); + return sw.toString(); + } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java b/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java index 87f7baa1fd59..37c49a92c1c4 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java +++ b/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java @@ -20,6 +20,7 @@ import java.util.function.Consumer; +import org.apache.maven.api.build.report.LogEvent; import org.apache.maven.execution.ExecutionEvent; import org.eclipse.aether.transfer.TransferEvent; @@ -38,8 +39,9 @@ public void sessionStarted(ExecutionEvent event) {} public void projectStarted(String projectId) {} @Override - public void projectLogMessage(String projectId, String event) { - log(event); + public void projectLogMessage(String projectId, LogEvent event) { + String formatted = event.formattedMessage(); + log(formatted != null ? formatted : event.message()); } @Override diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportCollectorTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportCollectorTest.java new file mode 100644 index 000000000000..022a92d0fa09 --- /dev/null +++ b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportCollectorTest.java @@ -0,0 +1,408 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.internal.build; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Properties; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.BuildReport; +import org.apache.maven.api.build.report.BuildStatus; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.DefaultMavenExecutionRequest; +import org.apache.maven.execution.DefaultMavenExecutionResult; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.plugin.MojoExecution; +import org.apache.maven.plugin.descriptor.MojoDescriptor; +import org.apache.maven.plugin.descriptor.PluginDescriptor; +import org.apache.maven.project.MavenProject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BuildReportCollectorTest { + + @TempDir + Path tempDir; + + private BuildReportCollector collector; + + @BeforeEach + void setUp() { + collector = new BuildReportCollector(); + } + + @Test + void testBuildReportAssembly() { + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + MavenExecutionResult result = session.getResult(); + + // Simulate: session started → project started → mojo started → mojo succeeded → project succeeded → session + // ended + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null)); + + MojoExecution mojo = createMojoExecution( + "org.apache.maven.plugins", "maven-compiler-plugin", "3.15.0", "compile", "default-compile", "compile"); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, project, mojo)); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoSucceeded, session, project, mojo)); + + // Record build success in the result + result.addBuildSummary(new BuildSuccess(project, 5000)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null)); + + // Build the report + BuildReport report = collector.buildReport(session); + + assertNotNull(report); + assertEquals(BuildStatus.SUCCESS, report.status()); + assertEquals(1, report.formatVersion()); + assertEquals("1.0.0", report.mavenVersion()); + assertFalse(report.multiModule()); + assertEquals(1, report.threads()); + assertEquals(1, report.modules().size()); + assertEquals("org.example", report.modules().get(0).groupId()); + assertEquals("my-app", report.modules().get(0).artifactId()); + assertEquals(BuildStatus.SUCCESS, report.modules().get(0).status()); + assertEquals(1, report.modules().get(0).mojos().size()); + assertEquals("compile", report.modules().get(0).mojos().get(0).goal()); + assertEquals(BuildStatus.SUCCESS, report.modules().get(0).mojos().get(0).status()); + assertTrue(report.failures().isEmpty()); + } + + @Test + void testBuildReportWithFailure() { + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + MavenExecutionResult result = session.getResult(); + + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null)); + + MojoExecution mojo = createMojoExecution( + "org.apache.maven.plugins", "maven-compiler-plugin", "3.15.0", "compile", "default-compile", "compile"); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, project, mojo)); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoFailed, session, project, mojo)); + + RuntimeException failure = new RuntimeException("Compilation failure: 3 errors"); + result.addBuildSummary(new org.apache.maven.execution.BuildFailure(project, 3000, failure)); + result.addException(failure); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectFailed, session, project, null)); + + BuildReport report = collector.buildReport(session); + + assertEquals(BuildStatus.FAILURE, report.status()); + assertEquals(1, report.failures().size()); + assertEquals("org.example:my-app:1.0.0", report.failures().get(0).module()); + assertTrue(report.failures().get(0).message().contains("Compilation failure")); + assertNotNull(report.failures().get(0).timestamp(), "failure should have a timestamp"); + assertEquals("RuntimeException", report.failures().get(0).exceptionType(), "exceptionType from cause"); + + // Navigate from failure → module → mojo using lookup methods + var failureReport = report.failures().get(0); + var moduleOpt = report.findModule(failureReport); + assertTrue(moduleOpt.isPresent(), "findModule(FailureReport) should find the module"); + assertEquals("my-app", moduleOpt.get().artifactId()); + assertEquals("org.example:my-app:1.0.0", moduleOpt.get().id()); + + assertNotNull(failureReport.mojo(), "failure should reference a mojo"); + var mojoOpt = moduleOpt.get().findMojo(failureReport.mojo()); + assertTrue(mojoOpt.isPresent(), "findMojo should find the failed mojo"); + assertEquals("compile", mojoOpt.get().goal()); + assertEquals(BuildStatus.FAILURE, mojoOpt.get().status()); + assertEquals("maven-compiler-plugin:3.15.0:compile", mojoOpt.get().id()); + } + + @Test + void testWriteReportToFile() throws IOException { + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + session.getResult().addBuildSummary(new BuildSuccess(project, 1000)); + + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null)); + + BuildReport report = collector.buildReport(session); + collector.writeReport(report, session); + + Path reportsDir = tempDir.resolve("target").resolve(BuildReportCollector.REPORT_DIR); + Path latestFile = reportsDir.resolve(BuildReportCollector.REPORT_LATEST); + assertTrue(Files.exists(latestFile), "build-report-latest.json should exist"); + + String content = Files.readString(latestFile); + assertTrue(content.contains("\"formatVersion\": 1")); + assertTrue(content.contains("\"status\": \"SUCCESS\"")); + assertTrue(content.contains("\"artifactId\": \"my-app\"")); + } + + @Test + void testMultiModuleBuild() { + MavenProject parent = createProject("org.example", "parent", "1.0.0"); + MavenProject child1 = createProject("org.example", "child-api", "1.0.0"); + MavenProject child2 = createProject("org.example", "child-impl", "1.0.0"); + + MavenSession session = createSession(parent, child1, child2); + MavenExecutionResult result = session.getResult(); + + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, parent, null)); + + // Build each module + for (MavenProject p : List.of(parent, child1, child2)) { + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, p, null)); + result.addBuildSummary(new BuildSuccess(p, 1000)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, p, null)); + } + + BuildReport report = collector.buildReport(session); + + assertEquals(BuildStatus.SUCCESS, report.status()); + assertTrue(report.multiModule()); + assertEquals(3, report.modules().size()); + assertEquals("parent", report.modules().get(0).artifactId()); + assertEquals("child-api", report.modules().get(1).artifactId()); + assertEquals("child-impl", report.modules().get(2).artifactId()); + } + + // ---- Warning mode tests ---- + + @Test + void testWarningModeNoneSuppressesSummary() { + // Register a diagnostic so there's something to print + collector.getDiagnosticCollector().report(DefaultDiagnostic.warning("test-key", "test warning", "test-source")); + + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + session.getUserProperties().setProperty("maven.build.warningMode", "none"); + session.getResult().addBuildSummary(new BuildSuccess(project, 1000)); + + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.SessionEnded, session, project, null)); + + // No exceptions means summary was suppressed (in "none" mode) + assertFalse(session.getResult().hasExceptions()); + } + + @Test + void testWarningModeFailAddsException() { + collector.getDiagnosticCollector().report(DefaultDiagnostic.warning("test-key", "test warning", "test-source")); + + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + session.getUserProperties().setProperty("maven.build.warningMode", "fail"); + session.getResult().addBuildSummary(new BuildSuccess(project, 1000)); + + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.SessionEnded, session, project, null)); + + assertTrue(session.getResult().hasExceptions(), "Build should fail with --warning-mode=fail and warnings"); + assertTrue( + session.getResult().getExceptions().get(0).getMessage().contains("warning"), + "Exception message should mention warnings"); + } + + @Test + void testWarningModeFailNoWarningsNoException() { + // No warnings registered — fail mode should not add exception + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + session.getUserProperties().setProperty("maven.build.warningMode", "fail"); + session.getResult().addBuildSummary(new BuildSuccess(project, 1000)); + + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.SessionEnded, session, project, null)); + + assertFalse(session.getResult().hasExceptions(), "No warnings, no exception"); + } + + @Test + void testWarningModeSummaryIsDefault() { + collector.getDiagnosticCollector().report(DefaultDiagnostic.warning("test-key", "test warning", "test-source")); + + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + // No warningMode property set — defaults to "summary" + session.getResult().addBuildSummary(new BuildSuccess(project, 1000)); + + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.SessionEnded, session, project, null)); + + // Summary mode should NOT fail the build + assertFalse(session.getResult().hasExceptions()); + } + + // ---- Synthetic diagnostic key tests ---- + + @Test + void testSyntheticDiagnosticKeyStripsPaths() { + String key1 = BuildReportCollector.syntheticDiagnosticKey("compiler", "Foo.java:42: unchecked cast"); + String key2 = BuildReportCollector.syntheticDiagnosticKey("compiler", "Bar.java:99: unchecked cast"); + assertEquals(key1, key2, "Same warning from different files should deduplicate"); + } + + @Test + void testSyntheticDiagnosticKeyDifferentMessages() { + String key1 = BuildReportCollector.syntheticDiagnosticKey("compiler", "unchecked cast"); + String key2 = BuildReportCollector.syntheticDiagnosticKey("compiler", "deprecated API"); + assertFalse(key1.equals(key2), "Different messages should produce different keys"); + } + + @Test + void testSyntheticDiagnosticKeyIncludesLoggerSuffix() { + String key = BuildReportCollector.syntheticDiagnosticKey( + "org.apache.maven.plugins.compiler.CompilerMojo", "unchecked cast"); + assertTrue(key.startsWith("auto:CompilerMojo:"), "Key should include short logger suffix"); + } + + // ---- Diagnostic suppression wiring tests ---- + + @Test + void testDiagnosticSuppressionFromUserProperty() { + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + session.getUserProperties().setProperty("maven.diagnostic.suppress", "key-a,key-b"); + session.getResult().addBuildSummary(new BuildSuccess(project, 1000)); + + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null)); + + // Report diagnostics — key-a and key-b should be suppressed + collector.getDiagnosticCollector().report(DefaultDiagnostic.warning("key-a", "warning a", null)); + collector.getDiagnosticCollector().report(DefaultDiagnostic.warning("key-b", "warning b", null)); + collector.getDiagnosticCollector().report(DefaultDiagnostic.warning("key-c", "warning c", null)); + + assertEquals(1, collector.getDiagnosticCollector().getDiagnostics().size()); + assertEquals( + "key-c", + collector.getDiagnosticCollector().getDiagnostics().get(0).key()); + } + + @Test + void testDiagnosticSuppressionWildcard() { + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + session.getUserProperties().setProperty("maven.diagnostic.suppress", "auto:*"); + session.getResult().addBuildSummary(new BuildSuccess(project, 1000)); + + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null)); + + collector.getDiagnosticCollector().report(DefaultDiagnostic.warning("auto:Compiler:abc", "unchecked", null)); + collector.getDiagnosticCollector().report(DefaultDiagnostic.warning("explicit-key", "explicit", null)); + + assertEquals(1, collector.getDiagnosticCollector().getDiagnostics().size()); + assertEquals( + "explicit-key", + collector.getDiagnosticCollector().getDiagnostics().get(0).key()); + } + + // ---- Test helpers ---- + + private MavenProject createProject(String groupId, String artifactId, String version) { + MavenProject project = new MavenProject(); + project.setGroupId(groupId); + project.setArtifactId(artifactId); + project.setVersion(version); + return project; + } + + private MavenSession createSession(MavenProject... projects) { + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setStartInstant(MonotonicClock.now()); + request.setGoals(List.of("clean", "install")); + request.setTopDirectory(tempDir); + + Properties systemProperties = new Properties(); + systemProperties.setProperty("maven.version", "1.0.0"); + request.setSystemProperties(systemProperties); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + + @SuppressWarnings("deprecation") + MavenSession session = new MavenSession(null, null, request, result); + session.setProjects(List.of(projects)); + return session; + } + + private MojoExecution createMojoExecution( + String groupId, String artifactId, String version, String goal, String executionId, String phase) { + @SuppressWarnings("deprecation") + PluginDescriptor pluginDescriptor = new PluginDescriptor(); + pluginDescriptor.setGroupId(groupId); + pluginDescriptor.setArtifactId(artifactId); + pluginDescriptor.setVersion(version); + + MojoDescriptor mojoDescriptor = new MojoDescriptor(); + mojoDescriptor.setGoal(goal); + mojoDescriptor.setPluginDescriptor(pluginDescriptor); + + MojoExecution execution = new MojoExecution(mojoDescriptor, executionId); + execution.setLifecyclePhase(phase); + + return execution; + } + + private ExecutionEvent createEvent( + ExecutionEvent.Type type, MavenSession session, MavenProject project, MojoExecution mojo) { + return new ExecutionEvent() { + @Override + public Type getType() { + return type; + } + + @Override + public MavenSession getSession() { + return session; + } + + @Override + public MavenProject getProject() { + return project; + } + + @Override + public MojoExecution getMojoExecution() { + return mojo; + } + + @Override + public Exception getException() { + return null; + } + }; + } +} diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportIntegrationTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportIntegrationTest.java new file mode 100644 index 000000000000..600a03379153 --- /dev/null +++ b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportIntegrationTest.java @@ -0,0 +1,345 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.internal.build; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Properties; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.BuildReport; +import org.apache.maven.api.build.report.BuildStatus; +import org.apache.maven.api.build.report.Diagnostic; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.DefaultMavenExecutionRequest; +import org.apache.maven.execution.DefaultMavenExecutionResult; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.plugin.MojoExecution; +import org.apache.maven.plugin.descriptor.MojoDescriptor; +import org.apache.maven.plugin.descriptor.PluginDescriptor; +import org.apache.maven.project.MavenProject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Integration test that exercises the full build report pipeline: + * BuildReportCollector → DiagnosticCollector → BuildReportJsonWriter → file. + *

+ * This test simulates a complete multi-module build lifecycle with mojo + * executions, diagnostics, and failures, then verifies the resulting + * JSON report file contains all expected data. + */ +class BuildReportIntegrationTest { + + @TempDir + Path tempDir; + + private DefaultDiagnosticCollector diagnosticCollector; + private BuildReportCollector collector; + + @BeforeEach + void setUp() { + diagnosticCollector = new DefaultDiagnosticCollector(); + collector = new BuildReportCollector(diagnosticCollector); + } + + /** + * Full lifecycle: multi-module build with mojos, diagnostics, and JSON persistence. + */ + @Test + void testFullMultiModuleBuildWithDiagnostics() throws IOException { + MavenProject parent = createProject("com.example", "parent", "2.0.0"); + MavenProject api = createProject("com.example", "api", "2.0.0"); + MavenProject impl = createProject("com.example", "impl", "2.0.0"); + + MavenSession session = createSession(parent, api, impl); + MavenExecutionResult result = session.getResult(); + + // Session starts + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, parent, null)); + + // --- Module: parent --- + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, parent, null)); + result.addBuildSummary(new BuildSuccess(parent, 500)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, parent, null)); + + // --- Module: api --- + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, api, null)); + + MojoExecution compileApi = createMojoExecution( + "org.apache.maven.plugins", "maven-compiler-plugin", "3.15.0", "compile", "default-compile", "compile"); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, api, compileApi)); + + // Report a deprecation warning during compile + diagnosticCollector.report(new DefaultDiagnostic( + "deprecated-source-target", + Diagnostic.Severity.WARNING, + "source/target value 8 is deprecated", + "maven-compiler-plugin:3.15.0:compile", + null, + -1, + -1, + "Update to 11 or higher", + null)); + + collector.onEvent(createEvent(ExecutionEvent.Type.MojoSucceeded, session, api, compileApi)); + + MojoExecution testApi = createMojoExecution( + "org.apache.maven.plugins", "maven-surefire-plugin", "3.5.0", "test", "default-test", "test"); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, api, testApi)); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoSucceeded, session, api, testApi)); + + result.addBuildSummary(new BuildSuccess(api, 3000)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, api, null)); + + // --- Module: impl --- + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, impl, null)); + + MojoExecution compileImpl = createMojoExecution( + "org.apache.maven.plugins", "maven-compiler-plugin", "3.15.0", "compile", "default-compile", "compile"); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, impl, compileImpl)); + + // Same warning fires again in a different module — should be deduplicated + diagnosticCollector.report( + DefaultDiagnostic.warning("deprecated-source-target", "source/target value 8 is deprecated", "impl")); + + // Also report a unique info diagnostic + diagnosticCollector.report(DefaultDiagnostic.info("build-note", "Using JDK 21 toolchain", "toolchain")); + + collector.onEvent(createEvent(ExecutionEvent.Type.MojoSucceeded, session, impl, compileImpl)); + result.addBuildSummary(new BuildSuccess(impl, 2000)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, impl, null)); + + // --- Build report --- + BuildReport report = collector.buildReport(session); + + // Basic assertions + assertNotNull(report); + assertEquals(BuildStatus.SUCCESS, report.status()); + assertTrue(report.multiModule()); + assertEquals(3, report.modules().size()); + + // Diagnostics in the report + assertEquals(2, report.diagnostics().size()); + assertEquals("deprecated-source-target", report.diagnostics().get(0).key()); + assertEquals(Diagnostic.Severity.WARNING, report.diagnostics().get(0).severity()); + assertEquals("build-note", report.diagnostics().get(1).key()); + assertEquals(Diagnostic.Severity.INFO, report.diagnostics().get(1).severity()); + + // Diagnostic summary should show count = 2 for the warning + assertEquals(2, diagnosticCollector.getSummary().get(0).count()); + assertEquals(1, diagnosticCollector.getSummary().get(1).count()); + + // Module reports + assertEquals("parent", report.modules().get(0).artifactId()); + assertEquals("api", report.modules().get(1).artifactId()); + assertEquals(2, report.modules().get(1).mojos().size()); + assertEquals("compile", report.modules().get(1).mojos().get(0).goal()); + assertEquals("test", report.modules().get(1).mojos().get(1).goal()); + assertEquals("impl", report.modules().get(2).artifactId()); + + // Write to JSON and verify + collector.writeReport(report, session); + + Path reportsDir = tempDir.resolve("target").resolve(BuildReportCollector.REPORT_DIR); + Path latestFile = reportsDir.resolve(BuildReportCollector.REPORT_LATEST); + assertTrue(Files.exists(latestFile), "build-report-latest.json should exist"); + + String json = Files.readString(latestFile); + + // Verify JSON structure + assertTrue(json.contains("\"formatVersion\": 1")); + assertTrue(json.contains("\"status\": \"SUCCESS\"")); + assertTrue(json.contains("\"multiModule\": true")); + assertTrue(json.contains("\"threads\": 1")); + + // Modules in JSON + assertTrue(json.contains("\"artifactId\": \"parent\"")); + assertTrue(json.contains("\"artifactId\": \"api\"")); + assertTrue(json.contains("\"artifactId\": \"impl\"")); + + // Mojos in JSON + assertTrue(json.contains("\"goal\": \"compile\"")); + assertTrue(json.contains("\"goal\": \"test\"")); + + // Diagnostics in JSON + assertTrue(json.contains("\"diagnostics\": [")); + assertTrue(json.contains("\"key\": \"deprecated-source-target\"")); + assertTrue(json.contains("\"severity\": \"WARNING\"")); + assertTrue(json.contains("\"suggestion\": \"Update to 11 or higher\"")); + assertTrue(json.contains("\"key\": \"build-note\"")); + assertTrue(json.contains("\"severity\": \"INFO\"")); + + // Failures should be empty + assertTrue(json.contains("\"failures\": []")); + + // Output arrays should be present (even if empty in unit test — no SLF4J sink) + assertTrue(json.contains("\"output\": [")); + } + + /** + * Tests that the diagnostic summary printing doesn't throw when there are no diagnostics. + */ + @Test + void testDiagnosticSummaryWithNoDiagnostics() { + // Should be a no-op, not throw + collector.printDiagnosticSummary(); + } + + /** + * Tests that the diagnostic summary printing handles mixed severities. + */ + @Test + void testDiagnosticSummaryWithMixedSeverities() { + diagnosticCollector.report(DefaultDiagnostic.warning("warn-1", "first warning", null)); + diagnosticCollector.report(DefaultDiagnostic.warning("warn-1", "first warning (dup)", null)); + diagnosticCollector.report(DefaultDiagnostic.error("err-1", "first error", null)); + diagnosticCollector.report(DefaultDiagnostic.info("info-1", "info note", null)); + + // Should not throw + collector.printDiagnosticSummary(); + + assertTrue(diagnosticCollector.hasWarnings()); + assertTrue(diagnosticCollector.hasErrors()); + } + + /** + * Tests navigation methods on the built report. + */ + @Test + void testReportNavigationMethods() { + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + MavenExecutionResult result = session.getResult(); + + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null)); + + MojoExecution mojo = createMojoExecution( + "org.apache.maven.plugins", "maven-compiler-plugin", "3.15.0", "compile", "default-compile", "compile"); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, project, mojo)); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoSucceeded, session, project, mojo)); + + result.addBuildSummary(new BuildSuccess(project, 5000)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null)); + + BuildReport report = collector.buildReport(session); + + // findModule by GAV + var moduleOpt = report.findModule("org.example:my-app:1.0.0"); + assertTrue(moduleOpt.isPresent()); + assertEquals("my-app", moduleOpt.get().artifactId()); + assertEquals("org.example:my-app:1.0.0", moduleOpt.get().id()); + + // findMojo by id + var mojoOpt = moduleOpt.get().findMojo("maven-compiler-plugin:3.15.0:compile"); + assertTrue(mojoOpt.isPresent()); + assertEquals("compile", mojoOpt.get().goal()); + + // Not found + assertFalse(report.findModule("nonexistent:module:1.0").isPresent()); + } + + // ---- Test helpers ---- + + private MavenProject createProject(String groupId, String artifactId, String version) { + MavenProject project = new MavenProject(); + project.setGroupId(groupId); + project.setArtifactId(artifactId); + project.setVersion(version); + return project; + } + + private MavenSession createSession(MavenProject... projects) { + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setStartInstant(MonotonicClock.now()); + request.setGoals(List.of("clean", "install")); + request.setTopDirectory(tempDir); + + Properties systemProperties = new Properties(); + systemProperties.setProperty("maven.version", "4.1.0-SNAPSHOT"); + request.setSystemProperties(systemProperties); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + + @SuppressWarnings("deprecation") + MavenSession session = new MavenSession(null, null, request, result); + session.setProjects(List.of(projects)); + return session; + } + + private MojoExecution createMojoExecution( + String groupId, String artifactId, String version, String goal, String executionId, String phase) { + @SuppressWarnings("deprecation") + PluginDescriptor pluginDescriptor = new PluginDescriptor(); + pluginDescriptor.setGroupId(groupId); + pluginDescriptor.setArtifactId(artifactId); + pluginDescriptor.setVersion(version); + + MojoDescriptor mojoDescriptor = new MojoDescriptor(); + mojoDescriptor.setGoal(goal); + mojoDescriptor.setPluginDescriptor(pluginDescriptor); + + MojoExecution execution = new MojoExecution(mojoDescriptor, executionId); + execution.setLifecyclePhase(phase); + + return execution; + } + + private ExecutionEvent createEvent( + ExecutionEvent.Type type, MavenSession session, MavenProject project, MojoExecution mojo) { + return new ExecutionEvent() { + @Override + public Type getType() { + return type; + } + + @Override + public MavenSession getSession() { + return session; + } + + @Override + public MavenProject getProject() { + return project; + } + + @Override + public MojoExecution getMojoExecution() { + return mojo; + } + + @Override + public Exception getException() { + return null; + } + }; + } +} diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportJsonWriterTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportJsonWriterTest.java new file mode 100644 index 000000000000..716098948c58 --- /dev/null +++ b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportJsonWriterTest.java @@ -0,0 +1,407 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.internal.build; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import org.apache.maven.api.build.report.BuildReport; +import org.apache.maven.api.build.report.BuildStatus; +import org.apache.maven.api.build.report.Diagnostic; +import org.apache.maven.api.build.report.FailureReport; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.LogLevel; +import org.apache.maven.api.build.report.ModuleReport; +import org.apache.maven.api.build.report.MojoReport; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BuildReportJsonWriterTest { + + private static final Instant BASE_TIME = Instant.parse("2025-01-15T10:30:00Z"); + + @Test + void testSuccessfulBuildReport() { + List mojoOutput = List.of( + new DefaultLogEvent( + BASE_TIME.plusSeconds(6), LogLevel.INFO, "Compiling 42 source files", "o.a.m.compiler", null), + new DefaultLogEvent(BASE_TIME.plusSeconds(7), LogLevel.INFO, "BUILD SUCCESS", "o.a.m.compiler", null)); + + MojoReport mojo = new DefaultMojoReport( + "org.apache.maven.plugins", + "maven-compiler-plugin", + "3.15.0", + "compile", + "default-compile", + "compile", + BuildStatus.SUCCESS, + BASE_TIME.plusSeconds(5), + Duration.ofMillis(2100), + mojoOutput); + + List moduleOutput = List.of(new DefaultLogEvent( + BASE_TIME.plusSeconds(2), + LogLevel.INFO, + "Resolving dependencies for maven-core", + "o.a.m.resolver", + null)); + + ModuleReport module = new DefaultModuleReport( + "org.apache.maven", + "maven-core", + "4.1.0-SNAPSHOT", + BuildStatus.SUCCESS, + BASE_TIME.plusSeconds(1), + Duration.ofMillis(12345), + List.of(mojo), + moduleOutput); + + List buildOutput = List.of( + new DefaultLogEvent(BASE_TIME, LogLevel.INFO, "Reactor Build Order:", "o.a.m.reactor", null), + new DefaultLogEvent(BASE_TIME, LogLevel.INFO, "Maven Core", "o.a.m.reactor", null)); + + BuildReport report = new DefaultBuildReport( + BuildStatus.SUCCESS, + Duration.ofMillis(30000), + BASE_TIME, + "4.1.0-SNAPSHOT", + "21.0.1", + List.of("clean", "install"), + "org.apache.maven:maven:4.1.0-SNAPSHOT", + true, + 4, + List.of(module), + List.of(), + List.of(), + buildOutput); + + String json = BuildReportJsonWriter.toJson(report); + + assertTrue(json.contains("\"formatVersion\": 1")); + assertTrue(json.contains("\"status\": \"SUCCESS\"")); + assertTrue(json.contains("\"mavenVersion\": \"4.1.0-SNAPSHOT\"")); + assertTrue(json.contains("\"javaVersion\": \"21.0.1\"")); + assertTrue(json.contains("\"goals\": [\"clean\", \"install\"]")); + assertTrue(json.contains("\"multiModule\": true")); + assertTrue(json.contains("\"threads\": 4")); + assertTrue(json.contains("\"groupId\": \"org.apache.maven\"")); + assertTrue(json.contains("\"artifactId\": \"maven-core\"")); + assertTrue(json.contains("\"goal\": \"compile\"")); + assertTrue(json.contains("\"executionId\": \"default-compile\"")); + assertTrue(json.contains("\"failures\": []")); + // Module and mojo start times + assertTrue(json.contains("\"startTime\": \"2025-01-15T10:30:01Z\""), "module startTime"); + assertTrue(json.contains("\"startTime\": \"2025-01-15T10:30:05Z\""), "mojo startTime"); + // Structured log events at all three levels + assertTrue(json.contains("\"message\": \"Compiling 42 source files\""), "mojo-level log event"); + assertTrue(json.contains("\"message\": \"Resolving dependencies for maven-core\""), "module-level log event"); + assertTrue(json.contains("\"message\": \"Reactor Build Order:\""), "build-level log event"); + // Log event structure + assertTrue(json.contains("\"level\": \"INFO\""), "log level"); + assertTrue(json.contains("\"loggerName\": \"o.a.m.compiler\""), "logger name"); + } + + @Test + void testFailedBuildReport() { + FailureReport failure = new DefaultFailureReport( + "org.apache.maven:maven-core:4.1.0-SNAPSHOT", + "maven-compiler-plugin:3.15.0:compile", + BASE_TIME.plusSeconds(3), + "CompilationFailureException", + "Compilation failure: 3 errors", + "org.apache.maven.plugin.compiler.CompilationFailureException: ...\n\tat ...\n"); + + BuildReport report = new DefaultBuildReport( + BuildStatus.FAILURE, + Duration.ofMillis(5000), + BASE_TIME, + "4.1.0-SNAPSHOT", + "21.0.1", + List.of("compile"), + "org.apache.maven:maven-core:4.1.0-SNAPSHOT", + false, + 1, + List.of(), + List.of(failure), + List.of(), + List.of()); + + String json = BuildReportJsonWriter.toJson(report); + + assertTrue(json.contains("\"status\": \"FAILURE\"")); + assertTrue(json.contains("\"module\": \"org.apache.maven:maven-core:4.1.0-SNAPSHOT\"")); + assertTrue(json.contains("\"mojo\": \"maven-compiler-plugin:3.15.0:compile\"")); + assertTrue(json.contains("\"message\": \"Compilation failure: 3 errors\"")); + assertTrue(json.contains("\"stackTrace\"")); + // New enriched fields + assertTrue(json.contains("\"timestamp\": \"2025-01-15T10:30:03Z\""), "failure timestamp"); + assertTrue(json.contains("\"exceptionType\": \"CompilationFailureException\""), "failure exceptionType"); + } + + @Test + void testJsonStringEscaping() { + FailureReport failure = new DefaultFailureReport( + "com.example:test:1.0", + null, + BASE_TIME, + null, + "Error: \"unexpected\" value\nwith newline\tand tab", + null); + + BuildReport report = new DefaultBuildReport( + BuildStatus.FAILURE, + Duration.ofMillis(100), + BASE_TIME, + "4.1.0", + "21", + List.of(), + "com.example:test:1.0", + false, + 1, + List.of(), + List.of(failure), + List.of(), + List.of()); + + String json = BuildReportJsonWriter.toJson(report); + + // Check proper JSON escaping + assertTrue(json.contains("\\\"unexpected\\\"")); + assertTrue(json.contains("\\n")); + assertTrue(json.contains("\\t")); + } + + @Test + void testEmptyModulesAndFailures() { + BuildReport report = new DefaultBuildReport( + BuildStatus.SUCCESS, + Duration.ofMillis(100), + BASE_TIME, + "4.1.0", + "21", + List.of(), + "com.example:test:1.0", + false, + 1, + List.of(), + List.of(), + List.of(), + List.of()); + + String json = BuildReportJsonWriter.toJson(report); + + assertTrue(json.contains("\"modules\": []")); + assertTrue(json.contains("\"failures\": []")); + } + + @Test + void testFormatVersion() { + BuildReport report = new DefaultBuildReport( + BuildStatus.SUCCESS, + Duration.ZERO, + BASE_TIME, + "4.1.0", + "21", + List.of(), + "test:test:1.0", + false, + 1, + List.of(), + List.of(), + List.of(), + List.of()); + + assertEquals(1, report.formatVersion()); + } + + @Test + void testMultipleModules() { + ModuleReport mod1 = new DefaultModuleReport( + "com.example", + "api", + "1.0", + BuildStatus.SUCCESS, + BASE_TIME.plusSeconds(1), + Duration.ofSeconds(5), + List.of(), + List.of()); + ModuleReport mod2 = new DefaultModuleReport( + "com.example", + "impl", + "1.0", + BuildStatus.SUCCESS, + BASE_TIME.plusSeconds(6), + Duration.ofSeconds(10), + List.of(), + List.of()); + ModuleReport mod3 = new DefaultModuleReport( + "com.example", + "web", + "1.0", + BuildStatus.SKIPPED, + BASE_TIME.plusSeconds(16), + Duration.ZERO, + List.of(), + List.of()); + + BuildReport report = new DefaultBuildReport( + BuildStatus.SUCCESS, + Duration.ofSeconds(15), + BASE_TIME, + "4.1.0", + "21", + List.of("install"), + "com.example:parent:1.0", + true, + 1, + List.of(mod1, mod2, mod3), + List.of(), + List.of(), + List.of()); + + String json = BuildReportJsonWriter.toJson(report); + + // All modules present + assertTrue(json.contains("\"artifactId\": \"api\"")); + assertTrue(json.contains("\"artifactId\": \"impl\"")); + assertTrue(json.contains("\"artifactId\": \"web\"")); + assertTrue(json.contains("\"status\": \"SKIPPED\"")); + } + + @Test + void testNullMojoFields() { + // mojo with null executionId and phase (direct invocation) + MojoReport mojo = new DefaultMojoReport( + "org.apache.maven.plugins", + "maven-help-plugin", + "3.4.1", + "effective-pom", + null, + null, + BuildStatus.SUCCESS, + BASE_TIME.plusSeconds(1), + Duration.ofMillis(500), + List.of()); + + String json = BuildReportJsonWriter.toJson(new DefaultBuildReport( + BuildStatus.SUCCESS, + Duration.ofSeconds(1), + BASE_TIME, + "4.1.0", + "21", + List.of("help:effective-pom"), + "test:test:1.0", + false, + 1, + List.of(new DefaultModuleReport( + "test", + "test", + "1.0", + BuildStatus.SUCCESS, + BASE_TIME, + Duration.ofSeconds(1), + List.of(mojo), + List.of())), + List.of(), + List.of(), + List.of())); + + assertTrue(json.contains("\"executionId\": null")); + assertTrue(json.contains("\"phase\": null")); + assertFalse(json.contains("\"executionId\": \"null\"")); + } + + @Test + void testDiagnosticsSerialization() { + Diagnostic d1 = new DefaultDiagnostic( + "deprecated-source-target", + Diagnostic.Severity.WARNING, + "source/target value 8 is deprecated", + "maven-compiler-plugin:3.15.0:compile", + "src/main/java/Foo.java", + 42, + 1, + "Update to 11 or higher", + "https://example.com/docs/compiler"); + + Diagnostic d2 = DefaultDiagnostic.error("compilation-failure", "3 errors found", "maven-compiler-plugin"); + + BuildReport report = new DefaultBuildReport( + BuildStatus.FAILURE, + Duration.ofSeconds(5), + BASE_TIME, + "4.1.0-SNAPSHOT", + "21.0.1", + List.of("compile"), + "com.example:test:1.0", + false, + 1, + List.of(), + List.of(), + List.of(d1, d2), + List.of()); + + String json = BuildReportJsonWriter.toJson(report); + + // Diagnostic structure + assertTrue(json.contains("\"diagnostics\": ["), "diagnostics array present"); + assertTrue(json.contains("\"key\": \"deprecated-source-target\""), "diagnostic key"); + assertTrue(json.contains("\"severity\": \"WARNING\""), "diagnostic severity"); + assertTrue(json.contains("\"message\": \"source/target value 8 is deprecated\""), "diagnostic message"); + assertTrue(json.contains("\"source\": \"maven-compiler-plugin:3.15.0:compile\""), "diagnostic source"); + assertTrue(json.contains("\"file\": \"src/main/java/Foo.java\""), "diagnostic file"); + assertTrue(json.contains("\"line\": 42"), "diagnostic line"); + assertTrue(json.contains("\"column\": 1"), "diagnostic column"); + assertTrue( + json.contains("\"suggestion\": \"Update to 11 or higher\""), + "diagnostic suggestion"); + assertTrue( + json.contains("\"documentationUrl\": \"https://example.com/docs/compiler\""), + "diagnostic documentationUrl"); + + // Second diagnostic (minimal fields) + assertTrue(json.contains("\"key\": \"compilation-failure\""), "second diagnostic key"); + assertTrue(json.contains("\"severity\": \"ERROR\""), "second diagnostic severity"); + } + + @Test + void testEmptyDiagnostics() { + BuildReport report = new DefaultBuildReport( + BuildStatus.SUCCESS, + Duration.ofMillis(100), + BASE_TIME, + "4.1.0", + "21", + List.of(), + "com.example:test:1.0", + false, + 1, + List.of(), + List.of(), + List.of(), + List.of()); + + String json = BuildReportJsonWriter.toJson(report); + assertTrue(json.contains("\"diagnostics\": []"), "empty diagnostics array"); + } +} diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/build/DefaultDiagnosticCollectorTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/build/DefaultDiagnosticCollectorTest.java new file mode 100644 index 000000000000..83fab681c187 --- /dev/null +++ b/impl/maven-core/src/test/java/org/apache/maven/internal/build/DefaultDiagnosticCollectorTest.java @@ -0,0 +1,299 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.internal.build; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import org.apache.maven.api.build.report.Diagnostic; +import org.apache.maven.api.build.report.DiagnosticSummary; +import org.junit.jupiter.api.BeforeEach; +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 DefaultDiagnosticCollectorTest { + + private DefaultDiagnosticCollector collector; + + @BeforeEach + void setUp() { + collector = new DefaultDiagnosticCollector(); + } + + @Test + void testEmptyCollector() { + assertTrue(collector.getDiagnostics().isEmpty()); + assertTrue(collector.getSummary().isEmpty()); + assertFalse(collector.hasWarnings()); + assertFalse(collector.hasErrors()); + } + + @Test + void testSingleWarning() { + Diagnostic d = DefaultDiagnostic.warning("deprecated-source", "source 8 is deprecated", "compiler:3.15.0"); + collector.report(d); + + assertEquals(1, collector.getDiagnostics().size()); + assertEquals("deprecated-source", collector.getDiagnostics().get(0).key()); + assertTrue(collector.hasWarnings()); + assertFalse(collector.hasErrors()); + } + + @Test + void testSingleError() { + Diagnostic d = DefaultDiagnostic.error("compilation-failure", "3 errors found", "compiler:3.15.0"); + collector.report(d); + + assertEquals(1, collector.getDiagnostics().size()); + assertTrue(collector.hasWarnings()); // errors are >= WARNING severity + assertTrue(collector.hasErrors()); + } + + @Test + void testInfoDoesNotCountAsWarningOrError() { + Diagnostic d = DefaultDiagnostic.info("build-summary", "Build completed", "reactor"); + collector.report(d); + + assertEquals(1, collector.getDiagnostics().size()); + assertFalse(collector.hasWarnings()); + assertFalse(collector.hasErrors()); + } + + @Test + void testDeduplicationByKey() { + Diagnostic d1 = DefaultDiagnostic.warning("deprecated-source", "source 8 is deprecated", "module-a"); + Diagnostic d2 = DefaultDiagnostic.warning("deprecated-source", "source 8 is deprecated", "module-b"); + Diagnostic d3 = DefaultDiagnostic.warning("deprecated-source", "source 8 is deprecated", "module-c"); + + collector.report(d1); + collector.report(d2); + collector.report(d3); + + // Only one unique diagnostic + assertEquals(1, collector.getDiagnostics().size()); + assertEquals("deprecated-source", collector.getDiagnostics().get(0).key()); + + // But the summary shows count = 3 + List summary = collector.getSummary(); + assertEquals(1, summary.size()); + assertEquals(3, summary.get(0).count()); + } + + @Test + void testMultipleDistinctDiagnostics() { + collector.report(DefaultDiagnostic.warning("deprecated-source", "source 8 is deprecated", "compiler")); + collector.report(DefaultDiagnostic.warning("unused-dep", "unused dependency: guava", "dependency")); + collector.report(DefaultDiagnostic.error("test-failure", "2 tests failed", "surefire")); + + assertEquals(3, collector.getDiagnostics().size()); + assertTrue(collector.hasWarnings()); + assertTrue(collector.hasErrors()); + + List summary = collector.getSummary(); + assertEquals(3, summary.size()); + // Each reported once + for (DiagnosticSummary s : summary) { + assertEquals(1, s.count()); + } + } + + @Test + void testInsertionOrderPreserved() { + collector.report(DefaultDiagnostic.warning("c-warning", "third", null)); + collector.report(DefaultDiagnostic.warning("a-warning", "first", null)); + collector.report(DefaultDiagnostic.warning("b-warning", "second", null)); + + List diagnostics = collector.getDiagnostics(); + assertEquals("c-warning", diagnostics.get(0).key()); + assertEquals("a-warning", diagnostics.get(1).key()); + assertEquals("b-warning", diagnostics.get(2).key()); + } + + @Test + void testDiagnosticsListIsUnmodifiable() { + collector.report(DefaultDiagnostic.warning("test", "test", null)); + List diagnostics = collector.getDiagnostics(); + + try { + diagnostics.add(DefaultDiagnostic.warning("another", "another", null)); + // Should not reach here + assertFalse(true, "Expected UnsupportedOperationException"); + } catch (UnsupportedOperationException e) { + // expected + } + } + + @Test + void testSummaryListIsUnmodifiable() { + collector.report(DefaultDiagnostic.warning("test", "test", null)); + List summary = collector.getSummary(); + + try { + summary.add(new DefaultDiagnosticSummary(DefaultDiagnostic.warning("x", "x", null), 1)); + assertFalse(true, "Expected UnsupportedOperationException"); + } catch (UnsupportedOperationException e) { + // expected + } + } + + @Test + void testFullDiagnosticFields() { + Diagnostic d = new DefaultDiagnostic( + "unchecked-cast", + Diagnostic.Severity.WARNING, + "unchecked cast from Object to List", + "maven-compiler-plugin:3.15.0:compile", + "src/main/java/Foo.java", + 42, + 15, + "Add @SuppressWarnings(\"unchecked\") or use a type-safe alternative", + "https://docs.oracle.com/javase/tutorial/java/generics/rawTypes.html"); + + collector.report(d); + + Diagnostic stored = collector.getDiagnostics().get(0); + assertEquals("unchecked-cast", stored.key()); + assertEquals(Diagnostic.Severity.WARNING, stored.severity()); + assertEquals("unchecked cast from Object to List", stored.message()); + assertEquals("maven-compiler-plugin:3.15.0:compile", stored.source()); + assertEquals("src/main/java/Foo.java", stored.file()); + assertEquals(42, stored.line()); + assertEquals(15, stored.column()); + assertEquals("Add @SuppressWarnings(\"unchecked\") or use a type-safe alternative", stored.suggestion()); + assertEquals("https://docs.oracle.com/javase/tutorial/java/generics/rawTypes.html", stored.documentationUrl()); + } + + // ---- Suppression tests ---- + + @Test + void testSuppressionByExactKey() { + collector.setSuppressedKeys(Set.of("deprecated-source")); + + collector.report(DefaultDiagnostic.warning("deprecated-source", "source 8 is deprecated", "compiler")); + collector.report(DefaultDiagnostic.warning("unused-dep", "unused dependency: guava", "dependency")); + + assertEquals(1, collector.getDiagnostics().size()); + assertEquals("unused-dep", collector.getDiagnostics().get(0).key()); + } + + @Test + void testSuppressionByPrefixWildcard() { + collector.setSuppressedKeys(Set.of("auto:*")); + + collector.report(DefaultDiagnostic.warning("auto:Compiler:1a2b3c", "unchecked cast", "compiler")); + collector.report(DefaultDiagnostic.warning("auto:Surefire:4d5e6f", "deprecated API", "surefire")); + collector.report(DefaultDiagnostic.warning("explicit-key", "some warning", "plugin")); + + assertEquals(1, collector.getDiagnostics().size()); + assertEquals("explicit-key", collector.getDiagnostics().get(0).key()); + } + + @Test + void testSuppressionMultipleKeys() { + collector.setSuppressedKeys(Set.of("key-a", "key-b")); + + collector.report(DefaultDiagnostic.warning("key-a", "warning a", null)); + collector.report(DefaultDiagnostic.warning("key-b", "warning b", null)); + collector.report(DefaultDiagnostic.warning("key-c", "warning c", null)); + + assertEquals(1, collector.getDiagnostics().size()); + assertEquals("key-c", collector.getDiagnostics().get(0).key()); + } + + @Test + void testSuppressionDoesNotAffectCounts() { + collector.setSuppressedKeys(Set.of("suppressed")); + + // Report suppressed key — should be silently dropped, no count + collector.report(DefaultDiagnostic.warning("suppressed", "suppressed warning", null)); + collector.report(DefaultDiagnostic.warning("kept", "kept warning", null)); + + assertEquals(1, collector.getSummary().size()); + assertEquals("kept", collector.getSummary().get(0).diagnostic().key()); + assertEquals(1, collector.getSummary().get(0).count()); + } + + @Test + void testEmptySuppressionSetAllowsAll() { + collector.setSuppressedKeys(Set.of()); + + collector.report(DefaultDiagnostic.warning("key-a", "warning a", null)); + collector.report(DefaultDiagnostic.warning("key-b", "warning b", null)); + + assertEquals(2, collector.getDiagnostics().size()); + } + + @Test + void testConcurrentReporting() throws Exception { + int threadCount = 8; + int reportsPerThread = 100; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + + List> futures = new ArrayList<>(); + for (int t = 0; t < threadCount; t++) { + int threadId = t; + futures.add(executor.submit(() -> { + try { + startLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + for (int i = 0; i < reportsPerThread; i++) { + // Half use a shared key (will be deduped), half use unique keys + if (i % 2 == 0) { + collector.report( + DefaultDiagnostic.warning("shared-key", "shared warning", "thread-" + threadId)); + } else { + collector.report(DefaultDiagnostic.warning( + "unique-" + threadId + "-" + i, "unique warning " + i, "thread-" + threadId)); + } + } + })); + } + + startLatch.countDown(); + for (Future f : futures) { + f.get(); + } + executor.shutdown(); + + // "shared-key" should be deduplicated to 1 entry + // Unique keys: 8 threads * 50 unique = 400 + // Total unique = 401 + int expectedUniqueKeys = 1 + (threadCount * (reportsPerThread / 2)); + assertEquals(expectedUniqueKeys, collector.getDiagnostics().size()); + + // shared-key should have count = 8 threads * 50 = 400 + DiagnosticSummary sharedSummary = collector.getSummary().stream() + .filter(s -> "shared-key".equals(s.diagnostic().key())) + .findFirst() + .orElseThrow(); + assertEquals(threadCount * (reportsPerThread / 2), sharedSummary.count()); + } +} diff --git a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenBaseLogger.java b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenBaseLogger.java index 20a5e7ab666b..d14167dfea51 100644 --- a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenBaseLogger.java +++ b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenBaseLogger.java @@ -232,6 +232,23 @@ protected void write(StringBuilder buf, Throwable t) { } } + /** + * Context-aware write that includes the log level, logger name, and + * clean message alongside the formatted output. Subclasses can override + * to forward structured data to a log sink. + *

+ * The default implementation delegates to {@link #write(StringBuilder, Throwable)}. + * + * @param level the SLF4J log level constant + * @param loggerName the name of the logger + * @param cleanMessage the formatted message without level/timestamp prefix + * @param formattedBuf the fully formatted log line + * @param t the throwable, may be {@code null} + */ + protected void write(int level, String loggerName, String cleanMessage, StringBuilder formattedBuf, Throwable t) { + write(formattedBuf, t); + } + protected void writeThrowable(Throwable t, PrintStream targetStream) { if (t != null) { t.printStackTrace(targetStream); @@ -375,7 +392,24 @@ private void innerHandleNormalizedLoggingCall( // Append the message buf.append(formattedMessage); - write(buf, t); + write(level.toInt(), name, formattedMessage, buf, t); + + // Notify structured log event listener (for build report capture) + onLogEvent(level.toInt(), name, formattedMessage, t); + } + + /** + * Hook called after every log event with structured data. + * Subclasses can override to forward to a structured event sink + * (e.g. for build report log capture). + * + * @param level the log level (one of the {@code LOG_LEVEL_*} constants) + * @param loggerName the name of the logger that produced the event + * @param message the formatted log message (without level/timestamp prefix) + * @param throwable the associated throwable, or {@code null} + */ + protected void onLogEvent(int level, String loggerName, String message, Throwable throwable) { + // no-op — overridden by MavenSimpleLogger } protected String renderLevel(int levelInt) { diff --git a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java index 02767987e2a1..5d44a0637555 100644 --- a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java +++ b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java @@ -39,14 +39,65 @@ public class MavenSimpleLogger extends MavenBaseLogger { private String warnRenderedLevel; private String errorRenderedLevel; - static Consumer logSink; + /** + * Structured log sink that receives level, logger name, clean message, + * formatted console output, and throwable for each log event. + *

+ * This replaces the previous {@code Consumer} sink to enable + * console renderers (e.g. rich mode) to filter by log level and access + * the clean message independently of ANSI formatting. + * + * @since 4.1.0 + */ + @FunctionalInterface + public interface LogSink { + void accept(int level, String loggerName, String cleanMessage, String formattedMessage, Throwable throwable); + } + + static volatile LogSink logSink; + + /** + * Structured log event sink for build report capture. + * Receives level, logger name, clean message, and throwable + * independently of the formatted console output. + */ + @FunctionalInterface + public interface LogEventSink { + void accept(int level, String loggerName, String message, Throwable throwable); + } + + private static volatile LogEventSink logEventSink; public static final String DEFAULT_LOG_LEVEL_KEY = "org.slf4j.simpleLogger.defaultLogLevel"; - public static void setLogSink(Consumer logSink) { + /** + * Sets the structured log sink. + * + * @param logSink the sink, or {@code null} to remove + * @since 4.1.0 + */ + public static void setLogSink(LogSink logSink) { MavenSimpleLogger.logSink = logSink; } + public static void setLogEventSink(LogEventSink sink) { + MavenSimpleLogger.logEventSink = sink; + } + + public static LogEventSink getLogEventSink() { + return logEventSink; + } + + /** + * Returns the current log sink, or {@code null} if none is set. + * + * @return the current log sink, or {@code null} + * @since 4.1.0 + */ + public static LogSink getLogSink() { + return logSink; + } + MavenSimpleLogger(String name) { super(name); } @@ -70,15 +121,74 @@ protected String renderLevel(int level) { } @Override - protected void write(StringBuilder buf, Throwable t) { - Consumer sink = logSink; + protected void write(int level, String loggerName, String cleanMessage, StringBuilder formattedBuf, Throwable t) { + LogSink sink = logSink; if (sink != null) { - sink.accept(buf.toString()); + // Build the full formatted output including throwable rendering + String formatted = formattedBuf.toString(); if (t != null) { - writeThrowable(t, sink); + StringBuilder full = new StringBuilder(formatted); + full.append(System.lineSeparator()); + appendFormattedThrowable(full, t, ""); + formatted = full.toString(); } + sink.accept(level, loggerName, cleanMessage, formatted, t); } else { - super.write(buf, t); + super.write(formattedBuf, t); + } + } + + /** + * Append a colorized throwable rendering to the given builder. + * Reuses the existing formatting logic for consistency with console output. + */ + private void appendFormattedThrowable(StringBuilder sb, Throwable t, String prefix) { + MessageBuilder builder = builder().a(prefix).failure(t.getClass().getName()); + if (t.getMessage() != null) { + builder.a(": ").failure(t.getMessage()); + } + sb.append(builder.toString()).append(System.lineSeparator()); + appendStackTrace(sb, t, prefix); + } + + private void appendStackTrace(StringBuilder sb, Throwable t, String prefix) { + MessageBuilder builder = builder(); + for (StackTraceElement e : t.getStackTrace()) { + builder.a(prefix); + builder.a(" "); + builder.strong("at"); + builder.a(" "); + builder.a(e.getClassName()); + builder.a("."); + builder.a(e.getMethodName()); + builder.a("("); + builder.strong(getLocation(e)); + builder.a(")"); + sb.append(builder.toString()).append(System.lineSeparator()); + builder.setLength(0); + } + for (Throwable se : t.getSuppressed()) { + builder.a(prefix) + .a(" ") + .strong("Suppressed") + .a(": ") + .a(se.getClass().getName()); + if (se.getMessage() != null) { + builder.a(": ").failure(se.getMessage()); + } + sb.append(builder.toString()).append(System.lineSeparator()); + builder.setLength(0); + appendStackTrace(sb, se, prefix + " "); + } + Throwable cause = t.getCause(); + if (cause != null && t != cause) { + builder.a(prefix).strong("Caused by").a(": ").a(cause.getClass().getName()); + if (cause.getMessage() != null) { + builder.a(": ").failure(cause.getMessage()); + } + sb.append(builder.toString()).append(System.lineSeparator()); + builder.setLength(0); + appendStackTrace(sb, cause, prefix); } } @@ -167,4 +277,12 @@ public void configure(int defaultLogLevel) { public void setLogLevel(int logLevel) { this.currentLogLevel = logLevel; } + + @Override + protected void onLogEvent(int level, String loggerName, String message, Throwable throwable) { + LogEventSink sink = logEventSink; + if (sink != null) { + sink.accept(level, loggerName, message, throwable); + } + } } diff --git a/its/core-it-suite/pom.xml b/its/core-it-suite/pom.xml index 5871a3570f4e..c5b0802febba 100644 --- a/its/core-it-suite/pom.xml +++ b/its/core-it-suite/pom.xml @@ -519,7 +519,7 @@ under the License. - + @@ -749,7 +749,7 @@ under the License. - + @@ -804,7 +804,7 @@ under the License. - + 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 000000000000..3d877a169f76 --- /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. + *

+ * Covers: build report JSON generation, console modes (plain, machine, verbose), + * warning mode, version info on failure, and the {@code mvnlog} viewer tool. + * + * @see gh-12571 + * @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("\"diagnostics\""), "Should contain diagnostics 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 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 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 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 010e39cc2d32..695fd825d615 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 9fc554a24710..353944c61681 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 44019dd9c46b..c41109e26512 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 65a53dde3287..807798e7ee2a 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 6a8519034876..25545f868d4a 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 89c39470d1c7..0cac3cd9930e 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 3321e0b5d854..334f25084c63 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 ba6864f2e9a8..19cade9d559c 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 b37994fd1647..f825aeda4d34 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 10188f23e74b..b5fedd0d5b41 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 bd1a6a21dd68..718b28b639ad 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 000000000000..d859b406d932 --- /dev/null +++ b/its/core-it-suite/src/test/resources/gh-12571-build-report/pom.xml @@ -0,0 +1,28 @@ + + + + 4.0.0 + org.apache.maven.its.gh12571 + build-report-test + 1.0 + Maven IT :: gh-12571 :: Build Report + 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 000000000000..4e63f41a8a33 --- /dev/null +++ b/its/core-it-suite/src/test/resources/gh-12571-multi-module/module-a/pom.xml @@ -0,0 +1,31 @@ + + + + 4.0.0 + + org.apache.maven.its.gh12571 + multi-module-parent + 1.0 + + module-a + Module A + 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 000000000000..52f4965b05a1 --- /dev/null +++ b/its/core-it-suite/src/test/resources/gh-12571-multi-module/module-b/pom.xml @@ -0,0 +1,31 @@ + + + + 4.0.0 + + org.apache.maven.its.gh12571 + multi-module-parent + 1.0 + + module-b + Module B + 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 000000000000..b3cabd7d3953 --- /dev/null +++ b/its/core-it-suite/src/test/resources/gh-12571-multi-module/pom.xml @@ -0,0 +1,33 @@ + + + + 4.0.0 + org.apache.maven.its.gh12571 + multi-module-parent + 1.0 + pom + Maven IT :: gh-12571 :: Multi Module + + module-a + module-b + +