"));
+ }
+
+ logger.info("Full report: target/build-reports/build-report-latest.json");
+ }
+}
\ No newline at end of file
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichBuildEventListener.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichBuildEventListener.java
new file mode 100644
index 000000000000..56931fc0f638
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichBuildEventListener.java
@@ -0,0 +1,785 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.cling.event;
+
+import java.io.PrintWriter;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.maven.api.MonotonicClock;
+import org.apache.maven.api.build.report.LogEvent;
+import org.apache.maven.api.build.report.LogLevel;
+import org.apache.maven.execution.ExecutionEvent;
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.logging.BuildEventListener;
+import org.apache.maven.project.MavenProject;
+import org.eclipse.aether.transfer.TransferEvent;
+import org.jline.terminal.Terminal;
+import org.jline.utils.Display;
+
+/**
+ * A rich terminal build event listener using JLine's {@link Display} in
+ * non-fullscreen mode — the same approach as mvnd.
+ *
+ * The status area is rendered at the current cursor position using
+ * {@link Display#updateAnsi}. When log output arrives, the display is
+ * cleared (updated with empty lines), the log line is printed normally,
+ * and then the status is redrawn below it. JLine handles all the cursor
+ * math (moving up, erasing changed lines, etc.) and only repaints what
+ * actually changed.
+ *
+ * At the end of the build, the display is cleared and nothing remains
+ * on screen — the summary then prints as normal scrolling text.
+ *
+ * The status area has a fixed height based on the degree of concurrency,
+ * so the separator and summary line stay anchored at the bottom. Active
+ * projects are packed to the top of the slot area; empty lines fill the
+ * gap between the last active project and the separator.
+ *
+ * Falls back to simple log passthrough on dumb terminals.
+ *
+ * @since 4.1.0
+ * @see PlainExecutionEventLogger
+ * @see ExecutionEventLogger
+ */
+public class RichBuildEventListener implements BuildEventListener {
+
+ // ---- ANSI colors ----
+
+ private static final String ESC = "\033[";
+ private static final String CYAN = ESC + "36m";
+ private static final String YELLOW = ESC + "33m";
+ private static final String BLUE = ESC + "34m";
+ private static final String GREEN = ESC + "32m";
+ private static final String RED = ESC + "31m";
+ private static final String BOLD = ESC + "1m";
+ private static final String DIM = ESC + "2m";
+ private static final String RESET = ESC + "0m";
+
+ // ---- Terminal & output ----
+
+ private final Terminal terminal;
+ private final PrintWriter writer;
+ private final boolean supported;
+
+ // ---- JLine Display ----
+
+ /** JLine display in non-fullscreen mode — handles cursor math. */
+ private volatile Display display;
+ /** Whether the display is currently active. */
+ private volatile boolean displayActive;
+ /** Fixed number of lines in the status area (set once in initReactor). */
+ private volatile int statusHeight;
+
+ // ---- Reactor state ----
+
+ private volatile int totalProjects;
+ private volatile int completedProjects;
+ private volatile Instant buildStartTime;
+ /** One-line header shown at the top of the status area. */
+ private volatile String headerLine;
+
+ // ---- Project display ----
+
+ private final Map activeProjects = new ConcurrentHashMap<>();
+ private final List projectOrder = new ArrayList<>();
+ private final Map projectNames = new ConcurrentHashMap<>();
+
+ // ---- Active downloads ----
+
+ private final Map activeTransfers = new ConcurrentHashMap<>();
+
+ // ---- Synchronization ----
+
+ /** Guards all terminal output and slot mutations. */
+ private final Object outputLock = new Object();
+
+ // ---- Periodic refresh ----
+
+ /** Scheduler for 1-second display refresh so elapsed timers stay live. */
+ private volatile ScheduledExecutorService refreshScheduler;
+ /** Handle for the periodic refresh task. */
+ private volatile ScheduledFuture> refreshFuture;
+
+ // ---- Warning tracking ----
+
+ /** Number of WARN-level messages seen during the build. */
+ private final AtomicInteger warningCount = new AtomicInteger();
+
+ /** Number of ERROR-level messages seen during the build. */
+ private final AtomicInteger errorCount = new AtomicInteger();
+
+ // ---- Constructor ----
+
+ /**
+ * Creates a new RichBuildEventListener.
+ *
+ * @param terminal the JLine terminal for output
+ * @param output fallback output consumer (unused — kept for API compat)
+ */
+ public RichBuildEventListener(Terminal terminal, java.util.function.Consumer output) {
+ this.terminal = terminal;
+ this.writer = terminal.writer();
+ // Support ANSI if terminal type is not "dumb" and has reasonable size
+ String type = terminal.getType();
+ this.supported = type != null && !Terminal.TYPE_DUMB.equals(type) && terminal.getWidth() > 0;
+ }
+
+ // ---- Reactor lifecycle ----
+
+ /**
+ * Initialize reactor state from the session. Called by {@link RichExecutionEventLogger}
+ * during {@code sessionStarted}.
+ */
+ public void initReactor(MavenSession session) {
+ List allProjects = session.getAllProjects();
+ List projects = session.getProjects();
+
+ this.totalProjects = allProjects.size();
+ this.completedProjects = allProjects.size() - projects.size();
+ this.buildStartTime = MonotonicClock.now();
+
+ for (MavenProject project : allProjects) {
+ projectOrder.add(project.getArtifactId());
+ projectNames.put(project.getArtifactId(), project.getName());
+ }
+
+ // Build header line
+ this.headerLine = buildHeaderLine(session);
+
+ // Slot count = degree of concurrency (capped for sanity)
+ int concurrency = 1;
+ try {
+ concurrency = Math.max(1, session.getRequest().getDegreeOfConcurrency());
+ } catch (Exception e) {
+ // fallback to 1
+ }
+ int slotCount = Math.min(concurrency, 8);
+ // Fixed height: 1 header + N project slots + 1 separator + 1 summary
+ this.statusHeight = slotCount + 3;
+
+ if (supported) {
+ setupDisplay();
+ }
+ }
+
+ private String buildHeaderLine(MavenSession session) {
+ StringBuilder h = new StringBuilder();
+ h.append(' ').append(BOLD);
+
+ // Maven version
+ String mavenVersion = null;
+ if (session.getSystemProperties() != null) {
+ mavenVersion = session.getSystemProperties().getProperty("maven.version");
+ }
+ if (mavenVersion != null) {
+ h.append("Maven ").append(mavenVersion);
+ } else {
+ h.append("Maven");
+ }
+ h.append(RESET);
+
+ // Project name
+ MavenProject top = session.getTopLevelProject();
+ if (top != null) {
+ h.append(DIM).append(" ─ ").append(RESET);
+ h.append("building ");
+ h.append(CYAN).append(top.getName()).append(RESET);
+ if (top.getVersion() != null) {
+ h.append(' ').append(DIM).append(top.getVersion()).append(RESET);
+ }
+ }
+
+ // Goals
+ List goals = session.getGoals();
+ if (goals != null && !goals.isEmpty()) {
+ h.append(DIM).append(" ─ ").append(RESET);
+ h.append(YELLOW).append(String.join(" ", goals)).append(RESET);
+ }
+
+ return h.toString();
+ }
+
+ /**
+ * Set up the JLine Display in non-fullscreen mode.
+ */
+ private void setupDisplay() {
+ synchronized (outputLock) {
+ display = new Display(terminal, false);
+ display.resize(statusHeight, terminal.getWidth());
+ displayActive = true;
+ display.updateAnsi(buildStatusLines(), 0);
+ }
+
+ // Start a 1-second periodic refresh so that elapsed-time counters
+ // stay live even when no build events are arriving (e.g. during
+ // a slow mojo execution with no log output).
+ ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1, r -> {
+ Thread t = new Thread(r, "maven-rich-display-refresh");
+ t.setDaemon(true);
+ return t;
+ });
+ executor.setRemoveOnCancelPolicy(true);
+ refreshScheduler = executor;
+ refreshFuture = refreshScheduler.scheduleAtFixedRate(this::redraw, 1, 1, TimeUnit.SECONDS);
+ }
+
+ /**
+ * Tear down the status display. Called by {@link RichExecutionEventLogger}
+ * during {@code sessionEnded} before printing the summary.
+ *
+ * The flush at the end is critical: {@link Display} writes through
+ * {@link Terminal#writer()} (a {@code PrintWriter} that does not auto-flush),
+ * while subsequent log output from SLF4J goes through {@code System.out}
+ * (which does auto-flush on {@code println}). Without the flush,
+ * the clear sequences sit in the writer's buffer while the summary text
+ * reaches the terminal first via {@code System.out} — then the belated
+ * clear erases the summary the user was supposed to see.
+ */
+ public void tearDown() {
+ // Stop the periodic refresh first (outside outputLock to avoid deadlock)
+ if (refreshFuture != null) {
+ refreshFuture.cancel(false);
+ refreshFuture = null;
+ }
+ if (refreshScheduler != null) {
+ refreshScheduler.shutdownNow();
+ refreshScheduler = null;
+ }
+
+ synchronized (outputLock) {
+ if (!displayActive) {
+ return;
+ }
+ displayActive = false;
+
+ // Clear the display area: update with empty lines, cursor at top
+ display.updateAnsi(Collections.nCopies(statusHeight, ""), 0);
+ // Erase from cursor to end of screen — removes any leftover artifacts
+ writer.print("\033[J");
+ // Flush immediately so the clear reaches the terminal BEFORE
+ // any subsequent log output that goes through System.out
+ writer.flush();
+ }
+ }
+
+ // ---- BuildEventListener interface ----
+
+ @Override
+ public void sessionStarted(ExecutionEvent event) {
+ // Reactor init is handled via initReactor() called from RichExecutionEventLogger
+ }
+
+ @Override
+ public void projectStarted(String projectId) {
+ activeProjects.put(projectId, new ProjectState(projectId, MonotonicClock.now()));
+ redraw();
+ }
+
+ @Override
+ public void projectFinished(String projectId) {
+ activeProjects.remove(projectId);
+ completedProjects++;
+ redraw();
+ }
+
+ @Override
+ public void projectLogMessage(String projectId, LogEvent event) {
+ // In rich mode, suppress INFO/DEBUG/TRACE/WARN — the status bar provides
+ // live progress and warnings are summarized at the end of the build.
+ // Only ERROR passes through to the terminal immediately.
+ if (event.level() == LogLevel.WARN) {
+ warningCount.incrementAndGet();
+ return;
+ }
+ if (event.level() == LogLevel.INFO || event.level() == LogLevel.DEBUG || event.level() == LogLevel.TRACE) {
+ return;
+ }
+ if (event.level() == LogLevel.ERROR) {
+ errorCount.incrementAndGet();
+ }
+ String output = event.formattedMessage();
+ if (output == null) {
+ output = event.message();
+ }
+ printAboveStatus(output);
+ }
+
+ /**
+ * Returns the number of WARN-level log messages seen during the build.
+ */
+ public int getWarningCount() {
+ return warningCount.get();
+ }
+
+ /**
+ * Returns the number of ERROR-level log messages seen during the build.
+ */
+ public int getErrorCount() {
+ return errorCount.get();
+ }
+
+ @Override
+ public void log(String msg) {
+ printAboveStatus(msg);
+ }
+
+ @Override
+ public void mojoStarted(ExecutionEvent event) {
+ String projectId = event.getProject().getArtifactId();
+ ProjectState state = activeProjects.get(projectId);
+ if (state != null) {
+ state.currentMojo = event.getMojoExecution().getArtifactId() + ":"
+ + event.getMojoExecution().getGoal();
+ }
+ synchronized (outputLock) {
+ if (displayActive) {
+ display.updateAnsi(buildStatusLines(), 0);
+ }
+ }
+ }
+
+ @Override
+ public void executionFailure(String projectId, boolean halted, String exception) {
+ ProjectState state = activeProjects.get(projectId);
+ if (state != null) {
+ state.failed = true;
+ }
+ synchronized (outputLock) {
+ if (displayActive) {
+ display.updateAnsi(buildStatusLines(), 0);
+ }
+ }
+ }
+
+ @Override
+ public void transfer(String projectId, TransferEvent event) {
+ String resource = event.getResource().getResourceName();
+
+ switch (event.getType()) {
+ case INITIATED:
+ case STARTED:
+ String artifactName = extractArtifactName(resource);
+ activeTransfers.put(
+ resource,
+ new TransferInfo(artifactName, 0, event.getResource().getContentLength()));
+ redraw();
+ break;
+ case PROGRESSED:
+ TransferInfo info = activeTransfers.get(resource);
+ if (info != null) {
+ info.transferred = event.getTransferredBytes();
+ // Only update every ~50KB to avoid too-frequent redraws
+ if (info.transferred - info.lastUpdateBytes > 51200) {
+ info.lastUpdateBytes = info.transferred;
+ redraw();
+ }
+ }
+ break;
+ case SUCCEEDED:
+ case FAILED:
+ activeTransfers.remove(resource);
+ redraw();
+ break;
+ default:
+ break;
+ }
+ }
+
+ @Override
+ public void finish(int exitCode) throws Exception {
+ tearDown();
+ }
+
+ @Override
+ public void fail(Throwable t) throws Exception {
+ tearDown();
+ }
+
+ // ---- Display helpers ----
+
+ /**
+ * Print a message above the status area: clear the display, print
+ * the message as normal scrolling text, then redraw the status below.
+ */
+ private void printAboveStatus(String msg) {
+ synchronized (outputLock) {
+ if (displayActive) {
+ // Clear status area so the message prints where it was
+ display.updateAnsi(Collections.nCopies(statusHeight, ""), 0);
+ display.reset();
+ // Print the log message (scrolls normally)
+ writer.println(msg);
+ writer.flush();
+ // Redraw status below the new output
+ display.updateAnsi(buildStatusLines(), 0);
+ } else {
+ writer.println(msg);
+ writer.flush();
+ }
+ }
+ }
+
+ private void redraw() {
+ synchronized (outputLock) {
+ if (displayActive) {
+ display.updateAnsi(buildStatusLines(), 0);
+ }
+ }
+ }
+
+ // ---- Status line building ----
+
+ /**
+ * Build exactly {@link #statusHeight} status lines.
+ *
+ * For small reactors (modules fit as individual indicators):
+ * Layout: header + active slots + padding + separator + summary (indicators + counter).
+ *
+ * For large reactors (progress bar mode):
+ * Layout: header + active slots + padding + progress bar (full-width, acts as separator + counter).
+ * The progress bar replaces the separator — no redundant horizontal rule.
+ */
+ private List buildStatusLines() {
+ int termWidth = Math.max(terminal.getWidth(), 40);
+
+ // Collect active projects sorted by start time for visual stability
+ List active = new ArrayList<>(activeProjects.values());
+ active.sort((a, b) -> a.startTime.compareTo(b.startTime));
+
+ // Determine if we're in progress bar mode (reactor too large for per-module indicators)
+ int maxIndicators = Math.min(projectOrder.size(), (termWidth - 40) / 3);
+ boolean useProgressBar = totalProjects > 1 && maxIndicators > 0 && totalProjects > maxIndicators;
+
+ // Number of project slot lines: subtract header (1) + bottom lines (2 for separator+summary, 1 for bar)
+ int slotCount = statusHeight - (useProgressBar ? 2 : 3);
+
+ List lines = new ArrayList<>(statusHeight);
+
+ // Header line
+ lines.add(headerLine != null ? headerLine : "");
+
+ // Active projects packed to the top (up to slotCount)
+ int projectsShown = 0;
+ for (ProjectState state : active) {
+ if (projectsShown >= slotCount) {
+ break;
+ }
+ lines.add(formatProjectSlot(state));
+ projectsShown++;
+ }
+
+ // Empty padding lines at the bottom of the slot area
+ for (int i = projectsShown; i < slotCount; i++) {
+ lines.add("");
+ }
+
+ if (useProgressBar) {
+ // Progress bar replaces separator + summary as a single full-width line
+ lines.add(buildProgressBarLine(termWidth));
+ } else {
+ // Separator line (always at the same position)
+ lines.add(DIM + "─".repeat(Math.min(termWidth, 120)) + RESET);
+ // Summary line (per-module indicators + counter + elapsed + downloads)
+ lines.add(buildSummaryLine(termWidth));
+ }
+
+ return lines;
+ }
+
+ private String formatProjectSlot(ProjectState state) {
+ StringBuilder b = new StringBuilder();
+ if (state.failed) {
+ b.append(RED).append(" ✗ ").append(RESET);
+ } else {
+ b.append(CYAN).append(" ● ").append(RESET);
+ }
+ b.append(BOLD);
+ b.append(projectNames.getOrDefault(state.projectId, state.projectId));
+ b.append(RESET);
+ if (state.currentMojo != null) {
+ b.append(" ").append(YELLOW).append(state.currentMojo).append(RESET);
+ }
+ Duration elapsed = Duration.between(state.startTime, MonotonicClock.now());
+ b.append(" ").append(DIM).append(formatCompactDuration(elapsed)).append(RESET);
+ return b.toString();
+ }
+
+ /**
+ * Build a full-width progress bar line for large reactors.
+ * Replaces both the separator and summary — one line with the proportional
+ * bar, counter, elapsed time, and download status.
+ */
+ private String buildProgressBarLine(int termWidth) {
+ // Build the suffix first so we know how much width the bar can use
+ StringBuilder suffix = new StringBuilder();
+ suffix.append(" [");
+ suffix.append(completedProjects).append('/').append(totalProjects);
+ suffix.append(']');
+ if (buildStartTime != null) {
+ Duration elapsed = Duration.between(buildStartTime, MonotonicClock.now());
+ suffix.append(" ").append(formatCompactDuration(elapsed));
+ }
+ if (!activeTransfers.isEmpty()) {
+ suffix.append(" ↓ ");
+ if (activeTransfers.size() == 1) {
+ TransferInfo ti = activeTransfers.values().iterator().next();
+ suffix.append(ti.artifactName);
+ if (ti.totalBytes > 0) {
+ suffix.append(' ')
+ .append(formatBytes(ti.transferred))
+ .append('/')
+ .append(formatBytes(ti.totalBytes));
+ }
+ } else {
+ suffix.append(activeTransfers.size()).append(" artifacts");
+ }
+ }
+ int suffixLen = suffix.length();
+
+ // Bar fills from column 0 to (lineWidth - suffixLen)
+ int lineWidth = Math.min(termWidth, 120);
+ int barWidth = Math.max(10, lineWidth - suffixLen);
+
+ int activeCount = activeProjects.size();
+ int doneChars = (int) ((long) completedProjects * barWidth / totalProjects);
+ int activeChars = (int) ((long) activeCount * barWidth / totalProjects);
+ if (activeCount > 0 && activeChars < 1) {
+ activeChars = 1;
+ }
+ if (doneChars + activeChars > barWidth) {
+ activeChars = barWidth - doneChars;
+ }
+ int remainChars = barWidth - doneChars - activeChars;
+
+ StringBuilder s = new StringBuilder();
+ s.append(GREEN).append("━".repeat(doneChars)).append(RESET);
+ s.append(YELLOW).append("━".repeat(activeChars)).append(RESET);
+ s.append(DIM).append("─".repeat(remainChars)).append(RESET);
+
+ // Append suffix with styling
+ s.append(" [");
+ s.append(BOLD)
+ .append(completedProjects)
+ .append('/')
+ .append(totalProjects)
+ .append(RESET);
+ s.append(']');
+ if (buildStartTime != null) {
+ Duration elapsed = Duration.between(buildStartTime, MonotonicClock.now());
+ s.append(" ").append(DIM).append(formatCompactDuration(elapsed)).append(RESET);
+ }
+ if (!activeTransfers.isEmpty()) {
+ s.append(" ").append(BLUE).append("↓ ").append(RESET);
+ if (activeTransfers.size() == 1) {
+ TransferInfo ti = activeTransfers.values().iterator().next();
+ s.append(ti.artifactName);
+ if (ti.totalBytes > 0) {
+ s.append(' ')
+ .append(DIM)
+ .append(formatBytes(ti.transferred))
+ .append('/')
+ .append(formatBytes(ti.totalBytes))
+ .append(RESET);
+ }
+ } else {
+ s.append(activeTransfers.size()).append(" artifacts");
+ }
+ }
+
+ return s.toString();
+ }
+
+ /**
+ * Build the summary line for small reactors (per-module indicators + counter).
+ */
+ private String buildSummaryLine(int termWidth) {
+ StringBuilder s = new StringBuilder(" ");
+
+ // Per-module indicators — each module gets its own symbol
+ if (totalProjects > 1) {
+ for (String pid : projectOrder) {
+ if (activeProjects.containsKey(pid)) {
+ ProjectState ps = activeProjects.get(pid);
+ if (ps != null && ps.failed) {
+ s.append(RED).append("✗ ").append(RESET);
+ } else {
+ s.append(YELLOW).append("● ").append(RESET);
+ }
+ } else if (projectOrder.indexOf(pid) < completedProjects) {
+ s.append(GREEN).append("✓ ").append(RESET);
+ } else {
+ s.append(DIM).append("○ ").append(RESET);
+ }
+ }
+ }
+
+ // Progress counter
+ s.append('[');
+ s.append(BOLD)
+ .append(completedProjects)
+ .append('/')
+ .append(totalProjects)
+ .append(RESET);
+ s.append(']');
+
+ // Elapsed time
+ if (buildStartTime != null) {
+ Duration elapsed = Duration.between(buildStartTime, MonotonicClock.now());
+ s.append(" ").append(DIM).append(formatCompactDuration(elapsed)).append(RESET);
+ }
+
+ // Downloads (merged into summary line to keep height fixed)
+ if (!activeTransfers.isEmpty()) {
+ s.append(" ").append(BLUE).append("↓ ").append(RESET);
+ if (activeTransfers.size() == 1) {
+ TransferInfo ti = activeTransfers.values().iterator().next();
+ s.append(ti.artifactName);
+ if (ti.totalBytes > 0) {
+ s.append(' ')
+ .append(DIM)
+ .append(formatBytes(ti.transferred))
+ .append('/')
+ .append(formatBytes(ti.totalBytes))
+ .append(RESET);
+ }
+ } else {
+ s.append(activeTransfers.size()).append(" artifacts");
+ }
+ }
+
+ return s.toString();
+ }
+
+ // ---- Helpers ----
+
+ /**
+ * Truncate a string containing ANSI escape sequences to
+ * {@code maxVisible} visible characters. If truncation occurs,
+ * a RESET is appended to close any open styling.
+ */
+ static String truncateAnsi(String s, int maxVisible) {
+ StringBuilder out = new StringBuilder(s.length());
+ int visible = 0;
+ int i = 0;
+ while (i < s.length()) {
+ char c = s.charAt(i);
+ if (c == '\033') {
+ // Start of escape sequence — copy through without counting
+ out.append(c);
+ i++;
+ if (i < s.length()) {
+ char next = s.charAt(i);
+ if (next == '[') {
+ // CSI sequence: ESC [ ...
+ out.append(next);
+ i++;
+ while (i < s.length()) {
+ char cc = s.charAt(i);
+ out.append(cc);
+ i++;
+ if (Character.isLetter(cc)) {
+ break;
+ }
+ }
+ } else {
+ // Two-char escape (DECSC, DECRC, etc.)
+ out.append(next);
+ i++;
+ }
+ }
+ } else {
+ if (visible >= maxVisible) {
+ out.append(RESET);
+ break;
+ }
+ out.append(c);
+ visible++;
+ i++;
+ }
+ }
+ return out.toString();
+ }
+
+ private static String extractArtifactName(String resourceName) {
+ if (resourceName == null) {
+ return "unknown";
+ }
+ int lastSlash = resourceName.lastIndexOf('/');
+ return lastSlash >= 0 ? resourceName.substring(lastSlash + 1) : resourceName;
+ }
+
+ private static String formatCompactDuration(Duration duration) {
+ long totalSeconds = duration.getSeconds();
+ if (totalSeconds < 60) {
+ return totalSeconds + "s";
+ } else if (totalSeconds < 3600) {
+ return (totalSeconds / 60) + "m " + (totalSeconds % 60) + "s";
+ } else {
+ return (totalSeconds / 3600) + "h " + ((totalSeconds % 3600) / 60) + "m";
+ }
+ }
+
+ private static String formatBytes(long bytes) {
+ if (bytes < 1024) {
+ return bytes + " B";
+ } else if (bytes < 1024 * 1024) {
+ return String.format("%.0f KB", bytes / 1024.0);
+ } else {
+ return String.format("%.1f MB", bytes / (1024.0 * 1024.0));
+ }
+ }
+
+ // ---- Inner state classes ----
+
+ private static class ProjectState {
+ final String projectId;
+ final Instant startTime;
+ volatile String currentMojo;
+ volatile boolean failed;
+
+ ProjectState(String projectId, Instant startTime) {
+ this.projectId = projectId;
+ this.startTime = startTime;
+ }
+ }
+
+ private static class TransferInfo {
+ final String artifactName;
+ final long totalBytes;
+ volatile long transferred;
+ volatile long lastUpdateBytes;
+
+ TransferInfo(String artifactName, long transferred, long totalBytes) {
+ this.artifactName = artifactName;
+ this.transferred = transferred;
+ this.totalBytes = totalBytes;
+ }
+ }
+}
\ No newline at end of file
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichExecutionEventLogger.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichExecutionEventLogger.java
new file mode 100644
index 000000000000..9ced16074235
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichExecutionEventLogger.java
@@ -0,0 +1,377 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.cling.event;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Objects;
+
+import org.apache.maven.api.MonotonicClock;
+import org.apache.maven.api.services.MessageBuilder;
+import org.apache.maven.api.services.MessageBuilderFactory;
+import org.apache.maven.cling.utils.CLIReportingUtils;
+import org.apache.maven.execution.AbstractExecutionListener;
+import org.apache.maven.execution.BuildFailure;
+import org.apache.maven.execution.BuildSuccess;
+import org.apache.maven.execution.BuildSummary;
+import org.apache.maven.execution.ExecutionEvent;
+import org.apache.maven.execution.MavenExecutionResult;
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.project.MavenProject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import static org.apache.maven.cling.utils.CLIReportingUtils.formatDuration;
+
+/**
+ * Execution event logger for the rich terminal mode ({@code --console=rich}).
+ *
+ * In rich mode, the {@link RichBuildEventListener} manages a JLine status bar at
+ * the bottom of the terminal showing live reactor progress. This logger is deliberately
+ * minimal — it suppresses the verbose per-mojo and per-project banners that
+ * {@link ExecutionEventLogger} produces, since the status bar replaces them.
+ *
+ * What this logger DOES print (above the status bar):
+ *
+ * - One line per completed module (like {@link PlainExecutionEventLogger})
+ * - Build result summary ({@code BUILD SUCCESS/FAILURE})
+ * - Compact module statistics and timing
+ * - Pointer to the structured build report
+ *
+ *
+ * What the status bar shows (managed by {@link RichBuildEventListener}):
+ *
+ * - Currently building modules with active mojo name
+ * - Reactor progress ({@code [n/total]}) and elapsed time
+ * - Active downloads with progress
+ *
+ *
+ * @since 4.1.0
+ * @see RichBuildEventListener
+ * @see PlainExecutionEventLogger
+ */
+public class RichExecutionEventLogger extends AbstractExecutionListener {
+
+ private static final int MAX_LOG_PREFIX_SIZE = 8; // "[ERROR] "
+ private static final int PROJECT_STATUS_SUFFIX_SIZE = 20; // "SUCCESS [ 0.000 s]"
+ private static final int MIN_TERMINAL_WIDTH = 60;
+ private static final int DEFAULT_TERMINAL_WIDTH = 80;
+ private static final int MAX_TERMINAL_WIDTH = 130;
+ private static final int MAX_PADDED_BUILD_TIME_DURATION_LENGTH = 9;
+
+ private final MessageBuilderFactory messageBuilderFactory;
+ private final Logger logger;
+ private final RichBuildEventListener buildEventListener;
+ private int terminalWidth;
+ private int lineLength;
+ private int maxProjectNameLength;
+ private int totalProjects;
+ private volatile int currentVisitedProjectCount;
+
+ public RichExecutionEventLogger(
+ MessageBuilderFactory messageBuilderFactory, RichBuildEventListener buildEventListener) {
+ this(messageBuilderFactory, buildEventListener, LoggerFactory.getLogger(RichExecutionEventLogger.class));
+ }
+
+ public RichExecutionEventLogger(
+ MessageBuilderFactory messageBuilderFactory, RichBuildEventListener buildEventListener, Logger logger) {
+ this(messageBuilderFactory, buildEventListener, logger, -1);
+ }
+
+ public RichExecutionEventLogger(
+ MessageBuilderFactory messageBuilderFactory,
+ RichBuildEventListener buildEventListener,
+ Logger logger,
+ int terminalWidth) {
+ this.logger = Objects.requireNonNull(logger, "logger cannot be null");
+ this.messageBuilderFactory = messageBuilderFactory;
+ this.buildEventListener = Objects.requireNonNull(buildEventListener, "buildEventListener cannot be null");
+ this.terminalWidth = terminalWidth;
+ }
+
+ private void init() {
+ if (maxProjectNameLength == 0) {
+ if (terminalWidth < 0) {
+ terminalWidth = messageBuilderFactory.getTerminalWidth();
+ }
+ terminalWidth = Math.min(
+ MAX_TERMINAL_WIDTH,
+ Math.max(terminalWidth <= 0 ? DEFAULT_TERMINAL_WIDTH : terminalWidth, MIN_TERMINAL_WIDTH));
+ lineLength = terminalWidth - MAX_LOG_PREFIX_SIZE;
+ maxProjectNameLength = lineLength - PROJECT_STATUS_SUFFIX_SIZE;
+ }
+ }
+
+ private MessageBuilder builder() {
+ return messageBuilderFactory.builder();
+ }
+
+ private static String chars(char c, int count) {
+ return String.valueOf(c).repeat(Math.max(0, count));
+ }
+
+ // ---- Session lifecycle ----
+
+ @Override
+ public void projectDiscoveryStarted(ExecutionEvent event) {
+ if (logger.isInfoEnabled()) {
+ init();
+ logger.info("Scanning for projects...");
+ }
+ }
+
+ @Override
+ public void sessionStarted(ExecutionEvent event) {
+ if (logger.isInfoEnabled()) {
+ init();
+ MavenSession session = event.getSession();
+ List projects = session.getProjects();
+ List allProjects = session.getAllProjects();
+
+ currentVisitedProjectCount = allProjects.size() - projects.size();
+ totalProjects = allProjects.size();
+
+ // Initialize the status bar
+ buildEventListener.initReactor(session);
+ }
+ }
+
+ @Override
+ public void sessionEnded(ExecutionEvent event) {
+ if (logger.isInfoEnabled()) {
+ init();
+
+ // Tear down the status bar before printing summary
+ buildEventListener.tearDown();
+
+ // Write summary directly through the terminal writer (via buildEventListener.log)
+ // rather than logger.info() — all SLF4J output is routed through
+ // ProjectBuildLogAppender → projectLogMessage which filters INFO in rich mode.
+ buildEventListener.log("");
+ logResult(event.getSession());
+ logStats(event.getSession());
+ }
+ }
+
+ // ---- Module lifecycle ----
+ // In rich mode, per-module success lines are suppressed — the status bar already
+ // shows ✓/●/○ indicators and the [n/total] counter for every module.
+ // Only FAILURE and SKIPPED scroll above the status bar since they're actionable.
+
+ @Override
+ public void projectStarted(ExecutionEvent event) {
+ // Suppressed — the status bar shows active modules
+ }
+
+ @Override
+ public void projectSucceeded(ExecutionEvent event) {
+ // Suppressed — the status bar checkmarks already indicate completion
+ }
+
+ @Override
+ public void projectFailed(ExecutionEvent event) {
+ if (logger.isInfoEnabled()) {
+ init();
+ logProjectLine(event, "FAILURE");
+ }
+ }
+
+ @Override
+ public void projectSkipped(ExecutionEvent event) {
+ if (logger.isInfoEnabled()) {
+ init();
+ logProjectLine(event, "SKIPPED");
+ }
+ }
+
+ // ---- Mojo lifecycle: suppressed (status bar shows active mojo) ----
+
+ @Override
+ public void mojoStarted(ExecutionEvent event) {
+ // Suppressed — the status bar shows the active mojo
+ }
+
+ @Override
+ public void mojoSkipped(ExecutionEvent event) {
+ if (logger.isWarnEnabled()) {
+ logger.warn(
+ "Goal '{}' requires online mode for execution but Maven is currently offline, skipping",
+ event.getMojoExecution().getGoal());
+ }
+ }
+
+ @Override
+ public void forkStarted(ExecutionEvent event) {
+ // Suppressed in rich mode
+ }
+
+ @Override
+ public void forkSucceeded(ExecutionEvent event) {
+ // Suppressed in rich mode
+ }
+
+ // ---- Formatting helpers (reuses PlainExecutionEventLogger patterns) ----
+
+ private void logProjectLine(ExecutionEvent event, String status) {
+ MavenProject project = event.getProject();
+ MavenSession session = event.getSession();
+ MavenExecutionResult result = session.getResult();
+ BuildSummary buildSummary = result.getBuildSummary(project);
+
+ StringBuilder buffer = new StringBuilder(128);
+
+ // Status icon
+ switch (status) {
+ case "SUCCESS":
+ buffer.append(" ✓ ");
+ break;
+ case "FAILURE":
+ buffer.append(" ✗ ");
+ break;
+ default:
+ buffer.append(" ○ ");
+ break;
+ }
+
+ buffer.append(project.getName());
+ buffer.append(' ');
+
+ if (totalProjects > 1) {
+ int number;
+ synchronized (this) {
+ number = ++currentVisitedProjectCount;
+ }
+ String progress = "[" + number + "/" + totalProjects + "]";
+ buffer.append(progress);
+ buffer.append(' ');
+ }
+
+ // Pad with dots to align status
+ int effectiveMax = maxProjectNameLength - 3; // account for status icon
+ if (buffer.length() <= effectiveMax) {
+ while (buffer.length() < effectiveMax) {
+ buffer.append('.');
+ }
+ buffer.append(' ');
+ }
+
+ // Status with color
+ MessageBuilder mb = builder();
+ mb.a(buffer);
+ switch (status) {
+ case "SUCCESS":
+ mb.success(status);
+ break;
+ case "FAILURE":
+ mb.failure(status);
+ break;
+ default:
+ mb.warning(status);
+ break;
+ }
+
+ // Duration
+ if (buildSummary != null) {
+ mb.a(" [");
+ String duration = formatDuration(buildSummary.getExecTime());
+ int padSize = MAX_PADDED_BUILD_TIME_DURATION_LENGTH - duration.length();
+ if (padSize > 0) {
+ mb.a(chars(' ', padSize));
+ }
+ mb.a(duration);
+ mb.a(']');
+ }
+
+ buildEventListener.log(mb.toString());
+ }
+
+ private void logResult(MavenSession session) {
+ MessageBuilder buffer = builder();
+ if (session.getResult().hasExceptions()) {
+ buffer.failure("BUILD FAILURE");
+ } else {
+ buffer.success("BUILD SUCCESS");
+ }
+
+ int passed = 0;
+ int failed = 0;
+ int skipped = 0;
+ for (MavenProject project : session.getProjects()) {
+ BuildSummary summary = session.getResult().getBuildSummary(project);
+ if (summary instanceof BuildSuccess) {
+ passed++;
+ } else if (summary instanceof BuildFailure) {
+ failed++;
+ } else {
+ skipped++;
+ }
+ }
+
+ buildEventListener.log(buffer.toString());
+
+ // Compact stats line
+ if (totalProjects > 1) {
+ StringBuilder stats = new StringBuilder();
+ stats.append(totalProjects).append(" modules");
+ stats.append(" | ").append(passed).append(" passed");
+ if (failed > 0) {
+ stats.append(" | ").append(failed).append(" failed");
+ }
+ if (skipped > 0) {
+ stats.append(" | ").append(skipped).append(" skipped");
+ }
+ buildEventListener.log(stats.toString());
+ }
+
+ // Warning/error summary — warnings are suppressed inline in rich mode,
+ // so the count and a command hint help the user find them.
+ int warnings = buildEventListener.getWarningCount();
+ int errors = buildEventListener.getErrorCount();
+ if (warnings > 0 || errors > 0) {
+ MessageBuilder diag = builder();
+ diag.a("Diagnostics: ");
+ if (warnings > 0) {
+ diag.warning(warnings + " warning" + (warnings > 1 ? "s" : ""));
+ }
+ if (warnings > 0 && errors > 0) {
+ diag.a(", ");
+ }
+ if (errors > 0) {
+ diag.failure(errors + " error" + (errors > 1 ? "s" : ""));
+ }
+ diag.a(" — run ").strong("mvnlog").a(" to see details");
+ buildEventListener.log(diag.toString());
+ }
+ }
+
+ private void logStats(MavenSession session) {
+ Duration time = Duration.between(session.getRequest().getStartInstant(), MonotonicClock.now());
+ String wallClock = session.getRequest().getDegreeOfConcurrency() > 1 ? " (Wall Clock)" : "";
+ buildEventListener.log("Total time: " + formatDuration(time) + wallClock);
+
+ // On failure, show Maven and Java version to help with bug reports (MNG-7372)
+ if (session.getResult().hasExceptions()) {
+ buildEventListener.log("Maven: " + CLIReportingUtils.showVersionMinimal());
+ buildEventListener.log("Java: " + System.getProperty("java.version", "") + " ("
+ + System.getProperty("java.vendor", "") + ")");
+ }
+
+ buildEventListener.log("Full report: target/build-reports/build-report-latest.json");
+ }
+}
\ No newline at end of file
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 7f5768715ae4..a75e79d9ba54 100644
--- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java
@@ -212,6 +212,26 @@ public Optional color() {
return Optional.empty();
}
+ @Override
+ public Optional console() {
+ if (commandLine.hasOption(CLIManager.CONSOLE)) {
+ if (commandLine.getOptionValue(CLIManager.CONSOLE) != null) {
+ return Optional.of(commandLine.getOptionValue(CLIManager.CONSOLE));
+ } else {
+ return Optional.of("auto");
+ }
+ }
+ return Optional.empty();
+ }
+
+ @Override
+ public Optional warningMode() {
+ if (commandLine.hasOption(CLIManager.WARNING_MODE)) {
+ return Optional.of(commandLine.getOptionValue(CLIManager.WARNING_MODE));
+ }
+ return Optional.empty();
+ }
+
@Override
public Optional offline() {
if (commandLine.hasOption(CLIManager.OFFLINE)) {
@@ -315,6 +335,8 @@ protected static class CLIManager {
public static final String LOG_FILE = "l";
public static final String RAW_STREAMS = "raw-streams";
public static final String COLOR = "color";
+ public static final String CONSOLE = "console";
+ public static final String WARNING_MODE = "warning-mode";
public static final String OFFLINE = "o";
public static final String HELP = "h";
@@ -344,13 +366,13 @@ protected CLIManager() {
prepareOptions(options);
}
+ @SuppressWarnings("checkstyle:MethodLength")
protected void prepareOptions(org.apache.commons.cli.Options options) {
options.addOption(Option.builder(HELP)
.longOpt("help")
.desc("Display help information")
.get());
options.addOption(Option.builder(USER_PROPERTY)
- .longOpt("define")
.numberOfArgs(2)
.valueSeparator('=')
.desc("Define a user property")
@@ -373,7 +395,7 @@ protected void prepareOptions(org.apache.commons.cli.Options options) {
.get());
options.addOption(Option.builder(BATCH_MODE)
.longOpt("batch-mode")
- .desc("Run in non-interactive mode. Alias for --non-interactive (kept for backwards compatibility)")
+ .desc("Run in non-interactive mode. Alias for --non-interactive (kept for backwards compatability)")
.get());
options.addOption(Option.builder()
.longOpt(NON_INTERACTIVE)
@@ -433,6 +455,23 @@ protected void prepareOptions(org.apache.commons.cli.Options options) {
.optionalArg(true)
.desc("Defines the color mode of the output. Supported are 'auto', 'always', 'never'.")
.get());
+ options.addOption(Option.builder()
+ .longOpt(CONSOLE)
+ .hasArg()
+ .optionalArg(true)
+ .desc("Defines the console output mode. Supported are 'auto' (default),"
+ + " 'plain', 'rich', 'verbose', 'machine'."
+ + " In 'auto' mode, CI environments use 'plain',"
+ + " interactive TTYs use 'rich' (status bar)."
+ + " 'machine' outputs one JSON line per lifecycle event.")
+ .get());
+ options.addOption(Option.builder()
+ .longOpt(WARNING_MODE)
+ .hasArg()
+ .desc("Controls how build warnings are displayed."
+ + " Supported modes: 'summary' (default, deduplicated summary at end),"
+ + " 'all' (inline + summary), 'none' (suppress), 'fail' (treat warnings as errors).")
+ .get());
options.addOption(Option.builder(OFFLINE)
.longOpt("offline")
.desc("Work offline")
@@ -539,4 +578,4 @@ protected String commandLineSyntax(String command) {
return command + " [options] [ ...]";
}
}
-}
+}
\ No newline at end of file
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LayeredOptions.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LayeredOptions.java
index 2f9c367c4786..0fd64704aaf2 100644
--- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LayeredOptions.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LayeredOptions.java
@@ -132,6 +132,16 @@ public Optional color() {
return returnFirstPresentOrEmpty(Options::color);
}
+ @Override
+ public Optional console() {
+ return returnFirstPresentOrEmpty(Options::console);
+ }
+
+ @Override
+ public Optional warningMode() {
+ return returnFirstPresentOrEmpty(Options::warningMode);
+ }
+
@Override
public Optional offline() {
return returnFirstPresentOrEmpty(Options::offline);
@@ -188,4 +198,4 @@ protected Optional