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", skip_serializing_if = "Vec::is_empty")]
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, rename = "review", skip_serializing_if = "Vec::is_empty")]
pub reviewers: Vec<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 id: String,
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(),
reviewers: vec![],
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).map_err(|e| {
let hint = match e.to_string() {
m if m.contains("GateConfig") => {
"\n\nhint: build/test/lint go under [verify]. [gate] takes only [[gate.check]] entries."
}
m if m.contains("VerifyConfig") => {
"\n\nhint: [verify] takes build, test and lint as strings."
}
_ => "",
};
anyhow::anyhow!("{e}{hint}")
})
.with_context(|| format!("parsing {}", path.display()))?;
Ok(cfg)
}
pub fn for_stack(markers: &[String]) -> Self {
let has = |needle: &str| markers.iter().any(|m| m.contains(needle));
let mut cfg = Self::default();
let (verify, oracle) = if has("Cargo") {
(
("cargo build --quiet", "cargo test --quiet", "cargo clippy --all-targets --quiet"),
("cargo test --quiet -- --exact {name}", "cargo test --doc --quiet"),
)
} else if has("Bun") {
(
("bunx tsc --noEmit", "bun test", "bunx tsc --noEmit"),
(
"out=$(bun test --test-name-pattern '{name}' 2>&1); echo \"$out\" | grep -qE '[1-9][0-9]* pass' && echo \"$out\" | grep -qE '0 fail'",
"",
),
)
} else if has("Go (modules)") {
(
("go build ./...", "go test ./...", "go vet ./..."),
("go test -run '^{name}$' ./... 2>&1 | grep -qv '^no test files'", ""),
)
} else if has("Python") {
(
("", "pytest -q", "ruff check ."),
("pytest -q -k '{name}' 2>&1 | grep -qE '[1-9][0-9]* passed'", ""),
)
} else {
(("", "", ""), ("", ""))
};
let set = |v: &str| (!v.is_empty()).then(|| v.to_string());
cfg.verify.build = set(verify.0);
cfg.verify.test = set(verify.1);
cfg.verify.lint = set(verify.2);
cfg.oracle.test_cmd = oracle.0.to_string();
cfg.oracle.doctest_cmd = oracle.1.to_string();
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\
# build/test/lint go under [verify] — they are what G2 runs.\n\
# [gate] is for extra checks of your own ([[gate.check]]).\n\
# [oracle] is how a spec's `oracle: test` lines are executed.\n\n{body}"
);
std::fs::write(path, doc).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
}
#[cfg(test)]
mod stack_tests {
use super::*;
fn m(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn a_bun_repo_is_not_seeded_with_cargo() {
let cfg = Config::for_stack(&m(&["Bun/TypeScript"]));
assert_eq!(cfg.verify.test.as_deref(), Some("bun test"));
assert!(!cfg.oracle.test_cmd.contains("cargo"));
assert!(cfg.oracle.test_cmd.contains("pass"));
assert!(cfg.oracle.test_cmd.contains("fail"));
}
#[test]
fn a_rust_repo_still_gets_cargo() {
let cfg = Config::for_stack(&m(&["Rust (Cargo)"]));
assert_eq!(cfg.verify.build.as_deref(), Some("cargo build --quiet"));
assert!(cfg.oracle.test_cmd.starts_with("cargo test"));
}
#[test]
fn an_unknown_stack_is_left_blank_rather_than_guessed() {
let cfg = Config::for_stack(&m(&["Docker", "Make"]));
assert_eq!(cfg.verify.build, None);
assert_eq!(cfg.verify.test, None);
assert!(cfg.oracle.test_cmd.is_empty());
}
#[test]
fn the_saved_config_says_where_build_and_test_go() {
let dir = std::env::temp_dir().join(format!("keel-cfg-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("keel.toml");
Config::for_stack(&m(&["Rust (Cargo)"])).save(&path).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("[verify]"), "{text}");
assert!(text.contains("build/test/lint go under [verify]"), "{text}");
std::fs::remove_dir_all(&dir).ok();
}
}