feat(config): OperatorProfile + ProfileLoader + PersonalizationConfig
This commit is contained in:
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user