epic-13: add cli and tui entry point, finish epic.

This commit is contained in:
2026-05-16 13:37:58 +04:00
parent 72d20726ce
commit 2207a37549
60 changed files with 2896 additions and 41 deletions
+4
View File
@@ -3,3 +3,7 @@ plugins {
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.plugin.serialization'
}
dependencies {
testImplementation "org.jetbrains.kotlin:kotlin-test"
}
@@ -0,0 +1,107 @@
package com.correx.core.config
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
object ConfigLoader {
fun load(): CorrexConfig {
val path = configPath()
if (!Files.exists(path)) {
return CorrexConfig()
}
return runCatching {
val content = Files.readString(path)
parseToml(content)
}.getOrElse { e ->
System.err.println("Warning: Failed to parse config at $path: ${e.message}")
CorrexConfig()
}
}
fun configPath(): Path {
val envPath = System.getenv("CORREX_CONFIG")
return if (envPath != null) {
Paths.get(envPath)
} else {
val homeDir = System.getProperty("user.home")
Paths.get(homeDir, ".config", "correx", "config.toml")
}
}
private fun parseToml(content: String): CorrexConfig {
val lines = content.trim().split("\n")
var currentSection = ""
val sections = mutableMapOf<String, MutableMap<String, String>>()
for (line in lines) {
val trimmed = line.trim()
when {
trimmed.isEmpty() || trimmed.startsWith("#") -> {
// Skip empty lines and comments
}
trimmed.startsWith("[") && trimmed.endsWith("]") -> {
// Parse section headers like [server]
currentSection = trimmed.substring(1, trimmed.length - 1).trim()
sections.putIfAbsent(currentSection, mutableMapOf())
}
else -> {
// Parse key=value pairs
val eqIndex = trimmed.indexOf("=")
if (eqIndex > 0 && currentSection.isNotEmpty()) {
val key = trimmed.substring(0, eqIndex).trim()
val value = trimmed.substring(eqIndex + 1).trim()
val cleanedValue = stripQuotes(value)
sections[currentSection]?.put(key, cleanedValue)
}
}
}
}
return buildConfig(sections)
}
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 buildConfig(sections: Map<String, Map<String, String>>): CorrexConfig {
val serverSection = sections["server"] ?: emptyMap()
val tuiSection = sections["tui"] ?: emptyMap()
val cliSection = sections["cli"] ?: emptyMap()
val approvalSection = sections["approval"] ?: emptyMap()
val server = ServerConfig(
host = serverSection["host"] ?: "localhost",
port = serverSection["port"]?.toIntOrNull() ?: 8080,
)
val tui = TuiConfig(
theme = tuiSection["theme"] ?: "dark",
sessionListLimit = tuiSection["session_list_limit"]?.toIntOrNull() ?: 5,
)
val cli = CliConfig(
defaultOutput = cliSection["default_output"] ?: "human",
)
val approval = ApprovalConfig(
timeoutMs = approvalSection["timeout_ms"]?.toLongOrNull() ?: 300_000L,
)
return CorrexConfig(
server = server,
tui = tui,
cli = cli,
approval = approval,
)
}
}
@@ -0,0 +1,33 @@
package com.correx.core.config
import kotlinx.serialization.Serializable
@Serializable
data class CorrexConfig(
val server: ServerConfig = ServerConfig(),
val tui: TuiConfig = TuiConfig(),
val cli: CliConfig = CliConfig(),
val approval: ApprovalConfig = ApprovalConfig(),
)
@Serializable
data class ServerConfig(
val host: String = "localhost",
val port: Int = 8080,
)
@Serializable
data class TuiConfig(
val theme: String = "dark",
val sessionListLimit: Int = 5,
)
@Serializable
data class CliConfig(
val defaultOutput: String = "human",
)
@Serializable
data class ApprovalConfig(
val timeoutMs: Long = 300_000L,
)
@@ -0,0 +1,78 @@
package com.correx.core.config
import org.junit.jupiter.api.Test
import java.nio.file.Files
import java.nio.file.Paths
import kotlin.test.assertEquals
class ConfigLoaderTest {
@Test
fun `load returns defaults when config file missing`() {
val config = CorrexConfig()
assertEquals("localhost", config.server.host)
assertEquals(8080, config.server.port)
assertEquals("dark", config.tui.theme)
assertEquals(5, config.tui.sessionListLimit)
assertEquals("human", config.cli.defaultOutput)
assertEquals(300_000L, config.approval.timeoutMs)
}
@Test
fun `parseToml parses valid toml content`() {
val toml = """
[server]
host = "0.0.0.0"
port = 9000
[tui]
theme = "light"
session_list_limit = 10
[cli]
default_output = "json"
[approval]
timeout_ms = 600000
""".trimIndent()
val loader = ConfigLoader::class.java
val parseTomlMethod = loader.getDeclaredMethod("parseToml", String::class.java)
parseTomlMethod.isAccessible = true
val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig
assertEquals("0.0.0.0", result.server.host)
assertEquals(9000, result.server.port)
assertEquals("light", result.tui.theme)
assertEquals(10, result.tui.sessionListLimit)
assertEquals("json", result.cli.defaultOutput)
assertEquals(600_000L, result.approval.timeoutMs)
}
@Test
fun `parseToml skips comments and empty lines`() {
val toml = """
# This is a comment
[server]
# Another comment
host = "localhost"
# Empty line above
port = 8080
""".trimIndent()
val loader = ConfigLoader::class.java
val parseTomlMethod = loader.getDeclaredMethod("parseToml", String::class.java)
parseTomlMethod.isAccessible = true
val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig
assertEquals("localhost", result.server.host)
assertEquals(8080, result.server.port)
}
@Test
fun `configPath returns a valid path`() {
val configPath = ConfigLoader.configPath()
// Verify it returns a non-null Path object
assertEquals("config.toml", configPath.fileName.toString())
}
}