1#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
2
3mod cli;
11mod load;
12
13pub use cli::CliOverrides;
14pub use load::{load, load_with};
15
16use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
19pub struct Settings {
20 #[serde(default)]
21 pub model: ModelSettings,
22 #[serde(default)]
23 pub anthropic: AnthropicSettings,
24 #[serde(default)]
25 pub ui: UiSettings,
26 #[serde(default)]
27 pub session: SessionSettings,
28 #[serde(default)]
29 pub logging: LoggingSettings,
30 #[serde(default)]
33 pub permissions: PermissionsSettings,
34}
35
36impl Settings {
37 pub fn load(cli: &CliOverrides) -> crate::Result<Self> {
38 load::load(cli)
39 }
40
41 pub fn load_with<F>(
42 agent_dir: &std::path::Path,
43 cli: &CliOverrides,
44 env_lookup: F,
45 ) -> crate::Result<Self>
46 where
47 F: Fn(&str) -> Option<String>,
48 {
49 load::load_with(agent_dir, cli, env_lookup)
50 }
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum LlmProviderKind {
57 Anthropic,
60 ClaudeCode,
64 CodexCli,
68 Openai,
71 Gemini,
74 GeminiCli,
78}
79
80impl LlmProviderKind {
81 pub fn parse(s: &str) -> Result<Self, String> {
83 match s.trim().to_ascii_lowercase().as_str() {
84 "anthropic" => Ok(Self::Anthropic),
85 "claude-code" | "claude_code" => Ok(Self::ClaudeCode),
86 "codex-cli" | "codex_cli" => Ok(Self::CodexCli),
87 "openai" => Ok(Self::Openai),
88 "gemini" => Ok(Self::Gemini),
89 "gemini-cli" | "gemini_cli" => Ok(Self::GeminiCli),
90 other => Err(format!(
91 "unknown LLM provider {other:?}; expected one of: anthropic, claude-code, codex-cli, openai, gemini, gemini-cli"
92 )),
93 }
94 }
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
98pub struct ModelSettings {
99 pub provider: String,
105 pub name: String,
106 pub max_tokens: u32,
107}
108
109impl Default for ModelSettings {
110 fn default() -> Self {
111 Self {
112 provider: "anthropic".to_string(),
113 name: "claude-sonnet-4-6".to_string(),
114 max_tokens: 8192,
115 }
116 }
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
120pub struct AnthropicSettings {
121 pub base_url: String,
127 #[serde(default)]
140 pub thinking_budget_tokens: Option<u32>,
141}
142
143impl Default for AnthropicSettings {
144 fn default() -> Self {
145 Self {
146 base_url: "https://api.anthropic.com".to_string(),
147 thinking_budget_tokens: None,
148 }
149 }
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
153pub struct UiSettings {
154 pub theme: String,
155 pub streaming_throttle_ms: u32,
156 pub footer_show_cost: bool,
157 #[serde(default = "default_hide_thinking_blocks")]
160 pub hide_thinking_blocks: bool,
161 #[serde(default = "default_collapse_tool_output_by_default")]
164 pub collapse_tool_output_by_default: bool,
165 #[serde(default = "default_collapse_tool_args_max_lines")]
168 pub collapse_tool_args_max_lines: u32,
169 #[serde(default = "default_collapse_history_after")]
172 pub collapse_history_after: u32,
173}
174
175fn default_hide_thinking_blocks() -> bool {
176 true
177}
178
179fn default_collapse_tool_output_by_default() -> bool {
180 true
181}
182
183fn default_collapse_tool_args_max_lines() -> u32 {
184 2
185}
186
187fn default_collapse_history_after() -> u32 {
188 0
189}
190
191impl Default for UiSettings {
192 fn default() -> Self {
193 Self {
194 theme: "dark".to_string(),
195 streaming_throttle_ms: 50,
196 footer_show_cost: true,
197 hide_thinking_blocks: default_hide_thinking_blocks(),
198 collapse_tool_output_by_default: default_collapse_tool_output_by_default(),
199 collapse_tool_args_max_lines: default_collapse_tool_args_max_lines(),
200 collapse_history_after: default_collapse_history_after(),
201 }
202 }
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
206pub struct SessionSettings {
207 pub autosave: bool,
208 pub compact_at_context_pct: f32,
212 pub max_context_tokens: usize,
217 pub keep_turns: usize,
219}
220
221impl Default for SessionSettings {
222 fn default() -> Self {
223 Self {
224 autosave: true,
225 compact_at_context_pct: 0.85,
226 max_context_tokens: 1_000_000,
227 keep_turns: 3,
228 }
229 }
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
233pub struct PermissionsSettings {
234 #[serde(default = "default_permission_mode_str")]
246 pub default_mode: String,
247}
248
249impl Default for PermissionsSettings {
250 fn default() -> Self {
251 Self {
252 default_mode: default_permission_mode_str(),
253 }
254 }
255}
256
257fn default_permission_mode_str() -> String {
258 "prompt".to_string()
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
262pub struct LoggingSettings {
263 pub level: String,
264 pub file: String,
265}
266
267impl Default for LoggingSettings {
268 fn default() -> Self {
269 Self {
270 level: "info".to_string(),
271 file: "~/.capo/agent/capo.log".to_string(),
272 }
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 #[test]
281 fn settings_default_matches_documented_baseline() {
282 let s = Settings::default();
283 assert_eq!(s.model.provider, "anthropic");
284 assert_eq!(s.model.name, "claude-sonnet-4-6");
285 assert_eq!(s.model.max_tokens, 8192);
286 assert_eq!(s.anthropic.base_url, "https://api.anthropic.com");
287 assert_eq!(s.anthropic.thinking_budget_tokens, None);
288 assert_eq!(s.ui.theme, "dark");
289 assert_eq!(s.ui.streaming_throttle_ms, 50);
290 assert!(s.ui.footer_show_cost);
291 assert!(s.session.autosave);
292 assert!((s.session.compact_at_context_pct - 0.85).abs() < f32::EPSILON);
293 assert_eq!(s.session.max_context_tokens, 1_000_000);
294 assert_eq!(s.session.keep_turns, 3);
295 assert_eq!(s.logging.level, "info");
296 assert_eq!(s.logging.file, "~/.capo/agent/capo.log");
297 }
298
299 #[test]
300 fn settings_serde_round_trips() {
301 let original = Settings::default();
302 let json = match serde_json::to_string_pretty(&original) {
303 Ok(json) => json,
304 Err(err) => panic!("serialize failed: {err}"),
305 };
306 let back: Settings = match serde_json::from_str(&json) {
307 Ok(settings) => settings,
308 Err(err) => panic!("deserialize failed: {err}"),
309 };
310 assert_eq!(original, back);
311 }
312
313 #[test]
314 fn settings_loads_partial_json_with_defaults_for_missing_sections() {
315 let json = r#"{ "model": { "provider": "anthropic", "name": "x", "max_tokens": 4096 } }"#;
316 let s: Settings = match serde_json::from_str(json) {
317 Ok(settings) => settings,
318 Err(err) => panic!("deserialize failed: {err}"),
319 };
320 assert_eq!(s.model.name, "x");
321 assert_eq!(s.ui.theme, "dark");
323 assert!((s.session.compact_at_context_pct - 0.85).abs() < f32::EPSILON);
324 }
325
326 #[test]
327 fn settings_v0_10_permissions_default_mode_defaults_to_prompt() {
328 let s = Settings::default();
329 assert_eq!(s.permissions.default_mode, "prompt");
330 }
331
332 #[test]
333 fn settings_v0_10_permissions_section_round_trips() {
334 let mut s = Settings::default();
335 s.permissions.default_mode = "bypass".into();
336 let toml_str = toml::to_string(&s).expect("serialize");
337 assert!(toml_str.contains("default_mode = \"bypass\""));
338 let back: Settings = toml::from_str(&toml_str).expect("deserialize");
339 assert_eq!(back.permissions.default_mode, "bypass");
340 }
341
342 #[test]
343 fn settings_v0_10_missing_permissions_section_uses_default() {
344 let toml_str = r#"
346[model]
347provider = "anthropic"
348name = "claude-opus-4-7"
349max_tokens = 8192
350"#;
351 let s: Settings = toml::from_str(toml_str).expect("load partial");
352 assert_eq!(s.permissions.default_mode, "prompt");
353 }
354
355 #[test]
356 fn ui_settings_v0_9_defaults() {
357 let s = UiSettings::default();
358 assert!(
359 s.hide_thinking_blocks,
360 "hide_thinking_blocks should default to true"
361 );
362 assert!(
363 s.collapse_tool_output_by_default,
364 "collapse_tool_output_by_default should default to true"
365 );
366 assert_eq!(s.collapse_tool_args_max_lines, 2);
367 assert_eq!(s.collapse_history_after, 0);
368 }
369
370 #[test]
371 fn ui_settings_v0_9_round_trips_through_toml() {
372 let mut s = Settings::default();
373 s.ui.hide_thinking_blocks = false;
374 s.ui.collapse_tool_output_by_default = false;
375 s.ui.collapse_tool_args_max_lines = 5;
376 s.ui.collapse_history_after = 10;
377 let toml_str = toml::to_string(&s).expect("serialize");
378 assert!(toml_str.contains("hide_thinking_blocks = false"));
380 assert!(toml_str.contains("collapse_tool_output_by_default = false"));
381 assert!(toml_str.contains("collapse_tool_args_max_lines = 5"));
382 assert!(toml_str.contains("collapse_history_after = 10"));
383 let back: Settings = toml::from_str(&toml_str).expect("deserialize");
384 assert!(!back.ui.hide_thinking_blocks);
385 assert!(!back.ui.collapse_tool_output_by_default);
386 assert_eq!(back.ui.collapse_tool_args_max_lines, 5);
387 assert_eq!(back.ui.collapse_history_after, 10);
388 }
389
390 fn parse_ok(input: &str) -> LlmProviderKind {
391 match LlmProviderKind::parse(input) {
392 Ok(kind) => kind,
393 Err(err) => panic!("parse failed for {input}: {err}"),
394 }
395 }
396
397 fn parse_err(input: &str) -> String {
398 match LlmProviderKind::parse(input) {
399 Ok(kind) => panic!("parse should have failed for {input}: {kind:?}"),
400 Err(err) => err,
401 }
402 }
403
404 #[test]
405 fn llm_provider_kind_parses_all_three() {
406 assert_eq!(parse_ok("anthropic"), LlmProviderKind::Anthropic);
407 assert_eq!(parse_ok("claude-code"), LlmProviderKind::ClaudeCode);
408 assert_eq!(parse_ok("claude_code"), LlmProviderKind::ClaudeCode);
409 assert_eq!(parse_ok("codex-cli"), LlmProviderKind::CodexCli);
410 assert_eq!(parse_ok("CODEX-CLI"), LlmProviderKind::CodexCli);
411 }
412
413 #[test]
414 fn llm_provider_kind_rejects_unknown() {
415 let err = parse_err("gpt-4");
416 assert!(err.contains("unknown LLM provider"));
417 assert!(err.contains("gpt-4"));
418 assert!(err.contains("anthropic"));
419 }
420
421 #[test]
422 fn parse_recognizes_v0_5_providers() {
423 assert!(matches!(parse_ok("openai"), LlmProviderKind::Openai));
424 assert!(matches!(parse_ok("gemini"), LlmProviderKind::Gemini));
425 assert!(matches!(parse_ok("gemini-cli"), LlmProviderKind::GeminiCli));
426 assert!(matches!(parse_ok("gemini_cli"), LlmProviderKind::GeminiCli));
427 }
428
429 #[test]
430 fn parse_unknown_provider_lists_all_six_supported_names() {
431 let err = parse_err("nope");
432 for name in [
433 "anthropic",
434 "claude-code",
435 "codex-cli",
436 "openai",
437 "gemini",
438 "gemini-cli",
439 ] {
440 assert!(err.contains(name), "{name} missing from: {err}");
441 }
442 }
443}