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}
62
63impl LlmProviderKind {
64 pub fn parse(s: &str) -> Result<Self, String> {
66 match s.trim().to_ascii_lowercase().as_str() {
67 "anthropic" => Ok(Self::Anthropic),
68 "claude-code" | "claude_code" => Ok(Self::ClaudeCode),
69 "codex-cli" | "codex_cli" => Ok(Self::CodexCli),
70 other => Err(format!(
71 "unknown LLM provider {other:?}; expected one of: anthropic, claude-code, codex-cli"
72 )),
73 }
74 }
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
78pub struct ModelSettings {
79 pub provider: String,
84 pub name: String,
85 pub max_tokens: u32,
86}
87
88impl Default for ModelSettings {
89 fn default() -> Self {
90 Self {
91 provider: "anthropic".to_string(),
92 name: "claude-sonnet-4-6".to_string(),
93 max_tokens: 8192,
94 }
95 }
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
99pub struct AnthropicSettings {
100 pub base_url: String,
106}
107
108impl Default for AnthropicSettings {
109 fn default() -> Self {
110 Self {
111 base_url: "https://api.anthropic.com".to_string(),
112 }
113 }
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
117pub struct UiSettings {
118 pub theme: String,
119 pub streaming_throttle_ms: u32,
120 pub footer_show_cost: bool,
121}
122
123impl Default for UiSettings {
124 fn default() -> Self {
125 Self {
126 theme: "dark".to_string(),
127 streaming_throttle_ms: 50,
128 footer_show_cost: true,
129 }
130 }
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
134pub struct SessionSettings {
135 pub autosave: bool,
136 pub compact_at_context_pct: f32,
140 pub max_context_tokens: usize,
145 pub keep_turns: usize,
147}
148
149impl Default for SessionSettings {
150 fn default() -> Self {
151 Self {
152 autosave: true,
153 compact_at_context_pct: 0.85,
154 max_context_tokens: 1_000_000,
155 keep_turns: 3,
156 }
157 }
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
161pub struct LoggingSettings {
162 pub level: String,
163 pub file: String,
164}
165
166impl Default for LoggingSettings {
167 fn default() -> Self {
168 Self {
169 level: "info".to_string(),
170 file: "~/.capo/agent/capo.log".to_string(),
171 }
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn settings_default_matches_documented_baseline() {
181 let s = Settings::default();
182 assert_eq!(s.model.provider, "anthropic");
183 assert_eq!(s.model.name, "claude-sonnet-4-6");
184 assert_eq!(s.model.max_tokens, 8192);
185 assert_eq!(s.anthropic.base_url, "https://api.anthropic.com");
186 assert_eq!(s.ui.theme, "dark");
187 assert_eq!(s.ui.streaming_throttle_ms, 50);
188 assert!(s.ui.footer_show_cost);
189 assert!(s.session.autosave);
190 assert!((s.session.compact_at_context_pct - 0.85).abs() < f32::EPSILON);
191 assert_eq!(s.session.max_context_tokens, 1_000_000);
192 assert_eq!(s.session.keep_turns, 3);
193 assert_eq!(s.logging.level, "info");
194 assert_eq!(s.logging.file, "~/.capo/agent/capo.log");
195 }
196
197 #[test]
198 fn settings_serde_round_trips() {
199 let original = Settings::default();
200 let json = match serde_json::to_string_pretty(&original) {
201 Ok(json) => json,
202 Err(err) => panic!("serialize failed: {err}"),
203 };
204 let back: Settings = match serde_json::from_str(&json) {
205 Ok(settings) => settings,
206 Err(err) => panic!("deserialize failed: {err}"),
207 };
208 assert_eq!(original, back);
209 }
210
211 #[test]
212 fn settings_loads_partial_json_with_defaults_for_missing_sections() {
213 let json = r#"{ "model": { "provider": "anthropic", "name": "x", "max_tokens": 4096 } }"#;
214 let s: Settings = match serde_json::from_str(json) {
215 Ok(settings) => settings,
216 Err(err) => panic!("deserialize failed: {err}"),
217 };
218 assert_eq!(s.model.name, "x");
219 assert_eq!(s.ui.theme, "dark");
221 assert!((s.session.compact_at_context_pct - 0.85).abs() < f32::EPSILON);
222 }
223
224 fn parse_ok(input: &str) -> LlmProviderKind {
225 match LlmProviderKind::parse(input) {
226 Ok(kind) => kind,
227 Err(err) => panic!("parse failed for {input}: {err}"),
228 }
229 }
230
231 fn parse_err(input: &str) -> String {
232 match LlmProviderKind::parse(input) {
233 Ok(kind) => panic!("parse should have failed for {input}: {kind:?}"),
234 Err(err) => err,
235 }
236 }
237
238 #[test]
239 fn llm_provider_kind_parses_all_three() {
240 assert_eq!(parse_ok("anthropic"), LlmProviderKind::Anthropic);
241 assert_eq!(parse_ok("claude-code"), LlmProviderKind::ClaudeCode);
242 assert_eq!(parse_ok("claude_code"), LlmProviderKind::ClaudeCode);
243 assert_eq!(parse_ok("codex-cli"), LlmProviderKind::CodexCli);
244 assert_eq!(parse_ok("CODEX-CLI"), LlmProviderKind::CodexCli);
245 }
246
247 #[test]
248 fn llm_provider_kind_rejects_unknown() {
249 let err = parse_err("gpt-4");
250 assert!(err.contains("unknown LLM provider"));
251 assert!(err.contains("gpt-4"));
252 assert!(err.contains("anthropic"));
253 }
254}