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/// Resolves layered config input into the runtime view seen by the rest of the
19/// application.
20///
21/// Callers usually populate the individual source layers first and then ask
22/// the resolver for either the final runtime view or an explanation trace.
23///
24/// High-level flow:
25///
26/// - select one winning raw value per key using source and scope precedence
27/// - interpolate placeholders inside the selected winners
28/// - adapt and validate the interpolated values against the schema
29/// - optionally expose an explanation trace that shows why each winner won
30///
31/// Contract:
32///
33/// - precedence rules live here, not in callers
34/// - schema adaptation happens after winner selection, not while scanning layers
35/// - bootstrap handling stays aligned with the config bootstrap helpers rather
36///   than becoming a separate merge system
37#[derive(Debug, Clone, Default)]
38pub struct ConfigResolver {
39    layers: LoadedLayers,
40    schema: ConfigSchema,
41}
42
43/// Resolution happens in two steps:
44/// 1. pick the winning raw value for each key using source + scope precedence
45/// 2. interpolate placeholders and then run schema adaptation on those winners
46#[derive(Debug, Clone)]
47struct ResolvedMaps {
48    pre_interpolated: BTreeMap<String, ResolvedValue>,
49    final_values: BTreeMap<String, ResolvedValue>,
50    alias_values: BTreeMap<String, ResolvedValue>,
51}
52
53impl ConfigResolver {
54    /// Creates a resolver from pre-loaded config layers.
55    pub fn from_loaded_layers(layers: LoadedLayers) -> Self {
56        Self {
57            layers,
58            schema: ConfigSchema::default(),
59        }
60    }
61
62    /// Replaces the schema used for validation and adaptation.
63    pub fn set_schema(&mut self, schema: ConfigSchema) {
64        self.schema = schema;
65    }
66
67    /// Returns mutable access to the active schema.
68    pub fn schema_mut(&mut self) -> &mut ConfigSchema {
69        &mut self.schema
70    }
71
72    /// Returns mutable access to the built-in defaults layer.
73    pub fn defaults_mut(&mut self) -> &mut ConfigLayer {
74        &mut self.layers.defaults
75    }
76
77    /// Returns mutable access to the config file layer.
78    pub fn file_mut(&mut self) -> &mut ConfigLayer {
79        &mut self.layers.file
80    }
81
82    /// Returns mutable access to the presentation defaults layer.
83    pub fn presentation_mut(&mut self) -> &mut ConfigLayer {
84        &mut self.layers.presentation
85    }
86
87    /// Returns mutable access to the secrets layer.
88    pub fn secrets_mut(&mut self) -> &mut ConfigLayer {
89        &mut self.layers.secrets
90    }
91
92    /// Returns mutable access to the environment layer.
93    pub fn env_mut(&mut self) -> &mut ConfigLayer {
94        &mut self.layers.env
95    }
96
97    /// Returns mutable access to the CLI layer.
98    pub fn cli_mut(&mut self) -> &mut ConfigLayer {
99        &mut self.layers.cli
100    }
101
102    /// Returns mutable access to the session layer.
103    pub fn session_mut(&mut self) -> &mut ConfigLayer {
104        &mut self.layers.session
105    }
106
107    /// Replaces the built-in defaults layer.
108    pub fn set_defaults(&mut self, layer: ConfigLayer) {
109        self.layers.defaults = layer;
110    }
111
112    /// Replaces the config file layer.
113    pub fn set_file(&mut self, layer: ConfigLayer) {
114        self.layers.file = layer;
115    }
116
117    /// Replaces the presentation defaults layer.
118    pub fn set_presentation(&mut self, layer: ConfigLayer) {
119        self.layers.presentation = layer;
120    }
121
122    /// Replaces the secrets layer.
123    pub fn set_secrets(&mut self, layer: ConfigLayer) {
124        self.layers.secrets = layer;
125    }
126
127    /// Replaces the environment layer.
128    pub fn set_env(&mut self, layer: ConfigLayer) {
129        self.layers.env = layer;
130    }
131
132    /// Replaces the CLI layer.
133    pub fn set_cli(&mut self, layer: ConfigLayer) {
134        self.layers.cli = layer;
135    }
136
137    /// Replaces the session layer.
138    pub fn set_session(&mut self, layer: ConfigLayer) {
139        self.layers.session = layer;
140    }
141
142    /// Resolves all configured layers into the final runtime config.
143    ///
144    /// Source precedence still applies inside this API, so later layers like
145    /// session or CLI overrides can replace lower-priority defaults.
146    ///
147    /// # Examples
148    ///
149    /// ```
150    /// use osp_cli::config::{ConfigResolver, LoadedLayers, ResolveOptions};
151    ///
152    /// let mut layers = LoadedLayers::default();
153    /// layers.defaults.set("profile.default", "default");
154    /// layers.defaults.set("theme.name", "plain");
155    /// layers.session.set("theme.name", "dracula");
156    ///
157    /// let resolved = ConfigResolver::from_loaded_layers(layers)
158    ///     .resolve(ResolveOptions::default())
159    ///     .unwrap();
160    ///
161    /// assert_eq!(resolved.get_string("theme.name"), Some("dracula"));
162    /// ```
163    pub fn resolve(&self, options: ResolveOptions) -> Result<ResolvedConfig, ConfigError> {
164        tracing::debug!(
165            profile_override = ?options.profile_override,
166            terminal = ?options.terminal,
167            "resolving config"
168        );
169        let frame = prepare_resolution(self.layers(), options)?;
170        let resolved = self.resolve_maps_for_frame(&frame)?;
171        let config = ResolvedConfig {
172            active_profile: frame.active_profile,
173            terminal: frame.terminal,
174            known_profiles: frame.known_profiles,
175            values: resolved.final_values,
176            aliases: resolved.alias_values,
177        };
178        tracing::debug!(
179            active_profile = %config.active_profile(),
180            terminal = ?config.terminal(),
181            values = config.values().len(),
182            aliases = config.aliases().len(),
183            "resolved config"
184        );
185        Ok(config)
186    }
187
188    /// Explains how a runtime key was selected, interpolated, and adapted.
189    ///
190    /// The explanation keeps the raw winning value as well as the final
191    /// adapted value so callers can see where interpolation or type coercion
192    /// changed the original input.
193    ///
194    /// # Examples
195    ///
196    /// ```
197    /// use osp_cli::config::{ConfigResolver, LoadedLayers, ResolveOptions};
198    ///
199    /// let mut layers = LoadedLayers::default();
200    /// layers.defaults.set("profile.default", "default");
201    /// layers.defaults.set("theme.name", "plain");
202    /// layers.cli.set("theme.name", "dracula");
203    ///
204    /// let explain = ConfigResolver::from_loaded_layers(layers)
205    ///     .explain_key("theme.name", ResolveOptions::default())
206    ///     .unwrap();
207    ///
208    /// assert_eq!(explain.key, "theme.name");
209    /// assert_eq!(
210    ///     explain.final_entry.unwrap().value.reveal(),
211    ///     &osp_cli::config::ConfigValue::String("dracula".to_string())
212    /// );
213    /// ```
214    pub fn explain_key(
215        &self,
216        key: &str,
217        options: ResolveOptions,
218    ) -> Result<ConfigExplain, ConfigError> {
219        if key.eq_ignore_ascii_case("profile.default") {
220            return explain_default_profile_key(self.layers(), options);
221        }
222
223        let frame = prepare_resolution(self.layers(), options)?;
224        let layers = explain_layers_for_runtime_key(self.layers(), key, &frame);
225        let resolved = self.resolve_maps_for_frame(&frame)?;
226        let final_entry = if is_alias_key(key) {
227            resolved.alias_values.get(key).cloned()
228        } else {
229            resolved.final_values.get(key).cloned()
230        };
231        // Explaining interpolation intentionally re-reads the pre-interpolated
232        // values so the trace shows the original placeholder chain rather than
233        // the already-expanded end state.
234        let interpolation =
235            explain_interpolation(key, &resolved.pre_interpolated, &resolved.final_values)?;
236
237        Ok(build_runtime_explain(
238            key,
239            frame,
240            layers,
241            final_entry,
242            if is_alias_key(key) {
243                None
244            } else {
245                interpolation
246            },
247        ))
248    }
249
250    /// Explains bootstrap resolution for a bootstrap-only key.
251    pub fn explain_bootstrap_key(
252        &self,
253        key: &str,
254        options: ResolveOptions,
255    ) -> Result<BootstrapConfigExplain, ConfigError> {
256        if key.eq_ignore_ascii_case("profile.default") {
257            return explain_default_profile_bootstrap(self.layers(), options);
258        }
259
260        Err(ConfigError::InvalidConfigKey {
261            key: key.to_string(),
262            reason: "not a bootstrap key".to_string(),
263        })
264    }
265
266    /// Run the resolver's two actual phases:
267    /// 1. select the winning raw value for each key
268    /// 2. interpolate/adapt those winners into final values
269    fn resolve_maps_for_frame(&self, frame: &ResolutionFrame) -> Result<ResolvedMaps, ConfigError> {
270        tracing::trace!(
271            active_profile = %frame.active_profile,
272            terminal = ?frame.terminal,
273            "resolving config maps for frame"
274        );
275        let mut pre_interpolated = self.collect_selected_values_for_frame(frame);
276        // Aliases are selected with the same precedence rules so explain can
277        // still show their winning raw source, but they stay out of ordinary
278        // runtime interpolation and schema validation.
279        let alias_values = Self::drain_alias_values(&mut pre_interpolated);
280        // Keep both snapshots: normal resolution only needs `final_values`, but
281        // `config explain` needs the selected raw winners alongside the final
282        // interpolated/adapted view.
283        let mut final_values = pre_interpolated.clone();
284        interpolate_all(&mut final_values)?;
285        self.schema.validate_and_adapt(&mut final_values)?;
286
287        tracing::trace!(
288            pre_interpolated = pre_interpolated.len(),
289            final_values = final_values.len(),
290            aliases = alias_values.len(),
291            "resolved config maps for frame"
292        );
293        Ok(ResolvedMaps {
294            pre_interpolated,
295            final_values,
296            alias_values,
297        })
298    }
299
300    /// Pick one raw winner per key using source precedence + scope precedence.
301    ///
302    /// Interpolation is intentionally excluded here; this map is the exact
303    /// input to the later placeholder-expansion pass.
304    fn collect_selected_values_for_frame(
305        &self,
306        frame: &ResolutionFrame,
307    ) -> BTreeMap<String, ResolvedValue> {
308        let selector = ScopeSelector::scoped(&frame.active_profile, frame.terminal.as_deref());
309        let keys = self.collect_keys();
310
311        let mut values = BTreeMap::new();
312        for key in keys {
313            if is_bootstrap_only_key(&key) {
314                continue;
315            }
316            if let Some(selected) = self.select_across_layers(&key, selector) {
317                values.insert(key, selected_value(&selected));
318            }
319        }
320
321        values.insert(
322            "profile.active".to_string(),
323            Self::derived_active_profile_value(frame),
324        );
325
326        values
327    }
328
329    /// Expose the chosen profile as a normal resolved value so later schema
330    /// defaults/interpolation can refer to it without special-case APIs.
331    fn derived_active_profile_value(frame: &ResolutionFrame) -> ResolvedValue {
332        ResolvedValue {
333            raw_value: ConfigValue::String(frame.active_profile.to_string()),
334            value: ConfigValue::String(frame.active_profile.to_string()),
335            source: ConfigSource::Derived,
336            scope: Scope::global(),
337            origin: None,
338        }
339    }
340
341    fn collect_keys(&self) -> BTreeSet<String> {
342        let mut keys = BTreeSet::new();
343
344        for layer in self.layers() {
345            for entry in &layer.layer.entries {
346                keys.insert(entry.key.clone());
347            }
348        }
349
350        keys
351    }
352
353    fn drain_alias_values(
354        values: &mut BTreeMap<String, ResolvedValue>,
355    ) -> BTreeMap<String, ResolvedValue> {
356        let alias_keys = values
357            .keys()
358            .filter(|key| is_alias_key(key))
359            .cloned()
360            .collect::<Vec<_>>();
361        let mut aliases = BTreeMap::new();
362        for key in alias_keys {
363            if let Some(value) = values.remove(&key) {
364                aliases.insert(key, value);
365            }
366        }
367        aliases
368    }
369
370    fn select_across_layers<'a>(
371        &'a self,
372        key: &str,
373        selector: ScopeSelector<'a>,
374    ) -> Option<SelectedLayerEntry<'a>> {
375        let mut selected: Option<SelectedLayerEntry<'a>> = None;
376
377        // Layers are returned in ascending priority order, so later matches
378        // intentionally overwrite earlier ones.
379        for layer in self.layers() {
380            if let Some(entry) = selector.select(layer, key) {
381                if let Some(previous) = &selected {
382                    if should_preserve_selected_secret(previous, &entry) {
383                        tracing::trace!(
384                            key = %key,
385                            secret_origin = ?previous.entry.origin,
386                            env_origin = ?entry.entry.origin,
387                            "preserving secret env override over plain env value"
388                        );
389                        continue;
390                    }
391                    tracing::trace!(
392                        key = %key,
393                        previous_source = ?previous.source,
394                        next_source = ?entry.source,
395                        "config key winner changed across layers"
396                    );
397                }
398                selected = Some(entry);
399            }
400        }
401
402        selected
403    }
404
405    fn layers(&self) -> [LayerRef<'_>; 7] {
406        // Keep this order in ascending priority so later layers can override
407        // earlier ones in `select_across_layers()`.
408        [
409            LayerRef {
410                source: ConfigSource::BuiltinDefaults,
411                layer: &self.layers.defaults,
412            },
413            LayerRef {
414                source: ConfigSource::PresentationDefaults,
415                layer: &self.layers.presentation,
416            },
417            LayerRef {
418                source: ConfigSource::ConfigFile,
419                layer: &self.layers.file,
420            },
421            LayerRef {
422                source: ConfigSource::Secrets,
423                layer: &self.layers.secrets,
424            },
425            LayerRef {
426                source: ConfigSource::Environment,
427                layer: &self.layers.env,
428            },
429            LayerRef {
430                source: ConfigSource::Cli,
431                layer: &self.layers.cli,
432            },
433            LayerRef {
434                source: ConfigSource::Session,
435                layer: &self.layers.session,
436            },
437        ]
438    }
439}
440
441fn should_preserve_selected_secret(
442    previous: &SelectedLayerEntry<'_>,
443    next: &SelectedLayerEntry<'_>,
444) -> bool {
445    previous.source == ConfigSource::Secrets
446        && next.source == ConfigSource::Environment
447        && previous.entry.value.is_secret()
448        && previous
449            .entry
450            .origin
451            .as_deref()
452            .is_some_and(|origin| origin.starts_with("OSP_SECRET__"))
453}
454
455#[cfg(test)]
456mod tests {
457    use super::ConfigResolver;
458    use crate::config::{
459        ConfigError, ConfigLayer, ConfigSource, ConfigValue, ResolveOptions, Scope,
460    };
461
462    #[test]
463    fn resolver_layer_mutators_and_setters_are_callable_unit() {
464        let mut resolver = ConfigResolver::default();
465        resolver.defaults_mut().set("profile.default", "default");
466        resolver.file_mut().set("theme.name", "file");
467        resolver.secrets_mut().set("profile.default", "default");
468        resolver.env_mut().set("theme.name", "env");
469        resolver.cli_mut().set("theme.name", "cli");
470        resolver.session_mut().set("theme.name", "session");
471
472        let resolved = resolver
473            .resolve(ResolveOptions::default().with_terminal("cli"))
474            .expect("resolver should resolve");
475        assert_eq!(resolved.get_string("theme.name"), Some("session"));
476        assert_eq!(resolved.active_profile(), "default");
477
478        let mut replacement = ConfigLayer::default();
479        replacement.set("profile.default", "default");
480        replacement.set("theme.name", "replaced");
481        resolver.set_defaults(replacement);
482        resolver.set_file(ConfigLayer::default());
483        resolver.set_secrets(ConfigLayer::default());
484        resolver.set_env(ConfigLayer::default());
485        resolver.set_cli(ConfigLayer::default());
486        resolver.set_session(ConfigLayer::default());
487
488        let replaced = resolver
489            .resolve(ResolveOptions::default().with_terminal("cli"))
490            .expect("replacement config should resolve");
491        assert_eq!(replaced.get_string("theme.name"), Some("replaced"));
492
493        let mut resolver = ConfigResolver::default();
494        resolver.defaults_mut().set("profile.default", "default");
495        resolver.secrets_mut().insert_with_origin(
496            "extensions.demo.token",
497            ConfigValue::String("secret-token".to_string()).into_secret(),
498            Scope::global(),
499            Some("OSP_SECRET__AUTH__TOKEN"),
500        );
501        resolver.env_mut().insert_with_origin(
502            "extensions.demo.token",
503            ConfigValue::String("plain-token".to_string()),
504            Scope::global(),
505            Some("OSP__AUTH__TOKEN"),
506        );
507
508        let resolved = resolver
509            .resolve(ResolveOptions::default())
510            .expect("resolver should resolve");
511        let entry = resolved
512            .get_value_entry("extensions.demo.token")
513            .expect("extensions.demo.token should resolve");
514
515        assert!(entry.value.is_secret());
516        assert_eq!(
517            entry.value.reveal(),
518            &ConfigValue::String("secret-token".to_string())
519        );
520        assert_eq!(entry.source, ConfigSource::Secrets);
521
522        let err = ConfigResolver::default()
523            .explain_bootstrap_key("ui.theme", ResolveOptions::default())
524            .expect_err("non-bootstrap key should fail");
525        assert!(matches!(
526            err,
527            ConfigError::InvalidConfigKey { key, .. } if key == "ui.theme"
528        ));
529
530        let mut resolver = ConfigResolver::default();
531        resolver.defaults_mut().set("profile.default", "ops");
532        let resolved = resolver
533            .resolve(ResolveOptions::default())
534            .expect("selected profile without scoped entries should resolve");
535        assert_eq!(resolved.active_profile(), "ops");
536        assert!(resolved.known_profiles().contains("ops"));
537    }
538}