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