summaryrefslogtreecommitdiff
path: root/platform-paper
diff options
context:
space:
mode:
Diffstat (limited to 'platform-paper')
-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)
+ }
}