Skip to main content

opencode_provider_manager/config_core/
merge.rs

1//! Deep merge logic for OpenCode configuration files.
2//!
3//! Follows OpenCode's documented precedence:
4//! 1. Remote config (.well-known/opencode) — organizational defaults
5//! 2. Global config (~/.config/opencode/opencode.json) — user preferences
6//! 3. Custom config (OPENCODE_CONFIG env var) — custom overrides
7//! 4. Project config (./opencode.json) — project-specific settings
8//! 5. .opencode directories — agents, commands, plugins
9//! 6. Inline config (OPENCODE_CONFIG_CONTENT env var) — runtime overrides
10//! 7. Managed config files — highest priority, not user-overridable
11//!
12//! Merge rules (replicating DOCUMENTED behavior):
13//! - For objects: deep merge (project keys override global, global keys preserved)
14//! - For arrays: project replaces global
15//! - For scalars: project overrides global
16//! - Special handling for `provider` field: deep merge provider entries
17
18use super::schema::OpenCodeConfig;
19
20/// Strategy for resolving merge conflicts.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum MergeStrategy {
23    /// Later sources override earlier ones (documented OpenCode behavior).
24    Override,
25    /// Only merge if the key doesn't already exist.
26    FillMissing,
27}
28
29/// Merge multiple configs in priority order (lowest priority first).
30///
31/// The configs are merged in order, with later configs overriding earlier ones.
32/// This follows the documented precedence order:
33/// remote < global < custom < project < inline < managed
34pub fn merge_configs(configs: &[OpenCodeConfig]) -> OpenCodeConfig {
35    configs
36        .iter()
37        .fold(OpenCodeConfig::default(), |acc, config| {
38            merge_two(acc, config.clone())
39        })
40}
41
42/// Merge two configs with the second taking priority.
43///
44/// Deep merge for objects, replace for arrays and scalars.
45pub fn merge_two(lower: OpenCodeConfig, higher: OpenCodeConfig) -> OpenCodeConfig {
46    let mut result = lower;
47
48    // Simple fields: higher priority overrides if set
49    if higher.schema.is_some() {
50        result.schema = higher.schema;
51    }
52    if higher.log_level.is_some() {
53        result.log_level = higher.log_level;
54    }
55    if higher.model.is_some() {
56        result.model = higher.model;
57    }
58    if higher.small_model.is_some() {
59        result.small_model = higher.small_model;
60    }
61    if higher.default_agent.is_some() {
62        result.default_agent = higher.default_agent;
63    }
64    if higher.username.is_some() {
65        result.username = higher.username;
66    }
67    if higher.snapshot.is_some() {
68        result.snapshot = higher.snapshot;
69    }
70    if higher.share.is_some() {
71        result.share = higher.share;
72    }
73    if higher.autoupdate.is_some() {
74        result.autoupdate = higher.autoupdate;
75    }
76    if higher.experimental.is_some() {
77        result.experimental = higher.experimental;
78    }
79
80    // Deep merge for optional objects
81    result.server = merge_option_struct(result.server, higher.server);
82    result.skills = merge_option_struct(result.skills, higher.skills);
83    result.watcher = merge_option_struct(result.watcher, higher.watcher);
84    result.compaction = merge_option_struct(result.compaction, higher.compaction);
85
86    // Deep merge for HashMap fields
87    result.provider = merge_option_hashmap(result.provider, higher.provider);
88    result.agent = merge_option_hashmap(result.agent, higher.agent);
89    result.command = merge_option_hashmap(result.command, higher.command);
90    result.mcp = merge_option_hashmap(result.mcp, higher.mcp);
91    // For formatter, tools, and provider options - use replace semantics for values
92    result.formatter = merge_option_hashmap_replace(result.formatter, higher.formatter);
93    result.tools = merge_option_hashmap_replace(result.tools, higher.tools);
94    result.permission = higher.permission.or(result.permission);
95
96    // Arrays: higher priority replaces
97    result.disabled_providers = higher.disabled_providers.or(result.disabled_providers);
98    result.enabled_providers = higher.enabled_providers.or(result.enabled_providers);
99    result.instructions = higher.instructions.or(result.instructions);
100    result.plugin = higher.plugin.or(result.plugin);
101
102    result
103}
104
105/// Deep merge two Option<T> structs. If both exist, merge fields.
106fn merge_option_struct<T: Mergeable>(lower: Option<T>, higher: Option<T>) -> Option<T> {
107    match (lower, higher) {
108        (None, None) => None,
109        (Some(l), None) => Some(l),
110        (None, Some(h)) => Some(h),
111        (Some(l), Some(h)) => Some(l.merge(h)),
112    }
113}
114
115/// Deep merge two Option<HashMap> fields. If both exist, merge entries.
116fn merge_option_hashmap<K, V>(
117    lower: Option<std::collections::HashMap<K, V>>,
118    higher: Option<std::collections::HashMap<K, V>>,
119) -> Option<std::collections::HashMap<K, V>>
120where
121    K: std::hash::Hash + Eq + Clone + std::fmt::Debug,
122    V: Clone + Mergeable + std::fmt::Debug,
123{
124    match (lower, higher) {
125        (None, None) => None,
126        (Some(l), None) => Some(l),
127        (None, Some(h)) => Some(h),
128        (Some(mut l), Some(h)) => {
129            for (key, higher_val) in h {
130                match l.remove(&key) {
131                    Some(lower_val) => {
132                        // Deep merge existing entries
133                        l.insert(key, lower_val.merge(higher_val));
134                    }
135                    None => {
136                        // New entry from higher priority
137                        l.insert(key, higher_val);
138                    }
139                }
140            }
141            Some(l)
142        }
143    }
144}
145
146/// Replace-merge two Option<HashMap> fields. Higher priority entries replace lower ones.
147/// Used for HashMaps where values don't support deep merge (FormatterConfig, bool, serde_json::Value).
148fn merge_option_hashmap_replace<K, V>(
149    lower: Option<std::collections::HashMap<K, V>>,
150    higher: Option<std::collections::HashMap<K, V>>,
151) -> Option<std::collections::HashMap<K, V>>
152where
153    K: std::hash::Hash + Eq + Clone + std::fmt::Debug,
154    V: Clone + std::fmt::Debug,
155{
156    match (lower, higher) {
157        (None, None) => None,
158        (Some(l), None) => Some(l),
159        (None, Some(h)) => Some(h),
160        (Some(mut l), Some(h)) => {
161            // Higher priority entries override lower ones
162            for (key, val) in h {
163                l.insert(key, val);
164            }
165            Some(l)
166        }
167    }
168}
169
170fn merge_hashmap_replace<K, V>(
171    mut lower: std::collections::HashMap<K, V>,
172    higher: std::collections::HashMap<K, V>,
173) -> std::collections::HashMap<K, V>
174where
175    K: std::hash::Hash + Eq,
176{
177    for (key, value) in higher {
178        lower.insert(key, value);
179    }
180    lower
181}
182
183/// Trait for types that support deep merge operations.
184pub trait Mergeable: Sized {
185    /// Merge another value into this one, with `other` taking priority on conflicts.
186    fn merge(self, other: Self) -> Self;
187}
188
189// Import schema types for Mergeable impls
190use super::schema::*;
191
192impl Mergeable for ServerConfig {
193    fn merge(self, other: Self) -> Self {
194        Self {
195            port: other.port.or(self.port),
196            hostname: other.hostname.or(self.hostname),
197            mdns: other.mdns.or(self.mdns),
198            mdns_domain: other.mdns_domain.or(self.mdns_domain),
199            cors: other.cors.or(self.cors),
200        }
201    }
202}
203
204impl Mergeable for SkillsConfig {
205    fn merge(self, other: Self) -> Self {
206        Self {
207            paths: other.paths.or(self.paths),
208            urls: other.urls.or(self.urls),
209        }
210    }
211}
212
213impl Mergeable for WatcherConfig {
214    fn merge(self, other: Self) -> Self {
215        Self {
216            ignore: other.ignore.or(self.ignore),
217        }
218    }
219}
220
221impl Mergeable for CompactionConfig {
222    fn merge(self, other: Self) -> Self {
223        Self {
224            auto: other.auto.or(self.auto),
225            prune: other.prune.or(self.prune),
226            reserved: other.reserved.or(self.reserved),
227        }
228    }
229}
230
231impl Mergeable for ProviderConfig {
232    fn merge(self, other: Self) -> Self {
233        Self {
234            npm: other.npm.or(self.npm),
235            name: other.name.or(self.name),
236            options: merge_option_hashmap_replace(self.options, other.options),
237            models: merge_option_hashmap(self.models, other.models),
238            disabled: other.disabled.or(self.disabled),
239            extra: merge_hashmap_replace(self.extra, other.extra),
240        }
241    }
242}
243
244impl Mergeable for ModelConfig {
245    fn merge(self, other: Self) -> Self {
246        Self {
247            name: other.name.or(self.name),
248            id: other.id.or(self.id),
249            options: merge_option_hashmap_replace(self.options, other.options),
250            variants: merge_option_hashmap(self.variants, other.variants),
251            limit: other.limit.or(self.limit),
252            disabled: other.disabled.or(self.disabled),
253            extra: merge_hashmap_replace(self.extra, other.extra),
254        }
255    }
256}
257
258impl Mergeable for AgentConfig {
259    fn merge(self, other: Self) -> Self {
260        Self {
261            model: other.model.or(self.model),
262            variant: other.variant.or(self.variant),
263            temperature: other.temperature.or(self.temperature),
264            top_p: other.top_p.or(self.top_p),
265            prompt: other.prompt.or(self.prompt),
266            description: other.description.or(self.description),
267            disable: other.disable.or(self.disable),
268            mode: other.mode.or(self.mode),
269            hidden: other.hidden.or(self.hidden),
270            steps: other.steps.or(self.steps),
271            color: other.color.or(self.color),
272            options: merge_option_hashmap_replace(self.options, other.options),
273            permission: other.permission.or(self.permission),
274            tools: other.tools.or(self.tools),
275        }
276    }
277}
278
279impl Mergeable for CommandConfig {
280    fn merge(self, other: Self) -> Self {
281        Self {
282            template: other.template,
283            description: other.description.or(self.description),
284            agent: other.agent.or(self.agent),
285            model: other.model.or(self.model),
286            subtask: other.subtask.or(self.subtask),
287        }
288    }
289}
290
291impl Mergeable for McpConfig {
292    fn merge(self, other: Self) -> Self {
293        Self {
294            mcp_type: other.mcp_type.or(self.mcp_type),
295            command: other.command.or(self.command),
296            args: other.args.or(self.args),
297            url: other.url.or(self.url),
298            env: other.env.or(self.env),
299            enabled: other.enabled.or(self.enabled),
300        }
301    }
302}
303
304impl Mergeable for VariantConfig {
305    fn merge(self, other: Self) -> Self {
306        Self {
307            options: {
308                let mut merged = self.options;
309                for (k, v) in other.options {
310                    merged.insert(k, v);
311                }
312                merged
313            },
314            disabled: other.disabled.or(self.disabled),
315        }
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::super::schema::*;
322    use super::*;
323    use std::collections::HashMap;
324
325    #[test]
326    fn test_merge_non_conflicting_keys() {
327        let global = OpenCodeConfig {
328            autoupdate: Some(AutoupdateConfig::Bool(true)),
329            ..Default::default()
330        };
331        let project = OpenCodeConfig {
332            model: Some("anthropic/claude-sonnet-4-5".to_string()),
333            ..Default::default()
334        };
335
336        let merged = merge_two(global, project);
337        assert!(matches!(
338            merged.autoupdate,
339            Some(AutoupdateConfig::Bool(true))
340        ));
341        assert_eq!(
342            merged.model,
343            Some("anthropic/claude-sonnet-4-5".to_string())
344        );
345    }
346
347    #[test]
348    fn test_merge_conflicting_scalar_project_overrides() {
349        let global = OpenCodeConfig {
350            model: Some("anthropic/claude-haiku-4-5".to_string()),
351            ..Default::default()
352        };
353        let project = OpenCodeConfig {
354            model: Some("anthropic/claude-sonnet-4-5".to_string()),
355            ..Default::default()
356        };
357
358        let merged = merge_two(global, project);
359        assert_eq!(
360            merged.model,
361            Some("anthropic/claude-sonnet-4-5".to_string())
362        );
363    }
364
365    #[test]
366    fn test_merge_provider_deep_merge() {
367        let mut global_models = HashMap::new();
368        global_models.insert(
369            "claude-haiku-4-5".to_string(),
370            ModelConfig {
371                name: Some("Claude Haiku 4.5".to_string()),
372                ..Default::default()
373            },
374        );
375
376        let global = OpenCodeConfig {
377            provider: Some({
378                let mut providers = HashMap::new();
379                providers.insert(
380                    "anthropic".to_string(),
381                    ProviderConfig {
382                        options: Some({
383                            let mut opts = HashMap::new();
384                            opts.insert(
385                                "apiKey".to_string(),
386                                serde_json::Value::String("{env:ANTHROPIC_API_KEY}".to_string()),
387                            );
388                            opts
389                        }),
390                        models: Some(global_models),
391                        ..Default::default()
392                    },
393                );
394                providers
395            }),
396            ..Default::default()
397        };
398
399        let mut project_models = HashMap::new();
400        project_models.insert(
401            "claude-sonnet-4-5".to_string(),
402            ModelConfig {
403                name: Some("Claude Sonnet 4.5".to_string()),
404                ..Default::default()
405            },
406        );
407
408        let project = OpenCodeConfig {
409            provider: Some({
410                let mut providers = HashMap::new();
411                providers.insert(
412                    "anthropic".to_string(),
413                    ProviderConfig {
414                        models: Some(project_models),
415                        ..Default::default()
416                    },
417                );
418                providers
419            }),
420            ..Default::default()
421        };
422
423        let merged = merge_two(global, project);
424        let providers = merged.provider.unwrap();
425        let anthropic = providers.get("anthropic").unwrap();
426        // Should have both models (deep merge)
427        assert!(
428            anthropic
429                .models
430                .as_ref()
431                .unwrap()
432                .contains_key("claude-haiku-4-5")
433        );
434        assert!(
435            anthropic
436                .models
437                .as_ref()
438                .unwrap()
439                .contains_key("claude-sonnet-4-5")
440        );
441        // Should have options from global
442        assert!(anthropic.options.is_some());
443    }
444
445    #[test]
446    fn test_merge_configs_priority_order() {
447        let global = OpenCodeConfig {
448            model: Some("global/model".to_string()),
449            ..Default::default()
450        };
451        let project = OpenCodeConfig {
452            model: Some("project/model".to_string()),
453            ..Default::default()
454        };
455
456        let merged = merge_configs(&[global, project]);
457        assert_eq!(merged.model, Some("project/model".to_string()));
458    }
459
460    #[test]
461    fn test_merge_empty_configs() {
462        let merged = merge_configs(&[]);
463        assert_eq!(merged.model, None);
464    }
465}