#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
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,
#[serde(default)]
pub permissions: PermissionsSettings,
}
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,
#[serde(default = "default_hide_thinking_blocks")]
pub hide_thinking_blocks: bool,
#[serde(default = "default_collapse_tool_output_by_default")]
pub collapse_tool_output_by_default: bool,
#[serde(default = "default_collapse_tool_args_max_lines")]
pub collapse_tool_args_max_lines: u32,
#[serde(default = "default_collapse_history_after")]
pub collapse_history_after: u32,
}
fn default_hide_thinking_blocks() -> bool {
true
}
fn default_collapse_tool_output_by_default() -> bool {
true
}
fn default_collapse_tool_args_max_lines() -> u32 {
2
}
fn default_collapse_history_after() -> u32 {
0
}
impl Default for UiSettings {
fn default() -> Self {
Self {
theme: "dark".to_string(),
streaming_throttle_ms: 50,
footer_show_cost: true,
hide_thinking_blocks: default_hide_thinking_blocks(),
collapse_tool_output_by_default: default_collapse_tool_output_by_default(),
collapse_tool_args_max_lines: default_collapse_tool_args_max_lines(),
collapse_history_after: default_collapse_history_after(),
}
}
}
#[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 PermissionsSettings {
#[serde(default = "default_permission_mode_str")]
pub default_mode: String,
}
impl Default for PermissionsSettings {
fn default() -> Self {
Self {
default_mode: default_permission_mode_str(),
}
}
}
fn default_permission_mode_str() -> String {
"prompt".to_string()
}
#[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);
}
#[test]
fn settings_v0_10_permissions_default_mode_defaults_to_prompt() {
let s = Settings::default();
assert_eq!(s.permissions.default_mode, "prompt");
}
#[test]
fn settings_v0_10_permissions_section_round_trips() {
let mut s = Settings::default();
s.permissions.default_mode = "bypass".into();
let toml_str = toml::to_string(&s).expect("serialize");
assert!(toml_str.contains("default_mode = \"bypass\""));
let back: Settings = toml::from_str(&toml_str).expect("deserialize");
assert_eq!(back.permissions.default_mode, "bypass");
}
#[test]
fn settings_v0_10_missing_permissions_section_uses_default() {
let toml_str = r#"
[model]
provider = "anthropic"
name = "claude-opus-4-7"
max_tokens = 8192
"#;
let s: Settings = toml::from_str(toml_str).expect("load partial");
assert_eq!(s.permissions.default_mode, "prompt");
}
#[test]
fn ui_settings_v0_9_defaults() {
let s = UiSettings::default();
assert!(
s.hide_thinking_blocks,
"hide_thinking_blocks should default to true"
);
assert!(
s.collapse_tool_output_by_default,
"collapse_tool_output_by_default should default to true"
);
assert_eq!(s.collapse_tool_args_max_lines, 2);
assert_eq!(s.collapse_history_after, 0);
}
#[test]
fn ui_settings_v0_9_round_trips_through_toml() {
let mut s = Settings::default();
s.ui.hide_thinking_blocks = false;
s.ui.collapse_tool_output_by_default = false;
s.ui.collapse_tool_args_max_lines = 5;
s.ui.collapse_history_after = 10;
let toml_str = toml::to_string(&s).expect("serialize");
assert!(toml_str.contains("hide_thinking_blocks = false"));
assert!(toml_str.contains("collapse_tool_output_by_default = false"));
assert!(toml_str.contains("collapse_tool_args_max_lines = 5"));
assert!(toml_str.contains("collapse_history_after = 10"));
let back: Settings = toml::from_str(&toml_str).expect("deserialize");
assert!(!back.ui.hide_thinking_blocks);
assert!(!back.ui.collapse_tool_output_by_default);
assert_eq!(back.ui.collapse_tool_args_max_lines, 5);
assert_eq!(back.ui.collapse_history_after, 10);
}
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}");
}
}
}