gnodet-bot commented on code in PR #13180: URL: https://github.com/apache/maven/pull/13180#discussion_r4060554041
########## impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichBuildEventListener.java: ########## @@ -0,0 +1,785 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.event; + +import java.io.PrintWriter; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.LogLevel; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.logging.BuildEventListener; +import org.apache.maven.project.MavenProject; +import org.eclipse.aether.transfer.TransferEvent; +import org.jline.terminal.Terminal; +import org.jline.utils.Display; + +/** + * A rich terminal build event listener using JLine's {@link Display} in + * non-fullscreen mode — the same approach as mvnd. + * <p> + * The status area is rendered at the current cursor position using + * {@link Display#updateAnsi}. When log output arrives, the display is + * cleared (updated with empty lines), the log line is printed normally, + * and then the status is redrawn below it. JLine handles all the cursor + * math (moving up, erasing changed lines, etc.) and only repaints what + * actually changed. + * <p> + * At the end of the build, the display is cleared and nothing remains + * on screen — the summary then prints as normal scrolling text. + * <p> + * The status area has a fixed height based on the degree of concurrency, + * so the separator and summary line stay anchored at the bottom. Active + * projects are packed to the top of the slot area; empty lines fill the + * gap between the last active project and the separator. + * <p> + * Falls back to simple log passthrough on dumb terminals. + * + * @since 4.1.0 + * @see PlainExecutionEventLogger + * @see ExecutionEventLogger + */ +public class RichBuildEventListener implements BuildEventListener { + + // ---- ANSI colors ---- + + private static final String ESC = "\033["; + private static final String CYAN = ESC + "36m"; + private static final String YELLOW = ESC + "33m"; + private static final String BLUE = ESC + "34m"; + private static final String GREEN = ESC + "32m"; + private static final String RED = ESC + "31m"; + private static final String BOLD = ESC + "1m"; + private static final String DIM = ESC + "2m"; + private static final String RESET = ESC + "0m"; + + // ---- Terminal & output ---- + + private final Terminal terminal; + private final PrintWriter writer; + private final boolean supported; + + // ---- JLine Display ---- + + /** JLine display in non-fullscreen mode — handles cursor math. */ + private volatile Display display; + /** Whether the display is currently active. */ + private volatile boolean displayActive; + /** Fixed number of lines in the status area (set once in initReactor). */ + private volatile int statusHeight; + + // ---- Reactor state ---- + + private volatile int totalProjects; + private volatile int completedProjects; + private volatile Instant buildStartTime; + /** One-line header shown at the top of the status area. */ + private volatile String headerLine; + + // ---- Project display ---- + + private final Map<String, ProjectState> activeProjects = new ConcurrentHashMap<>(); + private final List<String> projectOrder = new ArrayList<>(); + private final Map<String, String> projectNames = new ConcurrentHashMap<>(); + + // ---- Active downloads ---- + + private final Map<String, TransferInfo> activeTransfers = new ConcurrentHashMap<>(); + + // ---- Synchronization ---- + + /** Guards all terminal output and slot mutations. */ + private final Object outputLock = new Object(); + + // ---- Periodic refresh ---- + + /** Scheduler for 1-second display refresh so elapsed timers stay live. */ + private volatile ScheduledExecutorService refreshScheduler; + /** Handle for the periodic refresh task. */ + private volatile ScheduledFuture<?> refreshFuture; + + // ---- Warning tracking ---- + + /** Number of WARN-level messages seen during the build. */ + private final AtomicInteger warningCount = new AtomicInteger(); + + /** Number of ERROR-level messages seen during the build. */ + private final AtomicInteger errorCount = new AtomicInteger(); + + // ---- Constructor ---- + + /** + * Creates a new RichBuildEventListener. + * + * @param terminal the JLine terminal for output + * @param output fallback output consumer (unused — kept for API compat) + */ + public RichBuildEventListener(Terminal terminal, java.util.function.Consumer<String> output) { + this.terminal = terminal; + this.writer = terminal.writer(); + // Support ANSI if terminal type is not "dumb" and has reasonable size + String type = terminal.getType(); + this.supported = type != null && !Terminal.TYPE_DUMB.equals(type) && terminal.getWidth() > 0; + } + + // ---- Reactor lifecycle ---- + + /** + * Initialize reactor state from the session. Called by {@link RichExecutionEventLogger} + * during {@code sessionStarted}. + */ + public void initReactor(MavenSession session) { + List<MavenProject> allProjects = session.getAllProjects(); + List<MavenProject> projects = session.getProjects(); + + this.totalProjects = allProjects.size(); + this.completedProjects = allProjects.size() - projects.size(); + this.buildStartTime = MonotonicClock.now(); + + for (MavenProject project : allProjects) { + projectOrder.add(project.getArtifactId()); + projectNames.put(project.getArtifactId(), project.getName()); + } + + // Build header line + this.headerLine = buildHeaderLine(session); + + // Slot count = degree of concurrency (capped for sanity) + int concurrency = 1; + try { + concurrency = Math.max(1, session.getRequest().getDegreeOfConcurrency()); + } catch (Exception e) { + // fallback to 1 + } + int slotCount = Math.min(concurrency, 8); + // Fixed height: 1 header + N project slots + 1 separator + 1 summary + this.statusHeight = slotCount + 3; + + if (supported) { + setupDisplay(); + } + } + + private String buildHeaderLine(MavenSession session) { + StringBuilder h = new StringBuilder(); + h.append(' ').append(BOLD); + + // Maven version + String mavenVersion = null; + if (session.getSystemProperties() != null) { + mavenVersion = session.getSystemProperties().getProperty("maven.version"); + } + if (mavenVersion != null) { + h.append("Maven ").append(mavenVersion); + } else { + h.append("Maven"); + } + h.append(RESET); + + // Project name + MavenProject top = session.getTopLevelProject(); + if (top != null) { + h.append(DIM).append(" ─ ").append(RESET); + h.append("building "); + h.append(CYAN).append(top.getName()).append(RESET); + if (top.getVersion() != null) { + h.append(' ').append(DIM).append(top.getVersion()).append(RESET); + } + } + + // Goals + List<String> goals = session.getGoals(); + if (goals != null && !goals.isEmpty()) { + h.append(DIM).append(" ─ ").append(RESET); + h.append(YELLOW).append(String.join(" ", goals)).append(RESET); + } + + return h.toString(); + } + + /** + * Set up the JLine Display in non-fullscreen mode. + */ + private void setupDisplay() { + synchronized (outputLock) { + display = new Display(terminal, false); + display.resize(statusHeight, terminal.getWidth()); + displayActive = true; + display.updateAnsi(buildStatusLines(), 0); + } + + // Start a 1-second periodic refresh so that elapsed-time counters + // stay live even when no build events are arriving (e.g. during + // a slow mojo execution with no log output). + ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1, r -> { + Thread t = new Thread(r, "maven-rich-display-refresh"); + t.setDaemon(true); + return t; + }); + executor.setRemoveOnCancelPolicy(true); + refreshScheduler = executor; + refreshFuture = refreshScheduler.scheduleAtFixedRate(this::redraw, 1, 1, TimeUnit.SECONDS); + } + + /** + * Tear down the status display. Called by {@link RichExecutionEventLogger} + * during {@code sessionEnded} before printing the summary. + * <p> + * The flush at the end is critical: {@link Display} writes through + * {@link Terminal#writer()} (a {@code PrintWriter} that does not auto-flush), + * while subsequent log output from SLF4J goes through {@code System.out} + * (which <em>does</em> auto-flush on {@code println}). Without the flush, + * the clear sequences sit in the writer's buffer while the summary text + * reaches the terminal first via {@code System.out} — then the belated + * clear erases the summary the user was supposed to see. + */ + public void tearDown() { + // Stop the periodic refresh first (outside outputLock to avoid deadlock) + if (refreshFuture != null) { + refreshFuture.cancel(false); + refreshFuture = null; + } + if (refreshScheduler != null) { + refreshScheduler.shutdownNow(); + refreshScheduler = null; + } + + synchronized (outputLock) { + if (!displayActive) { + return; + } + displayActive = false; + + // Clear the display area: update with empty lines, cursor at top + display.updateAnsi(Collections.nCopies(statusHeight, ""), 0); + // Erase from cursor to end of screen — removes any leftover artifacts + writer.print("\033[J"); + // Flush immediately so the clear reaches the terminal BEFORE + // any subsequent log output that goes through System.out + writer.flush(); + } + } + + // ---- BuildEventListener interface ---- + + @Override + public void sessionStarted(ExecutionEvent event) { + // Reactor init is handled via initReactor() called from RichExecutionEventLogger + } + + @Override + public void projectStarted(String projectId) { + activeProjects.put(projectId, new ProjectState(projectId, MonotonicClock.now())); + redraw(); + } + + @Override + public void projectFinished(String projectId) { + activeProjects.remove(projectId); + completedProjects++; Review Comment: ✖️ **Race condition: `completedProjects++` outside `outputLock`** `completedProjects` is a `volatile int` incremented here **without** holding `outputLock`, but the periodic refresh thread calls `redraw()` → `buildStatusLines()` **inside** `outputLock`, reading `completedProjects` for progress-bar calculation (lines 543, 571, 589, 635, 646). `volatile` guarantees visibility but not mutual exclusion — the refresh thread and the event thread can interleave, producing stale or mid-update reads during multi-module concurrent builds. Move the increment inside the lock and absorb `redraw()` into the same critical section: ```suggestion public void projectFinished(String projectId) { synchronized (outputLock) { activeProjects.remove(projectId); completedProjects++; if (displayActive) { display.updateAnsi(buildStatusLines(), 0); } } } ``` ########## impl/maven-cli/src/main/java/org/apache/maven/cling/event/PlainExecutionEventLogger.java: ########## @@ -0,0 +1,323 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.event; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.services.MessageBuilder; +import org.apache.maven.api.services.MessageBuilderFactory; +import org.apache.maven.cling.utils.CLIReportingUtils; +import org.apache.maven.execution.AbstractExecutionListener; +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.BuildSummary; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.project.MavenProject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.apache.maven.cling.utils.CLIReportingUtils.formatDuration; + +/** + * Compact execution event logger for CI and batch environments. + * <p> + * Produces one line per completed module instead of the verbose per-mojo + * output of {@link ExecutionEventLogger}. Designed for CI log viewers + * and LLM-based tools where signal density matters more than verbosity. + * <p> + * Example output: + * <pre> + * [INFO] maven-api-core ................................ SUCCESS [ 2.1s] + * [INFO] maven-core ..................................... FAILURE [ 5.3s] + * [INFO] + * [INFO] BUILD FAILURE + * [INFO] Total time: 32.1s + * </pre> + * + * Selected via {@code --console=plain} or automatically in CI environments. + * + * @since 4.1.0 + * @see ExecutionEventLogger + */ +public class PlainExecutionEventLogger extends AbstractExecutionListener { + + private static final int MAX_LOG_PREFIX_SIZE = 8; // "[ERROR] " + private static final int PROJECT_STATUS_SUFFIX_SIZE = 20; // "SUCCESS [ 0.000 s]" + private static final int MIN_TERMINAL_WIDTH = 60; + private static final int DEFAULT_TERMINAL_WIDTH = 80; + private static final int MAX_TERMINAL_WIDTH = 130; + private static final int MAX_PADDED_BUILD_TIME_DURATION_LENGTH = 9; + + private final MessageBuilderFactory messageBuilderFactory; + private final Logger logger; + private int terminalWidth; + private int lineLength; + private int maxProjectNameLength; + private int totalProjects; + private volatile int currentVisitedProjectCount; + + public PlainExecutionEventLogger(MessageBuilderFactory messageBuilderFactory) { + this(messageBuilderFactory, LoggerFactory.getLogger(PlainExecutionEventLogger.class)); + } + + public PlainExecutionEventLogger(MessageBuilderFactory messageBuilderFactory, Logger logger) { + this(messageBuilderFactory, logger, -1); + } + + public PlainExecutionEventLogger(MessageBuilderFactory messageBuilderFactory, Logger logger, int terminalWidth) { + this.logger = Objects.requireNonNull(logger, "logger cannot be null"); + this.messageBuilderFactory = messageBuilderFactory; + this.terminalWidth = terminalWidth; + } + + private void init() { + if (maxProjectNameLength == 0) { + if (terminalWidth < 0) { + terminalWidth = messageBuilderFactory.getTerminalWidth(); + } + terminalWidth = Math.min( + MAX_TERMINAL_WIDTH, + Math.max(terminalWidth <= 0 ? DEFAULT_TERMINAL_WIDTH : terminalWidth, MIN_TERMINAL_WIDTH)); + lineLength = terminalWidth - MAX_LOG_PREFIX_SIZE; + maxProjectNameLength = lineLength - PROJECT_STATUS_SUFFIX_SIZE; + } + } + + private MessageBuilder builder() { + return messageBuilderFactory.builder(); + } + + private static String chars(char c, int count) { + return String.valueOf(c).repeat(Math.max(0, count)); + } + + private void infoMain(String msg) { + logger.info(builder().strong(msg).toString()); + } + + // ---- Session lifecycle ---- + + @Override + public void projectDiscoveryStarted(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logger.info("Scanning for projects..."); + } + } + + @Override + public void sessionStarted(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + List<MavenProject> projects = event.getSession().getProjects(); + List<MavenProject> allProjects = event.getSession().getAllProjects(); + + currentVisitedProjectCount = allProjects.size() - projects.size(); + totalProjects = allProjects.size(); + } + } + + @Override + public void sessionEnded(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logger.info(""); + logResult(event.getSession()); + logStats(event.getSession()); + } + } + + // ---- Module lifecycle: one line per completed module ---- + + @Override + public void projectStarted(ExecutionEvent event) { + // In plain mode, we only log when a project finishes (succeeded/failed/skipped) + } + + @Override + public void projectSucceeded(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logProjectLine(event, "SUCCESS"); + } + } + + @Override + public void projectFailed(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logProjectLine(event, "FAILURE"); + } + } + + @Override + public void projectSkipped(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logProjectLine(event, "SKIPPED"); + } + } + + // ---- Mojo lifecycle: suppressed in plain mode ---- + + @Override + public void mojoStarted(ExecutionEvent event) { + // Suppressed in plain mode — plugin execution details go to build report + } + + @Override + public void mojoSkipped(ExecutionEvent event) { + if (logger.isWarnEnabled()) { + logger.warn( + "Goal '{}' requires online mode for execution but Maven is currently offline, skipping", + event.getMojoExecution().getGoal()); + } + } + + @Override + public void forkStarted(ExecutionEvent event) { + // Suppressed in plain mode + } + + @Override + public void forkSucceeded(ExecutionEvent event) { + // Suppressed in plain mode + } + + // ---- Formatting helpers ---- + + private void logProjectLine(ExecutionEvent event, String status) { + MavenProject project = event.getProject(); + MavenSession session = event.getSession(); + MavenExecutionResult result = session.getResult(); + BuildSummary buildSummary = result.getBuildSummary(project); + + StringBuilder buffer = new StringBuilder(128); + buffer.append(project.getName()); + buffer.append(' '); + + if (totalProjects > 1) { + int number; + synchronized (this) { + number = ++currentVisitedProjectCount; + } + String progress = "[" + number + "/" + totalProjects + "]"; + buffer.append(progress); + buffer.append(' '); + } + + // Pad with dots to align status + if (buffer.length() <= maxProjectNameLength) { + while (buffer.length() < maxProjectNameLength) { + buffer.append('.'); + } + buffer.append(' '); + } + + // Status with color + MessageBuilder mb = builder(); + mb.a(buffer); + switch (status) { + case "SUCCESS": + mb.success(status); + break; + case "FAILURE": + mb.failure(status); + break; + default: + mb.warning(status); + break; + } + + // Duration + if (buildSummary != null) { + mb.a(" ["); + String duration = formatDuration(buildSummary.getExecTime()); + int padSize = MAX_PADDED_BUILD_TIME_DURATION_LENGTH - duration.length(); + if (padSize > 0) { + mb.a(chars(' ', padSize)); + } + mb.a(duration); + mb.a(']'); + } + + logger.info(mb.toString()); + } + + private void logResult(MavenSession session) { + MessageBuilder buffer = builder(); + if (session.getResult().hasExceptions()) { + buffer.failure("BUILD FAILURE"); + } else { + buffer.success("BUILD SUCCESS"); + } + + int passed = 0; + int failed = 0; + int skipped = 0; + for (MavenProject project : session.getProjects()) { + BuildSummary summary = session.getResult().getBuildSummary(project); + if (summary instanceof BuildSuccess) { + passed++; + } else if (summary instanceof BuildFailure) { + failed++; + } else { + skipped++; + } + } + + logger.info(buffer.toString()); Review Comment: ✖️ **Behavioral regression: BUILD FAILURE logged at INFO level** `logger.info(buffer.toString())` is called for both BUILD SUCCESS and BUILD FAILURE — the failure path uses the same level. CI tools, Maven wrappers, IDE parsers, and log aggregators detect build failure by log level. Downgrading BUILD FAILURE from ERROR to INFO makes them classify failed builds as successful. The test at `testBuildFailureLogsAtInfoLevel()` (diff line ~4893) verifies `inOrder.verify(logger).info("BUILD FAILURE")` — the test itself is asserting the wrong behavior and should be updated too. ```suggestion if (session.getResult().hasExceptions()) { logger.error(buffer.toString()); } else { logger.info(buffer.toString()); } ``` ########## impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java: ########## @@ -343,21 +350,102 @@ protected String determineGlobalChecksumPolicy(MavenContext context) { } protected ExecutionListener determineExecutionListener(MavenContext context) { - ExecutionListener listener = new ExecutionEventLogger(context.invokerRequest.messageBuilderFactory()); + ExecutionListener listener; + String consoleMode = determineConsoleMode(context); + switch (consoleMode) { + case "machine": + BuildEventListener machineBel = determineBuildEventListener(context); + if (machineBel instanceof MachineBuildEventListener machineListener) { + listener = new MachineExecutionEventLogger(machineListener); + } else { + // Fallback if machine listener couldn't be created + listener = new PlainExecutionEventLogger(context.invokerRequest.messageBuilderFactory()); + } + break; + case "rich": + BuildEventListener richBel = determineBuildEventListener(context); + if (richBel instanceof RichBuildEventListener richListener) { + listener = + new RichExecutionEventLogger(context.invokerRequest.messageBuilderFactory(), richListener); + } else { + // Fallback if status bar couldn't be created + listener = new PlainExecutionEventLogger(context.invokerRequest.messageBuilderFactory()); + } + break; + case "plain": + listener = new PlainExecutionEventLogger(context.invokerRequest.messageBuilderFactory()); + break; + default: + listener = new ExecutionEventLogger(context.invokerRequest.messageBuilderFactory()); + break; + } if (context.eventSpyDispatcher != null) { listener = context.eventSpyDispatcher.chainListener(listener); } return new LoggingExecutionListener(listener, determineBuildEventListener(context)); } + @Override + protected BuildEventListener doDetermineBuildEventListener(MavenContext context) { + String consoleMode = determineConsoleMode(context); + if ("machine".equals(consoleMode)) { + return new MachineBuildEventListener(determineWriter(context)); + } + if ("rich".equals(consoleMode) && context.terminal != null) { + return new RichBuildEventListener(context.terminal, determineWriter(context)); + } + return super.doDetermineBuildEventListener(context); + } + + /** + * Resolves the effective console mode from the {@code --console} flag and CI/TTY detection. + * <p> + * Resolution order: + * <ol> + * <li>Explicit {@code --console=plain}, {@code --console=verbose}, {@code --console=rich}, + * or {@code --console=machine} — always honored</li> + * <li>{@code --console=auto} (or unset) — selects mode based on environment: + * <ul> + * <li>CI detected → "plain"</li> + * <li>Interactive TTY → "rich"</li> + * <li>Otherwise → "verbose"</li> + * </ul> + * </li> + * </ol> + */ + String determineConsoleMode(MavenContext context) { + String consoleMode = context.options().console().orElse("auto"); + if ("plain".equalsIgnoreCase(consoleMode) + || "verbose".equalsIgnoreCase(consoleMode) + || "rich".equalsIgnoreCase(consoleMode) + || "machine".equalsIgnoreCase(consoleMode)) { + return consoleMode.toLowerCase(); Review Comment: ⚠️ **Silent fallthrough for unknown `--console=` values** Any unrecognized value (e.g. `--console=json`, `--console=colored`, a typo) passes through the known-value guard and gets silently treated as `"auto"`. The user sees Maven start with whatever mode auto-detection picks — no warning, no error. Configuration bugs in CI pipelines are completely invisible. Add a warning for the unrecognized case: ```suggestion if ("plain".equalsIgnoreCase(consoleMode) || "verbose".equalsIgnoreCase(consoleMode) || "rich".equalsIgnoreCase(consoleMode) || "machine".equalsIgnoreCase(consoleMode)) { return consoleMode.toLowerCase(); } if (!"auto".equalsIgnoreCase(consoleMode)) { org.slf4j.LoggerFactory.getLogger(getClass()) .warn("Unknown --console value '{}'; valid values are: auto, plain, verbose, rich, machine. " + "Falling back to auto-detection.", consoleMode); } ``` ########## impl/maven-cli/src/main/java/org/apache/maven/cling/event/PlainExecutionEventLogger.java: ########## @@ -0,0 +1,323 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.event; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.services.MessageBuilder; +import org.apache.maven.api.services.MessageBuilderFactory; +import org.apache.maven.cling.utils.CLIReportingUtils; +import org.apache.maven.execution.AbstractExecutionListener; +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.BuildSummary; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.project.MavenProject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.apache.maven.cling.utils.CLIReportingUtils.formatDuration; + +/** + * Compact execution event logger for CI and batch environments. + * <p> + * Produces one line per completed module instead of the verbose per-mojo + * output of {@link ExecutionEventLogger}. Designed for CI log viewers + * and LLM-based tools where signal density matters more than verbosity. + * <p> + * Example output: + * <pre> + * [INFO] maven-api-core ................................ SUCCESS [ 2.1s] + * [INFO] maven-core ..................................... FAILURE [ 5.3s] + * [INFO] + * [INFO] BUILD FAILURE + * [INFO] Total time: 32.1s + * </pre> + * + * Selected via {@code --console=plain} or automatically in CI environments. + * + * @since 4.1.0 + * @see ExecutionEventLogger + */ +public class PlainExecutionEventLogger extends AbstractExecutionListener { + + private static final int MAX_LOG_PREFIX_SIZE = 8; // "[ERROR] " + private static final int PROJECT_STATUS_SUFFIX_SIZE = 20; // "SUCCESS [ 0.000 s]" + private static final int MIN_TERMINAL_WIDTH = 60; + private static final int DEFAULT_TERMINAL_WIDTH = 80; + private static final int MAX_TERMINAL_WIDTH = 130; + private static final int MAX_PADDED_BUILD_TIME_DURATION_LENGTH = 9; + + private final MessageBuilderFactory messageBuilderFactory; + private final Logger logger; + private int terminalWidth; + private int lineLength; + private int maxProjectNameLength; + private int totalProjects; + private volatile int currentVisitedProjectCount; Review Comment: nit: `volatile int currentVisitedProjectCount` — `volatile` is misleading here. This field is incremented inside `synchronized(this)` and the lock already establishes the happens-before needed for safe publication. `volatile` implies to readers that this field is safely accessed lock-free, but it isn't — the compound `++currentVisitedProjectCount` is not atomic. Drop `volatile` and let the lock be the single source of truth. Same issue is present in `RichExecutionEventLogger.currentVisitedProjectCount` (identical declaration and usage pattern). ```suggestion private int currentVisitedProjectCount; ``` -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
