diff --git a/modules/core/src/main/java/org/approvej/approve/ApprovedFileInventory.java b/modules/core/src/main/java/org/approvej/approve/ApprovedFileInventory.java index eb3b23b9..eab5f98b 100644 --- a/modules/core/src/main/java/org/approvej/approve/ApprovedFileInventory.java +++ b/modules/core/src/main/java/org/approvej/approve/ApprovedFileInventory.java @@ -1,5 +1,6 @@ package org.approvej.approve; +import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; import static java.util.Arrays.stream; import static java.util.stream.Collectors.joining; @@ -13,6 +14,8 @@ import java.util.TreeMap; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicReference; +import org.approvej.configuration.Configuration; +import org.approvej.review.FileReviewer; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -200,13 +203,97 @@ static void reset() { reset(DEFAULT_INVENTORY_FILE); } + /** + * Finds all received files corresponding to inventory entries. + * + *

For each approved file in the inventory, checks if a corresponding received file exists next + * to it. + * + * @return a sorted list of paths to received files that exist + */ + static List findReceivedFiles() { + return loadInventory().keySet().stream() + .map(ApprovedFileInventory::receivedPathFor) + .filter(Files::exists) + .sorted() + .toList(); + } + + private static Path receivedPathFor(Path approvedPath) { + String filename = approvedPath.getFileName().toString(); + int approvedWithExtIdx = filename.lastIndexOf("-approved."); + if (approvedWithExtIdx >= 0) { + return approvedPath + .getParent() + .resolve( + filename.substring(0, approvedWithExtIdx) + + "-received." + + filename.substring(approvedWithExtIdx + "-approved.".length())); + } + if (filename.endsWith("-approved")) { + return approvedPath + .getParent() + .resolve(filename.substring(0, filename.length() - "-approved".length()) + "-received"); + } + return approvedPath; + } + + /** + * Approves all unapproved files by moving each received file to its corresponding approved file. + * + * @return the list of received file paths that were approved + */ + static List approveAll() { + List approved = new ArrayList<>(); + for (Path approvedPath : loadInventory().keySet()) { + Path received = receivedPathFor(approvedPath); + if (!Files.exists(received)) { + continue; + } + try { + Files.move(received, approvedPath, REPLACE_EXISTING); + approved.add(received); + } catch (IOException e) { + System.err.printf("Failed to approve %s: %s%n", received, e.getMessage()); + } + } + return approved; + } + + /** + * Reviews all unapproved files using the configured {@link FileReviewer}. + * + * @param reviewer the {@link FileReviewer} to use for reviewing each unapproved file + */ + static void reviewUnapproved(FileReviewer reviewer) { + var unapprovedEntries = + loadInventory().keySet().stream() + .filter(approvedPath -> Files.exists(receivedPathFor(approvedPath))) + .sorted() + .toList(); + if (unapprovedEntries.isEmpty()) { + System.out.println("No unapproved files found."); + return; + } + System.out.println("Unapproved files:"); + unapprovedEntries.forEach( + approvedPath -> { + Path receivedPath = receivedPathFor(approvedPath); + System.out.printf(" %s%n", receivedPath.toUri()); + reviewer.apply(PathProviders.approvedPath(approvedPath)); + }); + } + /** * CLI entry point for build tool plugins. * - * @param args {@code --find} to list leftovers, {@code --remove} to delete them + * @param args {@code --find} to list leftovers, {@code --remove} to delete them, {@code + * --approve-all} to approve all unapproved files, {@code --review-unapproved} to review all + * unapproved files */ public static void main(String[] args) { - String usage = "Usage: ApprovedFileInventory --find | --remove"; + String usage = + "Usage: ApprovedFileInventory --find | --remove | --approve-all | --review-unapproved"; if (args.length == 0) { System.err.println(usage); System.exit(1); @@ -236,6 +323,18 @@ public static void main(String[] args) { removed.forEach(leftover -> System.out.printf(" %s%n", leftover.relativePath().toUri())); } } + case "--approve-all" -> { + List approved = approveAll(); + if (approved.isEmpty()) { + System.out.println("No unapproved files found."); + } else { + System.out.println("Approved files:"); + approved.forEach(path -> System.out.printf(" %s%n", path.toUri())); + } + } + case "--review-unapproved" -> { + reviewUnapproved(Configuration.configuration.defaultFileReviewer()); + } default -> { System.err.printf("Unknown command: %s%n", command); System.err.println(usage); diff --git a/modules/core/src/test/java/org/approvej/approve/ApprovedFileInventoryTest.java b/modules/core/src/test/java/org/approvej/approve/ApprovedFileInventoryTest.java index 9a17fb5f..c02757a0 100644 --- a/modules/core/src/test/java/org/approvej/approve/ApprovedFileInventoryTest.java +++ b/modules/core/src/test/java/org/approvej/approve/ApprovedFileInventoryTest.java @@ -4,10 +4,13 @@ import static org.assertj.core.api.Assertions.assertThat; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; +import java.util.ArrayList; import java.util.List; import java.util.TreeMap; +import org.approvej.review.FileReviewResult; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -188,4 +191,129 @@ void removeLeftovers() throws IOException { TreeMap inventory = ApprovedFileInventory.loadInventory(); assertThat(inventory).doesNotContainKey(leftoverFile).containsKey(validFile); } + + @Test + void findReceivedFiles() throws IOException { + Path approvedFile1 = tempDir.resolve("MyTest-myMethod-approved.txt"); + Files.createFile(approvedFile1); + Path receivedFile1 = tempDir.resolve("MyTest-myMethod-received.txt"); + Files.createFile(receivedFile1); + Path approvedFile2 = tempDir.resolve("OtherTest-other-approved.json"); + Files.createFile(approvedFile2); + Path receivedFile2 = tempDir.resolve("OtherTest-other-received.json"); + Files.createFile(receivedFile2); + writeString( + inventoryFile, + "# ApproveJ Approved File Inventory (auto-generated, do not edit)\n" + + approvedFile1 + + " = com.example.MyTest#myMethod\n" + + approvedFile2 + + " = com.example.OtherTest#other\n", + StandardOpenOption.CREATE); + + List receivedFiles = ApprovedFileInventory.findReceivedFiles(); + + assertThat(receivedFiles) + .hasSize(2) + .anySatisfy( + p -> assertThat(p.getFileName().toString()).isEqualTo("MyTest-myMethod-received.txt")) + .anySatisfy( + p -> assertThat(p.getFileName().toString()).isEqualTo("OtherTest-other-received.json")); + } + + @Test + void findReceivedFiles_only_existing() throws IOException { + Path approvedFile = tempDir.resolve("MyTest-myMethod-approved.txt"); + Files.createFile(approvedFile); + Path receivedFile = tempDir.resolve("MyTest-myMethod-received.txt"); + Files.createFile(receivedFile); + Path approvedFileNoReceived = tempDir.resolve("OtherTest-other-approved.json"); + Files.createFile(approvedFileNoReceived); + writeString( + inventoryFile, + "# ApproveJ Approved File Inventory (auto-generated, do not edit)\n" + + approvedFile + + " = com.example.MyTest#myMethod\n" + + approvedFileNoReceived + + " = com.example.OtherTest#other\n", + StandardOpenOption.CREATE); + + List receivedFiles = ApprovedFileInventory.findReceivedFiles(); + + assertThat(receivedFiles) + .hasSize(1) + .anySatisfy( + p -> assertThat(p.getFileName().toString()).isEqualTo("MyTest-myMethod-received.txt")); + } + + @Test + void findReceivedFiles_empty() { + List receivedFiles = ApprovedFileInventory.findReceivedFiles(); + + assertThat(receivedFiles).isEmpty(); + } + + @Test + void approveAll() throws IOException { + Path receivedFile = tempDir.resolve("MyTest-myMethod-received.txt"); + writeString(receivedFile, "received content", StandardOpenOption.CREATE); + Path approvedFile = tempDir.resolve("MyTest-myMethod-approved.txt"); + writeString(approvedFile, "old approved content", StandardOpenOption.CREATE); + writeString( + inventoryFile, + "# ApproveJ Approved File Inventory (auto-generated, do not edit)\n" + + approvedFile + + " = com.example.MyTest#myMethod\n", + StandardOpenOption.CREATE); + + List approved = ApprovedFileInventory.approveAll(); + + assertThat(approved).hasSize(1); + assertThat(receivedFile).doesNotExist(); + assertThat(approvedFile).exists().hasContent("received content"); + } + + @Test + void approveAll_no_received_files() { + List approved = ApprovedFileInventory.approveAll(); + + assertThat(approved).isEmpty(); + } + + @Test + void reviewUnapproved() throws IOException { + Path receivedFile = tempDir.resolve("MyTest-myMethod-received.txt"); + writeString(receivedFile, "received content", StandardOpenOption.CREATE); + Path approvedFile = tempDir.resolve("MyTest-myMethod-approved.txt"); + writeString(approvedFile, "approved content", StandardOpenOption.CREATE); + writeString( + inventoryFile, + "# ApproveJ Approved File Inventory (auto-generated, do not edit)\n" + + approvedFile + + " = com.example.MyTest#myMethod\n", + StandardOpenOption.CREATE); + + var reviewedProviders = new ArrayList(); + ApprovedFileInventory.reviewUnapproved( + pathProvider -> { + reviewedProviders.add(pathProvider); + return new FileReviewResult(false); + }); + + assertThat(reviewedProviders).hasSize(1); + assertThat(reviewedProviders.getFirst().receivedPath()).isEqualTo(receivedFile.normalize()); + assertThat(reviewedProviders.getFirst().approvedPath()).isEqualTo(approvedFile.normalize()); + } + + @Test + void reviewUnapproved_no_received_files() { + var reviewedProviders = new ArrayList(); + ApprovedFileInventory.reviewUnapproved( + pathProvider -> { + reviewedProviders.add(pathProvider); + return new FileReviewResult(false); + }); + + assertThat(reviewedProviders).isEmpty(); + } } 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 index 60f5e56e..5a2dd01e 100644 --- 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 @@ -49,6 +49,32 @@ public void apply(Project project) { task.getMainClass().set("org.approvej.approve.ApprovedFileInventory"); task.args("--remove"); }); + + project + .getTasks() + .register( + "approvejApproveAll", + JavaExec.class, + task -> { + task.setGroup("verification"); + task.setDescription("Approve all unapproved files"); + task.setClasspath(testClasspath); + task.getMainClass().set("org.approvej.approve.ApprovedFileInventory"); + task.args("--approve-all"); + }); + + project + .getTasks() + .register( + "approvejReviewUnapproved", + JavaExec.class, + task -> { + task.setGroup("verification"); + task.setDescription("Review all unapproved files"); + task.setClasspath(testClasspath); + task.getMainClass().set("org.approvej.approve.ApprovedFileInventory"); + task.args("--review-unapproved"); + }); }); } } 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 index be97f71e..ebb6a187 100644 --- 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 @@ -25,6 +25,8 @@ void apply() { assertThat(project.getTasks().findByName("approvejFindLeftovers")).isNotNull(); assertThat(project.getTasks().findByName("approvejCleanup")).isNotNull(); + assertThat(project.getTasks().findByName("approvejApproveAll")).isNotNull(); + assertThat(project.getTasks().findByName("approvejReviewUnapproved")).isNotNull(); } @Test @@ -35,6 +37,8 @@ void apply_without_java_plugin() { assertThat(project.getTasks().findByName("approvejFindLeftovers")).isNull(); assertThat(project.getTasks().findByName("approvejCleanup")).isNull(); + assertThat(project.getTasks().findByName("approvejApproveAll")).isNull(); + assertThat(project.getTasks().findByName("approvejReviewUnapproved")).isNull(); } @Test @@ -46,6 +50,8 @@ void apply_java_plugin_after_approvej() { assertThat(project.getTasks().findByName("approvejFindLeftovers")).isNotNull(); assertThat(project.getTasks().findByName("approvejCleanup")).isNotNull(); + assertThat(project.getTasks().findByName("approvejApproveAll")).isNotNull(); + assertThat(project.getTasks().findByName("approvejReviewUnapproved")).isNotNull(); } @Test @@ -76,6 +82,34 @@ void apply_cleanup_task_configuration() { assertThat(task.getArgs()).containsExactly("--remove"); } + @Test + void apply_approveAll_task_configuration() { + Project project = ProjectBuilder.builder().build(); + project.getPluginManager().apply("java"); + project.getPluginManager().apply(ApproveJPlugin.class); + + var task = (JavaExec) project.getTasks().getByName("approvejApproveAll"); + + assertThat(task.getGroup()).isEqualTo("verification"); + assertThat(task.getDescription()).isEqualTo("Approve all unapproved files"); + assertThat(task.getMainClass().get()).isEqualTo("org.approvej.approve.ApprovedFileInventory"); + assertThat(task.getArgs()).containsExactly("--approve-all"); + } + + @Test + void apply_reviewUnapproved_task_configuration() { + Project project = ProjectBuilder.builder().build(); + project.getPluginManager().apply("java"); + project.getPluginManager().apply(ApproveJPlugin.class); + + var task = (JavaExec) project.getTasks().getByName("approvejReviewUnapproved"); + + assertThat(task.getGroup()).isEqualTo("verification"); + assertThat(task.getDescription()).isEqualTo("Review all unapproved files"); + assertThat(task.getMainClass().get()).isEqualTo("org.approvej.approve.ApprovedFileInventory"); + assertThat(task.getArgs()).containsExactly("--review-unapproved"); + } + @Test void apply_functional() throws IOException { Files.writeString( @@ -96,7 +130,9 @@ void apply_functional() throws IOException { assertThat(result.getOutput()) .contains("approvejFindLeftovers - List leftover approved files") - .contains("approvejCleanup - Detect and remove leftover approved files"); + .contains("approvejCleanup - Detect and remove leftover approved files") + .contains("approvejApproveAll - Approve all unapproved files") + .contains("approvejReviewUnapproved - Review all unapproved files"); } @Test @@ -118,6 +154,8 @@ void apply_functional_without_java_plugin() throws IOException { assertThat(result.getOutput()) .doesNotContain("approvejFindLeftovers") - .doesNotContain("approvejCleanup"); + .doesNotContain("approvejCleanup") + .doesNotContain("approvejApproveAll") + .doesNotContain("approvejReviewUnapproved"); } } diff --git a/plugins/approvej-maven-plugin/src/main/java/org/approvej/maven/ApproveAllMojo.java b/plugins/approvej-maven-plugin/src/main/java/org/approvej/maven/ApproveAllMojo.java new file mode 100644 index 00000000..b7c3250e --- /dev/null +++ b/plugins/approvej-maven-plugin/src/main/java/org/approvej/maven/ApproveAllMojo.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; + +/** Approves all unapproved files by moving each received file to its approved counterpart. */ +@Mojo(name = "approve-all", requiresDependencyResolution = ResolutionScope.TEST, threadSafe = true) +public class ApproveAllMojo extends AbstractMojo { + + @Parameter(defaultValue = "${project}", readonly = true, required = true) + private MavenProject project; + + @Override + public void execute() throws MojoExecutionException { + MojoHelper.executeInventory(project, "--approve-all", getLog()); + } +} diff --git a/plugins/approvej-maven-plugin/src/main/java/org/approvej/maven/ReviewUnapprovedMojo.java b/plugins/approvej-maven-plugin/src/main/java/org/approvej/maven/ReviewUnapprovedMojo.java new file mode 100644 index 00000000..246af63c --- /dev/null +++ b/plugins/approvej-maven-plugin/src/main/java/org/approvej/maven/ReviewUnapprovedMojo.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; + +/** Reviews all unapproved files using the configured file reviewer. */ +@Mojo( + name = "review-unapproved", + requiresDependencyResolution = ResolutionScope.TEST, + threadSafe = true) +public class ReviewUnapprovedMojo extends AbstractMojo { + + @Parameter(defaultValue = "${project}", readonly = true, required = true) + private MavenProject project; + + @Override + public void execute() throws MojoExecutionException { + MojoHelper.executeInventory(project, "--review-unapproved", getLog()); + } +} 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 index 6bd555f6..81432f6a 100644 --- 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 @@ -28,7 +28,7 @@ void executeInventory_nonzero_exit_code(@TempDir Path tempDir) { } @ParameterizedTest - @ValueSource(strings = {"--find", "--remove"}) + @ValueSource(strings = {"--find", "--remove", "--approve-all", "--review-unapproved"}) void buildCommand(String command) { var classpathElements = List.of("/lib/a.jar", "/lib/b.jar");