use crate::omo_config::error::{AgentConfigError, Result};
use crate::omo_config::types::OhMyOpencodeConfig;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ConfigLayer {
Global,
Project,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AgentConfigManager {
pub global_path: PathBuf,
pub project_path: Option<PathBuf>,
pub global_config: Option<OhMyOpencodeConfig>,
pub project_config: Option<OhMyOpencodeConfig>,
}
impl AgentConfigManager {
pub fn new() -> Result<Self> {
let global_path = default_global_path()?;
let project_path = find_project_path();
Ok(Self {
global_path,
project_path,
global_config: None,
project_config: None,
})
}
pub fn load_all(
&mut self,
) -> Result<(&Option<OhMyOpencodeConfig>, &Option<OhMyOpencodeConfig>)> {
self.global_config = self.load_layer(ConfigLayer::Global)?;
self.project_config = self.load_layer(ConfigLayer::Project)?;
Ok((&self.global_config, &self.project_config))
}
pub fn load_layer(&self, layer: ConfigLayer) -> Result<Option<OhMyOpencodeConfig>> {
let path = match layer {
ConfigLayer::Global => &self.global_path,
ConfigLayer::Project => {
let Some(ref path) = self.project_path else {
return Ok(None);
};
path
}
};
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(path).map_err(|e| AgentConfigError::ReadError {
path: path.clone(),
source: e,
})?;
let config = parse_config_content(&content, path)?;
Ok(Some(config))
}
pub fn save(&self, layer: ConfigLayer, config: &OhMyOpencodeConfig) -> Result<()> {
let path = match layer {
ConfigLayer::Global => &self.global_path,
ConfigLayer::Project => {
let Some(ref path) = self.project_path else {
return Err(AgentConfigError::InvalidLayer("project".to_string()));
};
path
}
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json =
serde_json::to_string_pretty(config).map_err(AgentConfigError::SerializeError)?;
std::fs::write(path, json).map_err(|e| AgentConfigError::WriteError {
path: path.clone(),
source: e,
})?;
Ok(())
}
pub fn path_for(&self, layer: ConfigLayer) -> &Path {
match layer {
ConfigLayer::Global => &self.global_path,
ConfigLayer::Project => self
.project_path
.as_deref()
.unwrap_or_else(|| Path::new(".opencode/oh-my-opencode.json")),
}
}
}
impl Default for AgentConfigManager {
fn default() -> Self {
Self::new().unwrap_or_else(|_| Self {
global_path: default_global_path_fallback(),
project_path: None,
global_config: None,
project_config: None,
})
}
}
fn parse_config_content(content: &str, path: &Path) -> Result<OhMyOpencodeConfig> {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
match ext.as_str() {
"jsonc" => parse_jsonc(content, path),
"json" | "" => {
if content.contains("//") || content.contains("/*") {
parse_jsonc(content, path)
} else {
serde_json::from_str(content).map_err(|e| AgentConfigError::JsonParseError {
path: path.to_path_buf(),
source: e,
})
}
}
"toml" => toml::from_str(content).map_err(|e| AgentConfigError::TomlParseError {
path: path.to_path_buf(),
source: Box::new(e),
}),
"yaml" | "yml" => {
serde_yaml::from_str(content).map_err(|e| AgentConfigError::YamlParseError {
path: path.to_path_buf(),
source: Box::new(e),
})
}
other => Err(AgentConfigError::UnsupportedFormat {
format: other.to_string(),
path: path.to_path_buf(),
}),
}
}
fn parse_jsonc(content: &str, path: &Path) -> Result<OhMyOpencodeConfig> {
let parsed = jsonc_parser::parse_to_value(content, &Default::default())
.map_err(|e| AgentConfigError::Other(format!("JSONC parse error: {}", e)))?;
let Some(value) = parsed else {
return Err(AgentConfigError::Other(
"JSONC parse returned None".to_string(),
));
};
let serde_value = jsonc_to_serde(value);
serde_json::from_value(serde_value).map_err(|e| AgentConfigError::JsonParseError {
path: path.to_path_buf(),
source: e,
})
}
fn jsonc_to_serde(value: jsonc_parser::JsonValue) -> serde_json::Value {
match value {
jsonc_parser::JsonValue::String(s) => serde_json::Value::String(s.into_owned()),
jsonc_parser::JsonValue::Number(n) => {
serde_json::Value::Number(n.parse().unwrap_or_else(|_| serde_json::Number::from(0)))
}
jsonc_parser::JsonValue::Boolean(b) => serde_json::Value::Bool(b),
jsonc_parser::JsonValue::Object(obj) => {
let map = obj
.take_inner()
.into_iter()
.map(|(k, v)| (k, jsonc_to_serde(v)))
.collect();
serde_json::Value::Object(map)
}
jsonc_parser::JsonValue::Array(arr) => {
serde_json::Value::Array(arr.take_inner().into_iter().map(jsonc_to_serde).collect())
}
jsonc_parser::JsonValue::Null => serde_json::Value::Null,
}
}
fn default_global_path() -> Result<PathBuf> {
let mut bases = Vec::new();
if let Some(config_dir) = dirs::config_dir() {
bases.push(config_dir.join("opencode"));
}
if let Some(home_dir) = dirs::home_dir() {
let unix_style = home_dir.join(".config").join("opencode");
if !bases.contains(&unix_style) {
bases.push(unix_style);
}
}
if bases.is_empty() {
return Err(AgentConfigError::Other(
"Could not determine config directory".to_string(),
));
}
for base in &bases {
for filename in [
"oh-my-opencode.jsonc",
"oh-my-opencode.json",
"oh-my-openagent.jsonc",
"oh-my-openagent.json",
] {
let path = base.join(filename);
if path.exists() {
return Ok(path);
}
}
}
Ok(bases[0].join("oh-my-opencode.jsonc"))
}
fn default_global_path_fallback() -> PathBuf {
PathBuf::from("~/.config/opencode/oh-my-opencode.jsonc")
}
fn find_project_path() -> Option<PathBuf> {
let mut current = std::env::current_dir().ok()?;
loop {
let opencode_dir = current.join(".opencode");
if opencode_dir.is_dir() {
for filename in [
"oh-my-opencode.jsonc",
"oh-my-opencode.json",
"oh-my-openagent.jsonc",
"oh-my-openagent.json",
] {
let path = opencode_dir.join(filename);
if path.exists() {
return Some(path);
}
}
}
for filename in [
"oh-my-opencode.jsonc",
"oh-my-opencode.json",
"oh-my-openagent.jsonc",
"oh-my-openagent.json",
] {
let path = current.join(filename);
if path.exists() {
return Some(path);
}
}
if current.join(".git").exists() {
return None;
}
match current.parent() {
Some(parent) => current = parent.to_path_buf(),
None => return None,
}
}
}
pub fn parse_config_file(path: &Path) -> Result<OhMyOpencodeConfig> {
if !path.exists() {
return Err(AgentConfigError::NotFound(path.to_path_buf()));
}
let content = std::fs::read_to_string(path).map_err(|e| AgentConfigError::ReadError {
path: path.to_path_buf(),
source: e,
})?;
parse_config_content(&content, path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_parse_json_config() {
let json = r#"{
"$schema": "https://example.com/schema.json",
"newTaskSystemEnabled": true,
"defaultRunAgent": "build",
"agents": {
"build": {
"model": "anthropic/claude-sonnet-4-5",
"temperature": 0.7
}
}
}"#;
let mut file = NamedTempFile::with_suffix(".json").unwrap();
file.write_all(json.as_bytes()).unwrap();
let config = parse_config_file(file.path()).unwrap();
assert_eq!(config.new_task_system_enabled, Some(true));
assert_eq!(config.default_run_agent.as_deref(), Some("build"));
assert!(config.agents.is_some());
}
#[test]
fn test_parse_jsonc_with_comments() {
let jsonc = r#"{
// This is a comment
"$schema": "https://example.com/schema.json",
/* multi-line
comment */
"newTaskSystemEnabled": true
}"#;
let mut file = NamedTempFile::with_suffix(".jsonc").unwrap();
file.write_all(jsonc.as_bytes()).unwrap();
let config = parse_config_file(file.path()).unwrap();
assert_eq!(config.new_task_system_enabled, Some(true));
}
#[test]
fn test_parse_jsonc_with_trailing_commas() {
let jsonc = r#"{
"newTaskSystemEnabled": true,
"defaultRunAgent": "build",
}"#;
let mut file = NamedTempFile::with_suffix(".jsonc").unwrap();
file.write_all(jsonc.as_bytes()).unwrap();
let config = parse_config_file(file.path()).unwrap();
assert_eq!(config.new_task_system_enabled, Some(true));
}
#[test]
fn test_parse_toml_config() {
let toml = r#"
newTaskSystemEnabled = true
defaultRunAgent = "plan"
[agents.build]
model = "openai/gpt-4o"
temperature = 0.5
"#;
let mut file = NamedTempFile::with_suffix(".toml").unwrap();
file.write_all(toml.as_bytes()).unwrap();
let config = parse_config_file(file.path()).unwrap();
assert_eq!(config.default_run_agent.as_deref(), Some("plan"));
let agents = config.agents.unwrap();
assert!(agents.build.is_some());
}
#[test]
fn test_agent_config_manager_new() {
let manager = AgentConfigManager::new();
assert!(manager.is_ok());
}
}