Skip to main content

opencode_provider_manager/omo_config/
config.rs

1//! Configuration file management for oh-my-openagent.
2//!
3//! Handles reading, writing, and path resolution for agent config files.
4//!
5//! Config file locations (matching oh-my-openagent conventions):
6//! - Project: `.opencode/oh-my-opencode.json[c]` or `.opencode/oh-my-openagent.json[c]`
7//! - Global:  `~/.config/opencode/oh-my-opencode.json[c]` (XDG) or `%APPDATA%\opencode\oh-my-opencode.json[c]` (Windows)
8
9use crate::omo_config::error::{AgentConfigError, Result};
10use crate::omo_config::types::OhMyOpencodeConfig;
11use std::path::{Path, PathBuf};
12
13/// Which configuration layer to operate on.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum ConfigLayer {
16    /// Global config: `~/.config/opencode/oh-my-opencode.json`
17    Global,
18    /// Project config: `.opencode/oh-my-opencode.json`
19    Project,
20}
21
22/// Manages agent configuration files.
23#[derive(Debug, Clone, PartialEq)]
24pub struct AgentConfigManager {
25    /// Global config file path.
26    pub global_path: PathBuf,
27    /// Project config file path.
28    pub project_path: Option<PathBuf>,
29    /// Cached global config.
30    pub global_config: Option<OhMyOpencodeConfig>,
31    /// Cached project config.
32    pub project_config: Option<OhMyOpencodeConfig>,
33}
34
35impl AgentConfigManager {
36    /// Create a new manager with default paths.
37    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    /// Load all configs (global + project).
50    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    /// Load a single config layer.
59    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    /// Save a config layer to disk.
84    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        // Ensure parent directory exists
96        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    /// Get the path for a specific layer.
112    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
134/// Parse config content from various formats (JSON / JSONC / TOML / YAML).
135fn 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            // Try JSONC first (handles comments if present), fall back to plain JSON
146            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
172/// Parse JSONC content using jsonc-parser crate.
173fn 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
190/// Convert jsonc_parser::JsonValue to serde_json::Value.
191fn 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
213/// Default global config path: `~/.config/opencode/oh-my-opencode.json`.
214/// Prefers `.jsonc` if it exists.
215///
216/// Checks both platform config dir (e.g., `%APPDATA%\opencode` on Windows)
217/// and `~/.config/opencode` (Unix-style, also used by oh-my-opencode on Windows).
218fn default_global_path() -> Result<PathBuf> {
219    let mut bases = Vec::new();
220
221    // Platform config dir (e.g., %APPDATA%/opencode on Windows, ~/.config on Linux)
222    if let Some(config_dir) = dirs::config_dir() {
223        bases.push(config_dir.join("opencode"));
224    }
225
226    // Unix-style ~/.config/opencode (also used by oh-my-opencode on Windows)
227    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    // Prefer .jsonc, then .json; prefer oh-my-opencode, then oh-my-openagent
241    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    // Default to oh-my-opencode.jsonc in the first available directory
256    Ok(bases[0].join("oh-my-opencode.jsonc"))
257}
258
259/// Fallback global path for Default impl when dirs::config_dir() fails.
260fn default_global_path_fallback() -> PathBuf {
261    PathBuf::from("~/.config/opencode/oh-my-opencode.jsonc")
262}
263
264/// Find the project config by walking up from the current directory.
265/// Looks in `.opencode/` for `oh-my-opencode.json[c]` or `oh-my-openagent.json[c]`.
266fn 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        // Check .opencode/ directory first
273        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        // Also check root-level fallback (legacy location)
288        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        // Stop at git root
301        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
312/// Parse a config file directly from a path.
313pub 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}