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");