opencode-provider-manager 0.1.7-beta.1

TUI/CLI binary crate for managing OpenCode provider configs
Documentation
//! User actions that transform app state.

use super::error::Result;
use super::state::AppState;
use crate::config_core::{ConfigLayer, OpenCodeConfig, ProviderConfig, merge::Mergeable};
use std::collections::HashMap;

/// Actions the user can perform on the app state.
impl AppState {
    /// Add a new provider to the config at the specified layer.
    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(())
    }

    /// Remove a provider from the config at the specified layer.
    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(())
    }

    /// Edit a provider field in the config at the specified layer.
    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);
                        }
                    }
                    _ => {
                        // Store as an option
                        provider
                            .options
                            .get_or_insert_with(HashMap::new)
                            .insert(field.to_string(), value);
                    }
                }
            }
        }
        self.recompute_merged();
        self.mark_dirty(layer);
        Ok(())
    }

    /// Add a model to a provider.
    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(())
    }

    /// Remove a model from a provider.
    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(())
    }

    /// Copy a provider from the current edit_layer to the global config (merge if already present).
    ///
    /// The incoming provider (from edit_layer) is treated as higher priority: its
    /// fields override the existing global entry for the same provider, but models
    /// that only exist in global are preserved.  Only the Global layer is marked
    /// dirty — edit_layer's dirty state is unchanged.
    pub fn copy_provider_to_global(&mut self, provider_id: &str) -> Result<()> {
        // Read source provider from edit_layer (not merged).
        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
                ))
            })?;

        // Merge into global config.
        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 (global) is lower priority, source (edit_layer) is higher priority.
            existing.merge(source)
        } else {
            source
        };
        providers.insert(provider_id.to_string(), merged);

        self.recompute_merged();
        self.mark_dirty(ConfigLayer::Global);
        Ok(())
    }

    /// Save the config at the specified layer to disk.
    ///
    /// For the Project layer, falls back to `./opencode.json` in the current
    /// directory when no project file was discovered, so that new project
    /// configs can be created.
    pub fn save(&mut self, layer: ConfigLayer) -> Result<()> {
        // Resolve save path. For Project, fall back to ./opencode.json if none
        // was discovered so first-time project config creation works.
        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(())
    }

    // --- Private helpers ---

    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);

        // Copy to global marks global dirty
        state.copy_provider_to_global("openai").unwrap();
        assert!(state.dirty);

        // mark_clean for Global should clear dirty when no other layer is dirty
        state.mark_clean(ConfigLayer::Global);
        assert!(!state.dirty);
    }

    #[test]
    fn test_per_layer_dirty_independent() {
        let mut state = make_state_with_project_provider();

        // Mark project dirty via add_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);

        // Copy to global also marks global dirty
        state.copy_provider_to_global("openai").unwrap();

        // Saving global clears global dirty, but project remains dirty
        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();
        // "nonexistent" provider is not in project_config
        let result = state.add_model(
            "nonexistent",
            "model-x".to_string(),
            crate::config_core::ModelConfig::default(),
            ConfigLayer::Project,
        );
        assert!(result.is_err());
    }
}