feat(config): OperatorProfile + ProfileLoader + PersonalizationConfig

This commit is contained in:
2026-06-08 10:34:41 +04:00
parent 7af5d602bc
commit 76b19e2f63
4 changed files with 211 additions and 0 deletions
@@ -4,6 +4,109 @@ import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths 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<String, Any>()
val sections = mutableMapOf<String, MutableMap<String, Any>>()
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<String> {
val content = arrayStr.substring(1, arrayStr.length - 1).trim()
if (content.isEmpty()) return emptyList()
val result = mutableListOf<String>()
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<String> = when (value) {
is List<*> -> value.filterIsInstance<String>()
is String -> listOf(value)
else -> emptyList()
}
}
object ConfigLoader { object ConfigLoader {
fun load(): CorrexConfig { fun load(): CorrexConfig {
val path = configPath() 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 projectSection = sections["project"] ?: emptyMap()
val project = ProjectConfig( val project = ProjectConfig(
enabled = asBoolean(projectSection["enabled"], false), enabled = asBoolean(projectSection["enabled"], false),
@@ -451,6 +560,7 @@ object ConfigLoader {
modelsSettings = modelsSettings, modelsSettings = modelsSettings,
artifacts = artifacts, artifacts = artifacts,
project = project, project = project,
personalization = personalization,
) )
} }
@@ -14,6 +14,13 @@ data class CorrexConfig(
val modelsSettings: ModelsSettings = ModelsSettings(), val modelsSettings: ModelsSettings = ModelsSettings(),
val artifacts: List<ArtifactKindConfig> = emptyList(), val artifacts: List<ArtifactKindConfig> = emptyList(),
val project: ProjectConfig = ProjectConfig(), val project: ProjectConfig = ProjectConfig(),
val personalization: PersonalizationConfig = PersonalizationConfig(),
)
@Serializable
data class PersonalizationConfig(
val enabled: Boolean = false,
val learn: Boolean = false,
) )
/** /**
@@ -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<String> = emptyList(),
val conventions: List<String> = emptyList(),
)
@@ -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())
}
}