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