Repository: config4k/config4k
Pull request #306 (2025-03-03 07:00:34)

`java.nio.file.Path` should be serializable

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/config4k/config4k/pull/306?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=config4k) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 89.73%. Comparing base [(`a626982`)](https://app.codecov.io/gh/config4k/config4k/commit/a62698211df598a0b596ce414f946bf1f42cf1d6?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=config4k) to head [(`83677ee`)](https://app.codecov.io/gh/config4k/config4k/commit/83677ee87987fc0ce560bac4d2fa774a951c8a0b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=config4k).

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff              @@
##               main     #306      +/-   ##
============================================
+ Coverage     89.66%   89.73%   +0.07%     
- Complexity      131      132       +1     
============================================
  Files            42       43       +1     
  Lines           416      419       +3     
  Branches         93       93              
============================================
+ Hits            373      376       +3     
  Misses           22       22              
  Partials         21       21              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/config4k/config4k/pull/306?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=config4k).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=config4k).


Patch:
diff --git a/api/config4k.api b/api/config4k.api
index 34c935f..7c80f87 100644
--- a/api/config4k.api
+++ b/api/config4k.api
@@ -74,6 +74,15 @@ public final class io/github/config4k/serializers/ConfigValueSerializer : kotlin
 	public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V
 }
 
