1use super::schema::OpenCodeConfig;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum MergeStrategy {
23 Override,
25 FillMissing,
27}
28
29pub 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
42pub fn merge_two(lower: OpenCodeConfig, higher: OpenCodeConfig) -> OpenCodeConfig {
46 let mut result = lower;
47
48 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 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 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 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 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
105fn 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
115fn 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 l.insert(key, lower_val.merge(higher_val));
134 }
135 None => {
136 l.insert(key, higher_val);
138 }
139 }
140 }
141 Some(l)
142 }
143 }
144}
145
146fn 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 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
183pub trait Mergeable: Sized {
185 fn merge(self, other: Self) -> Self;
187}
188
189use 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 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 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}