ricecoder_storage/config/
merge.rs

1//! Configuration merging with precedence rules
2//!
3//! This module provides configuration merging with the following precedence:
4//! environment > project > legacy > global > defaults
5
6use super::Config;
7use tracing::debug;
8
9/// Configuration merger
10pub struct ConfigMerger;
11
12/// Merge decision for logging
13#[derive(Debug, Clone)]
14pub struct MergeDecision {
15    /// The key that was merged
16    pub key: String,
17    /// The source of the value
18    pub source: String,
19    /// The value that was applied
20    pub value: String,
21}
22
23impl ConfigMerger {
24    /// Merge configurations with precedence rules
25    ///
26    /// Precedence: env > project > legacy > global > defaults
27    ///
28    /// Returns the merged configuration and a list of merge decisions for logging.
29    pub fn merge(
30        defaults: Config,
31        global: Option<Config>,
32        project: Option<Config>,
33        env_overrides: Option<Config>,
34    ) -> (Config, Vec<MergeDecision>) {
35        let mut decisions = Vec::new();
36        let mut result = defaults;
37
38        // Apply global config
39        if let Some(global_config) = global {
40            Self::merge_into(&mut result, &global_config, "global", &mut decisions);
41        }
42
43        // Apply project config (overrides global)
44        if let Some(project_config) = project {
45            Self::merge_into(&mut result, &project_config, "project", &mut decisions);
46        }
47
48        // Apply environment overrides (highest priority)
49        if let Some(env_config) = env_overrides {
50            Self::merge_into(&mut result, &env_config, "environment", &mut decisions);
51        }
52
53        // Log merge decisions
54        for decision in &decisions {
55            debug!(
56                key = %decision.key,
57                source = %decision.source,
58                value = %decision.value,
59                "Configuration merged"
60            );
61        }
62
63        (result, decisions)
64    }
65
66    /// Merge one configuration into another
67    fn merge_into(
68        target: &mut Config,
69        source: &Config,
70        source_name: &str,
71        decisions: &mut Vec<MergeDecision>,
72    ) {
73        // Merge providers
74        if let Some(ref provider) = source.providers.default_provider {
75            if target.providers.default_provider != source.providers.default_provider {
76                decisions.push(MergeDecision {
77                    key: "providers.default_provider".to_string(),
78                    source: source_name.to_string(),
79                    value: provider.clone(),
80                });
81                target.providers.default_provider = Some(provider.clone());
82            }
83        }
84
85        for (key, value) in &source.providers.api_keys {
86            if !target.providers.api_keys.contains_key(key) {
87                decisions.push(MergeDecision {
88                    key: format!("providers.api_keys.{}", key),
89                    source: source_name.to_string(),
90                    value: value.clone(),
91                });
92            }
93            target.providers.api_keys.insert(key.clone(), value.clone());
94        }
95
96        for (key, value) in &source.providers.endpoints {
97            if !target.providers.endpoints.contains_key(key) {
98                decisions.push(MergeDecision {
99                    key: format!("providers.endpoints.{}", key),
100                    source: source_name.to_string(),
101                    value: value.clone(),
102                });
103            }
104            target
105                .providers
106                .endpoints
107                .insert(key.clone(), value.clone());
108        }
109
110        // Merge defaults
111        if let Some(ref model) = source.defaults.model {
112            if target.defaults.model != source.defaults.model {
113                decisions.push(MergeDecision {
114                    key: "defaults.model".to_string(),
115                    source: source_name.to_string(),
116                    value: model.clone(),
117                });
118                target.defaults.model = Some(model.clone());
119            }
120        }
121
122        if let Some(temp) = source.defaults.temperature {
123            if target.defaults.temperature != source.defaults.temperature {
124                decisions.push(MergeDecision {
125                    key: "defaults.temperature".to_string(),
126                    source: source_name.to_string(),
127                    value: temp.to_string(),
128                });
129                target.defaults.temperature = Some(temp);
130            }
131        }
132
133        if let Some(tokens) = source.defaults.max_tokens {
134            if target.defaults.max_tokens != source.defaults.max_tokens {
135                decisions.push(MergeDecision {
136                    key: "defaults.max_tokens".to_string(),
137                    source: source_name.to_string(),
138                    value: tokens.to_string(),
139                });
140                target.defaults.max_tokens = Some(tokens);
141            }
142        }
143
144        // Merge steering
145        for rule in &source.steering {
146            if !target.steering.iter().any(|r| r.name == rule.name) {
147                decisions.push(MergeDecision {
148                    key: format!("steering.{}", rule.name),
149                    source: source_name.to_string(),
150                    value: format!("{} bytes", rule.content.len()),
151                });
152                target.steering.push(rule.clone());
153            }
154        }
155
156        // Merge custom settings
157        for (key, value) in &source.custom {
158            if !target.custom.contains_key(key) {
159                decisions.push(MergeDecision {
160                    key: key.clone(),
161                    source: source_name.to_string(),
162                    value: value.to_string(),
163                });
164            }
165            target.custom.insert(key.clone(), value.clone());
166        }
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn test_merge_global_into_defaults() {
176        let defaults = Config::default();
177        let mut global = Config::default();
178        global.defaults.model = Some("gpt-4".to_string());
179
180        let (result, decisions) = ConfigMerger::merge(defaults, Some(global), None, None);
181
182        assert_eq!(result.defaults.model, Some("gpt-4".to_string()));
183        assert_eq!(decisions.len(), 1);
184        assert_eq!(decisions[0].source, "global");
185    }
186
187    #[test]
188    fn test_merge_project_overrides_global() {
189        let defaults = Config::default();
190        let mut global = Config::default();
191        global.defaults.model = Some("gpt-4".to_string());
192
193        let mut project = Config::default();
194        project.defaults.model = Some("gpt-3.5".to_string());
195
196        let (result, decisions) = ConfigMerger::merge(defaults, Some(global), Some(project), None);
197
198        assert_eq!(result.defaults.model, Some("gpt-3.5".to_string()));
199        // Should have 2 decisions: one for global, one for project override
200        assert!(decisions.iter().any(|d| d.source == "project"));
201    }
202
203    #[test]
204    fn test_merge_env_overrides_all() {
205        let defaults = Config::default();
206        let mut global = Config::default();
207        global.defaults.model = Some("gpt-4".to_string());
208
209        let mut env = Config::default();
210        env.defaults.model = Some("gpt-3.5-turbo".to_string());
211
212        let (result, decisions) = ConfigMerger::merge(defaults, Some(global), None, Some(env));
213
214        assert_eq!(result.defaults.model, Some("gpt-3.5-turbo".to_string()));
215        assert!(decisions.iter().any(|d| d.source == "environment"));
216    }
217
218    #[test]
219    fn test_merge_api_keys() {
220        let defaults = Config::default();
221        let mut global = Config::default();
222        global
223            .providers
224            .api_keys
225            .insert("openai".to_string(), "key1".to_string());
226
227        let mut project = Config::default();
228        project
229            .providers
230            .api_keys
231            .insert("anthropic".to_string(), "key2".to_string());
232
233        let (result, _) = ConfigMerger::merge(defaults, Some(global), Some(project), None);
234
235        assert_eq!(
236            result.providers.api_keys.get("openai"),
237            Some(&"key1".to_string())
238        );
239        assert_eq!(
240            result.providers.api_keys.get("anthropic"),
241            Some(&"key2".to_string())
242        );
243    }
244
245    #[test]
246    fn test_merge_decisions_logged() {
247        let defaults = Config::default();
248        let mut global = Config::default();
249        global.defaults.model = Some("gpt-4".to_string());
250        global.defaults.temperature = Some(0.7);
251
252        let (_, decisions) = ConfigMerger::merge(defaults, Some(global), None, None);
253
254        assert_eq!(decisions.len(), 2);
255        assert!(decisions.iter().any(|d| d.key == "defaults.model"));
256        assert!(decisions.iter().any(|d| d.key == "defaults.temperature"));
257    }
258
259    #[test]
260    fn test_merge_no_duplicate_decisions() {
261        let defaults = Config::default();
262        let mut global = Config::default();
263        global.defaults.model = Some("gpt-4".to_string());
264
265        let mut project = Config::default();
266        project.defaults.model = Some("gpt-4".to_string()); // Same as global
267
268        let (_, decisions) = ConfigMerger::merge(defaults, Some(global), Some(project), None);
269
270        // Should only have one decision for model (from global), not from project
271        let model_decisions: Vec<_> = decisions
272            .iter()
273            .filter(|d| d.key == "defaults.model")
274            .collect();
275        assert_eq!(model_decisions.len(), 1);
276    }
277}