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..7e3c8bdae83b 100644 --- a/apache-maven/src/assembly/maven/bin/mvn.cmd +++ b/apache-maven/src/assembly/maven/bin/mvn.cmd @@ -275,6 +275,8 @@ if "%~1"=="--debug" ( set "MAVEN_MAIN_CLASS=org.apache.maven.cling.MavenShellCling" ) else if "%~1"=="--up" ( set "MAVEN_MAIN_CLASS=org.apache.maven.cling.MavenUpCling" +) else if "%~1"=="--log" ( + set "MAVEN_MAIN_CLASS=org.apache.maven.cling.MavenLogCling" ) exit /b 0 diff --git a/apache-maven/src/assembly/maven/bin/mvnlog b/apache-maven/src/assembly/maven/bin/mvnlog new file mode 100755 index 000000000000..8170bdb16785 --- /dev/null +++ b/apache-maven/src/assembly/maven/bin/mvnlog @@ -0,0 +1,30 @@ +#!/bin/sh + +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# ----------------------------------------------------------------------------- +# Apache Maven Build Log Viewer Script +# +# Environment Variable Prerequisites +# +# JAVA_HOME (Optional) Points to a Java installation. +# MAVEN_OPTS (Optional) Java runtime options used when Maven is executed. +# MAVEN_SKIP_RC (Optional) Flag to disable loading of mavenrc files. +# ----------------------------------------------------------------------------- + +"`dirname "$0"`/mvn" --log "$@" diff --git a/apache-maven/src/assembly/maven/bin/mvnlog.cmd b/apache-maven/src/assembly/maven/bin/mvnlog.cmd new file mode 100644 index 000000000000..7069255cd817 --- /dev/null +++ b/apache-maven/src/assembly/maven/bin/mvnlog.cmd @@ -0,0 +1,39 @@ +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. + +@REM ----------------------------------------------------------------------------- +@REM Apache Maven Build Log Viewer Script +@REM +@REM Environment Variable Prerequisites +@REM +@REM JAVA_HOME (Optional) Points to a Java installation. +@REM MAVEN_BATCH_ECHO (Optional) Set to 'on' to enable the echoing of the batch commands. +@REM MAVEN_BATCH_PAUSE (Optional) set to 'on' to wait for a key stroke before ending. +@REM MAVEN_OPTS (Optional) Java runtime options used when Maven is executed. +@REM MAVEN_SKIP_RC (Optional) Flag to disable loading of mavenrc files. +@REM ----------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%"=="on" echo %MAVEN_BATCH_ECHO% + +@setlocal + +@call "%~dp0"mvn.cmd --log %* diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/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..9d7a07c0aaca --- /dev/null +++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/LogOptions.java @@ -0,0 +1,115 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.api.cli.mvnlog; + +import java.util.Optional; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.cli.Options; + +/** + * Defines the options specific to the Maven build log viewer tool ({@code mvnlog}). + * This interface extends the general {@link Options} interface, adding log-viewing options. + * + * @since 4.1.0 + */ +@Experimental +public interface LogOptions extends Options { + /** + * Whether to show detailed diagnostics (warnings and errors) from the build. + * + * @return an {@link Optional} containing {@code true} if diagnostics should be shown + */ + Optional diagnostics(); + + /** + * Whether to show detailed failure information including stack traces. + * + * @return an {@link Optional} containing {@code true} if failures should be shown in detail + */ + Optional failures(); + + /** + * Whether to show a full per-mojo timing breakdown. + * + * @return an {@link Optional} containing {@code true} if the full breakdown should be shown + */ + Optional full(); + + /** + * Whether to list all available build reports instead of showing one. + * + * @return an {@link Optional} containing {@code true} if reports should be listed + */ + Optional list(); + + /** + * Whether to output the raw JSON build report instead of formatted text. + * Useful for piping to tools like {@code jq} or for programmatic consumption. + * + * @return an {@link Optional} containing {@code true} if raw JSON should be output + */ + Optional json(); + + /** + * Returns the path to a specific build report file to display. + * If not specified, defaults to {@code target/build-reports/build-report-latest.json}. + * + * @return an {@link Optional} containing the report file path, or empty if not specified + */ + Optional reportFile(); + + /** + * Filter output to a specific module (by artifactId substring or glob pattern). + * When specified, only modules whose {@code artifactId} contains the given string + * (case-insensitive) are shown. With {@code --json}, only matching modules + * are included in the output. + * + * @return an {@link Optional} containing the module filter pattern + */ + Optional module(); + + /** + * Filter output to a specific mojo (by goal substring or glob pattern). + * When specified, only mojos whose {@code goal} contains the given string + * (case-insensitive) are shown. + * + * @return an {@link Optional} containing the mojo filter pattern + */ + Optional mojo(); + + /** + * Filter log events by minimum level ({@code TRACE}, {@code DEBUG}, {@code INFO}, + * {@code WARN}, {@code ERROR}). Only events at or above the given severity are shown. + * In text mode, renders matching log lines from the build report. + * With {@code --json}, filters the log event arrays in the output. + * + * @return an {@link Optional} containing the minimum log level + */ + Optional level(); + + /** + * Search through log messages for a substring (case-insensitive). + * In text mode, renders matching log lines from the build report. + * With {@code --json}, filters the log event arrays in the output. + * + * @return an {@link Optional} containing the grep pattern + */ + Optional grep(); +} 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/invoker/CommonsCliOptions.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java index 77d5faac61be..d75f07b81aec 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java @@ -349,6 +349,7 @@ protected static class CLIManager { public static final String UPGRADE = "up"; public static final String SHELL = "shell"; public static final String YJP = "yjp"; + public static final String LOG = "log"; // deprecated ones @Deprecated @@ -498,6 +499,10 @@ protected void prepareOptions(org.apache.commons.cli.Options options) { .longOpt(YJP) .desc("Launch the JVM with Yourkit profiler (script option).") .get()); + options.addOption(Option.builder() + .longOpt(LOG) + .desc("Launch the Maven Build Log Viewer (script option).") + .get()); // Deprecated options.addOption(Option.builder(ALTERNATE_GLOBAL_SETTINGS) diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java index 4a6fe8f18c55..6ea7c6f099c2 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 @@ -76,6 +76,7 @@ import org.apache.maven.eventspy.internal.EventSpyDispatcher; import org.apache.maven.execution.MavenExecutionRequest; import org.apache.maven.impl.SettingsUtilsV4; +import org.apache.maven.internal.build.DefaultDiagnosticCollector; import org.apache.maven.jline.FastTerminal; import org.apache.maven.jline.MessageUtils; import org.apache.maven.logging.BuildEventListener; @@ -746,6 +747,13 @@ protected Runnable settings(C context, boolean emitSettingsWarnings, SettingsBui } } context.logger.info(""); + + // Pipe structured problems directly to DiagnosticCollector so that + // key, suggestion, documentationUrl, and source location are preserved + // in the build report (instead of being lost to plain-text logging). + // This runs before SessionStarted, so the SLF4J auto-collection hook + // is not active yet — no double-counting risk. + pipeSettingsProblems(context, settingsResult); } return () -> { context.installationSettingsPath = null; @@ -757,6 +765,20 @@ protected Runnable settings(C context, boolean emitSettingsWarnings, SettingsBui }; } + /** + * Pipes structured settings validation problems to the DiagnosticCollector. + * This preserves key, suggestion, documentationUrl, and source location + * that would otherwise be lost when problems are logged as plain text. + */ + private void pipeSettingsProblems(C context, SettingsBuilderResult settingsResult) { + context.lookup.lookupOptional(DefaultDiagnosticCollector.class).ifPresent(collector -> { + for (BuilderProblem problem : + settingsResult.getProblems().problems().toList()) { + collector.report(problem); + } + }); + } + protected void customizeSettingsRequest(C context, SettingsBuilderRequest settingsBuilderRequest) throws Exception {} 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 25bcf8ab60c0..65dcfc1ba527 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 @@ -69,6 +69,7 @@ import org.apache.maven.execution.MavenExecutionResult; import org.apache.maven.execution.ProfileActivation; import org.apache.maven.execution.ProjectActivation; +import org.apache.maven.internal.build.DefaultDiagnosticCollector; import org.apache.maven.jline.MessageUtils; import org.apache.maven.lifecycle.LifecycleExecutionException; import org.apache.maven.logging.BuildEventListener; @@ -220,6 +221,17 @@ protected void toolchains(MavenContext context, MavenExecutionRequest request) t } context.logger.info(""); + + // Pipe structured problems directly to DiagnosticCollector so that + // key, suggestion, documentationUrl, and source location are preserved + // in the build report. This runs before SessionStarted, so the SLF4J + // auto-collection hook is not active yet — no double-counting risk. + context.lookup.lookupOptional(DefaultDiagnosticCollector.class).ifPresent(collector -> { + for (BuilderProblem problem : + toolchainsResult.getProblems().problems().toList()) { + collector.report(problem); + } + }); } } diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/BuildReportFilter.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/BuildReportFilter.java new file mode 100644 index 000000000000..d48215ece342 --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/BuildReportFilter.java @@ -0,0 +1,306 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.invoker.mvnlog; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Applies structural filters to a parsed build report (JSON as {@code Map}). + *

+ * Filters are applied in order: + *

    + *
  1. {@code --module}: keep only modules whose {@code artifactId} contains the pattern
  2. + *
  3. {@code --mojo}: within each module, keep only mojos whose {@code goal} contains the pattern
  4. + *
  5. {@code --level}: within each mojo and module, keep only log events at or above the level
  6. + *
  7. {@code --grep}: within each mojo and module, keep only log events whose message matches
  8. + *
