1use std::path::{Path, PathBuf};
13
14use serde::Deserialize;
15
16#[derive(Debug, Clone, PartialEq, Default)]
22pub struct OpiConfig {
23 pub defaults: DefaultsConfig,
24 pub thinking: ThinkingConfig,
25 pub providers: ProvidersConfig,
26 pub keybindings: KeybindingsConfig,
27 pub retry: opi_ai::retry::RetryConfig,
28 pub compaction: CompactionConfigSection,
29}
30
31#[derive(Debug, Clone, PartialEq)]
33pub struct DefaultsConfig {
34 pub model: String,
35 pub max_iterations: u32,
36 pub tool_timeout_ms: u64,
37 pub theme: String,
38 pub allow_mutating_tools: bool,
39}
40
41impl Default for DefaultsConfig {
42 fn default() -> Self {
43 Self {
44 model: "anthropic:claude-sonnet-4".into(),
45 max_iterations: 50,
46 tool_timeout_ms: 30_000,
47 theme: "default".into(),
48 allow_mutating_tools: false,
49 }
50 }
51}
52
53#[derive(Debug, Clone, PartialEq)]
55pub struct ThinkingConfig {
56 pub enabled: bool,
57 pub budget_tokens: u32,
58}
59
60impl Default for ThinkingConfig {
61 fn default() -> Self {
62 Self {
63 enabled: true,
64 budget_tokens: 10_000,
65 }
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Default)]
71pub struct ProvidersConfig {
72 pub anthropic: AnthropicProviderConfig,
73 pub openai: GenericProviderConfig,
74 pub openrouter: OpenRouterProviderConfig,
75 pub mistral: GenericProviderConfig,
76 pub openai_responses: GenericProviderConfig,
77 pub gemini: GenericProviderConfig,
78}
79
80#[derive(Debug, Clone, PartialEq)]
82pub struct AnthropicProviderConfig {
83 pub api_key_env: String,
84 pub base_url: Option<String>,
85}
86
87impl Default for AnthropicProviderConfig {
88 fn default() -> Self {
89 Self {
90 api_key_env: "ANTHROPIC_API_KEY".into(),
91 base_url: None,
92 }
93 }
94}
95
96#[derive(Debug, Clone, PartialEq, Default)]
98pub struct GenericProviderConfig {
99 pub api_key_env: String,
100 pub base_url: Option<String>,
101}
102
103#[derive(Debug, Clone, PartialEq, Default)]
105pub struct OpenRouterProviderConfig {
106 pub api_key_env: String,
107 pub base_url: Option<String>,
108 pub referer: Option<String>,
109}
110
111#[derive(Debug, Clone, PartialEq)]
113pub struct KeybindingsConfig {
114 pub submit: String,
115 pub abort: String,
116 pub new_line: String,
117}
118
119impl Default for KeybindingsConfig {
120 fn default() -> Self {
121 Self {
122 submit: "enter".into(),
123 abort: "escape".into(),
124 new_line: "alt+enter".into(),
125 }
126 }
127}
128
129#[derive(Debug, Clone, PartialEq)]
131pub struct CompactionConfigSection {
132 pub enabled: bool,
133 pub threshold_tokens: u64,
134}
135
136impl Default for CompactionConfigSection {
137 fn default() -> Self {
138 Self {
139 enabled: true,
140 threshold_tokens: 100_000,
141 }
142 }
143}
144
145#[derive(Debug, Clone, Deserialize, Default)]
150#[serde(default)]
151struct TomlConfig {
152 defaults: TomlDefaults,
153 thinking: TomlThinking,
154 providers: TomlProviders,
155 keybindings: TomlKeybindings,
156 retry: TomlRetry,
157 compaction: TomlCompaction,
158}
159
160#[derive(Debug, Clone, Deserialize, Default)]
161#[serde(default)]
162struct TomlDefaults {
163 model: Option<String>,
164 max_iterations: Option<u32>,
165 tool_timeout_ms: Option<u64>,
166 theme: Option<String>,
167 allow_mutating_tools: Option<bool>,
168}
169
170#[derive(Debug, Clone, Deserialize, Default)]
171#[serde(default)]
172struct TomlThinking {
173 enabled: Option<bool>,
174 budget_tokens: Option<u32>,
175}
176
177#[derive(Debug, Clone, Deserialize, Default)]
178#[serde(default)]
179struct TomlProviders {
180 anthropic: TomlAnthropic,
181 openai: TomlGenericProvider,
182 openrouter: TomlOpenRouterProvider,
183 mistral: TomlGenericProvider,
184 openai_responses: TomlGenericProvider,
185 gemini: TomlGenericProvider,
186}
187
188#[derive(Debug, Clone, Deserialize, Default)]
189#[serde(default)]
190struct TomlAnthropic {
191 api_key_env: Option<String>,
192 base_url: Option<String>,
193}
194
195#[derive(Debug, Clone, Deserialize, Default)]
196#[serde(default)]
197struct TomlGenericProvider {
198 api_key_env: Option<String>,
199 base_url: Option<String>,
200}
201
202#[derive(Debug, Clone, Deserialize, Default)]
203#[serde(default)]
204struct TomlOpenRouterProvider {
205 api_key_env: Option<String>,
206 base_url: Option<String>,
207 referer: Option<String>,
208}
209
210#[derive(Debug, Clone, Deserialize, Default)]
211#[serde(default)]
212struct TomlKeybindings {
213 submit: Option<String>,
214 abort: Option<String>,
215 new_line: Option<String>,
216}
217
218#[derive(Debug, Clone, Deserialize, Default)]
219#[serde(default)]
220struct TomlRetry {
221 max_attempts: Option<u32>,
222 initial_delay_ms: Option<u64>,
223 max_delay_ms: Option<u64>,
224}
225
226#[derive(Debug, Clone, Deserialize, Default)]
227#[serde(default)]
228struct TomlCompaction {
229 enabled: Option<bool>,
230 threshold_tokens: Option<u64>,
231}
232
233impl TomlConfig {
234 fn merge_into(self, config: &mut OpiConfig) {
235 if let Some(v) = self.defaults.model {
236 config.defaults.model = v;
237 }
238 if let Some(v) = self.defaults.max_iterations {
239 config.defaults.max_iterations = v;
240 }
241 if let Some(v) = self.defaults.tool_timeout_ms {
242 config.defaults.tool_timeout_ms = v;
243 }
244 if let Some(v) = self.defaults.theme {
245 config.defaults.theme = v;
246 }
247 if let Some(v) = self.defaults.allow_mutating_tools {
248 config.defaults.allow_mutating_tools = v;
249 }
250 if let Some(v) = self.thinking.enabled {
251 config.thinking.enabled = v;
252 }
253 if let Some(v) = self.thinking.budget_tokens {
254 config.thinking.budget_tokens = v;
255 }
256 if let Some(v) = self.providers.anthropic.api_key_env {
257 config.providers.anthropic.api_key_env = v;
258 }
259 if let Some(v) = self.providers.anthropic.base_url {
260 config.providers.anthropic.base_url = Some(v);
261 }
262 if let Some(v) = self.providers.openai.api_key_env {
263 config.providers.openai.api_key_env = v;
264 }
265 if let Some(v) = self.providers.openai.base_url {
266 config.providers.openai.base_url = Some(v);
267 }
268 if let Some(v) = self.providers.openrouter.api_key_env {
269 config.providers.openrouter.api_key_env = v;
270 }
271 if let Some(v) = self.providers.openrouter.base_url {
272 config.providers.openrouter.base_url = Some(v);
273 }
274 if let Some(v) = self.providers.openrouter.referer {
275 config.providers.openrouter.referer = Some(v);
276 }
277 if let Some(v) = self.providers.mistral.api_key_env {
278 config.providers.mistral.api_key_env = v;
279 }
280 if let Some(v) = self.providers.mistral.base_url {
281 config.providers.mistral.base_url = Some(v);
282 }
283 if let Some(v) = self.providers.openai_responses.api_key_env {
284 config.providers.openai_responses.api_key_env = v;
285 }
286 if let Some(v) = self.providers.openai_responses.base_url {
287 config.providers.openai_responses.base_url = Some(v);
288 }
289 if let Some(v) = self.providers.gemini.api_key_env {
290 config.providers.gemini.api_key_env = v;
291 }
292 if let Some(v) = self.providers.gemini.base_url {
293 config.providers.gemini.base_url = Some(v);
294 }
295 if let Some(v) = self.keybindings.submit {
296 config.keybindings.submit = v;
297 }
298 if let Some(v) = self.keybindings.abort {
299 config.keybindings.abort = v;
300 }
301 if let Some(v) = self.keybindings.new_line {
302 config.keybindings.new_line = v;
303 }
304 if let Some(v) = self.retry.max_attempts {
305 config.retry.max_attempts = v;
306 }
307 if let Some(v) = self.retry.initial_delay_ms {
308 config.retry.initial_delay_ms = v;
309 }
310 if let Some(v) = self.retry.max_delay_ms {
311 config.retry.max_delay_ms = v;
312 }
313 if let Some(v) = self.compaction.enabled {
314 config.compaction.enabled = v;
315 }
316 if let Some(v) = self.compaction.threshold_tokens {
317 config.compaction.threshold_tokens = v;
318 }
319 }
320}
321
322#[derive(Debug, thiserror::Error)]
328pub enum ConfigError {
329 #[error("failed to parse config file {path}: {source}")]
330 Parse {
331 path: PathBuf,
332 #[source]
333 source: Box<toml::de::Error>,
334 },
335 #[error("failed to read config file {path}: {source}")]
336 Read {
337 path: PathBuf,
338 #[source]
339 source: std::io::Error,
340 },
341}
342
343pub fn load_config_file(path: &Path) -> Result<OpiConfig, ConfigError> {
350 if !path.exists() {
351 return Ok(OpiConfig::default());
352 }
353 let contents = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
354 path: path.to_path_buf(),
355 source,
356 })?;
357 parse_toml(&contents, path)
358}
359
360fn parse_toml(contents: &str, path: &Path) -> Result<OpiConfig, ConfigError> {
361 let raw: TomlConfig = toml::from_str(contents).map_err(|source| ConfigError::Parse {
362 path: path.to_path_buf(),
363 source: Box::new(source),
364 })?;
365 let mut config = OpiConfig::default();
366 raw.merge_into(&mut config);
367 Ok(config)
368}
369
370pub struct ConfigSource {
376 pub cli_model: Option<String>,
378 pub config_path: Option<PathBuf>,
380 pub env_model: Option<String>,
382 pub project_dir: Option<PathBuf>,
384 pub user_config_path: Option<PathBuf>,
387}
388
389pub fn resolve_config(source: ConfigSource) -> Result<OpiConfig, ConfigError> {
392 let user_path = source.user_config_path.unwrap_or_else(user_config_path);
393 let mut config = load_config_file(&user_path)?;
394
395 if let Some(project_dir) = &source.project_dir {
396 let project_config_path = project_dir.join(".opi").join("config.toml");
397 let project_raw = load_raw_config(&project_config_path)?;
398 project_raw.merge_into(&mut config);
399 }
400
401 if let Some(config_path) = &source.config_path {
403 if !config_path.exists() {
404 return Err(ConfigError::Read {
405 path: config_path.clone(),
406 source: std::io::Error::new(std::io::ErrorKind::NotFound, "config file not found"),
407 });
408 }
409 let cli_raw = load_raw_config(config_path)?;
410 cli_raw.merge_into(&mut config);
411 }
412
413 if source.config_path.is_none()
416 && let Some(env_model) = &source.env_model
417 {
418 config.defaults.model = env_model.clone();
419 }
420
421 if let Some(cli_model) = &source.cli_model {
422 config.defaults.model = cli_model.clone();
423 }
424
425 Ok(config)
426}
427
428fn load_raw_config(path: &Path) -> Result<TomlConfig, ConfigError> {
429 if !path.exists() {
430 return Ok(TomlConfig::default());
431 }
432 let contents = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
433 path: path.to_path_buf(),
434 source,
435 })?;
436 toml::from_str(&contents).map_err(|source| ConfigError::Parse {
437 path: path.to_path_buf(),
438 source: Box::new(source),
439 })
440}
441
442pub fn user_config_path() -> PathBuf {
444 if cfg!(windows) {
445 std::env::var("APPDATA")
447 .map(|p| PathBuf::from(p).join("opi").join("config.toml"))
448 .unwrap_or_else(|_| PathBuf::from(".opi").join("config.toml"))
449 } else {
450 dirs_home()
452 .map(|h| h.join(".config").join("opi").join("config.toml"))
453 .unwrap_or_else(|| PathBuf::from(".opi").join("config.toml"))
454 }
455}
456
457fn dirs_home() -> Option<PathBuf> {
458 std::env::var("HOME").ok().map(PathBuf::from)
459}