opencode_provider_manager/omo_config/
config.rs1use crate::omo_config::error::{AgentConfigError, Result};
10use crate::omo_config::types::OhMyOpencodeConfig;
11use std::path::{Path, PathBuf};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum ConfigLayer {
16 Global,
18 Project,
20}
21
22#[derive(Debug, Clone, PartialEq)]
24pub struct AgentConfigManager {
25 pub global_path: PathBuf,
27 pub project_path: Option<PathBuf>,
29 pub global_config: Option<OhMyOpencodeConfig>,
31 pub project_config: Option<OhMyOpencodeConfig>,
33}
34
35impl AgentConfigManager {
36 pub fn new() -> Result<Self> {
38 let global_path = default_global_path()?;
39 let project_path = find_project_path();
40
41 Ok(Self {
42 global_path,
43 project_path,
44 global_config: None,
45 project_config: None,
46 })
47 }
48
49 pub fn load_all(
51 &mut self,
52 ) -> Result<(&Option<OhMyOpencodeConfig>, &Option<OhMyOpencodeConfig>)> {
53 self.global_config = self.load_layer(ConfigLayer::Global)?;
54 self.project_config = self.load_layer(ConfigLayer::Project)?;
55 Ok((&self.global_config, &self.project_config))
56 }
57
58 pub fn load_layer(&self, layer: ConfigLayer) -> Result<Option<OhMyOpencodeConfig>> {
60 let path = match layer {
61 ConfigLayer::Global => &self.global_path,
62 ConfigLayer::Project => {
63 let Some(ref path) = self.project_path else {
64 return Ok(None);
65 };
66 path
67 }
68 };
69
70 if !path.exists() {
71 return Ok(None);
72 }
73
74 let content = std::fs::read_to_string(path).map_err(|e| AgentConfigError::ReadError {
75 path: path.clone(),
76 source: e,
77 })?;
78
79 let config = parse_config_content(&content, path)?;
80 Ok(Some(config))
81 }
82
83 pub fn save(&self, layer: ConfigLayer, config: &OhMyOpencodeConfig) -> Result<()> {
85 let path = match layer {
86 ConfigLayer::Global => &self.global_path,
87 ConfigLayer::Project => {
88 let Some(ref path) = self.project_path else {
89 return Err(AgentConfigError::InvalidLayer("project".to_string()));
90 };
91 path
92 }
93 };
94
95 if let Some(parent) = path.parent() {
97 std::fs::create_dir_all(parent)?;
98 }
99
100 let json =
101 serde_json::to_string_pretty(config).map_err(AgentConfigError::SerializeError)?;
102
103 std::fs::write(path, json).map_err(|e| AgentConfigError::WriteError {
104 path: path.clone(),
105 source: e,
106 })?;
107
108 Ok(())
109 }
110
111 pub fn path_for(&self, layer: ConfigLayer) -> &Path {
113 match layer {
114 ConfigLayer::Global => &self.global_path,
115 ConfigLayer::Project => self
116 .project_path
117 .as_deref()
118 .unwrap_or_else(|| Path::new(".opencode/oh-my-opencode.json")),
119 }
120 }
121}
122
123impl Default for AgentConfigManager {
124 fn default() -> Self {
125 Self::new().unwrap_or_else(|_| Self {
126 global_path: default_global_path_fallback(),
127 project_path: None,
128 global_config: None,
129 project_config: None,
130 })
131 }
132}
133
134fn parse_config_content(content: &str, path: &Path) -> Result<OhMyOpencodeConfig> {
136 let ext = path
137 .extension()
138 .and_then(|e| e.to_str())
139 .unwrap_or("")
140 .to_lowercase();
141
142 match ext.as_str() {
143 "jsonc" => parse_jsonc(content, path),
144 "json" | "" => {
145 if content.contains("//") || content.contains("/*") {
147 parse_jsonc(content, path)
148 } else {
149 serde_json::from_str(content).map_err(|e| AgentConfigError::JsonParseError {
150 path: path.to_path_buf(),
151 source: e,
152 })
153 }
154 }
155 "toml" => toml::from_str(content).map_err(|e| AgentConfigError::TomlParseError {
156 path: path.to_path_buf(),
157 source: Box::new(e),
158 }),
159 "yaml" | "yml" => {
160 serde_yaml::from_str(content).map_err(|e| AgentConfigError::YamlParseError {
161 path: path.to_path_buf(),
162 source: Box::new(e),
163 })
164 }
165 other => Err(AgentConfigError::UnsupportedFormat {
166 format: other.to_string(),
167 path: path.to_path_buf(),
168 }),
169 }
170}
171
172fn parse_jsonc(content: &str, path: &Path) -> Result<OhMyOpencodeConfig> {
174 let parsed = jsonc_parser::parse_to_value(content, &Default::default())
175 .map_err(|e| AgentConfigError::Other(format!("JSONC parse error: {}", e)))?;
176
177 let Some(value) = parsed else {
178 return Err(AgentConfigError::Other(
179 "JSONC parse returned None".to_string(),
180 ));
181 };
182
183 let serde_value = jsonc_to_serde(value);
184 serde_json::from_value(serde_value).map_err(|e| AgentConfigError::JsonParseError {
185 path: path.to_path_buf(),
186 source: e,
187 })
188}
189
190fn jsonc_to_serde(value: jsonc_parser::JsonValue) -> serde_json::Value {
192 match value {
193 jsonc_parser::JsonValue::String(s) => serde_json::Value::String(s.into_owned()),
194 jsonc_parser::JsonValue::Number(n) => {
195 serde_json::Value::Number(n.parse().unwrap_or_else(|_| serde_json::Number::from(0)))
196 }
197 jsonc_parser::JsonValue::Boolean(b) => serde_json::Value::Bool(b),
198 jsonc_parser::JsonValue::Object(obj) => {
199 let map = obj
200 .take_inner()
201 .into_iter()
202 .map(|(k, v)| (k, jsonc_to_serde(v)))
203 .collect();
204 serde_json::Value::Object(map)
205 }
206 jsonc_parser::JsonValue::Array(arr) => {
207 serde_json::Value::Array(arr.take_inner().into_iter().map(jsonc_to_serde).collect())
208 }
209 jsonc_parser::JsonValue::Null => serde_json::Value::Null,
210 }
211}
212
213fn default_global_path() -> Result<PathBuf> {
219 let mut bases = Vec::new();
220
221 if let Some(config_dir) = dirs::config_dir() {
223 bases.push(config_dir.join("opencode"));
224 }
225
226 if let Some(home_dir) = dirs::home_dir() {
228 let unix_style = home_dir.join(".config").join("opencode");
229 if !bases.contains(&unix_style) {
230 bases.push(unix_style);
231 }
232 }
233
234 if bases.is_empty() {
235 return Err(AgentConfigError::Other(
236 "Could not determine config directory".to_string(),
237 ));
238 }
239
240 for base in &bases {
242 for filename in [
243 "oh-my-opencode.jsonc",
244 "oh-my-opencode.json",
245 "oh-my-openagent.jsonc",
246 "oh-my-openagent.json",
247 ] {
248 let path = base.join(filename);
249 if path.exists() {
250 return Ok(path);
251 }
252 }
253 }
254
255 Ok(bases[0].join("oh-my-opencode.jsonc"))
257}
258
259fn default_global_path_fallback() -> PathBuf {
261 PathBuf::from("~/.config/opencode/oh-my-opencode.jsonc")
262}
263
264fn find_project_path() -> Option<PathBuf> {
267 let mut current = std::env::current_dir().ok()?;
268
269 loop {
270 let opencode_dir = current.join(".opencode");
271
272 if opencode_dir.is_dir() {
274 for filename in [
275 "oh-my-opencode.jsonc",
276 "oh-my-opencode.json",
277 "oh-my-openagent.jsonc",
278 "oh-my-openagent.json",
279 ] {
280 let path = opencode_dir.join(filename);
281 if path.exists() {
282 return Some(path);
283 }
284 }
285 }
286
287 for filename in [
289 "oh-my-opencode.jsonc",
290 "oh-my-opencode.json",
291 "oh-my-openagent.jsonc",
292 "oh-my-openagent.json",
293 ] {
294 let path = current.join(filename);
295 if path.exists() {
296 return Some(path);
297 }
298 }
299
300 if current.join(".git").exists() {
302 return None;
303 }
304
305 match current.parent() {
306 Some(parent) => current = parent.to_path_buf(),
307 None => return None,
308 }
309 }
310}
311
312pub fn parse_config_file(path: &Path) -> Result<OhMyOpencodeConfig> {
314 if !path.exists() {
315 return Err(AgentConfigError::NotFound(path.to_path_buf()));
316 }
317
318 let content = std::fs::read_to_string(path).map_err(|e| AgentConfigError::ReadError {
319 path: path.to_path_buf(),
320 source: e,
321 })?;
322
323 parse_config_content(&content, path)
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329 use std::io::Write;
330 use tempfile::NamedTempFile;
331
332 #[test]
333 fn test_parse_json_config() {
334 let json = r#"{
335 "$schema": "https://example.com/schema.json",
336 "newTaskSystemEnabled": true,
337 "defaultRunAgent": "build",
338 "agents": {
339 "build": {
340 "model": "anthropic/claude-sonnet-4-5",
341 "temperature": 0.7
342 }
343 }
344 }"#;
345
346 let mut file = NamedTempFile::with_suffix(".json").unwrap();
347 file.write_all(json.as_bytes()).unwrap();
348
349 let config = parse_config_file(file.path()).unwrap();
350 assert_eq!(config.new_task_system_enabled, Some(true));
351 assert_eq!(config.default_run_agent.as_deref(), Some("build"));
352 assert!(config.agents.is_some());
353 }
354
355 #[test]
356 fn test_parse_jsonc_with_comments() {
357 let jsonc = r#"{
358 // This is a comment
359 "$schema": "https://example.com/schema.json",
360 /* multi-line
361 comment */
362 "newTaskSystemEnabled": true
363 }"#;
364
365 let mut file = NamedTempFile::with_suffix(".jsonc").unwrap();
366 file.write_all(jsonc.as_bytes()).unwrap();
367
368 let config = parse_config_file(file.path()).unwrap();
369 assert_eq!(config.new_task_system_enabled, Some(true));
370 }
371
372 #[test]
373 fn test_parse_jsonc_with_trailing_commas() {
374 let jsonc = r#"{
375 "newTaskSystemEnabled": true,
376 "defaultRunAgent": "build",
377 }"#;
378
379 let mut file = NamedTempFile::with_suffix(".jsonc").unwrap();
380 file.write_all(jsonc.as_bytes()).unwrap();
381
382 let config = parse_config_file(file.path()).unwrap();
383 assert_eq!(config.new_task_system_enabled, Some(true));
384 }
385
386 #[test]
387 fn test_parse_toml_config() {
388 let toml = r#"
389newTaskSystemEnabled = true
390defaultRunAgent = "plan"
391
392[agents.build]
393model = "openai/gpt-4o"
394temperature = 0.5
395"#;
396
397 let mut file = NamedTempFile::with_suffix(".toml").unwrap();
398 file.write_all(toml.as_bytes()).unwrap();
399
400 let config = parse_config_file(file.path()).unwrap();
401 assert_eq!(config.default_run_agent.as_deref(), Some("plan"));
402 let agents = config.agents.unwrap();
403 assert!(agents.build.is_some());
404 }
405
406 #[test]
407 fn test_agent_config_manager_new() {
408 let manager = AgentConfigManager::new();
409 assert!(manager.is_ok());
410 }
411}