use super::error::Result;
use super::state::AppState;
use crate::config_core::{ConfigLayer, OpenCodeConfig, ProviderConfig, merge::Mergeable};
use std::collections::HashMap;
impl AppState {
pub fn add_provider(
&mut self,
provider_id: String,
config: ProviderConfig,
layer: ConfigLayer,
) -> Result<()> {
let target_config = self.config_for_layer_mut(layer)?;
target_config
.provider
.get_or_insert_with(HashMap::new)
.insert(provider_id.clone(), config);
self.recompute_merged();
self.mark_dirty(layer);
Ok(())
}
pub fn remove_provider(&mut self, provider_id: &str, layer: ConfigLayer) -> Result<()> {
let target_config = self.config_for_layer_mut(layer)?;
if let Some(ref mut providers) = target_config.provider {
providers.remove(provider_id);
}
self.recompute_merged();
self.mark_dirty(layer);
Ok(())
}
pub fn edit_provider_field(
&mut self,
provider_id: &str,
field: &str,
value: serde_json::Value,
layer: ConfigLayer,
) -> Result<()> {
let target_config = self.config_for_layer_mut(layer)?;
if let Some(ref mut providers) = target_config.provider {
if let Some(provider) = providers.get_mut(provider_id) {
match field {
"name" => {
if let serde_json::Value::String(s) = value {
provider.name = Some(s);
}
}
"npm" => {
if let serde_json::Value::String(s) = value {
provider.npm = Some(s);
}
}
_ => {
provider
.options
.get_or_insert_with(HashMap::new)
.insert(field.to_string(), value);
}
}
}
}
self.recompute_merged();
self.mark_dirty(layer);
Ok(())
}
pub fn add_model(
&mut self,
provider_id: &str,
model_id: String,
model_config: crate::config_core::ModelConfig,
layer: ConfigLayer,
) -> Result<()> {
let target_config = self.config_for_layer_mut(layer)?;
let provider = target_config
.provider
.as_mut()
.and_then(|p| p.get_mut(provider_id))
.ok_or_else(|| {
super::error::AppError::State(format!(
"Provider '{provider_id}' not found in {layer:?} config"
))
})?;
provider
.models
.get_or_insert_with(HashMap::new)
.insert(model_id, model_config);
self.recompute_merged();
self.mark_dirty(layer);
Ok(())
}
pub fn remove_model(
&mut self,
provider_id: &str,
model_id: &str,
layer: ConfigLayer,
) -> Result<()> {
let target_config = self.config_for_layer_mut(layer)?;
if let Some(ref mut providers) = target_config.provider {
if let Some(provider) = providers.get_mut(provider_id) {
if let Some(ref mut models) = provider.models {
models.remove(model_id);
}
}
}
self.recompute_merged();
self.mark_dirty(layer);
Ok(())
}
pub fn copy_provider_to_global(&mut self, provider_id: &str) -> Result<()> {
let source = self
.config_for_layer(self.edit_layer)?
.provider
.as_ref()
.and_then(|p| p.get(provider_id))
.cloned()
.ok_or_else(|| {
super::error::AppError::State(format!(
"Provider '{provider_id}' not found in {:?} config",
self.edit_layer
))
})?;
let global = self.config_for_layer_mut(ConfigLayer::Global)?;
let providers = global.provider.get_or_insert_with(HashMap::new);
let merged = if let Some(existing) = providers.remove(provider_id) {
existing.merge(source)
} else {
source
};
providers.insert(provider_id.to_string(), merged);
self.recompute_merged();
self.mark_dirty(ConfigLayer::Global);
Ok(())
}
pub fn save(&mut self, layer: ConfigLayer) -> Result<()> {
let path_buf = match layer {
ConfigLayer::Project => match self.paths.project.clone() {
Some(p) => p,
None => {
let cwd = std::env::current_dir().map_err(|e| {
super::error::AppError::State(format!("Cannot read cwd: {e}"))
})?;
let fallback = cwd.join("opencode.json");
self.paths.project = Some(fallback.clone());
fallback
}
},
other => self.paths.path_for_layer(other).cloned().ok_or_else(|| {
super::error::AppError::State(format!("No config path for layer {other:?}"))
})?,
};
let config = self.config_for_layer(layer)?;
crate::config_core::jsonc::write_config(config, &path_buf)?;
self.mark_clean(layer);
Ok(())
}
fn config_for_layer(&self, layer: ConfigLayer) -> Result<&OpenCodeConfig> {
match layer {
ConfigLayer::Global => self.global_config.as_ref().ok_or_else(|| {
super::error::AppError::State("No global config loaded".to_string())
}),
ConfigLayer::Project => self.project_config.as_ref().ok_or_else(|| {
super::error::AppError::State("No project config loaded".to_string())
}),
ConfigLayer::Custom => self.custom_config.as_ref().ok_or_else(|| {
super::error::AppError::State("No custom config loaded".to_string())
}),
}
}
fn config_for_layer_mut(&mut self, layer: ConfigLayer) -> Result<&mut OpenCodeConfig> {
match layer {
ConfigLayer::Global => {
if self.global_config.is_none() {
self.global_config = Some(OpenCodeConfig::default());
}
Ok(self.global_config.as_mut().unwrap())
}
ConfigLayer::Project => {
if self.project_config.is_none() {
self.project_config = Some(OpenCodeConfig::default());
}
Ok(self.project_config.as_mut().unwrap())
}
ConfigLayer::Custom => {
if self.custom_config.is_none() {
self.custom_config = Some(OpenCodeConfig::default());
}
Ok(self.custom_config.as_mut().unwrap())
}
}
}
pub fn recompute_merged(&mut self) {
let mut configs = Vec::new();
if let Some(global) = &self.global_config {
configs.push(global.clone());
}
if let Some(custom) = &self.custom_config {
configs.push(custom.clone());
}
if let Some(project) = &self.project_config {
configs.push(project.clone());
}
self.merged_config = crate::config_core::merge_configs(&configs);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config_core::{ConfigLayer, ModelLimit};
fn make_state_with_project_provider() -> AppState {
let mut state = AppState::new().unwrap();
state.edit_layer = ConfigLayer::Project;
let mut project_models = HashMap::new();
project_models.insert(
"gpt-4o".to_string(),
crate::config_core::ModelConfig {
name: Some("GPT-4o".to_string()),
limit: Some(ModelLimit {
context: Some(128_000),
output: None,
}),
..Default::default()
},
);
let project_provider = ProviderConfig {
npm: Some("@ai-sdk/openai".to_string()),
name: Some("OpenAI".to_string()),
models: Some(project_models),
..Default::default()
};
state.project_config = Some(OpenCodeConfig::default());
state
.project_config
.as_mut()
.unwrap()
.provider
.get_or_insert_with(HashMap::new)
.insert("openai".to_string(), project_provider);
state.recompute_merged();
state
}
#[test]
fn test_copy_provider_to_global_new() {
let mut state = make_state_with_project_provider();
assert!(state.global_config.is_none());
state.copy_provider_to_global("openai").unwrap();
let global = state.global_config.as_ref().unwrap();
let provider = global.provider.as_ref().unwrap().get("openai").unwrap();
assert_eq!(provider.npm.as_deref(), Some("@ai-sdk/openai"));
assert!(state.dirty);
}
#[test]
fn test_copy_provider_to_global_merge() {
let mut state = make_state_with_project_provider();
let mut global_models = HashMap::new();
global_models.insert(
"gpt-3.5-turbo".to_string(),
crate::config_core::ModelConfig::default(),
);
let global_provider = ProviderConfig {
npm: Some("old-sdk".to_string()),
name: Some("Old Name".to_string()),
models: Some(global_models),
..Default::default()
};
state.global_config = Some(OpenCodeConfig::default());
state
.global_config
.as_mut()
.unwrap()
.provider
.get_or_insert_with(HashMap::new)
.insert("openai".to_string(), global_provider);
state.copy_provider_to_global("openai").unwrap();
let global = state.global_config.as_ref().unwrap();
let provider = global.provider.as_ref().unwrap().get("openai").unwrap();
assert_eq!(provider.npm.as_deref(), Some("@ai-sdk/openai"));
assert_eq!(provider.name.as_deref(), Some("OpenAI"));
let models = provider.models.as_ref().unwrap();
assert!(models.contains_key("gpt-4o"), "project model should be present");
assert!(
models.contains_key("gpt-3.5-turbo"),
"global-only model should be preserved"
);
}
#[test]
fn test_copy_provider_to_global_missing() {
let mut state = make_state_with_project_provider();
let result = state.copy_provider_to_global("nonexistent");
assert!(result.is_err());
}
#[test]
fn test_per_layer_dirty_tracking() {
let mut state = make_state_with_project_provider();
assert!(!state.dirty);
state.copy_provider_to_global("openai").unwrap();
assert!(state.dirty);
state.mark_clean(ConfigLayer::Global);
assert!(!state.dirty);
}
#[test]
fn test_per_layer_dirty_independent() {
let mut state = make_state_with_project_provider();
let provider = ProviderConfig {
npm: Some("test-sdk".to_string()),
..Default::default()
};
state
.add_provider("test".to_string(), provider, ConfigLayer::Project)
.unwrap();
assert!(state.dirty);
state.copy_provider_to_global("openai").unwrap();
state.mark_clean(ConfigLayer::Global);
assert!(state.dirty, "project edits should still be dirty after saving global");
}
#[test]
fn test_add_model_fails_when_provider_missing() {
let mut state = make_state_with_project_provider();
let result = state.add_model(
"nonexistent",
"model-x".to_string(),
crate::config_core::ModelConfig::default(),
ConfigLayer::Project,
);
assert!(result.is_err());
}
}