+public final class io/github/config4k/serializers/PathSerializer : kotlinx/serialization/KSerializer {
+	public static final field INSTANCE Lio/github/config4k/serializers/PathSerializer;
+	public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object;
+	public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/nio/file/Path;
+	public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor;
+	public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V
+	public fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/nio/file/Path;)V
+}
+
 public final class io/github/config4k/serializers/PatternSerializer : kotlinx/serialization/KSerializer {
 	public static final field INSTANCE Lio/github/config4k/serializers/PatternSerializer;
 	public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object;
diff --git a/src/main/kotlin/io/github/config4k/Config4k.kt b/src/main/kotlin/io/github/config4k/Config4k.kt
index 49f1017..e86f5d0 100644
--- a/src/main/kotlin/io/github/config4k/Config4k.kt
+++ b/src/main/kotlin/io/github/config4k/Config4k.kt
@@ -2,6 +2,7 @@ package io.github.config4k
 
 import io.github.config4k.serializers.ConfigSerializer
 import io.github.config4k.serializers.ConfigValueSerializer
+import io.github.config4k.serializers.PathSerializer
 import io.github.config4k.serializers.PatternSerializer
 import io.github.config4k.serializers.PeriodSerializer
 import io.github.config4k.serializers.RegexSerializer
@@ -16,6 +17,7 @@ import kotlinx.serialization.modules.contextual
 public val Config4kModule: SerializersModule =
     SerializersModule {
         contextual(JavaDurationSerializer)
+        contextual(PathSerializer)
         contextual(PeriodSerializer)
         contextual(RegexSerializer)
         contextual(PatternSerializer)
diff --git a/src/main/kotlin/io/github/config4k/serializers/PathSerializer.kt b/src/main/kotlin/io/github/config4k/serializers/PathSerializer.kt
new file mode 100644
index 0000000..17b8c30
--- /dev/null
+++ b/src/main/kotlin/io/github/config4k/serializers/PathSerializer.kt
@@ -0,0 +1,27 @@
+package io.github.config4k.serializers
+
+import kotlinx.serialization.KSerializer
+import java.nio.file.Path
+import java.nio.file.Paths
+import kotlin.io.path.invariantSeparatorsPathString
+
+/**
+ * Serializer for [Path]
+ *
+ * Usage example:
+ * ```
+ * @Serializable
+ * data class ExamplePath(
+ *   @Serializable(PathSerializer::class)
+ *   val path: Path,
+ * )
+ * val config = ConfigFactory.parseString("path = /tmp/file")
+ * val examplePath: ExamplePath = Hocon.decodeFromConfig(config)
+ * val newConfig = Hocon.encodeToConfig(ExamplePath.serializer(), examplePath)
+ * ```
+ */
+public object PathSerializer : KSerializer<Path> by stringSerializer(
+    serialName = "hocon.${Path::class.qualifiedName}",
+    decode = { Paths.get(it) },
+    encode = Path::invariantSeparatorsPathString,
+)


Test patch:
diff --git a/src/test/kotlin/io/github/config4k/serializers/PathSerializerTest.kt b/src/test/kotlin/io/github/config4k/serializers/PathSerializerTest.kt
new file mode 100644
index 0000000..d50814d
--- /dev/null
+++ b/src/test/kotlin/io/github/config4k/serializers/PathSerializerTest.kt
@@ -0,0 +1,260 @@
+package io.github.config4k.serializers
+
+import com.typesafe.config.Config
+import io.github.config4k.Config4k
+import io.github.config4k.render
+import io.github.config4k.toConfig
+import io.mockk.mockk
+import kotlinx.serialization.Contextual
+import kotlinx.serialization.MissingFieldException
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.hocon.decodeFromConfig
+import kotlinx.serialization.hocon.encodeToConfig
+import org.assertj.core.api.Assertions.assertThat
+import org.assertj.core.api.Assertions.assertThatThrownBy
+import org.assertj.core.api.Assertions.catchThrowableOfType
+import org.junit.jupiter.api.Nested
+import org.junit.jupiter.api.Test
+import java.nio.file.Path
+import java.nio.file.Paths
+
+class PathSerializerTest {
+    private companion object {
+        val path: Path = Paths.get("/example/file")
+        val defaultPath: Path = Paths.get("/default/file")
+    }
+
+    @Nested
+    inner class SimpleTest {
+        @Test
+        fun checkDecoding() {
+            @Serializable
+            data class Conf(
+                @Contextual val p: Path,
+            )
+
+            val test: Conf = Config4k.decodeFromConfig("p = /example/file".toConfig())
+            assertThat(test.p).isEqualTo(path)
+
+            val thrown =
+                catchThrowableOfType(MissingFieldException::class.java) {
+                    Config4k.decodeFromConfig<Conf>("foo = bar".toConfig())
+                }
+            assertThat(thrown.missingFields).contains("p")
+        }
+
+        @Test
+        fun checkNullableDecoding() {
+            @Serializable
+            data class Conf(
+                @Contextual val p: Path? = null,
+            )
+
+            val test: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(test.p).isNull()
+        }
+
+        @Test
+        fun checkDefaultDecoding() {
+            @Serializable
+            data class Conf(
+                @Contextual val p: Path = defaultPath,
+            )
+
+            val test: Conf = Config4k.decodeFromConfig("p = /example/file".toConfig())
+            assertThat(test.p).isEqualTo(path)
+
+            val default: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(default.p).isEqualTo(defaultPath)
+        }
+
+        @Test
+        fun checkIncorrectDecoder() {
+            assertThatThrownBy { PathSerializer.deserialize(mockk()) }
+                .isInstanceOf(SerializationException::class.java)
+                .hasMessage("This class can be decoded only by Hocon format")
+        }
+
+        @Test
+        fun checkEncoding() {
+            @Serializable
+            data class Conf(
+                @Contextual val p: Path,
+            )
+
+            val test = Config4k.encodeToConfig(Conf(path))
+            assertThat(test.render()).isEqualTo("p=\"/example/file\"")
+        }
+
+        @Test
+        fun checkIncorrectEncoder() {
+            assertThatThrownBy { PathSerializer.serialize(mockk(), mockk()) }
+                .isInstanceOf(SerializationException::class.java)
+                .hasMessage("This class can be encoded only by Hocon format")
+        }
+    }
+
+    @Nested
+    inner class ListTest {
+        @Test
+        fun checkDecoding() {
+            @Serializable
+            data class Conf(
+                val ps: List<@Contextual Path>,
+            )
+
+            val emptyTest: Conf = Config4k.decodeFromConfig("ps = []".toConfig())
+            assertThat(emptyTest.ps).isEmpty()
+
+            val test: Conf =
+                Config4k.decodeFromConfig(
+                    "ps = [/example/file, /default/file]".toConfig(),
+                )
+            assertThat(test.ps).containsExactly(path, defaultPath)
+
+            val thrown =
+                catchThrowableOfType(MissingFieldException::class.java) { Config4k.decodeFromConfig<Conf>("foo = bar".toConfig()) }
+            assertThat(thrown.missingFields).contains("ps")
+        }
+
+        @Test
+        fun checkNullableDecoding() {
+            @Serializable
+            data class Conf(
+                val ps: List<@Contextual Path?>? = null,
+            )
+
+            val nullTest: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(nullTest.ps).isNull()
+
+            val emptyTest: Conf = Config4k.decodeFromConfig("ps = []".toConfig())
+            assertThat(emptyTest.ps).isEmpty()
+
+            val test: Conf =
+                Config4k.decodeFromConfig(
+                    "ps = [/example/file, null, /default/file]".toConfig(),
+                )
+            assertThat(test.ps).containsExactly(path, null, defaultPath)
+        }
+
+        @Test
+        fun checkDefaultDecoding() {
+            @Serializable
+            data class Conf(
+                val ps: List<@Contextual Path> = listOf(defaultPath),
+            )
+
+            val test: Conf =
+                Config4k.decodeFromConfig(
+                    "ps = [/example/file, /default/file]".toConfig(),
+                )
+            assertThat(test.ps).containsExactly(path, defaultPath)
+
+            val default: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(default.ps).containsExactly(defaultPath)
+        }
+
+        @Test
+        fun checkEncoding() {
+            @Serializable
+            data class Conf(
+                val ps: List<@Contextual Path?>,
+            )
+
+            val test: Config = Config4k.encodeToConfig(Conf(listOf(path, null)))
+            assertThat(test.render()).isEqualTo("ps=[\"/example/file\",null]")
+        }
+    }
+
+    @Nested
+    inner class MapTest {
+        @Test
+        fun checkDecoding() {
+            @Serializable
+            data class Conf(
+                val ps: Map<String, @Contextual Path>,
+            )
+
+            val emptyTest: Conf = Config4k.decodeFromConfig("ps = {}".toConfig())
+            assertThat(emptyTest.ps).isEmpty()
+
+            val test: Conf =
+                Config4k.decodeFromConfig(
+                    "ps { key1 = /example/file, key2 = /default/file }".toConfig(),
+                )
+            assertThat(test.ps)
+                .hasSize(2)
+                .containsEntry("key1", path)
+                .containsEntry("key2", defaultPath)
+
+            val thrown =
+                catchThrowableOfType(MissingFieldException::class.java) { Config4k.decodeFromConfig<Conf>("foo = bar".toConfig()) }
+            assertThat(thrown.missingFields).contains("ps")
+        }
+
+        @Test
+        fun checkNullableDecoding() {
+            @Serializable
+            data class Conf(
+                val ps: Map<String, @Contextual Path?>? = null,
+            )
+
+            val nullTest: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(nullTest.ps).isNull()
+
+            val emptyTest: Conf = Config4k.decodeFromConfig("ps = {}".toConfig())
+            assertThat(emptyTest.ps).isEmpty()
+
+            val test: Conf =
+                Config4k.decodeFromConfig(
+                    "ps { key1 = /example/file, key2 = null, key3 = /default/file }"
+                        .toConfig(),
+                )
+            assertThat(test.ps)
+                .hasSize(3)
+                .containsEntry("key1", path)
+                .containsEntry("key2", null)
+                .containsEntry("key3", defaultPath)
+        }
+
+        @Test
+        fun checkDefaultDecoding() {
+            @Serializable
+            data class Conf(
+                val ps: Map<String, @Contextual Path> = mapOf("key" to defaultPath),
+            )
+
+            val test: Conf =
+                Config4k.decodeFromConfig(
+                    "ps = { key1 = /example/file, key2 = /default/file }".toConfig(),
+                )
+            assertThat(test.ps)
+                .hasSize(2)
+                .containsEntry("key1", path)
+                .containsEntry("key2", defaultPath)
+
+            val default: Conf = Config4k.decodeFromConfig("foo = bar".toConfig())
+            assertThat(default.ps).hasSize(1).containsEntry("key", defaultPath)
+        }
+
+        @Test
+        fun checkEncoding() {
+            @Serializable
+            data class Conf(
+                val ps: Map<String, @Contextual Path?>,
+            )
+
+            val test: Config =
+                Config4k.encodeToConfig(
+                    Conf(
+                        mapOf(
+                            "key1" to defaultPath,
+                            "key2" to null,
+                        ),
+                    ),
+                )
+            assertThat(test.render()).isEqualTo("ps{key1=\"/default/file\",key2=null}")
+        }
+    }
+}