diff options
Diffstat (limited to 'platform-paper')
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) + } } |
