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; + } +}