Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.arc_e_tect.gradle.detector.core.console;

import org.gradle.api.logging.Logger;

/**
* Emits the always-visible, top-level "[N/M] stage" header lines that mark the major phases of a
* detector plugin run (controller scanning, OpenAPI endpoint collection, verification-evidence
* scanning, ...), e.g. {@code "Doppelganger API Detector: [1/3] Scanning @RestController
* classes..."}.
*
* <p>Always emitted at {@code LIFECYCLE} level - unlike the per-item detail a {@link
* ScanProgressReporter} reports for the work inside each stage, which is {@code INFO}-only and
* thus hidden unless the build is run with {@code --info}. Pairing the two keeps default output to
* one line per stage plus a completion summary, while still letting {@code --info} reveal
* per-batch progress within a stage.</p>
*/
public final class DetectorStageReporter {

private final Logger logger;
private final String pluginLabel;
private final int totalStages;

private int currentStage;

/**
* Creates a reporter for a plugin run with a known, fixed number of stages.
*
* @param logger the logger stage headers are emitted to, at {@code LIFECYCLE} level
* @param pluginLabel short label identifying the plugin, e.g. {@code "Doppelganger API Detector"}
* @param totalStages the total number of stages the run will report
*/
public DetectorStageReporter(Logger logger, String pluginLabel, int totalStages) {
this.logger = logger;
this.pluginLabel = pluginLabel;
this.totalStages = totalStages;
}

/**
* Advances to the next stage and emits its header line, e.g. {@code "Doppelganger API
* Detector: [2/3] Collecting OpenAPI endpoints..."}. Calling this more times than {@code
* totalStages} keeps counting upward rather than clamping - callers are expected to call it
* exactly {@code totalStages} times, once per stage, in order.
*
* @param description short description of the stage being entered, without a trailing
* ellipsis or punctuation, e.g. {@code "Collecting OpenAPI endpoints"}
*/
public void stage(String description) {
currentStage++;
logger.lifecycle(pluginLabel + ": [" + currentStage + "/" + totalStages + "] " + description + "...");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
import java.util.function.LongSupplier;

/**
* Emits periodic, low-overhead {@code LIFECYCLE}-level status lines for a long-running scan loop
* (controller scanning, OpenAPI {@code $ref} resolution, verification-evidence scanning, ...), so
* a consumer watching the build knows the task is still alive and roughly how far along it is.
* Emits periodic, low-overhead status lines for a long-running scan loop (controller scanning,
* OpenAPI {@code $ref} resolution, verification-evidence scanning, ...), so a consumer watching
* the build knows the task is still alive and roughly how far along it is.
*
* <p>Deliberately built only on the public {@link Logger} API - one plain line per status update,
* never overwriting a line in place - rather than Gradle's internal, unsupported rich-console
Expand All @@ -17,12 +17,14 @@
* changed shape across versions and offers no compatibility guarantee. This is a deliberate
* trade-off in exchange for forward/backward compatibility, not an oversight.</p>
*
* <p>A status line is emitted every {@code everyNItems} items <strong>or</strong> every
* {@code everySeconds} seconds since the last emission, whichever comes first - never on every
* single item, and never silent for more than {@code everySeconds} regardless of how many items
* are processed in between. {@link #complete()} always emits a final summary line, even if the
* most recent {@link #step()} landed inside the throttle window - the final line is never
* suppressed by throttling.</p>
* <p>{@link #step()} lines are emitted at {@code INFO} level - visible only with {@code --info} -
* every {@code everyNItems} items <strong>or</strong> every {@code everySeconds} seconds since the
* last emission, whichever comes first - never on every single item, and never silent for more
* than {@code everySeconds} regardless of how many items are processed in between. {@link
* #complete()} always emits its final summary line at {@code LIFECYCLE} level, visible by default,
* regardless of whether the most recent {@link #step()} landed inside the throttle window - the
* final line is never suppressed by throttling. Pair this with a {@code DetectorStageReporter} for
* the always-visible top-level "[N/M] stage" headers a plugin task prints around each phase.</p>
*/
public final class ScanProgressReporter {

Expand Down Expand Up @@ -50,7 +52,8 @@ public final class ScanProgressReporter {
* every {@value #DEFAULT_EVERY_SECONDS} seconds) and the real wall-clock time source. Prefer
* {@link #determinate(Logger, String, int)} or {@link #indeterminate(Logger, String)}.
*
* @param logger the logger status lines are emitted to, at {@code LIFECYCLE} level
* @param logger the logger status lines are emitted to - {@link #step} at {@code INFO}
* level, {@link #complete()} at {@code LIFECYCLE} level
* @param phaseLabel short label identifying the scan phase, e.g. {@code "Scanning @RestController classes"}
* @param total the total number of items expected, or a negative number for an
* indeterminate-total scan (the total isn't known ahead of time)
Expand All @@ -62,7 +65,8 @@ public ScanProgressReporter(Logger logger, String phaseLabel, int total) {
/**
* Creates a reporter with an explicit throttle and the real wall-clock time source.
*
* @param logger the logger status lines are emitted to, at {@code LIFECYCLE} level
* @param logger the logger status lines are emitted to - {@link #step} at {@code INFO}
* level, {@link #complete()} at {@code LIFECYCLE} level
* @param phaseLabel short label identifying the scan phase
* @param total the total number of items expected, or a negative number for an
* indeterminate-total scan
Expand All @@ -78,7 +82,8 @@ public ScanProgressReporter(Logger logger, String phaseLabel, int total, int eve
* Creates a reporter with an explicit throttle and time source, for use by tests that need to
* control elapsed time without a real {@code Thread.sleep}.
*
* @param logger the logger status lines are emitted to, at {@code LIFECYCLE} level
* @param logger the logger status lines are emitted to - {@link #step} at {@code INFO}
* level, {@link #complete()} at {@code LIFECYCLE} level
* @param phaseLabel short label identifying the scan phase
* @param total the total number of items expected, or a negative number for an
* indeterminate-total scan
Expand All @@ -103,7 +108,8 @@ public ScanProgressReporter(Logger logger, String phaseLabel, int total, int eve
* Creates a reporter for a scan whose total item count is known ahead of time. Emitted lines
* include a running fraction and percentage, e.g. {@code "Scanning @RestController classes: 150/438 (34%)"}.
*
* @param logger the logger status lines are emitted to, at {@code LIFECYCLE} level
* @param logger the logger status lines are emitted to - {@link #step} at {@code INFO}
* level, {@link #complete()} at {@code LIFECYCLE} level
* @param phaseLabel short label identifying the scan phase
* @param total the total number of items expected; {@code 0} is valid and not an error
* @return a new determinate-mode reporter, using the default throttle
Expand All @@ -116,7 +122,8 @@ public static ScanProgressReporter determinate(Logger logger, String phaseLabel,
* Creates a reporter for a scan whose total item count isn't known ahead of time. Emitted
* lines report only a running count, e.g. {@code "Resolving OpenAPI documents: 27 processed so far"}.
*
* @param logger the logger status lines are emitted to, at {@code LIFECYCLE} level
* @param logger the logger status lines are emitted to - {@link #step} at {@code INFO}
* level, {@link #complete()} at {@code LIFECYCLE} level
* @param phaseLabel short label identifying the scan phase
* @return a new indeterminate-mode reporter, using the default throttle
*/
Expand All @@ -125,16 +132,18 @@ public static ScanProgressReporter indeterminate(Logger logger, String phaseLabe
}

/**
* Records one item processed, emitting a status line if the throttle window has elapsed.
* Equivalent to {@link #step(String)} with no detail.
* Records one item processed, emitting an {@code INFO}-level status line - visible only with
* {@code --info} - if the throttle window has elapsed. Equivalent to {@link #step(String)}
* with no detail.
*/
public void step() {
step(null);
}

/**
* Records one item processed, emitting a status line - with {@code detail} appended, when
* given - if the throttle window has elapsed.
* Records one item processed, emitting an {@code INFO}-level status line - visible only with
* {@code --info}, with {@code detail} appended when given - if the throttle window has
* elapsed.
*
* @param detail short description of the current item, appended to the line only when this
* call itself results in an emission; or {@code null}/blank for no detail
Expand All @@ -145,17 +154,18 @@ public void step(String detail) {
boolean dueByCount = (count - lastEmittedCount) >= everyNItems;
boolean dueByTime = (now - lastEmittedNanos) >= everyNanos;
if (dueByCount || dueByTime) {
logger.lifecycle(progressLine(detail));
logger.info(progressLine(detail));
lastEmittedCount = count;
lastEmittedNanos = now;
}
}

/**
* Emits a final summary line unconditionally, regardless of throttling state - the last
* {@link #step()} may have landed inside the throttle window, but this line is never
* suppressed. Safe to call on a reporter that never had {@link #step()} called at all (the
* {@code 0}-items-processed case).
* Emits a final summary line unconditionally at {@code LIFECYCLE} level - visible by default,
* regardless of throttling state. The last {@link #step()} may have landed inside the throttle
* window (or never fired at all, since its lines are {@code INFO}-only), but this line is
* never suppressed. Safe to call on a reporter that never had {@link #step()} called at all
* (the {@code 0}-items-processed case).
*/
public void complete() {
logger.lifecycle(phaseLabel + ": done, " + count + " item(s)");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.arc_e_tect.gradle.detector.core.console;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

@DisplayName("DetectorStageReporter")
class DetectorStageReporterTest {

private final RecordingLogger logger = new RecordingLogger();

@Test
@DisplayName("emits a numbered header line at LIFECYCLE level for each stage, in order")
void emitsNumberedHeaderLineForEachStage() {
DetectorStageReporter reporter = new DetectorStageReporter(logger, "Doppelganger API Detector", 3);

reporter.stage("Scanning @RestController classes");
reporter.stage("Collecting OpenAPI endpoints");
reporter.stage("Scanning Spring RestDocs verification evidence");

assertThat(logger.lifecycleMessages()).containsExactly(
"Doppelganger API Detector: [1/3] Scanning @RestController classes...",
"Doppelganger API Detector: [2/3] Collecting OpenAPI endpoints...",
"Doppelganger API Detector: [3/3] Scanning Spring RestDocs verification evidence...");
}

@Test
@DisplayName("never emits at INFO level")
void neverEmitsAtInfoLevel() {
DetectorStageReporter reporter = new DetectorStageReporter(logger, "Shadow API Detector", 1);

reporter.stage("Scanning @RestController classes");

assertThat(logger.infoMessages()).isEmpty();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@

/**
* Hand-written {@link Logger} test double that records every message passed to
* {@link #lifecycle(String)}/{@link #lifecycle(String, Object...)} and no-ops everything else -
* this codebase's tests use hand-written fakes rather than a mocking framework.
* {@link #lifecycle(String)}/{@link #lifecycle(String, Object...)} and
* {@link #info(String)}/{@link #info(String, Object...)}, and no-ops everything else - this
* codebase's tests use hand-written fakes rather than a mocking framework.
*/
class RecordingLogger implements Logger {

private final List<String> lifecycleMessages = new ArrayList<>();
private final List<String> infoMessages = new ArrayList<>();

/** Creates a new {@code RecordingLogger}. */
RecordingLogger() {}
Expand All @@ -28,6 +30,15 @@ List<String> lifecycleMessages() {
return lifecycleMessages;
}

/**
* Every message passed to {@code info(...)}, in call order.
*
* @return the recorded info messages
*/
List<String> infoMessages() {
return infoMessages;
}

@Override
public void lifecycle(String message) {
lifecycleMessages.add(message);
Expand Down Expand Up @@ -163,11 +174,13 @@ public void debug(Marker marker, String msg, Throwable t) {}

@Override
public boolean isInfoEnabled() {
return false;
return true;
}

@Override
public void info(String msg) {}
public void info(String msg) {
infoMessages.add(msg);
}

@Override
public void info(String format, Object arg) {}
Expand All @@ -176,10 +189,14 @@ public void info(String format, Object arg) {}
public void info(String format, Object arg1, Object arg2) {}

@Override
public void info(String format, Object... arguments) {}
public void info(String format, Object... arguments) {
infoMessages.add(format);
}

@Override
public void info(String msg, Throwable t) {}
public void info(String msg, Throwable t) {
infoMessages.add(msg);
}

@Override
public boolean isInfoEnabled(Marker marker) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ void emitsNothingBeforeItemCountThrottleIsReached() {
reporter.step();
}

assertThat(logger.lifecycleMessages()).isEmpty();
assertThat(logger.infoMessages()).isEmpty();
}

@Test
Expand All @@ -36,7 +36,7 @@ void emitsStatusLineOnceItemCountThrottleIsReached() {
reporter.step();
}

assertThat(logger.lifecycleMessages()).containsExactly("Scanning: 5/100 (5%)");
assertThat(logger.infoMessages()).containsExactly("Scanning: 5/100 (5%)");
}

@Test
Expand All @@ -49,7 +49,7 @@ void doesNotEmitAgainUntilAnotherFullItemCountWindow() {
reporter.step();
}

assertThat(logger.lifecycleMessages()).hasSize(1);
assertThat(logger.infoMessages()).hasSize(1);
}

@Test
Expand All @@ -62,7 +62,7 @@ void emitsNothingBeforeElapsedTimeThrottleIsReached() {
nanos.set(1_000_000_000L); // 1 second, less than the 2-second throttle
reporter.step();

assertThat(logger.lifecycleMessages()).isEmpty();
assertThat(logger.infoMessages()).isEmpty();
}

@Test
Expand All @@ -75,7 +75,7 @@ void emitsStatusLineOnceElapsedTimeThrottleIsReached() {
nanos.set(2_000_000_000L); // exactly 2 seconds
reporter.step();

assertThat(logger.lifecycleMessages()).containsExactly("Scanning: 1/100 (1%)");
assertThat(logger.infoMessages()).containsExactly("Scanning: 1/100 (1%)");
}

@Test
Expand Down Expand Up @@ -110,7 +110,7 @@ void indeterminateModeReportsRunningCount() {
reporter.step();
}

assertThat(logger.lifecycleMessages()).containsExactly("Resolving: 3 processed so far");
assertThat(logger.infoMessages()).containsExactly("Resolving: 3 processed so far");
}

@Test
Expand All @@ -133,7 +133,7 @@ void stepWithDetailAppendsDetailToEmittedLine() {

reporter.step("UserController.java");

assertThat(logger.lifecycleMessages()).containsExactly("Scanning: 1/100 (1%) - UserController.java");
assertThat(logger.infoMessages()).containsExactly("Scanning: 1/100 (1%) - UserController.java");
}

private LongSupplier fixedClock(long value) {
Expand Down
Loading