1mod cli;
9mod load;
10
11pub use cli::CliOverrides;
12pub use load::{load, load_with};
13
14use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
17pub struct Settings {
18 #[serde(default)]
19 pub model: ModelSettings,
20 #[serde(default)]
21 pub anthropic: AnthropicSettings,
22 #[serde(default)]
23 pub ui: UiSettings,
24 #[serde(default)]
25 pub session: SessionSettings,
26 #[serde(default)]
27 pub logging: LoggingSettings,
28}
29
30impl Settings {
31 pub fn load(cli: &CliOverrides) -> crate::Result<Self> {
32 load::load(cli)
33 }
34
35 pub fn load_with<F>(
36 agent_dir: &std::path::Path,
37 cli: &CliOverrides,
38 env_lookup: F,
39 ) -> crate::Result<Self>
40 where
41 F: Fn(&str) -> Option<String>,
42 {
43 load::load_with(agent_dir, cli, env_lookup)
44 }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum LlmProviderKind {
51 Anthropic,
54 ClaudeCode,
58 CodexCli,
62 Openai,
65 Gemini,
68 GeminiCli,
72}
73
74impl LlmProviderKind {
75 pub fn parse(s: &str) -> Result<Self, String> {
77 match s.trim().to_ascii_lowercase().as_str() {
78 "anthropic" => Ok(Self::Anthropic),
79 "claude-code" | "claude_code" => Ok(Self::ClaudeCode),
80 "codex-cli" | "codex_cli" => Ok(Self::CodexCli),
81 "openai" => Ok(Self::Openai),
82 "gemini" => Ok(Self::Gemini),
83 "gemini-cli" | "gemini_cli" => Ok(Self::GeminiCli),
84 other => Err(format!(
85 "unknown LLM provider {other:?}; expected one of: anthropic, claude-code, codex-cli, openai, gemini, gemini-cli"
86 )),
87 }
88 }
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
92pub struct ModelSettings {
93 pub provider: String,
99 pub name: String,
100 pub max_tokens: u32,
101}
102
103impl Default for ModelSettings {
104 fn default() -> Self {
105 Self {
106 provider: "anthropic".to_string(),
107 name: "claude-sonnet-4-6".to_string(),
108 max_tokens: 8192,
109 }
110 }
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
114pub struct AnthropicSettings {
115 pub base_url: String,
121}
122
123impl Default for AnthropicSettings {
124 fn default() -> Self {
125 Self {
126 base_url: "https://api.anthropic.com".to_string(),
127 }
128 }
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
132pub struct UiSettings {
133 pub theme: String,
134 pub streaming_throttle_ms: u32,
135 pub footer_show_cost: bool,
136}
137
138impl Default for UiSettings {
139 fn default() -> Self {
140 Self {
141 theme: "dark".to_string(),
142 streaming_throttle_ms: 50,
143 footer_show_cost: true,
144 }
145 }
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
149pub struct SessionSettings {
150 pub autosave: bool,
151 pub compact_at_context_pct: f32,
155 pub max_context_tokens: usize,
160 pub keep_turns: usize,
162}
163
164impl Default for SessionSettings {
165 fn default() -> Self {
166 Self {
167 autosave: true,
168 compact_at_context_pct: 0.85,
169 max_context_tokens: 1_000_000,
170 keep_turns: 3,
171 }
172 }
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
176pub struct LoggingSettings {
177 pub level: String,
178 pub file: String,
179}
180
181impl Default for LoggingSettings {
182 fn default() -> Self {
183 Self {
184 level: "info".to_string(),
185 file: "~/.capo/agent/capo.log".to_string(),
186 }
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 #[test]
195 fn settings_default_matches_documented_baseline() {
196 let s = Settings::default();
197 assert_eq!(s.model.provider, "anthropic");
198 assert_eq!(s.model.name, "claude-sonnet-4-6");
199 assert_eq!(s.model.max_tokens, 8192);
200 assert_eq!(s.anthropic.base_url, "https://api.anthropic.com");
201 assert_eq!(s.ui.theme, "dark");
202 assert_eq!(s.ui.streaming_throttle_ms, 50);
203 assert!(s.ui.footer_show_cost);
204 assert!(s.session.autosave);
205 assert!((s.session.compact_at_context_pct - 0.85).abs() < f32::EPSILON);
206 assert_eq!(s.session.max_context_tokens, 1_000_000);
207 assert_eq!(s.session.keep_turns, 3);
208 assert_eq!(s.logging.level, "info");
209 assert_eq!(s.logging.file, "~/.capo/agent/capo.log");
210 }
211
212 #[test]
213 fn settings_serde_round_trips() {
214 let original = Settings::default();
215 let json = match serde_json::to_string_pretty(&original) {
216 Ok(json) => json,
217 Err(err) => panic!("serialize failed: {err}"),
218 };
219 let back: Settings = match serde_json::from_str(&json) {
220 Ok(settings) => settings,
221 Err(err) => panic!("deserialize failed: {err}"),
222 };
223 assert_eq!(original, back);
224 }
225
226 #[test]
227 fn settings_loads_partial_json_with_defaults_for_missing_sections() {
228 let json = r#"{ "model": { "provider": "anthropic", "name": "x", "max_tokens": 4096 } }"#;
229 let s: Settings = match serde_json::from_str(json) {
230 Ok(settings) => settings,
231 Err(err) => panic!("deserialize failed: {err}"),
232 };
233 assert_eq!(s.model.name, "x");
234 assert_eq!(s.ui.theme, "dark");
236 assert!((s.session.compact_at_context_pct - 0.85).abs() < f32::EPSILON);
237 }
238
239 fn parse_ok(input: &str) -> LlmProviderKind {
240 match LlmProviderKind::parse(input) {
241 Ok(kind) => kind,
242 Err(err) => panic!("parse failed for {input}: {err}"),
243 }
244 }
245
246 fn parse_err(input: &str) -> String {
247 match LlmProviderKind::parse(input) {
248 Ok(kind) => panic!("parse should have failed for {input}: {kind:?}"),
249 Err(err) => err,
250 }
251 }
252
253 #[test]
254 fn llm_provider_kind_parses_all_three() {
255 assert_eq!(parse_ok("anthropic"), LlmProviderKind::Anthropic);
256 assert_eq!(parse_ok("claude-code"), LlmProviderKind::ClaudeCode);
257 assert_eq!(parse_ok("claude_code"), LlmProviderKind::ClaudeCode);
258 assert_eq!(parse_ok("codex-cli"), LlmProviderKind::CodexCli);
259 assert_eq!(parse_ok("CODEX-CLI"), LlmProviderKind::CodexCli);
260 }
261
262 #[test]
263 fn llm_provider_kind_rejects_unknown() {
264 let err = parse_err("gpt-4");
265 assert!(err.contains("unknown LLM provider"));
266 assert!(err.contains("gpt-4"));
267 assert!(err.contains("anthropic"));
268 }
269
270 #[test]
271 fn parse_recognizes_v0_5_providers() {
272 assert!(matches!(parse_ok("openai"), LlmProviderKind::Openai));
273 assert!(matches!(parse_ok("gemini"), LlmProviderKind::Gemini));
274 assert!(matches!(parse_ok("gemini-cli"), LlmProviderKind::GeminiCli));
275 assert!(matches!(parse_ok("gemini_cli"), LlmProviderKind::GeminiCli));
276 }
277
278 #[test]
279 fn parse_unknown_provider_lists_all_six_supported_names() {
280 let err = parse_err("nope");
281 for name in [
282 "anthropic",
283 "claude-code",
284 "codex-cli",
285 "openai",
286 "gemini",
287 "gemini-cli",
288 ] {
289 assert!(err.contains(name), "{name} missing from: {err}");
290 }
291 }
292}