From 76b19e2f636e6bff7ab7e6473da69771e9e35128 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 8 Jun 2026 10:34:41 +0400 Subject: [PATCH] feat(config): OperatorProfile + ProfileLoader + PersonalizationConfig --- .../com/correx/core/config/ConfigLoader.kt | 110 ++++++++++++++++++ .../com/correx/core/config/CorrexConfig.kt | 7 ++ .../com/correx/core/config/OperatorProfile.kt | 16 +++ .../correx/core/config/ProfileLoaderTest.kt | 78 +++++++++++++ 4 files changed, 211 insertions(+) create mode 100644 core/config/src/main/kotlin/com/correx/core/config/OperatorProfile.kt create mode 100644 core/config/src/test/kotlin/com/correx/core/config/ProfileLoaderTest.kt diff --git a/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt b/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt index 5826b8d9..480986c9 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt @@ -4,6 +4,109 @@ import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths +object ProfileLoader { + fun profilePath(homeDir: String = System.getProperty("user.home")): Path = + Paths.get(homeDir, ".config", "correx", "profile.toml") + + fun load(homeDir: String = System.getProperty("user.home")): OperatorProfile { + val path = profilePath(homeDir) + if (!Files.exists(path)) return OperatorProfile() + + return runCatching { + val content = Files.readString(path) + parseProfile(content) + }.getOrElse { e -> + System.err.println("Warning: Failed to parse profile at $path: ${e.message}") + OperatorProfile() + } + } + + private fun parseProfile(content: String): OperatorProfile { + val lines = content.trim().split("\n") + var currentSection = "" + val rootKeys = mutableMapOf() + val sections = mutableMapOf>() + + for (line in lines) { + val trimmed = line.trim() + when { + trimmed.isEmpty() || trimmed.startsWith("#") -> Unit + trimmed.startsWith("[") && !trimmed.startsWith("[[") && trimmed.endsWith("]") -> { + currentSection = trimmed.substring(1, trimmed.length - 1).trim() + sections.putIfAbsent(currentSection, mutableMapOf()) + } + else -> { + val eqIndex = trimmed.indexOf("=") + if (eqIndex > 0) { + val key = trimmed.substring(0, eqIndex).trim() + val valueStr = trimmed.substring(eqIndex + 1).trim() + val parsed = parseSimpleValue(valueStr) + if (currentSection.isEmpty()) { + rootKeys[key] = parsed + } else { + sections[currentSection]?.put(key, parsed) + } + } + } + } + } + + val prefsSection = sections["preferences"] ?: emptyMap() + val preferences = ProfilePreferences( + approvalMode = asString(prefsSection["approval_mode"], ""), + preferredModels = asStringList(prefsSection["preferred_models"]), + conventions = asStringList(prefsSection["conventions"]), + ) + + return OperatorProfile( + about = asString(rootKeys["about"], ""), + preferences = preferences, + ) + } + + private fun parseSimpleValue(valueStr: String): Any = when { + valueStr.startsWith("[") && valueStr.endsWith("]") -> parseArray(valueStr) + valueStr == "true" || valueStr == "false" -> valueStr.toBoolean() + valueStr.toDoubleOrNull() != null -> + if (valueStr.contains(".")) valueStr.toDouble() else valueStr.toLong() + else -> stripQuotes(valueStr) + } + + private fun parseArray(arrayStr: String): List { + val content = arrayStr.substring(1, arrayStr.length - 1).trim() + if (content.isEmpty()) return emptyList() + val result = mutableListOf() + var current = StringBuilder() + var inQuotes = false + var quoteChar = ' ' + for (ch in content) { + when { + (ch == '"' || ch == '\'') && !inQuotes -> { inQuotes = true; quoteChar = ch; current.append(ch) } + ch == quoteChar && inQuotes -> { inQuotes = false; current.append(ch) } + ch == ',' && !inQuotes -> { result.add(stripQuotes(current.toString().trim())); current = StringBuilder() } + else -> current.append(ch) + } + } + if (current.isNotEmpty()) result.add(stripQuotes(current.toString().trim())) + return result + } + + private fun stripQuotes(value: String): String { + val isDoubleQuoted = value.startsWith("\"") && value.endsWith("\"") + val isSingleQuoted = value.startsWith("'") && value.endsWith("'") + return if (isDoubleQuoted || isSingleQuoted) value.substring(1, value.length - 1) else value + } + + private fun asString(value: Any?, default: String = ""): String = + if (value is String) value else default + + private fun asStringList(value: Any?): List = when (value) { + is List<*> -> value.filterIsInstance() + is String -> listOf(value) + else -> emptyList() + } +} + object ConfigLoader { fun load(): CorrexConfig { val path = configPath() @@ -413,6 +516,12 @@ object ConfigLoader { ) } + val personalizationSection = sections["personalization"] ?: emptyMap() + val personalization = PersonalizationConfig( + enabled = asBoolean(personalizationSection["enabled"], false), + learn = asBoolean(personalizationSection["learn"], false), + ) + val projectSection = sections["project"] ?: emptyMap() val project = ProjectConfig( enabled = asBoolean(projectSection["enabled"], false), @@ -451,6 +560,7 @@ object ConfigLoader { modelsSettings = modelsSettings, artifacts = artifacts, project = project, + personalization = personalization, ) } diff --git a/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt index 2e0303f5..5c2bdbc4 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt @@ -14,6 +14,13 @@ data class CorrexConfig( val modelsSettings: ModelsSettings = ModelsSettings(), val artifacts: List = emptyList(), val project: ProjectConfig = ProjectConfig(), + val personalization: PersonalizationConfig = PersonalizationConfig(), +) + +@Serializable +data class PersonalizationConfig( + val enabled: Boolean = false, + val learn: Boolean = false, ) /** diff --git a/core/config/src/main/kotlin/com/correx/core/config/OperatorProfile.kt b/core/config/src/main/kotlin/com/correx/core/config/OperatorProfile.kt new file mode 100644 index 00000000..3be4987d --- /dev/null +++ b/core/config/src/main/kotlin/com/correx/core/config/OperatorProfile.kt @@ -0,0 +1,16 @@ +package com.correx.core.config + +import kotlinx.serialization.Serializable + +@Serializable +data class OperatorProfile( + val about: String = "", + val preferences: ProfilePreferences = ProfilePreferences(), +) + +@Serializable +data class ProfilePreferences( + val approvalMode: String = "", + val preferredModels: List = emptyList(), + val conventions: List = emptyList(), +) diff --git a/core/config/src/test/kotlin/com/correx/core/config/ProfileLoaderTest.kt b/core/config/src/test/kotlin/com/correx/core/config/ProfileLoaderTest.kt new file mode 100644 index 00000000..bbf26cd7 --- /dev/null +++ b/core/config/src/test/kotlin/com/correx/core/config/ProfileLoaderTest.kt @@ -0,0 +1,78 @@ +package com.correx.core.config + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import java.nio.file.Files + +class ProfileLoaderTest { + + private fun tempHome(): String { + val dir = Files.createTempDirectory("correx-profile-test") + val configDir = dir.resolve(".config/correx") + Files.createDirectories(configDir) + return dir.toString() + } + + @Test + fun `missing file returns default OperatorProfile`() { + val home = tempHome() + val profile = ProfileLoader.load(home) + assertEquals(OperatorProfile(), profile) + } + + @Test + fun `file with about populates about field`() { + val home = tempHome() + val profileFile = java.nio.file.Paths.get(home, ".config", "correx", "profile.toml") + Files.writeString(profileFile, """about = "I am a test operator"""") + val profile = ProfileLoader.load(home) + assertEquals("I am a test operator", profile.about) + } + + @Test + fun `file with preferences section populates preferred_models and conventions`() { + val home = tempHome() + val profileFile = java.nio.file.Paths.get(home, ".config", "correx", "profile.toml") + Files.writeString( + profileFile, + """ + about = "tester" + + [preferences] + approval_mode = "PROMPT" + preferred_models = ["claude-3-opus", "claude-3-haiku"] + conventions = ["use sealed classes", "no nullable returns"] + """.trimIndent(), + ) + val profile = ProfileLoader.load(home) + assertEquals("tester", profile.about) + assertEquals("PROMPT", profile.preferences.approvalMode) + assertEquals(listOf("claude-3-opus", "claude-3-haiku"), profile.preferences.preferredModels) + assertEquals(listOf("use sealed classes", "no nullable returns"), profile.preferences.conventions) + } + + @Test + fun `file with unknown fields does not crash`() { + val home = tempHome() + val profileFile = java.nio.file.Paths.get(home, ".config", "correx", "profile.toml") + Files.writeString( + profileFile, + """ + about = "tester" + unknown_field = "ignored" + + [preferences] + approval_mode = "AUTO" + mystery_key = 42 + + [unknown_section] + foo = "bar" + """.trimIndent(), + ) + val profile = ProfileLoader.load(home) + assertEquals("tester", profile.about) + assertEquals("AUTO", profile.preferences.approvalMode) + assertTrue(profile.preferences.preferredModels.isEmpty()) + } +}