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 5b19e879..ff467166 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 @@ -33,34 +33,158 @@ object ConfigLoader { private fun parseToml(content: String): CorrexConfig { val lines = content.trim().split("\n") var currentSection = "" - val sections = mutableMapOf>() + val sections = mutableMapOf>() + val providers = mutableListOf>() + var currentProvider: MutableMap? = null - for (line in lines) { + for ((lineNum, line) in lines.withIndex()) { val trimmed = line.trim() when { trimmed.isEmpty() || trimmed.startsWith("#") -> { // Skip empty lines and comments } - trimmed.startsWith("[") && trimmed.endsWith("]") -> { - // Parse section headers like [server] + trimmed == "[[providers]]" -> { + // Start a new provider entry + if (currentProvider != null) { + providers.add(currentProvider) + } + currentProvider = mutableMapOf() + currentSection = "" + } + trimmed.startsWith("[") && !trimmed.startsWith("[[") && trimmed.endsWith("]") -> { + // Parse section headers like [server] or [tools.shell] + if (currentProvider != null) { + providers.add(currentProvider) + currentProvider = null + } 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()) { + if (eqIndex > 0) { val key = trimmed.substring(0, eqIndex).trim() - val value = trimmed.substring(eqIndex + 1).trim() - val cleanedValue = stripQuotes(value) - sections[currentSection]?.put(key, cleanedValue) + val valueStr = trimmed.substring(eqIndex + 1).trim() + val parsedValue = parseValue(valueStr, lineNum + 1) + + if (currentProvider != null) { + currentProvider[key] = parsedValue + } else if (currentSection.isNotEmpty()) { + sections[currentSection]?.put(key, parsedValue) + } } } } } - return buildConfig(sections) + // Don't forget the last provider if file ends with [[providers]] block + if (currentProvider != null) { + providers.add(currentProvider) + } + + return buildConfig(sections, providers) + } + + private fun parseValue(valueStr: String, lineNum: Int): Any { + return when { + // Inline table: { key = value, key2 = value2 } + valueStr.startsWith("{") && valueStr.endsWith("}") -> { + parseInlineTable(valueStr, lineNum) + } + // JSON-style array: ["item1", "item2"] + valueStr.startsWith("[") && valueStr.endsWith("]") -> { + parseArray(valueStr, lineNum) + } + // CSV fallback for backward compat (detect by "," and "=" pattern without brackets) + valueStr.contains(",") && valueStr.contains("=") && !valueStr.startsWith("\"") -> { + parseCSVCapabilities(valueStr) + } + // Boolean + valueStr == "true" || valueStr == "false" -> valueStr.toBoolean() + // Number + valueStr.toDoubleOrNull() != null -> { + if (valueStr.contains(".")) valueStr.toDouble() else valueStr.toLong() + } + // String (with or without quotes) + else -> stripQuotes(valueStr) + } + } + + private fun parseInlineTable(tableStr: String, lineNum: Int): Map { + val result = mutableMapOf() + val content = tableStr.substring(1, tableStr.length - 1).trim() + if (content.isEmpty()) return result + + val pairs = splitTopLevel(content, ',') + for (pair in pairs) { + val eqIdx = pair.indexOf("=") + if (eqIdx > 0) { + val key = pair.substring(0, eqIdx).trim() + val valStr = pair.substring(eqIdx + 1).trim() + result[key] = parseValue(valStr, lineNum) + } + } + return result + } + + private fun parseArray(arrayStr: String, lineNum: Int): List { + val result = mutableListOf() + val content = arrayStr.substring(1, arrayStr.length - 1).trim() + if (content.isEmpty()) return result + + val items = splitTopLevel(content, ',') + for (item in items) { + val trimmed = item.trim() + result.add(stripQuotes(trimmed)) + } + return result + } + + private fun splitTopLevel(text: String, delimiter: Char): List { + val result = mutableListOf() + var current = StringBuilder() + var inQuotes = false + var quoteChar = ' ' + + for (ch in text) { + when { + (ch == '"' || ch == '\'') && !inQuotes -> { + inQuotes = true + quoteChar = ch + current.append(ch) + } + ch == quoteChar && inQuotes -> { + inQuotes = false + current.append(ch) + } + ch == delimiter && !inQuotes -> { + result.add(current.toString().trim()) + current = StringBuilder() + } + else -> current.append(ch) + } + } + if (current.isNotEmpty()) { + result.add(current.toString().trim()) + } + return result + } + + private fun parseCSVCapabilities(capsStr: String): Map { + val result = mutableMapOf() + capsStr.split(",").forEach { pair -> + val parts = pair.trim().split("=") + if (parts.size == 2) { + val cap = parts[0].trim() + val score = parts[1].trim().toDoubleOrNull() + if (score != null && score >= 0.0 && score <= 1.0) { + result[cap] = score + } + } + } + return result } private fun stripQuotes(value: String): String { @@ -73,47 +197,211 @@ object ConfigLoader { } } - private fun buildConfig(sections: Map>): CorrexConfig { + private fun buildConfig( + sections: Map>, + providersList: List> = emptyList(), + ): CorrexConfig { val serverSection = sections["server"] ?: emptyMap() val tuiSection = sections["tui"] ?: emptyMap() val cliSection = sections["cli"] ?: emptyMap() val approvalSection = sections["approval"] ?: emptyMap() val toolsSection = sections["tools"] ?: emptyMap() + val toolsShellSection = sections["tools.shell"] ?: emptyMap() + val toolsFileReadSection = sections["tools.file_read"] ?: emptyMap() + val toolsFileWriteSection = sections["tools.file_write"] ?: emptyMap() + val toolsFileEditSection = sections["tools.file_edit"] ?: emptyMap() val server = ServerConfig( - host = serverSection["host"] ?: "localhost", - port = serverSection["port"]?.toIntOrNull() ?: 8080, + host = asString(serverSection["host"], "localhost"), + port = asInt(serverSection["port"], 8080), ) val tui = TuiConfig( - theme = tuiSection["theme"] ?: "dark", - sessionListLimit = tuiSection["session_list_limit"]?.toIntOrNull() ?: 5, + theme = asString(tuiSection["theme"], "dark"), + sessionListLimit = asInt(tuiSection["session_list_limit"], 5), ) val cli = CliConfig( - defaultOutput = cliSection["default_output"] ?: "human", + defaultOutput = asString(cliSection["default_output"], "human"), ) val approval = ApprovalConfig( - timeoutMs = approvalSection["timeout_ms"]?.toLongOrNull() ?: 300_000L, + timeoutMs = asLong(approvalSection["timeout_ms"], 300_000L), ) + // Resolve tool enable flags: prefer nested [tools.shell], [tools.file_read], etc. + // Fall back to flat [tools] section for backward compat + val shellEnabled = when { + toolsShellSection.containsKey("enabled") -> asBoolean(toolsShellSection["enabled"], true) + toolsSection.containsKey("shell_enabled") -> { + System.err.println("Warning: 'shell_enabled' in [tools] is deprecated, use [tools.shell] enabled instead") + asBoolean(toolsSection["shell_enabled"], true) + } + else -> true + } + + val fileReadEnabled = when { + toolsFileReadSection.containsKey("enabled") -> asBoolean(toolsFileReadSection["enabled"], true) + toolsSection.containsKey("file_read_enabled") -> { + System.err.println("Warning: 'file_read_enabled' in [tools] is deprecated, use [tools.file_read] enabled instead") + asBoolean(toolsSection["file_read_enabled"], true) + } + else -> true + } + + val fileWriteEnabled = when { + toolsFileWriteSection.containsKey("enabled") -> asBoolean(toolsFileWriteSection["enabled"], true) + toolsSection.containsKey("file_write_enabled") -> { + System.err.println("Warning: 'file_write_enabled' in [tools] is deprecated, use [tools.file_write] enabled instead") + asBoolean(toolsSection["file_write_enabled"], true) + } + else -> true + } + + val fileEditEnabled = when { + toolsFileEditSection.containsKey("enabled") -> asBoolean(toolsFileEditSection["enabled"], true) + toolsSection.containsKey("file_edit_enabled") -> { + System.err.println("Warning: 'file_edit_enabled' in [tools] is deprecated, use [tools.file_edit] enabled instead") + asBoolean(toolsSection["file_edit_enabled"], true) + } + else -> true + } + + val shellAllowedExecutables = when { + toolsShellSection.containsKey("allowed_executables") -> { + asStringList(toolsShellSection["allowed_executables"]) + } + toolsSection.containsKey("shell_allowed_executables") -> { + val val1 = toolsSection["shell_allowed_executables"] + when { + val1 is List<*> -> val1.filterIsInstance() + val1 is String -> { + // Backward compat: check if it's CSV or already a single item + if (val1.contains(",")) { + System.err.println("Warning: CSV format 'shell_allowed_executables' is deprecated, use JSON array instead") + val1.split(",").map { it.trim() }.filter { it.isNotEmpty() } + } else { + listOf(val1) + } + } + else -> emptyList() + } + } + else -> emptyList() + } + val tools = ToolsConfig( - sandboxRoot = toolsSection["sandbox_root"] ?: "", - workingDir = toolsSection["working_dir"] ?: "", - shellAllowedExecutables = toolsSection["shell_allowed_executables"] - ?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } - ?: emptyList(), - defaultSystemPromptPath = toolsSection["default_system_prompt_path"] - ?: "~/.config/correx/prompts/default_system.md", + sandboxRoot = asString(toolsSection["sandbox_root"], ""), + workingDir = asString(toolsSection["working_dir"], ""), + shellAllowedExecutables = shellAllowedExecutables, + defaultSystemPromptPath = asString( + toolsSection["default_system_prompt_path"], + "~/.config/correx/prompts/default_system.md" + ), + shellEnabled = shellEnabled, + fileReadEnabled = fileReadEnabled, + fileWriteEnabled = fileWriteEnabled, + fileEditEnabled = fileEditEnabled, ) + val providers = providersList.mapNotNull { providerMap -> + val id = asString(providerMap["id"]) ?: return@mapNotNull null + val type = asString(providerMap["type"]) ?: return@mapNotNull null + val modelId = asString(providerMap["model_id"]) ?: return@mapNotNull null + val modelPath = asString(providerMap["model_path"], "") + val url = asString(providerMap["url"], "http://127.0.0.1:10000") + val capabilities = parseCapabilities(providerMap["capabilities"]) + + ProviderConfig( + id = id, + type = type, + modelId = modelId, + modelPath = modelPath, + url = url, + capabilities = capabilities, + ) + } + return CorrexConfig( server = server, tui = tui, cli = cli, approval = approval, tools = tools, + providers = providers, ) } + + private fun asString(value: Any?, default: String = ""): String { + return when (value) { + is String -> value + else -> default + } + } + + private fun asStringOrNull(value: Any?): String? { + return when (value) { + is String -> value + else -> null + } + } + + private fun asStringList(value: Any?): List { + return when (value) { + is List<*> -> value.filterIsInstance() + is String -> if (value.contains(",")) { + value.split(",").map { it.trim() }.filter { it.isNotEmpty() } + } else { + listOf(value) + } + else -> emptyList() + } + } + + private fun asInt(value: Any?, default: Int = 0): Int { + return when (value) { + is Int -> value + is Long -> value.toInt() + is String -> value.toIntOrNull() ?: default + else -> default + } + } + + private fun asLong(value: Any?, default: Long = 0L): Long { + return when (value) { + is Long -> value + is Int -> value.toLong() + is String -> value.toLongOrNull() ?: default + else -> default + } + } + + private fun asBoolean(value: Any?, default: Boolean = false): Boolean { + return when (value) { + is Boolean -> value + is String -> value.lowercase() == "true" + else -> default + } + } + + private fun parseCapabilities(value: Any?): Map { + return when (value) { + is Map<*, *> -> { + // Inline table parsed earlier + value.filterKeys { it is String } + .mapKeys { it.key as String } + .mapValues { (_, v) -> + when (v) { + is Double -> v + is Number -> v.toDouble() + is String -> v.toDoubleOrNull() ?: 0.0 + else -> 0.0 + } + } + .filter { it.value in 0.0..1.0 } + } + is String -> parseCSVCapabilities(value) + else -> emptyMap() + } + } } diff --git a/core/config/src/test/kotlin/com/correx/core/config/ConfigLoaderTest.kt b/core/config/src/test/kotlin/com/correx/core/config/ConfigLoaderTest.kt index 124e4f9d..0eb0bf04 100644 --- a/core/config/src/test/kotlin/com/correx/core/config/ConfigLoaderTest.kt +++ b/core/config/src/test/kotlin/com/correx/core/config/ConfigLoaderTest.kt @@ -73,4 +73,148 @@ class ConfigLoaderTest { // Verify it returns a non-null Path object assertEquals("config.toml", configPath.fileName.toString()) } + + @Test + fun `parseToml parses providers with inline table capabilities`() { + val toml = """ + [server] + host = "localhost" + + [[providers]] + id = "local-llama" + type = "llamacpp" + model_id = "mistral-7b" + model_path = "/path/to/model.gguf" + url = "http://127.0.0.1:10000" + capabilities = { General = 1.0, Coding = 0.7, Reasoning = 0.6 } + + [[providers]] + id = "alt-llama" + type = "llamacpp" + model_id = "neural-chat-7b" + model_path = "/path/to/model2.gguf" + url = "http://127.0.0.1:10001" + capabilities = { General = 0.9, Coding = 0.8 } + """.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(2, result.providers.size) + assertEquals("local-llama", result.providers[0].id) + assertEquals("llamacpp", result.providers[0].type) + assertEquals("mistral-7b", result.providers[0].modelId) + assertEquals("/path/to/model.gguf", result.providers[0].modelPath) + assertEquals("http://127.0.0.1:10000", result.providers[0].url) + assertEquals(3, result.providers[0].capabilities.size) + assertEquals(1.0, result.providers[0].capabilities["General"]) + assertEquals(0.7, result.providers[0].capabilities["Coding"]) + assertEquals(0.6, result.providers[0].capabilities["Reasoning"]) + + assertEquals("alt-llama", result.providers[1].id) + assertEquals("neural-chat-7b", result.providers[1].modelId) + assertEquals(2, result.providers[1].capabilities.size) + } + + @Test + fun `parseToml parses nested tool sections with enable flags`() { + val toml = """ + [tools] + sandbox_root = "/tmp/correx" + + [tools.shell] + enabled = false + allowed_executables = ["bash", "sh", "python3"] + + [tools.file_read] + enabled = true + + [tools.file_write] + enabled = false + + [tools.file_edit] + enabled = true + """.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(false, result.tools.shellEnabled) + assertEquals(true, result.tools.fileReadEnabled) + assertEquals(false, result.tools.fileWriteEnabled) + assertEquals(true, result.tools.fileEditEnabled) + assertEquals(3, result.tools.shellAllowedExecutables.size) + assertEquals("bash", result.tools.shellAllowedExecutables[0]) + assertEquals("sh", result.tools.shellAllowedExecutables[1]) + assertEquals("python3", result.tools.shellAllowedExecutables[2]) + } + + @Test + fun `parseToml parses JSON array shell_allowed_executables`() { + val toml = """ + [tools] + shell_allowed_executables = ["bash", "sh", "node", "python3"] + """.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(4, result.tools.shellAllowedExecutables.size) + assertEquals("bash", result.tools.shellAllowedExecutables[0]) + assertEquals("sh", result.tools.shellAllowedExecutables[1]) + assertEquals("node", result.tools.shellAllowedExecutables[2]) + assertEquals("python3", result.tools.shellAllowedExecutables[3]) + } + + @Test + fun `parseToml parses old flat tool enable flags for backward compat`() { + val toml = """ + [tools] + shell_enabled = false + file_read_enabled = true + file_write_enabled = false + file_edit_enabled = true + shell_allowed_executables = "bash,sh,python3" + """.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(false, result.tools.shellEnabled) + assertEquals(true, result.tools.fileReadEnabled) + assertEquals(false, result.tools.fileWriteEnabled) + assertEquals(true, result.tools.fileEditEnabled) + } + + @Test + fun `parseToml parses capabilities as CSV string for backward compat`() { + val toml = """ + [server] + host = "localhost" + + [[providers]] + id = "legacy-llama" + type = "llamacpp" + model_id = "mistral-7b" + capabilities = "General=1.0,Coding=0.7,Reasoning=0.6" + """.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(1, result.providers.size) + assertEquals(3, result.providers[0].capabilities.size) + assertEquals(1.0, result.providers[0].capabilities["General"]) + assertEquals(0.7, result.providers[0].capabilities["Coding"]) + } } diff --git a/docs/sample-config.toml b/docs/sample-config.toml new file mode 100644 index 00000000..4f7ecdaa --- /dev/null +++ b/docs/sample-config.toml @@ -0,0 +1,52 @@ +# CORREX Configuration Sample +# Place at ~/.config/correx/config.toml + +[server] +host = "localhost" +port = 8080 + +[tui] +theme = "dark" +session_list_limit = 5 + +[cli] +default_output = "human" + +[approval] +timeout_ms = 300000 + +[tools] +sandbox_root = "~/.config/correx/sandbox" +working_dir = "/tmp" +default_system_prompt_path = "~/.config/correx/prompts/default_system.md" + +[tools.shell] +enabled = true +allowed_executables = ["bash", "sh", "python3", "node"] + +[tools.file_read] +enabled = true + +[tools.file_write] +enabled = true + +[tools.file_edit] +enabled = true + +# Provider configuration (array of tables) +[[providers]] +id = "local-llama" +type = "llamacpp" +model_id = "mistral-7b" +model_path = "~/models/mistral-7b-gguf/model.gguf" +url = "http://127.0.0.1:10000" +capabilities = { General = 1.0, Coding = 0.7, Reasoning = 0.6, Summarization = 0.8, ToolCalling = 0.5 } + +# Example: second provider (if you have multiple) +# [[providers]] +# id = "alternative-llama" +# type = "llamacpp" +# model_id = "neural-chat-7b" +# model_path = "~/models/neural-chat-7b-gguf/model.gguf" +# url = "http://127.0.0.1:10001" +# capabilities = { General = 0.9, Coding = 0.8, Reasoning = 0.7, Summarization = 0.75, ToolCalling = 0.6 }