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,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct ToolsConfig {
95 pub shell: ShellToolConfig,
96 pub write: EnabledToolConfig,
97 pub web_search: EnabledToolConfig,
98 #[serde(default)]
99 pub grep: GrepToolConfig,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct GrepToolConfig {
104 pub max_matches: usize,
105 pub max_files: usize,
106}
107
108#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
109#[serde(rename_all = "lowercase")]
110pub enum ShellSandboxMode {
111 #[default]
112 Off,
113 Restricted,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct ShellToolConfig {
118 pub enabled: bool,
119 pub timeout_secs: u64,
120 #[serde(default)]
121 pub sandbox: ShellSandboxMode,
122 #[serde(default)]
123 pub allowlist: Vec<String>,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct EnabledToolConfig {
128 pub enabled: bool,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct UiConfig {
133 pub theme: UiTheme,
134 pub show_tool_output: bool,
135 pub confirm_destructive: bool,
136}
137
138#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
139#[serde(rename_all = "lowercase")]
140pub enum UiTheme {
141 #[default]
142 Auto,
143 Dark,
144 Light,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct SessionConfig {
149 pub storage: SessionStorage,
150 pub dir: String,
151}
152
153#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
154#[serde(rename_all = "lowercase")]
155pub enum SessionStorage {
156 #[default]
157 Sqlite,
158 Json,
159}
160
161impl Default for Config {
162 fn default() -> Self {
163 Self {
164 defaults: DefaultsConfig::default(),
165 providers: default_providers(),
166 agent: AgentConfig::default(),
167 tools: ToolsConfig::default(),
168 ui: UiConfig::default(),
169 session: SessionConfig::default(),
170 }
171 }
172}
173
174impl Default for DefaultsConfig {
175 fn default() -> Self {
176 Self {
177 model: "gpt-4o".to_string(),
178 provider: "openai".to_string(),
179 temperature: 0.2,
180 max_tokens: 8192,
181 language: "zh-CN".to_string(),
182 }
183 }
184}
185
186impl Default for AgentConfig {
187 fn default() -> Self {
188 Self {
189 max_turns: 50,
190 max_tool_rounds_per_turn: 25,
191 context_window_tokens: 128_000,
192 compaction_threshold: 0.85,
193 }
194 }
195}
196
197impl Default for ToolsConfig {
198 fn default() -> Self {
199 Self {
200 shell: ShellToolConfig {
201 enabled: true,
202 timeout_secs: 120,
203 sandbox: ShellSandboxMode::Off,
204 allowlist: Vec::new(),
205 },
206 write: EnabledToolConfig { enabled: true },
207 web_search: EnabledToolConfig { enabled: false },
208 grep: GrepToolConfig::default(),
209 }
210 }
211}
212
213impl Default for GrepToolConfig {
214 fn default() -> Self {
215 Self {
216 max_matches: 200,
217 max_files: 5_000,
218 }
219 }
220}
221
222impl Default for UiConfig {
223 fn default() -> Self {
224 Self {
225 theme: UiTheme::Auto,
226 show_tool_output: true,
227 confirm_destructive: true,
228 }
229 }
230}
231
232impl Default for SessionConfig {
233 fn default() -> Self {
234 Self {
235 storage: SessionStorage::Sqlite,
236 dir: "~/.local/share/codei/sessions".to_string(),
237 }
238 }
239}
240
241fn default_providers() -> HashMap<String, ProviderConfig> {
242 HashMap::from([
243 (
244 "openai".to_string(),
245 ProviderConfig {
246 api_key: None,
247 api_key_env: Some("OPENAI_API_KEY".to_string()),
248 base_url: Some("https://api.openai.com/v1".to_string()),
249 api_style: Some("openai".to_string()),
250 tool_format: Some("tools".to_string()),
251 },
252 ),
253 (
254 "anthropic".to_string(),
255 ProviderConfig {
256 api_key: None,
257 api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
258 base_url: None,
259 api_style: Some("anthropic".to_string()),
260 tool_format: None,
261 },
262 ),
263 (
264 "custom".to_string(),
265 ProviderConfig {
266 api_key: None,
267 api_key_env: Some("CUSTOM_API_KEY".to_string()),
268 base_url: Some("http://localhost:8080/v1".to_string()),
269 api_style: Some("openai".to_string()),
270 tool_format: Some("tools".to_string()),
271 },
272 ),
273 ])
274}
275
276impl ResolvedConfig {
277 pub fn validate(&self) -> Result<(), crate::ConfigError> {
278 let lang = &self.config.defaults.language;
279 if lang != "zh-CN" && lang != "en-US" {
280 return Err(crate::ConfigError::InvalidLanguage {
281 language: lang.clone(),
282 });
283 }
284 Ok(())
285 }
286}
287
288#[cfg(test)]
289mod provider_tests {
290 use super::ProviderConfig;
291
292 #[test]
293 fn resolve_api_key_prefers_direct_config() {
294 let cfg = ProviderConfig {
295 api_key: Some("sk-direct".into()),
296 api_key_env: Some("OPENAI_API_KEY".into()),
297 base_url: None,
298 api_style: None,
299 tool_format: None,
300 };
301 assert_eq!(cfg.resolve_api_key().unwrap(), "sk-direct");
302 }
303
304 #[test]
305 fn blank_api_key_falls_back_to_env() {
306 let cfg = ProviderConfig {
307 api_key: Some(" ".into()),
308 api_key_env: Some("NONEXISTENT_CODEI_API_KEY".into()),
309 base_url: None,
310 api_style: None,
311 tool_format: None,
312 };
313 let err = cfg.resolve_api_key().unwrap_err();
314 assert!(matches!(
315 err,
316 crate::ConfigError::MissingApiKey {
317 env: ref name
318 } if name == "NONEXISTENT_CODEI_API_KEY"
319 ));
320 }
321}