diff --git a/.github/workflows/build-main.yml b/.github/workflows/build-main.yml index 48317e83..1aedb678 100644 --- a/.github/workflows/build-main.yml +++ b/.github/workflows/build-main.yml @@ -46,4 +46,6 @@ jobs: if: failure() with: name: modules-test-reports - path: modules/**/build/reports/tests/test/ + path: | + modules/**/build/reports/tests/test/ + plugins/**/build/reports/tests/test/ diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index f252ba3e..0fbf6d7e 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -57,7 +57,9 @@ jobs: if: failure() with: name: modules-test-reports - path: modules/**/build/reports/tests/test/ + path: | + modules/**/build/reports/tests/test/ + plugins/**/build/reports/tests/test/ - name: Enable auto merge for Dependabot PRs run: | diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d440f614..76263a99 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -40,6 +40,10 @@ jobs: - run: ./gradlew jreleaserConfig -Pversion=${{ github.event.inputs.version }} --git-root-search - run: ./gradlew publish -Pversion=${{ github.event.inputs.version }} + - run: ./gradlew :plugins:approvej-gradle-plugin:publishPlugins -Pversion=${{ github.event.inputs.version }} + env: + GRADLE_PUBLISH_KEY: ${{ secrets.GRADLE_PUBLISH_KEY }} + GRADLE_PUBLISH_SECRET: ${{ secrets.GRADLE_PUBLISH_SECRET }} - run: ./gradlew jreleaserFullRelease -Pversion=${{ github.event.inputs.version }} --git-root-search --stacktrace - uses: actions/upload-artifact@v6 with: diff --git a/.gitignore b/.gitignore index b8d6898f..7435375d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ docs/** *.key pages banner-*.* +.approvej diff --git a/CLAUDE.md b/CLAUDE.md index 7ce9701c..f87fc108 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,8 @@ It provides a fluent API to compare actual values against previously approved "g - **modules/yaml-jackson3** - YAML support using Jackson 3.x - **modules/http** - HTTP stub server for approving HTTP requests - **modules/http-wiremock** - WireMock adapter for HTTP testing +- **plugins/approvej-gradle-plugin** - Gradle plugin for managing approved files +- **plugins/approvej-maven-plugin** - Maven plugin for managing approved files - **bom** - Maven Bill of Materials - **manual** - AsciiDoc documentation (code samples included from tests in `manual/src/test`) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9e9ab15c..2d1afdfe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -103,20 +103,26 @@ All pipelines can be found in [.github/workflows](.github/workflows). ### Versioning -Shakespeare is using [SemVer 2.0](https://semver.org/spec/v2.0.0.html) but omits the patch digit in case it is `0`. +ApproveJ is using [SemVer 2.0](https://semver.org/spec/v2.0.0.html) but omits the patch digit in case it is `0`. E.g. `1.0.0` is written as `1.0`, `1.0.1` is written as `1.0.1`, and `1.1.0` is written as `1.1`. ### Project Structure -It is structured in four main modules: +It is structured in four main directories: -- The [modules](modules) directory contains all the published code modules: +- The [modules](modules) directory contains all the published library modules: - [core](modules/core) contains the code for the core framework and should not have any dependencies to other modules and only very few (if any) to external libraries, - - [json-jackson](modules/json-jackson) contains JSON-related code using Jackson, - - [yaml-jackson](modules/yaml-jackson) contains YAML-related code using Jackson - - [http](modules/http) contains code to create an HTTP server for approving requests + - [json-jackson](modules/json-jackson) contains JSON-related code using Jackson 2.x, + - [json-jackson3](modules/json-jackson3) contains JSON-related code using Jackson 3.x, + - [yaml-jackson](modules/yaml-jackson) contains YAML-related code using Jackson 2.x, + - [yaml-jackson3](modules/yaml-jackson3) contains YAML-related code using Jackson 3.x, + - [http](modules/http) contains code to create an HTTP server for approving requests, + - [http-wiremock](modules/http-wiremock) contains the WireMock adapter for HTTP testing +- The [plugins](plugins) directory contains build tool plugins: + - [approvej-gradle-plugin](plugins/approvej-gradle-plugin) contains the Gradle plugin for managing approved files + - [approvej-maven-plugin](plugins/approvej-maven-plugin) contains the Maven plugin for managing approved files - the [bom](bom) directory contains the build file to generate a [Maven Bill of Material (BOM)](https://maven.apache.org/guides/introduction/introduction-to-dependency-mechanism.html#bill-of-materials-bom-poms) for all the ApproveJ modules, and - the [manual](manual) directory contains the projects documentation written in [AsciiDoc](https://docs.asciidoctor.org/asciidoc/latest/). diff --git a/bom/build.gradle.kts b/bom/build.gradle.kts index b3677879..c11bb4b6 100644 --- a/bom/build.gradle.kts +++ b/bom/build.gradle.kts @@ -8,7 +8,11 @@ repositories { mavenCentral() } dependencies { constraints { rootProject.subprojects - .filter { it != project && it.name != "manual" && it.subprojects.isEmpty() } + .filter { + it != project && + it.name !in listOf("approvej-gradle-plugin", "approvej-maven-plugin", "manual") && + it.subprojects.isEmpty() + } .sortedBy { it.name } .forEach { api(it) } diff --git a/build.gradle.kts b/build.gradle.kts index 1175036e..84453c47 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,7 +10,7 @@ repositories { mavenCentral() } subprojects { afterEvaluate { - if (plugins.hasPlugin("maven-publish")) { + if (plugins.hasPlugin("maven-publish") && !plugins.hasPlugin("com.gradle.plugin-publish")) { publishing { publications { create(name) { @@ -78,7 +78,10 @@ gradle.projectsEvaluated { mavenCentral { named("sonatype") { subprojects - .filter { it.plugins.hasPlugin("maven-publish") } + .filter { + it.plugins.hasPlugin("maven-publish") && + !it.plugins.hasPlugin("com.gradle.plugin-publish") + } .sortedBy { it.name } .forEach { stagingRepository("${it.projectDir.relativeTo(rootDir)}/build/staging-deploy") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 33204904..02f0d61d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ junit = "6.0.3" assertj = "3.27.7" jackson2 = "2.21.0" jackson3 = "3.0.4" +maven = "3.9.12" wiremock = "3.13.2" [libraries] @@ -21,6 +22,9 @@ junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.re junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" } junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version = "6.0.3" } +maven-core = { module = "org.apache.maven:maven-core", version.ref = "maven" } +maven-plugin-annotations = { module = "org.apache.maven.plugin-tools:maven-plugin-annotations", version = "3.15.2" } +maven-plugin-api = { module = "org.apache.maven:maven-plugin-api", version.ref = "maven" } spock = { module = "org.spockframework:spock-core", version = "2.4-M7-groovy-5.0" } testng = { module = "org.testng:testng", version = "7.12.0" } wiremock = { module = "org.wiremock:wiremock", version.ref = "wiremock" } @@ -30,5 +34,7 @@ asciidoctor = { id = "org.asciidoctor.jvm.convert", version = "4.0.5" } asciidoctor-pdf = { id = "org.asciidoctor.jvm.pdf", version = "4.0.5" } jreleaser = { id = "org.jreleaser", version = "1.20.0" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version = "2.3.10" } +maven-plugin-development = { id = "org.gradlex.maven-plugin-development", version = "1.0.3" } +plugin-publish = { id = "com.gradle.plugin-publish", version = "2.0.0" } sonar = { id = "org.sonarqube", version = "7.2.2.6593" } spotless = { id = "com.diffplug.spotless", version = "7.2.1" } diff --git a/manual/src/docs/asciidoc/chapters/10-cleanup.adoc b/manual/src/docs/asciidoc/chapters/10-cleanup.adoc new file mode 100644 index 00000000..583bc53c --- /dev/null +++ b/manual/src/docs/asciidoc/chapters/10-cleanup.adoc @@ -0,0 +1,151 @@ +[id=cleanup] += Cleaning Up Leftover Approved Files + + +[id="cleanup_problem"] +== The Problem + +When you rename or delete a test method, its approved file stays behind on disk. +Over time these leftover files accumulate and clutter the repository. + +ApproveJ provides an inventory-based mechanism to detect and remove leftover approved files automatically. + + +[id="cleanup_inventory"] +== The Approved File Inventory + +Every time a test calls `byFile()`, ApproveJ records the approved file path and the originating test method. +At the end of the test run, all entries are merged into a project-level inventory file: + +---- +.approvej/inventory.properties +---- + +The file uses Java Properties format. +Each entry maps a relative file path to the test reference that created it: + +[source,properties] +---- +src/test/resources/MyTest-myTest-approved.txt = com.example.MyTest#myTest +---- + +TIP: Commit `.approvej/inventory.properties` to version control so that the inventory is shared across the team. + +The inventory is updated incrementally. +Only entries for test methods that ran in the current execution are refreshed. +Entries for tests that did not run are preserved. + + +[id="cleanup_configuration"] +== Enabling the Inventory + +The inventory is controlled by the `inventoryEnabled` property. + +|=== +|Environment |Default + +|Local development (no `CI` environment variable) +|`true` + +|CI (the `CI` environment variable is set) +|`false` +|=== + +You can override this default in any <>: + +.`src/test/resources/approvej.properties` +[source,properties] +---- +inventoryEnabled = true +---- + +Or via environment variable: + +[source,bash] +---- +export APPROVEJ_INVENTORY_ENABLED=false +---- + + +[id="cleanup_finding_leftovers"] +== Finding and Removing Leftovers + +After running your tests with the inventory enabled, use the build plugin to detect leftovers. +An approved file is considered a leftover when its originating test method no longer exists in the compiled test classes. + + +[id="cleanup_gradle"] +=== Gradle Plugin + +Apply the plugin in your build file: + +.Gradle +[source,groovy,subs=attributes+,role="primary"] +---- +plugins { + id 'org.approvej' version '{revnumber}' +} +---- +.Gradle.kts +[source,kotlin,subs=attributes+,role="secondary"] +---- +plugins { + id("org.approvej") version "{revnumber}" +} +---- + +The plugin registers two tasks in the `verification` group: + +[cols="1,2"] +|=== +|Task |Description + +|`./gradlew approvejFindLeftovers` +|Lists leftover approved files without deleting them + +|`./gradlew approvejCleanup` +|Detects and removes leftover approved files +|=== + +NOTE: Both tasks require the Java plugin to be applied in the same project. +They use the test runtime classpath to resolve test classes. + + +[id="cleanup_maven"] +=== Maven Plugin + +Add the plugin to your `pom.xml`: + +[source,xml,subs=attributes+] +---- + + org.approvej + approvej-maven-plugin + {revnumber} + +---- + +The plugin provides two goals: + +[cols="1,2"] +|=== +|Goal |Description + +|`mvn approvej:find-leftovers` +|Lists leftover approved files without deleting them + +|`mvn approvej:cleanup` +|Detects and removes leftover approved files +|=== + + +[id="cleanup_workflow"] +== Typical Workflow + +1. Run your tests locally so the inventory is populated. +2. Run the find-leftovers task/goal to see which approved files are leftovers. +3. Run the cleanup task/goal to delete the leftover files. +4. Commit the updated inventory and the removal of leftover files. + +WARNING: Only approved files that have been recorded in the inventory can be detected as leftovers. +Make sure you have run all relevant tests with the inventory enabled at least once before cleaning up. diff --git a/manual/src/docs/asciidoc/chapters/10-configuration.adoc b/manual/src/docs/asciidoc/chapters/11-configuration.adoc similarity index 96% rename from manual/src/docs/asciidoc/chapters/10-configuration.adoc rename to manual/src/docs/asciidoc/chapters/11-configuration.adoc index fcc30a5c..2a20d7f9 100644 --- a/manual/src/docs/asciidoc/chapters/10-configuration.adoc +++ b/manual/src/docs/asciidoc/chapters/11-configuration.adoc @@ -45,6 +45,11 @@ Can be an alias (e.g., `none`, `automatic`), a fully-qualified class name, or a |The script to execute for reviewing differences. This implicitly sets the `defaultFileReviewer` to script |_(none)_ + +|`inventoryEnabled` +|Whether the approved file inventory is enabled. +When enabled, ApproveJ records approved file paths in `.approvej/inventory.properties` during test runs. +|`true` locally, `false` in CI |=== @@ -121,6 +126,9 @@ Environment variables use the `APPROVEJ_` prefix and convert camelCase property |`defaultFileReviewer` |`APPROVEJ_DEFAULT_FILE_REVIEWER` + +|`inventoryEnabled` +|`APPROVEJ_INVENTORY_ENABLED` |=== .Example: Set default print format via environment variable diff --git a/manual/src/docs/asciidoc/chapters/11-cheat-sheet.adoc b/manual/src/docs/asciidoc/chapters/12-cheat-sheet.adoc similarity index 93% rename from manual/src/docs/asciidoc/chapters/11-cheat-sheet.adoc rename to manual/src/docs/asciidoc/chapters/12-cheat-sheet.adoc index e7e336c9..c7869a4f 100644 --- a/manual/src/docs/asciidoc/chapters/11-cheat-sheet.adoc +++ b/manual/src/docs/asciidoc/chapters/12-cheat-sheet.adoc @@ -277,6 +277,26 @@ endif::[] |=== +== Cleanup Tasks + +[cols="2,3"] +|=== +|Command |Description + +|`./gradlew approvejFindLeftovers` +|List leftover approved files (Gradle) + +|`./gradlew approvejCleanup` +|Remove leftover approved files (Gradle) + +|`mvn approvej:find-leftovers` +|List leftover approved files (Maven) + +|`mvn approvej:cleanup` +|Remove leftover approved files (Maven) +|=== + + == Configuration Properties [cols="2,2,1"] @@ -294,6 +314,10 @@ endif::[] |`defaultFileReviewerScript` |`APPROVEJ_DEFAULT_FILE_REVIEWER_SCRIPT` |_(none)_ + +|`inventoryEnabled` +|`APPROVEJ_INVENTORY_ENABLED` +|`true` locally, `false` in CI |=== Configuration is resolved in priority order: environment variables > project properties (`src/test/resources/approvej.properties`) > user home properties (`~/.config/approvej/approvej.properties`) > defaults. diff --git a/manual/src/docs/asciidoc/index.adoc b/manual/src/docs/asciidoc/index.adoc index e988a841..bb36b2a9 100644 --- a/manual/src/docs/asciidoc/index.adoc +++ b/manual/src/docs/asciidoc/index.adoc @@ -21,5 +21,6 @@ include::chapters/06-reviewing.adoc[leveloffset=1] include::chapters/07-json-jackson.adoc[leveloffset=1] include::chapters/08-yaml-jackson.adoc[leveloffset=1] include::chapters/09-http.adoc[leveloffset=1] -include::chapters/10-configuration.adoc[leveloffset=1] -include::chapters/11-cheat-sheet.adoc[leveloffset=1] +include::chapters/10-cleanup.adoc[leveloffset=1] +include::chapters/11-configuration.adoc[leveloffset=1] +include::chapters/12-cheat-sheet.adoc[leveloffset=1] diff --git a/modules/core/src/main/java/org/approvej/ApprovalBuilder.java b/modules/core/src/main/java/org/approvej/ApprovalBuilder.java index 4b8b7d06..e40eb905 100644 --- a/modules/core/src/main/java/org/approvej/ApprovalBuilder.java +++ b/modules/core/src/main/java/org/approvej/ApprovalBuilder.java @@ -11,6 +11,7 @@ import java.nio.file.Path; import java.util.function.Function; import java.util.function.UnaryOperator; +import org.approvej.approve.ApprovedFileInventory; import org.approvej.approve.Approver; import org.approvej.approve.PathProvider; import org.approvej.approve.PathProviders; @@ -201,6 +202,9 @@ public void byValue(final String previouslyApproved) { public void byFile(PathProvider pathProvider) { PathProvider updatedPathProvider = pathProvider.filenameAffix(name).filenameExtension(filenameExtension); + if (configuration.inventoryEnabled()) { + ApprovedFileInventory.registerApprovedFile(updatedPathProvider); + } if (!(value instanceof String)) { printed().byFile(updatedPathProvider); return; diff --git a/modules/core/src/main/java/org/approvej/approve/ApprovedFileInventory.java b/modules/core/src/main/java/org/approvej/approve/ApprovedFileInventory.java new file mode 100644 index 00000000..eb3b23b9 --- /dev/null +++ b/modules/core/src/main/java/org/approvej/approve/ApprovedFileInventory.java @@ -0,0 +1,246 @@ +package org.approvej.approve; + +import static java.util.Arrays.stream; +import static java.util.stream.Collectors.joining; + +import java.io.BufferedReader; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; +import java.util.TreeMap; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Tracks approved files in an inventory so that leftover files (from renamed or deleted tests) can + * be detected and cleaned up. + * + *

During a test run, each {@code byFile()} call records the approved file path and its + * originating test method. At JVM shutdown, the inventory is merged with any existing inventory + * file and written to {@code .approvej/inventory.properties}. + */ +@NullMarked +public class ApprovedFileInventory { + + private static final Path DEFAULT_INVENTORY_FILE = Path.of(".approvej/inventory.properties"); + private static final String HEADER = + "# ApproveJ Approved File Inventory (auto-generated, do not edit)"; + + private static final ConcurrentHashMap entries = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap executedMethods = + new ConcurrentHashMap<>(); + private static final AtomicReference<@Nullable Thread> shutdownHook = new AtomicReference<>(); + + private static final AtomicReference inventoryFile = + new AtomicReference<>(DEFAULT_INVENTORY_FILE); + + private ApprovedFileInventory() {} + + /** + * Records an approved file path in the inventory. + * + * @param pathProvider the path provider for the approved file + */ + public static void registerApprovedFile(PathProvider pathProvider) { + TestMethod testMethod; + try { + testMethod = StackTraceTestFinderUtil.currentTestMethod(); + } catch (TestMethodNotFoundInStackTraceError e) { + return; + } + + String testReference = + "%s#%s".formatted(testMethod.testClass().getName(), testMethod.testCaseName()); + + addEntry(pathProvider.approvedPath(), testReference); + + Thread hook = new Thread(ApprovedFileInventory::writeInventory, "ApproveJ-Inventory-Writer"); + if (shutdownHook.compareAndSet(null, hook)) { + Runtime.getRuntime().addShutdownHook(hook); + } + } + + static void writeInventory() { + TreeMap merged = loadInventory(); + + merged + .entrySet() + .removeIf(entry -> executedMethods.containsKey(entry.getValue().testReference())); + merged.putAll(entries); + + saveInventory(merged); + } + + /** + * Finds leftover inventory entries whose test methods no longer exist. + * + * @return a list of leftover inventory entries + */ + static List findLeftovers() { + return loadInventory().values().stream().filter(ApprovedFileInventory::isLeftover).toList(); + } + + private static boolean isLeftover(InventoryEntry entry) { + try { + return stream(Class.forName(entry.className()).getDeclaredMethods()) + .noneMatch(method -> method.getName().equals(entry.methodName())); + } catch (ClassNotFoundException e) { + return true; + } + } + + /** + * Removes leftover approved files and updates the inventory. + * + * @return the list of removed leftover entries + */ + static List removeLeftovers() { + List leftovers = findLeftovers(); + if (leftovers.isEmpty()) { + return leftovers; + } + + TreeMap inventory = loadInventory(); + List removed = new ArrayList<>(); + for (InventoryEntry leftover : leftovers) { + try { + Files.deleteIfExists(leftover.relativePath()); + inventory.remove(leftover.relativePath()); + removed.add(leftover); + } catch (IOException e) { + System.err.printf("Failed to delete leftover file: %s%n", leftover.relativePath()); + } + } + + saveInventory(inventory); + + return removed; + } + + static TreeMap loadInventory() { + TreeMap result = new TreeMap<>(); + Path inventoryPath = inventoryFile.get(); + if (!Files.exists(inventoryPath)) { + return result; + } + Properties properties = new Properties(); + try (BufferedReader reader = Files.newBufferedReader(inventoryPath)) { + properties.load(reader); + } catch (IOException e) { + System.err.printf("Failed to read inventory file: %s%n", e.getMessage()); + return result; + } + properties + .stringPropertyNames() + .forEach( + key -> { + Path path = Path.of(key); + result.put(path, new InventoryEntry(path, properties.getProperty(key))); + }); + return result; + } + + private static void saveInventory(TreeMap inventory) { + Path inventoryPath = inventoryFile.get(); + try { + if (inventory.isEmpty()) { + Files.deleteIfExists(inventoryPath); + } else { + Files.createDirectories(inventoryPath.getParent()); + String content = + "%s%n%s" + .formatted( + HEADER, + inventory.values().stream() + .map( + e -> + "%s = %s" + .formatted( + escapeKey(e.relativePath().toString()), e.testReference())) + .collect(joining("\n", "", "\n"))); + Files.writeString(inventoryPath, content); + } + } catch (IOException e) { + System.err.printf("Failed to write inventory file: %s%n", e.getMessage()); + } + } + + private static String escapeKey(String key) { + return key.replace("\\", "\\\\").replace(" ", "\\ ").replace("=", "\\=").replace(":", "\\:"); + } + + /** Adds an entry directly. For testing only. */ + static void addEntry(Path relativePath, String testReference) { + entries.put(relativePath, new InventoryEntry(relativePath, testReference)); + executedMethods.put(testReference, Boolean.TRUE); + } + + /** Resets static state and sets the inventory file path. For testing only. */ + static void reset(Path testInventoryFile) { + entries.clear(); + executedMethods.clear(); + Thread hook = shutdownHook.getAndSet(null); + if (hook != null) { + try { + Runtime.getRuntime().removeShutdownHook(hook); + } catch (IllegalStateException e) { + // JVM is already shutting down + } + } + inventoryFile.set(testInventoryFile); + } + + /** Resets static state to defaults. For testing only. */ + static void reset() { + reset(DEFAULT_INVENTORY_FILE); + } + + /** + * CLI entry point for build tool plugins. + * + * @param args {@code --find} to list leftovers, {@code --remove} to delete them + */ + public static void main(String[] args) { + String usage = "Usage: ApprovedFileInventory --find | --remove"; + if (args.length == 0) { + System.err.println(usage); + System.exit(1); + } + + String command = args[0]; + switch (command) { + case "--find" -> { + List leftovers = findLeftovers(); + if (leftovers.isEmpty()) { + System.out.println("No leftover approved files found."); + } else { + System.out.println("Leftover approved files:"); + leftovers.forEach( + leftover -> + System.out.printf( + " %s%n from %s%n", + leftover.relativePath().toUri(), leftover.testReference())); + } + } + case "--remove" -> { + List removed = removeLeftovers(); + if (removed.isEmpty()) { + System.out.println("No leftover approved files found."); + } else { + System.out.println("Removed leftover approved files:"); + removed.forEach(leftover -> System.out.printf(" %s%n", leftover.relativePath().toUri())); + } + } + default -> { + System.err.printf("Unknown command: %s%n", command); + System.err.println(usage); + System.exit(1); + } + } + } +} diff --git a/modules/core/src/main/java/org/approvej/approve/FileApprover.java b/modules/core/src/main/java/org/approvej/approve/FileApprover.java index f0997f22..2351dc6c 100644 --- a/modules/core/src/main/java/org/approvej/approve/FileApprover.java +++ b/modules/core/src/main/java/org/approvej/approve/FileApprover.java @@ -77,7 +77,7 @@ private void handleOldApprovedFiles() { paths .filter( path -> - !path.toAbsolutePath().equals(approvedPath) + !path.normalize().equals(approvedPath) && baseFilenamePattern.matcher(path.getFileName().toString()).matches()) .sorted( comparing( diff --git a/modules/core/src/main/java/org/approvej/approve/InventoryEntry.java b/modules/core/src/main/java/org/approvej/approve/InventoryEntry.java new file mode 100644 index 00000000..b25e1a28 --- /dev/null +++ b/modules/core/src/main/java/org/approvej/approve/InventoryEntry.java @@ -0,0 +1,30 @@ +package org.approvej.approve; + +import java.nio.file.Path; +import org.jspecify.annotations.NullMarked; + +/** An entry in the approved file inventory, mapping a file path to its originating test method. */ +@NullMarked +record InventoryEntry(Path relativePath, String className, String methodName) { + + InventoryEntry(Path relativePath, String testReference) { + this(relativePath, parseClassName(testReference), parseMethodName(testReference)); + } + + String testReference() { + return "%s#%s".formatted(className, methodName); + } + + private static String parseClassName(String testReference) { + int hashIndex = testReference.indexOf('#'); + if (hashIndex < 0) { + throw new IllegalArgumentException( + "Invalid test reference (expected 'className#methodName'): %s".formatted(testReference)); + } + return testReference.substring(0, hashIndex); + } + + private static String parseMethodName(String testReference) { + return testReference.substring(testReference.indexOf('#') + 1); + } +} diff --git a/modules/core/src/main/java/org/approvej/approve/PathProvider.java b/modules/core/src/main/java/org/approvej/approve/PathProvider.java index c65172b0..eabb2ed5 100644 --- a/modules/core/src/main/java/org/approvej/approve/PathProvider.java +++ b/modules/core/src/main/java/org/approvej/approve/PathProvider.java @@ -82,7 +82,7 @@ public PathProvider filenameExtension(String filenameExtension) { * {@link #baseFilename}, followed by {@link #approvedLabel} (if any), followed by the {@link * #filenameExtension} (if any). * - * @return the absolute and normalized {@link Path} to the approved file + * @return the normalized {@link Path} to the approved file */ public Path approvedPath() { return directory @@ -93,7 +93,6 @@ public Path approvedPath() { filenameAffix.isBlank() ? "" : "-%s".formatted(filenameAffix), approvedLabel.isBlank() ? "" : "-%s".formatted(approvedLabel), filenameExtension.isBlank() ? "" : ".%s".formatted(filenameExtension))) - .toAbsolutePath() .normalize(); } @@ -102,7 +101,7 @@ public Path approvedPath() { * {@link #baseFilename}, followed by {@value RECEIVED} (if any), followed by the {@link * #filenameExtension} (if any). * - * @return the absolute and normalized {@link Path} to the received file + * @return the normalized {@link Path} to the received file */ public Path receivedPath() { return directory @@ -113,7 +112,6 @@ public Path receivedPath() { filenameAffix.isBlank() ? "" : "-%s".formatted(filenameAffix), "-%s".formatted(RECEIVED), filenameExtension.isBlank() ? "" : ".%s".formatted(filenameExtension))) - .toAbsolutePath() .normalize(); } } diff --git a/modules/core/src/main/java/org/approvej/approve/StackTraceTestFinderUtil.java b/modules/core/src/main/java/org/approvej/approve/StackTraceTestFinderUtil.java index 9c09bd8b..80186037 100644 --- a/modules/core/src/main/java/org/approvej/approve/StackTraceTestFinderUtil.java +++ b/modules/core/src/main/java/org/approvej/approve/StackTraceTestFinderUtil.java @@ -77,7 +77,6 @@ public static Path findTestSourcePath(Method testMethod) { attributes.isRegularFile() && path.normalize().toString().matches(pathRegex))) { return pathStream .findFirst() - .map(Path::toAbsolutePath) .map(Path::normalize) .orElseThrow(() -> new FileApproverError("Could not locate test source file")); } catch (IOException e) { diff --git a/modules/core/src/main/java/org/approvej/configuration/Configuration.java b/modules/core/src/main/java/org/approvej/configuration/Configuration.java index 23cfce7d..596b514b 100644 --- a/modules/core/src/main/java/org/approvej/configuration/Configuration.java +++ b/modules/core/src/main/java/org/approvej/configuration/Configuration.java @@ -31,14 +31,18 @@ * @param defaultPrintFormat the {@link PrintFormat} that will be used if none is specified * otherwise * @param defaultFileReviewer the {@link FileReviewer} that will be used if none is specified + * @param inventoryEnabled whether the approved file inventory is enabled */ @NullMarked public record Configuration( - PrintFormat defaultPrintFormat, FileReviewer defaultFileReviewer) { + PrintFormat defaultPrintFormat, + FileReviewer defaultFileReviewer, + boolean inventoryEnabled) { private static final String DEFAULT_PRINT_FORMAT_PROPERTY = "defaultPrintFormat"; private static final String DEFAULT_FILE_REVIEWER_PROPERTY = "defaultFileReviewer"; private static final String DEFAULT_FILE_REVIEWER_SCRIPT_PROPERTY = "defaultFileReviewerScript"; + private static final String INVENTORY_ENABLED_PROPERTY = "inventoryEnabled"; /** The loaded {@link Configuration} object. */ public static final Configuration configuration = @@ -50,7 +54,9 @@ static Configuration loadConfiguration(ConfigurationLoader loader) { FileReviewer fileReviewer = resolveFileReviewer(loader); - return new Configuration(printFormat, fileReviewer); + boolean inventoryEnabled = resolveInventoryEnabled(loader); + + return new Configuration(printFormat, fileReviewer, inventoryEnabled); } @SuppressWarnings("unchecked") @@ -69,4 +75,13 @@ private static FileReviewer resolveFileReviewer(ConfigurationLoader loader) { return Registry.resolve(loader.get(DEFAULT_FILE_REVIEWER_PROPERTY, "none"), FileReviewer.class); } + + private static boolean resolveInventoryEnabled(ConfigurationLoader loader) { + String configured = loader.get(INVENTORY_ENABLED_PROPERTY); + if (configured != null) { + return Boolean.parseBoolean(configured); + } + String ci = loader.getenv("CI"); + return ci == null || ci.isBlank(); + } } diff --git a/modules/core/src/main/java/org/approvej/configuration/ConfigurationLoader.java b/modules/core/src/main/java/org/approvej/configuration/ConfigurationLoader.java index 58609d4b..25531ceb 100644 --- a/modules/core/src/main/java/org/approvej/configuration/ConfigurationLoader.java +++ b/modules/core/src/main/java/org/approvej/configuration/ConfigurationLoader.java @@ -18,9 +18,12 @@ final class ConfigurationLoader { private static final String ENV_PREFIX = "APPROVEJ_"; private final List sources; + private final Function envLookup; - private ConfigurationLoader(List sources) { + private ConfigurationLoader( + List sources, Function envLookup) { this.sources = List.copyOf(sources); + this.envLookup = envLookup; } @Nullable String get(String key) { @@ -33,6 +36,11 @@ private ConfigurationLoader(List sources) { return null; } + /** Looks up a raw environment variable by its exact name (no prefix transformation). */ + @Nullable String getenv(String name) { + return envLookup.apply(name); + } + String get(String key, String defaultValue) { String value = get(key); if (value == null) { @@ -66,12 +74,14 @@ static String toEnvironmentVariableName(String propertyName) { static final class Builder { private final List sources = new ArrayList<>(); + private Function envLookup = key -> null; Builder withEnvironmentVariables() { return withEnvironmentVariables(System::getenv); } Builder withEnvironmentVariables(Function envLookup) { + this.envLookup = envLookup; sources.add(key -> envLookup.apply(toEnvironmentVariableName(key))); return this; } @@ -112,7 +122,7 @@ Builder withUserHomeProperties() { } ConfigurationLoader build() { - return new ConfigurationLoader(sources); + return new ConfigurationLoader(sources, envLookup); } } } diff --git a/modules/core/src/spock/groovy/org/approvej/approve/StackTraceTestFinderUtilSpockSpec.groovy b/modules/core/src/spock/groovy/org/approvej/approve/StackTraceTestFinderUtilSpockSpec.groovy index 06a4dda0..4b7934ac 100644 --- a/modules/core/src/spock/groovy/org/approvej/approve/StackTraceTestFinderUtilSpockSpec.groovy +++ b/modules/core/src/spock/groovy/org/approvej/approve/StackTraceTestFinderUtilSpockSpec.groovy @@ -32,7 +32,7 @@ class StackTraceTestFinderUtilSpockSpec extends Specification { StackTraceTestFinderUtil.currentTestMethod().method()) then: - testSourcePath == thisTestSourcePath.toAbsolutePath().normalize() + testSourcePath == thisTestSourcePath.normalize() } def 'findTestSourcePath_file_in_build'() { @@ -50,7 +50,7 @@ class StackTraceTestFinderUtilSpockSpec extends Specification { StackTraceTestFinderUtil.currentTestMethod().method()) then: - testSourcePath == thisTestSourcePath.toAbsolutePath().normalize() + testSourcePath == thisTestSourcePath.normalize() cleanup: delete(wrongTestSourcePath) @@ -69,7 +69,7 @@ class StackTraceTestFinderUtilSpockSpec extends Specification { Path testSourcePath = StackTraceTestFinderUtil.findTestSourcePath(StackTraceTestFinderUtil.currentTestMethod().method()) then: - testSourcePath == thisTestSourcePath.toAbsolutePath().normalize() + testSourcePath == thisTestSourcePath.normalize() cleanup: delete(wrongTestSourcePath) diff --git a/modules/core/src/test/java/org/approvej/approve/ApprovedFileInventoryTest.java b/modules/core/src/test/java/org/approvej/approve/ApprovedFileInventoryTest.java new file mode 100644 index 00000000..9a17fb5f --- /dev/null +++ b/modules/core/src/test/java/org/approvej/approve/ApprovedFileInventoryTest.java @@ -0,0 +1,191 @@ +package org.approvej.approve; + +import static java.nio.file.Files.writeString; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.List; +import java.util.TreeMap; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ApprovedFileInventoryTest { + + @TempDir private Path tempDir; + + private Path inventoryFile; + + @BeforeEach + void setUp() { + inventoryFile = tempDir.resolve("inventory.properties"); + ApprovedFileInventory.reset(inventoryFile); + } + + @AfterEach + void tearDown() { + ApprovedFileInventory.reset(); + } + + @Test + void writeInventory() { + ApprovedFileInventory.addEntry( + Path.of("src/test/MyTest-myTest-approved.txt"), "com.example.MyTest#myTest"); + + ApprovedFileInventory.writeInventory(); + + TreeMap inventory = ApprovedFileInventory.loadInventory(); + assertThat(inventory.values()) + .containsExactly( + new InventoryEntry( + Path.of("src/test/MyTest-myTest-approved.txt"), "com.example.MyTest#myTest")); + } + + @Test + void writeInventory_multiple_entries() { + ApprovedFileInventory.addEntry(Path.of("src/test/BTest-b-approved.txt"), "com.example.BTest#b"); + ApprovedFileInventory.addEntry(Path.of("src/test/ATest-a-approved.txt"), "com.example.ATest#a"); + + ApprovedFileInventory.writeInventory(); + + TreeMap inventory = ApprovedFileInventory.loadInventory(); + assertThat(inventory).hasSize(2); + assertThat(inventory.firstKey()).isEqualTo(Path.of("src/test/ATest-a-approved.txt")); + } + + @Test + void writeInventory_named_approvals() { + ApprovedFileInventory.addEntry( + Path.of("src/test/MyTest-myTest-alpha-approved.txt"), "com.example.MyTest#myTest"); + ApprovedFileInventory.addEntry( + Path.of("src/test/MyTest-myTest-beta-approved.txt"), "com.example.MyTest#myTest"); + + ApprovedFileInventory.writeInventory(); + + TreeMap inventory = ApprovedFileInventory.loadInventory(); + assertThat(inventory.values()) + .hasSize(2) + .contains( + new InventoryEntry( + Path.of("src/test/MyTest-myTest-alpha-approved.txt"), "com.example.MyTest#myTest"), + new InventoryEntry( + Path.of("src/test/MyTest-myTest-beta-approved.txt"), "com.example.MyTest#myTest")); + } + + @Test + void writeInventory_replaces_entries_for_executed_methods() throws IOException { + writeString( + inventoryFile, + """ + # ApproveJ Approved File Inventory (auto-generated, do not edit) + src/test/MyTest-myTest-alpha-approved.txt = com.example.MyTest#myTest + src/test/MyTest-myTest-beta-approved.txt = com.example.MyTest#myTest + """, + StandardOpenOption.CREATE); + + ApprovedFileInventory.addEntry( + Path.of("src/test/MyTest-myTest-gamma-approved.txt"), "com.example.MyTest#myTest"); + ApprovedFileInventory.addEntry( + Path.of("src/test/MyTest-myTest-beta-approved.txt"), "com.example.MyTest#myTest"); + + ApprovedFileInventory.writeInventory(); + + TreeMap inventory = ApprovedFileInventory.loadInventory(); + assertThat(inventory.values()) + .hasSize(2) + .contains( + new InventoryEntry( + Path.of("src/test/MyTest-myTest-gamma-approved.txt"), "com.example.MyTest#myTest"), + new InventoryEntry( + Path.of("src/test/MyTest-myTest-beta-approved.txt"), "com.example.MyTest#myTest")); + assertThat(inventory).doesNotContainKey(Path.of("src/test/MyTest-myTest-alpha-approved.txt")); + } + + @Test + void writeInventory_preserves_entries_for_unexecuted_methods() throws IOException { + writeString( + inventoryFile, + """ + # ApproveJ Approved File Inventory (auto-generated, do not edit) + src/test/OtherTest-other-approved.txt = com.example.OtherTest#other + """, + StandardOpenOption.CREATE); + + ApprovedFileInventory.addEntry( + Path.of("src/test/MyTest-myTest-approved.txt"), "com.example.MyTest#myTest"); + + ApprovedFileInventory.writeInventory(); + + TreeMap inventory = ApprovedFileInventory.loadInventory(); + assertThat(inventory.values()) + .hasSize(2) + .contains( + new InventoryEntry( + Path.of("src/test/OtherTest-other-approved.txt"), "com.example.OtherTest#other"), + new InventoryEntry( + Path.of("src/test/MyTest-myTest-approved.txt"), "com.example.MyTest#myTest")); + } + + @Test + void findLeftovers() throws IOException { + writeString( + inventoryFile, + """ + # ApproveJ Approved File Inventory (auto-generated, do not edit) + src/test/NonExistent-test-approved.txt = com.nonexistent.NonExistentTest#test + """, + StandardOpenOption.CREATE); + + List leftovers = ApprovedFileInventory.findLeftovers(); + + assertThat(leftovers).hasSize(1); + assertThat(leftovers.getFirst().relativePath()) + .isEqualTo(Path.of("src/test/NonExistent-test-approved.txt")); + assertThat(leftovers.getFirst().testReference()) + .isEqualTo("com.nonexistent.NonExistentTest#test"); + } + + @Test + void findLeftovers_missing_method() throws IOException { + writeString( + inventoryFile, + """ + # ApproveJ Approved File Inventory (auto-generated, do not edit) + src/test/ApprovedFileInventoryTest-nonExistentMethod-approved.txt = org.approvej.approve.ApprovedFileInventoryTest#nonExistentMethod + """, + StandardOpenOption.CREATE); + + List leftovers = ApprovedFileInventory.findLeftovers(); + + assertThat(leftovers).hasSize(1); + assertThat(leftovers.getFirst().testReference()) + .isEqualTo("org.approvej.approve.ApprovedFileInventoryTest#nonExistentMethod"); + } + + @Test + void removeLeftovers() throws IOException { + Path leftoverFile = tempDir.resolve("leftover-approved.txt"); + writeString(leftoverFile, "old content", StandardOpenOption.CREATE); + + Path validFile = tempDir.resolve("valid-approved.txt"); + + writeString( + inventoryFile, + "# ApproveJ Approved File Inventory (auto-generated, do not edit)\n" + + leftoverFile + + " = com.nonexistent.NonExistentTest#test\n" + + validFile + + " = org.approvej.approve.ApprovedFileInventoryTest#removeLeftovers\n", + StandardOpenOption.CREATE); + + List removed = ApprovedFileInventory.removeLeftovers(); + + assertThat(removed).hasSize(1); + assertThat(leftoverFile).doesNotExist(); + TreeMap inventory = ApprovedFileInventory.loadInventory(); + assertThat(inventory).doesNotContainKey(leftoverFile).containsKey(validFile); + } +} diff --git a/modules/core/src/test/java/org/approvej/approve/PathProviderTest.java b/modules/core/src/test/java/org/approvej/approve/PathProviderTest.java index 9db9dc76..d4b9c06d 100644 --- a/modules/core/src/test/java/org/approvej/approve/PathProviderTest.java +++ b/modules/core/src/test/java/org/approvej/approve/PathProviderTest.java @@ -16,13 +16,10 @@ void constructor_blank_approvedLabel() { assertThat(new PathProvider(Path.of("./src/test/resources/"), "base", "affix", "", "xml")) .hasFieldOrPropertyWithValue( "approvedPath", - Path.of("./src/test/resources/" + "base" + "-affix" + ".xml") - .toAbsolutePath() - .normalize()) + Path.of("./src/test/resources/" + "base" + "-affix" + ".xml").normalize()) .hasFieldOrPropertyWithValue( "receivedPath", Path.of("./src/test/resources/" + "base" + "-affix" + "-received" + ".xml") - .toAbsolutePath() .normalize()); } @@ -30,15 +27,10 @@ void constructor_blank_approvedLabel() { void directory() { assertThat(pathProvider.directory(Path.of("/tmp/"))) .hasFieldOrPropertyWithValue( - "approvedPath", - Path.of("/tmp/" + "base" + "-affix" + "-approved" + ".xml") - .toAbsolutePath() - .normalize()) + "approvedPath", Path.of("/tmp/" + "base" + "-affix" + "-approved" + ".xml").normalize()) .hasFieldOrPropertyWithValue( "receivedPath", - Path.of("/tmp/" + "base" + "-affix" + "-received" + ".xml") - .toAbsolutePath() - .normalize()); + Path.of("/tmp/" + "base" + "-affix" + "-received" + ".xml").normalize()); } @Test @@ -47,12 +39,10 @@ void filenameAffix() { .hasFieldOrPropertyWithValue( "approvedPath", Path.of("./src/test/resources/" + "base" + "-special" + "-approved" + ".xml") - .toAbsolutePath() .normalize()) .hasFieldOrPropertyWithValue( "receivedPath", Path.of("./src/test/resources/" + "base" + "-special" + "-received" + ".xml") - .toAbsolutePath() .normalize()); } @@ -61,14 +51,10 @@ void filenameAffix_blank() { assertThat(pathProvider.filenameAffix(" ")) .hasFieldOrPropertyWithValue( "approvedPath", - Path.of("./src/test/resources/" + "base" + "-approved" + ".xml") - .toAbsolutePath() - .normalize()) + Path.of("./src/test/resources/" + "base" + "-approved" + ".xml").normalize()) .hasFieldOrPropertyWithValue( "receivedPath", - Path.of("./src/test/resources/" + "base" + "-received" + ".xml") - .toAbsolutePath() - .normalize()); + Path.of("./src/test/resources/" + "base" + "-received" + ".xml").normalize()); } @Test @@ -76,13 +62,10 @@ void filenameExtension() { assertThat(pathProvider.filenameExtension("yml")) .hasFieldOrPropertyWithValue( "approvedPath", - Path.of("./src/test/resources/" + "base" + "-affix" + "-approved" + ".yml") - .toAbsolutePath() - .normalize()) + Path.of("./src/test/resources/" + "base" + "-affix" + "-approved" + ".yml").normalize()) .hasFieldOrPropertyWithValue( "receivedPath", Path.of("./src/test/resources/" + "base" + "-affix" + "-received" + ".yml") - .toAbsolutePath() .normalize()); } @@ -91,14 +74,10 @@ void filenameExtension_blank() { assertThat(pathProvider.filenameExtension(" ")) .hasFieldOrPropertyWithValue( "approvedPath", - Path.of("./src/test/resources/" + "base" + "-affix" + "-approved") - .toAbsolutePath() - .normalize()) + Path.of("./src/test/resources/" + "base" + "-affix" + "-approved").normalize()) .hasFieldOrPropertyWithValue( "receivedPath", - Path.of("./src/test/resources/" + "base" + "-affix" + "-received") - .toAbsolutePath() - .normalize()); + Path.of("./src/test/resources/" + "base" + "-affix" + "-received").normalize()); } @Test @@ -114,7 +93,6 @@ void filenameExtension_default_blank() { + "-approved" + "." + DEFAULT_FILENAME_EXTENSION) - .toAbsolutePath() .normalize()) .hasFieldOrPropertyWithValue( "receivedPath", @@ -125,7 +103,6 @@ void filenameExtension_default_blank() { + "-received" + "." + DEFAULT_FILENAME_EXTENSION) - .toAbsolutePath() .normalize()); } @@ -136,13 +113,10 @@ void filenameExtension_default_already_set() { pathProviderFilenameExtensionAlreadySet.filenameExtension(DEFAULT_FILENAME_EXTENSION)) .hasFieldOrPropertyWithValue( "approvedPath", - Path.of("./src/test/resources/" + "base" + "-affix" + "-approved" + ".xml") - .toAbsolutePath() - .normalize()) + Path.of("./src/test/resources/" + "base" + "-affix" + "-approved" + ".xml").normalize()) .hasFieldOrPropertyWithValue( "receivedPath", Path.of("./src/test/resources/" + "base" + "-affix" + "-received" + ".xml") - .toAbsolutePath() .normalize()); } @@ -151,7 +125,6 @@ void approvedPath() { assertThat(pathProvider.approvedPath()) .isEqualTo( Path.of("./src/test/resources/" + "base" + "-affix" + "-approved" + ".xml") - .toAbsolutePath() .normalize()); } @@ -160,7 +133,6 @@ void receivedPath() { assertThat(pathProvider.receivedPath()) .isEqualTo( Path.of("./src/test/resources/" + "base" + "-affix" + "-received" + ".xml") - .toAbsolutePath() .normalize()); } } diff --git a/modules/core/src/test/java/org/approvej/approve/PathProvidersTest.java b/modules/core/src/test/java/org/approvej/approve/PathProvidersTest.java index 22fafe36..188a7aed 100644 --- a/modules/core/src/test/java/org/approvej/approve/PathProvidersTest.java +++ b/modules/core/src/test/java/org/approvej/approve/PathProvidersTest.java @@ -12,10 +12,9 @@ void approvedPath() { String giveApprovedPath = "./src/test/resources/some file"; PathProvider pathProvider = PathProviders.approvedPath(giveApprovedPath); - assertThat(pathProvider.approvedPath()) - .isEqualTo(Path.of(giveApprovedPath).toAbsolutePath().normalize()); + assertThat(pathProvider.approvedPath()).isEqualTo(Path.of(giveApprovedPath).normalize()); assertThat(pathProvider.receivedPath()) - .isEqualTo(Path.of("./src/test/resources/some file-received").toAbsolutePath().normalize()); + .isEqualTo(Path.of("./src/test/resources/some file-received").normalize()); } @Test @@ -29,7 +28,6 @@ void nextToTest() { + "PathProvidersTest" + "-nextToTest" + "-approved.txt") - .toAbsolutePath() .normalize()); assertThat(pathProvider.receivedPath()) .isEqualTo( @@ -38,7 +36,6 @@ void nextToTest() { + "PathProvidersTest" + "-nextToTest" + "-received.txt") - .toAbsolutePath() .normalize()); } @@ -52,7 +49,6 @@ void nextToTest_filenameExtension() { + "PathProvidersTest" + "-nextToTest_filenameExtension" + "-approved.json") - .toAbsolutePath() .normalize()); assertThat(pathProvider.receivedPath()) .isEqualTo( @@ -61,7 +57,6 @@ void nextToTest_filenameExtension() { + "PathProvidersTest" + "-nextToTest_filenameExtension" + "-received.json") - .toAbsolutePath() .normalize()); } @@ -75,7 +70,6 @@ void nextToTestInSubdirectory() { + "PathProvidersTest/" + "nextToTestInSubdirectory" + "-approved.txt") - .toAbsolutePath() .normalize()); assertThat(pathProvider.receivedPath()) .isEqualTo( @@ -84,7 +78,6 @@ void nextToTestInSubdirectory() { + "PathProvidersTest/" + "nextToTestInSubdirectory" + "-received.txt") - .toAbsolutePath() .normalize()); } @@ -96,13 +89,11 @@ void nextToTest_directory() { .isEqualTo( directory .resolve("PathProvidersTest" + "-nextToTest_directory" + "-approved.txt") - .toAbsolutePath() .normalize()); assertThat(pathProvider.receivedPath()) .isEqualTo( directory .resolve("PathProvidersTest" + "-nextToTest_directory" + "-received.txt") - .toAbsolutePath() .normalize()); } @@ -117,7 +108,6 @@ void nextToTest_filenameAffix() { + "-nextToTest_filenameAffix" + "-additional info" + "-approved.txt") - .toAbsolutePath() .normalize()); assertThat(pathProvider.receivedPath()) .isEqualTo( @@ -127,7 +117,6 @@ void nextToTest_filenameAffix() { + "-nextToTest_filenameAffix" + "-additional info" + "-received.txt") - .toAbsolutePath() .normalize()); } } diff --git a/modules/core/src/test/java/org/approvej/approve/StackTraceTestFinderUtilTest.java b/modules/core/src/test/java/org/approvej/approve/StackTraceTestFinderUtilTest.java index 190f62d9..1e9dd5e1 100644 --- a/modules/core/src/test/java/org/approvej/approve/StackTraceTestFinderUtilTest.java +++ b/modules/core/src/test/java/org/approvej/approve/StackTraceTestFinderUtilTest.java @@ -58,7 +58,7 @@ void findTestSourcePath() { StackTraceTestFinderUtil.findTestSourcePath( StackTraceTestFinderUtil.currentTestMethod().method()); - assertThat(testSourcePath).isEqualTo(thisTestSourcePath.toAbsolutePath().normalize()); + assertThat(testSourcePath).isEqualTo(thisTestSourcePath.normalize()); } @Test @@ -74,7 +74,7 @@ void findTestSourcePath_file_in_build() throws IOException { StackTraceTestFinderUtil.findTestSourcePath( StackTraceTestFinderUtil.currentTestMethod().method()); - assertThat(testSourcePath).isEqualTo(thisTestSourcePath.toAbsolutePath().normalize()); + assertThat(testSourcePath).isEqualTo(thisTestSourcePath.normalize()); } @Test @@ -90,6 +90,6 @@ void findTestSourcePath_file_in_target() throws IOException { StackTraceTestFinderUtil.findTestSourcePath( StackTraceTestFinderUtil.currentTestMethod().method()); - assertThat(testSourcePath).isEqualTo(thisTestSourcePath.toAbsolutePath().normalize()); + assertThat(testSourcePath).isEqualTo(thisTestSourcePath.normalize()); } } diff --git a/modules/core/src/test/java/org/approvej/configuration/ConfigurationTest.java b/modules/core/src/test/java/org/approvej/configuration/ConfigurationTest.java index bc460197..30d38f61 100644 --- a/modules/core/src/test/java/org/approvej/configuration/ConfigurationTest.java +++ b/modules/core/src/test/java/org/approvej/configuration/ConfigurationTest.java @@ -81,6 +81,53 @@ void loadConfiguration_invalidPrintFormatThrowsError() { .withMessageContaining("org.nonexistent.InvalidFormat"); } + @Test + void loadConfiguration_inventoryEnabled_defaults_to_true() { + ConfigurationLoader loader = ConfigurationLoader.builder().build(); + + Configuration config = Configuration.loadConfiguration(loader); + + assertThat(config.inventoryEnabled()).isTrue(); + } + + @Test + void loadConfiguration_inventoryEnabled_defaults_to_false_in_ci() { + Map env = Map.of("CI", "true"); + ConfigurationLoader loader = + ConfigurationLoader.builder().withEnvironmentVariables(env::get).build(); + + Configuration config = Configuration.loadConfiguration(loader); + + assertThat(config.inventoryEnabled()).isFalse(); + } + + @Test + void loadConfiguration_inventoryEnabled_from_properties() { + Properties props = new Properties(); + props.setProperty("inventoryEnabled", "false"); + ConfigurationLoader loader = ConfigurationLoader.builder().withProperties(props).build(); + + Configuration config = Configuration.loadConfiguration(loader); + + assertThat(config.inventoryEnabled()).isFalse(); + } + + @Test + void loadConfiguration_inventoryEnabled_env_overrides_properties() { + Map env = Map.of("APPROVEJ_INVENTORY_ENABLED", "false"); + Properties props = new Properties(); + props.setProperty("inventoryEnabled", "true"); + ConfigurationLoader loader = + ConfigurationLoader.builder() + .withEnvironmentVariables(env::get) + .withProperties(props) + .build(); + + Configuration config = Configuration.loadConfiguration(loader); + + assertThat(config.inventoryEnabled()).isFalse(); + } + @Test void configurationLoader_priorityChain() { // Simulate: env > project props > user home props @@ -129,16 +176,6 @@ void configurationLoader_envOverridesAll() { assertThat(config.defaultPrintFormat()).isInstanceOf(SingleLineStringPrintFormat.class); } - @Test - void toEnvironmentVariableName() { - assertThat(ConfigurationLoader.toEnvironmentVariableName("defaultPrintFormat")) - .isEqualTo("APPROVEJ_DEFAULT_PRINT_FORMAT"); - assertThat(ConfigurationLoader.toEnvironmentVariableName("timeout")) - .isEqualTo("APPROVEJ_TIMEOUT"); - assertThat(ConfigurationLoader.toEnvironmentVariableName("maxRetryCount")) - .isEqualTo("APPROVEJ_MAX_RETRY_COUNT"); - } - @Test void configurationLoader_get_returnsDefaultWhenNoSourcesConfigured() { ConfigurationLoader loader = ConfigurationLoader.builder().build(); @@ -192,9 +229,12 @@ void configurationLoader_chainedSources_firstNonNullWins() { } @Test - void configurationLoader_emptyBuilder_returnsLoaderWithNoSources() { - ConfigurationLoader loader = ConfigurationLoader.builder().build(); - - assertThat(loader.get("anyKey", "default")).isEqualTo("default"); + void toEnvironmentVariableName() { + assertThat(ConfigurationLoader.toEnvironmentVariableName("defaultPrintFormat")) + .isEqualTo("APPROVEJ_DEFAULT_PRINT_FORMAT"); + assertThat(ConfigurationLoader.toEnvironmentVariableName("timeout")) + .isEqualTo("APPROVEJ_TIMEOUT"); + assertThat(ConfigurationLoader.toEnvironmentVariableName("maxRetryCount")) + .isEqualTo("APPROVEJ_MAX_RETRY_COUNT"); } } diff --git a/modules/core/src/test/resources/approvej.properties b/modules/core/src/test/resources/approvej.properties index 70f4c044..b9918f0b 100644 --- a/modules/core/src/test/resources/approvej.properties +++ b/modules/core/src/test/resources/approvej.properties @@ -1,2 +1,3 @@ defaultPrintFormat = org.approvej.print.SingleLineStringPrintFormat +inventoryEnabled = true ignored = some value diff --git a/modules/core/src/testng/java/org/approvej/approve/StackTraceTestFinderUtilTestNgTest.java b/modules/core/src/testng/java/org/approvej/approve/StackTraceTestFinderUtilTestNgTest.java index 9903235f..401245ad 100644 --- a/modules/core/src/testng/java/org/approvej/approve/StackTraceTestFinderUtilTestNgTest.java +++ b/modules/core/src/testng/java/org/approvej/approve/StackTraceTestFinderUtilTestNgTest.java @@ -43,7 +43,7 @@ void findTestSourcePath() { StackTraceTestFinderUtil.findTestSourcePath( StackTraceTestFinderUtil.currentTestMethod().method()); - assertEquals(testSourcePath, thisTestSourcePath.toAbsolutePath().normalize()); + assertEquals(testSourcePath, thisTestSourcePath.normalize()); } @Test @@ -59,7 +59,7 @@ void findTestSourcePath_file_in_build() throws IOException { StackTraceTestFinderUtil.findTestSourcePath( StackTraceTestFinderUtil.currentTestMethod().method()); - assertEquals(testSourcePath, thisTestSourcePath.toAbsolutePath().normalize()); + assertEquals(testSourcePath, thisTestSourcePath.normalize()); } @Test @@ -75,6 +75,6 @@ void findTestSourcePath_file_in_target() throws IOException { StackTraceTestFinderUtil.findTestSourcePath( StackTraceTestFinderUtil.currentTestMethod().method()); - assertEquals(testSourcePath, thisTestSourcePath.toAbsolutePath().normalize()); + assertEquals(testSourcePath, thisTestSourcePath.normalize()); } } diff --git a/plugins/approvej-gradle-plugin/build.gradle.kts b/plugins/approvej-gradle-plugin/build.gradle.kts new file mode 100644 index 00000000..8fb8e741 --- /dev/null +++ b/plugins/approvej-gradle-plugin/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + `java-gradle-plugin` + jacoco + `jvm-test-suite` + alias(libs.plugins.plugin.publish) +} + +java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } + +repositories { mavenCentral() } + +testing { + suites { + val test by + getting(JvmTestSuite::class) { + useJUnitJupiter() + dependencies { + 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 } } + +gradlePlugin { + website = "https://approvej.org" + vcsUrl = "https://github.com/mkutz/approvej" + plugins { + create("approvej") { + id = "org.approvej" + implementationClass = "org.approvej.gradle.ApproveJPlugin" + displayName = "ApproveJ" + description = "Find and remove leftover approved files" + tags = listOf("testing", "approval-testing", "snapshot-testing") + } + } +} diff --git a/plugins/approvej-gradle-plugin/src/main/java/org/approvej/gradle/ApproveJPlugin.java b/plugins/approvej-gradle-plugin/src/main/java/org/approvej/gradle/ApproveJPlugin.java new file mode 100644 index 00000000..60f5e56e --- /dev/null +++ b/plugins/approvej-gradle-plugin/src/main/java/org/approvej/gradle/ApproveJPlugin.java @@ -0,0 +1,54 @@ +package org.approvej.gradle; + +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.plugins.JavaPlugin; +import org.gradle.api.tasks.JavaExec; +import org.gradle.api.tasks.SourceSetContainer; + +/** Gradle plugin that registers tasks to find and remove leftover approved files. */ +@SuppressWarnings("unused") +public final class ApproveJPlugin implements Plugin { + + @Override + public void apply(Project project) { + project + .getPlugins() + .withType( + JavaPlugin.class, + javaPlugin -> { + var testClasspath = + project + .getExtensions() + .getByType(SourceSetContainer.class) + .getByName("test") + .getRuntimeClasspath(); + + project + .getTasks() + .register( + "approvejFindLeftovers", + JavaExec.class, + task -> { + task.setGroup("verification"); + task.setDescription("List leftover approved files"); + task.setClasspath(testClasspath); + task.getMainClass().set("org.approvej.approve.ApprovedFileInventory"); + task.args("--find"); + }); + + project + .getTasks() + .register( + "approvejCleanup", + JavaExec.class, + task -> { + task.setGroup("verification"); + task.setDescription("Detect and remove leftover approved files"); + task.setClasspath(testClasspath); + task.getMainClass().set("org.approvej.approve.ApprovedFileInventory"); + task.args("--remove"); + }); + }); + } +} diff --git a/plugins/approvej-gradle-plugin/src/test/java/org/approvej/gradle/ApproveJPluginTest.java b/plugins/approvej-gradle-plugin/src/test/java/org/approvej/gradle/ApproveJPluginTest.java new file mode 100644 index 00000000..be97f71e --- /dev/null +++ b/plugins/approvej-gradle-plugin/src/test/java/org/approvej/gradle/ApproveJPluginTest.java @@ -0,0 +1,123 @@ +package org.approvej.gradle; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.gradle.api.Project; +import org.gradle.api.tasks.JavaExec; +import org.gradle.testfixtures.ProjectBuilder; +import org.gradle.testkit.runner.GradleRunner; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ApproveJPluginTest { + + @TempDir Path tempProjectDir; + + @Test + void apply() { + Project project = ProjectBuilder.builder().build(); + project.getPluginManager().apply("java"); + + project.getPluginManager().apply(ApproveJPlugin.class); + + assertThat(project.getTasks().findByName("approvejFindLeftovers")).isNotNull(); + assertThat(project.getTasks().findByName("approvejCleanup")).isNotNull(); + } + + @Test + void apply_without_java_plugin() { + Project project = ProjectBuilder.builder().build(); + + project.getPluginManager().apply(ApproveJPlugin.class); + + assertThat(project.getTasks().findByName("approvejFindLeftovers")).isNull(); + assertThat(project.getTasks().findByName("approvejCleanup")).isNull(); + } + + @Test + void apply_java_plugin_after_approvej() { + Project project = ProjectBuilder.builder().build(); + project.getPluginManager().apply(ApproveJPlugin.class); + + project.getPluginManager().apply("java"); + + assertThat(project.getTasks().findByName("approvejFindLeftovers")).isNotNull(); + assertThat(project.getTasks().findByName("approvejCleanup")).isNotNull(); + } + + @Test + void apply_findLeftovers_task_configuration() { + Project project = ProjectBuilder.builder().build(); + project.getPluginManager().apply("java"); + project.getPluginManager().apply(ApproveJPlugin.class); + + var task = (JavaExec) project.getTasks().getByName("approvejFindLeftovers"); + + assertThat(task.getGroup()).isEqualTo("verification"); + assertThat(task.getDescription()).isEqualTo("List leftover approved files"); + assertThat(task.getMainClass().get()).isEqualTo("org.approvej.approve.ApprovedFileInventory"); + assertThat(task.getArgs()).containsExactly("--find"); + } + + @Test + void apply_cleanup_task_configuration() { + Project project = ProjectBuilder.builder().build(); + project.getPluginManager().apply("java"); + project.getPluginManager().apply(ApproveJPlugin.class); + + var task = (JavaExec) project.getTasks().getByName("approvejCleanup"); + + assertThat(task.getGroup()).isEqualTo("verification"); + assertThat(task.getDescription()).isEqualTo("Detect and remove leftover approved files"); + assertThat(task.getMainClass().get()).isEqualTo("org.approvej.approve.ApprovedFileInventory"); + assertThat(task.getArgs()).containsExactly("--remove"); + } + + @Test + void apply_functional() throws IOException { + Files.writeString( + tempProjectDir.resolve("build.gradle"), + """ + plugins { + id 'java' + id 'org.approvej' + } + """); + + var result = + GradleRunner.create() + .withProjectDir(tempProjectDir.toFile()) + .withPluginClasspath() + .withArguments("tasks", "--group", "verification") + .build(); + + assertThat(result.getOutput()) + .contains("approvejFindLeftovers - List leftover approved files") + .contains("approvejCleanup - Detect and remove leftover approved files"); + } + + @Test + void apply_functional_without_java_plugin() throws IOException { + Files.writeString( + tempProjectDir.resolve("build.gradle"), + """ + plugins { + id 'org.approvej' + } + """); + + var result = + GradleRunner.create() + .withProjectDir(tempProjectDir.toFile()) + .withPluginClasspath() + .withArguments("tasks", "--all") + .build(); + + assertThat(result.getOutput()) + .doesNotContain("approvejFindLeftovers") + .doesNotContain("approvejCleanup"); + } +} diff --git a/plugins/approvej-maven-plugin/build.gradle.kts b/plugins/approvej-maven-plugin/build.gradle.kts new file mode 100644 index 00000000..f517cddc --- /dev/null +++ b/plugins/approvej-maven-plugin/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + `java-library` + jacoco + `jvm-test-suite` + `maven-publish` + alias(libs.plugins.maven.plugin.development) +} + +java { + withJavadocJar() + withSourcesJar() + toolchain { languageVersion = JavaLanguageVersion.of(21) } +} + +repositories { mavenCentral() } + +dependencies { + compileOnly(libs.maven.plugin.annotations) + implementation(libs.maven.plugin.api) + implementation(libs.maven.core) +} + +tasks.jacocoTestReport { reports { xml.required = true } } + +testing { + suites { + val test by + getting(JvmTestSuite::class) { + useJUnitJupiter() + dependencies { + implementation(platform(libs.junit.bom)) + implementation(libs.junit.jupiter.api) + implementation(libs.assertj.core) + + runtimeOnly(libs.junit.platform.launcher) + runtimeOnly(libs.junit.jupiter.engine) + } + } + } +} diff --git a/plugins/approvej-maven-plugin/gradle.properties b/plugins/approvej-maven-plugin/gradle.properties new file mode 100644 index 00000000..cea5a3fb --- /dev/null +++ b/plugins/approvej-maven-plugin/gradle.properties @@ -0,0 +1,2 @@ +mavenPomName = ApproveJ Maven Plugin +mavenPomDescription = Maven plugin to find and remove leftover approved files diff --git a/plugins/approvej-maven-plugin/src/main/java/org/approvej/maven/CleanupMojo.java b/plugins/approvej-maven-plugin/src/main/java/org/approvej/maven/CleanupMojo.java new file mode 100644 index 00000000..fbcfa37f --- /dev/null +++ b/plugins/approvej-maven-plugin/src/main/java/org/approvej/maven/CleanupMojo.java @@ -0,0 +1,21 @@ +package org.approvej.maven; + +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.plugins.annotations.ResolutionScope; +import org.apache.maven.project.MavenProject; + +/** Detects and removes leftover approved files whose originating test method no longer exists. */ +@Mojo(name = "cleanup", requiresDependencyResolution = ResolutionScope.TEST, threadSafe = true) +public class CleanupMojo extends AbstractMojo { + + @Parameter(defaultValue = "${project}", readonly = true, required = true) + private MavenProject project; + + @Override + public void execute() throws MojoExecutionException { + MojoHelper.executeInventory(project, "--remove", getLog()); + } +} diff --git a/plugins/approvej-maven-plugin/src/main/java/org/approvej/maven/FindLeftoversMojo.java b/plugins/approvej-maven-plugin/src/main/java/org/approvej/maven/FindLeftoversMojo.java new file mode 100644 index 00000000..254668c3 --- /dev/null +++ b/plugins/approvej-maven-plugin/src/main/java/org/approvej/maven/FindLeftoversMojo.java @@ -0,0 +1,24 @@ +package org.approvej.maven; + +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.plugins.annotations.ResolutionScope; +import org.apache.maven.project.MavenProject; + +/** Lists leftover approved files whose originating test method no longer exists. */ +@Mojo( + name = "find-leftovers", + requiresDependencyResolution = ResolutionScope.TEST, + threadSafe = true) +public class FindLeftoversMojo extends AbstractMojo { + + @Parameter(defaultValue = "${project}", readonly = true, required = true) + private MavenProject project; + + @Override + public void execute() throws MojoExecutionException { + MojoHelper.executeInventory(project, "--find", getLog()); + } +} diff --git a/plugins/approvej-maven-plugin/src/main/java/org/approvej/maven/MojoHelper.java b/plugins/approvej-maven-plugin/src/main/java/org/approvej/maven/MojoHelper.java new file mode 100644 index 00000000..3b286c58 --- /dev/null +++ b/plugins/approvej-maven-plugin/src/main/java/org/approvej/maven/MojoHelper.java @@ -0,0 +1,82 @@ +package org.approvej.maven; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.Path; +import java.util.List; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.logging.Log; +import org.apache.maven.project.MavenProject; + +/** Shared helper for forking a JVM to run {@code ApprovedFileInventory}. */ +final class MojoHelper { + + private static final String MAIN_CLASS = "org.approvej.approve.ApprovedFileInventory"; + + private MojoHelper() {} + + static void executeInventory(MavenProject project, String command, Log log) + throws MojoExecutionException { + List classpathElements; + try { + classpathElements = project.getTestClasspathElements(); + } catch (Exception e) { + throw new MojoExecutionException("Failed to resolve test classpath", e); + } + + List cmd = buildCommand(classpathElements, command); + + try { + ProcessBuilder processBuilder = new ProcessBuilder(cmd); + processBuilder.directory(project.getBasedir()); + processBuilder.redirectErrorStream(false); + + Process process = processBuilder.start(); + + Thread stdoutThread = + new Thread( + () -> { + try (BufferedReader stdout = + new BufferedReader(new InputStreamReader(process.getInputStream()))) { + stdout.lines().forEach(log::info); + } catch (IOException e) { + log.error("Error reading stdout from ApprovedFileInventory", e); + } + }); + Thread stderrThread = + new Thread( + () -> { + try (BufferedReader stderr = + new BufferedReader(new InputStreamReader(process.getErrorStream()))) { + stderr.lines().forEach(log::error); + } catch (IOException e) { + log.error("Error reading stderr from ApprovedFileInventory", e); + } + }); + stdoutThread.start(); + stderrThread.start(); + + int exitCode = process.waitFor(); + stdoutThread.join(); + stderrThread.join(); + if (exitCode != 0) { + throw new MojoExecutionException( + "ApprovedFileInventory exited with code %d".formatted(exitCode)); + } + } catch (IOException e) { + throw new MojoExecutionException("Failed to execute ApprovedFileInventory", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new MojoExecutionException("Interrupted while running ApprovedFileInventory", e); + } + } + + static List buildCommand(List classpathElements, String command) { + String javaExecutable = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + String classpath = String.join(File.pathSeparator, classpathElements); + + return List.of(javaExecutable, "-cp", classpath, MAIN_CLASS, command); + } +} diff --git a/plugins/approvej-maven-plugin/src/test/java/org/approvej/maven/MojoHelperTest.java b/plugins/approvej-maven-plugin/src/test/java/org/approvej/maven/MojoHelperTest.java new file mode 100644 index 00000000..6bd555f6 --- /dev/null +++ b/plugins/approvej-maven-plugin/src/test/java/org/approvej/maven/MojoHelperTest.java @@ -0,0 +1,47 @@ +package org.approvej.maven; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import java.nio.file.Path; +import java.util.List; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.logging.SystemStreamLog; +import org.apache.maven.project.MavenProject; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class MojoHelperTest { + + @Test + void executeInventory_nonzero_exit_code(@TempDir Path tempDir) { + var project = new MavenProject(); + project.getBuild().setOutputDirectory(tempDir.resolve("classes").toString()); + project.getBuild().setTestOutputDirectory(tempDir.resolve("test-classes").toString()); + project.setFile(tempDir.resolve("pom.xml").toFile()); + + assertThatExceptionOfType(MojoExecutionException.class) + .isThrownBy(() -> MojoHelper.executeInventory(project, "--find", new SystemStreamLog())) + .withMessageContaining("exited with code"); + } + + @ParameterizedTest + @ValueSource(strings = {"--find", "--remove"}) + void buildCommand(String command) { + var classpathElements = List.of("/lib/a.jar", "/lib/b.jar"); + + var result = MojoHelper.buildCommand(classpathElements, command); + + String expectedJava = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + String expectedClasspath = "/lib/a.jar" + System.getProperty("path.separator") + "/lib/b.jar"; + assertThat(result) + .containsExactly( + expectedJava, + "-cp", + expectedClasspath, + "org.approvej.approve.ApprovedFileInventory", + command); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 321dace2..3c56cda7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -6,6 +6,10 @@ include("bom") include("modules:core") +include("plugins:approvej-gradle-plugin") + +include("plugins:approvej-maven-plugin") + include("modules:json-jackson") include("modules:json-jackson3")