diff --git a/zipline-gradle-plugin/src/main/kotlin/app/cash/zipline/gradle/ZiplineApiDumpTask.kt b/zipline-gradle-plugin/src/main/kotlin/app/cash/zipline/gradle/ZiplineApiValidationTask.kt similarity index 69% rename from zipline-gradle-plugin/src/main/kotlin/app/cash/zipline/gradle/ZiplineApiDumpTask.kt rename to zipline-gradle-plugin/src/main/kotlin/app/cash/zipline/gradle/ZiplineApiValidationTask.kt index 8920eb48cf..febf9555d6 100644 --- a/zipline-gradle-plugin/src/main/kotlin/app/cash/zipline/gradle/ZiplineApiDumpTask.kt +++ b/zipline-gradle-plugin/src/main/kotlin/app/cash/zipline/gradle/ZiplineApiValidationTask.kt @@ -32,13 +32,15 @@ import org.gradle.api.DefaultTask import org.gradle.api.file.RegularFileProperty import org.gradle.api.internal.file.FileCollectionFactory import org.gradle.api.tasks.Classpath +import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.TaskAction @Suppress("unused") // Public API for Gradle plugin users. -abstract class ZiplineApiDumpTask @Inject constructor( +abstract class ZiplineApiValidationTask @Inject constructor( fileCollectionFactory: FileCollectionFactory, + @Input val mode: Mode, ) : DefaultTask() { @get:OutputFile @@ -72,7 +74,22 @@ abstract class ZiplineApiDumpTask @Inject constructor( } is ExpectedApiRequiresUpdates -> { - tomlFile.sink().buffer().use { it.writeZiplineApi(decision.updatedApi) } + val tomlFileRelative = tomlFile.relativeTo(project.projectDir) + when (mode) { + Mode.Check -> { + throw Exception( + """ + |Zipline API file is incomplete. Run :ziplineApiDump to update it. + | $tomlFileRelative + """.trimMargin(), + ) + } + + Mode.Dump -> { + logger.info("Updated $tomlFileRelative because Zipline API has changed") + tomlFile.sink().buffer().use { it.writeZiplineApi(decision.updatedApi) } + } + } } ExpectedApiIsUpToDate -> { @@ -80,4 +97,21 @@ abstract class ZiplineApiDumpTask @Inject constructor( } } } + + /** + * This enum decides what happens when the actual API (.kt files) declares more services or + * functions than expected (.toml file): + * + * * ziplineApiCheck fails the build. + * * ziplineApiDump updates the TOML file. + * + * Both modes fail the build if the converse is true; ie. the actual API declares fewer services + * or functions than expected. + * + * Both modes succeed if the actual APIs are equal to the expectations. + */ + enum class Mode { + Check, + Dump, + } } diff --git a/zipline-gradle-plugin/src/main/kotlin/app/cash/zipline/gradle/ZiplinePlugin.kt b/zipline-gradle-plugin/src/main/kotlin/app/cash/zipline/gradle/ZiplinePlugin.kt index 2e597cc80e..7b328e75a2 100644 --- a/zipline-gradle-plugin/src/main/kotlin/app/cash/zipline/gradle/ZiplinePlugin.kt +++ b/zipline-gradle-plugin/src/main/kotlin/app/cash/zipline/gradle/ZiplinePlugin.kt @@ -79,7 +79,8 @@ class ZiplinePlugin : KotlinCompilerPluginSupportPlugin { target.tasks.withType(KotlinCompile::class.java) { kotlinCompile -> if (kotlinCompile.name == "compileKotlinJvm") { - registerZiplineApiDumpTask(target, kotlinCompile) + registerZiplineApiTask(target, kotlinCompile, ZiplineApiValidationTask.Mode.Check) + registerZiplineApiTask(target, kotlinCompile, ZiplineApiValidationTask.Mode.Dump) } } } @@ -113,22 +114,22 @@ class ZiplinePlugin : KotlinCompilerPluginSupportPlugin { return ziplineCompileTask } - private fun registerZiplineApiDumpTask( + private fun registerZiplineApiTask( project: Project, kotlinCompileTool: KotlinCompileTool, - ): TaskProvider { - val result = project.tasks.register( - "ziplineApiDump", - ZiplineApiDumpTask::class.java, + mode: ZiplineApiValidationTask.Mode, + ) { + val task = project.tasks.register( + "ziplineApi$mode", + ZiplineApiValidationTask::class.java, + mode, ) - result.configure { + task.configure { it.ziplineApiFile.set(project.file("api/zipline-api.toml")) it.sourcepath.setFrom(kotlinCompileTool.sources) it.classpath.setFrom(kotlinCompileTool.libraries) } - - return result } override fun applyToCompilation( diff --git a/zipline-gradle-plugin/src/test/kotlin/app/cash/zipline/gradle/ZiplinePluginTest.kt b/zipline-gradle-plugin/src/test/kotlin/app/cash/zipline/gradle/ZiplinePluginTest.kt index 5dec2261c4..e10ccde7d4 100644 --- a/zipline-gradle-plugin/src/test/kotlin/app/cash/zipline/gradle/ZiplinePluginTest.kt +++ b/zipline-gradle-plugin/src/test/kotlin/app/cash/zipline/gradle/ZiplinePluginTest.kt @@ -196,16 +196,160 @@ class ZiplinePluginTest { } @Test - fun ziplineApiDumpCreatesTomlFile() { + fun ziplineApiDumpDoesNothingOnApiMatch() { + ziplineApiTaskDoesNothingOnApiMatch(":lib:ziplineApiDump") + } + + @Test + fun ziplineApiCheckDoesNothingOnApiMatch() { + ziplineApiTaskDoesNothingOnApiMatch(":lib:ziplineApiCheck") + } + + private fun ziplineApiTaskDoesNothingOnApiMatch(taskName: String) { val projectDir = File("src/test/projects/basic") val ziplineApiToml = projectDir.resolve("lib/api/zipline-api.toml") + ziplineApiToml.parentFile.mkdirs() + + val ziplineApiTomlContent = """ + |# This comment will be clobbered if this file is overwritten + |# by the Gradle task. + | + |[app.cash.zipline.tests.GreetService] + | + |functions = [ + | # fun close(): kotlin.Unit + | "moYx+T3e", + | + | # This comment will also be clobbered on an unexpected update. + | "ipvircui", + |] + """.trimMargin() + ziplineApiToml.writeText(ziplineApiTomlContent) + + try { + createRunner(projectDir, "clean", taskName).build() + assertThat(ziplineApiToml.readText()) + .isEqualTo(ziplineApiTomlContent) + } finally { + ziplineApiToml.delete() + } + } + + @Test + fun ziplineApiCheckFailsOnDroppedApi() { + ziplineApiTaskFailsOnDroppedApi(":lib:ziplineApiCheck") + } + + @Test + fun ziplineApiDumpFailsOnDroppedApi() { + ziplineApiTaskFailsOnDroppedApi(":lib:ziplineApiDump") + } + + private fun ziplineApiTaskFailsOnDroppedApi(taskName: String) { + val projectDir = File("src/test/projects/basic") + val ziplineApiToml = projectDir.resolve("lib/api/zipline-api.toml") + ziplineApiToml.parentFile.mkdirs() + + // Expect an API that contains a function not offered. + ziplineApiToml.writeText( + """ + |[app.cash.zipline.tests.GreetService] + | + |functions = [ + | # fun close(): kotlin.Unit + | "moYx+T3e", + | + | # fun greet(kotlin.String): kotlin.String + | "ipvircui", + | + | # fun hello(kotlin.String): kotlin.String + | "Cw62Cti7", + |] + | + """.trimMargin(), + ) + + try { + val result = createRunner(projectDir, "clean", taskName).buildAndFail() + assertThat(result.output).contains( + """ + | Expected function Cw62Cti7 of app.cash.zipline.tests.GreetService not found: + | fun hello(kotlin.String): kotlin.String + """.trimMargin(), + ) + } finally { + ziplineApiToml.delete() + } + } + + @Test + fun ziplineApiDumpCreatesNewTomlFile() { + val projectDir = File("src/test/projects/basic") + val ziplineApiToml = projectDir.resolve("lib/api/zipline-api.toml") + ziplineApiToml.delete() // In case a previous execution crashed. try { val taskName = ":lib:ziplineApiDump" - val result = createRunner(projectDir, "clean", taskName).build() - assertThat(SUCCESS_OUTCOMES) - .contains(result.task(taskName)!!.outcome) + createRunner(projectDir, "clean", taskName).build() + assertThat(ziplineApiToml.readText()).isEqualTo( + """ + |[app.cash.zipline.tests.GreetService] + | + |functions = [ + | # fun close(): kotlin.Unit + | "moYx+T3e", + | + | # fun greet(kotlin.String): kotlin.String + | "ipvircui", + |] + | + """.trimMargin(), + ) + } finally { + ziplineApiToml.delete() + } + } + + @Test + fun ziplineApiCheckFailsOnMissingTomlFile() { + val projectDir = File("src/test/projects/basic") + val ziplineApiToml = projectDir.resolve("lib/api/zipline-api.toml") + ziplineApiToml.delete() // In case a previous execution crashed. + + val taskName = ":lib:ziplineApiCheck" + val result = createRunner(projectDir, "clean", taskName).buildAndFail() + assertThat(result.output).contains( + """ + |Zipline API file is incomplete. Run :ziplineApiDump to update it. + | api/zipline-api.toml + """.trimMargin(), + ) + } + + @Test + fun ziplineApiDumpUpdatesIncompleteFile() { + val projectDir = File("src/test/projects/basic") + val ziplineApiToml = projectDir.resolve("lib/api/zipline-api.toml") + ziplineApiToml.parentFile.mkdirs() + + // Expect an API that doesn't declare 'greet'. + ziplineApiToml.writeText( + """ + |[app.cash.zipline.tests.GreetService] + | + |functions = [ + | # fun close(): kotlin.Unit + | "moYx+T3e", + |] + | + """.trimMargin(), + ) + + try { + val taskName = ":lib:ziplineApiDump" + createRunner(projectDir, "clean", taskName).build() + // The task updates the file to include the 'greet' function. assertThat(ziplineApiToml.readText()).isEqualTo( """ |[app.cash.zipline.tests.GreetService] @@ -225,6 +369,39 @@ class ZiplinePluginTest { } } + @Test + fun ziplineApiCheckFailsOnIncompleteFile() { + val projectDir = File("src/test/projects/basic") + val ziplineApiToml = projectDir.resolve("lib/api/zipline-api.toml") + ziplineApiToml.parentFile.mkdirs() + + // Expect an API that doesn't declare 'greet'. + ziplineApiToml.writeText( + """ + |[app.cash.zipline.tests.GreetService] + | + |functions = [ + | # fun close(): kotlin.Unit + | "moYx+T3e", + |] + | + """.trimMargin(), + ) + + try { + val taskName = ":lib:ziplineApiCheck" + val result = createRunner(projectDir, "clean", taskName).buildAndFail() + assertThat(result.output).contains( + """ + |Zipline API file is incomplete. Run :ziplineApiDump to update it. + | api/zipline-api.toml + """.trimMargin(), + ) + } finally { + ziplineApiToml.delete() + } + } + private fun createRunner(projectDir: File, vararg taskNames: String): GradleRunner { val gradleRoot = projectDir.resolve("gradle").also { it.mkdir() } File("../gradle/wrapper").copyRecursively(gradleRoot.resolve("wrapper"), true)