From 5eda64949fabc5c66ebe22212135abcf8e287215 Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 10 Jun 2026 21:45:46 +0400 Subject: [PATCH] =?UTF-8?q?feat(config):=20ProjectProfile=20=E2=80=94=20pe?= =?UTF-8?q?r-repo=20standing=20context=20at=20.correx/project.toml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/correx/core/config/ConfigLoader.kt | 79 +++++++++++-------- .../com/correx/core/config/ProjectProfile.kt | 18 +++++ .../core/config/ProjectProfileLoader.kt | 29 +++++++ .../core/config/ProjectProfileLoaderTest.kt | 47 +++++++++++ 4 files changed, 139 insertions(+), 34 deletions(-) create mode 100644 core/config/src/main/kotlin/com/correx/core/config/ProjectProfile.kt create mode 100644 core/config/src/main/kotlin/com/correx/core/config/ProjectProfileLoader.kt create mode 100644 core/config/src/test/kotlin/com/correx/core/config/ProjectProfileLoaderTest.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 89f5b1b4..01898d4e 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,24 +4,14 @@ 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 { +/** + * Minimal hand-rolled TOML parser shared by [ProfileLoader] and [ProjectProfileLoader]. + * Handles root key-value pairs, `[section]` tables, string arrays, booleans, and numbers. + * Does NOT handle array-of-tables (`[[…]]`) — use [ConfigLoader] for those. + */ +internal object SimpleToml { + /** Returns (rootKeys, sections) from a simple TOML string. */ + fun parse(content: String): Pair, Map>> { val lines = content.trim().split("\n") var currentSection = "" val rootKeys = mutableMapOf() @@ -50,18 +40,16 @@ object ProfileLoader { } } } + return Pair(rootKeys, sections) + } - val prefsSection = sections["preferences"] ?: emptyMap() - val preferences = ProfilePreferences( - approvalMode = asString(prefsSection["approval_mode"], ""), - preferredModels = asStringList(prefsSection["preferred_models"]), - conventions = asStringList(prefsSection["conventions"]), - ) + fun asString(value: Any?, default: String = ""): String = + if (value is String) value else default - return OperatorProfile( - about = asString(rootKeys["about"], ""), - preferences = preferences, - ) + fun asStringList(value: Any?): List = when (value) { + is List<*> -> value.filterIsInstance() + is String -> listOf(value) + else -> emptyList() } private fun parseSimpleValue(valueStr: String): Any = when { @@ -96,14 +84,37 @@ object ProfileLoader { 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 +object ProfileLoader { + fun profilePath(homeDir: String = System.getProperty("user.home")): Path = + Paths.get(homeDir, ".config", "correx", "profile.toml") - private fun asStringList(value: Any?): List = when (value) { - is List<*> -> value.filterIsInstance() - is String -> listOf(value) - else -> emptyList() + 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 (rootKeys, sections) = SimpleToml.parse(content) + val prefsSection = sections["preferences"] ?: emptyMap() + val preferences = ProfilePreferences( + approvalMode = SimpleToml.asString(prefsSection["approval_mode"], ""), + preferredModels = SimpleToml.asStringList(prefsSection["preferred_models"]), + conventions = SimpleToml.asStringList(prefsSection["conventions"]), + ) + return OperatorProfile( + about = SimpleToml.asString(rootKeys["about"], ""), + preferences = preferences, + ) } } diff --git a/core/config/src/main/kotlin/com/correx/core/config/ProjectProfile.kt b/core/config/src/main/kotlin/com/correx/core/config/ProjectProfile.kt new file mode 100644 index 00000000..45a72721 --- /dev/null +++ b/core/config/src/main/kotlin/com/correx/core/config/ProjectProfile.kt @@ -0,0 +1,18 @@ +package com.correx.core.config + +import kotlinx.serialization.Serializable + +/** + * Curated per-repo standing context, authored by the operator at + * `/.correx/project.toml`. Unlike distilled project memory (reactive, + * derived from decision journals), this is the deliberate "how to work in this repo" + * card: injected as L0 for every stage of every session bound to the workspace. + */ +@Serializable +data class ProjectProfile( + val about: String = "", + val conventions: List = emptyList(), + val commands: Map = emptyMap(), +) { + fun isEmpty(): Boolean = about.isBlank() && conventions.isEmpty() && commands.isEmpty() +} diff --git a/core/config/src/main/kotlin/com/correx/core/config/ProjectProfileLoader.kt b/core/config/src/main/kotlin/com/correx/core/config/ProjectProfileLoader.kt new file mode 100644 index 00000000..95f82143 --- /dev/null +++ b/core/config/src/main/kotlin/com/correx/core/config/ProjectProfileLoader.kt @@ -0,0 +1,29 @@ +package com.correx.core.config + +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths + +object ProjectProfileLoader { + fun profilePath(workspaceRoot: String): Path = + Paths.get(workspaceRoot, ".correx", "project.toml") + + fun load(workspaceRoot: String): ProjectProfile { + val path = profilePath(workspaceRoot) + if (!Files.exists(path)) return ProjectProfile() + return runCatching { parse(Files.readString(path)) }.getOrElse { e -> + System.err.println("Warning: Failed to parse project profile at $path: ${e.message}") + ProjectProfile() + } + } + + private fun parse(content: String): ProjectProfile { + val (rootKeys, sections) = SimpleToml.parse(content) + val commandsSection = sections["commands"] ?: emptyMap() + return ProjectProfile( + about = SimpleToml.asString(rootKeys["about"], ""), + conventions = SimpleToml.asStringList(rootKeys["conventions"]), + commands = commandsSection.mapValues { (_, v) -> v.toString() }, + ) + } +} diff --git a/core/config/src/test/kotlin/com/correx/core/config/ProjectProfileLoaderTest.kt b/core/config/src/test/kotlin/com/correx/core/config/ProjectProfileLoaderTest.kt new file mode 100644 index 00000000..77169c28 --- /dev/null +++ b/core/config/src/test/kotlin/com/correx/core/config/ProjectProfileLoaderTest.kt @@ -0,0 +1,47 @@ +package com.correx.core.config + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.nio.file.Files +import java.nio.file.Paths + +class ProjectProfileLoaderTest { + + private fun tempRoot(): String { + val dir = Files.createTempDirectory("correx-project-profile-test") + Files.createDirectories(dir.resolve(".correx")) + return dir.toString() + } + + @Test + fun `missing file returns default ProjectProfile`() { + assertEquals(ProjectProfile(), ProjectProfileLoader.load(tempRoot())) + } + + @Test + fun `full file populates about conventions and commands`() { + val root = tempRoot() + Files.writeString( + Paths.get(root, ".correx", "project.toml"), + """ + about = "Event-sourced orchestration kernel, Kotlin/JVM 21" + conventions = ["no bare try-catch", "events are the only source of truth"] + + [commands] + build = "./gradlew build" + test = "./gradlew check" + """.trimIndent(), + ) + val p = ProjectProfileLoader.load(root) + assertEquals("Event-sourced orchestration kernel, Kotlin/JVM 21", p.about) + assertEquals(listOf("no bare try-catch", "events are the only source of truth"), p.conventions) + assertEquals(mapOf("build" to "./gradlew build", "test" to "./gradlew check"), p.commands) + } + + @Test + fun `malformed file returns default without throwing`() { + val root = tempRoot() + Files.writeString(Paths.get(root, ".correx", "project.toml"), "[[[not toml") + assertEquals(ProjectProfile(), ProjectProfileLoader.load(root)) + } +}