+ * All string matching is case-insensitive. + * + * @since 4.1.0 + */ +final class BuildReportFilter { + + /** + * Log level ordinals for severity comparison. + * Higher value = more severe. + */ + private static final Map LEVEL_ORDINALS = Map.of( + "TRACE", 0, + "DEBUG", 1, + "INFO", 2, + "WARN", 3, + "WARNING", 3, + "ERROR", 4); + + private final String modulePattern; + private final String mojoPattern; + private final String levelFilter; + private final String grepPattern; + + BuildReportFilter(String modulePattern, String mojoPattern, String levelFilter, String grepPattern) { + this.modulePattern = modulePattern != null ? modulePattern.toLowerCase(Locale.ROOT) : null; + this.mojoPattern = mojoPattern != null ? mojoPattern.toLowerCase(Locale.ROOT) : null; + this.levelFilter = levelFilter != null ? levelFilter.toUpperCase(Locale.ROOT) : null; + this.grepPattern = grepPattern != null ? grepPattern.toLowerCase(Locale.ROOT) : null; + } + + /** + * Returns {@code true} if any filter is active. + */ + boolean hasFilters() { + return modulePattern != null || mojoPattern != null || levelFilter != null || grepPattern != null; + } + + /** + * Returns {@code true} if log-event-level filters are active + * ({@code --level} or {@code --grep}). + */ + boolean hasLogFilters() { + return levelFilter != null || grepPattern != null; + } + + /** + * Apply all active filters to the report, returning a new report map + * with only the matching entries. The original map is not modified. + */ + @SuppressWarnings("unchecked") + Map apply(Map report) { + if (!hasFilters()) { + return report; + } + + Map result = new LinkedHashMap<>(report); + + // Filter modules + Object modulesObj = result.get("modules"); + if (modulesObj instanceof List) { + List> modules = (List>) modulesObj; + List> filtered = new ArrayList<>(); + + for (Map module : modules) { + // --module filter: match on artifactId + if (modulePattern != null) { + String artifactId = getString(module, "artifactId"); + if (artifactId == null + || !artifactId.toLowerCase(Locale.ROOT).contains(modulePattern)) { + continue; + } + } + + Map filteredModule = new LinkedHashMap<>(module); + + // --mojo filter: keep only matching mojos + if (mojoPattern != null) { + Object mojosObj = filteredModule.get("mojos"); + if (mojosObj instanceof List) { + List> mojos = (List>) mojosObj; + List> filteredMojos = new ArrayList<>(); + for (Map mojo : mojos) { + String goal = getString(mojo, "goal"); + if (goal != null && goal.toLowerCase(Locale.ROOT).contains(mojoPattern)) { + filteredMojos.add(filterMojoLogEvents(mojo)); + } + } + filteredModule.put("mojos", filteredMojos); + } + } else if (hasLogFilters()) { + // Apply log filters to mojos even without --mojo + Object mojosObj = filteredModule.get("mojos"); + if (mojosObj instanceof List) { + List> mojos = (List>) mojosObj; + List> filteredMojos = new ArrayList<>(); + for (Map mojo : mojos) { + filteredMojos.add(filterMojoLogEvents(mojo)); + } + filteredModule.put("mojos", filteredMojos); + } + } + + // Apply log filters to module-level output + if (hasLogFilters()) { + filteredModule.put("output", filterLogEvents(getLogEvents(filteredModule, "output"))); + } + + filtered.add(filteredModule); + } + + result.put("modules", filtered); + } + + // Apply log filters to build-level output + if (hasLogFilters()) { + result.put("output", filterLogEvents(getLogEvents(result, "output"))); + } + + return result; + } + + /** + * Collect all log events matching the current filters from a report. + * Returns a flat list of log event maps, each annotated with a + * {@code "context"} field indicating where the event came from + * (module name, mojo goal, or build-level). + */ + @SuppressWarnings("unchecked") + List> collectMatchingLogEvents(Map report) { + List> results = new ArrayList<>(); + + // Build-level log events (skip when filtering by module — build-level + // events don't belong to any module) + if (modulePattern == null) { + for (Map event : getLogEvents(report, "output")) { + if (matchesLogFilters(event)) { + Map annotated = new LinkedHashMap<>(event); + annotated.put("context", "build"); + results.add(annotated); + } + } + } + + // Module and mojo-level log events + Object modulesObj = report.get("modules"); + if (modulesObj instanceof List) { + for (Map module : (List>) modulesObj) { + String artifactId = getString(module, "artifactId"); + + // Check module filter + if (modulePattern != null) { + if (artifactId == null + || !artifactId.toLowerCase(Locale.ROOT).contains(modulePattern)) { + continue; + } + } + + // Module-level output + for (Map event : getLogEvents(module, "output")) { + if (matchesLogFilters(event)) { + Map annotated = new LinkedHashMap<>(event); + annotated.put("context", artifactId != null ? artifactId : "unknown"); + results.add(annotated); + } + } + + // Mojo-level output + Object mojosObj = module.get("mojos"); + if (mojosObj instanceof List) { + for (Map mojo : (List>) mojosObj) { + String goal = getString(mojo, "goal"); + + // Check mojo filter + if (mojoPattern != null) { + if (goal == null || !goal.toLowerCase(Locale.ROOT).contains(mojoPattern)) { + continue; + } + } + + String mojoLabel = (artifactId != null ? artifactId : "") + ":" + (goal != null ? goal : ""); + for (Map event : getLogEvents(mojo, "output")) { + if (matchesLogFilters(event)) { + Map annotated = new LinkedHashMap<>(event); + annotated.put("context", mojoLabel); + results.add(annotated); + } + } + } + } + } + } + + return results; + } + + /** + * Filter the log events within a mojo entry. + */ + private Map filterMojoLogEvents(Map mojo) { + if (!hasLogFilters()) { + return mojo; + } + Map filtered = new LinkedHashMap<>(mojo); + filtered.put("output", filterLogEvents(getLogEvents(mojo, "output"))); + return filtered; + } + + /** + * Filter a list of log events by level and grep pattern. + */ + private List> filterLogEvents(List> events) { + List> result = new ArrayList<>(); + for (Map event : events) { + if (matchesLogFilters(event)) { + result.add(event); + } + } + return result; + } + + /** + * Check if a single log event matches the active level and grep filters. + */ + private boolean matchesLogFilters(Map event) { + // Level filter + if (levelFilter != null) { + String eventLevel = getString(event, "level"); + if (eventLevel == null || !isAtOrAbove(eventLevel, levelFilter)) { + return false; + } + } + + // Grep filter + if (grepPattern != null) { + String message = getString(event, "message"); + if (message == null || !message.toLowerCase(Locale.ROOT).contains(grepPattern)) { + return false; + } + } + + return true; + } + + /** + * Returns {@code true} if {@code eventLevel} is at or above {@code minLevel} + * in severity. + */ + static boolean isAtOrAbove(String eventLevel, String minLevel) { + Integer eventOrd = LEVEL_ORDINALS.get(eventLevel.toUpperCase(Locale.ROOT)); + Integer minOrd = LEVEL_ORDINALS.get(minLevel.toUpperCase(Locale.ROOT)); + if (eventOrd == null || minOrd == null) { + return true; // unknown levels pass through + } + return eventOrd >= minOrd; + } + + @SuppressWarnings("unchecked") + private static List> getLogEvents(Map container, String key) { + Object value = container.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; + } +} 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..dc9e22c06755 --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/BuildReportRenderer.java @@ -0,0 +1,636 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.invoker.mvnlog; + +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import org.apache.maven.api.services.MessageBuilder; +import org.apache.maven.api.services.MessageBuilderFactory; + +/** + * Renders a build report (parsed from JSON into a {@code Map}) + * 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()); + } + + // Problems — show structured warnings/errors so the user doesn't have to re-run the build + List> problems = getList(report, "problems"); + if (!problems.isEmpty()) { + long warnings = problems.stream() + .filter(d -> "WARNING".equals(getString(d, "severity"))) + .count(); + long errors = problems.stream() + .filter(d -> "ERROR".equals(getString(d, "severity"))) + .count(); + if (warnings > 0 || errors > 0) { + output.accept(""); + MessageBuilder header = messageBuilderFactory.builder(); + header.a("Problems: "); + if (errors > 0) { + header.failure(errors + " error" + (errors > 1 ? "s" : "")); + } + if (errors > 0 && warnings > 0) { + header.a(", "); + } + if (warnings > 0) { + header.warning(warnings + " warning" + (warnings > 1 ? "s" : "")); + } + output.accept(header.toString()); + + // Show each problem with structured details + for (Map p : problems) { + renderProblemCompact(p); + } + } + } + + // 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> problems = getList(report, "problems"); + if (problems.isEmpty()) { + output.accept(messageBuilderFactory + .builder() + .success("No problems recorded.") + .toString()); + return; + } + + // Count by severity + long errors = problems.stream() + .filter(p -> "ERROR".equals(getString(p, "severity"))) + .count(); + long warnings = problems.stream() + .filter(p -> "WARNING".equals(getString(p, "severity"))) + .count(); + long infos = problems.size() - errors - warnings; + + MessageBuilder header = messageBuilderFactory.builder(); + header.strong("Problems (" + problems.size() + ")"); + header.a(": "); + List parts = new ArrayList<>(); + if (errors > 0) { + parts.add(errors + " error" + (errors > 1 ? "s" : "")); + } + if (warnings > 0) { + parts.add(warnings + " warning" + (warnings > 1 ? "s" : "")); + } + if (infos > 0) { + parts.add(infos + " info"); + } + header.a(String.join(", ", parts)); + output.accept(header.toString()); + output.accept(""); + + for (Map problem : problems) { + renderProblemDetailed(problem); + } + } + + /** + * 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(" [FAIL] ").a(module); + if (mojo != null && !mojo.isEmpty()) { + mb.a(" - ").a(mojo); + } + output.accept(mb.toString()); + + String message = getString(failure, "message"); + if (message != null) { + output.accept(" " + message); + } + + String stackTrace = getString(failure, "stackTrace"); + if (stackTrace != null && !stackTrace.isEmpty()) { + // Show first few lines of stack trace + String[] lines = stackTrace.split("\n"); + int limit = Math.min(lines.length, 10); + for (int i = 0; i < limit; i++) { + output.accept(" " + lines[i]); + } + if (lines.length > limit) { + output.accept(" ... " + (lines.length - limit) + " more lines"); + } + } + } + } + + /** + * Render the full per-mojo timing breakdown. + */ + @SuppressWarnings("unchecked") + public void renderFull(Map 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()); + } + } + + // ---- Log event rendering ---- + + /** + * Render a list of log events (from filter results) as a log view. + * Each event shows the level, context (module/mojo), and message. + */ + public void renderLogEvents(List> events) { + if (events.isEmpty()) { + output.accept(messageBuilderFactory + .builder() + .warning("No matching log events found.") + .toString()); + return; + } + + output.accept(messageBuilderFactory + .builder() + .strong(events.size() + " matching log event" + (events.size() > 1 ? "s" : "") + ":") + .toString()); + output.accept(""); + + for (Map event : events) { + String level = getString(event, "level"); + String message = getString(event, "message"); + String context = getString(event, "context"); + String loggerName = getString(event, "loggerName"); + + MessageBuilder mb = messageBuilderFactory.builder(); + + // Level tag with color + if ("ERROR".equals(level)) { + mb.failure("[ERROR]"); + } else if ("WARN".equals(level)) { + mb.warning("[WARN] "); + } else if ("DEBUG".equals(level)) { + mb.debug("[DEBUG]"); + } else if ("TRACE".equals(level)) { + mb.a("[TRACE]"); + } else { + mb.info("[INFO] "); + } + + // Context (module:goal or just module) + if (context != null && !"build".equals(context)) { + mb.a(" "); + mb.strong(context); + } + + // Logger name (shortened) + if (loggerName != null) { + mb.a(" "); + mb.a(shortenLoggerName(loggerName)); + } + + // Message + if (message != null) { + mb.a(" - ").a(message); + } + + output.accept(mb.toString()); + } + } + + /** + * Shorten a fully-qualified logger name to its simple class name. + */ + private static String shortenLoggerName(String loggerName) { + int lastDot = loggerName.lastIndexOf('.'); + return lastDot >= 0 ? loggerName.substring(lastDot + 1) : loggerName; + } + + // ---- Problem rendering ---- + + /** + * Compact problem rendering for the default summary view. + * Shows severity, message, source, and suggestion on two lines. + */ + private void renderProblemCompact(Map problem) { + String severity = getString(problem, "severity"); + String message = getString(problem, "message"); + String source = getString(problem, "source"); + String suggestion = getString(problem, "suggestion"); + + MessageBuilder mb = messageBuilderFactory.builder(); + if ("ERROR".equals(severity)) { + mb.failure(" [ERROR] "); + } else if ("WARNING".equals(severity)) { + mb.warning(" [WARN] "); + } else { + mb.a(" [INFO] "); + } + mb.a(message); + if (source != null && !source.isEmpty()) { + mb.a(" ").a(messageBuilderFactory.builder().strong(source).toString()); + } + output.accept(mb.toString()); + + if (suggestion != null && !suggestion.isEmpty()) { + output.accept(" suggestion: " + suggestion); + } + } + + /** + * Detailed problem rendering for the {@code --diagnostics} view. + * Shows all available fields: key, severity, message, source, location, + * suggestion, and documentation URL. + */ + private void renderProblemDetailed(Map problem) { + String severity = getString(problem, "severity"); + String message = getString(problem, "message"); + String key = getString(problem, "key"); + String source = getString(problem, "source"); + String suggestion = getString(problem, "suggestion"); + String docUrl = getString(problem, "documentationUrl"); + + // Severity label + message + MessageBuilder mb = messageBuilderFactory.builder(); + if ("ERROR".equals(severity)) { + mb.failure(" [ERROR] "); + } else if ("WARNING".equals(severity)) { + mb.warning(" [WARN] "); + } else { + mb.a(" [INFO] "); + } + mb.a(message); + output.accept(mb.toString()); + + // Key (diagnostic identifier for suppression) + if (key != null && !key.isEmpty()) { + output.accept(" key: " + key); + } + + // Source + location + if (source != null && !source.isEmpty()) { + StringBuilder loc = new StringBuilder(" source: "); + loc.append(source); + Number line = getNumber(problem, "line"); + if (line != null && line.intValue() > 0) { + loc.append(":").append(line.intValue()); + Number column = getNumber(problem, "column"); + if (column != null && column.intValue() > 0) { + loc.append(":").append(column.intValue()); + } + } + output.accept(loc.toString()); + } + + // Suggestion + if (suggestion != null && !suggestion.isEmpty()) { + MessageBuilder sugMb = messageBuilderFactory.builder(); + sugMb.a(" suggestion: ").success(suggestion); + output.accept(sugMb.toString()); + } + + // Documentation URL + if (docUrl != null && !docUrl.isEmpty()) { + output.accept(" docs: " + docUrl); + } + + output.accept(""); + } + + // ---- Internal helpers ---- + + private void renderHeader(Map 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 marker + buffer.append(' '); + + buffer.append(artifactId); + buffer.append(' '); + + // Pad with dots + int maxLen = 60; + if (buffer.length() <= maxLen) { + while (buffer.length() < maxLen) { + buffer.append('.'); + } + buffer.append(' '); + } + + MessageBuilder mb = messageBuilderFactory.builder(); + mb.a(buffer); + switch (status) { + case "SUCCESS": + mb.success(status); + break; + case "FAILURE": + mb.failure(status); + break; + default: + mb.warning(status); + break; + } + + // Duration + if (duration != null) { + mb.a(" [").a(formatDuration(duration)).a("]"); + } + + output.accept(mb.toString()); + } + + /** + * Format an ISO-8601 duration string (e.g. "PT2.1S") into a human-readable form. + */ + static String formatDuration(String isoDuration) { + try { + Duration d = Duration.parse(isoDuration); + long totalSeconds = d.getSeconds(); + int millis = d.getNano() / 1_000_000; + + if (totalSeconds >= 60) { + long minutes = totalSeconds / 60; + long seconds = totalSeconds % 60; + return String.format("%d:%02d min", minutes, seconds); + } else { + return String.format("%d.%03d s", totalSeconds, millis); + } + } catch (Exception e) { + return isoDuration; // fallback to raw string + } + } + + @SuppressWarnings("unchecked") + private static List> 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..a80192bf4715 --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/CommonsCliLogOptions.java @@ -0,0 +1,217 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.invoker.mvnlog; + +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.Option; +import org.apache.commons.cli.ParseException; +import org.apache.maven.api.cli.Options; +import org.apache.maven.api.cli.ParserRequest; +import org.apache.maven.api.cli.mvnlog.LogOptions; +import org.apache.maven.cling.invoker.CommonsCliOptions; + +/** + * Implementation of {@link LogOptions} using Commons CLI. + */ +public class CommonsCliLogOptions extends CommonsCliOptions implements LogOptions { + + public static CommonsCliLogOptions parse(String[] args) throws ParseException { + CLIManager cliManager = new CLIManager(); + return new CommonsCliLogOptions(Options.SOURCE_CLI, cliManager, cliManager.parse(args)); + } + + protected CommonsCliLogOptions(String source, CLIManager cliManager, CommandLine commandLine) { + super(source, cliManager, commandLine); + } + + @Override + public Optional 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 Optional module() { + if (commandLine.hasOption(CLIManager.MODULE)) { + return Optional.of(commandLine.getOptionValue(CLIManager.MODULE)); + } + return Optional.empty(); + } + + @Override + public Optional mojo() { + if (commandLine.hasOption(CLIManager.MOJO)) { + return Optional.of(commandLine.getOptionValue(CLIManager.MOJO)); + } + return Optional.empty(); + } + + @Override + public Optional level() { + if (commandLine.hasOption(CLIManager.LEVEL)) { + return Optional.of(commandLine.getOptionValue(CLIManager.LEVEL)); + } + return Optional.empty(); + } + + @Override + public Optional grep() { + if (commandLine.hasOption(CLIManager.GREP)) { + return Optional.of(commandLine.getOptionValue(CLIManager.GREP)); + } + 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("Filters:"); + printStream.accept(" mvnlog --module api Show only modules matching 'api'"); + printStream.accept(" mvnlog --level WARN Show only WARN and ERROR log events"); + printStream.accept(" mvnlog --grep 'deprecated' Search log messages for 'deprecated'"); + printStream.accept(" mvnlog --mojo compile Show only 'compile' mojo executions"); + printStream.accept(" mvnlog --module api --level WARN Combine filters (all must match)"); + printStream.accept(""); + printStream.accept("JSON output:"); + printStream.accept(" mvnlog --json Output full JSON report"); + printStream.accept(" mvnlog --json --module api Output filtered JSON"); + printStream.accept(" mvnlog --json | jq '.modules' Pipe to jq for complex queries"); + printStream.accept(""); + } + + @Override + protected CommonsCliLogOptions copy( + String source, CommonsCliOptions.CLIManager cliManager, CommandLine commandLine) { + return new CommonsCliLogOptions(source, (CLIManager) cliManager, commandLine); + } + + protected static class CLIManager extends CommonsCliOptions.CLIManager { + public static final String DIAGNOSTICS = "d"; + public static final String FAILURES = "f"; + public static final String FULL = "F"; + public static final String LIST = "L"; + public static final String JSON = "j"; + public static final String MODULE = "m"; + public static final String MOJO = "M"; + public static final String LEVEL = "l"; + public static final String GREP = "g"; + + @Override + protected void prepareOptions(org.apache.commons.cli.Options options) { + super.prepareOptions(options); + options.addOption(Option.builder(DIAGNOSTICS) + .longOpt("diagnostics") + .desc("Show detailed warnings and errors from the build") + .get()); + options.addOption(Option.builder(FAILURES) + .longOpt("failures") + .desc("Show detailed failure information including stack traces") + .get()); + options.addOption(Option.builder(FULL) + .longOpt("full") + .desc("Show full per-mojo timing breakdown") + .get()); + options.addOption(Option.builder(LIST) + .longOpt("list") + .desc("List all available build reports") + .get()); + options.addOption(Option.builder(JSON) + .longOpt("json") + .desc("Output the raw JSON build report (useful for piping to jq)") + .get()); + options.addOption(Option.builder(MODULE) + .longOpt("module") + .hasArg() + .argName("pattern") + .desc("Filter to modules whose artifactId contains (case-insensitive)") + .get()); + options.addOption(Option.builder(MOJO) + .longOpt("mojo") + .hasArg() + .argName("pattern") + .desc("Filter to mojos whose goal contains (case-insensitive)") + .get()); + options.addOption(Option.builder(LEVEL) + .longOpt("level") + .hasArg() + .argName("LEVEL") + .desc("Filter log events by minimum level (TRACE, DEBUG, INFO, WARN, ERROR)") + .get()); + options.addOption(Option.builder(GREP) + .longOpt("grep") + .hasArg() + .argName("pattern") + .desc("Search log messages for (case-insensitive)") + .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..bc194888fa3c --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogInvoker.java @@ -0,0 +1,199 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.invoker.mvnlog; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.function.Consumer; + +import org.apache.maven.api.annotations.Nullable; +import org.apache.maven.api.cli.InvokerRequest; +import org.apache.maven.api.cli.mvnlog.LogOptions; +import org.apache.maven.api.services.Lookup; +import org.apache.maven.cling.invoker.LookupContext; +import org.apache.maven.cling.invoker.LookupInvoker; + +/** + * Invoker for the {@code mvnlog} build log viewer. + *

+ * 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; + } + + Map report; + try { + report = SimpleJsonReader.parse(json); + } catch (IllegalArgumentException e) { + context.logger.error("Failed to parse report file: " + e.getMessage()); + return ERROR; + } + + // Build filter from options + BuildReportFilter filter = buildFilter(options); + + // Handle --json: output JSON (filtered if applicable) and exit + if (options != null && options.json().orElse(false)) { + if (filter.hasFilters()) { + Map filtered = filter.apply(report); + output.accept(SimpleJsonWriter.toJson(filtered)); + } else { + output.accept(json); + } + return OK; + } + + // If log-level filters are active (--level, --grep), render a log view + // instead of the default summary. + if (filter.hasLogFilters()) { + java.util.List> events = filter.collectMatchingLogEvents(report); + renderer.renderLogEvents(events); + return OK; + } + + // Apply structural filters (--module, --mojo) to the report + Map filteredReport = filter.apply(report); + + // Render based on flags + if (options != null && options.full().orElse(false)) { + renderer.renderFull(filteredReport); + } else if (options != null && options.failures().orElse(false)) { + renderer.renderFailures(filteredReport); + } else if (options != null && options.diagnostics().orElse(false)) { + renderer.renderDiagnostics(filteredReport); + } else { + renderer.renderSummary(filteredReport); + } + + return OK; + } + + private static BuildReportFilter buildFilter(LogOptions options) { + if (options == null) { + return new BuildReportFilter(null, null, null, null); + } + return new BuildReportFilter( + options.module().orElse(null), + options.mojo().orElse(null), + options.level().orElse(null), + options.grep().orElse(null)); + } + + private Path resolveReportDir(LogContext context) { + Path cwd = context.invokerRequest.cwd(); + return cwd.resolve(DEFAULT_REPORT_DIR); + } + + private Path resolveReportFile(LogContext context) { + LogOptions options = context.options(); + + // Explicit report file path from command line + if (options != null) { + String reportFile = options.reportFile().orElse(null); + if (reportFile != null) { + Path path = Path.of(reportFile); + if (path.isAbsolute()) { + return path; + } + return context.invokerRequest.cwd().resolve(path); + } + } + + // Default: target/build-reports/build-report-latest.json + return resolveReportDir(context).resolve(DEFAULT_REPORT_FILE); + } +} diff --git a/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/mvnlog/SimpleJsonWriter.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonWriter.java new file mode 100644 index 000000000000..3626d2631b20 --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonWriter.java @@ -0,0 +1,154 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.invoker.mvnlog; + +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * Minimal JSON writer for serializing {@code Map} structures + * back to well-formatted JSON. Used to output filtered build reports when + * {@code --json} is combined with filters. + *

+ * Supports: {@code Map}, {@code List}, {@code String}, {@code Number}, + * {@code Boolean}, and {@code null}. + * + * @since 4.1.0 + */ +final class SimpleJsonWriter { + + private static final String INDENT = " "; + + private SimpleJsonWriter() {} + + /** + * Serialize a value to a pretty-printed JSON string. + */ + static String toJson(Object value) { + StringBuilder sb = new StringBuilder(4096); + writeValue(sb, value, 0); + return sb.toString(); + } + + @SuppressWarnings("unchecked") + private static void writeValue(StringBuilder sb, Object value, int depth) { + if (value == null) { + sb.append("null"); + } else if (value instanceof Map) { + writeObject(sb, (Map) value, depth); + } else if (value instanceof List) { + writeArray(sb, (List) value, depth); + } else if (value instanceof String) { + writeString(sb, (String) value); + } else if (value instanceof Number) { + sb.append(value); + } else if (value instanceof Boolean) { + sb.append(value); + } else { + writeString(sb, value.toString()); + } + } + + private static void writeObject(StringBuilder sb, Map map, int depth) { + if (map.isEmpty()) { + sb.append("{}"); + return; + } + + sb.append("{\n"); + Iterator> it = map.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + indent(sb, depth + 1); + writeString(sb, entry.getKey()); + sb.append(": "); + writeValue(sb, entry.getValue(), depth + 1); + if (it.hasNext()) { + sb.append(','); + } + sb.append('\n'); + } + indent(sb, depth); + sb.append('}'); + } + + private static void writeArray(StringBuilder sb, List list, int depth) { + if (list.isEmpty()) { + sb.append("[]"); + return; + } + + sb.append("[\n"); + Iterator it = list.iterator(); + while (it.hasNext()) { + indent(sb, depth + 1); + writeValue(sb, it.next(), depth + 1); + if (it.hasNext()) { + sb.append(','); + } + sb.append('\n'); + } + indent(sb, depth); + sb.append(']'); + } + + private static void writeString(StringBuilder sb, String value) { + sb.append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + case '\b': + sb.append("\\b"); + break; + case '\f': + sb.append("\\f"); + break; + default: + if (c < 0x20) { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + } + } + sb.append('"'); + } + + private static void indent(StringBuilder sb, int depth) { + for (int i = 0; i < depth; i++) { + sb.append(INDENT); + } + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnsh/builtin/BuiltinShellCommandRegistryFactory.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnsh/builtin/BuiltinShellCommandRegistryFactory.java index 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/invoker/mvnlog/BuildReportFilterTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/BuildReportFilterTest.java new file mode 100644 index 000000000000..db118b003d5e --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/BuildReportFilterTest.java @@ -0,0 +1,242 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.invoker.mvnlog; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BuildReportFilterTest { + + @Test + void noFiltersPassesThrough() { + BuildReportFilter filter = new BuildReportFilter(null, null, null, null); + assertFalse(filter.hasFilters()); + + Map report = sampleReport(); + Map result = filter.apply(report); + assertEquals(report, result); + } + + @Test + void moduleFilterKeepsMatchingModules() { + BuildReportFilter filter = new BuildReportFilter("api", null, null, null); + assertTrue(filter.hasFilters()); + + Map result = filter.apply(sampleReport()); + @SuppressWarnings("unchecked") + List> modules = (List>) result.get("modules"); + assertEquals(1, modules.size()); + assertEquals("maven-api-core", modules.get(0).get("artifactId")); + } + + @Test + void moduleFilterIsCaseInsensitive() { + BuildReportFilter filter = new BuildReportFilter("API", null, null, null); + Map result = filter.apply(sampleReport()); + @SuppressWarnings("unchecked") + List> modules = (List>) result.get("modules"); + assertEquals(1, modules.size()); + } + + @Test + void mojoFilterKeepsMatchingMojos() { + BuildReportFilter filter = new BuildReportFilter(null, "resources", null, null); + Map result = filter.apply(sampleReport()); + + @SuppressWarnings("unchecked") + List> modules = (List>) result.get("modules"); + // Both modules should remain + assertEquals(2, modules.size()); + + // Module 1 (api) has no resources mojo + @SuppressWarnings("unchecked") + List> mojos0 = + (List>) modules.get(0).get("mojos"); + assertEquals(0, mojos0.size()); + + // Module 2 (cli) has a resources mojo + @SuppressWarnings("unchecked") + List> mojos1 = + (List>) modules.get(1).get("mojos"); + assertEquals(1, mojos1.size()); + assertEquals("resources", mojos1.get(0).get("goal")); + } + + @Test + void levelFilterKeepsEventsAtOrAbove() { + BuildReportFilter filter = new BuildReportFilter(null, null, "WARN", null); + assertTrue(filter.hasLogFilters()); + + Map report = sampleReport(); + List> events = filter.collectMatchingLogEvents(report); + // Should only include WARN and ERROR events + for (Map event : events) { + String level = (String) event.get("level"); + assertTrue("WARN".equals(level) || "ERROR".equals(level), "Unexpected level: " + level); + } + assertFalse(events.isEmpty(), "Should find at least one WARN/ERROR event"); + } + + @Test + void grepFilterMatchesMessages() { + BuildReportFilter filter = new BuildReportFilter(null, null, null, "deprecated"); + assertTrue(filter.hasLogFilters()); + + Map report = sampleReport(); + List> events = filter.collectMatchingLogEvents(report); + assertEquals(1, events.size()); + assertTrue(((String) events.get(0).get("message")).contains("deprecated")); + } + + @Test + void grepFilterIsCaseInsensitive() { + BuildReportFilter filter = new BuildReportFilter(null, null, null, "DEPRECATED"); + List> events = filter.collectMatchingLogEvents(sampleReport()); + assertEquals(1, events.size()); + } + + @Test + void combinedModuleAndLevelFilter() { + BuildReportFilter filter = new BuildReportFilter("cli", null, "WARN", null); + List> events = filter.collectMatchingLogEvents(sampleReport()); + // Should only include WARN+ events from maven-cli module + for (Map event : events) { + String context = (String) event.get("context"); + assertTrue(context.contains("cli"), "Event context should contain 'cli': " + context); + } + } + + @Test + void isAtOrAboveLevelComparison() { + assertTrue(BuildReportFilter.isAtOrAbove("ERROR", "WARN")); + assertTrue(BuildReportFilter.isAtOrAbove("WARN", "WARN")); + assertFalse(BuildReportFilter.isAtOrAbove("INFO", "WARN")); + assertFalse(BuildReportFilter.isAtOrAbove("DEBUG", "WARN")); + assertTrue(BuildReportFilter.isAtOrAbove("INFO", "INFO")); + assertTrue(BuildReportFilter.isAtOrAbove("ERROR", "TRACE")); + } + + @Test + void jsonFilteredOutputRemovesNonMatchingModules() { + BuildReportFilter filter = new BuildReportFilter("api", null, null, null); + Map result = filter.apply(sampleReport()); + // The result should still have all top-level keys + assertTrue(result.containsKey("status")); + assertTrue(result.containsKey("modules")); + + @SuppressWarnings("unchecked") + List> modules = (List>) result.get("modules"); + assertEquals(1, modules.size()); + } + + @Test + void applyWithLevelFilterFiltersModuleOutput() { + BuildReportFilter filter = new BuildReportFilter(null, null, "ERROR", null); + Map result = filter.apply(sampleReport()); + + @SuppressWarnings("unchecked") + List> modules = (List>) result.get("modules"); + for (Map module : modules) { + @SuppressWarnings("unchecked") + List> output = (List>) module.get("output"); + for (Map event : output) { + assertEquals("ERROR", event.get("level")); + } + } + } + + // ---- Test data ---- + + @SuppressWarnings("unchecked") + private static Map sampleReport() { + Map report = new LinkedHashMap<>(); + report.put("status", "SUCCESS"); + report.put("mavenVersion", "4.1.0-SNAPSHOT"); + report.put("duration", "PT10.5S"); + report.put( + "output", + List.of( + logEvent("INFO", "Building reactor..."), + logEvent("WARN", "Reactor build order could be optimized"))); + + // Module 1: maven-api-core + Map apiModule = new LinkedHashMap<>(); + apiModule.put("artifactId", "maven-api-core"); + apiModule.put("status", "SUCCESS"); + apiModule.put("duration", "PT3.2S"); + apiModule.put( + "output", + List.of( + logEvent("INFO", "Compiling 42 source files"), + logEvent("WARN", "Using deprecated API method"))); + apiModule.put( + "mojos", + List.of( + mojo("compile", List.of(logEvent("INFO", "Compiling sources"))), + mojo("test-compile", List.of(logEvent("INFO", "Compiling test sources"))))); + + // Module 2: maven-cli + Map cliModule = new LinkedHashMap<>(); + cliModule.put("artifactId", "maven-cli"); + cliModule.put("status", "SUCCESS"); + cliModule.put("duration", "PT4.1S"); + cliModule.put( + "output", + List.of( + logEvent("INFO", "Compiling 30 source files"), + logEvent("WARN", "Some warning in CLI"), + logEvent("ERROR", "Critical error in module"))); + cliModule.put( + "mojos", + List.of( + mojo("compile", List.of(logEvent("INFO", "Compiling sources"))), + mojo("resources", List.of(logEvent("DEBUG", "Copying resources"))))); + + report.put("modules", List.of(apiModule, cliModule)); + report.put("problems", List.of()); + report.put("failures", List.of()); + return report; + } + + private static Map logEvent(String level, String message) { + Map event = new LinkedHashMap<>(); + event.put("level", level); + event.put("message", message); + event.put("loggerName", "org.apache.maven.TestLogger"); + event.put("timestamp", "2026-08-08T10:00:00Z"); + return event; + } + + private static Map mojo(String goal, List> output) { + Map mojo = new LinkedHashMap<>(); + mojo.put("goal", goal); + mojo.put("artifactId", "maven-compiler-plugin"); + mojo.put("status", "SUCCESS"); + mojo.put("duration", "PT1.5S"); + mojo.put("output", output); + return mojo; + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/BuildReportRendererTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/BuildReportRendererTest.java new file mode 100644 index 000000000000..8e7286718960 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/BuildReportRendererTest.java @@ -0,0 +1,250 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.invoker.mvnlog; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.apache.maven.jline.JLineMessageBuilderFactory; +import org.apache.maven.jline.MessageUtils; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BuildReportRendererTest { + + private final JLineMessageBuilderFactory messageBuilderFactory = new JLineMessageBuilderFactory(); + + @BeforeAll + static void setUp() { + MessageUtils.setColorEnabled(false); + } + + @AfterAll + static void tearDown() { + MessageUtils.setColorEnabled(true); + } + + private Map createSuccessReport() { + String json = """ + { + "formatVersion": "1.0", + "status": "SUCCESS", + "duration": "PT6.7S", + "startTime": "2026-07-29T10:00:00Z", + "mavenVersion": "4.1.0-SNAPSHOT", + "javaVersion": "21.0.1", + "goals": ["clean", "install"], + "project": "org.example:root", + "multiModule": true, + "threads": 1, + "modules": [ + { + "groupId": "org.example", + "artifactId": "api", + "version": "1.0", + "status": "SUCCESS", + "startTime": "2026-07-29T10:00:01Z", + "duration": "PT2.1S", + "mojos": [ + { + "groupId": "org.apache.maven.plugins", + "artifactId": "maven-compiler-plugin", + "version": "3.15.0", + "goal": "compile", + "executionId": "default-compile", + "phase": "compile", + "status": "SUCCESS", + "startTime": "2026-07-29T10:00:01Z", + "duration": "PT1.5S", + "output": [] + } + ], + "output": [] + }, + { + "groupId": "org.example", + "artifactId": "core", + "version": "1.0", + "status": "SUCCESS", + "startTime": "2026-07-29T10:00:03Z", + "duration": "PT3.4S", + "mojos": [], + "output": [] + } + ], + "problems": [ + { + "key": "deprecated-source-target", + "severity": "WARNING", + "message": "source/target value 8 is obsolete and will be removed in a future release", + "source": "maven-compiler-plugin:3.15.0:compile", + "suggestion": "Update maven.compiler.source to 11 or higher", + "documentationUrl": "https://maven.apache.org/plugins/maven-compiler-plugin/" + } + ], + "failures": [], + "output": [] + }"""; + return SimpleJsonReader.parse(json); + } + + private Map createFailureReport() { + String json = """ + { + "formatVersion": "1.0", + "status": "FAILURE", + "duration": "PT5.0S", + "startTime": "2026-07-29T10:00:00Z", + "mavenVersion": "4.1.0-SNAPSHOT", + "javaVersion": "21.0.1", + "goals": ["compile"], + "project": "org.example:root", + "multiModule": false, + "threads": 1, + "modules": [ + { + "groupId": "org.example", + "artifactId": "core", + "version": "1.0", + "status": "FAILURE", + "startTime": "2026-07-29T10:00:01Z", + "duration": "PT5.0S", + "mojos": [], + "output": [] + } + ], + "problems": [], + "failures": [ + { + "module": "org.example:core", + "mojo": "compiler:compile", + "timestamp": "2026-07-29T10:00:05Z", + "message": "Compilation failure", + "stackTrace": "org.apache.maven.lifecycle.LifecycleExecutionException\\nat Lifecycle.java:42" + } + ], + "output": [] + }"""; + return SimpleJsonReader.parse(json); + } + + @Test + void testRenderSummarySuccess() { + List lines = new ArrayList<>(); + BuildReportRenderer renderer = new BuildReportRenderer(messageBuilderFactory, lines::add); + + renderer.renderSummary(createSuccessReport()); + + String output = String.join("\n", lines); + assertTrue(output.contains("Build Report"), "Should contain header"); + assertTrue(output.contains("Maven 4.1.0-SNAPSHOT"), "Should contain Maven version"); + assertTrue(output.contains("BUILD SUCCESS"), "Should contain BUILD SUCCESS"); + assertTrue(output.contains("api"), "Should contain first module"); + assertTrue(output.contains("core"), "Should contain second module"); + assertTrue(output.contains("2 modules"), "Should contain module count"); + assertTrue(output.contains("2 passed"), "Should contain passed count"); + assertTrue(output.contains("1 warning"), "Should contain warning count"); + assertTrue(output.contains("source/target value 8"), "Should show actual warning message in default view"); + assertTrue(output.contains("maven-compiler-plugin"), "Should show warning source in default view"); + assertTrue(output.contains("Update maven.compiler.source"), "Should show suggestion in default view"); + assertTrue(output.contains("Total time: 6.700 s"), "Should contain formatted total time"); + } + + @Test + void testRenderSummaryFailure() { + List 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("Problems (1)"), "Should contain problems header"); + assertTrue(output.contains("source/target value 8"), "Should contain warning message"); + assertTrue(output.contains("deprecated-source-target"), "Should contain problem key"); + assertTrue(output.contains("maven-compiler-plugin"), "Should contain source"); + assertTrue(output.contains("Update maven.compiler.source"), "Should contain suggestion"); + assertTrue( + output.contains("https://maven.apache.org/plugins/maven-compiler-plugin/"), + "Should contain documentation URL"); + } + + @Test + void testRenderDiagnosticsWhenEmpty() { + List lines = new ArrayList<>(); + BuildReportRenderer renderer = new BuildReportRenderer(messageBuilderFactory, lines::add); + + renderer.renderDiagnostics(createFailureReport()); + + String output = String.join("\n", lines); + assertTrue(output.contains("No problems recorded"), "Should show empty message"); + } + + @Test + void testRenderFailures() { + List 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..451151387cef --- /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": [] + } + ], + "problems": [], + "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-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonWriterTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonWriterTest.java new file mode 100644 index 000000000000..fdadf81b7131 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonWriterTest.java @@ -0,0 +1,116 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.invoker.mvnlog; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SimpleJsonWriterTest { + + @Test + void emptyObject() { + assertEquals("{}", SimpleJsonWriter.toJson(Map.of())); + } + + @Test + void emptyArray() { + assertEquals("[]", SimpleJsonWriter.toJson(List.of())); + } + + @Test + void simpleString() { + assertEquals("\"hello\"", SimpleJsonWriter.toJson("hello")); + } + + @Test + void stringWithEscapes() { + String json = SimpleJsonWriter.toJson("line1\nline2\t\"quoted\""); + assertEquals("\"line1\\nline2\\t\\\"quoted\\\"\"", json); + } + + @Test + void numbers() { + assertEquals("42", SimpleJsonWriter.toJson(42)); + assertEquals("3.14", SimpleJsonWriter.toJson(3.14)); + } + + @Test + void booleans() { + assertEquals("true", SimpleJsonWriter.toJson(true)); + assertEquals("false", SimpleJsonWriter.toJson(false)); + } + + @Test + void nullValue() { + assertEquals("null", SimpleJsonWriter.toJson(null)); + } + + @Test + void simpleObject() { + Map map = new LinkedHashMap<>(); + map.put("name", "test"); + map.put("count", 42); + + String json = SimpleJsonWriter.toJson(map); + assertTrue(json.contains("\"name\": \"test\"")); + assertTrue(json.contains("\"count\": 42")); + } + + @Test + void nestedObject() { + Map inner = new LinkedHashMap<>(); + inner.put("key", "value"); + + Map outer = new LinkedHashMap<>(); + outer.put("nested", inner); + + String json = SimpleJsonWriter.toJson(outer); + assertTrue(json.contains("\"nested\": {")); + assertTrue(json.contains("\"key\": \"value\"")); + } + + @Test + void arrayOfStrings() { + String json = SimpleJsonWriter.toJson(List.of("a", "b", "c")); + assertTrue(json.contains("\"a\"")); + assertTrue(json.contains("\"b\"")); + assertTrue(json.contains("\"c\"")); + } + + @Test + void roundTripWithSimpleJsonReader() { + Map original = new LinkedHashMap<>(); + original.put("status", "SUCCESS"); + original.put("count", 42); + original.put("items", List.of("a", "b")); + + String json = SimpleJsonWriter.toJson(original); + Map parsed = SimpleJsonReader.parse(json); + + assertEquals("SUCCESS", parsed.get("status")); + // SimpleJsonReader parses small integers as Integer + assertEquals(42, ((Number) parsed.get("count")).intValue()); + } +} diff --git a/impl/maven-core/pom.xml b/impl/maven-core/pom.xml index 07ffa6662b12..efec4c8c0975 100644 --- a/impl/maven-core/pom.xml +++ b/impl/maven-core/pom.xml @@ -396,6 +396,8 @@ under the License. org.apache.maven.toolchain.ToolchainManagerPrivate org.apache.maven.toolchain.ToolchainPrivate org.apache.maven.toolchain.ToolchainsBuilder + + org.apache.maven.plugin.PluginValidationManager diff --git a/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java b/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java index 23e85a14b01e..91769faf509e 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java +++ b/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java @@ -43,6 +43,7 @@ import org.apache.maven.api.model.Model; import org.apache.maven.api.model.Prerequisites; import org.apache.maven.api.model.Profile; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.api.services.Lookup; import org.apache.maven.api.services.LookupException; import org.apache.maven.artifact.ArtifactUtils; @@ -59,6 +60,7 @@ import org.apache.maven.execution.ProjectDependencyGraph; import org.apache.maven.graph.GraphBuilder; import org.apache.maven.graph.ProjectSelector; +import org.apache.maven.internal.build.DefaultDiagnosticCollector; import org.apache.maven.internal.impl.DefaultSessionFactory; import org.apache.maven.internal.impl.InternalMavenSession; import org.apache.maven.lifecycle.LifecycleExecutionException; @@ -113,6 +115,8 @@ public class DefaultMaven implements Maven { private final ProjectSelector projectSelector; + private final DefaultDiagnosticCollector diagnosticCollector; + @Inject @SuppressWarnings("checkstyle:ParameterNumber") public DefaultMaven( @@ -126,7 +130,8 @@ public DefaultMaven( BuildResumptionDataRepository buildResumptionDataRepository, SuperPomProvider superPomProvider, DefaultSessionFactory defaultSessionFactory, - @Nullable @Named("ide") WorkspaceReader ideWorkspaceReader) { + @Nullable @Named("ide") WorkspaceReader ideWorkspaceReader, + DefaultDiagnosticCollector diagnosticCollector) { this.lookup = lookup; this.eventCatapult = eventCatapult; this.legacySupport = legacySupport; @@ -138,6 +143,7 @@ public DefaultMaven( this.superPomProvider = superPomProvider; this.ideWorkspaceReader = ideWorkspaceReader; this.defaultSessionFactory = defaultSessionFactory; + this.diagnosticCollector = diagnosticCollector; this.projectSelector = new ProjectSelector(); // if necessary switch to DI } @@ -648,6 +654,10 @@ private Result buildGraph(MavenSession session } else { logger.error(problem.getMessage()); } + // Pipe structured problem directly to DiagnosticCollector so that + // source location and severity are preserved in the build report. + // The SLF4J hook excludes this logger to avoid double-counting. + diagnosticCollector.report(toBuilderProblem(problem)); } if (!graphResult.hasErrors()) { @@ -660,6 +670,28 @@ private Result buildGraph(MavenSession session return graphResult; } + /** + * Converts a compat {@link ModelProblem} to the Maven 4 {@link BuilderProblem} API, + * preserving source, line, column, severity, and message. + */ + private static BuilderProblem toBuilderProblem(ModelProblem problem) { + BuilderProblem.Severity severity = + switch (problem.getSeverity()) { + case FATAL -> BuilderProblem.Severity.FATAL; + case ERROR -> BuilderProblem.Severity.ERROR; + default -> BuilderProblem.Severity.WARNING; + }; + return BuilderProblem.builder() + .source(problem.getSource()) + .lineNumber(problem.getLineNumber()) + .columnNumber(problem.getColumnNumber()) + .exception(problem.getException()) + .message(problem.getMessage()) + .severity(severity) + .key("model:" + problem.getMessage().hashCode()) + .build(); + } + @Deprecated // 5 January 2014 protected Logger getLogger() { 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 index be3e0ee2a9d2..df0a041fe833 100644 --- 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 @@ -91,6 +91,19 @@ public final class BuildReportCollector extends AbstractEventSpy { private static final int MAX_STACKTRACE_LINES = 30; + /** + * Logger names excluded from SLF4J auto-collection because these classes + * already pipe structured {@link org.apache.maven.api.services.BuilderProblem} + * objects directly to the {@link DefaultDiagnosticCollector}. Without this + * exclusion, each problem would be counted twice: once from the direct pipe + * and once from the SLF4J WARN interception. + */ + private static final Set EXCLUDED_LOGGERS = Set.of( + BuildReportCollector.class.getName(), + "org.apache.maven.DefaultMaven", + "org.apache.maven.project.collector.DefaultProjectsSelector", + "org.apache.maven.plugin.internal.DefaultPluginValidationManager"); + private final DefaultDiagnosticCollector diagnosticCollector; @Inject @@ -353,10 +366,12 @@ private void captureLogEvent(LogEvent event) { // Auto-collect WARN-level log events as build problems, giving Maven 3 plugins // automatic deduplication and summary at end of build without code changes. - // Skip our own logger to avoid feedback loops from problem summary printing. + // Skip loggers that already pipe structured BuilderProblems directly to the + // DiagnosticCollector (avoiding double-counting), and our own logger to avoid + // feedback loops from problem summary printing. if (event.level() == LogLevel.WARN && event.message() != null - && !event.loggerName().equals(BuildReportCollector.class.getName())) { + && !EXCLUDED_LOGGERS.contains(event.loggerName())) { String syntheticKey = syntheticDiagnosticKey(event.loggerName(), event.message()); diagnosticCollector.report(BuilderProblem.builder() .source(event.loggerName()) diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/PluginValidationManager.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/PluginValidationManager.java index d65f8dad681a..6c2cc943add1 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/PluginValidationManager.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/PluginValidationManager.java @@ -18,6 +18,7 @@ */ package org.apache.maven.plugin; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.execution.MavenSession; import org.apache.maven.plugin.descriptor.MojoDescriptor; import org.eclipse.aether.RepositorySystemSession; @@ -54,27 +55,94 @@ enum IssueLocality { * This method should be used in "early" phase of plugin execution, possibly even when plugin or mojo descriptor * does not exist yet. In turn, this method will not record extra information like plugin occurrence or declaration * location as those are not yet available. + * + * @since 4.1.0 */ void reportPluginValidationIssue( - IssueLocality locality, RepositorySystemSession session, Artifact pluginArtifact, String issue); + IssueLocality locality, RepositorySystemSession session, Artifact pluginArtifact, BuilderProblem problem); /** * Reports plugin issues applicable to the plugin as a whole. *

* This method will record extra information as well, like plugin occurrence or declaration location. + * + * @since 4.1.0 */ void reportPluginValidationIssue( - IssueLocality locality, MavenSession mavenSession, MojoDescriptor mojoDescriptor, String issue); + IssueLocality locality, MavenSession mavenSession, MojoDescriptor mojoDescriptor, BuilderProblem problem); /** * Reports plugin Mojo issues applicable to the Mojo itself. *

* This method will record extra information as well, like plugin occurrence or declaration location. + * + * @since 4.1.0 */ void reportPluginMojoValidationIssue( IssueLocality locality, MavenSession mavenSession, MojoDescriptor mojoDescriptor, Class mojoClass, - String issue); + BuilderProblem problem); + + // ---- Deprecated String-based adapters for external callers ---- + + /** + * @deprecated Use {@link #reportPluginValidationIssue(IssueLocality, RepositorySystemSession, Artifact, + * BuilderProblem)} instead. + */ + @Deprecated(since = "4.1.0", forRemoval = true) + default void reportPluginValidationIssue( + IssueLocality locality, RepositorySystemSession session, Artifact pluginArtifact, String issue) { + reportPluginValidationIssue( + locality, + session, + pluginArtifact, + BuilderProblem.builder() + .message(issue) + .severity(BuilderProblem.Severity.WARNING) + .key("plugin-validation:" + issue.hashCode()) + .build()); + } + + /** + * @deprecated Use {@link #reportPluginValidationIssue(IssueLocality, MavenSession, MojoDescriptor, + * BuilderProblem)} instead. + */ + @Deprecated(since = "4.1.0", forRemoval = true) + default void reportPluginValidationIssue( + IssueLocality locality, MavenSession mavenSession, MojoDescriptor mojoDescriptor, String issue) { + reportPluginValidationIssue( + locality, + mavenSession, + mojoDescriptor, + BuilderProblem.builder() + .message(issue) + .severity(BuilderProblem.Severity.WARNING) + .key("plugin-validation:" + issue.hashCode()) + .build()); + } + + /** + * @deprecated Use {@link #reportPluginMojoValidationIssue(IssueLocality, MavenSession, MojoDescriptor, Class, + * BuilderProblem)} instead. + */ + @Deprecated(since = "4.1.0", forRemoval = true) + default void reportPluginMojoValidationIssue( + IssueLocality locality, + MavenSession mavenSession, + MojoDescriptor mojoDescriptor, + Class mojoClass, + String issue) { + reportPluginMojoValidationIssue( + locality, + mavenSession, + mojoDescriptor, + mojoClass, + BuilderProblem.builder() + .message(issue) + .severity(BuilderProblem.Severity.WARNING) + .key("plugin-validation:" + issue.hashCode()) + .build()); + } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/AbstractMavenPluginParametersValidator.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/AbstractMavenPluginParametersValidator.java index 9e7426b168ff..47a772809e37 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/AbstractMavenPluginParametersValidator.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/AbstractMavenPluginParametersValidator.java @@ -18,6 +18,7 @@ */ package org.apache.maven.plugin.internal; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.execution.MavenSession; import org.apache.maven.plugin.PluginValidationManager; import org.apache.maven.plugin.descriptor.MojoDescriptor; @@ -97,6 +98,11 @@ protected boolean isIgnoredProperty(String strValue) { protected abstract String getParameterLogReason(Parameter parameter); + /** + * Returns the validation key prefix for this validator (e.g. "deprecated-param", "readonly-param"). + */ + protected abstract String getValidationKeyPrefix(); + protected String formatParameter(Parameter parameter) { StringBuilder stringBuilder = new StringBuilder() .append("Parameter '") @@ -112,4 +118,12 @@ protected String formatParameter(Parameter parameter) { return stringBuilder.toString(); } + + protected BuilderProblem buildParameterProblem(Parameter parameter) { + return BuilderProblem.builder() + .message(formatParameter(parameter)) + .severity(BuilderProblem.Severity.WARNING) + .key("plugin-validation:" + getValidationKeyPrefix() + ":" + parameter.getName()) + .build(); + } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java index 62a39f72fb8c..2a5e8e9acc5e 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java @@ -51,6 +51,7 @@ import org.apache.maven.api.Service; import org.apache.maven.api.Session; import org.apache.maven.api.plugin.descriptor.Resolution; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.api.services.DependencyResolver; import org.apache.maven.api.services.DependencyResolverResult; import org.apache.maven.api.services.PathScopeRegistry; @@ -751,7 +752,13 @@ private T loadV3Mojo( session, mojoDescriptor, mojo.getClass(), - "Mojo implements `Contextualizable` interface from Plexus Container, which is EOL."); + BuilderProblem.builder() + .message( + "Mojo implements `Contextualizable` interface from Plexus Container, which is EOL.") + .severity(BuilderProblem.Severity.WARNING) + .key("plugin-validation:contextualizable") + .suggestion("Migrate from Contextualizable to javax.inject dependency injection") + .build()); } XmlNode dom = mojoExecution.getConfiguration() != null diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultPluginValidationManager.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultPluginValidationManager.java index 0bd14d1635d1..e445b2978e1f 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultPluginValidationManager.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultPluginValidationManager.java @@ -18,6 +18,7 @@ */ package org.apache.maven.plugin.internal; +import javax.inject.Inject; import javax.inject.Named; import javax.inject.Singleton; @@ -39,9 +40,11 @@ import java.util.stream.Collectors; import org.apache.maven.api.Constants; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.eventspy.AbstractEventSpy; import org.apache.maven.execution.ExecutionEvent; import org.apache.maven.execution.MavenSession; +import org.apache.maven.internal.build.DefaultDiagnosticCollector; import org.apache.maven.model.InputLocation; import org.apache.maven.plugin.PluginValidationManager; import org.apache.maven.plugin.descriptor.MojoDescriptor; @@ -83,6 +86,13 @@ private enum ValidationReportLevel { private final Logger logger = LoggerFactory.getLogger(getClass()); + private final DefaultDiagnosticCollector diagnosticCollector; + + @Inject + DefaultPluginValidationManager(DefaultDiagnosticCollector diagnosticCollector) { + this.diagnosticCollector = diagnosticCollector; + } + @Override public void onEvent(Object event) { if (event instanceof ExecutionEvent executionEvent) { @@ -158,28 +168,30 @@ private void mayReportInline(RepositorySystemSession session, IssueLocality loca @Override public void reportPluginValidationIssue( - IssueLocality locality, RepositorySystemSession session, Artifact pluginArtifact, String issue) { + IssueLocality locality, RepositorySystemSession session, Artifact pluginArtifact, BuilderProblem problem) { String pluginKey = pluginKey(pluginArtifact); if (validationPluginExcludes(session).contains(pluginKey)) { return; } PluginValidationIssues pluginIssues = pluginIssues(session).computeIfAbsent(pluginKey, k -> new PluginValidationIssues()); - pluginIssues.reportPluginIssue(locality, null, issue); - mayReportInline(session, locality, issue); + pluginIssues.reportPluginIssue(locality, null, problem.getMessage()); + diagnosticCollector.report(problem); + mayReportInline(session, locality, problem.getMessage()); } @Override public void reportPluginValidationIssue( - IssueLocality locality, MavenSession mavenSession, MojoDescriptor mojoDescriptor, String issue) { + IssueLocality locality, MavenSession mavenSession, MojoDescriptor mojoDescriptor, BuilderProblem problem) { String pluginKey = pluginKey(mojoDescriptor); if (validationPluginExcludes(mavenSession.getRepositorySession()).contains(pluginKey)) { return; } PluginValidationIssues pluginIssues = pluginIssues(mavenSession.getRepositorySession()) .computeIfAbsent(pluginKey, k -> new PluginValidationIssues()); - pluginIssues.reportPluginIssue(locality, pluginDeclaration(mavenSession, mojoDescriptor), issue); - mayReportInline(mavenSession.getRepositorySession(), locality, issue); + pluginIssues.reportPluginIssue(locality, pluginDeclaration(mavenSession, mojoDescriptor), problem.getMessage()); + diagnosticCollector.report(problem); + mayReportInline(mavenSession.getRepositorySession(), locality, problem.getMessage()); } @Override @@ -188,7 +200,7 @@ public void reportPluginMojoValidationIssue( MavenSession mavenSession, MojoDescriptor mojoDescriptor, Class mojoClass, - String issue) { + BuilderProblem problem) { String pluginKey = pluginKey(mojoDescriptor); if (validationPluginExcludes(mavenSession.getRepositorySession()).contains(pluginKey)) { return; @@ -196,8 +208,12 @@ public void reportPluginMojoValidationIssue( PluginValidationIssues pluginIssues = pluginIssues(mavenSession.getRepositorySession()) .computeIfAbsent(pluginKey, k -> new PluginValidationIssues()); pluginIssues.reportPluginMojoIssue( - locality, pluginDeclaration(mavenSession, mojoDescriptor), mojoInfo(mojoDescriptor, mojoClass), issue); - mayReportInline(mavenSession.getRepositorySession(), locality, issue); + locality, + pluginDeclaration(mavenSession, mojoDescriptor), + mojoInfo(mojoDescriptor, mojoClass), + problem.getMessage()); + diagnosticCollector.report(problem); + mayReportInline(mavenSession.getRepositorySession(), locality, problem.getMessage()); } private void reportSessionCollectedValidationIssues(MavenSession mavenSession) { diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DeprecatedCoreExpressionValidator.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DeprecatedCoreExpressionValidator.java index faaac5cf1c53..81029df570fb 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DeprecatedCoreExpressionValidator.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DeprecatedCoreExpressionValidator.java @@ -63,6 +63,11 @@ protected String getParameterLogReason(Parameter parameter) { + DEPRECATED_CORE_PARAMETERS.get(parameter.getDefaultValue()); } + @Override + protected String getValidationKeyPrefix() { + return "deprecated-expr"; + } + @Override protected void doValidate( MavenSession mavenSession, @@ -76,9 +81,13 @@ protected void doValidate( mojoDescriptor.getParameters().stream() .filter(this::isDeprecated) - .map(this::formatParameter) - .forEach(m -> pluginValidationManager.reportPluginMojoValidationIssue( - PluginValidationManager.IssueLocality.EXTERNAL, mavenSession, mojoDescriptor, mojoClass, m)); + .map(this::buildParameterProblem) + .forEach(problem -> pluginValidationManager.reportPluginMojoValidationIssue( + PluginValidationManager.IssueLocality.EXTERNAL, + mavenSession, + mojoDescriptor, + mojoClass, + problem)); } private boolean isDeprecated(Parameter parameter) { diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DeprecatedPluginValidator.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DeprecatedPluginValidator.java index a4d784c403dc..45c973fcd967 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DeprecatedPluginValidator.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DeprecatedPluginValidator.java @@ -22,6 +22,7 @@ import javax.inject.Named; import javax.inject.Singleton; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.api.services.MessageBuilderFactory; import org.apache.maven.execution.MavenSession; import org.apache.maven.plugin.PluginValidationManager; @@ -52,6 +53,11 @@ protected String getParameterLogReason(Parameter parameter) { return "is deprecated: " + parameter.getDeprecated(); } + @Override + protected String getValidationKeyPrefix() { + return "deprecated-param"; + } + @Override protected void doValidate( MavenSession mavenSession, @@ -65,7 +71,11 @@ protected void doValidate( mavenSession, mojoDescriptor, mojoClass, - logDeprecatedMojo(mojoDescriptor)); + BuilderProblem.builder() + .message(logDeprecatedMojo(mojoDescriptor)) + .severity(BuilderProblem.Severity.WARNING) + .key("plugin-validation:deprecated-goal:" + mojoDescriptor.getGoal()) + .build()); } if (mojoDescriptor.getParameters() != null) { @@ -92,7 +102,7 @@ private void checkParameter( mavenSession, mojoDescriptor, mojoClass, - formatParameter(parameter)); + buildParameterProblem(parameter)); } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/Maven2DependenciesValidator.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/Maven2DependenciesValidator.java index d2f8dd89ce77..05b206b97c0b 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/Maven2DependenciesValidator.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/Maven2DependenciesValidator.java @@ -25,6 +25,7 @@ import java.util.Set; import java.util.stream.Collectors; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.plugin.PluginValidationManager; import org.eclipse.aether.RepositorySystemSession; import org.eclipse.aether.artifact.Artifact; @@ -64,7 +65,12 @@ protected void doValidate( PluginValidationManager.IssueLocality.EXTERNAL, session, pluginArtifact, - "Plugin is a Maven 2.x plugin, which will be not supported in Maven 4.x"); + BuilderProblem.builder() + .message("Plugin is a Maven 2.x plugin, which will be not supported in Maven 4.x") + .severity(BuilderProblem.Severity.WARNING) + .key("plugin-validation:maven2-plugin") + .suggestion("Upgrade to a Maven 3.x/4.x compatible version of this plugin") + .build()); } } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/Maven3CompatDependenciesValidator.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/Maven3CompatDependenciesValidator.java index 0cf8fee6ad35..559d3c189828 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/Maven3CompatDependenciesValidator.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/Maven3CompatDependenciesValidator.java @@ -23,6 +23,7 @@ import javax.inject.Singleton; import org.apache.maven.api.DependencyScope; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.plugin.PluginValidationManager; import org.eclipse.aether.RepositorySystemSession; import org.eclipse.aether.artifact.Artifact; @@ -55,7 +56,13 @@ protected void doValidate( PluginValidationManager.IssueLocality.EXTERNAL, session, pluginArtifact, - "Plugin depends on the deprecated Maven 2.x compatibility layer, which will be not supported in Maven 4.x"); + BuilderProblem.builder() + .message( + "Plugin depends on the deprecated Maven 2.x compatibility layer, which will be not supported in Maven 4.x") + .severity(BuilderProblem.Severity.WARNING) + .key("plugin-validation:maven-compat-dep") + .suggestion("Remove the maven-compat dependency from the plugin") + .build()); } } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/MavenMixedDependenciesValidator.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/MavenMixedDependenciesValidator.java index 27003836d3fd..3ae05d3a100a 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/MavenMixedDependenciesValidator.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/MavenMixedDependenciesValidator.java @@ -25,6 +25,7 @@ import java.util.Set; import java.util.stream.Collectors; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.plugin.PluginValidationManager; import org.eclipse.aether.RepositorySystemSession; import org.eclipse.aether.artifact.Artifact; @@ -63,7 +64,12 @@ protected void doValidate( PluginValidationManager.IssueLocality.EXTERNAL, session, pluginArtifact, - "Plugin mixes multiple Maven versions: " + mavenVersions); + BuilderProblem.builder() + .message("Plugin mixes multiple Maven versions: " + mavenVersions) + .severity(BuilderProblem.Severity.WARNING) + .key("plugin-validation:mixed-maven-versions") + .suggestion("Align all Maven dependencies to a single version to avoid classloading issues") + .build()); } } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/MavenScopeDependenciesValidator.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/MavenScopeDependenciesValidator.java index 460a91c6ec18..48f78c901102 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/MavenScopeDependenciesValidator.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/MavenScopeDependenciesValidator.java @@ -26,6 +26,7 @@ import java.util.stream.Collectors; import org.apache.maven.api.DependencyScope; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.plugin.PluginValidationManager; import org.eclipse.aether.RepositorySystemSession; import org.eclipse.aether.artifact.Artifact; @@ -65,8 +66,15 @@ protected void doValidate( PluginValidationManager.IssueLocality.EXTERNAL, session, pluginArtifact, - "Plugin should declare Maven artifacts in `provided` scope. If the plugin already declares them in `provided` scope, update the maven-plugin-plugin to latest version. Artifacts found with wrong scope: " - + mavenArtifacts); + BuilderProblem.builder() + .message( + "Plugin should declare Maven artifacts in `provided` scope. If the plugin already declares them in `provided` scope, update the maven-plugin-plugin to latest version. Artifacts found with wrong scope: " + + mavenArtifacts) + .severity(BuilderProblem.Severity.WARNING) + .key("plugin-validation:wrong-scope") + .suggestion( + "Change Maven artifact dependencies to 'provided' scope or update maven-plugin-plugin") + .build()); } } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/PlexusContainerDefaultDependenciesValidator.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/PlexusContainerDefaultDependenciesValidator.java index 31638139a3f7..ba15ab4d80cc 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/PlexusContainerDefaultDependenciesValidator.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/PlexusContainerDefaultDependenciesValidator.java @@ -22,6 +22,7 @@ import javax.inject.Named; import javax.inject.Singleton; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.plugin.PluginValidationManager; import org.eclipse.aether.RepositorySystemSession; import org.eclipse.aether.artifact.Artifact; @@ -55,7 +56,12 @@ protected void doValidate( PluginValidationManager.IssueLocality.EXTERNAL, session, pluginArtifact, - "Plugin depends on plexus-container-default, which is EOL"); + BuilderProblem.builder() + .message("Plugin depends on plexus-container-default, which is EOL") + .severity(BuilderProblem.Severity.WARNING) + .key("plugin-validation:plexus-container-eol") + .suggestion("Migrate from plexus-container-default to javax.inject / Eclipse Sisu") + .build()); } } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/ReadOnlyPluginParametersValidator.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/ReadOnlyPluginParametersValidator.java index 2989fb5166a2..69e4d9870aff 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/ReadOnlyPluginParametersValidator.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/ReadOnlyPluginParametersValidator.java @@ -47,6 +47,11 @@ protected String getParameterLogReason(Parameter parameter) { return "is read-only, must not be used in configuration"; } + @Override + protected String getValidationKeyPrefix() { + return "readonly-param"; + } + @Override protected void doValidate( MavenSession mavenSession, @@ -79,7 +84,7 @@ private void checkParameter( mavenSession, mojoDescriptor, mojoClass, - formatParameter(parameter)); + buildParameterProblem(parameter)); } } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/project/collector/DefaultProjectsSelector.java b/impl/maven-core/src/main/java/org/apache/maven/project/collector/DefaultProjectsSelector.java index 0be344384adf..bd5664d1eba2 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/project/collector/DefaultProjectsSelector.java +++ b/impl/maven-core/src/main/java/org/apache/maven/project/collector/DefaultProjectsSelector.java @@ -26,7 +26,9 @@ import java.util.ArrayList; import java.util.List; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.internal.build.DefaultDiagnosticCollector; import org.apache.maven.model.building.ModelProblem; import org.apache.maven.model.building.ModelProblemUtils; import org.apache.maven.project.MavenProject; @@ -45,10 +47,12 @@ public class DefaultProjectsSelector implements ProjectsSelector { private static final Logger LOGGER = LoggerFactory.getLogger(DefaultProjectsSelector.class); private final ProjectBuilder projectBuilder; + private final DefaultDiagnosticCollector diagnosticCollector; @Inject - public DefaultProjectsSelector(ProjectBuilder projectBuilder) { + public DefaultProjectsSelector(ProjectBuilder projectBuilder, DefaultDiagnosticCollector diagnosticCollector) { this.projectBuilder = projectBuilder; + this.diagnosticCollector = diagnosticCollector; } @Override @@ -83,6 +87,14 @@ public List selectProjects(List files, MavenExecutionRequest LOGGER.warn("{}{}", problem.getMessage(), ((loc != null && !loc.isEmpty()) ? " @ " + loc : "")); } } + + // Pipe structured problems directly to DiagnosticCollector so that + // source location and severity are preserved in the build report + // (instead of being lost to plain-text logging). The SLF4J hook + // excludes this logger to avoid double-counting. + for (ModelProblem problem : result.getProblems()) { + diagnosticCollector.report(toBuilderProblem(problem)); + } } } @@ -100,4 +112,26 @@ public List selectProjects(List files, MavenExecutionRequest return projects; } + + /** + * Converts a compat {@link ModelProblem} to the Maven 4 {@link BuilderProblem} API, + * preserving source, line, column, severity, and message. + */ + private static BuilderProblem toBuilderProblem(ModelProblem problem) { + BuilderProblem.Severity severity = + switch (problem.getSeverity()) { + case FATAL -> BuilderProblem.Severity.FATAL; + case ERROR -> BuilderProblem.Severity.ERROR; + default -> BuilderProblem.Severity.WARNING; + }; + return BuilderProblem.builder() + .source(problem.getSource()) + .lineNumber(problem.getLineNumber()) + .columnNumber(problem.getColumnNumber()) + .exception(problem.getException()) + .message(problem.getMessage()) + .severity(severity) + .key("model:" + problem.getMessage().hashCode()) + .build(); + } } diff --git a/impl/maven-core/src/test/java/org/apache/maven/graph/DefaultGraphBuilderTest.java b/impl/maven-core/src/test/java/org/apache/maven/graph/DefaultGraphBuilderTest.java index 72dad0b244d5..6982db4b97a0 100644 --- a/impl/maven-core/src/test/java/org/apache/maven/graph/DefaultGraphBuilderTest.java +++ b/impl/maven-core/src/test/java/org/apache/maven/graph/DefaultGraphBuilderTest.java @@ -101,7 +101,8 @@ class DefaultGraphBuilderTest { private final MavenSession session = mock(MavenSession.class); private final MavenExecutionRequest mavenExecutionRequest = mock(MavenExecutionRequest.class); - private final ProjectsSelector projectsSelector = new DefaultProjectsSelector(projectBuilder); + private final ProjectsSelector projectsSelector = new DefaultProjectsSelector( + projectBuilder, new org.apache.maven.internal.build.DefaultDiagnosticCollector()); // Not using mocks for these strategies - a mock would just copy the actual implementation. 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 index 5733daf74d0a..2358d5d3a9d6 100644 --- 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 @@ -35,7 +35,6 @@ import org.apache.maven.execution.MavenExecutionRequest; import org.apache.maven.execution.MavenExecutionResult; import org.apache.maven.execution.MavenSession; -import org.apache.maven.impl.DefaultBuilderProblem; import org.apache.maven.plugin.MojoExecution; import org.apache.maven.plugin.descriptor.MojoDescriptor; import org.apache.maven.plugin.descriptor.PluginDescriptor; @@ -334,8 +333,12 @@ void testDiagnosticSuppressionWildcard() { // ---- Test helpers ---- private static BuilderProblem warning(String key, String message, String source) { - return new DefaultBuilderProblem( - source, -1, -1, null, message, BuilderProblem.Severity.WARNING, key, null, null); + return BuilderProblem.builder() + .source(source) + .message(message) + .severity(BuilderProblem.Severity.WARNING) + .key(key) + .build(); } private MavenProject createProject(String groupId, String artifactId, String version) { 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 index e200cd06b6f8..10f2dc1ae78b 100644 --- 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 @@ -35,7 +35,6 @@ import org.apache.maven.execution.MavenExecutionRequest; import org.apache.maven.execution.MavenExecutionResult; import org.apache.maven.execution.MavenSession; -import org.apache.maven.impl.DefaultBuilderProblem; import org.apache.maven.plugin.MojoExecution; import org.apache.maven.plugin.descriptor.MojoDescriptor; import org.apache.maven.plugin.descriptor.PluginDescriptor; @@ -72,21 +71,41 @@ void setUp() { } private static BuilderProblem warning(String key, String message, String source) { - return new DefaultBuilderProblem( - source, -1, -1, null, message, BuilderProblem.Severity.WARNING, key, null, null); + return BuilderProblem.builder() + .source(source) + .message(message) + .severity(BuilderProblem.Severity.WARNING) + .key(key) + .build(); } private static BuilderProblem warning(String key, String message, String source, String suggestion, String docUrl) { - return new DefaultBuilderProblem( - source, -1, -1, null, message, BuilderProblem.Severity.WARNING, key, suggestion, docUrl); + return BuilderProblem.builder() + .source(source) + .message(message) + .severity(BuilderProblem.Severity.WARNING) + .key(key) + .suggestion(suggestion) + .documentationUrl(docUrl) + .build(); } private static BuilderProblem info(String key, String message, String source) { - return new DefaultBuilderProblem(source, -1, -1, null, message, BuilderProblem.Severity.INFO, key, null, null); + return BuilderProblem.builder() + .source(source) + .message(message) + .severity(BuilderProblem.Severity.INFO) + .key(key) + .build(); } private static BuilderProblem error(String key, String message, String source) { - return new DefaultBuilderProblem(source, -1, -1, null, message, BuilderProblem.Severity.ERROR, key, null, null); + return BuilderProblem.builder() + .source(source) + .message(message) + .severity(BuilderProblem.Severity.ERROR) + .key(key) + .build(); } /** 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 index 32b0424e3b34..95b6f9caae2d 100644 --- 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 @@ -30,7 +30,6 @@ import org.apache.maven.api.build.report.ModuleReport; import org.apache.maven.api.build.report.MojoReport; import org.apache.maven.api.services.BuilderProblem; -import org.apache.maven.impl.DefaultBuilderProblem; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -404,27 +403,23 @@ void testLogEventWithJulMetadata() { @Test void testProblemsSerialization() { - BuilderProblem p1 = new DefaultBuilderProblem( - "maven-compiler-plugin:3.15.0:compile", - 42, - 1, - null, - "source/target value 8 is deprecated", - BuilderProblem.Severity.WARNING, - "deprecated-source-target", - "Update to 11 or higher", - "https://example.com/docs/compiler"); - - BuilderProblem p2 = new DefaultBuilderProblem( - "maven-compiler-plugin", - -1, - -1, - null, - "3 errors found", - BuilderProblem.Severity.ERROR, - "compilation-failure", - null, - null); + BuilderProblem p1 = BuilderProblem.builder() + .source("maven-compiler-plugin:3.15.0:compile") + .lineNumber(42) + .columnNumber(1) + .message("source/target value 8 is deprecated") + .severity(BuilderProblem.Severity.WARNING) + .key("deprecated-source-target") + .suggestion("Update to 11 or higher") + .documentationUrl("https://example.com/docs/compiler") + .build(); + + BuilderProblem p2 = BuilderProblem.builder() + .source("maven-compiler-plugin") + .message("3 errors found") + .severity(BuilderProblem.Severity.ERROR) + .key("compilation-failure") + .build(); BuildReport report = new DefaultBuildReport( BuildStatus.FAILURE, 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 index 3276c3c533cc..90dfeefe1457 100644 --- 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 @@ -27,7 +27,6 @@ import java.util.concurrent.Future; import org.apache.maven.api.services.BuilderProblem; -import org.apache.maven.impl.DefaultBuilderProblem; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -45,16 +44,30 @@ void setUp() { } private static BuilderProblem warning(String key, String message, String source) { - return new DefaultBuilderProblem( - source, -1, -1, null, message, BuilderProblem.Severity.WARNING, key, null, null); + return BuilderProblem.builder() + .source(source) + .message(message) + .severity(BuilderProblem.Severity.WARNING) + .key(key) + .build(); } private static BuilderProblem error(String key, String message, String source) { - return new DefaultBuilderProblem(source, -1, -1, null, message, BuilderProblem.Severity.ERROR, key, null, null); + return BuilderProblem.builder() + .source(source) + .message(message) + .severity(BuilderProblem.Severity.ERROR) + .key(key) + .build(); } private static BuilderProblem info(String key, String message, String source) { - return new DefaultBuilderProblem(source, -1, -1, null, message, BuilderProblem.Severity.INFO, key, null, null); + return BuilderProblem.builder() + .source(source) + .message(message) + .severity(BuilderProblem.Severity.INFO) + .key(key) + .build(); } @Test @@ -175,16 +188,16 @@ void testSummaryListIsUnmodifiable() { @Test void testFullProblemFields() { - BuilderProblem p = new DefaultBuilderProblem( - "maven-compiler-plugin:3.15.0:compile", - 42, - 15, - null, - "unchecked cast from Object to List", - BuilderProblem.Severity.WARNING, - "unchecked-cast", - "Add @SuppressWarnings(\"unchecked\") or use a type-safe alternative", - "https://docs.oracle.com/javase/tutorial/java/generics/rawTypes.html"); + BuilderProblem p = BuilderProblem.builder() + .source("maven-compiler-plugin:3.15.0:compile") + .lineNumber(42) + .columnNumber(15) + .message("unchecked cast from Object to List") + .severity(BuilderProblem.Severity.WARNING) + .key("unchecked-cast") + .suggestion("Add @SuppressWarnings(\"unchecked\") or use a type-safe alternative") + .documentationUrl("https://docs.oracle.com/javase/tutorial/java/generics/rawTypes.html") + .build(); collector.report(p); diff --git a/its/core-it-suite/pom.xml b/its/core-it-suite/pom.xml index 233f88d3488e..50e468d077e1 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..dbb19974cbbe --- /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("\"problems\""), "Should contain problems array"); + assertTrue(json.contains("\"failures\""), "Should contain failures array"); + assertTrue(json.contains("\"build-report-test\""), "Should contain artifactId"); + } + + /** + * Verify that a multi-module build produces a JSON report with all modules listed. + */ + @Test + void testBuildReportMultiModule() throws Exception { + Path basedir = extractResources("gh-12571-multi-module"); + + Verifier verifier = newVerifier(basedir); + verifier.setLogFileName("multi-module.txt"); + verifier.addCliArgument("--console=verbose"); + verifier.addCliArgument("validate"); + verifier.execute(); + verifier.verifyErrorFreeLog(); + + Path reportFile = basedir.resolve("target/build-reports/build-report-latest.json"); + verifier.verifyFilePresent(reportFile); + + String json = Files.readString(reportFile); + assertTrue(json.contains("\"module-a\""), "Should contain module-a"); + assertTrue(json.contains("\"module-b\""), "Should contain module-b"); + assertTrue(json.contains("\"multi-module-parent\""), "Should contain parent"); + assertTrue(json.contains("\"multiModule\""), "Should contain multiModule flag"); + } + + // ------------------------------------------------------------------------- + // Console modes + // ------------------------------------------------------------------------- + + /** + * Verify that {@code --console=plain} produces compact output without + * the full mojo-level detail that verbose mode shows. + */ + @Test + void testConsolePlainMode() throws Exception { + Path basedir = extractResources("gh-12571-multi-module"); + + Verifier verifier = newVerifier(basedir); + verifier.setLogFileName("plain.txt"); + verifier.addCliArgument("--console=plain"); + verifier.addCliArgument("validate"); + verifier.execute(); + verifier.verifyErrorFreeLog(); + + // Plain mode should still show BUILD SUCCESS and Total time + verifier.verifyTextInLog("BUILD SUCCESS"); + verifier.verifyTextInLog("Total time:"); + + // Plain mode should NOT show the verbose "--- plugin:goal" lines + List 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 + +