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.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<String, Any>, Map<String, Map<String, Any>>> {
|
||||
val lines = content.trim().split("\n")
|
||||
var currentSection = ""
|
||||
val rootKeys = mutableMapOf<String, Any>()
|
||||
@@ -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<String> = when (value) {
|
||||
is List<*> -> value.filterIsInstance<String>()
|
||||
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<String> = when (value) {
|
||||
is List<*> -> value.filterIsInstance<String>()
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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