opencode-provider-manager 0.1.7-beta.5

TUI/CLI binary crate for managing OpenCode provider configs
Documentation
//! Deep merge logic for oh-my-openagent configuration files.
//!
//! Merge rules (similar to OpenCode's documented behavior):
//! - For objects: deep merge (project keys override global, global keys preserved)
//! - For arrays: project replaces global
//! - For scalars: project overrides global

use crate::omo_config::types::{AgentDefinition, AgentsConfig, OhMyOpencodeConfig};
use std::collections::HashMap;

/// Merge multiple agent configs in priority order (lowest priority first).
pub fn merge_agent_configs(configs: &[OhMyOpencodeConfig]) -> OhMyOpencodeConfig {
    configs
        .iter()
        .fold(OhMyOpencodeConfig::default(), |acc, config| {
            merge_two(acc, config.clone())
        })
}

/// Merge two configs with the second taking priority.
fn merge_two(lower: OhMyOpencodeConfig, higher: OhMyOpencodeConfig) -> OhMyOpencodeConfig {
    let mut result = lower;

    // Simple fields: higher overrides if set
    if higher.schema.is_some() {
        result.schema = higher.schema;
    }
    if higher.new_task_system_enabled.is_some() {
        result.new_task_system_enabled = higher.new_task_system_enabled;
    }
    if higher.default_run_agent.is_some() {
        result.default_run_agent = higher.default_run_agent;
    }
    if higher.hashline_edit.is_some() {
        result.hashline_edit = higher.hashline_edit;
    }
    if higher.model_fallback.is_some() {
        result.model_fallback = higher.model_fallback;
    }

    // Arrays: higher replaces if set
    if higher.agent_order.is_some() {
        result.agent_order = higher.agent_order;
    }
    if higher.agent_definitions.is_some() {
        result.agent_definitions = higher.agent_definitions;
    }
    if higher.disabled_mcps.is_some() {
        result.disabled_mcps = higher.disabled_mcps;
    }
    if higher.disabled_agents.is_some() {
        result.disabled_agents = higher.disabled_agents;
    }
    if higher.disabled_skills.is_some() {
        result.disabled_skills = higher.disabled_skills;
    }
    if higher.disabled_hooks.is_some() {
        result.disabled_hooks = higher.disabled_hooks;
    }
    if higher.disabled_commands.is_some() {
        result.disabled_commands = higher.disabled_commands;
    }
    if higher.disabled_tools.is_some() {
        result.disabled_tools = higher.disabled_tools;
    }
    if higher.disabled_providers.is_some() {
        result.disabled_providers = higher.disabled_providers;
    }
    if higher.mcp_env_allowlist.is_some() {
        result.mcp_env_allowlist = higher.mcp_env_allowlist;
    }
    if higher.disabled_categories.is_some() {
        result.disabled_categories = higher.disabled_categories;
    }

    // Nested configs: higher overrides if set (arrays replace, objects merge)
    if higher.categories.is_some() {
        result.categories = higher.categories;
    }
    if higher.background_task.is_some() {
        result.background_task = higher.background_task;
    }
    if higher.tmux.is_some() {
        result.tmux = higher.tmux;
    }
    if higher.experimental.is_some() {
        result.experimental = higher.experimental;
    }
    if higher.sisyphus_agent.is_some() {
        result.sisyphus_agent = higher.sisyphus_agent;
    }
    if higher.sisyphus.is_some() {
        result.sisyphus = higher.sisyphus;
    }
    if higher.skills.is_some() {
        result.skills = higher.skills;
    }
    if higher.browser_automation_engine.is_some() {
        result.browser_automation_engine = higher.browser_automation_engine;
    }
    if higher.git_master.is_some() {
        result.git_master = higher.git_master;
    }
    if higher.comment_checker.is_some() {
        result.comment_checker = higher.comment_checker;
    }
    if higher.notification.is_some() {
        result.notification = higher.notification;
    }
    if higher.lsp.is_some() {
        result.lsp = higher.lsp;
    }
    if higher.runtime_fallback.is_some() {
        result.runtime_fallback = higher.runtime_fallback;
    }

    // Agents: deep merge
    if let Some(higher_agents) = higher.agents {
        result.agents = Some(match result.agents {
            Some(lower_agents) => merge_agents(lower_agents, higher_agents),
            None => higher_agents,
        });
    }

    // Extra fields: merge JSON objects, otherwise higher wins
    for (key, value) in higher.extra {
        if let (Some(lower_obj), Some(higher_obj)) = (
            result.extra.get(&key).and_then(|v| v.as_object()),
            value.as_object(),
        ) {
            let mut merged = lower_obj.clone();
            for (k, v) in higher_obj {
                merged.insert(k.clone(), v.clone());
            }
            result.extra.insert(key, serde_json::Value::Object(merged));
        } else {
            result.extra.insert(key, value);
        }
    }

    result
}

