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
9use std::path::{Path, PathBuf};
10
11use serde::Deserialize;
12
13// ---------------------------------------------------------------------------
14// Resolved config (public API — all fields present)
15// ---------------------------------------------------------------------------
16
17/// Top-level opi configuration (fully resolved).
18#[derive(Debug, Clone, PartialEq, Default)]
19pub struct OpiConfig {
20    pub defaults: DefaultsConfig,
21    pub thinking: ThinkingConfig,
22    pub providers: ProvidersConfig,
23}
24
25/// `[defaults]` section.
26#[derive(Debug, Clone, PartialEq)]
27pub struct DefaultsConfig {
28    pub model: String,
29    pub max_iterations: u32,
30    pub tool_timeout_ms: u64,
31    pub theme: String,
32    pub allow_mutating_tools: bool,
33}
34
35impl Default for DefaultsConfig {
36    fn default() -> Self {
37        Self {
38            model: "anthropic:claude-sonnet-4".into(),
39            max_iterations: 50,
40            tool_timeout_ms: 30_000,
41            theme: "default".into(),
42            allow_mutating_tools: false,
43        }
44    }
45}
46
47/// `[thinking]` section.
48#[derive(Debug, Clone, PartialEq)]
49pub struct ThinkingConfig {
50    pub enabled: bool,
51    pub budget_tokens: u32,
52}
53
54impl Default for ThinkingConfig {
55    fn default() -> Self {
56        Self {
57            enabled: true,
58            budget_tokens: 10_000,
59        }
60    }
61}
62
63/// `[providers]` section.
64#[derive(Debug, Clone, PartialEq, Default)]
65pub struct ProvidersConfig {
66    pub anthropic: AnthropicProviderConfig,
67}
68
69/// `[providers.anthropic]` section.
70#[derive(Debug, Clone, PartialEq)]
71pub struct AnthropicProviderConfig {
72    pub api_key_env: String,
73    pub base_url: Option<String>,
74}
75
76impl Default for AnthropicProviderConfig {
77    fn default() -> Self {
78        Self {
79            api_key_env: "ANTHROPIC_API_KEY".into(),
80            base_url: None,
81        }
82    }
83}
84
85// ---------------------------------------------------------------------------
86// TOML deserialization structs (Option fields detect presence)
87// ---------------------------------------------------------------------------
88
89#[derive(Debug, Clone, Deserialize, Default)]
90#[serde(default)]
91struct TomlConfig {
92    defaults: TomlDefaults,
93    thinking: TomlThinking,
94    providers: TomlProviders,
95}
96
97#[derive(Debug, Clone, Deserialize, Default)]
98#[serde(default)]
99struct TomlDefaults {
100    model: Option<String>,
101    max_iterations: Option<u32>,
102    tool_timeout_ms: Option<u64>,
103    theme: Option<String>,
104    allow_mutating_tools: Option<bool>,
105}
106
107#[derive(Debug, Clone, Deserialize, Default)]
108#[serde(default)]
109struct TomlThinking {
110    enabled: Option<bool>,
111    budget_tokens: Option<u32>,
112}
113
114#[derive(Debug, Clone, Deserialize, Default)]
115#[serde(default)]
116struct TomlProviders {
117    anthropic: TomlAnthropic,
118}
119
120#[derive(Debug, Clone, Deserialize, Default)]
121#[serde(default)]
122struct TomlAnthropic {
123    api_key_env: Option<String>,
124    base_url: Option<String>,
125}
126
127impl TomlConfig {
128    fn merge_into(self, config: &mut OpiConfig) {
129        if let Some(v) = self.defaults.model {
130            config.defaults.model = v;
131        }
132        if let Some(v) = self.defaults.max_iterations {
133            config.defaults.max_iterations = v;
134        }
135        if let Some(v) = self.defaults.tool_timeout_ms {
136            config.defaults.tool_timeout_ms = v;
137        }
138        if let Some(v) = self.defaults.theme {
139            config.defaults.theme = v;
140        }
141        if let Some(v) = self.defaults.allow_mutating_tools {
142            config.defaults.allow_mutating_tools = v;
143        }
144        if let Some(v) = self.thinking.enabled {
145            config.thinking.enabled = v;
146        }
147        if let Some(v) = self.thinking.budget_tokens {
148            config.thinking.budget_tokens = v;
149        }
150        if let Some(v) = self.providers.anthropic.api_key_env {
151            config.providers.anthropic.api_key_env = v;
152        }
153        if let Some(v) = self.providers.anthropic.base_url {
154            config.providers.anthropic.base_url = Some(v);
155        }
156    }
157}
158
159// ---------------------------------------------------------------------------
160// Error type
161// ---------------------------------------------------------------------------
162
163/// Errors from config loading and parsing.
164#[derive(Debug, thiserror::Error)]
165pub enum ConfigError {
166    #[error("failed to parse config file {path}: {source}")]
167    Parse {
168        path: PathBuf,
169        #[source]
170        source: Box<toml::de::Error>,
171    },
172    #[error("failed to read config file {path}: {source}")]
173    Read {
174        path: PathBuf,
175        #[source]
176        source: std::io::Error,
177    },
178}
179
180// ---------------------------------------------------------------------------
181// Loading
182// ---------------------------------------------------------------------------
183
184/// Load and parse a TOML config file. Returns defaults if the file doesn't
185/// exist. Returns a clear error for malformed TOML.
186pub fn load_config_file(path: &Path) -> Result<OpiConfig, ConfigError> {
187    if !path.exists() {
188        return Ok(OpiConfig::default());
189    }
190    let contents = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
191        path: path.to_path_buf(),
192        source,
193    })?;
194    parse_toml(&contents, path)
195}
196
197fn parse_toml(contents: &str, path: &Path) -> Result<OpiConfig, ConfigError> {
198    let raw: TomlConfig = toml::from_str(contents).map_err(|source| ConfigError::Parse {
199        path: path.to_path_buf(),
200        source: Box::new(source),
201    })?;
202    let mut config = OpiConfig::default();
203    raw.merge_into(&mut config);
204    Ok(config)
205}
206
207// ---------------------------------------------------------------------------
208// Resolution
209// ---------------------------------------------------------------------------
210
211/// External configuration sources for precedence resolution.
212pub struct ConfigSource {
213    /// Model from CLI `--model` flag.
214    pub cli_model: Option<String>,
215    /// Explicit config path from CLI `--config` flag.
216    pub config_path: Option<PathBuf>,
217    /// Model from env var `OPI_MODEL`.
218    pub env_model: Option<String>,
219    /// Project root directory (for `.opi/config.toml`).
220    pub project_dir: Option<PathBuf>,
221    /// User config file path override (for testing). When `None`, uses
222    /// the platform-default path from `user_config_path()`.
223    pub user_config_path: Option<PathBuf>,
224}
225
226/// Resolve configuration from all sources with correct precedence:
227/// CLI > env > project config > user config > built-in defaults.
228pub fn resolve_config(source: ConfigSource) -> Result<OpiConfig, ConfigError> {
229    let user_path = source.user_config_path.unwrap_or_else(user_config_path);
230    let mut config = load_config_file(&user_path)?;
231
232    if let Some(project_dir) = &source.project_dir {
233        let project_config_path = project_dir.join(".opi").join("config.toml");
234        let project_raw = load_raw_config(&project_config_path)?;
235        project_raw.merge_into(&mut config);
236    }
237
238    // --config file overrides project and user config
239    if let Some(config_path) = &source.config_path {
240        if !config_path.exists() {
241            return Err(ConfigError::Read {
242                path: config_path.clone(),
243                source: std::io::Error::new(std::io::ErrorKind::NotFound, "config file not found"),
244            });
245        }
246        let cli_raw = load_raw_config(config_path)?;
247        cli_raw.merge_into(&mut config);
248    }
249
250    // Env model only applies when --config was NOT explicitly provided,
251    // so that an explicit config file's model takes precedence over env.
252    if source.config_path.is_none()
253        && let Some(env_model) = &source.env_model
254    {
255        config.defaults.model = env_model.clone();
256    }
257
258    if let Some(cli_model) = &source.cli_model {
259        config.defaults.model = cli_model.clone();
260    }
261
262    Ok(config)
263}
264
265fn load_raw_config(path: &Path) -> Result<TomlConfig, ConfigError> {
266    if !path.exists() {
267        return Ok(TomlConfig::default());
268    }
269    let contents = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
270        path: path.to_path_buf(),
271        source,
272    })?;
273    toml::from_str(&contents).map_err(|source| ConfigError::Parse {
274        path: path.to_path_buf(),
275        source: Box::new(source),
276    })
277}
278
279/// Return the platform-specific user config path.
280pub fn user_config_path() -> PathBuf {
281    if cfg!(windows) {
282        // Windows: %APPDATA%\opi\config.toml
283        std::env::var("APPDATA")
284            .map(|p| PathBuf::from(p).join("opi").join("config.toml"))
285            .unwrap_or_else(|_| PathBuf::from(".opi").join("config.toml"))
286    } else {
287        // Unix: ~/.config/opi/config.toml
288        dirs_home()
289            .map(|h| h.join(".config").join("opi").join("config.toml"))
290            .unwrap_or_else(|| PathBuf::from(".opi").join("config.toml"))
291    }
292}
293
294fn dirs_home() -> Option<PathBuf> {
295    std::env::var("HOME").ok().map(PathBuf::from)
296}