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}
31
32impl Settings {
33 pub fn load(cli: &CliOverrides) -> crate::Result<Self> {
34 load::load(cli)
35 }
36
37 pub fn load_with<F>(
38 agent_dir: &std::path::Path,
39 cli: &CliOverrides,
40 env_lookup: F,
41 ) -> crate::Result<Self>
42 where
43 F: Fn(&str) -> Option<String>,
44 {
45 load::load_with(agent_dir, cli, env_lookup)
46 }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum LlmProviderKind {
53 Anthropic,
56 ClaudeCode,
60 CodexCli,
64 Openai,
67 Gemini,
70 GeminiCli,
74}
75
76impl LlmProviderKind {
77 pub fn parse(s: &str) -> Result<Self, String> {
79 match s.trim().to_ascii_lowercase().as_str() {
80 "anthropic" => Ok(Self::Anthropic),
81 "claude-code" | "claude_code" => Ok(Self::ClaudeCode),
82 "codex-cli" | "codex_cli" => Ok(Self::CodexCli),
83 "openai" => Ok(Self::Openai),
84 "gemini" => Ok(Self::Gemini),
85 "gemini-cli" | "gemini_cli" => Ok(Self::GeminiCli),
86 other => Err(format!(
87 "unknown LLM provider {other:?}; expected one of: anthropic, claude-code, codex-cli, openai, gemini, gemini-cli"
88 )),
89 }
90 }
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
94pub struct ModelSettings {
95 pub provider: String,
101 pub name: String,
102 pub max_tokens: u32,
103}
104
105impl Default for ModelSettings {
106 fn default() -> Self {
107 Self {
108 provider: "anthropic".to_string(),
109 name: "claude-sonnet-4-6".to_string(),
110 max_tokens: 8192,
111 }
112 }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
116pub struct AnthropicSettings {
117 pub base_url: String,
123}
124
125impl Default for AnthropicSettings {
126 fn default() -> Self {
127 Self {
128 base_url: "https://api.anthropic.com".to_string(),
129 }
130 }
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
134pub struct UiSettings {
135 pub theme: String,
136 pub streaming_throttle_ms: u32,
137 pub footer_show_cost: bool,
138 #[serde(default = "default_hide_thinking_blocks")]
141 pub hide_thinking_blocks: bool,
142 #[serde(default = "default_collapse_tool_output_by_default")]
145 pub collapse_tool_output_by_default: bool,
146 #[serde(default = "default_collapse_tool_args_max_lines")]
149 pub collapse_tool_args_max_lines: u32,
150 #[serde(default = "default_collapse_history_after")]
153 pub collapse_history_after: u32,
154}
155
156fn default_hide_thinking_blocks() -> bool {
157 true
158}
159
160fn default_collapse_tool_output_by_default() -> bool {
161 true
162}
163
164fn default_collapse_tool_args_max_lines() -> u32 {
165 2
166}
167
168fn default_collapse_history_after() -> u32 {
169 0
170}
171
172impl Default for UiSettings {
173 fn default() -> Self {
174 Self {
175 theme: "dark".to_string(),
176 streaming_throttle_ms: 50,
177 footer_show_cost: true,
178 hide_thinking_blocks: default_hide_thinking_blocks(),
179 collapse_tool_output_by_default: default_collapse_tool_output_by_default(),
180 collapse_tool_args_max_lines: default_collapse_tool_args_max_lines(),
181 collapse_history_after: default_collapse_history_after(),
182 }
183 }
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
187pub struct SessionSettings {
188 pub autosave: bool,
189 pub compact_at_context_pct: f32,
193 pub max_context_tokens: usize,
198 pub keep_turns: usize,
200}
201
202impl Default for SessionSettings {
203 fn default() -> Self {
204 Self {
205 autosave: true,
206 compact_at_context_pct: 0.85,
207 max_context_tokens: 1_000_000,
208 keep_turns: 3,
209 }
210 }
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
214pub struct LoggingSettings {
215 pub level: String,
216 pub file: String,
217}
218
219impl Default for LoggingSettings {
220 fn default() -> Self {
221 Self {
222 level: "info".to_string(),
223 file: "~/.capo/agent/capo.log".to_string(),
224 }
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 #[test]
233 fn settings_default_matches_documented_baseline() {
234 let s = Settings::default();
235 assert_eq!(s.model.provider, "anthropic");
236 assert_eq!(s.model.name, "claude-sonnet-4-6");
237 assert_eq!(s.model.max_tokens, 8192);
238 assert_eq!(s.anthropic.base_url, "https://api.anthropic.com");
239 assert_eq!(s.ui.theme, "dark");
240 assert_eq!(s.ui.streaming_throttle_ms, 50);
241 assert!(s.ui.footer_show_cost);
242 assert!(s.session.autosave);
243 assert!((s.session.compact_at_context_pct - 0.85).abs() < f32::EPSILON);
244 assert_eq!(s.session.max_context_tokens, 1_000_000);
245 assert_eq!(s.session.keep_turns, 3);
246 assert_eq!(s.logging.level, "info");
247 assert_eq!(s.logging.file, "~/.capo/agent/capo.log");
248 }
249
250 #[test]
251 fn settings_serde_round_trips() {
252 let original = Settings::default();
253 let json = match serde_json::to_string_pretty(&original) {
254 Ok(json) => json,
255 Err(err) => panic!("serialize failed: {err}"),
256 };
257 let back: Settings = match serde_json::from_str(&json) {
258 Ok(settings) => settings,
259 Err(err) => panic!("deserialize failed: {err}"),
260 };
261 assert_eq!(original, back);
262 }
263
264 #[test]
265 fn settings_loads_partial_json_with_defaults_for_missing_sections() {
266 let json = r#"{ "model": { "provider": "anthropic", "name": "x", "max_tokens": 4096 } }"#;
267 let s: Settings = match serde_json::from_str(json) {
268 Ok(settings) => settings,
269 Err(err) => panic!("deserialize failed: {err}"),
270 };
271 assert_eq!(s.model.name, "x");
272 assert_eq!(s.ui.theme, "dark");
274 assert!((s.session.compact_at_context_pct - 0.85).abs() < f32::EPSILON);
275 }
276
277 #[test]
278 fn ui_settings_v0_9_defaults() {
279 let s = UiSettings::default();
280 assert!(
281 s.hide_thinking_blocks,
282 "hide_thinking_blocks should default to true"
283 );
284 assert!(
285 s.collapse_tool_output_by_default,
286 "collapse_tool_output_by_default should default to true"
287 );
288 assert_eq!(s.collapse_tool_args_max_lines, 2);
289 assert_eq!(s.collapse_history_after, 0);
290 }
291
292 #[test]
293 fn ui_settings_v0_9_round_trips_through_toml() {
294 let mut s = Settings::default();
295 s.ui.hide_thinking_blocks = false;
296 s.ui.collapse_tool_output_by_default = false;
297 s.ui.collapse_tool_args_max_lines = 5;
298 s.ui.collapse_history_after = 10;
299 let toml_str = toml::to_string(&s).expect("serialize");
300 assert!(toml_str.contains("hide_thinking_blocks = false"));
302 assert!(toml_str.contains("collapse_tool_output_by_default = false"));
303 assert!(toml_str.contains("collapse_tool_args_max_lines = 5"));
304 assert!(toml_str.contains("collapse_history_after = 10"));
305 let back: Settings = toml::from_str(&toml_str).expect("deserialize");
306 assert!(!back.ui.hide_thinking_blocks);
307 assert!(!back.ui.collapse_tool_output_by_default);
308 assert_eq!(back.ui.collapse_tool_args_max_lines, 5);
309 assert_eq!(back.ui.collapse_history_after, 10);
310 }
311
312 fn parse_ok(input: &str) -> LlmProviderKind {
313 match LlmProviderKind::parse(input) {
314 Ok(kind) => kind,
315 Err(err) => panic!("parse failed for {input}: {err}"),
316 }
317 }
318
319 fn parse_err(input: &str) -> String {
320 match LlmProviderKind::parse(input) {
321 Ok(kind) => panic!("parse should have failed for {input}: {kind:?}"),
322 Err(err) => err,
323 }
324 }
325
326 #[test]
327 fn llm_provider_kind_parses_all_three() {
328 assert_eq!(parse_ok("anthropic"), LlmProviderKind::Anthropic);
329 assert_eq!(parse_ok("claude-code"), LlmProviderKind::ClaudeCode);
330 assert_eq!(parse_ok("claude_code"), LlmProviderKind::ClaudeCode);
331 assert_eq!(parse_ok("codex-cli"), LlmProviderKind::CodexCli);
332 assert_eq!(parse_ok("CODEX-CLI"), LlmProviderKind::CodexCli);
333 }
334
335 #[test]
336 fn llm_provider_kind_rejects_unknown() {
337 let err = parse_err("gpt-4");
338 assert!(err.contains("unknown LLM provider"));
339 assert!(err.contains("gpt-4"));
340 assert!(err.contains("anthropic"));
341 }
342
343 #[test]
344 fn parse_recognizes_v0_5_providers() {
345 assert!(matches!(parse_ok("openai"), LlmProviderKind::Openai));
346 assert!(matches!(parse_ok("gemini"), LlmProviderKind::Gemini));
347 assert!(matches!(parse_ok("gemini-cli"), LlmProviderKind::GeminiCli));
348 assert!(matches!(parse_ok("gemini_cli"), LlmProviderKind::GeminiCli));
349 }
350
351 #[test]
352 fn parse_unknown_provider_lists_all_six_supported_names() {
353 let err = parse_err("nope");
354 for name in [
355 "anthropic",
356 "claude-code",
357 "codex-cli",
358 "openai",
359 "gemini",
360 "gemini-cli",
361 ] {
362 assert!(err.contains(name), "{name} missing from: {err}");
363 }
364 }
365}