1use std::path::{Path, PathBuf};
10
11use serde::Deserialize;
12
13#[derive(Debug, Clone, PartialEq, Default)]
19pub struct OpiConfig {
20 pub defaults: DefaultsConfig,
21 pub thinking: ThinkingConfig,
22 pub providers: ProvidersConfig,
23}
24
25#[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#[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#[derive(Debug, Clone, PartialEq, Default)]
65pub struct ProvidersConfig {
66 pub anthropic: AnthropicProviderConfig,
67}
68
69#[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#[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#[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
180pub 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
207pub struct ConfigSource {
213 pub cli_model: Option<String>,
215 pub config_path: Option<PathBuf>,
217 pub env_model: Option<String>,
219 pub project_dir: Option<PathBuf>,
221 pub user_config_path: Option<PathBuf>,
224}
225
226pub 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 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 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
279pub fn user_config_path() -> PathBuf {
281 if cfg!(windows) {
282 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 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}