Skip to main content

opi_coding_agent/
config.rs

1//! TOML config loading (S9.1/S9.1.1).
2//!
3//! Loads and resolves opi configuration with precedence:
4//! CLI > env > project config > user config > built-in defaults.
5//!
6//! Phase 1 fields: model, max_iterations, tool_timeout_ms, theme,
7//! thinking, providers.anthropic.api_key_env.
8//!
9//! Phase 2 fields: providers.{openai,openrouter,mistral,openai_responses,gemini}
10//! config with api_key_env, base_url, and OpenRouter-specific referer.
11
12use std::path::{Path, PathBuf};
13
14use serde::Deserialize;
15
16// ---------------------------------------------------------------------------
17// Resolved config (public API — all fields present)
18// ---------------------------------------------------------------------------
19
20/// Top-level opi configuration (fully resolved).
21#[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/// `[defaults]` section.
32#[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/// `[thinking]` section.
54#[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/// `[providers]` section.
70#[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/// `[providers.anthropic]` section.
81#[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/// Generic provider config (api_key_env + optional base_url).
97#[derive(Debug, Clone, PartialEq, Default)]
98pub struct GenericProviderConfig {
99    pub api_key_env: String,
100    pub base_url: Option<String>,
101}
102
103/// OpenRouter-specific provider config.
104#[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/// `[keybindings]` section.
112#[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/// `[compaction]` section.
130#[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// ---------------------------------------------------------------------------
146// TOML deserialization structs (Option fields detect presence)
147// ---------------------------------------------------------------------------
148
149#[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// ---------------------------------------------------------------------------
323// Error type
324// ---------------------------------------------------------------------------
325
326/// Errors from config loading and parsing.
327#[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
343// ---------------------------------------------------------------------------
344// Loading
345// ---------------------------------------------------------------------------
346
347/// Load and parse a TOML config file. Returns defaults if the file doesn't
348/// exist. Returns a clear error for malformed TOML.
349pub 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
370// ---------------------------------------------------------------------------
371// Resolution
372// ---------------------------------------------------------------------------
373
374/// External configuration sources for precedence resolution.
375pub struct ConfigSource {
376    /// Model from CLI `--model` flag.
377    pub cli_model: Option<String>,
378    /// Explicit config path from CLI `--config` flag.
379    pub config_path: Option<PathBuf>,
380    /// Model from env var `OPI_MODEL`.
381    pub env_model: Option<String>,
382    /// Project root directory (for `.opi/config.toml`).
383    pub project_dir: Option<PathBuf>,
384    /// User config file path override (for testing). When `None`, uses
385    /// the platform-default path from `user_config_path()`.
386    pub user_config_path: Option<PathBuf>,
387}
388
389/// Resolve configuration from all sources with correct precedence:
390/// CLI > env > project config > user config > built-in defaults.
391pub 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    // --config file overrides project and user config
402    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    // Env model only applies when --config was NOT explicitly provided,
414    // so that an explicit config file's model takes precedence over env.
415    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
442/// Return the platform-specific user config path.
443pub fn user_config_path() -> PathBuf {
444    if cfg!(windows) {
445        // Windows: %APPDATA%\opi\config.toml
446        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        // Unix: ~/.config/opi/config.toml
451        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}