From c5a917b7c0a096167288e46455dd5c309b05345e Mon Sep 17 00:00:00 2001 From: Michael Kutz Date: Thu, 19 Feb 2026 09:10:32 +0100 Subject: [PATCH] Add review-console module with colored diff output Non-blocking FileReviewer that prints a colored unified diff to the test output, letting users inspect changes directly in IDE test panels or CI logs. --- gradle/libs.versions.toml | 3 +- .../docs/asciidoc/chapters/06-reviewing.adoc | 19 ++++ modules/review-console/build.gradle.kts | 44 ++++++++ .../review/console/ConsoleFileReviewer.java | 104 ++++++++++++++++++ .../review/console/NullableTerminal.java | 42 +++++++ .../org/approvej/review/console/Terminal.java | 37 +++++++ .../org.approvej.configuration.Provider | 1 + .../console/ConsoleFileReviewerTest.java | 64 +++++++++++ settings.gradle.kts | 2 + 9 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 modules/review-console/build.gradle.kts create mode 100644 modules/review-console/src/main/java/org/approvej/review/console/ConsoleFileReviewer.java create mode 100644 modules/review-console/src/main/java/org/approvej/review/console/NullableTerminal.java create mode 100644 modules/review-console/src/main/java/org/approvej/review/console/Terminal.java create mode 100644 modules/review-console/src/main/resources/META-INF/services/org.approvej.configuration.Provider create mode 100644 modules/review-console/src/test/java/org/approvej/review/console/ConsoleFileReviewerTest.java diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 33204904..2a63e45f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] -junit = "6.0.3" assertj = "3.27.7" jackson2 = "2.21.0" jackson3 = "3.0.4" +junit = "6.0.3" wiremock = "3.13.2" [libraries] @@ -15,6 +15,7 @@ jackson2-dataformat-yaml = { module = "com.fasterxml.jackson.dataformat:jackson- jackson2-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson2" } jackson3-databind = { module = "tools.jackson.core:jackson-databind", version.ref = "jackson3" } jackson3-dataformat-yaml = { module = "tools.jackson.dataformat:jackson-dataformat-yaml", version.ref = "jackson3" } +java-diff-utils = { module = "io.github.java-diff-utils:java-diff-utils", version = "4.15" } jspecify = { module = "org.jspecify:jspecify", version = "1.0.0" } junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" } junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" } diff --git a/manual/src/docs/asciidoc/chapters/06-reviewing.adoc b/manual/src/docs/asciidoc/chapters/06-reviewing.adoc index e04dac5a..02ddf7be 100644 --- a/manual/src/docs/asciidoc/chapters/06-reviewing.adoc +++ b/manual/src/docs/asciidoc/chapters/06-reviewing.adoc @@ -57,6 +57,25 @@ include::../../../test/kotlin/examples/kotlin/BasicsDocTest.kt[tag=approve_revie ---- +== Console Review + +The `review-console` module provides a non-blocking reviewer that prints a colored unified diff to the test output. +This lets you see what changed directly in your IDE's test panel or CI logs, without needing an external diff tool. +The test will still fail, so you can review the diff and then approve the received file manually or with the `automatic` reviewer. + +To use it in your test code: + +[source,java,indent=0] +---- +.reviewedBy(ConsoleFileReviewer.console()) +---- + +To set it as the default reviewer globally, configure the alias `console` as described in <>. + +NOTE: ANSI colors are used by default. +Set the `NO_COLOR` environment variable to disable them. + + == Configure the Default Reviewer Globally See <> on how to configure a global default reviewer. diff --git a/modules/review-console/build.gradle.kts b/modules/review-console/build.gradle.kts new file mode 100644 index 00000000..5c10e54e --- /dev/null +++ b/modules/review-console/build.gradle.kts @@ -0,0 +1,44 @@ +@file:Suppress("UnstableApiUsage", "unused") + +plugins { + `java-library` + jacoco + `jvm-test-suite` + `maven-publish` +} + +java { + withJavadocJar() + withSourcesJar() + toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } +} + +repositories { mavenCentral() } + +dependencies { + api(project(":modules:core")) + api(libs.jspecify) + + implementation(libs.java.diff.utils) +} + +testing { + suites { + val test by + getting(JvmTestSuite::class) { + useJUnitJupiter() + dependencies { + implementation(testFixtures(project(":modules:core"))) + + implementation(platform(libs.junit.bom)) + implementation(libs.junit.jupiter.api) + implementation(libs.assertj.core) + + runtimeOnly(libs.junit.platform.launcher) + runtimeOnly(libs.junit.jupiter.engine) + } + } + } +} + +tasks.jacocoTestReport { reports { xml.required = true } } diff --git a/modules/review-console/src/main/java/org/approvej/review/console/ConsoleFileReviewer.java b/modules/review-console/src/main/java/org/approvej/review/console/ConsoleFileReviewer.java new file mode 100644 index 00000000..8fbdb939 --- /dev/null +++ b/modules/review-console/src/main/java/org/approvej/review/console/ConsoleFileReviewer.java @@ -0,0 +1,104 @@ +package org.approvej.review.console; + +import static java.nio.file.Files.readString; + +import com.github.difflib.DiffUtils; +import com.github.difflib.UnifiedDiffUtils; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.logging.Logger; +import org.approvej.approve.PathProvider; +import org.approvej.review.FileReviewResult; +import org.approvej.review.FileReviewer; +import org.approvej.review.FileReviewerProvider; +import org.approvej.review.ReviewResult; +import org.jspecify.annotations.NullMarked; + +/** + * A {@link FileReviewer} that prints a colored unified diff to {@code System.out}. + * + *

This is a non-blocking reviewer: it displays the differences between the received and approved + * files but does not approve them. The test will fail, allowing you to inspect the diff in the test + * output and then approve the received file manually or with the {@code automatic} reviewer. + */ +@NullMarked +public final class ConsoleFileReviewer implements FileReviewer, FileReviewerProvider { + + private static final Logger LOGGER = Logger.getLogger(ConsoleFileReviewer.class.getName()); + + static final String ANSI_RESET = "\033[0m"; + static final String ANSI_RED_STRIKETHROUGH = "\033[31;9m"; + static final String ANSI_GREEN_BOLD = "\033[32;1m"; + static final String ANSI_CYAN = "\033[36m"; + + private final Terminal terminal; + + /** Creates a new {@link ConsoleFileReviewer}. */ + public ConsoleFileReviewer() { + this(Terminal.system()); + } + + ConsoleFileReviewer(Terminal terminal) { + this.terminal = terminal; + } + + /** + * Creates a new {@link ConsoleFileReviewer} for use with {@code reviewedBy()}. + * + * @return a new {@link ConsoleFileReviewer} + */ + public static ConsoleFileReviewer console() { + return new ConsoleFileReviewer(); + } + + @Override + public ReviewResult apply(PathProvider pathProvider) { + try { + List unifiedDiff = + computeDiff(pathProvider.approvedPath(), pathProvider.receivedPath()); + boolean useColor = terminal.supportsColor(); + for (String line : unifiedDiff) { + terminal.print((useColor ? colorize(line) : line) + "\n"); + } + } catch (IOException exception) { + LOGGER.info("Console review failed with exception %s.".formatted(exception)); + } + return new FileReviewResult(false); + } + + @Override + public String alias() { + return "console"; + } + + @Override + public FileReviewer create() { + return new ConsoleFileReviewer(); + } + + static List computeDiff(Path approvedPath, Path receivedPath) throws IOException { + String approvedContent = Files.exists(approvedPath) ? readString(approvedPath) : ""; + String receivedContent = readString(receivedPath); + List approvedLines = Arrays.asList(approvedContent.split("\n", -1)); + List receivedLines = Arrays.asList(receivedContent.split("\n", -1)); + var patch = DiffUtils.diff(approvedLines, receivedLines); + return UnifiedDiffUtils.generateUnifiedDiff( + approvedPath.toString(), receivedPath.toString(), approvedLines, patch, 3); + } + + static String colorize(String line) { + if (line.startsWith("@@")) { + return ANSI_CYAN + line + ANSI_RESET; + } + if (line.startsWith("+")) { + return ANSI_GREEN_BOLD + line + ANSI_RESET; + } + if (line.startsWith("-")) { + return ANSI_RED_STRIKETHROUGH + line + ANSI_RESET; + } + return line; + } +} diff --git a/modules/review-console/src/main/java/org/approvej/review/console/NullableTerminal.java b/modules/review-console/src/main/java/org/approvej/review/console/NullableTerminal.java new file mode 100644 index 00000000..aa3d84e6 --- /dev/null +++ b/modules/review-console/src/main/java/org/approvej/review/console/NullableTerminal.java @@ -0,0 +1,42 @@ +package org.approvej.review.console; + +import org.jspecify.annotations.NullMarked; + +/** + * An in-memory {@link Terminal} for testing. + * + *

Captures all output in a {@link StringBuilder}. + */ +@NullMarked +final class NullableTerminal implements Terminal { + + private final StringBuilder output = new StringBuilder(); + private final boolean supportsColor; + + NullableTerminal() { + this(false); + } + + NullableTerminal(boolean supportsColor) { + this.supportsColor = supportsColor; + } + + @Override + public void print(String text) { + output.append(text); + } + + @Override + public boolean supportsColor() { + return supportsColor; + } + + /** + * Returns all output that was printed to this terminal. + * + * @return the captured output + */ + String output() { + return output.toString(); + } +} diff --git a/modules/review-console/src/main/java/org/approvej/review/console/Terminal.java b/modules/review-console/src/main/java/org/approvej/review/console/Terminal.java new file mode 100644 index 00000000..651e7b14 --- /dev/null +++ b/modules/review-console/src/main/java/org/approvej/review/console/Terminal.java @@ -0,0 +1,37 @@ +package org.approvej.review.console; + +import org.jspecify.annotations.NullMarked; + +/** Abstraction for writing output to a terminal and querying its capabilities. */ +@NullMarked +interface Terminal { + + /** + * Prints the given text to the terminal. + * + * @param text the text to print + */ + void print(String text); + + /** + * Returns whether this terminal supports ANSI color codes. + * + * @return {@code true} if ANSI colors can be used + */ + boolean supportsColor(); + + /** Creates a terminal that writes to {@code System.out} and respects the {@code NO_COLOR} env. */ + static Terminal system() { + return new Terminal() { + @Override + public void print(String text) { + System.out.print(text); + } + + @Override + public boolean supportsColor() { + return System.getenv("NO_COLOR") == null; + } + }; + } +} diff --git a/modules/review-console/src/main/resources/META-INF/services/org.approvej.configuration.Provider b/modules/review-console/src/main/resources/META-INF/services/org.approvej.configuration.Provider new file mode 100644 index 00000000..c287ea79 --- /dev/null +++ b/modules/review-console/src/main/resources/META-INF/services/org.approvej.configuration.Provider @@ -0,0 +1 @@ +org.approvej.review.console.ConsoleFileReviewer diff --git a/modules/review-console/src/test/java/org/approvej/review/console/ConsoleFileReviewerTest.java b/modules/review-console/src/test/java/org/approvej/review/console/ConsoleFileReviewerTest.java new file mode 100644 index 00000000..59f04922 --- /dev/null +++ b/modules/review-console/src/test/java/org/approvej/review/console/ConsoleFileReviewerTest.java @@ -0,0 +1,64 @@ +package org.approvej.review.console; + +import static java.nio.file.Files.writeString; +import static org.approvej.approve.PathProviders.approvedPath; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import org.approvej.approve.PathProvider; +import org.approvej.review.ReviewResult; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ConsoleFileReviewerTest { + + @TempDir private Path tempDir; + + @Test + void apply() throws IOException { + var terminal = new NullableTerminal(); + var reviewer = new ConsoleFileReviewer(terminal); + PathProvider pathProvider = approvedPath(tempDir.resolve("apply-approved.txt")); + writeString(pathProvider.approvedPath(), "old content\n", StandardOpenOption.CREATE); + writeString(pathProvider.receivedPath(), "new content\n", StandardOpenOption.CREATE); + + ReviewResult result = reviewer.apply(pathProvider); + + assertThat(result.needsReapproval()).isFalse(); + assertThat(pathProvider.approvedPath()).content().isEqualTo("old content\n"); + assertThat(pathProvider.receivedPath()).content().isEqualTo("new content\n"); + assertThat(terminal.output()).contains("-old content"); + assertThat(terminal.output()).contains("+new content"); + assertThat(terminal.output()).doesNotContain("\033["); + } + + @Test + void apply_colored() throws IOException { + var terminal = new NullableTerminal(true); + var reviewer = new ConsoleFileReviewer(terminal); + PathProvider pathProvider = approvedPath(tempDir.resolve("colored-approved.txt")); + writeString(pathProvider.approvedPath(), "old content\n", StandardOpenOption.CREATE); + writeString(pathProvider.receivedPath(), "new content\n", StandardOpenOption.CREATE); + + ReviewResult result = reviewer.apply(pathProvider); + + assertThat(result.needsReapproval()).isFalse(); + assertThat(terminal.output()).contains("\033[31;9m-old content\033[0m"); + assertThat(terminal.output()).contains("\033[32;1m+new content\033[0m"); + } + + @Test + void apply_no_approved_file() throws IOException { + var terminal = new NullableTerminal(); + var reviewer = new ConsoleFileReviewer(terminal); + PathProvider pathProvider = approvedPath(tempDir.resolve("new-approved.txt")); + writeString(pathProvider.receivedPath(), "brand new content\n", StandardOpenOption.CREATE); + + ReviewResult result = reviewer.apply(pathProvider); + + assertThat(result.needsReapproval()).isFalse(); + assertThat(terminal.output()).contains("+brand new content"); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 321dace2..02af2d13 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,4 +18,6 @@ include("modules:http") include("modules:http-wiremock") +include("modules:review-console") + include("manual")