use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::grind::plan::{Hooks, PlanBudgets};
use crate::util::paths;
pub fn config_path(workspace: impl AsRef<Path>) -> PathBuf {
paths::config_path(workspace)
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub models: ModelRoles,
pub retries: RetryBudgets,
pub audit: AuditConfig,
pub git: GitConfig,
pub tests: TestsConfig,
pub budgets: Budgets,
pub agent: AgentConfig,
pub caveman: CavemanConfig,
pub grind: GrindConfig,
pub sweep: SweepConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ModelRoles {
pub planner: String,
pub implementer: String,
pub auditor: String,
pub fixer: String,
}
impl Default for ModelRoles {
fn default() -> Self {
Self {
planner: "claude-opus-4-7".to_string(),
implementer: "claude-opus-4-7".to_string(),
auditor: "claude-opus-4-7".to_string(),
fixer: "claude-opus-4-7".to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct RetryBudgets {
pub fixer_max_attempts: u32,
pub max_phase_attempts: u32,
}
impl Default for RetryBudgets {
fn default() -> Self {
Self {
fixer_max_attempts: 2,
max_phase_attempts: 3,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct AuditConfig {
pub enabled: bool,
pub small_fix_line_limit: u32,
}
impl Default for AuditConfig {
fn default() -> Self {
Self {
enabled: true,
small_fix_line_limit: 30,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct GitConfig {
pub branch_prefix: String,
pub create_pr: bool,
}
impl Default for GitConfig {
fn default() -> Self {
Self {
branch_prefix: "pitboss/play/".to_string(),
create_pr: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Budgets {
pub max_total_tokens: Option<u64>,
pub max_total_usd: Option<f64>,
pub pricing: HashMap<String, ModelPricing>,
}
impl Default for Budgets {
fn default() -> Self {
let mut pricing = HashMap::new();
pricing.insert(
"claude-opus-4-7".to_string(),
ModelPricing {
input_per_million_usd: 15.0,
output_per_million_usd: 75.0,
},
);
pricing.insert(
"claude-sonnet-4-6".to_string(),
ModelPricing {
input_per_million_usd: 3.0,
output_per_million_usd: 15.0,
},
);
pricing.insert(
"claude-haiku-4-5".to_string(),
ModelPricing {
input_per_million_usd: 1.0,
output_per_million_usd: 5.0,
},
);
Self {
max_total_tokens: None,
max_total_usd: None,
pricing,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct ModelPricing {
pub input_per_million_usd: f64,
pub output_per_million_usd: f64,
}
impl ModelPricing {
pub fn cost_usd(&self, input: u64, output: u64) -> f64 {
let input = (input as f64) * self.input_per_million_usd / 1_000_000.0;
let output = (output as f64) * self.output_per_million_usd / 1_000_000.0;
input + output
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct TestsConfig {
pub command: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct AgentConfig {
pub backend: Option<String>,
pub claude_code: BackendOverrides,
pub codex: BackendOverrides,
pub aider: BackendOverrides,
pub gemini: BackendOverrides,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct CavemanConfig {
pub enabled: bool,
pub intensity: CavemanIntensity,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum CavemanIntensity {
Lite,
#[default]
Full,
Ultra,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct GrindConfig {
pub prompts_dir: Option<PathBuf>,
pub default_rotation: Option<String>,
pub max_parallel: u32,
pub consecutive_failure_limit: u32,
pub hook_timeout_secs: u64,
pub hook_env_passthrough: Vec<String>,
pub transcript_retention: TranscriptRetention,
pub budgets: PlanBudgets,
pub hooks: Hooks,
}
impl Default for GrindConfig {
fn default() -> Self {
Self {
prompts_dir: None,
default_rotation: None,
max_parallel: 1,
consecutive_failure_limit: 3,
hook_timeout_secs: 60,
hook_env_passthrough: Vec::new(),
transcript_retention: TranscriptRetention::default(),
budgets: PlanBudgets::default(),
hooks: Hooks::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct SweepConfig {
pub enabled: bool,
pub trigger_min_items: u32,
pub trigger_max_items: u32,
pub max_consecutive: u32,
pub escalate_after: u32,
pub audit_enabled: bool,
pub final_sweep_enabled: bool,
pub final_sweep_max_iterations: u32,
}
impl Default for SweepConfig {
fn default() -> Self {
Self {
enabled: true,
trigger_min_items: 5,
trigger_max_items: 8,
max_consecutive: 1,
escalate_after: 3,
audit_enabled: true,
final_sweep_enabled: true,
final_sweep_max_iterations: 3,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum TranscriptRetention {
#[default]
KeepAll,
KeepNone,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct BackendOverrides {
pub binary: Option<PathBuf>,
pub extra_args: Vec<String>,
pub model: Option<String>,
pub approval_policy: Option<String>,
pub permission_mode: Option<String>,
}
pub fn load(workspace: impl AsRef<Path>) -> Result<Config> {
let path = config_path(workspace.as_ref());
let text = match fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Config::default()),
Err(e) => {
return Err(anyhow::Error::new(e).context(format!("config::load: reading {:?}", path)));
}
};
parse(&text).with_context(|| format!("config::load: parsing {:?}", path))
}
pub fn parse(text: &str) -> Result<Config> {
if text.trim().is_empty() {
return Ok(Config::default());
}
let value: toml::Value = toml::from_str(text).context("config.toml is not valid TOML")?;
for unknown in find_unknown_keys(&value) {
warn!(key = %unknown, "config.toml: unknown key {:?} (ignored)", unknown);
}
let cfg: Config = value
.try_into()
.context("config.toml does not match the expected schema")?;
validate(&cfg)?;
Ok(cfg)
}
fn validate(cfg: &Config) -> Result<()> {
if cfg.grind.max_parallel == 0 {
anyhow::bail!("config.toml: [grind] max_parallel must be >= 1");
}
if cfg.grind.consecutive_failure_limit == 0 {
anyhow::bail!("config.toml: [grind] consecutive_failure_limit must be >= 1");
}
if cfg.grind.hook_timeout_secs == 0 {
anyhow::bail!("config.toml: [grind] hook_timeout_secs must be >= 1");
}
if cfg.sweep.trigger_min_items < 1 {
anyhow::bail!("config.toml: [sweep] trigger_min_items must be >= 1");
}
if cfg.sweep.trigger_min_items > cfg.sweep.trigger_max_items {
anyhow::bail!(
"config.toml: [sweep] trigger_min_items ({}) must be <= trigger_max_items ({})",
cfg.sweep.trigger_min_items,
cfg.sweep.trigger_max_items
);
}
if cfg.sweep.max_consecutive < 1 {
anyhow::bail!("config.toml: [sweep] max_consecutive must be >= 1");
}
if cfg.sweep.escalate_after < 1 {
anyhow::bail!("config.toml: [sweep] escalate_after must be >= 1");
}
if cfg.sweep.final_sweep_max_iterations < 1 {
anyhow::bail!("config.toml: [sweep] final_sweep_max_iterations must be >= 1");
}
Ok(())
}
fn find_unknown_keys(value: &toml::Value) -> Vec<String> {
let mut out = Vec::new();
let toml::Value::Table(top) = value else {
return out;
};
for (section, sub) in top {
let known_subkeys: &[&str] = match section.as_str() {
"models" => &["planner", "implementer", "auditor", "fixer"],
"retries" => &["fixer_max_attempts", "max_phase_attempts"],
"audit" => &["enabled", "small_fix_line_limit"],
"git" => &["branch_prefix", "create_pr"],
"tests" => &["command"],
"budgets" => &["max_total_tokens", "max_total_usd", "pricing"],
"agent" => &["backend", "claude_code", "codex", "aider", "gemini"],
"caveman" => &["enabled", "intensity"],
"grind" => &[
"prompts_dir",
"default_rotation",
"max_parallel",
"consecutive_failure_limit",
"hook_timeout_secs",
"hook_env_passthrough",
"transcript_retention",
"budgets",
"hooks",
],
"sweep" => &[
"enabled",
"trigger_min_items",
"trigger_max_items",
"max_consecutive",
"escalate_after",
"audit_enabled",
"final_sweep_enabled",
"final_sweep_max_iterations",
],
_ => {
out.push(section.clone());
continue;
}
};
if let toml::Value::Table(sub_table) = sub {
for sub_key in sub_table.keys() {
if !known_subkeys.contains(&sub_key.as_str()) {
out.push(format!("{}.{}", section, sub_key));
}
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn defaults_are_self_consistent() {
let cfg = Config::default();
assert_eq!(cfg.models.planner, "claude-opus-4-7");
assert_eq!(cfg.models.implementer, "claude-opus-4-7");
assert_eq!(cfg.models.auditor, "claude-opus-4-7");
assert_eq!(cfg.models.fixer, "claude-opus-4-7");
assert_eq!(cfg.retries.fixer_max_attempts, 2);
assert_eq!(cfg.retries.max_phase_attempts, 3);
assert!(cfg.audit.enabled);
assert_eq!(cfg.audit.small_fix_line_limit, 30);
assert_eq!(cfg.git.branch_prefix, "pitboss/play/");
assert!(!cfg.git.create_pr);
assert!(cfg.tests.command.is_none());
assert_eq!(cfg.budgets.max_total_tokens, None);
assert_eq!(cfg.budgets.max_total_usd, None);
assert!(cfg.budgets.pricing.contains_key("claude-opus-4-7"));
assert_eq!(cfg.agent, AgentConfig::default());
assert_eq!(cfg.agent.backend, None);
assert!(!cfg.caveman.enabled);
assert_eq!(cfg.caveman.intensity, CavemanIntensity::Full);
assert_eq!(cfg.grind, GrindConfig::default());
assert_eq!(cfg.grind.max_parallel, 1);
assert_eq!(cfg.grind.consecutive_failure_limit, 3);
assert_eq!(cfg.grind.hook_timeout_secs, 60);
assert_eq!(cfg.grind.transcript_retention, TranscriptRetention::KeepAll);
assert!(cfg.grind.prompts_dir.is_none());
assert!(cfg.grind.default_rotation.is_none());
assert_eq!(cfg.sweep, SweepConfig::default());
assert!(cfg.sweep.enabled);
assert_eq!(cfg.sweep.trigger_min_items, 5);
assert_eq!(cfg.sweep.trigger_max_items, 8);
assert_eq!(cfg.sweep.max_consecutive, 1);
assert_eq!(cfg.sweep.escalate_after, 3);
assert!(cfg.sweep.audit_enabled);
assert!(cfg.sweep.final_sweep_enabled);
assert_eq!(cfg.sweep.final_sweep_max_iterations, 3);
}
#[test]
fn caveman_section_round_trips_full_form() {
let text = "
[caveman]
enabled = true
intensity = \"ultra\"
";
let cfg = parse(text).unwrap();
assert!(cfg.caveman.enabled);
assert_eq!(cfg.caveman.intensity, CavemanIntensity::Ultra);
let value: toml::Value = toml::from_str(text).unwrap();
assert!(find_unknown_keys(&value).is_empty());
}
#[test]
fn caveman_section_accepts_each_intensity_level() {
for (s, expected) in [
("lite", CavemanIntensity::Lite),
("full", CavemanIntensity::Full),
("ultra", CavemanIntensity::Ultra),
] {
let text = format!("[caveman]\nenabled = true\nintensity = \"{s}\"\n");
let cfg = parse(&text).unwrap();
assert_eq!(cfg.caveman.intensity, expected, "intensity {s}");
}
}
#[test]
fn caveman_section_rejects_unknown_intensity() {
let text = "
[caveman]
enabled = true
intensity = \"galaxybrain\"
";
let err = parse(text).unwrap_err();
let msg = format!("{:#}", err);
assert!(
msg.contains("expected schema"),
"expected schema error for unknown intensity, got: {msg}"
);
}
#[test]
fn caveman_unknown_subkeys_are_flagged() {
let text = "
[caveman]
enabled = true
mode = \"wenyan\"
";
let value: toml::Value = toml::from_str(text).unwrap();
let unknown = find_unknown_keys(&value);
assert!(unknown.contains(&"caveman.mode".to_string()));
}
#[test]
fn model_pricing_cost_usd_is_per_million_tokens() {
let p = ModelPricing {
input_per_million_usd: 10.0,
output_per_million_usd: 100.0,
};
let cost = p.cost_usd(1_000_000, 100_000);
assert!((cost - 20.0).abs() < 1e-9, "cost: {cost}");
}
#[test]
fn budgets_section_parses_full_form() {
let text = "
[budgets]
max_total_tokens = 1_000_000
max_total_usd = 5.0
[budgets.pricing.claude-opus-4-7]
input_per_million_usd = 12.5
output_per_million_usd = 60.0
[budgets.pricing.custom-model]
input_per_million_usd = 0.5
output_per_million_usd = 2.0
";
let cfg = parse(text).unwrap();
assert_eq!(cfg.budgets.max_total_tokens, Some(1_000_000));
assert_eq!(cfg.budgets.max_total_usd, Some(5.0));
let opus = cfg.budgets.pricing.get("claude-opus-4-7").unwrap();
assert_eq!(opus.input_per_million_usd, 12.5);
assert_eq!(opus.output_per_million_usd, 60.0);
let custom = cfg.budgets.pricing.get("custom-model").unwrap();
assert_eq!(custom.input_per_million_usd, 0.5);
}
#[test]
fn budgets_pricing_subkeys_are_not_flagged_as_unknown() {
let text = "
[budgets]
max_total_tokens = 100
[budgets.pricing.brand-new-model]
input_per_million_usd = 1.0
output_per_million_usd = 2.0
";
let value: toml::Value = toml::from_str(text).unwrap();
let unknown = find_unknown_keys(&value);
assert!(unknown.is_empty(), "unexpected unknown keys: {:?}", unknown);
}
#[test]
fn agent_section_round_trips_full_form() {
let text = "
[agent]
backend = \"codex\"
[agent.claude_code]
binary = \"/opt/anthropic/claude\"
extra_args = [\"--max-turns\", \"50\"]
model = \"claude-opus-4-7\"
permission_mode = \"bypassPermissions\"
[agent.codex]
binary = \"/usr/local/bin/codex\"
extra_args = [\"--quiet\"]
model = \"gpt-5\"
approval_policy = \"on-request\"
[agent.aider]
binary = \"/usr/local/bin/aider\"
extra_args = []
model = \"sonnet\"
[agent.gemini]
binary = \"/usr/local/bin/gemini\"
extra_args = [\"--no-stream\"]
model = \"gemini-2.5-pro\"
";
let cfg = parse(text).unwrap();
assert_eq!(cfg.agent.backend.as_deref(), Some("codex"));
assert_eq!(
cfg.agent.claude_code.binary,
Some(PathBuf::from("/opt/anthropic/claude"))
);
assert_eq!(
cfg.agent.claude_code.extra_args,
vec!["--max-turns".to_string(), "50".to_string()]
);
assert_eq!(
cfg.agent.claude_code.model.as_deref(),
Some("claude-opus-4-7")
);
assert_eq!(
cfg.agent.claude_code.permission_mode.as_deref(),
Some("bypassPermissions")
);
assert_eq!(
cfg.agent.codex.binary,
Some(PathBuf::from("/usr/local/bin/codex"))
);
assert_eq!(cfg.agent.codex.extra_args, vec!["--quiet".to_string()]);
assert_eq!(cfg.agent.codex.model.as_deref(), Some("gpt-5"));
assert_eq!(
cfg.agent.codex.approval_policy.as_deref(),
Some("on-request")
);
assert_eq!(
cfg.agent.aider.binary,
Some(PathBuf::from("/usr/local/bin/aider"))
);
assert!(cfg.agent.aider.extra_args.is_empty());
assert_eq!(cfg.agent.aider.model.as_deref(), Some("sonnet"));
assert_eq!(
cfg.agent.gemini.binary,
Some(PathBuf::from("/usr/local/bin/gemini"))
);
assert_eq!(cfg.agent.gemini.extra_args, vec!["--no-stream".to_string()]);
assert_eq!(cfg.agent.gemini.model.as_deref(), Some("gemini-2.5-pro"));
let value: toml::Value = toml::from_str(text).unwrap();
assert!(find_unknown_keys(&value).is_empty());
}
#[test]
fn agent_backend_alone_round_trips_with_defaults() {
let text = "
[agent]
backend = \"codex\"
";
let cfg = parse(text).unwrap();
assert_eq!(cfg.agent.backend.as_deref(), Some("codex"));
assert_eq!(cfg.agent.codex, BackendOverrides::default());
assert_eq!(cfg.agent.claude_code, BackendOverrides::default());
assert_eq!(cfg.agent.aider, BackendOverrides::default());
assert_eq!(cfg.agent.gemini, BackendOverrides::default());
}
#[test]
fn empty_input_yields_defaults() {
assert_eq!(parse("").unwrap(), Config::default());
assert_eq!(parse(" \n\t\n").unwrap(), Config::default());
}
#[test]
fn full_input_overrides_every_field() {
let text = "
[models]
planner = \"a\"
implementer = \"b\"
auditor = \"c\"
fixer = \"d\"
[retries]
fixer_max_attempts = 7
max_phase_attempts = 11
[audit]
enabled = false
small_fix_line_limit = 5
[git]
branch_prefix = \"work/\"
create_pr = true
[tests]
command = \"make check\"
";
let cfg = parse(text).unwrap();
assert_eq!(cfg.models.planner, "a");
assert_eq!(cfg.models.implementer, "b");
assert_eq!(cfg.models.auditor, "c");
assert_eq!(cfg.models.fixer, "d");
assert_eq!(cfg.retries.fixer_max_attempts, 7);
assert_eq!(cfg.retries.max_phase_attempts, 11);
assert!(!cfg.audit.enabled);
assert_eq!(cfg.audit.small_fix_line_limit, 5);
assert_eq!(cfg.git.branch_prefix, "work/");
assert!(cfg.git.create_pr);
assert_eq!(cfg.tests.command.as_deref(), Some("make check"));
}
#[test]
fn partial_input_fills_remaining_with_defaults() {
let text = "
[git]
create_pr = true
";
let cfg = parse(text).unwrap();
assert!(cfg.git.create_pr);
assert_eq!(cfg.git.branch_prefix, "pitboss/play/");
assert_eq!(cfg.models, ModelRoles::default());
assert_eq!(cfg.retries, RetryBudgets::default());
assert_eq!(cfg.audit, AuditConfig::default());
}
#[test]
fn partial_section_fills_missing_subkeys() {
let text = "
[models]
implementer = \"custom-impl\"
";
let cfg = parse(text).unwrap();
assert_eq!(cfg.models.implementer, "custom-impl");
assert_eq!(cfg.models.planner, ModelRoles::default().planner);
assert_eq!(cfg.models.auditor, ModelRoles::default().auditor);
assert_eq!(cfg.models.fixer, ModelRoles::default().fixer);
}
#[test]
fn malformed_toml_is_an_error() {
let err = parse("[models\nplanner = \"x\"").unwrap_err();
let msg = format!("{:#}", err);
assert!(msg.contains("not valid TOML"), "msg: {msg}");
}
#[test]
fn wrong_value_type_is_an_error() {
let text = "
[retries]
fixer_max_attempts = \"two\"
";
let err = parse(text).unwrap_err();
let msg = format!("{:#}", err);
assert!(
msg.contains("expected schema"),
"expected schema error, got: {msg}"
);
}
#[test]
fn unknown_keys_are_collected_not_errored() {
let text = "
something_extra = 1
[models]
planner = \"p\"
new_role = \"x\"
[telemetry]
sink = \"stdout\"
";
let cfg = parse(text).unwrap();
assert_eq!(cfg.models.planner, "p");
let toml_value: toml::Value = toml::from_str(text).unwrap();
let unknown = find_unknown_keys(&toml_value);
assert!(unknown.contains(&"something_extra".to_string()));
assert!(unknown.contains(&"models.new_role".to_string()));
assert!(unknown.contains(&"telemetry".to_string()));
}
#[test]
fn no_unknown_keys_for_canonical_input() {
let text = "
[models]
planner = \"p\"
implementer = \"i\"
auditor = \"a\"
fixer = \"f\"
[retries]
fixer_max_attempts = 1
max_phase_attempts = 2
[audit]
enabled = true
small_fix_line_limit = 10
[git]
branch_prefix = \"x/\"
create_pr = false
[tests]
command = \"cargo test\"
";
let value: toml::Value = toml::from_str(text).unwrap();
assert!(find_unknown_keys(&value).is_empty());
}
#[test]
fn load_returns_defaults_when_file_missing() {
let dir = tempdir().unwrap();
let cfg = load(dir.path()).unwrap();
assert_eq!(cfg, Config::default());
}
#[test]
fn load_reads_file_from_workspace() {
let dir = tempdir().unwrap();
let cfg_path = config_path(dir.path());
std::fs::create_dir_all(cfg_path.parent().unwrap()).unwrap();
std::fs::write(&cfg_path, "[git]\nbranch_prefix = \"loaded/\"\n").unwrap();
let cfg = load(dir.path()).unwrap();
assert_eq!(cfg.git.branch_prefix, "loaded/");
}
#[test]
fn load_surfaces_parse_errors_with_path_context() {
let dir = tempdir().unwrap();
let cfg_path = config_path(dir.path());
std::fs::create_dir_all(cfg_path.parent().unwrap()).unwrap();
std::fs::write(&cfg_path, "[broken").unwrap();
let err = load(dir.path()).unwrap_err();
let msg = format!("{:#}", err);
assert!(msg.contains("config.toml"), "msg: {msg}");
}
#[test]
fn init_template_round_trips_through_loader() {
let dir = tempdir().unwrap();
crate::cli::init::run(dir.path()).unwrap();
let cfg = load(dir.path()).unwrap();
assert_eq!(cfg, Config::default());
}
}
#[cfg(test)]
mod grind {
use super::*;
#[test]
fn missing_section_yields_default() {
let cfg = parse("[git]\nbranch_prefix = \"x/\"\n").unwrap();
assert_eq!(cfg.grind, GrindConfig::default());
}
#[test]
fn full_section_round_trips() {
let text = r#"
[grind]
prompts_dir = "/var/pitboss/prompts"
default_rotation = "nightly"
max_parallel = 4
consecutive_failure_limit = 7
hook_timeout_secs = 90
transcript_retention = "keep_none"
[grind.budgets]
max_iterations = 50
until = "2026-05-01T00:00:00Z"
max_cost_usd = 5.0
max_tokens = 1000000
[grind.hooks]
pre_session = "echo before"
post_session = "echo after"
on_failure = "echo failed"
"#;
let cfg = parse(text).unwrap();
assert_eq!(
cfg.grind.prompts_dir,
Some(PathBuf::from("/var/pitboss/prompts"))
);
assert_eq!(cfg.grind.default_rotation.as_deref(), Some("nightly"));
assert_eq!(cfg.grind.max_parallel, 4);
assert_eq!(cfg.grind.consecutive_failure_limit, 7);
assert_eq!(cfg.grind.hook_timeout_secs, 90);
assert_eq!(
cfg.grind.transcript_retention,
TranscriptRetention::KeepNone
);
assert_eq!(cfg.grind.budgets.max_iterations, Some(50));
assert_eq!(cfg.grind.budgets.max_cost_usd, Some(5.0));
assert_eq!(cfg.grind.budgets.max_tokens, Some(1_000_000));
assert!(cfg.grind.budgets.until.is_some());
assert_eq!(cfg.grind.hooks.pre_session.as_deref(), Some("echo before"));
assert_eq!(cfg.grind.hooks.post_session.as_deref(), Some("echo after"));
assert_eq!(cfg.grind.hooks.on_failure.as_deref(), Some("echo failed"));
let value: toml::Value = toml::from_str(text).unwrap();
assert!(find_unknown_keys(&value).is_empty());
}
#[test]
fn partial_section_fills_missing_with_defaults() {
let text = r#"
[grind]
max_parallel = 2
"#;
let cfg = parse(text).unwrap();
assert_eq!(cfg.grind.max_parallel, 2);
assert_eq!(cfg.grind.consecutive_failure_limit, 3);
assert_eq!(cfg.grind.hook_timeout_secs, 60);
assert_eq!(cfg.grind.transcript_retention, TranscriptRetention::KeepAll);
assert!(cfg.grind.budgets.max_iterations.is_none());
assert!(cfg.grind.hooks.pre_session.is_none());
}
#[test]
fn transcript_retention_accepts_each_variant() {
for (s, expected) in [
("keep_all", TranscriptRetention::KeepAll),
("keep_none", TranscriptRetention::KeepNone),
] {
let text = format!("[grind]\ntranscript_retention = \"{s}\"\n");
let cfg = parse(&text).unwrap();
assert_eq!(
cfg.grind.transcript_retention, expected,
"transcript_retention {s}"
);
}
}
#[test]
fn transcript_retention_rejects_unknown_value() {
let text = "[grind]\ntranscript_retention = \"shred\"\n";
let err = parse(text).unwrap_err();
let msg = format!("{:#}", err);
assert!(
msg.contains("expected schema"),
"expected schema error for unknown retention, got: {msg}"
);
}
#[test]
fn unknown_top_level_grind_key_is_flagged() {
let text = "[grind]\nturbo = true\n";
let value: toml::Value = toml::from_str(text).unwrap();
let unknown = find_unknown_keys(&value);
assert!(unknown.contains(&"grind.turbo".to_string()));
}
#[test]
fn max_parallel_zero_is_rejected() {
let text = "[grind]\nmax_parallel = 0\n";
let err = parse(text).unwrap_err();
let msg = format!("{:#}", err);
assert!(msg.contains("max_parallel"), "msg: {msg}");
}
#[test]
fn consecutive_failure_limit_zero_is_rejected() {
let text = "[grind]\nconsecutive_failure_limit = 0\n";
let err = parse(text).unwrap_err();
let msg = format!("{:#}", err);
assert!(msg.contains("consecutive_failure_limit"), "msg: {msg}");
}
#[test]
fn hook_timeout_secs_zero_is_rejected() {
let text = "[grind]\nhook_timeout_secs = 0\n";
let err = parse(text).unwrap_err();
let msg = format!("{:#}", err);
assert!(msg.contains("hook_timeout_secs"), "msg: {msg}");
}
}
#[cfg(test)]
mod sweep {
use super::*;
#[test]
fn missing_section_yields_default() {
let cfg = parse("[git]\nbranch_prefix = \"x/\"\n").unwrap();
assert_eq!(cfg.sweep, SweepConfig::default());
}
#[test]
fn full_section_round_trips() {
let text = "
[sweep]
enabled = false
trigger_min_items = 3
trigger_max_items = 10
max_consecutive = 2
escalate_after = 4
audit_enabled = false
final_sweep_enabled = false
final_sweep_max_iterations = 5
";
let cfg = parse(text).unwrap();
assert!(!cfg.sweep.enabled);
assert_eq!(cfg.sweep.trigger_min_items, 3);
assert_eq!(cfg.sweep.trigger_max_items, 10);
assert_eq!(cfg.sweep.max_consecutive, 2);
assert_eq!(cfg.sweep.escalate_after, 4);
assert!(!cfg.sweep.audit_enabled);
assert!(!cfg.sweep.final_sweep_enabled);
assert_eq!(cfg.sweep.final_sweep_max_iterations, 5);
let value: toml::Value = toml::from_str(text).unwrap();
assert!(find_unknown_keys(&value).is_empty());
}
#[test]
fn pre_phase_08_section_picks_up_final_sweep_defaults() {
let text = "
[sweep]
enabled = true
trigger_min_items = 4
escalate_after = 2
";
let cfg = parse(text).unwrap();
assert!(cfg.sweep.final_sweep_enabled);
assert_eq!(cfg.sweep.final_sweep_max_iterations, 3);
}
#[test]
fn final_sweep_max_iterations_zero_is_rejected() {
let text = "[sweep]\nfinal_sweep_max_iterations = 0\n";
let err = parse(text).unwrap_err();
let msg = format!("{:#}", err);
assert!(msg.contains("final_sweep_max_iterations"), "msg: {msg}");
}
#[test]
fn partial_section_fills_missing_with_defaults() {
let text = "
[sweep]
trigger_min_items = 7
";
let cfg = parse(text).unwrap();
assert_eq!(cfg.sweep.trigger_min_items, 7);
assert!(cfg.sweep.enabled);
assert_eq!(cfg.sweep.trigger_max_items, 8);
assert_eq!(cfg.sweep.max_consecutive, 1);
assert_eq!(cfg.sweep.escalate_after, 3);
assert!(cfg.sweep.audit_enabled);
assert!(cfg.sweep.final_sweep_enabled);
assert_eq!(cfg.sweep.final_sweep_max_iterations, 3);
}
#[test]
fn unknown_subkey_is_flagged() {
let text = "[sweep]\naggressive = true\n";
let value: toml::Value = toml::from_str(text).unwrap();
let unknown = find_unknown_keys(&value);
assert!(unknown.contains(&"sweep.aggressive".to_string()));
}
#[test]
fn trigger_min_items_zero_is_rejected() {
let text = "[sweep]\ntrigger_min_items = 0\n";
let err = parse(text).unwrap_err();
let msg = format!("{:#}", err);
assert!(msg.contains("trigger_min_items"), "msg: {msg}");
}
#[test]
fn trigger_min_above_max_is_rejected() {
let text = "
[sweep]
trigger_min_items = 9
trigger_max_items = 4
";
let err = parse(text).unwrap_err();
let msg = format!("{:#}", err);
assert!(msg.contains("trigger_min_items"), "msg: {msg}");
assert!(msg.contains("trigger_max_items"), "msg: {msg}");
}
#[test]
fn max_consecutive_zero_is_rejected() {
let text = "[sweep]\nmax_consecutive = 0\n";
let err = parse(text).unwrap_err();
let msg = format!("{:#}", err);
assert!(msg.contains("max_consecutive"), "msg: {msg}");
}
#[test]
fn escalate_after_zero_is_rejected() {
let text = "[sweep]\nescalate_after = 0\n";
let err = parse(text).unwrap_err();
let msg = format!("{:#}", err);
assert!(msg.contains("escalate_after"), "msg: {msg}");
}
#[test]
fn missing_sweep_section_uses_documented_defaults() {
let text = "
[git]
branch_prefix = \"work/\"
[audit]
enabled = true
";
let cfg = parse(text).unwrap();
assert_eq!(cfg.sweep, SweepConfig::default());
}
}