feat(config): ProjectProfile — per-repo standing context at .correx/project.toml
This commit is contained in:
@@ -4,24 +4,14 @@ 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 =
|
* Minimal hand-rolled TOML parser shared by [ProfileLoader] and [ProjectProfileLoader].
|
||||||
Paths.get(homeDir, ".config", "correx", "profile.toml")
|
* Handles root key-value pairs, `[section]` tables, string arrays, booleans, and numbers.
|
||||||
|
* Does NOT handle array-of-tables (`[[…]]`) — use [ConfigLoader] for those.
|
||||||
fun load(homeDir: String = System.getProperty("user.home")): OperatorProfile {
|
*/
|
||||||
val path = profilePath(homeDir)
|
internal object SimpleToml {
|
||||||
if (!Files.exists(path)) return OperatorProfile()
|
/** Returns (rootKeys, sections) from a simple TOML string. */
|
||||||
|
fun parse(content: String): Pair<Map<String, Any>, Map<String, Map<String, Any>>> {
|
||||||
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")
|
val lines = content.trim().split("\n")
|
||||||
var currentSection = ""
|
var currentSection = ""
|
||||||
val rootKeys = mutableMapOf<String, Any>()
|
val rootKeys = mutableMapOf<String, Any>()
|
||||||
@@ -50,18 +40,16 @@ object ProfileLoader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return Pair(rootKeys, sections)
|
||||||
|
}
|
||||||
|
|
||||||
val prefsSection = sections["preferences"] ?: emptyMap()
|
fun asString(value: Any?, default: String = ""): String =
|
||||||
val preferences = ProfilePreferences(
|
if (value is String) value else default
|
||||||
approvalMode = asString(prefsSection["approval_mode"], ""),
|
|
||||||
preferredModels = asStringList(prefsSection["preferred_models"]),
|
|
||||||
conventions = asStringList(prefsSection["conventions"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
return OperatorProfile(
|
fun asStringList(value: Any?): List<String> = when (value) {
|
||||||
about = asString(rootKeys["about"], ""),
|
is List<*> -> value.filterIsInstance<String>()
|
||||||
preferences = preferences,
|
is String -> listOf(value)
|
||||||
)
|
else -> emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseSimpleValue(valueStr: String): Any = when {
|
private fun parseSimpleValue(valueStr: String): Any = when {
|
||||||
@@ -96,14 +84,37 @@ object ProfileLoader {
|
|||||||
val isSingleQuoted = value.startsWith("'") && value.endsWith("'")
|
val isSingleQuoted = value.startsWith("'") && value.endsWith("'")
|
||||||
return if (isDoubleQuoted || isSingleQuoted) value.substring(1, value.length - 1) else value
|
return if (isDoubleQuoted || isSingleQuoted) value.substring(1, value.length - 1) else value
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun asString(value: Any?, default: String = ""): String =
|
object ProfileLoader {
|
||||||
if (value is String) value else default
|
fun profilePath(homeDir: String = System.getProperty("user.home")): Path =
|
||||||
|
Paths.get(homeDir, ".config", "correx", "profile.toml")
|
||||||
|
|
||||||
private fun asStringList(value: Any?): List<String> = when (value) {
|
fun load(homeDir: String = System.getProperty("user.home")): OperatorProfile {
|
||||||
is List<*> -> value.filterIsInstance<String>()
|
val path = profilePath(homeDir)
|
||||||
is String -> listOf(value)
|
if (!Files.exists(path)) return OperatorProfile()
|
||||||
else -> emptyList()
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.correx.core.config
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Curated per-repo standing context, authored by the operator at
|
||||||
|
* `<workspaceRoot>/.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<String> = emptyList(),
|
||||||
|
val commands: Map<String, String> = emptyMap(),
|
||||||
|
) {
|
||||||
|
fun isEmpty(): Boolean = about.isBlank() && conventions.isEmpty() && commands.isEmpty()
|
||||||
|
}
|
||||||
@@ -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() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user