mod cli;
mod load;
pub use cli::CliOverrides;
pub use load::{load, load_with};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct Settings {
#[serde(default)]
pub model: ModelSettings,
#[serde(default)]
pub anthropic: AnthropicSettings,
#[serde(default)]
pub ui: UiSettings,
#[serde(default)]
pub session: SessionSettings,
#[serde(default)]
pub logging: LoggingSettings,
}
impl Settings {
pub fn load(cli: &CliOverrides) -> crate::Result<Self> {
load::load(cli)
}
pub fn load_with<F>(
agent_dir: &std::path::Path,
cli: &CliOverrides,
env_lookup: F,
) -> crate::Result<Self>
where
F: Fn(&str) -> Option<String>,
{
load::load_with(agent_dir, cli, env_lookup)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LlmProviderKind {
Anthropic,
ClaudeCode,
CodexCli,
Openai,
Gemini,
GeminiCli,
}
impl LlmProviderKind {
pub fn parse(s: &str) -> Result<Self, String> {
match s.trim().to_ascii_lowercase().as_str() {
"anthropic" => Ok(Self::Anthropic),
"claude-code" | "claude_code" => Ok(Self::ClaudeCode),
"codex-cli" | "codex_cli" => Ok(Self::CodexCli),
"openai" => Ok(Self::Openai),
"gemini" => Ok(Self::Gemini),
"gemini-cli" | "gemini_cli" => Ok(Self::GeminiCli),
other => Err(format!(
"unknown LLM provider {other:?}; expected one of: anthropic, claude-code, codex-cli, openai, gemini, gemini-cli"
)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ModelSettings {
pub provider: String,
pub name: String,
pub max_tokens: u32,
}
impl Default for ModelSettings {
fn default() -> Self {
Self {
provider: "anthropic".to_string(),
name: "claude-sonnet-4-6".to_string(),
max_tokens: 8192,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AnthropicSettings {
pub base_url: String,
}
impl Default for AnthropicSettings {
fn default() -> Self {
Self {
base_url: "https://api.anthropic.com".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct UiSettings {
pub theme: String,
pub streaming_throttle_ms: u32,
pub footer_show_cost: bool,
}
impl Default for UiSettings {
fn default() -> Self {
Self {
theme: "dark".to_string(),
streaming_throttle_ms: 50,
footer_show_cost: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionSettings {
pub autosave: bool,
pub compact_at_context_pct: f32,
pub max_context_tokens: usize,
pub keep_turns: usize,
}
impl Default for SessionSettings {
fn default() -> Self {
Self {
autosave: true,
compact_at_context_pct: 0.85,
max_context_tokens: 1_000_000,
keep_turns: 3,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LoggingSettings {
pub level: String,
pub file: String,
}
impl Default for LoggingSettings {
fn default() -> Self {
Self {
level: "info".to_string(),
file: "~/.capo/agent/capo.log".to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_default_matches_documented_baseline() {
let s = Settings::default();
assert_eq!(s.model.provider, "anthropic");
assert_eq!(s.model.name, "claude-sonnet-4-6");
assert_eq!(s.model.max_tokens, 8192);
assert_eq!(s.anthropic.base_url, "https://api.anthropic.com");
assert_eq!(s.ui.theme, "dark");
assert_eq!(s.ui.streaming_throttle_ms, 50);
assert!(s.ui.footer_show_cost);
assert!(s.session.autosave);
assert!((s.session.compact_at_context_pct - 0.85).abs() < f32::EPSILON);
assert_eq!(s.session.max_context_tokens, 1_000_000);
assert_eq!(s.session.keep_turns, 3);
assert_eq!(s.logging.level, "info");
assert_eq!(s.logging.file, "~/.capo/agent/capo.log");
}
#[test]
fn settings_serde_round_trips() {
let original = Settings::default();
let json = match serde_json::to_string_pretty(&original) {
Ok(json) => json,
Err(err) => panic!("serialize failed: {err}"),
};
let back: Settings = match serde_json::from_str(&json) {
Ok(settings) => settings,
Err(err) => panic!("deserialize failed: {err}"),
};
assert_eq!(original, back);
}
#[test]
fn settings_loads_partial_json_with_defaults_for_missing_sections() {
let json = r#"{ "model": { "provider": "anthropic", "name": "x", "max_tokens": 4096 } }"#;
let s: Settings = match serde_json::from_str(json) {
Ok(settings) => settings,
Err(err) => panic!("deserialize failed: {err}"),
};
assert_eq!(s.model.name, "x");
assert_eq!(s.ui.theme, "dark");
assert!((s.session.compact_at_context_pct - 0.85).abs() < f32::EPSILON);
}
fn parse_ok(input: &str) -> LlmProviderKind {
match LlmProviderKind::parse(input) {
Ok(kind) => kind,
Err(err) => panic!("parse failed for {input}: {err}"),
}
}
fn parse_err(input: &str) -> String {
match LlmProviderKind::parse(input) {
Ok(kind) => panic!("parse should have failed for {input}: {kind:?}"),
Err(err) => err,
}
}
#[test]
fn llm_provider_kind_parses_all_three() {
assert_eq!(parse_ok("anthropic"), LlmProviderKind::Anthropic);
assert_eq!(parse_ok("claude-code"), LlmProviderKind::ClaudeCode);
assert_eq!(parse_ok("claude_code"), LlmProviderKind::ClaudeCode);
assert_eq!(parse_ok("codex-cli"), LlmProviderKind::CodexCli);
assert_eq!(parse_ok("CODEX-CLI"), LlmProviderKind::CodexCli);
}
#[test]
fn llm_provider_kind_rejects_unknown() {
let err = parse_err("gpt-4");
assert!(err.contains("unknown LLM provider"));
assert!(err.contains("gpt-4"));
assert!(err.contains("anthropic"));
}
#[test]
fn parse_recognizes_v0_5_providers() {
assert!(matches!(parse_ok("openai"), LlmProviderKind::Openai));
assert!(matches!(parse_ok("gemini"), LlmProviderKind::Gemini));
assert!(matches!(parse_ok("gemini-cli"), LlmProviderKind::GeminiCli));
assert!(matches!(parse_ok("gemini_cli"), LlmProviderKind::GeminiCli));
}
#[test]
fn parse_unknown_provider_lists_all_six_supported_names() {
let err = parse_err("nope");
for name in [
"anthropic",
"claude-code",
"codex-cli",
"openai",
"gemini",
"gemini-cli",
] {
assert!(err.contains(name), "{name} missing from: {err}");
}
}
}