1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Config {
9 #[serde(default)]
10 pub defaults: DefaultsConfig,
11
12 #[serde(default)]
13 pub providers: HashMap<String, ProviderConfig>,
14
15 #[serde(default)]
16 pub agent: AgentConfig,
17
18 #[serde(default)]
19 pub tools: ToolsConfig,
20
21 #[serde(default)]
22 pub ui: UiConfig,
23
24 #[serde(default)]
25 pub session: SessionConfig,
26}
27
28#[derive(Debug, Clone)]
30pub struct ResolvedConfig {
31 pub config: Config,
32 pub cwd: PathBuf,
33 pub project_root: Option<PathBuf>,
34 pub user_config_path: PathBuf,
35 pub project_config_path: Option<PathBuf>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct DefaultsConfig {
40 pub model: String,
41 pub provider: String,
42 pub temperature: f32,
43 pub max_tokens: u32,
44 pub language: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct ProviderConfig {
49 #[serde(default)]
51 pub api_key: Option<String>,
52 #[serde(default)]
53 pub api_key_env: Option<String>,
54 #[serde(default)]
55 pub base_url: Option<String>,
56 #[serde(default)]
57 pub api_style: Option<String>,
58 #[serde(default)]
60 pub tool_format: Option<String>,
61}
62
63impl ProviderConfig {
64 pub fn resolve_api_key(&self) -> Result<String, crate::ConfigError> {
66 if let Some(key) = self
67 .api_key
68 .as_deref()
69 .map(str::trim)
70 .filter(|k| !k.is_empty())
71 {
72 return Ok(key.to_string());
73 }
74 let env_name = self
75 .api_key_env
76 .as_deref()
77 .filter(|name| !name.is_empty())
78 .unwrap_or("OPENAI_API_KEY");
79 std::env::var(env_name).map_err(|_| crate::ConfigError::MissingApiKey {
80 env: env_name.to_string(),
81 })
82 }
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct AgentConfig {
87 pub max_turns: u32,
88 pub max_tool_rounds_per_turn: u32,
89 pub context_window_tokens: u32,
90 pub compaction_threshold: f32,
92 pub compaction_keep_messages: u32,
94 pub compaction_summary_max_tokens: u32,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct ToolsConfig {
100 pub shell: ShellToolConfig,
101 pub write: EnabledToolConfig,
102 pub web_search: EnabledToolConfig,
103 #[serde(default)]
104 pub grep: GrepToolConfig,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct GrepToolConfig {
109 pub max_matches: usize,
110 pub max_files: usize,
111}
112
113#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
114#[serde(rename_all = "lowercase")]
115pub enum ShellSandboxMode {
116 #[default]
117 Off,
118 Restricted,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct ShellToolConfig {
123 pub enabled: bool,
124 pub timeout_secs: u64,
125 #[serde(default)]
126 pub sandbox: ShellSandboxMode,
127 #[serde(default)]
128 pub allowlist: Vec<String>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct EnabledToolConfig {
133 pub enabled: bool,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct UiConfig {
138 pub theme: UiTheme,
139 pub show_tool_output: bool,
140 pub confirm_destructive: bool,
141}
142
143#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
144#[serde(rename_all = "lowercase")]
145pub enum UiTheme {
146 #[default]
147 Auto,
148 Dark,
149 Light,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct SessionConfig {
154 pub storage: SessionStorage,
155 pub dir: String,
156}
157
158#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
159#[serde(rename_all = "lowercase")]
160pub enum SessionStorage {
161 #[default]
162 Sqlite,
163 Json,
164}
165
166impl Default for Config {
167 fn default() -> Self {
168 Self {
169 defaults: DefaultsConfig::default(),
170 providers: default_providers(),
171 agent: AgentConfig::default(),
172 tools: ToolsConfig::default(),
173 ui: UiConfig::default(),
174 session: SessionConfig::default(),
175 }
176 }
177}
178
179impl Default for DefaultsConfig {
180 fn default() -> Self {
181 Self {
182 model: "gpt-4o".to_string(),
183 provider: "openai".to_string(),
184 temperature: 0.2,
185 max_tokens: 8192,
186 language: "zh-CN".to_string(),
187 }
188 }
189}
190
191impl Default for AgentConfig {
192 fn default() -> Self {
193 Self {
194 max_turns: 50,
195 max_tool_rounds_per_turn: 25,
196 context_window_tokens: 128_000,
197 compaction_threshold: 0.85,
198 compaction_keep_messages: 12,
199 compaction_summary_max_tokens: 2048,
200 }
201 }
202}
203
204impl Default for ToolsConfig {
205 fn default() -> Self {
206 Self {
207 shell: ShellToolConfig {
208 enabled: true,
209 timeout_secs: 120,
210 sandbox: ShellSandboxMode::Off,
211 allowlist: Vec::new(),
212 },
213 write: EnabledToolConfig { enabled: true },
214 web_search: EnabledToolConfig { enabled: false },
215 grep: GrepToolConfig::default(),
216 }
217 }
218}
219
220impl Default for GrepToolConfig {
221 fn default() -> Self {
222 Self {
223 max_matches: 200,
224 max_files: 5_000,
225 }
226 }
227}
228
229impl Default for UiConfig {
230 fn default() -> Self {
231 Self {
232 theme: UiTheme::Auto,
233 show_tool_output: true,
234 confirm_destructive: true,
235 }
236 }
237}
238
239impl Default for SessionConfig {
240 fn default() -> Self {
241 Self {
242 storage: SessionStorage::Sqlite,
243 dir: "~/.local/share/codei/sessions".to_string(),
244 }
245 }
246}
247
248fn default_providers() -> HashMap<String, ProviderConfig> {
249 HashMap::from([
250 (
251 "openai".to_string(),
252 ProviderConfig {
253 api_key: None,
254 api_key_env: Some("OPENAI_API_KEY".to_string()),
255 base_url: Some("https://api.openai.com/v1".to_string()),
256 api_style: Some("openai".to_string()),
257 tool_format: Some("tools".to_string()),
258 },
259 ),
260 (
261 "anthropic".to_string(),
262 ProviderConfig {
263 api_key: None,
264 api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
265 base_url: None,
266 api_style: Some("anthropic".to_string()),
267 tool_format: None,
268 },
269 ),
270 (
271 "custom".to_string(),
272 ProviderConfig {
273 api_key: None,
274 api_key_env: Some("CUSTOM_API_KEY".to_string()),
275 base_url: Some("http://localhost:8080/v1".to_string()),
276 api_style: Some("openai".to_string()),
277 tool_format: Some("tools".to_string()),
278 },
279 ),
280 ])
281}
282
283impl ResolvedConfig {
284 pub fn validate(&self) -> Result<(), crate::ConfigError> {
285 let lang = &self.config.defaults.language;
286 if lang != "zh-CN" && lang != "en-US" {
287 return Err(crate::ConfigError::InvalidLanguage {
288 language: lang.clone(),
289 });
290 }
291 let threshold = self.config.agent.compaction_threshold;
292 if !(0.0..=1.0).contains(&threshold) {
293 return Err(crate::ConfigError::InvalidCompactionThreshold { threshold });
294 }
295 Ok(())
296 }
297}
298
299#[cfg(test)]
300mod provider_tests {
301 use super::ProviderConfig;
302
303 #[test]
304 fn resolve_api_key_prefers_direct_config() {
305 let cfg = ProviderConfig {
306 api_key: Some("sk-direct".into()),
307 api_key_env: Some("OPENAI_API_KEY".into()),
308 base_url: None,
309 api_style: None,
310 tool_format: None,
311 };
312 assert_eq!(cfg.resolve_api_key().unwrap(), "sk-direct");
313 }
314
315 #[test]
316 fn blank_api_key_falls_back_to_env() {
317 let cfg = ProviderConfig {
318 api_key: Some(" ".into()),
319 api_key_env: Some("NONEXISTENT_CODEI_API_KEY".into()),
320 base_url: None,
321 api_style: None,
322 tool_format: None,
323 };
324 let err = cfg.resolve_api_key().unwrap_err();
325 assert!(matches!(
326 err,
327 crate::ConfigError::MissingApiKey {
328 env: ref name
329 } if name == "NONEXISTENT_CODEI_API_KEY"
330 ));
331 }
332}