/// Deep-merge two `AgentsConfig` objects.
fn merge_agents(lower: AgentsConfig, higher: AgentsConfig) -> AgentsConfig {
    AgentsConfig {
        build: merge_option_agent(lower.build, higher.build),
        plan: merge_option_agent(lower.plan, higher.plan),
        sisyphus: merge_option_agent(lower.sisyphus, higher.sisyphus),
        hephaestus: merge_option_agent(lower.hephaestus, higher.hephaestus),
        prometheus: merge_option_agent(lower.prometheus, higher.prometheus),
        oracle: merge_option_agent(lower.oracle, higher.oracle),
        librarian: merge_option_agent(lower.librarian, higher.librarian),
        explore: merge_option_agent(lower.explore, higher.explore),
        multimodal_looker: merge_option_agent(lower.multimodal_looker, higher.multimodal_looker),
        metis: merge_option_agent(lower.metis, higher.metis),
        momus: merge_option_agent(lower.momus, higher.momus),
        atlas: merge_option_agent(lower.atlas, higher.atlas),
        custom: merge_agent_map(lower.custom, higher.custom),
    }
}

/// Merge two optional agent definitions.
fn merge_option_agent(
    lower: Option<AgentDefinition>,
    higher: Option<AgentDefinition>,
) -> Option<AgentDefinition> {
    match (lower, higher) {
        (Some(l), Some(h)) => Some(merge_agent_definition(l, h)),
        (None, Some(h)) => Some(h),
        (l, None) => l,
    }
}

/// Deep-merge two `AgentDefinition` objects.
fn merge_agent_definition(lower: AgentDefinition, higher: AgentDefinition) -> AgentDefinition {
    let mut result = lower;

    macro_rules! override_if_some {
        ($field:ident) => {
            if higher.$field.is_some() {
                result.$field = higher.$field;
            }
        };
    }

    override_if_some!(model);
    override_if_some!(fallback_models);
    override_if_some!(variant);
    override_if_some!(category);
    override_if_some!(skills);
    override_if_some!(temperature);
    override_if_some!(top_p);
    override_if_some!(prompt);
    override_if_some!(prompt_append);
    override_if_some!(tools);
    override_if_some!(disable);
    override_if_some!(description);
    override_if_some!(mode);
    override_if_some!(color);
    override_if_some!(display_name);
    override_if_some!(permission);
    override_if_some!(max_tokens);
    override_if_some!(thinking);
    override_if_some!(reasoning_effort);
    override_if_some!(text_verbosity);
    override_if_some!(provider_options);
    override_if_some!(ultrawork);
    override_if_some!(compaction);

    result
}

/// Merge two agent maps (custom agents).
fn merge_agent_map(
    lower: HashMap<String, AgentDefinition>,
    higher: HashMap<String, AgentDefinition>,
) -> HashMap<String, AgentDefinition> {
    let mut result = lower;
    for (key, value) in higher {
        result
            .entry(key)
            .and_modify(|existing| {
                *existing = merge_agent_definition(existing.clone(), value.clone())
            })
            .or_insert(value);
    }
    result
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::omo_config::types::{AgentDefinition, AgentMode, AgentsConfig, OhMyOpencodeConfig};

    #[test]
    fn test_merge_simple_fields() {
        let global = OhMyOpencodeConfig {
            new_task_system_enabled: Some(false),
            default_run_agent: Some("build".to_string()),
            ..Default::default()
        };

        let project = OhMyOpencodeConfig {
            new_task_system_enabled: Some(true),
            ..Default::default()
        };

        let merged = merge_agent_configs(&[global, project]);
        assert_eq!(merged.new_task_system_enabled, Some(true));
        assert_eq!(merged.default_run_agent.as_deref(), Some("build"));
    }

    #[test]
    fn test_merge_agents_deep() {
        let global = OhMyOpencodeConfig {
            agents: Some(AgentsConfig {
                build: Some(AgentDefinition {
                    model: Some("global-model".to_string()),
                    temperature: Some(0.5),
                    ..Default::default()
                }),
                ..Default::default()
            }),
            ..Default::default()
        };

        let project = OhMyOpencodeConfig {
            agents: Some(AgentsConfig {
                build: Some(AgentDefinition {
                    temperature: Some(0.9),
                    mode: Some(AgentMode::Primary),
                    ..Default::default()
                }),
                ..Default::default()
            }),
            ..Default::default()
        };

        let merged = merge_agent_configs(&[global, project]);
        let build = merged.agents.unwrap().build.unwrap();
        assert_eq!(build.model.as_deref(), Some("global-model")); // preserved from global
        assert_eq!(build.temperature, Some(0.9)); // overridden by project
        assert_eq!(build.mode, Some(AgentMode::Primary)); // added by project
    }

    #[test]
    fn test_merge_arrays_replace() {
        let global = OhMyOpencodeConfig {
            disabled_skills: Some(vec![crate::omo_config::types::DisabledSkill::Playwright]),
            ..Default::default()
        };

        let project = OhMyOpencodeConfig {
            disabled_skills: Some(vec![
                crate::omo_config::types::DisabledSkill::AgentBrowser,
                crate::omo_config::types::DisabledSkill::DevBrowser,
            ]),
            ..Default::default()
        };

        let merged = merge_agent_configs(&[global, project]);
        let disabled = merged.disabled_skills.unwrap();
        assert_eq!(disabled.len(), 2);
        assert!(matches!(
            disabled[0],
            crate::omo_config::types::DisabledSkill::AgentBrowser
        ));
    }
}