diff --git a/README.md b/README.md
index 96152d5..645b2b1 100644
--- a/README.md
+++ b/README.md
@@ -84,7 +84,8 @@ A relative `--config` value is resolved against the directory the command is
run in, while the relative paths inside the file stay relative to the file's own
directory. `generate` exits with status `0` on success, and with status `1`
after printing one diagnostic for invalid configuration, an unreadable source, a
-query it cannot compile, or an output directory or file it cannot write.
+query it cannot compile, an output directory or file it cannot write, a stale
+generated file it cannot delete, or an output manifest it cannot read or write.
## Getting started
diff --git a/docs/configuration.md b/docs/configuration.md
index 19b3306..b6a5f1c 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -288,15 +288,18 @@ sqlcj: Generated file paths for repositories 'UserData' and 'Userdata' differ on
## Diagnostics
Invalid configuration, unreadable SQL sources, and output that cannot be written
-make `sqlcj generate` print a single message on standard error and exit with a
-non-zero status. The message names the configuration file and, when available,
-the offending field, value, source path, or output path:
+or cleaned up make `sqlcj generate` print a single message on standard error and
+exit with a non-zero status. The message names the configuration file and, when
+available, the offending field, value, source path, or output path:
```text
sqlcj: Invalid configuration in /home/dev/project/sqlcj.yaml: 'java.package' value 'dev.class.generated' is not a valid Java package name
sqlcj: Cannot read queries source: /home/dev/project/sql/missing.sql
sqlcj: Cannot create output directory: /home/dev/project/target/generated-sources/sqlcj/dev/example/generated
sqlcj: Cannot write generated file: /home/dev/project/target/generated-sources/sqlcj/dev/example/generated/UsersRepository.java
+sqlcj: Cannot read output manifest: /home/dev/project/target/generated-sources/sqlcj/sqlcj-manifest.txt
+sqlcj: Cannot delete stale generated file: /home/dev/project/target/generated-sources/sqlcj/dev/example/generated/OrdersRepository.java
+sqlcj: Cannot write output manifest: /home/dev/project/target/generated-sources/sqlcj/sqlcj-manifest.txt
```
## Generated Output and Failures
@@ -316,8 +319,22 @@ A filesystem failure while writing ends the run with one diagnostic naming the
output directory or generated file it could not write and exit status `1`. The
files the run had already written stay in place.
-sqlcj also never deletes a generated file that the current run did not produce,
-so a renamed or removed configuration entry leaves its previous repository
-behind. Generate into a
-build-owned directory and let the build's clean step remove stale output, as the
-[Quickstart](quickstart.md) does.
+A successful run records what it generated in `sqlcj-manifest.txt` inside
+`java.out`. The manifest is a UTF-8 text file listing every generated file as a
+path relative to `java.out`, with `/` between its name elements, sorted, one per
+line. The next successful run deletes the files the previous manifest listed
+that it did not generate itself, so a renamed or removed configuration entry
+leaves no stale repository behind.
+
+Cleanup is deliberately narrow:
+
+- A file no previous manifest listed is never deleted, so a hand-written file in
+ the generated package survives, and so does output written before the first
+ manifest existed.
+- No directory is deleted, so a package directory stays in place after the
+ repository inside it is removed.
+- Changing `java.out` leaves the previous directory and its files untouched,
+ because cleanup only reads the manifest of the directory it generates into.
+- A run that fails while writing a generated file, deleting a stale one, or
+ writing the manifest leaves the previous manifest in place, so the next
+ successful run cleans up the files it still lists.
diff --git a/docs/quickstart.md b/docs/quickstart.md
index fa7ba34..56a5006 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -410,10 +410,11 @@ mvn compile
mvn exec:java
```
-- `mvn clean` deletes `target`, including previously generated sources. sqlcj
- writes and overwrites its own files but never deletes a file it did not just
- generate, so a query that was renamed or removed would otherwise leave a stale
- class behind that still compiles.
+- `mvn clean` deletes `target`, including previously generated sources and the
+ classes compiled from them. sqlcj writes and overwrites its own files and
+ deletes a repository its previous run recorded that the current run no longer
+ generates, but it never deletes a compiled class, so a query that was renamed
+ or removed would otherwise leave a stale class behind that still compiles.
- `sqlcj generate` is a separate command. It is not bound to the Maven
lifecycle, so it must run after `clean` and before `compile`. The commands
above run from the project root, because `generate` reads `sqlcj.yaml` from
diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/compiler/SqlcjCompiler.java b/sqlcj-cli/src/main/java/dev/sqlcj/compiler/SqlcjCompiler.java
index dd82b09..b344008 100644
--- a/sqlcj-cli/src/main/java/dev/sqlcj/compiler/SqlcjCompiler.java
+++ b/sqlcj-cli/src/main/java/dev/sqlcj/compiler/SqlcjCompiler.java
@@ -8,6 +8,7 @@
import dev.sqlcj.generator.GeneratedFile;
import dev.sqlcj.generator.JavaCodeGenerator;
import dev.sqlcj.io.GeneratedFileWriter;
+import dev.sqlcj.io.GeneratedOutputManifest;
import dev.sqlcj.parser.Query;
import dev.sqlcj.schema.Schema;
import dev.sqlcj.schema.parser.DefaultSchemaParser;
@@ -29,12 +30,18 @@ public final class SqlcjCompiler {
private final SqlParser sqlParser = new SqlParser();
private final QueryAnalyzer queryAnalyzer = new QueryAnalyzer();
private final GeneratedFileWriter generatedFileWriter = new GeneratedFileWriter();
+ private final GeneratedOutputManifest generatedOutputManifest = new GeneratedOutputManifest();
private final SchemaParser schemaParser = new DefaultSchemaParser();
/**
* Compiles every configured entry before any generated file is written, so
* a failure in a later source cannot leave a mixture of previously
* generated and newly generated output.
+ *
+ *
The manifest of the previous run is read before the first write, and
+ * the files it lists that this run did not produce are deleted before the
+ * new manifest replaces it, so a failed write or cleanup keeps the previous
+ * manifest for the next run.
*/
public void compile(Config config) {
List sources = sourceLoader.load(config);
@@ -45,9 +52,19 @@ public void compile(Config config) {
Path outputDirectory = Path.of(config.java().out());
+ List previousEntries = readManifest(outputDirectory);
+
for (GeneratedFile file : files) {
write(file, outputDirectory);
}
+
+ List generatedPaths = files.stream()
+ .map(GeneratedFile::path)
+ .toList();
+
+ deleteStale(outputDirectory, previousEntries, generatedPaths);
+
+ writeManifest(outputDirectory, generatedPaths);
}
/** Generates one repository per configured query group. */
@@ -201,6 +218,34 @@ private void write(GeneratedFile file, Path outputDirectory) {
}
}
+ private List readManifest(Path outputDirectory) {
+ try {
+ return generatedOutputManifest.read(outputDirectory);
+ } catch (IOException e) {
+ throw new CompilationException(e.getMessage(), e);
+ }
+ }
+
+ private void deleteStale(
+ Path outputDirectory,
+ List previousEntries,
+ List generatedPaths
+ ) {
+ try {
+ generatedOutputManifest.deleteStale(outputDirectory, previousEntries, generatedPaths);
+ } catch (IOException e) {
+ throw new CompilationException(e.getMessage(), e);
+ }
+ }
+
+ private void writeManifest(Path outputDirectory, List generatedPaths) {
+ try {
+ generatedOutputManifest.write(outputDirectory, generatedPaths);
+ } catch (IOException e) {
+ throw new CompilationException(e.getMessage(), e);
+ }
+ }
+
private record GeneratedRepository(String groupName, Path path) {
}
}
diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/io/GeneratedOutputManifest.java b/sqlcj-cli/src/main/java/dev/sqlcj/io/GeneratedOutputManifest.java
new file mode 100644
index 0000000..ba0aeaa
--- /dev/null
+++ b/sqlcj-cli/src/main/java/dev/sqlcj/io/GeneratedOutputManifest.java
@@ -0,0 +1,200 @@
+package dev.sqlcj.io;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.InvalidPathException;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Records the generated files of one successful run in {@code java.out} and
+ * deletes the files a previous run recorded that this run did not produce.
+ *
+ *
Every entry is one generated file path relative to the output directory,
+ * with {@code /} between its name elements, so a manifest written on one
+ * filesystem reads the same on another.
+ */
+public final class GeneratedOutputManifest {
+
+ /** The manifest file name, inside the output directory. */
+ public static final String FILE_NAME = "sqlcj-manifest.txt";
+
+ /**
+ * The entries of the manifest a previous run wrote, or no entry when the
+ * output directory holds no manifest.
+ */
+ public List read(Path outputDirectory) throws IOException {
+ Path manifestPath = manifestPath(outputDirectory);
+
+ if (!Files.exists(manifestPath)) {
+ return List.of();
+ }
+
+ try {
+ return List.copyOf(
+ Files.readAllLines(manifestPath, StandardCharsets.UTF_8)
+ );
+ } catch (IOException e) {
+ throw new IOException("Cannot read output manifest: " + manifestPath, e);
+ }
+ }
+
+ /**
+ * Deletes every generated file the previous manifest listed that this run
+ * did not produce, and nothing else. An entry that does not name a regular
+ * file inside the output directory is left alone.
+ */
+ public void deleteStale(
+ Path outputDirectory,
+ List previousEntries,
+ List generatedPaths
+ ) throws IOException {
+ List producedEntries = entries(generatedPaths);
+
+ for (String previousEntry : previousEntries) {
+ if (producedEntries.contains(previousEntry)) {
+ continue;
+ }
+
+ deleteStaleEntry(outputDirectory, previousEntry, generatedPaths);
+ }
+ }
+
+ /** Records the generated paths of one successful run, one entry per line. */
+ public void write(Path outputDirectory, List generatedPaths) throws IOException {
+ Path manifestPath = manifestPath(outputDirectory);
+
+ StringBuilder content = new StringBuilder();
+
+ entries(generatedPaths)
+ .stream()
+ .sorted()
+ .forEach(entry -> content.append(entry).append('\n'));
+
+ try {
+ Files.writeString(
+ manifestPath,
+ content,
+ StandardCharsets.UTF_8,
+ StandardOpenOption.CREATE,
+ StandardOpenOption.TRUNCATE_EXISTING
+ );
+ } catch (IOException e) {
+ throw new IOException("Cannot write output manifest: " + manifestPath, e);
+ }
+ }
+
+ /**
+ * Deletes one stale entry, skipping an entry that does not name a regular
+ * file inside the output directory and an entry that names a file this run
+ * wrote, which a rename differing only by case produces on a
+ * case-insensitive filesystem.
+ */
+ private void deleteStaleEntry(
+ Path outputDirectory,
+ String entry,
+ List generatedPaths
+ ) throws IOException {
+ Path relativePath = relativePath(entry);
+
+ if (relativePath == null) {
+ return;
+ }
+
+ Path file = outputDirectory.resolve(relativePath);
+
+ try {
+ if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
+ return;
+ }
+
+ if (isGenerated(file, outputDirectory, generatedPaths)) {
+ return;
+ }
+
+ Files.delete(file);
+ } catch (IOException e) {
+ throw new IOException("Cannot delete stale generated file: " + file, e);
+ }
+ }
+
+ /**
+ * The relative path of one manifest entry, or {@code null} when the entry
+ * is not a normalized relative path that stays inside the output
+ * directory.
+ */
+ private Path relativePath(String entry) {
+ if (entry.isEmpty()) {
+ return null;
+ }
+
+ Path path;
+
+ try {
+ path = Path.of(entry);
+ } catch (InvalidPathException e) {
+ return null;
+ }
+
+ if (path.getRoot() != null) {
+ return null;
+ }
+
+ if (path.getName(0).toString().equals("..")) {
+ return null;
+ }
+
+ if (!entry.equals(entry(path.normalize()))) {
+ return null;
+ }
+
+ return path;
+ }
+
+ private boolean isGenerated(
+ Path file,
+ Path outputDirectory,
+ List generatedPaths
+ ) throws IOException {
+ for (Path generatedPath : generatedPaths) {
+ if (Files.isSameFile(file, outputDirectory.resolve(generatedPath))) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private List entries(List generatedPaths) {
+ List entries = new ArrayList<>(generatedPaths.size());
+
+ for (Path generatedPath : generatedPaths) {
+ entries.add(entry(generatedPath));
+ }
+
+ return entries;
+ }
+
+ /** One entry, joining the path's name elements with {@code /}. */
+ private String entry(Path path) {
+ StringBuilder entry = new StringBuilder();
+
+ for (Path name : path) {
+ if (!entry.isEmpty()) {
+ entry.append('/');
+ }
+
+ entry.append(name);
+ }
+
+ return entry.toString();
+ }
+
+ private Path manifestPath(Path outputDirectory) {
+ return outputDirectory.resolve(FILE_NAME);
+ }
+}
diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java
index e1b25e0..fffde51 100644
--- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java
+++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java
@@ -24,9 +24,11 @@
import java.sql.Connection;
import java.sql.Statement;
import java.time.LocalDateTime;
+import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
@@ -1411,6 +1413,10 @@ name VARCHAR(255)
assertTrue(previous.contains("resultSet.getObject(1, Long.class)"));
+ byte[] previousManifest = Files.readAllBytes(
+ generatedDirectory.resolve("sqlcj-manifest.txt")
+ );
+
Files.writeString(
usersQueries,
"""
@@ -1469,6 +1475,11 @@ name VARCHAR(255)
assertEquals(previous, Files.readString(generatedFile));
+ assertArrayEquals(
+ previousManifest,
+ Files.readAllBytes(generatedDirectory.resolve("sqlcj-manifest.txt"))
+ );
+
assertFalse(
Files.exists(
generatedDirectory
@@ -1478,6 +1489,140 @@ name VARCHAR(255)
);
}
+ @Test
+ void shouldDeleteTheStaleRepositoriesOfRenamedAndRemovedGroups() throws IOException {
+ Path generatedDirectory = tempDir.resolve("generated");
+
+ compileGroups(generatedDirectory, "dev.example.generated", "Users", "Orders");
+
+ Path packageDirectory = generatedDirectory.resolve("dev/example/generated");
+
+ assertTrue(Files.exists(packageDirectory.resolve("UsersRepository.java")));
+ assertTrue(Files.exists(packageDirectory.resolve("OrdersRepository.java")));
+
+ compileGroups(generatedDirectory, "dev.example.generated", "Customers");
+
+ assertFalse(Files.exists(packageDirectory.resolve("UsersRepository.java")));
+ assertFalse(Files.exists(packageDirectory.resolve("OrdersRepository.java")));
+ assertTrue(Files.exists(packageDirectory.resolve("CustomersRepository.java")));
+
+ assertEquals(
+ "dev/example/generated/CustomersRepository.java\n",
+ Files.readString(generatedDirectory.resolve("sqlcj-manifest.txt"))
+ );
+ }
+
+ /** A changed package leaves the previous package directory in place. */
+ @Test
+ void shouldDeleteTheStaleRepositoryOfAChangedPackage() throws IOException {
+ Path generatedDirectory = tempDir.resolve("generated");
+
+ compileGroups(generatedDirectory, "dev.example.generated", "Users");
+
+ Path previousPackageDirectory = generatedDirectory.resolve("dev/example/generated");
+
+ assertTrue(Files.exists(previousPackageDirectory.resolve("UsersRepository.java")));
+
+ compileGroups(generatedDirectory, "dev.other.generated", "Users");
+
+ assertFalse(Files.exists(previousPackageDirectory.resolve("UsersRepository.java")));
+ assertTrue(Files.isDirectory(previousPackageDirectory));
+
+ assertTrue(
+ Files.exists(
+ generatedDirectory.resolve("dev/other/generated/UsersRepository.java")
+ )
+ );
+
+ assertEquals(
+ "dev/other/generated/UsersRepository.java\n",
+ Files.readString(generatedDirectory.resolve("sqlcj-manifest.txt"))
+ );
+ }
+
+ @Test
+ void shouldKeepAFileNoPreviousManifestListed() throws IOException {
+ Path generatedDirectory = tempDir.resolve("generated");
+ Path packageDirectory = generatedDirectory.resolve("dev/example/generated");
+
+ Files.createDirectories(packageDirectory);
+
+ Path staleLookingFile = packageDirectory.resolve("OrdersRepository.java");
+
+ Files.writeString(staleLookingFile, "// written before the first run\n");
+
+ compileGroups(generatedDirectory, "dev.example.generated", "Users");
+
+ assertEquals(
+ "// written before the first run\n",
+ Files.readString(staleLookingFile)
+ );
+
+ Path userFile = packageDirectory.resolve("Helper.java");
+
+ Files.writeString(userFile, "// hand written\n");
+
+ compileGroups(generatedDirectory, "dev.example.generated", "Users");
+
+ assertEquals("// hand written\n", Files.readString(userFile));
+
+ assertEquals(
+ "// written before the first run\n",
+ Files.readString(staleLookingFile)
+ );
+ }
+
+ @Test
+ void shouldGenerateByteIdenticalRepositoriesAndManifestForTwoRuns() throws IOException {
+ Path generatedDirectory = tempDir.resolve("generated");
+
+ compileGroups(generatedDirectory, "dev.example.generated", "Users", "Orders");
+
+ Path manifestFile = generatedDirectory.resolve("sqlcj-manifest.txt");
+ Path usersFile = generatedDirectory.resolve("dev/example/generated/UsersRepository.java");
+ Path ordersFile = generatedDirectory.resolve("dev/example/generated/OrdersRepository.java");
+
+ byte[] firstManifest = Files.readAllBytes(manifestFile);
+ byte[] firstUsers = Files.readAllBytes(usersFile);
+ byte[] firstOrders = Files.readAllBytes(ordersFile);
+
+ compileGroups(generatedDirectory, "dev.example.generated", "Users", "Orders");
+
+ assertArrayEquals(firstManifest, Files.readAllBytes(manifestFile));
+ assertArrayEquals(firstUsers, Files.readAllBytes(usersFile));
+ assertArrayEquals(firstOrders, Files.readAllBytes(ordersFile));
+
+ assertEquals(
+ "dev/example/generated/OrdersRepository.java\n"
+ + "dev/example/generated/UsersRepository.java\n",
+ Files.readString(manifestFile)
+ );
+ }
+
+ @Test
+ void shouldReportTheOutputManifestItCannotRead() throws IOException {
+ Path generatedDirectory = tempDir.resolve("generated");
+ Path manifestFile = generatedDirectory.resolve("sqlcj-manifest.txt");
+
+ Files.createDirectories(manifestFile);
+
+ CompilationException exception = assertThrows(
+ CompilationException.class,
+ () -> compileGroups(generatedDirectory, "dev.example.generated", "Users")
+ );
+
+ assertEquals(
+ "Cannot read output manifest: " + manifestFile,
+ exception.getMessage()
+ );
+
+ assertFalse(
+ Files.exists(
+ generatedDirectory.resolve("dev/example/generated/UsersRepository.java")
+ )
+ );
+ }
+
@Test
void shouldReportTheGeneratedFileItCannotWrite() throws IOException {
Path usersSchema = tempDir.resolve("users-schema.sql");
@@ -1906,6 +2051,59 @@ name VARCHAR(255)
new SqlcjCompiler().compile(config);
}
+ /**
+ * Compiles the named query groups of one shared users schema and query
+ * source into one configured package.
+ */
+ private void compileGroups(
+ Path generatedDirectory,
+ String packageName,
+ String... groupNames
+ ) throws IOException {
+ Path schemaFile = tempDir.resolve("schema.sql");
+ Path queriesFile = tempDir.resolve("queries.sql");
+
+ Files.writeString(
+ schemaFile,
+ """
+ CREATE TABLE users
+ (
+ id BIGINT NOT NULL,
+ name VARCHAR(255)
+ );
+ """
+ );
+
+ Files.writeString(
+ queriesFile,
+ """
+ -- name: GetUser :one
+ SELECT id, name
+ FROM users
+ WHERE id = $1;
+ """
+ );
+
+ List sql = new ArrayList<>(groupNames.length);
+
+ for (String groupName : groupNames) {
+ sql.add(
+ new SqlConfig(
+ groupName,
+ schemaFile.toString(),
+ queriesFile.toString()
+ )
+ );
+ }
+
+ new SqlcjCompiler().compile(
+ new Config(
+ List.copyOf(sql),
+ new JavaConfig(generatedDirectory.toString(), packageName)
+ )
+ );
+ }
+
/** Compiles two configured groups that share one schema and query source. */
private void compileUsersGroups(
String firstGroup,
diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/io/GeneratedOutputManifestTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/io/GeneratedOutputManifestTest.java
new file mode 100644
index 0000000..fcb974a
--- /dev/null
+++ b/sqlcj-cli/src/test/java/dev/sqlcj/io/GeneratedOutputManifestTest.java
@@ -0,0 +1,203 @@
+package dev.sqlcj.io;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class GeneratedOutputManifestTest {
+
+ @TempDir
+ Path tempDir;
+
+ private final GeneratedOutputManifest manifest = new GeneratedOutputManifest();
+
+ @Test
+ void shouldReadNoEntryWithoutAManifest() throws IOException {
+ assertEquals(List.of(), manifest.read(tempDir.resolve("generated")));
+ }
+
+ @Test
+ void shouldReadTheEntriesOfAPreviousManifest() throws IOException {
+ Path outputDirectory = tempDir.resolve("generated");
+
+ Files.createDirectories(outputDirectory);
+
+ Files.writeString(
+ outputDirectory.resolve("sqlcj-manifest.txt"),
+ """
+ dev/example/generated/OrdersRepository.java
+ dev/example/generated/UsersRepository.java
+ """
+ );
+
+ assertEquals(
+ List.of(
+ "dev/example/generated/OrdersRepository.java",
+ "dev/example/generated/UsersRepository.java"
+ ),
+ manifest.read(outputDirectory)
+ );
+ }
+
+ @Test
+ void shouldWriteSortedEntriesTerminatedByNewlines() throws IOException {
+ Path outputDirectory = tempDir.resolve("generated");
+
+ Files.createDirectories(outputDirectory);
+
+ manifest.write(
+ outputDirectory,
+ List.of(
+ Path.of("dev", "example", "generated", "UsersRepository.java"),
+ Path.of("dev", "example", "generated", "OrdersRepository.java")
+ )
+ );
+
+ assertEquals(
+ "dev/example/generated/OrdersRepository.java\n"
+ + "dev/example/generated/UsersRepository.java\n",
+ Files.readString(outputDirectory.resolve("sqlcj-manifest.txt"))
+ );
+ }
+
+ @Test
+ void shouldReportTheManifestItCannotRead() throws IOException {
+ Path outputDirectory = tempDir.resolve("generated");
+ Path manifestFile = outputDirectory.resolve("sqlcj-manifest.txt");
+
+ Files.createDirectories(manifestFile);
+
+ IOException failure = assertThrows(
+ IOException.class,
+ () -> manifest.read(outputDirectory)
+ );
+
+ assertEquals(
+ "Cannot read output manifest: " + manifestFile,
+ failure.getMessage()
+ );
+ }
+
+ @Test
+ void shouldReportTheManifestItCannotWrite() throws IOException {
+ Path outputDirectory = tempDir.resolve("generated");
+ Path manifestFile = outputDirectory.resolve("sqlcj-manifest.txt");
+
+ Files.createDirectories(manifestFile);
+
+ IOException failure = assertThrows(
+ IOException.class,
+ () -> manifest.write(
+ outputDirectory,
+ List.of(Path.of("generated", "UsersRepository.java"))
+ )
+ );
+
+ assertEquals(
+ "Cannot write output manifest: " + manifestFile,
+ failure.getMessage()
+ );
+ }
+
+ @Test
+ void shouldDeleteOnlyAListedFileThisRunDidNotProduce() throws IOException {
+ Path outputDirectory = tempDir.resolve("generated");
+
+ Path produced = write(outputDirectory, "generated/UsersRepository.java");
+ Path stale = write(outputDirectory, "generated/OrdersRepository.java");
+
+ manifest.deleteStale(
+ outputDirectory,
+ List.of(
+ "generated/UsersRepository.java",
+ "generated/OrdersRepository.java"
+ ),
+ List.of(Path.of("generated", "UsersRepository.java"))
+ );
+
+ assertTrue(Files.exists(produced));
+ assertFalse(Files.exists(stale));
+ }
+
+ @Test
+ void shouldKeepAnUnlistedFile() throws IOException {
+ Path outputDirectory = tempDir.resolve("generated");
+
+ write(outputDirectory, "generated/UsersRepository.java");
+
+ Path unlisted = write(outputDirectory, "generated/Helper.java");
+
+ manifest.deleteStale(
+ outputDirectory,
+ List.of("generated/UsersRepository.java"),
+ List.of(Path.of("generated", "UsersRepository.java"))
+ );
+
+ assertTrue(Files.exists(unlisted));
+ }
+
+ /**
+ * An entry that escapes the output directory, is absolute, is not
+ * normalized, is blank, names a directory, or names the same file this run
+ * produced is never deleted.
+ */
+ @Test
+ void shouldKeepAListedEntryItMustNotDelete() throws IOException {
+ Path outputDirectory = tempDir.resolve("generated");
+
+ Path produced = write(outputDirectory, "generated/UsersRepository.java");
+
+ Path outside = tempDir.resolve("outside.java");
+
+ Files.writeString(outside, "// outside\n");
+
+ Path absolute = write(outputDirectory, "generated/AbsoluteRepository.java");
+ Path notNormalized = write(outputDirectory, "generated/StaleRepository.java");
+ Path directory = outputDirectory.resolve("generated/nested");
+
+ Files.createDirectories(directory);
+
+ Path hardLink = outputDirectory.resolve("generated/LinkedRepository.java");
+
+ Files.createLink(hardLink, produced);
+
+ manifest.deleteStale(
+ outputDirectory,
+ List.of(
+ "../outside.java",
+ absolute.toString(),
+ "generated/./StaleRepository.java",
+ "",
+ "generated/nested",
+ "generated/LinkedRepository.java"
+ ),
+ List.of(Path.of("generated", "UsersRepository.java"))
+ );
+
+ assertTrue(Files.exists(outside));
+ assertTrue(Files.exists(absolute));
+ assertTrue(Files.exists(notNormalized));
+ assertTrue(Files.isDirectory(directory));
+ assertTrue(Files.exists(hardLink));
+ assertTrue(Files.exists(produced));
+ }
+
+ private Path write(Path outputDirectory, String entry) throws IOException {
+ Path file = outputDirectory.resolve(entry);
+
+ Files.createDirectories(file.getParent());
+
+ Files.writeString(file, "// " + entry + "\n");
+
+ return file;
+ }
+}