From 3e35b0045c84f0d619117ad5b9a6092a14ada10d Mon Sep 17 00:00:00 2001 From: Patrick Tyska Date: Mon, 10 Aug 2026 15:17:39 -0700 Subject: [PATCH] Extract shared readManifest and writeManifest utilities --- .../kotlin/xyz/block/microfilm/Manifest.kt | 43 +++++++ .../microfilm/compression/CompressTask.kt | 22 +--- .../block/microfilm/scanning/RealScanner.kt | 18 +-- .../xyz/block/microfilm/ManifestTest.kt | 119 ++++++++++++++++++ 4 files changed, 167 insertions(+), 35 deletions(-) create mode 100644 plugin/src/test/kotlin/xyz/block/microfilm/ManifestTest.kt diff --git a/plugin/src/main/kotlin/xyz/block/microfilm/Manifest.kt b/plugin/src/main/kotlin/xyz/block/microfilm/Manifest.kt index 8e15e75..d0ee0b8 100644 --- a/plugin/src/main/kotlin/xyz/block/microfilm/Manifest.kt +++ b/plugin/src/main/kotlin/xyz/block/microfilm/Manifest.kt @@ -15,7 +15,11 @@ */ package xyz.block.microfilm +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okio.FileSystem +import okio.Path import xyz.block.microfilm.Manifest.Compressor @Serializable @@ -38,6 +42,27 @@ internal data class Manifest(val entries: List = emptyList()) { val compressionMethod: Int?, val metadata: String?, ) + + internal fun toJson(): String = + JSON_SERIALIZER.encodeToString(serializer = serializer(), value = this) + "\n" + + companion object { + internal fun fromJson(json: String): Manifest = + JSON_DESERIALIZER.decodeFromString(string = json) + + @OptIn(ExperimentalSerializationApi::class) + private val JSON_SERIALIZER = Json { + encodeDefaults = true + explicitNulls = false + prettyPrint = true + prettyPrintIndent = " " + } + + private val JSON_DESERIALIZER = Json { + explicitNulls = false + ignoreUnknownKeys = true + } + } } internal fun ImageSettings.Compress.toCompressor(cwebpVersion: String) = @@ -49,3 +74,21 @@ internal fun ImageSettings.Compress.toCompressor(cwebpVersion: String) = compressionMethod = compressionMethod, metadata = metadata?.toString(), ) + +/** Reads a manifest from disk, or returns an empty manifest if there isn't an existing file. */ +internal fun FileSystem.readManifest(path: Path): Manifest = + if (exists(path = path)) { + read(file = path) { readUtf8() }.let { string -> Manifest.fromJson(json = string) } + } else { + Manifest() + } + +/** Writes the manifest to disk, or deletes the existing file if the manifest is empty. */ +internal fun FileSystem.writeManifest(path: Path, manifest: Manifest) { + if (manifest.entries.isEmpty()) { + delete(path = path) + } else { + path.parent?.let { parent -> createDirectories(dir = parent) } + write(file = path) { writeUtf8(manifest.toJson()) } + } +} diff --git a/plugin/src/main/kotlin/xyz/block/microfilm/compression/CompressTask.kt b/plugin/src/main/kotlin/xyz/block/microfilm/compression/CompressTask.kt index acf2a41..544d1b1 100644 --- a/plugin/src/main/kotlin/xyz/block/microfilm/compression/CompressTask.kt +++ b/plugin/src/main/kotlin/xyz/block/microfilm/compression/CompressTask.kt @@ -16,8 +16,6 @@ package xyz.block.microfilm.compression import javax.inject.Inject -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.Json import okio.FileSystem import okio.Path.Companion.toOkioPath import org.gradle.api.DefaultTask @@ -39,6 +37,7 @@ import xyz.block.microfilm.compression.Compressor.Result.Failure import xyz.block.microfilm.compression.Compressor.Result.Success import xyz.block.microfilm.cwebp.RealCwebp import xyz.block.microfilm.scanning.RealScanner +import xyz.block.microfilm.writeManifest @DisableCachingByDefault(because = "This task modifies the source tree in place") internal abstract class CompressTask @@ -123,14 +122,7 @@ constructor(private val execOperations: ExecOperations) : DefaultTask() { ) // Write the manifest to disk - if (manifest.entries.isEmpty()) { - fileSystem.delete(path = microfilmManifestPath) - } else { - fileSystem.createDirectories(dir = microfilmDirectoryPath) - fileSystem.write(file = microfilmManifestPath) { - writeUtf8(JSON.encodeToString(serializer = Manifest.serializer(), value = manifest) + "\n") - } - } + fileSystem.writeManifest(path = microfilmManifestPath, manifest = manifest) // Fail if any images could not be compressed val failures = results.filterIsInstance() @@ -143,14 +135,4 @@ constructor(private val execOperations: ExecOperations) : DefaultTask() { ) } } - - companion object { - @OptIn(ExperimentalSerializationApi::class) - private val JSON = Json { - encodeDefaults = true - explicitNulls = false - prettyPrint = true - prettyPrintIndent = " " - } - } } diff --git a/plugin/src/main/kotlin/xyz/block/microfilm/scanning/RealScanner.kt b/plugin/src/main/kotlin/xyz/block/microfilm/scanning/RealScanner.kt index 2b2bb36..18c336a 100644 --- a/plugin/src/main/kotlin/xyz/block/microfilm/scanning/RealScanner.kt +++ b/plugin/src/main/kotlin/xyz/block/microfilm/scanning/RealScanner.kt @@ -15,14 +15,13 @@ */ package xyz.block.microfilm.scanning -import kotlinx.serialization.json.Json import okio.FileSystem import okio.Path import okio.Path.Companion.toPath -import xyz.block.microfilm.Manifest import xyz.block.microfilm.isPngDrawable import xyz.block.microfilm.isWebpDrawable import xyz.block.microfilm.listRecursivelyOrEmpty +import xyz.block.microfilm.readManifest /** A [Scanner] backed by an Okio [FileSystem]. */ internal class RealScanner( @@ -54,14 +53,8 @@ internal class RealScanner( // Read the manifest entries val microfilmManifestPath = microfilmDirectory.resolve("manifest.json") val microfilmManifestEntries = - if (fileSystem.exists(path = microfilmManifestPath)) { - fileSystem - .read(file = microfilmManifestPath) { readUtf8() } - .let { string -> JSON.decodeFromString(string = string) } - .entries - .associateBy { entry -> entry.sourcePath.toPath().key } - } else { - emptyMap() + fileSystem.readManifest(path = microfilmManifestPath).entries.associateBy { entry -> + entry.sourcePath.toPath().key } // Group the images for each unique key @@ -85,11 +78,6 @@ internal class RealScanner( } } -private val JSON = Json { - explicitNulls = false - ignoreUnknownKeys = true -} - private val Path.key get() = buildList { addAll(segments.dropLast(n = 1)) diff --git a/plugin/src/test/kotlin/xyz/block/microfilm/ManifestTest.kt b/plugin/src/test/kotlin/xyz/block/microfilm/ManifestTest.kt new file mode 100644 index 0000000..78dcf11 --- /dev/null +++ b/plugin/src/test/kotlin/xyz/block/microfilm/ManifestTest.kt @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2026 Block, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package xyz.block.microfilm + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import okio.fakefilesystem.FakeFileSystem +import org.junit.jupiter.api.Test +import xyz.block.microfilm.scanning.ImageGroupFixtures.LOSSLESS_MANIFEST_ENTRY +import xyz.block.microfilm.scanning.ImageGroupFixtures.LOSSY_MANIFEST_ENTRY +import xyz.block.microfilm.scanning.ImageGroupFixtures.MICROFILM_DIRECTORY + +class ManifestTest { + private val fileSystem = FakeFileSystem() + + @Test + fun `toJson serializes Manifest`() { + assertThat(LOSSLESS_MANIFEST.toJson()).isEqualTo(LOSSLESS_JSON) + } + + @Test + fun `fromJson deserializes Manifest`() { + assertThat(Manifest.fromJson(json = LOSSLESS_JSON)).isEqualTo(LOSSLESS_MANIFEST) + } + + @Test + fun `readManifest without existing file returns empty manifest`() { + val manifest = fileSystem.readManifest(path = MANIFEST_PATH) + + assertThat(manifest).isEqualTo(EMPTY_MANIFEST) + } + + @Test + fun `readManifest with existing file returns valid manifest`() { + fileSystem.writeManifest(path = MANIFEST_PATH, manifest = LOSSLESS_MANIFEST) + + val manifest = fileSystem.readManifest(path = MANIFEST_PATH) + + assertThat(manifest).isEqualTo(LOSSLESS_MANIFEST) + } + + @Test + fun `writeManifest empty manifest without existing file does nothing`() { + fileSystem.writeManifest(path = MANIFEST_PATH, manifest = EMPTY_MANIFEST) + + assertThat(fileSystem.exists(path = MANIFEST_PATH)).isFalse() + } + + @Test + fun `writeManifest empty manifest with existing file deletes existing file`() { + fileSystem.writeManifest(path = MANIFEST_PATH, manifest = LOSSLESS_MANIFEST) + + fileSystem.writeManifest(path = MANIFEST_PATH, manifest = EMPTY_MANIFEST) + + assertThat(fileSystem.exists(path = MANIFEST_PATH)).isFalse() + } + + @Test + fun `writeManifest valid manifest without existing file writes new file`() { + fileSystem.writeManifest(path = MANIFEST_PATH, manifest = LOSSLESS_MANIFEST) + + assertThat(fileSystem.exists(path = MANIFEST_PATH)).isTrue() + assertThat(fileSystem.readManifest(path = MANIFEST_PATH)).isEqualTo(LOSSLESS_MANIFEST) + } + + @Test + fun `writeManifest valid manifest with existing file overwrites existing file`() { + fileSystem.writeManifest(path = MANIFEST_PATH, manifest = LOSSY_MANIFEST) + + fileSystem.writeManifest(path = MANIFEST_PATH, manifest = LOSSLESS_MANIFEST) + + assertThat(fileSystem.exists(path = MANIFEST_PATH)).isTrue() + assertThat(fileSystem.readManifest(path = MANIFEST_PATH)).isEqualTo(LOSSLESS_MANIFEST) + } + + companion object { + val MANIFEST_PATH = MICROFILM_DIRECTORY.resolve(child = "manifest.json") + + private val EMPTY_MANIFEST = Manifest() + private val LOSSLESS_MANIFEST = Manifest(entries = listOf(LOSSLESS_MANIFEST_ENTRY)) + private val LOSSY_MANIFEST = Manifest(entries = listOf(LOSSY_MANIFEST_ENTRY)) + + private val LOSSLESS_JSON = + """ + { + "entries": [ + { + "sourcePath": "drawable/photo.png", + "sourceSha256": "8f8cbb7dcf46e0bc7d53265749a6c17d116093a6ba95e442764060c76fd4a86c", + "compressedPath": "drawable/photo.webp", + "compressedSha256": "a57bb082e728a0cdce930ecfcccf4510a3a247be5f322b09b3a971a3f5ed34f8", + "compressor": { + "name": "cwebp", + "version": "1.2.3", + "lossless": true + } + } + ] + } + + """ + .trimIndent() + } +}