summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSho Sakuma <me@m1sk9.dev>2026-08-05 02:13:34 +0900
committerSho Sakuma <me@m1sk9.dev>2026-08-05 02:13:34 +0900
commite8c90068535e9b6a98e3e87ed3d2293cccf1ab38 (patch)
tree62126af2f00853f9f31f5d43943b2eebc9257c43
parentf2cb1a244c10adbce7eb7e40b6dcf2c0c03ca83a (diff)
downloadLunaticChat-e8c90068535e9b6a98e3e87ed3d2293cccf1ab38.tar.gz
LunaticChat-e8c90068535e9b6a98e3e87ed3d2293cccf1ab38.tar.bz2
LunaticChat-e8c90068535e9b6a98e3e87ed3d2293cccf1ab38.zip
fix: fall back per setting rather than discarding all of config.yml
kaml rejects a document as a whole, so one unreadable value lost every other setting the operator had written: velocityIntegration off, serverName "Unknown" and channel chat disabled, behind one vague SEVERE line. The hand-written mapper this replaced defaulted per key, so reading the file directly had quietly made config.yml far more brittle than it was. The offending setting is now dropped by the path its parse error carries and the rest of the document is decoded, so the operator loses the one value they got wrong and is told which. Only a document that is not YAML at all still costs them everything. Booleans also accept the YAML 1.1 spellings again. Bukkit read config.yml as YAML 1.1, where `yes`, `no`, `on` and `off` are booleans; under kaml's YAML 1.2 they became strings, so `checkForUpdates: no` would have reset to its default - which is the opposite of what the file says. Co-Authored-By: Claude <noreply@anthropic.com>
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt71
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LenientBoolean.kt53
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LunaticChatConfiguration.kt4
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelChatFeatureConfig.kt3
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelMessageLoggingConfig.kt3
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/JapaneseConversionFeatureConfig.kt3
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/QuickRepliesFeatureConfig.kt3
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/VelocityIntegrationConfig.kt7
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManagerTest.kt96
9 files changed, 227 insertions, 16 deletions
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt
index cf4fd07..00c1973 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt
@@ -1,7 +1,14 @@
package dev.m1sk9.lunaticChat.paper.config
+import com.charleskorn.kaml.EmptyYamlDocumentException
import com.charleskorn.kaml.Yaml
import com.charleskorn.kaml.YamlConfiguration
+import com.charleskorn.kaml.YamlException
+import com.charleskorn.kaml.YamlMap
+import com.charleskorn.kaml.YamlNode
+import com.charleskorn.kaml.YamlPath
+import com.charleskorn.kaml.YamlPathSegment
+import java.util.logging.Level
import java.util.logging.Logger
/**
@@ -26,13 +33,63 @@ class ConfigManager(
)
/**
- * Parses [contents] as config.yml, falling back to defaults if it cannot be read.
+ * Parses [contents] as config.yml.
+ *
+ * A setting that cannot be read falls back to its default on its own; the rest of the file is
+ * still honoured. Only a document that is not YAML at all costs the operator every setting.
*/
- fun loadConfiguration(contents: String): LunaticChatConfiguration =
- try {
- yaml.decodeFromString(LunaticChatConfiguration.serializer(), contents)
- } catch (e: Exception) {
- logger.severe("Failed to read config.yml, falling back to defaults: ${e.message}")
- LunaticChatConfiguration()
+ fun loadConfiguration(contents: String): LunaticChatConfiguration {
+ var document =
+ try {
+ yaml.parseToYamlNode(contents)
+ } catch (e: EmptyYamlDocumentException) {
+ // A file that only holds comments is a valid way of saying "use the defaults", so it
+ // is not reported as a failure the operator has to act on.
+ return LunaticChatConfiguration()
+ } catch (e: YamlException) {
+ return allDefaults("config.yml is not valid YAML", e)
+ }
+
+ // Each pass drops exactly one setting, so this terminates: the document strictly shrinks
+ // until it decodes or there is nothing left to drop.
+ while (true) {
+ try {
+ return yaml.decodeFromYamlNode(LunaticChatConfiguration.serializer(), document)
+ } catch (e: YamlException) {
+ // kaml rejects the document as a whole, so without this one unreadable value would
+ // lose every other setting in the file - a regression against the hand-written
+ // mapper, which defaulted per key.
+ val setting = e.path.settingKeys()
+ val remaining =
+ document.without(setting)
+ ?: return allDefaults("config.yml could not be read", e)
+ logger.warning("${setting.joinToString(".")} in config.yml fell back to its default: ${e.message}")
+ document = remaining
+ }
}
+ }
+
+ private fun allDefaults(
+ what: String,
+ cause: YamlException,
+ ): LunaticChatConfiguration {
+ logger.log(
+ Level.SEVERE,
+ "$what, so EVERY setting fell back to its default (fix the reported value and restart): ${cause.message}",
+ cause,
+ )
+ return LunaticChatConfiguration()
+ }
+
+ /** The config.yml keys leading to the node this path points at, outermost first. */
+ private fun YamlPath.settingKeys(): List<String> = segments.filterIsInstance<YamlPathSegment.MapElementKey>().map { it.key }
+
+ /** A copy of this document without [keys], or null when that entry is not there to remove. */
+ private fun YamlNode.without(keys: List<String>): YamlNode? {
+ if (this !is YamlMap || keys.isEmpty()) return null
+ val key = entries.keys.firstOrNull { it.content == keys.first() } ?: return null
+ if (keys.size == 1) return YamlMap(entries - key, path)
+ val remaining = entries.getValue(key).without(keys.drop(1)) ?: return null
+ return YamlMap(entries + (key to remaining), path)
+ }
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LenientBoolean.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LenientBoolean.kt
new file mode 100644
index 0000000..3c93fb8
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LenientBoolean.kt
@@ -0,0 +1,53 @@
+package dev.m1sk9.lunaticChat.paper.config
+
+import com.charleskorn.kaml.YamlException
+import com.charleskorn.kaml.YamlInput
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+
+/**
+ * A Boolean that also accepts the spellings YAML 1.1 counted as boolean.
+ *
+ * Bukkit read config.yml as YAML 1.1, where `yes`, `no`, `on` and `off` are booleans; kaml reads
+ * YAML 1.2, where they are plain strings. Rejecting them would take a file that has worked for
+ * releases and quietly reset the setting - and for `checkForUpdates: no` the default is the
+ * opposite of what the file says, so the operator would get behaviour they had turned off.
+ */
+typealias LenientBoolean =
+ @Serializable(with = LenientBooleanSerializer::class)
+ Boolean
+
+object LenientBooleanSerializer : KSerializer<Boolean> {
+ private val trueWords = setOf("true", "yes", "on", "y")
+ private val falseWords = setOf("false", "no", "off", "n")
+
+ // STRING rather than BOOLEAN: the point is to read the scalar before YAML 1.2 decides it is not
+ // a boolean at all.
+ override val descriptor = PrimitiveSerialDescriptor("LenientBoolean", PrimitiveKind.STRING)
+
+ override fun deserialize(decoder: Decoder): Boolean {
+ // Captured before decoding: ConfigManager prunes the offending setting by path, and only a
+ // YamlException carries one. A plain SerializationException would escape it and cost the
+ // operator the whole file.
+ val path = (decoder as? YamlInput)?.node?.path
+ val raw = decoder.decodeString()
+ return when (raw.lowercase()) {
+ in trueWords -> true
+ in falseWords -> false
+ else -> {
+ val reason = "expected true/false, yes/no or on/off but found '$raw'"
+ throw path?.let { YamlException(reason, it) } ?: SerializationException(reason)
+ }
+ }
+ }
+
+ override fun serialize(
+ encoder: Encoder,
+ value: Boolean,
+ ) = encoder.encodeString(value.toString())
+}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LunaticChatConfiguration.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LunaticChatConfiguration.kt
index 170f9c2..facd6ac 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LunaticChatConfiguration.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LunaticChatConfiguration.kt
@@ -17,9 +17,9 @@ import kotlinx.serialization.Serializable
data class LunaticChatConfiguration(
val features: FeaturesConfig = FeaturesConfig(),
val messageFormat: MessageFormatConfig = MessageFormatConfig(),
- val debug: Boolean = false,
+ val debug: LenientBoolean = false,
val userSettingsFilePath: String = "player-settings.yaml",
- val checkForUpdates: Boolean = true,
+ val checkForUpdates: LenientBoolean = true,
@Serializable(with = LanguageSerializer::class)
val language: Language = Language.EN,
)
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelChatFeatureConfig.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelChatFeatureConfig.kt
index 88a2c0d..b46147d 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelChatFeatureConfig.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelChatFeatureConfig.kt
@@ -1,10 +1,11 @@
package dev.m1sk9.lunaticChat.paper.config.key
+import dev.m1sk9.lunaticChat.paper.config.LenientBoolean
import kotlinx.serialization.Serializable
@Serializable
data class ChannelChatFeatureConfig(
- val enabled: Boolean = false,
+ val enabled: LenientBoolean = false,
val maxChannelsPerServer: Int = 0,
val maxMembersPerChannel: Int = 0,
val maxMembershipPerPlayer: Int = 0,
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelMessageLoggingConfig.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelMessageLoggingConfig.kt
index f036620..1a1d23b 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelMessageLoggingConfig.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelMessageLoggingConfig.kt
@@ -1,5 +1,6 @@
package dev.m1sk9.lunaticChat.paper.config.key
+import dev.m1sk9.lunaticChat.paper.config.LenientBoolean
import kotlinx.serialization.Serializable
/**
@@ -14,7 +15,7 @@ import kotlinx.serialization.Serializable
*/
@Serializable
data class ChannelMessageLoggingConfig(
- val enabled: Boolean = true,
+ val enabled: LenientBoolean = true,
val retentionDays: Int = 30,
val maxFileSizeMB: Int = 100,
)
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/JapaneseConversionFeatureConfig.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/JapaneseConversionFeatureConfig.kt
index 061b152..845a294 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/JapaneseConversionFeatureConfig.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/JapaneseConversionFeatureConfig.kt
@@ -1,5 +1,6 @@
package dev.m1sk9.lunaticChat.paper.config.key
+import dev.m1sk9.lunaticChat.paper.config.LenientBoolean
import kotlinx.serialization.Serializable
/**
@@ -9,7 +10,7 @@ import kotlinx.serialization.Serializable
*/
@Serializable
data class JapaneseConversionFeatureConfig(
- val enabled: Boolean = false,
+ val enabled: LenientBoolean = false,
val cache: ConversionCacheConfig = ConversionCacheConfig(),
val api: ConversionApiConfig = ConversionApiConfig(),
)
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/QuickRepliesFeatureConfig.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/QuickRepliesFeatureConfig.kt
index ed8a59a..524cc57 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/QuickRepliesFeatureConfig.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/QuickRepliesFeatureConfig.kt
@@ -1,8 +1,9 @@
package dev.m1sk9.lunaticChat.paper.config.key
+import dev.m1sk9.lunaticChat.paper.config.LenientBoolean
import kotlinx.serialization.Serializable
@Serializable
data class QuickRepliesFeatureConfig(
- val enabled: Boolean = true,
+ val enabled: LenientBoolean = true,
)
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/VelocityIntegrationConfig.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/VelocityIntegrationConfig.kt
index 897e946..927b7a0 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/VelocityIntegrationConfig.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/VelocityIntegrationConfig.kt
@@ -1,12 +1,13 @@
package dev.m1sk9.lunaticChat.paper.config.key
+import dev.m1sk9.lunaticChat.paper.config.LenientBoolean
import kotlinx.serialization.Serializable
@Serializable
data class VelocityIntegrationConfig(
- val enabled: Boolean = false,
- val crossServerGlobalChat: Boolean = false,
- val crossServerDirectMessage: Boolean = false,
+ val enabled: LenientBoolean = false,
+ val crossServerGlobalChat: LenientBoolean = false,
+ val crossServerDirectMessage: LenientBoolean = false,
val serverName: String = "Unknown",
val messageDeduplicationCacheSize: Int = 100,
)
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManagerTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManagerTest.kt
index e76eeb9..dd9bd54 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManagerTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManagerTest.kt
@@ -180,4 +180,100 @@ class ConfigManagerTest {
fun `a malformed file falls back to defaults instead of failing startup`() {
assertEquals(LunaticChatConfiguration(), load("features: [this is not a map"))
}
+
+ @Test
+ fun `a document that is not YAML at all says that every setting was reset`() {
+ val logger = TestUtils.TestLogger()
+
+ val config = ConfigManager(logger).loadConfiguration("features: [this is not a map")
+
+ assertEquals(LunaticChatConfiguration(), config)
+ assertTrue(logger.severeMessages.any { it.contains("EVERY setting") })
+ }
+
+ @Test
+ fun `a file holding only comments is not reported as a failure`() {
+ val logger = TestUtils.TestLogger()
+
+ val config = ConfigManager(logger).loadConfiguration("# everything left at its default\n")
+
+ assertEquals(LunaticChatConfiguration(), config)
+ assertTrue(logger.severeMessages.isEmpty())
+ }
+
+ @Test
+ fun `the boolean spellings Bukkit accepted are still booleans`() {
+ // Bukkit read config.yml as YAML 1.1, where these are booleans. A file written against that
+ // must keep meaning what it says.
+ val config = load("debug: yes\ncheckForUpdates: off")
+
+ assertTrue(config.debug)
+ assertFalse(config.checkForUpdates)
+ }
+
+ @Test
+ fun `boolean spellings are matched regardless of case`() {
+ assertTrue(load("debug: YES").debug)
+ }
+
+ @Test
+ fun `an unreadable setting falls back alone and leaves the rest of the file standing`() {
+ val logger = TestUtils.TestLogger()
+
+ val config =
+ ConfigManager(logger).loadConfiguration(
+ """
+ debug: perhaps
+ userSettingsFilePath: "custom.yaml"
+ language: "ja"
+ """.trimIndent(),
+ )
+
+ assertFalse(config.debug)
+ assertEquals("custom.yaml", config.userSettingsFilePath)
+ assertEquals(Language.JA, config.language)
+ assertTrue(logger.warningMessages.any { it.contains("debug") })
+ assertTrue(logger.severeMessages.isEmpty())
+ }
+
+ @Test
+ fun `an unreadable nested setting leaves its siblings standing`() {
+ val logger = TestUtils.TestLogger()
+
+ val config =
+ ConfigManager(logger).loadConfiguration(
+ """
+ features:
+ velocityIntegration:
+ enabled: true
+ serverName: "survival"
+ messageDeduplicationCacheSize: "not a number"
+ """.trimIndent(),
+ )
+
+ val velocity = config.features.velocityIntegration
+ assertTrue(velocity.enabled)
+ assertEquals("survival", velocity.serverName)
+ assertEquals(
+ LunaticChatConfiguration().features.velocityIntegration.messageDeduplicationCacheSize,
+ velocity.messageDeduplicationCacheSize,
+ )
+ assertTrue(logger.warningMessages.any { it.contains("features.velocityIntegration.messageDeduplicationCacheSize") })
+ }
+
+ @Test
+ fun `several unreadable settings each fall back without taking the others`() {
+ val config =
+ load(
+ """
+ debug: perhaps
+ checkForUpdates: sometimes
+ userSettingsFilePath: "custom.yaml"
+ """.trimIndent(),
+ )
+
+ assertFalse(config.debug)
+ assertTrue(config.checkForUpdates)
+ assertEquals("custom.yaml", config.userSettingsFilePath)
+ }
}