Skip to main content

osp_cli/config/
resolver.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use crate::config::bootstrap::{
4    ResolutionFrame, explain_default_profile_bootstrap, explain_default_profile_key,
5    prepare_resolution,
6};
7use crate::config::explain::{
8    build_runtime_explain, explain_layers_for_runtime_key, selected_value,
9};
10use crate::config::interpolate::{explain_interpolation, interpolate_all};
11use crate::config::selector::{LayerRef, ScopeSelector, SelectedLayerEntry};
12use crate::config::{
13    BootstrapConfigExplain, ConfigError, ConfigExplain, ConfigLayer, ConfigSchema, ConfigSource,
14    ConfigValue, LoadedLayers, ResolveOptions, ResolvedConfig, ResolvedValue, Scope, is_alias_key,
15    is_bootstrap_only_key,
16};
17
18#[derive(Debug, Clone, Default)]
19pub struct ConfigResolver {
20    layers: LoadedLayers,
21    schema: ConfigSchema,
22}
23
24/// Resolution happens in two steps:
25/// 1. pick the winning raw value for each key using source + scope precedence
26/// 2. interpolate placeholders and then run schema adaptation on those winners
27#[derive(Debug, Clone)]
28struct ResolvedMaps {
29    pre_interpolated: BTreeMap<String, ResolvedValue>,
30    final_values: BTreeMap<String, ResolvedValue>,
31    alias_values: BTreeMap<String, ResolvedValue>,
32}
33
34impl ConfigResolver {
35    pub fn from_loaded_layers(layers: LoadedLayers) -> Self {
36        Self {
37            layers,
38            schema: ConfigSchema::default(),
39        }
40    }
41
42    pub fn set_schema(&mut self, schema: ConfigSchema) {
43        self.schema = schema;
44    }
45
46    pub fn schema_mut(&mut self) -> &mut ConfigSchema {
47        &mut self.schema
48    }
49
50    pub fn defaults_mut(&mut self) -> &mut ConfigLayer {
51        &mut self.layers.defaults
52    }
53
54    pub fn file_mut(&mut self) -> &mut ConfigLayer {
55        &mut self.layers.file
56    }
57
58    pub fn presentation_mut(&mut self) -> &mut ConfigLayer {
59        &mut self.layers.presentation
60    }
61
62    pub fn secrets_mut(&mut self) -> &mut ConfigLayer {
63        &mut self.layers.secrets
64    }
65
66    pub fn env_mut(&mut self) -> &mut ConfigLayer {
67        &mut self.layers.env
68    }
69
70    pub fn cli_mut(&mut self) -> &mut ConfigLayer {
71        &mut self.layers.cli
72    }
73
74    pub fn session_mut(&mut self) -> &mut ConfigLayer {
75        &mut self.layers.session
76    }
77
78    pub fn set_defaults(&mut self, layer: ConfigLayer) {
79        self.layers.defaults = layer;
80    }
81
82    pub fn set_file(&mut self, layer: ConfigLayer) {
83        self.layers.file = layer;
84    }
85
86    pub fn set_presentation(&mut self, layer: ConfigLayer) {
87        self.layers.presentation = layer;
88    }
89
90    pub fn set_secrets(&mut self, layer: ConfigLayer) {
91        self.layers.secrets = layer;
92    }
93
94    pub fn set_env(&mut self, layer: ConfigLayer) {
95        self.layers.env = layer;
96    }
97
98    pub fn set_cli(&mut self, layer: ConfigLayer) {
99        self.layers.cli = layer;
100    }
101
102    pub fn set_session(&mut self, layer: ConfigLayer) {
103        self.layers.session = layer;
104    }
105
106    pub fn resolve(&self, options: ResolveOptions) -> Result<ResolvedConfig, ConfigError> {
107        tracing::debug!(
108            profile_override = ?options.profile_override,
109            terminal = ?options.terminal,
110            "resolving config"
111        );
112        let frame = prepare_resolution(self.layers(), options)?;
113        let resolved = self.resolve_maps_for_frame(&frame)?;
114        let config = ResolvedConfig {
115            active_profile: frame.active_profile,
116            terminal: frame.terminal,
117            known_profiles: frame.known_profiles,
118            values: resolved.final_values,
119            aliases: resolved.alias_values,
120        };
121        tracing::debug!(
122            active_profile = %config.active_profile(),
123            terminal = ?config.terminal(),
124            values = config.values().len(),
125            aliases = config.aliases().len(),
126            "resolved config"
127        );
128        Ok(config)
129    }
130
131    pub fn explain_key(
132        &self,
133        key: &str,
134        options: ResolveOptions,
135    ) -> Result<ConfigExplain, ConfigError> {
136        if key.eq_ignore_ascii_case("profile.default") {
137            return explain_default_profile_key(self.layers(), options);
138        }
139
140        let frame = prepare_resolution(self.layers(), options)?;
141        let layers = explain_layers_for_runtime_key(self.layers(), key, &frame);
142        let resolved = self.resolve_maps_for_frame(&frame)?;
143        let final_entry = if is_alias_key(key) {
144            resolved.alias_values.get(key).cloned()
145        } else {
146            resolved.final_values.get(key).cloned()
147        };
148        // Explaining interpolation intentionally re-reads the pre-interpolated
149        // values so the trace shows the original placeholder chain rather than
150        // the already-expanded end state.
151        let interpolation =
152            explain_interpolation(key, &resolved.pre_interpolated, &resolved.final_values)?;
153
154        Ok(build_runtime_explain(
155            key,
156            frame,
157            layers,
158            final_entry,
159            if is_alias_key(key) {
160                None
161            } else {
162                interpolation
163            },
164        ))
165    }
166
167    pub fn explain_bootstrap_key(
168        &self,
169        key: &str,
170        options: ResolveOptions,
171    ) -> Result<BootstrapConfigExplain, ConfigError> {
172        if key.eq_ignore_ascii_case("profile.default") {
173            return explain_default_profile_bootstrap(self.layers(), options);
174        }
175
176        Err(ConfigError::InvalidConfigKey {
177            key: key.to_string(),
178            reason: "not a bootstrap key".to_string(),
179        })
180    }
181
182    /// Run the resolver's two actual phases:
183    /// 1. select the winning raw value for each key
184    /// 2. interpolate/adapt those winners into final values
185    fn resolve_maps_for_frame(&self, frame: &ResolutionFrame) -> Result<ResolvedMaps, ConfigError> {
186        tracing::trace!(
187            active_profile = %frame.active_profile,
188            terminal = ?frame.terminal,
189            "resolving config maps for frame"
190        );
191        let mut pre_interpolated = self.collect_selected_values_for_frame(frame);
192        // Aliases are selected with the same precedence rules so explain can
193        // still show their winning raw source, but they stay out of ordinary
194        // runtime interpolation and schema validation.
195        let alias_values = Self::drain_alias_values(&mut pre_interpolated);
196        // Keep both snapshots: normal resolution only needs `final_values`, but
197        // `config explain` needs the selected raw winners alongside the final
198        // interpolated/adapted view.
199        let mut final_values = pre_interpolated.clone();
200        interpolate_all(&mut final_values)?;
201        self.schema.validate_and_adapt(&mut final_values)?;
202
203        tracing::trace!(
204            pre_interpolated = pre_interpolated.len(),
205            final_values = final_values.len(),
206            aliases = alias_values.len(),
207            "resolved config maps for frame"
208        );
209        Ok(ResolvedMaps {
210            pre_interpolated,
211            final_values,
212            alias_values,
213        })
214    }
215
216    /// Pick one raw winner per key using source precedence + scope precedence.
217    ///
218    /// Interpolation is intentionally excluded here; this map is the exact
219    /// input to the later placeholder-expansion pass.
220    fn collect_selected_values_for_frame(
221        &self,
222        frame: &ResolutionFrame,
223    ) -> BTreeMap<String, ResolvedValue> {
224        let selector = ScopeSelector::scoped(&frame.active_profile, frame.terminal.as_deref());
225        let keys = self.collect_keys();
226
227        let mut values = BTreeMap::new();
228        for key in keys {
229            if is_bootstrap_only_key(&key) {
230                continue;
231            }
232            if let Some(selected) = self.select_across_layers(&key, selector) {
233                values.insert(key, selected_value(&selected));
234            }
235        }
236
237        values.insert(
238            "profile.active".to_string(),
239            Self::derived_active_profile_value(frame),
240        );
241
242        values
243    }
244
245    /// Expose the chosen profile as a normal resolved value so later schema
246    /// defaults/interpolation can refer to it without special-case APIs.
247    fn derived_active_profile_value(frame: &ResolutionFrame) -> ResolvedValue {
248        ResolvedValue {
249            raw_value: ConfigValue::String(frame.active_profile.to_string()),
250            value: ConfigValue::String(frame.active_profile.to_string()),
251            source: ConfigSource::Derived,
252            scope: Scope::global(),
253            origin: None,
254        }
255    }
256
257    fn collect_keys(&self) -> BTreeSet<String> {
258        let mut keys = BTreeSet::new();
259
260        for layer in self.layers() {
261            for entry in &layer.layer.entries {
262                keys.insert(entry.key.clone());
263            }
264        }
265
266        keys
267    }
268
269    fn drain_alias_values(
270        values: &mut BTreeMap<String, ResolvedValue>,
271    ) -> BTreeMap<String, ResolvedValue> {
272        let alias_keys = values
273            .keys()
274            .filter(|key| is_alias_key(key))
275            .cloned()
276            .collect::<Vec<_>>();
277        let mut aliases = BTreeMap::new();
278        for key in alias_keys {
279            if let Some(value) = values.remove(&key) {
280                aliases.insert(key, value);
281            }
282        }
283        aliases
284    }
285
286    fn select_across_layers<'a>(
287        &'a self,
288        key: &str,
289        selector: ScopeSelector<'a>,
290    ) -> Option<SelectedLayerEntry<'a>> {
291        let mut selected: Option<SelectedLayerEntry<'a>> = None;
292
293        // Layers are returned in ascending priority order, so later matches
294        // intentionally overwrite earlier ones.
295        for layer in self.layers() {
296            if let Some(entry) = selector.select(layer, key) {
297                if let Some(previous) = &selected {
298                    tracing::trace!(
299                        key = %key,
300                        previous_source = ?previous.source,
301                        next_source = ?entry.source,
302                        "config key winner changed across layers"
303                    );
304                }
305                selected = Some(entry);
306            }
307        }
308
309        selected
310    }
311
312    fn layers(&self) -> [LayerRef<'_>; 7] {
313        // Keep this order in ascending priority so later layers can override
314        // earlier ones in `select_across_layers()`.
315        [
316            LayerRef {
317                source: ConfigSource::BuiltinDefaults,
318                layer: &self.layers.defaults,
319            },
320            LayerRef {
321                source: ConfigSource::PresentationDefaults,
322                layer: &self.layers.presentation,
323            },
324            LayerRef {
325                source: ConfigSource::ConfigFile,
326                layer: &self.layers.file,
327            },
328            LayerRef {
329                source: ConfigSource::Secrets,
330                layer: &self.layers.secrets,
331            },
332            LayerRef {
333                source: ConfigSource::Environment,
334                layer: &self.layers.env,
335            },
336            LayerRef {
337                source: ConfigSource::Cli,
338                layer: &self.layers.cli,
339            },
340            LayerRef {
341                source: ConfigSource::Session,
342                layer: &self.layers.session,
343            },
344        ]
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::ConfigResolver;
351    use crate::config::{ConfigError, ConfigLayer, ResolveOptions};
352
353    #[test]
354    fn resolver_layer_mutators_and_setters_are_callable_unit() {
355        let mut resolver = ConfigResolver::default();
356        resolver.defaults_mut().set("profile.default", "default");
357        resolver.file_mut().set("theme.name", "file");
358        resolver.secrets_mut().set("profile.default", "default");
359        resolver.env_mut().set("theme.name", "env");
360        resolver.cli_mut().set("theme.name", "cli");
361        resolver.session_mut().set("theme.name", "session");
362
363        let resolved = resolver
364            .resolve(ResolveOptions::default().with_terminal("cli"))
365            .expect("resolver should resolve");
366        assert_eq!(resolved.get_string("theme.name"), Some("session"));
367        assert_eq!(resolved.active_profile(), "default");
368
369        let mut replacement = ConfigLayer::default();
370        replacement.set("profile.default", "default");
371        replacement.set("theme.name", "replaced");
372        resolver.set_defaults(replacement);
373        resolver.set_file(ConfigLayer::default());
374        resolver.set_secrets(ConfigLayer::default());
375        resolver.set_env(ConfigLayer::default());
376        resolver.set_cli(ConfigLayer::default());
377        resolver.set_session(ConfigLayer::default());
378
379        let replaced = resolver
380            .resolve(ResolveOptions::default().with_terminal("cli"))
381            .expect("replacement config should resolve");
382        assert_eq!(replaced.get_string("theme.name"), Some("replaced"));
383    }
384
385    #[test]
386    fn explain_bootstrap_key_rejects_non_bootstrap_keys_unit() {
387        let resolver = ConfigResolver::default();
388        let err = resolver
389            .explain_bootstrap_key("ui.theme", ResolveOptions::default())
390            .expect_err("non-bootstrap key should fail");
391
392        assert!(matches!(
393            err,
394            ConfigError::InvalidConfigKey { key, .. } if key == "ui.theme"
395        ));
396    }
397}