Skip to main content

whycodes_config/
merge.rs

1//! Layered config merge.
2
3use crate::types::{
4    BashConfig, Config, FormatConfig, MagicKeywordsConfig, QuestionToolConfig, SlopConfig,
5    ToolsConfig, default_auto_background_after_secs, default_auto_index_chunks,
6    default_auto_index_files, default_code_min_score, default_code_top_k, default_compaction_llm,
7    default_compaction_method, default_compaction_threshold, default_consolidate_max,
8    default_goal_max_turns, default_intent_guidance, default_max_background_jobs,
9    default_max_tokens, default_memory_backend, default_memory_embed_dim,
10    default_memory_index_bytes, default_memory_index_lines, default_memory_min_score,
11    default_memory_scope, default_memory_token_budget, default_memory_top_k, default_model_race,
12    default_prompt_cache, default_prompt_suggestions, default_race_after_ms,
13    default_response_cache, default_retain_every_n, default_retain_max_facts,
14    default_risk_threshold, default_sandbox_fallback, default_sandbox_mode,
15    default_session_min_score, default_session_top_k, default_stream_retry_secs,
16    default_swarm_max_agents, default_tool_profile, default_undo_depth,
17};
18use whycodes_core::types::PermissionSet;
19
20impl Config {
21    /// Deep-merge `other` into `self`, producing a new Config.
22    ///
23    /// Fields in `other` that are `Some(_)` or non-empty collections
24    /// override corresponding fields in `self`. This implements the
25    /// layering semantics where higher-priority layers win.
26    pub fn merge_with(&self, other: &Config) -> Config {
27        let mut merged = self.clone();
28        merged.merge_providers(other);
29        merged.merge_models(other);
30        merged.merge_agents(other);
31        merged.merge_defaults(other);
32        merged.merge_command_configs(other);
33        merged.tools = self.tools.merge_with(&other.tools);
34        merged.merge_session(other);
35        merged.merge_tui(other);
36        merged.merge_general(other);
37        merged.merge_servers(other);
38        merged.merge_maps(other);
39        merged.merge_security(other);
40        merged.merge_hooks(other);
41        merged.merge_memory(other);
42        merged.merge_memory_index(other);
43        merged.merge_swarm(other);
44        merged.merge_tail(other);
45        merged
46    }
47
48    fn merge_providers(&mut self, other: &Config) {
49        // Providers: merge per-provider
50        for (key, provider) in &other.providers {
51            self.providers
52                .entry(key.clone())
53                .and_modify(|existing| {
54                    if provider.api_key.is_some() {
55                        existing.api_key = provider.api_key.clone();
56                    }
57                    if provider.api_base.is_some() {
58                        existing.api_base = provider.api_base.clone();
59                    }
60                    if provider.base_url.is_some() {
61                        existing.base_url = provider.base_url.clone();
62                    }
63                    if provider.headers.is_some() {
64                        existing.headers = provider.headers.clone();
65                    }
66                    if !provider.models.is_empty() {
67                        existing.models = provider.models.clone();
68                    }
69                    if provider.tool_arguments.is_some() {
70                        existing.tool_arguments = provider.tool_arguments;
71                    }
72                    if !provider.extra.is_empty() {
73                        existing.extra = provider.extra.clone();
74                    }
75                    if !provider.credentials.is_empty() {
76                        existing.credentials = provider.credentials.clone();
77                    }
78                })
79                .or_insert_with(|| provider.clone());
80        }
81    }
82
83    fn merge_models(&mut self, other: &Config) {
84        // Models: merge per model_id+provider_id key
85        for (key, model) in &other.models {
86            self.models
87                .entry(key.clone())
88                .and_modify(|existing| {
89                    if model.max_tokens.is_some() {
90                        existing.max_tokens = model.max_tokens;
91                    }
92                    if model.context_window.is_some() {
93                        existing.context_window = model.context_window;
94                    }
95                    if model.temperature.is_some() {
96                        existing.temperature = model.temperature;
97                    }
98                    if model.top_p.is_some() {
99                        existing.top_p = model.top_p;
100                    }
101                    if model.thinking.is_some() {
102                        existing.thinking = model.thinking;
103                    }
104                    if model.supports_tools.is_some() {
105                        existing.supports_tools = model.supports_tools;
106                    }
107                    if model.supports_images.is_some() {
108                        existing.supports_images = model.supports_images;
109                    }
110                })
111                .or_insert_with(|| model.clone());
112        }
113    }
114
115    fn merge_agents(&mut self, other: &Config) {
116        // Agents: append unique (by name). A same-name agent in the higher
117        // layer only overrides the per-agent model settings it sets; the
118        // rest stays with the lower layer, so a stale copy of a built-in
119        // agent in an old `config.toml` cannot pin its prompt or tools.
120        for agent in &other.agents {
121            match self.agents.iter_mut().find(|a| a.name == agent.name) {
122                Some(existing) => {
123                    if agent.model.is_some() {
124                        existing.model = agent.model.clone();
125                    }
126                    if agent.temperature.is_some() {
127                        existing.temperature = agent.temperature;
128                    }
129                    if agent.top_p.is_some() {
130                        existing.top_p = agent.top_p;
131                    }
132                }
133                None => self.agents.push(agent.clone()),
134            }
135        }
136    }
137
138    fn merge_defaults(&mut self, other: &Config) {
139        // Default agent: a non-empty value wins. A project layer that does not
140        // set the key carries an empty one (`clear_unset_default_agent`).
141        if !other.default_agent.is_empty() {
142            self.default_agent = other.default_agent.clone();
143        }
144
145        // Default model
146        if other.default_model.is_some() {
147            self.default_model = other.default_model.clone();
148        }
149    }
150
151    fn merge_command_configs(&mut self, other: &Config) {
152        // Command configs: merge per-command
153        for (cmd, cmd_cfg) in &other.command_configs {
154            self.command_configs
155                .entry(cmd.clone())
156                .and_modify(|existing| {
157                    if cmd_cfg.model.is_some() {
158                        existing.model = cmd_cfg.model.clone();
159                    }
160                    if cmd_cfg.agent.is_some() {
161                        existing.agent = cmd_cfg.agent.clone();
162                    }
163                    if cmd_cfg.max_turns.is_some() {
164                        existing.max_turns = cmd_cfg.max_turns;
165                    }
166                })
167                .or_insert_with(|| cmd_cfg.clone());
168        }
169    }
170
171    fn merge_session(&mut self, other: &Config) {
172        // Session
173        if other.session.max_context_tokens != default_max_tokens() {
174            self.session.max_context_tokens = other.session.max_context_tokens;
175        }
176        if other.session.compaction_threshold != default_compaction_threshold() {
177            self.session.compaction_threshold = other.session.compaction_threshold;
178        }
179        if other.session.compaction_buffer_percent.is_some() {
180            self.session.compaction_buffer_percent = other.session.compaction_buffer_percent;
181        }
182        if other.session.store_path.is_some() {
183            self.session.store_path = other.session.store_path.clone();
184        }
185        // auto_title defaults true; only an explicit false in a higher layer wins
186        // when the lower layer left the default — merge by taking `other` always
187        // for bools that appear in TOML (serde always deserializes them).
188        if !other.session.auto_title {
189            self.session.auto_title = false;
190        }
191        if other.session.title_model.is_some() {
192            self.session.title_model = other.session.title_model.clone();
193        }
194        if other.session.tool_profile != default_tool_profile() {
195            self.session.tool_profile = other.session.tool_profile.clone();
196        }
197        if other.session.prompt_cache != default_prompt_cache() {
198            self.session.prompt_cache = other.session.prompt_cache.clone();
199        }
200        if other.session.cache_warm_minutes != 0 {
201            self.session.cache_warm_minutes = other.session.cache_warm_minutes;
202        }
203        if other.session.undo_depth != default_undo_depth() {
204            self.session.undo_depth = other.session.undo_depth;
205        }
206        if other.session.compaction_llm != default_compaction_llm() {
207            self.session.compaction_llm = other.session.compaction_llm.clone();
208        }
209        if other.session.compaction_method != default_compaction_method() {
210            self.session.compaction_method = other.session.compaction_method.clone();
211        }
212        if other.session.reasoning_effort.is_some() {
213            self.session.reasoning_effort = other.session.reasoning_effort.clone();
214        }
215        if other.session.model_fast.is_some() {
216            self.session.model_fast = other.session.model_fast.clone();
217        }
218        if other.session.goal_model.is_some() {
219            self.session.goal_model = other.session.goal_model.clone();
220        }
221        if other.session.goal_max_turns != default_goal_max_turns() {
222            self.session.goal_max_turns = other.session.goal_max_turns;
223        }
224        if other.session.model_smol.is_some() {
225            self.session.model_smol = other.session.model_smol.clone();
226        }
227        if other.session.model_plan.is_some() {
228            self.session.model_plan = other.session.model_plan.clone();
229        }
230        if other.session.prewalk.is_some() {
231            self.session.prewalk = other.session.prewalk;
232        }
233        if !other.session.stream_rules.is_empty() {
234            self.session.stream_rules = other.session.stream_rules.clone();
235        }
236        if other.session.model_race != default_model_race() {
237            self.session.model_race = other.session.model_race.clone();
238        }
239        if other.session.race_after_ms != default_race_after_ms() {
240            self.session.race_after_ms = other.session.race_after_ms;
241        }
242        if other.session.stream_retry_secs != default_stream_retry_secs() {
243            self.session.stream_retry_secs = other.session.stream_retry_secs;
244        }
245        if other.session.response_cache != default_response_cache() {
246            self.session.response_cache = other.session.response_cache.clone();
247        }
248        // Defaults true, like `auto_title`: an explicit false in any layer wins.
249        if !other.session.preconnect {
250            self.session.preconnect = false;
251        }
252        if other.session.intent_guidance != default_intent_guidance() {
253            self.session.intent_guidance = other.session.intent_guidance.clone();
254        }
255        if other.session.magic_keywords != MagicKeywordsConfig::default() {
256            self.session.magic_keywords = other.session.magic_keywords.clone();
257        }
258        if other.session.headless_ask.is_some() {
259            self.session.headless_ask = other.session.headless_ask;
260        }
261        if !other.session.fallback_models.is_empty() {
262            self.session.fallback_models = other.session.fallback_models.clone();
263        }
264    }
265
266    fn merge_tui(&mut self, other: &Config) {
267        // TUI
268        if other.tui.theme.is_some() {
269            self.tui.theme = other.tui.theme.clone();
270        }
271        if other.tui.key_bindings.is_some() {
272            self.tui.key_bindings = other.tui.key_bindings.clone();
273        }
274        if other.tui.prompt_suggestions != default_prompt_suggestions() {
275            self.tui.prompt_suggestions = other.tui.prompt_suggestions.clone();
276        }
277        // Higher layer can only turn the sidebar on (default is off).
278        self.tui.show_sidebar |= other.tui.show_sidebar;
279        self.tui.skip_openrouter_key_prompt |= other.tui.skip_openrouter_key_prompt;
280        for (name, spec) in &other.tui.agent_colors {
281            self.tui.agent_colors.insert(name.clone(), spec.clone());
282        }
283    }
284
285    fn merge_general(&mut self, other: &Config) {
286        // General
287        if other.general.project_path.is_some() {
288            self.general.project_path = other.general.project_path.clone();
289        }
290        if other.general.log_level.is_some() {
291            self.general.log_level = other.general.log_level.clone();
292        }
293        if other.general.default_gcp_project.is_some() {
294            self.general.default_gcp_project = other.general.default_gcp_project.clone();
295        }
296        // auto_update defaults true; only an explicit false in a higher layer wins.
297        self.general.auto_update &= other.general.auto_update;
298        if other.general.approval_mode.is_some() {
299            self.general.approval_mode = other.general.approval_mode;
300        }
301        if other.schema_version > self.schema_version {
302            self.schema_version = other.schema_version;
303        }
304    }
305
306    fn merge_servers(&mut self, other: &Config) {
307        // MCP servers: higher-priority entries override by name
308        for (name, server) in &other.mcp_servers {
309            self.mcp_servers.insert(name.clone(), server.clone());
310        }
311
312        // LSP overlay: higher-priority idle timeout wins; servers overlay by name.
313        if other.lsp.idle_timeout_ms.is_some() {
314            self.lsp.idle_timeout_ms = other.lsp.idle_timeout_ms;
315        }
316        if other.lsp.edit_diagnostics_ms.is_some() {
317            self.lsp.edit_diagnostics_ms = other.lsp.edit_diagnostics_ms;
318        }
319        for (name, server) in &other.lsp.servers {
320            self.lsp.servers.insert(name.clone(), server.clone());
321        }
322    }
323
324    fn merge_maps(&mut self, other: &Config) {
325        // Global permissions
326        for (k, v) in &other.permission {
327            self.permission.insert(k.clone(), *v);
328        }
329
330        // Custom commands
331        for (k, v) in &other.commands {
332            self.commands.insert(k.clone(), v.clone());
333        }
334
335        // Model prices (USD per million tokens)
336        for (k, v) in &other.pricing {
337            self.pricing.insert(k.clone(), *v);
338        }
339    }
340
341    fn merge_security(&mut self, other: &Config) {
342        // Security: a layer that set a non-default field overrides the one below.
343        if other.security.bash_risk_threshold != default_risk_threshold() {
344            self.security.bash_risk_threshold = other.security.bash_risk_threshold.clone();
345        }
346        if other.security.sandbox != default_sandbox_mode() {
347            self.security.sandbox = other.security.sandbox.clone();
348        }
349        if !other.security.sandbox_network {
350            self.security.sandbox_network = false;
351        }
352        if !other.security.redact_secrets {
353            self.security.redact_secrets = false;
354        }
355        if other.security.sandbox_fallback != default_sandbox_fallback() {
356            self.security.sandbox_fallback = other.security.sandbox_fallback.clone();
357        }
358        // Network lists: non-empty higher layer replaces (project can restrict).
359        if !other.security.network_allowlist.is_empty() {
360            self.security.network_allowlist = other.security.network_allowlist.clone();
361        }
362        if !other.security.network_denylist.is_empty() {
363            self.security.network_denylist = other.security.network_denylist.clone();
364        }
365    }
366
367    fn merge_hooks(&mut self, other: &Config) {
368        // Hooks: non-empty higher layer replaces (project can define its own set).
369        if !other.hooks.is_empty() {
370            self.hooks = other.hooks.clone();
371        }
372    }
373
374    fn merge_memory(&mut self, other: &Config) {
375        // Memory: higher layer wins on explicit non-default knobs; enabled can
376        // only be turned off by a higher layer (default is on).
377        if !other.memory.enabled {
378            self.memory.enabled = false;
379        }
380        if !other.memory.auto_inject {
381            self.memory.auto_inject = false;
382        }
383        if !other.memory.auto_retain {
384            self.memory.auto_retain = false;
385        }
386        if !other.memory.retain_llm {
387            self.memory.retain_llm = false;
388        }
389        if other.memory.retain_llm_always {
390            self.memory.retain_llm_always = true;
391        }
392        if other.memory.retain_every_n != default_retain_every_n() {
393            self.memory.retain_every_n = other.memory.retain_every_n;
394        }
395        if other.memory.retain_max_facts != default_retain_max_facts() {
396            self.memory.retain_max_facts = other.memory.retain_max_facts;
397        }
398        if other.memory.max_index_lines != default_memory_index_lines() {
399            self.memory.max_index_lines = other.memory.max_index_lines;
400        }
401        if other.memory.max_index_bytes != default_memory_index_bytes() {
402            self.memory.max_index_bytes = other.memory.max_index_bytes;
403        }
404        if other.memory.recall_top_k != default_memory_top_k() {
405            self.memory.recall_top_k = other.memory.recall_top_k;
406        }
407        if (other.memory.recall_min_score - default_memory_min_score()).abs() > f32::EPSILON {
408            self.memory.recall_min_score = other.memory.recall_min_score;
409        }
410        if other.memory.recall_token_budget != default_memory_token_budget() {
411            self.memory.recall_token_budget = other.memory.recall_token_budget;
412        }
413        if other.memory.embed_dim != default_memory_embed_dim() {
414            self.memory.embed_dim = other.memory.embed_dim;
415        }
416        if other.memory.scope != default_memory_scope() {
417            self.memory.scope = other.memory.scope.clone();
418        }
419        if other.memory.embed_backend != default_memory_backend() {
420            self.memory.embed_backend = other.memory.embed_backend.clone();
421        }
422    }
423
424    fn merge_memory_index(&mut self, other: &Config) {
425        if !other.memory.code_inject {
426            self.memory.code_inject = false;
427        }
428        if other.memory.code_top_k != default_code_top_k() {
429            self.memory.code_top_k = other.memory.code_top_k;
430        }
431        if (other.memory.code_min_score - default_code_min_score()).abs() > f32::EPSILON {
432            self.memory.code_min_score = other.memory.code_min_score;
433        }
434        if !other.memory.subagent_banks {
435            self.memory.subagent_banks = false;
436        }
437        if !other.memory.auto_index {
438            self.memory.auto_index = false;
439        }
440        if other.memory.auto_index_max_files != default_auto_index_files() {
441            self.memory.auto_index_max_files = other.memory.auto_index_max_files;
442        }
443        if other.memory.auto_index_max_chunks != default_auto_index_chunks() {
444            self.memory.auto_index_max_chunks = other.memory.auto_index_max_chunks;
445        }
446        if !other.memory.session_inject {
447            self.memory.session_inject = false;
448        }
449        if other.memory.session_top_k != default_session_top_k() {
450            self.memory.session_top_k = other.memory.session_top_k;
451        }
452        if (other.memory.session_min_score - default_session_min_score()).abs() > f32::EPSILON {
453            self.memory.session_min_score = other.memory.session_min_score;
454        }
455        if !other.memory.consolidate {
456            self.memory.consolidate = false;
457        }
458        if other.memory.consolidate_max != default_consolidate_max() {
459            self.memory.consolidate_max = other.memory.consolidate_max;
460        }
461    }
462
463    fn merge_swarm(&mut self, other: &Config) {
464        // Swarm: higher layer can disable; max_agents overrides when non-default.
465        if !other.swarm.enabled {
466            self.swarm.enabled = false;
467        }
468        if other.swarm.max_agents != default_swarm_max_agents() {
469            self.swarm.max_agents = other.swarm.max_agents;
470        }
471        if !other.swarm.worktrees {
472            self.swarm.worktrees = false;
473        }
474        if other.swarm.isolation.is_some() {
475            self.swarm.isolation = other.swarm.isolation.clone();
476        }
477    }
478
479    fn merge_tail(&mut self, other: &Config) {
480        if other.automation.max_background_jobs != default_max_background_jobs() {
481            self.automation.max_background_jobs = other.automation.max_background_jobs;
482        }
483
484        if other.slop != SlopConfig::default() {
485            self.slop = self.slop.merge_with(&other.slop);
486        }
487
488        self.notify = self.notify.merge_with(&other.notify);
489        self.websearch = self.websearch.merge_with(&other.websearch);
490
491        for (k, v) in &other.system_prompt_overlays.providers {
492            self.system_prompt_overlays
493                .providers
494                .insert(k.clone(), v.clone());
495        }
496        for (k, v) in &other.system_prompt_overlays.models {
497            self.system_prompt_overlays
498                .models
499                .insert(k.clone(), v.clone());
500        }
501    }
502
503    /// Merge global `permission` map into an agent's PermissionSet (agent rules win).
504    pub fn effective_permission(&self, agent: &PermissionSet) -> PermissionSet {
505        let mut out = agent.clone();
506        for (k, v) in &self.permission {
507            out.rules.entry(k.clone()).or_insert(*v);
508        }
509        // agent rules already on out.rules take precedence (entry::or_insert)
510        // but agent may have set rules that should win — re-apply agent rules last
511        for (k, v) in &agent.rules {
512            out.rules.insert(k.clone(), *v);
513        }
514        out
515    }
516}
517
518// ── ToolsConfig merging helper ────────────────────────────────────────
519
520impl ToolsConfig {
521    pub(crate) fn merge_with(&self, other: &ToolsConfig) -> ToolsConfig {
522        let mut merged = self.clone();
523
524        // Boolean flags: if other was explicitly deserialized (non-default),
525        // the value from other takes priority. Since we can't determine
526        // "was it explicitly set?" without a sentinel, we use a heuristic:
527        // if other differs from the default, take other's value.
528        let defaults = ToolsConfig::default();
529        if other.enable_read != defaults.enable_read {
530            merged.enable_read = other.enable_read;
531        }
532        if other.enable_write != defaults.enable_write {
533            merged.enable_write = other.enable_write;
534        }
535        if other.enable_edit != defaults.enable_edit {
536            merged.enable_edit = other.enable_edit;
537        }
538        if other.enable_glob != defaults.enable_glob {
539            merged.enable_glob = other.enable_glob;
540        }
541        if other.enable_grep != defaults.enable_grep {
542            merged.enable_grep = other.enable_grep;
543        }
544        if other.enable_shell != defaults.enable_shell {
545            merged.enable_shell = other.enable_shell;
546        }
547        if other.enable_webfetch != defaults.enable_webfetch {
548            merged.enable_webfetch = other.enable_webfetch;
549        }
550        if other.enable_websearch != defaults.enable_websearch {
551            merged.enable_websearch = other.enable_websearch;
552        }
553
554        let qdef = QuestionToolConfig::default();
555        if other.question.timeout_enabled != qdef.timeout_enabled {
556            merged.question.timeout_enabled = other.question.timeout_enabled;
557        }
558        if other.question.timeout_secs != qdef.timeout_secs {
559            merged.question.timeout_secs = other.question.timeout_secs;
560        }
561
562        let bdef = BashConfig::default();
563        if other.bash.auto_background != bdef.auto_background {
564            merged.bash.auto_background = other.bash.auto_background;
565        }
566        if other.bash.auto_background_after_secs != default_auto_background_after_secs() {
567            merged.bash.auto_background_after_secs = other.bash.auto_background_after_secs;
568        }
569        if other.format != FormatConfig::default() {
570            merged.format = other.format.clone();
571        }
572
573        if !other.disabled_tools.is_empty() {
574            merged.disabled_tools = other.disabled_tools.clone();
575        }
576        for (name, tool) in &other.custom_tools {
577            merged
578                .custom_tools
579                .entry(name.clone())
580                .or_insert_with(|| tool.clone());
581        }
582
583        merged
584    }
585}
586
587#[cfg(test)]
588mod tests {
589    use crate::types::Config;
590
591    #[test]
592    fn merge_overrides_default_agent_and_model() {
593        let base = Config::default();
594        let other = Config {
595            default_agent: "review".into(),
596            default_model: Some(whycodes_core::types::ModelConfig {
597                model_id: "m".into(),
598                provider_id: "p".into(),
599                max_tokens: Some(1),
600                context_window: None,
601                temperature: None,
602                top_p: None,
603                thinking: None,
604                supports_tools: None,
605                supports_images: None,
606            }),
607            ..Config::default()
608        };
609        let merged = base.merge_with(&other);
610        assert_eq!(merged.default_agent, "review");
611        assert_eq!(merged.default_model.as_ref().unwrap().model_id, "m");
612    }
613}