use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
pub const CONFIG_SCHEMA: &str = "keel.config/1";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default = "default_schema")]
pub schema: String,
#[serde(default)]
pub map: MapConfig,
#[serde(default)]
pub store: StoreConfig,
#[serde(default = "default_adapters", rename = "adapter")]
pub adapters: Vec<Adapter>,
#[serde(default)]
pub spec: SpecConfig,
#[serde(default)]
pub plan: PlanConfig,
#[serde(default)]
pub gate: std::collections::BTreeMap<String, GateConfig>,
#[serde(default = "default_drivers", rename = "driver")]
pub drivers: Vec<Driver>,
#[serde(default)]
pub verify: VerifyConfig,
#[serde(default)]
pub oracle: OracleConfig,
#[serde(default)]
pub learn: LearnConfig,
#[serde(default)]
pub retrieve: RetrieveConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review: Option<Reviewer>,
#[serde(default, rename = "shared")]
pub shared: Vec<SharedStore>,
#[serde(default = "default_ratchets", rename = "ratchet")]
pub ratchets: Vec<Ratchet>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SharedStore {
pub id: String,
pub path: String,
#[serde(default = "default_true")]
pub required: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Reviewer {
pub cmd: String,
#[serde(default = "default_review_timeout")]
pub timeout_secs: u64,
#[serde(default)]
pub advisory: bool,
}
fn default_review_timeout() -> u64 { 300 }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RetrieveConfig {
pub query_tokens: usize,
pub slice_tokens: usize,
pub max_unjustified_lines: usize,
}
impl Default for RetrieveConfig {
fn default() -> Self {
Self { query_tokens: 2_000, slice_tokens: 6_000, max_unjustified_lines: 300 }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LearnConfig {
pub max_unattributable_rate: f64,
}
impl Default for LearnConfig {
fn default() -> Self {
Self { max_unattributable_rate: 0.5 }
}
}
fn default_ratchets() -> Vec<Ratchet> {
vec![Ratchet {
id: "todo-markers".into(),
cmd: "git grep -cE 'TODO|FIXME' -- . 2>/dev/null | awk -F: '{s+=$2} END {print s+0}'".into(),
direction: crate::gate::ratchet::Direction::Down,
}]
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Ratchet {
pub id: String,
pub cmd: String,
pub direction: crate::gate::ratchet::Direction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Driver {
pub id: String,
pub cmd: String,
#[serde(default)]
pub default: bool,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
}
fn default_timeout() -> u64 { 900 }
fn default_drivers() -> Vec<Driver> {
vec![Driver {
id: "claude-code".into(),
cmd: ".keel/drivers/claude-code".into(),
default: true,
timeout_secs: default_timeout(),
}]
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct VerifyConfig {
pub build: Option<String>,
pub test: Option<String>,
pub lint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct OracleConfig {
pub test_cmd: String,
pub doctest_cmd: String,
}
impl Default for OracleConfig {
fn default() -> Self {
Self {
test_cmd: "cargo test --quiet -- --exact {name}".into(),
doctest_cmd: "cargo test --doc --quiet".into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SpecConfig {
pub max_criteria: usize,
pub max_lines: usize,
pub max_ambiguities: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub cmd: Option<String>,
}
impl Default for SpecConfig {
fn default() -> Self {
Self { max_criteria: 12, max_lines: 250, max_ambiguities: 0, cmd: None }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct PlanConfig {
pub blast_depth: usize,
pub max_task_lines: usize,
pub max_tasks: usize,
pub max_reviewable_lines: usize,
}
impl Default for PlanConfig {
fn default() -> Self {
Self { blast_depth: 2, max_task_lines: 150, max_tasks: 15, max_reviewable_lines: 600 }
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GateConfig {
#[serde(default, rename = "check", skip_serializing_if = "Vec::is_empty")]
pub checks: Vec<CheckPlugin>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckPlugin {
pub id: String,
pub cmd: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from: Option<String>,
}
fn default_schema() -> String { CONFIG_SCHEMA.to_string() }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct MapConfig {
pub budget_lines: usize,
pub codemap_budget_lines: usize,
pub max_file_bytes: u64,
pub codemap_min_files: usize,
pub exclude: Vec<String>,
}
impl Default for MapConfig {
fn default() -> Self {
Self {
budget_lines: 400,
codemap_budget_lines: 150,
max_file_bytes: 1_048_576,
codemap_min_files: 2,
exclude: vec![],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct StoreConfig {
pub steering_budget_lines: usize,
}
impl Default for StoreConfig {
fn default() -> Self { Self { steering_budget_lines: 150 } }
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Adapter {
pub id: String,
pub out: String,
pub budget: usize,
#[serde(default = "default_sections")]
pub sections: Vec<String>,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cmd: Option<String>,
}
fn default_true() -> bool { true }
fn default_sections() -> Vec<String> {
["conventions", "tech", "structure", "product", "lessons"]
.iter().map(|s| s.to_string()).collect()
}
fn default_adapters() -> Vec<Adapter> {
vec![
Adapter { id: "claude".into(), out: "CLAUDE.md".into(), budget: 180, sections: default_sections(), enabled: true, cmd: None },
Adapter { id: "agents".into(), out: "AGENTS.md".into(), budget: 180, sections: default_sections(), enabled: true, cmd: None },
Adapter { id: "kiro".into(), out: ".kiro/steering/keel.md".into(), budget: 200, sections: default_sections(), enabled: true, cmd: None },
Adapter { id: "copilot".into(), out: ".github/copilot-instructions.md".into(), budget: 120, sections: default_sections(), enabled: true, cmd: None },
]
}
impl Default for Config {
fn default() -> Self {
Self {
schema: default_schema(),
map: MapConfig::default(),
store: StoreConfig::default(),
adapters: default_adapters(),
spec: SpecConfig::default(),
plan: PlanConfig::default(),
gate: Default::default(),
drivers: default_drivers(),
verify: VerifyConfig::default(),
oracle: OracleConfig::default(),
learn: LearnConfig::default(),
retrieve: RetrieveConfig::default(),
review: None,
shared: vec![],
ratchets: default_ratchets(),
}
}
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("reading {}", path.display()))?;
let cfg: Config = toml::from_str(&raw)
.with_context(|| format!("parsing {}", path.display()))?;
Ok(cfg)
}
pub fn save(&self, path: &Path) -> Result<()> {
let body = toml::to_string_pretty(self)?;
let doc = format!("# keel configuration — schema {CONFIG_SCHEMA}\n\
# Budgets are enforced, not advised. Lower them until they hurt.\n\n{body}");
std::fs::write(path, doc).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
}