Skip to main content

osp_cli/config/
runtime.rs

1//! Runtime-facing config defaults, path discovery, and loader-pipeline
2//! assembly.
3//!
4//! This module exists to bridge the full layered config system into the smaller
5//! runtime surfaces the app actually needs at startup.
6//!
7//! High-level flow:
8//!
9//! - define stable default values and path-discovery rules
10//! - discover runtime config file locations from the current environment
11//! - assemble the standard loader pipeline used by the host
12//! - lower resolved config into the compact [`RuntimeConfig`] view used by
13//!   callers that do not need the full explanation surface
14//!
15//! Contract:
16//!
17//! - this module may depend on config loaders and resolved config types
18//! - it should not reimplement precedence rules already owned by the resolver
19//! - callers should use this module for runtime bootstrap wiring instead of
20//!   inventing their own config path and default logic
21//!
22//! Public API shape:
23//!
24//! - small bootstrap toggles like [`RuntimeLoadOptions`] use direct
25//!   constructor/`with_*` methods
26//! - discovered path/default snapshots stay plain data
27//! - loader-pipeline assembly stays centralized here so callers do not invent
28//!   incompatible bootstrap rules
29
30use std::{collections::BTreeMap, path::PathBuf};
31
32use directories::{BaseDirs, ProjectDirs};
33
34use crate::config::{
35    ChainedLoader, ConfigLayer, DEFAULT_PROFILE_NAME, EnvSecretsLoader, EnvVarLoader,
36    LoaderPipeline, ResolvedConfig, SecretsTomlLoader, StaticLayerLoader, TomlFileLoader,
37    build_builtin_defaults,
38};
39
40const PROJECT_APPLICATION_NAME: &str = "osp";
41
42/// Options that control which runtime config sources are included.
43///
44/// # Examples
45///
46/// ```
47/// use osp_cli::config::{RuntimeBootstrapMode, RuntimeLoadOptions};
48///
49/// let options = RuntimeLoadOptions::default();
50///
51/// assert!(options.include_env);
52/// assert!(options.include_config_file);
53/// assert_eq!(options.bootstrap_mode, RuntimeBootstrapMode::Standard);
54/// ```
55///
56/// When callers need a sealed bootstrap path with no environment variables,
57/// file loading, or home/XDG-derived discovery, use
58/// [`RuntimeLoadOptions::defaults_only`].
59///
60/// ```
61/// use osp_cli::config::{RuntimeBootstrapMode, RuntimeLoadOptions};
62///
63/// let options = RuntimeLoadOptions::defaults_only();
64///
65/// assert!(!options.include_env);
66/// assert!(!options.include_config_file);
67/// assert_eq!(options.bootstrap_mode, RuntimeBootstrapMode::DefaultsOnly);
68/// ```
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum RuntimeBootstrapMode {
71    /// Use the crate's normal environment and platform-derived bootstrap
72    /// behavior.
73    Standard,
74    /// Use only built-in defaults plus explicit in-memory inputs.
75    ///
76    /// This disables environment-derived defaults, HOME/XDG path discovery,
77    /// config-file and secrets-file lookup, and env/path override discovery.
78    DefaultsOnly,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82#[non_exhaustive]
83#[must_use = "RuntimeLoadOptions builder-style methods return an updated value"]
84/// Source-loading policy for runtime config bootstrap.
85///
86/// This is the caller-facing switchboard for how much ambient process state the
87/// normal runtime bootstrap path may consult before config resolution runs.
88///
89/// Use [`RuntimeLoadOptions::new`] for the normal host path, then selectively
90/// disable environment or config-file inputs for test or embedding scenarios.
91/// Use [`RuntimeLoadOptions::defaults_only`] when the goal is a sealed config
92/// bootstrap that ignores environment-derived defaults and path discovery.
93pub struct RuntimeLoadOptions {
94    /// Whether environment-derived layers should be loaded.
95    pub include_env: bool,
96    /// Whether the ordinary config file should be loaded.
97    ///
98    /// This does not disable the secrets layer; secrets files and secret
99    /// environment overrides still participate through the secrets pipeline.
100    pub include_config_file: bool,
101    /// Controls whether bootstrap may consult ambient environment and
102    /// platform-derived paths before the loader pipeline runs.
103    pub bootstrap_mode: RuntimeBootstrapMode,
104}
105
106impl Default for RuntimeLoadOptions {
107    fn default() -> Self {
108        Self {
109            include_env: true,
110            include_config_file: true,
111            bootstrap_mode: RuntimeBootstrapMode::Standard,
112        }
113    }
114}
115
116impl RuntimeLoadOptions {
117    /// Creates runtime-load options with the default source set enabled.
118    pub fn new() -> Self {
119        Self::default()
120    }
121
122    /// Creates a sealed bootstrap policy that uses only built-in defaults plus
123    /// explicit in-memory layers.
124    pub fn defaults_only() -> Self {
125        Self {
126            include_env: false,
127            include_config_file: false,
128            bootstrap_mode: RuntimeBootstrapMode::DefaultsOnly,
129        }
130    }
131
132    /// Sets whether environment-derived layers should be loaded.
133    ///
134    /// The default is `true`.
135    pub fn with_env(mut self, include_env: bool) -> Self {
136        self.include_env = include_env;
137        if include_env {
138            self.bootstrap_mode = RuntimeBootstrapMode::Standard;
139        }
140        self
141    }
142
143    /// Sets whether the ordinary config file should be loaded.
144    ///
145    /// The default is `true`. This does not disable the secrets layer.
146    pub fn with_config_file(mut self, include_config_file: bool) -> Self {
147        self.include_config_file = include_config_file;
148        if include_config_file {
149            self.bootstrap_mode = RuntimeBootstrapMode::Standard;
150        }
151        self
152    }
153
154    /// Sets whether bootstrap may consult ambient environment and
155    /// platform-derived paths before the loader pipeline runs.
156    ///
157    /// Switching to [`RuntimeBootstrapMode::DefaultsOnly`] also disables the
158    /// env and config-file loader layers.
159    pub fn with_bootstrap_mode(mut self, bootstrap_mode: RuntimeBootstrapMode) -> Self {
160        self.bootstrap_mode = bootstrap_mode;
161        if matches!(bootstrap_mode, RuntimeBootstrapMode::DefaultsOnly) {
162            self.include_env = false;
163            self.include_config_file = false;
164        }
165        self
166    }
167
168    /// Returns whether the load options seal bootstrap against ambient process
169    /// and home-directory state.
170    pub fn is_defaults_only(self) -> bool {
171        matches!(self.bootstrap_mode, RuntimeBootstrapMode::DefaultsOnly)
172    }
173}
174
175impl RuntimeBootstrapMode {
176    fn capture_env(self) -> RuntimeEnvironment {
177        match self {
178            Self::Standard => RuntimeEnvironment::capture(),
179            Self::DefaultsOnly => RuntimeEnvironment::defaults_only(),
180        }
181    }
182}
183
184impl RuntimeLoadOptions {
185    fn runtime_environment(self) -> RuntimeEnvironment {
186        self.bootstrap_mode.capture_env()
187    }
188}
189
190/// Minimal runtime-derived config that callers often need directly.
191///
192/// This is intentionally much smaller than [`ResolvedConfig`]. Keep the full
193/// [`ResolvedConfig`] when a caller needs arbitrary resolved keys, provenance,
194/// terminal selection, or explanation data. Use [`RuntimeConfig`] when the
195/// caller only needs the tiny runtime snapshot the host commonly carries
196/// around directly.
197#[derive(Debug, Clone)]
198pub struct RuntimeConfig {
199    /// Active profile name selected for the current invocation.
200    pub active_profile: String,
201}
202
203impl Default for RuntimeConfig {
204    fn default() -> Self {
205        Self {
206            active_profile: DEFAULT_PROFILE_NAME.to_string(),
207        }
208    }
209}
210
211impl RuntimeConfig {
212    /// Extracts the small runtime snapshot most callers need from a resolved config.
213    ///
214    /// # Examples
215    ///
216    /// ```
217    /// use osp_cli::config::{ConfigLayer, ConfigResolver, ResolveOptions, RuntimeConfig};
218    ///
219    /// let mut defaults = ConfigLayer::default();
220    /// defaults.set("profile.default", "default");
221    ///
222    /// let mut resolver = ConfigResolver::default();
223    /// resolver.set_defaults(defaults);
224    /// let resolved = resolver.resolve(ResolveOptions::default()).unwrap();
225    ///
226    /// let runtime = RuntimeConfig::from_resolved(&resolved);
227    /// assert_eq!(runtime.active_profile, "default");
228    /// ```
229    pub fn from_resolved(resolved: &ResolvedConfig) -> Self {
230        Self {
231            active_profile: resolved.active_profile().to_string(),
232        }
233    }
234}
235
236/// Discovered filesystem paths for runtime config inputs.
237#[derive(Debug, Clone, PartialEq, Eq)]
238pub struct RuntimeConfigPaths {
239    /// Path to the ordinary config file, when discovered.
240    pub config_file: Option<PathBuf>,
241    /// Path to the secrets config file, when discovered.
242    pub secrets_file: Option<PathBuf>,
243}
244
245impl RuntimeConfigPaths {
246    /// Discovers config and secrets paths from the current process environment.
247    ///
248    /// This is the standard path-discovery entrypoint for host bootstrap. Use
249    /// it together with [`RuntimeDefaults`] and [`build_runtime_pipeline`] when
250    /// a wrapper crate wants the same platform/env behavior as the stock host.
251    ///
252    /// # Examples
253    ///
254    /// ```no_run
255    /// use osp_cli::config::RuntimeConfigPaths;
256    ///
257    /// let paths = RuntimeConfigPaths::discover();
258    ///
259    /// let _config = paths.config_file.as_deref();
260    /// let _secrets = paths.secrets_file.as_deref();
261    /// ```
262    pub fn discover() -> Self {
263        Self::discover_with(RuntimeLoadOptions::default())
264    }
265
266    /// Discovers config and secrets paths using the supplied runtime bootstrap
267    /// policy.
268    ///
269    /// [`RuntimeLoadOptions::defaults_only`] returns an empty path set here so
270    /// callers can build a fully sealed bootstrap path.
271    pub fn discover_with(load: RuntimeLoadOptions) -> Self {
272        let paths = Self::from_env(&load.runtime_environment());
273        tracing::debug!(
274            config_file = ?paths.config_file.as_ref().map(|path| path.display().to_string()),
275            secrets_file = ?paths.secrets_file.as_ref().map(|path| path.display().to_string()),
276            bootstrap_mode = ?load.bootstrap_mode,
277            "discovered runtime config paths"
278        );
279        paths
280    }
281
282    fn from_env(env: &RuntimeEnvironment) -> Self {
283        Self {
284            config_file: env
285                .path_override("OSP_CONFIG_FILE")
286                .or_else(|| env.config_path("config.toml")),
287            secrets_file: env
288                .path_override("OSP_SECRETS_FILE")
289                .or_else(|| env.config_path("secrets.toml")),
290        }
291    }
292}
293
294/// Built-in default values seeded before user-provided config is loaded.
295#[derive(Debug, Clone, Default)]
296pub struct RuntimeDefaults {
297    layer: ConfigLayer,
298}
299
300impl RuntimeDefaults {
301    /// Builds the default layer using the current process environment.
302    ///
303    /// `default_theme_name` and `default_repl_prompt` are the product-level
304    /// knobs wrapper crates typically own themselves, while the rest of the
305    /// default layer follows the crate's standard runtime bootstrap rules.
306    ///
307    /// # Examples
308    ///
309    /// ```
310    /// use osp_cli::config::RuntimeDefaults;
311    ///
312    /// let defaults = RuntimeDefaults::from_process_env("dracula", "osp> ");
313    ///
314    /// assert_eq!(defaults.get_string("theme.name"), Some("dracula"));
315    /// assert_eq!(defaults.get_string("repl.prompt"), Some("osp> "));
316    /// ```
317    pub fn from_process_env(default_theme_name: &str, default_repl_prompt: &str) -> Self {
318        Self::from_runtime_load(
319            RuntimeLoadOptions::default(),
320            default_theme_name,
321            default_repl_prompt,
322        )
323    }
324
325    /// Builds the default layer using the supplied runtime bootstrap policy.
326    ///
327    /// [`RuntimeLoadOptions::defaults_only`] suppresses environment-derived
328    /// identity, theme-path, history-path, and log-path discovery so the
329    /// resulting layer depends only on built-in values plus the provided
330    /// product defaults.
331    pub fn from_runtime_load(
332        load: RuntimeLoadOptions,
333        default_theme_name: &str,
334        default_repl_prompt: &str,
335    ) -> Self {
336        Self::from_env(
337            &load.runtime_environment(),
338            default_theme_name,
339            default_repl_prompt,
340        )
341    }
342
343    fn from_env(
344        env: &RuntimeEnvironment,
345        default_theme_name: &str,
346        default_repl_prompt: &str,
347    ) -> Self {
348        Self {
349            layer: build_builtin_defaults(env, default_theme_name, default_repl_prompt),
350        }
351    }
352
353    /// Returns a default string value by key from the global scope.
354    ///
355    /// # Examples
356    ///
357    /// ```
358    /// use osp_cli::config::RuntimeDefaults;
359    ///
360    /// let defaults = RuntimeDefaults::from_process_env("dracula", "> ");
361    ///
362    /// assert_eq!(defaults.get_string("theme.name"), Some("dracula"));
363    /// assert_eq!(defaults.get_string("repl.prompt"), Some("> "));
364    /// ```
365    pub fn get_string(&self, key: &str) -> Option<&str> {
366        self.layer
367            .entries()
368            .iter()
369            .find(|entry| entry.key == key && entry.scope == crate::config::Scope::global())
370            .and_then(|entry| match entry.value.reveal() {
371                crate::config::ConfigValue::String(value) => Some(value.as_str()),
372                _ => None,
373            })
374    }
375
376    /// Clones the defaults as a standalone config layer.
377    ///
378    /// # Examples
379    ///
380    /// ```
381    /// use osp_cli::config::RuntimeDefaults;
382    ///
383    /// let defaults = RuntimeDefaults::from_process_env("plain", "> ");
384    /// let layer = defaults.to_layer();
385    ///
386    /// assert!(layer.entries().iter().any(|entry| entry.key == "theme.name"));
387    /// ```
388    pub fn to_layer(&self) -> ConfigLayer {
389        self.layer.clone()
390    }
391}
392
393/// Assembles the runtime loader precedence stack for CLI startup.
394///
395/// The ordering encoded here is part of the config contract: defaults first,
396/// then optional presentation/env/file/secrets layers, then CLI/session
397/// overrides last.
398///
399/// This is the normal bootstrap path for hosts that want the crate's standard
400/// platform/env/file loading semantics without manually wiring each loader.
401///
402/// # Examples
403///
404/// ```no_run
405/// use osp_cli::config::{
406///     ResolveOptions, RuntimeConfigPaths, RuntimeDefaults, RuntimeLoadOptions,
407///     build_runtime_pipeline,
408/// };
409///
410/// let defaults = RuntimeDefaults::from_process_env("dracula", "osp> ").to_layer();
411/// let paths = RuntimeConfigPaths::discover();
412/// let presentation = None;
413/// let cli = None;
414/// let session = None;
415///
416/// let resolved = build_runtime_pipeline(
417///     defaults,
418///     presentation,
419///     &paths,
420///     RuntimeLoadOptions::default(),
421///     cli,
422///     session,
423/// )
424/// .resolve(ResolveOptions::new().with_terminal("cli"))?;
425///
426/// assert_eq!(resolved.terminal(), Some("cli"));
427/// # Ok::<(), osp_cli::config::ConfigError>(())
428/// ```
429pub fn build_runtime_pipeline(
430    defaults: ConfigLayer,
431    presentation: Option<ConfigLayer>,
432    paths: &RuntimeConfigPaths,
433    load: RuntimeLoadOptions,
434    cli: Option<ConfigLayer>,
435    session: Option<ConfigLayer>,
436) -> LoaderPipeline {
437    tracing::debug!(
438        include_env = load.include_env,
439        include_config_file = load.include_config_file,
440        config_file = ?paths.config_file.as_ref().map(|path| path.display().to_string()),
441        secrets_file = ?paths.secrets_file.as_ref().map(|path| path.display().to_string()),
442        has_presentation_layer = presentation.is_some(),
443        has_cli_layer = cli.is_some(),
444        has_session_layer = session.is_some(),
445        defaults_entries = defaults.entries().len(),
446        "building runtime loader pipeline"
447    );
448    let mut pipeline = LoaderPipeline::new(StaticLayerLoader::new(defaults));
449
450    if let Some(presentation_layer) = presentation {
451        pipeline = pipeline.with_presentation(StaticLayerLoader::new(presentation_layer));
452    }
453
454    if load.include_env {
455        pipeline = pipeline.with_env(EnvVarLoader::from_process_env());
456    }
457
458    if load.include_config_file
459        && let Some(path) = &paths.config_file
460    {
461        pipeline = pipeline.with_file(TomlFileLoader::new(path.clone()).optional());
462    }
463
464    if let Some(path) = &paths.secrets_file {
465        let mut secret_chain = ChainedLoader::new(SecretsTomlLoader::new(path.clone()).optional());
466        if load.include_env {
467            secret_chain = secret_chain.with(EnvSecretsLoader::from_process_env());
468        }
469        pipeline = pipeline.with_secrets(secret_chain);
470    } else if load.include_env {
471        pipeline = pipeline.with_secrets(ChainedLoader::new(EnvSecretsLoader::from_process_env()));
472    }
473
474    if let Some(cli_layer) = cli {
475        pipeline = pipeline.with_cli(StaticLayerLoader::new(cli_layer));
476    }
477    if let Some(session_layer) = session {
478        pipeline = pipeline.with_session(StaticLayerLoader::new(session_layer));
479    }
480
481    pipeline
482}
483
484/// Resolves the default platform config root from the current process environment.
485pub fn default_config_root_dir() -> Option<PathBuf> {
486    RuntimeEnvironment::capture().config_root_dir()
487}
488
489/// Resolves the default platform cache root from the current process environment.
490pub fn default_cache_root_dir() -> Option<PathBuf> {
491    RuntimeEnvironment::capture().cache_root_dir()
492}
493
494/// Resolves the default platform state root from the current process environment.
495pub fn default_state_root_dir() -> Option<PathBuf> {
496    RuntimeEnvironment::capture().state_root_dir()
497}
498
499/// Resolves the current user's home directory from the running platform.
500pub fn default_home_dir() -> Option<PathBuf> {
501    BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
502}
503
504#[derive(Debug, Clone, Default)]
505pub(super) struct RuntimeEnvironment {
506    vars: BTreeMap<String, String>,
507    prefer_platform_dirs: bool,
508}
509
510impl RuntimeEnvironment {
511    fn capture() -> Self {
512        Self {
513            vars: std::env::vars().collect(),
514            prefer_platform_dirs: true,
515        }
516    }
517
518    pub(super) fn defaults_only() -> Self {
519        Self {
520            vars: BTreeMap::new(),
521            prefer_platform_dirs: false,
522        }
523    }
524
525    #[cfg(test)]
526    pub(super) fn from_pairs<I, K, V>(vars: I) -> Self
527    where
528        I: IntoIterator<Item = (K, V)>,
529        K: AsRef<str>,
530        V: AsRef<str>,
531    {
532        Self {
533            vars: vars
534                .into_iter()
535                .map(|(key, value)| (key.as_ref().to_string(), value.as_ref().to_string()))
536                .collect(),
537            prefer_platform_dirs: false,
538        }
539    }
540
541    fn config_root_dir(&self) -> Option<PathBuf> {
542        self.xdg_root_dir("XDG_CONFIG_HOME", &[".config"])
543    }
544
545    fn cache_root_dir(&self) -> Option<PathBuf> {
546        self.xdg_root_dir("XDG_CACHE_HOME", &[".cache"])
547    }
548
549    fn state_root_dir(&self) -> Option<PathBuf> {
550        if let Some(path) = self.get_nonempty("XDG_STATE_HOME") {
551            return Some(join_path(PathBuf::from(path), &[PROJECT_APPLICATION_NAME]));
552        }
553
554        if self.prefer_platform_dirs {
555            return project_dirs().map(|dirs| {
556                dirs.state_dir()
557                    .unwrap_or_else(|| dirs.data_local_dir())
558                    .to_path_buf()
559            });
560        }
561
562        self.home_root_dir(&[".local", "state"])
563    }
564
565    fn config_path(&self, leaf: &str) -> Option<PathBuf> {
566        self.config_root_dir().map(|root| join_path(root, &[leaf]))
567    }
568
569    pub(super) fn theme_paths(&self) -> Vec<String> {
570        self.config_root_dir()
571            .map(|root| join_path(root, &["themes"]).to_string_lossy().to_string())
572            .into_iter()
573            .collect()
574    }
575
576    pub(super) fn user_name(&self) -> String {
577        self.get_nonempty("USER")
578            .or_else(|| self.get_nonempty("USERNAME"))
579            .map(ToOwned::to_owned)
580            .unwrap_or_else(|| "anonymous".to_string())
581    }
582
583    pub(super) fn domain_name(&self) -> String {
584        self.get_nonempty("HOSTNAME")
585            .or_else(|| self.get_nonempty("COMPUTERNAME"))
586            .unwrap_or("localhost")
587            .split_once('.')
588            .map(|(_, domain)| domain.to_string())
589            .filter(|domain| !domain.trim().is_empty())
590            .unwrap_or_else(|| "local".to_string())
591    }
592
593    pub(super) fn repl_history_path(&self) -> String {
594        join_path(
595            self.state_root_dir_or_temp(),
596            &["history", "${user.name}@${profile.active}.history"],
597        )
598        .display()
599        .to_string()
600    }
601
602    pub(super) fn log_file_path(&self) -> String {
603        join_path(self.state_root_dir_or_temp(), &["osp.log"])
604            .display()
605            .to_string()
606    }
607
608    fn path_override(&self, key: &str) -> Option<PathBuf> {
609        self.get_nonempty(key).map(PathBuf::from)
610    }
611
612    fn state_root_dir_or_temp(&self) -> PathBuf {
613        self.state_root_dir().unwrap_or_else(|| {
614            let mut path = std::env::temp_dir();
615            path.push(PROJECT_APPLICATION_NAME);
616            path
617        })
618    }
619
620    fn xdg_root_dir(&self, xdg_var: &str, home_suffix: &[&str]) -> Option<PathBuf> {
621        if let Some(path) = self.get_nonempty(xdg_var) {
622            return Some(join_path(PathBuf::from(path), &[PROJECT_APPLICATION_NAME]));
623        }
624
625        if self.prefer_platform_dirs {
626            return match xdg_var {
627                "XDG_CONFIG_HOME" => project_dirs().map(|dirs| dirs.config_dir().to_path_buf()),
628                "XDG_CACHE_HOME" => project_dirs().map(|dirs| dirs.cache_dir().to_path_buf()),
629                _ => None,
630            };
631        }
632
633        self.home_root_dir(home_suffix)
634    }
635
636    fn home_root_dir(&self, home_suffix: &[&str]) -> Option<PathBuf> {
637        let home = self.get_nonempty("HOME")?;
638        Some(join_path(PathBuf::from(home), home_suffix).join(PROJECT_APPLICATION_NAME))
639    }
640
641    fn get_nonempty(&self, key: &str) -> Option<&str> {
642        self.vars
643            .get(key)
644            .map(String::as_str)
645            .map(str::trim)
646            .filter(|value| !value.is_empty())
647    }
648}
649
650fn join_path(mut root: PathBuf, segments: &[&str]) -> PathBuf {
651    for segment in segments {
652        root.push(segment);
653    }
654    root
655}
656
657fn project_dirs() -> Option<ProjectDirs> {
658    ProjectDirs::from("", "", PROJECT_APPLICATION_NAME)
659}
660
661#[cfg(test)]
662mod tests {
663    use std::path::PathBuf;
664
665    use super::{
666        DEFAULT_PROFILE_NAME, RuntimeBootstrapMode, RuntimeConfigPaths, RuntimeDefaults,
667        RuntimeEnvironment, RuntimeLoadOptions,
668    };
669    use crate::config::{
670        ConfigLayer, ConfigValue, DEFAULT_REPL_HISTORY_MAX_ENTRIES, DEFAULT_REPL_HISTORY_MENU_ROWS,
671        DEFAULT_REPL_INTRO, DEFAULT_UI_CHROME_FRAME, DEFAULT_UI_MESSAGES_LAYOUT,
672        DEFAULT_UI_PRESENTATION, DEFAULT_UI_TABLE_BORDER, DEFAULT_UI_WIDTH, Scope,
673    };
674
675    fn find_value<'a>(layer: &'a ConfigLayer, key: &str) -> Option<&'a ConfigValue> {
676        layer
677            .entries()
678            .iter()
679            .find(|entry| entry.key == key && entry.scope == Scope::global())
680            .map(|entry| &entry.value)
681    }
682
683    #[test]
684    fn runtime_defaults_seed_expected_keys_and_history_placeholders_unit() {
685        let defaults =
686            RuntimeDefaults::from_env(&RuntimeEnvironment::default(), "nord", "osp> ").to_layer();
687
688        assert_eq!(
689            find_value(&defaults, "profile.default"),
690            Some(&ConfigValue::String(DEFAULT_PROFILE_NAME.to_string()))
691        );
692        assert_eq!(
693            find_value(&defaults, "theme.name"),
694            Some(&ConfigValue::String("nord".to_string()))
695        );
696        assert_eq!(
697            find_value(&defaults, "repl.prompt"),
698            Some(&ConfigValue::String("osp> ".to_string()))
699        );
700        assert_eq!(
701            find_value(&defaults, "repl.intro"),
702            Some(&ConfigValue::String(DEFAULT_REPL_INTRO.to_string()))
703        );
704        assert_eq!(
705            find_value(&defaults, "repl.history.max_entries"),
706            Some(&ConfigValue::Integer(DEFAULT_REPL_HISTORY_MAX_ENTRIES))
707        );
708        assert_eq!(
709            find_value(&defaults, "repl.history.menu_rows"),
710            Some(&ConfigValue::Integer(DEFAULT_REPL_HISTORY_MENU_ROWS))
711        );
712        assert_eq!(
713            find_value(&defaults, "ui.width"),
714            Some(&ConfigValue::Integer(DEFAULT_UI_WIDTH))
715        );
716        assert_eq!(
717            find_value(&defaults, "ui.presentation"),
718            Some(&ConfigValue::String(DEFAULT_UI_PRESENTATION.to_string()))
719        );
720        assert_eq!(
721            find_value(&defaults, "ui.help.level"),
722            Some(&ConfigValue::String("inherit".to_string()))
723        );
724        assert_eq!(
725            find_value(&defaults, "ui.messages.layout"),
726            Some(&ConfigValue::String(DEFAULT_UI_MESSAGES_LAYOUT.to_string()))
727        );
728        assert_eq!(
729            find_value(&defaults, "ui.message.verbosity"),
730            Some(&ConfigValue::String("success".to_string()))
731        );
732        assert_eq!(
733            find_value(&defaults, "ui.chrome.frame"),
734            Some(&ConfigValue::String(DEFAULT_UI_CHROME_FRAME.to_string()))
735        );
736        assert_eq!(
737            find_value(&defaults, "ui.table.border"),
738            Some(&ConfigValue::String(DEFAULT_UI_TABLE_BORDER.to_string()))
739        );
740        assert_eq!(
741            find_value(&defaults, "color.prompt.text"),
742            Some(&ConfigValue::String(String::new()))
743        );
744        let path = match find_value(&defaults, "repl.history.path") {
745            Some(ConfigValue::String(value)) => value.as_str(),
746            other => panic!("unexpected history path value: {other:?}"),
747        };
748
749        assert!(path.contains("${user.name}@${profile.active}.history"));
750    }
751
752    #[test]
753    fn defaults_only_runtime_load_options_disable_ambient_bootstrap_unit() {
754        let load = RuntimeLoadOptions::defaults_only();
755
756        assert!(!load.include_env);
757        assert!(!load.include_config_file);
758        assert_eq!(load.bootstrap_mode, RuntimeBootstrapMode::DefaultsOnly);
759        assert!(load.is_defaults_only());
760    }
761
762    #[test]
763    fn runtime_config_paths_prefer_explicit_file_overrides() {
764        let env = RuntimeEnvironment::from_pairs([
765            ("OSP_CONFIG_FILE", "/tmp/custom-config.toml"),
766            ("OSP_SECRETS_FILE", "/tmp/custom-secrets.toml"),
767            ("XDG_CONFIG_HOME", "/ignored"),
768        ]);
769
770        let paths = RuntimeConfigPaths::from_env(&env);
771
772        assert_eq!(
773            paths.config_file,
774            Some(PathBuf::from("/tmp/custom-config.toml"))
775        );
776        assert_eq!(
777            paths.secrets_file,
778            Some(PathBuf::from("/tmp/custom-secrets.toml"))
779        );
780
781        let env = RuntimeEnvironment::from_pairs([("XDG_CONFIG_HOME", "/var/tmp/xdg-config")]);
782
783        let paths = RuntimeConfigPaths::from_env(&env);
784
785        assert_eq!(
786            paths.config_file,
787            Some(PathBuf::from("/var/tmp/xdg-config/osp/config.toml"))
788        );
789        assert_eq!(
790            paths.secrets_file,
791            Some(PathBuf::from("/var/tmp/xdg-config/osp/secrets.toml"))
792        );
793    }
794
795    #[test]
796    fn runtime_environment_uses_home_and_temp_fallbacks_for_state_paths_unit() {
797        let env = RuntimeEnvironment::from_pairs([("HOME", "/home/tester")]);
798
799        assert_eq!(
800            env.config_root_dir(),
801            Some(PathBuf::from("/home/tester/.config/osp"))
802        );
803        assert_eq!(
804            env.cache_root_dir(),
805            Some(PathBuf::from("/home/tester/.cache/osp"))
806        );
807        assert_eq!(
808            env.state_root_dir(),
809            Some(PathBuf::from("/home/tester/.local/state/osp"))
810        );
811
812        let env = RuntimeEnvironment::default();
813        let mut expected_root = std::env::temp_dir();
814        expected_root.push("osp");
815
816        assert_eq!(
817            env.repl_history_path(),
818            expected_root
819                .join("history")
820                .join("${user.name}@${profile.active}.history")
821                .display()
822                .to_string()
823        );
824        assert_eq!(
825            env.log_file_path(),
826            expected_root.join("osp.log").display().to_string()
827        );
828    }
829
830    #[test]
831    fn defaults_only_bootstrap_skips_home_and_override_discovery_unit() {
832        let load = RuntimeLoadOptions::defaults_only();
833        let paths = RuntimeConfigPaths::discover_with(load);
834        let defaults = RuntimeDefaults::from_runtime_load(load, "nord", "osp> ");
835
836        assert_eq!(paths.config_file, None);
837        assert_eq!(paths.secrets_file, None);
838        assert_eq!(defaults.get_string("user.name"), Some("anonymous"));
839        assert_eq!(defaults.get_string("domain"), Some("local"));
840        assert_eq!(defaults.get_string("theme.name"), Some("nord"));
841        assert_eq!(defaults.get_string("repl.prompt"), Some("osp> "));
842        assert_eq!(defaults.get_string("theme.path"), None);
843    }
844}