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, EnvSecretsLoader, EnvVarLoader, LoaderPipeline, ResolvedConfig,
36    SecretsTomlLoader, StaticLayerLoader, TomlFileLoader,
37};
38
39/// Default logical profile name used when no profile override is active.
40pub const DEFAULT_PROFILE_NAME: &str = "default";
41/// Default maximum number of REPL history entries to keep.
42pub const DEFAULT_REPL_HISTORY_MAX_ENTRIES: i64 = 1000;
43/// Default toggle for persistent REPL history.
44pub const DEFAULT_REPL_HISTORY_ENABLED: bool = true;
45/// Default toggle for deduplicating REPL history entries.
46pub const DEFAULT_REPL_HISTORY_DEDUPE: bool = true;
47/// Default toggle for profile-scoped REPL history storage.
48pub const DEFAULT_REPL_HISTORY_PROFILE_SCOPED: bool = true;
49/// Default maximum number of rows shown in the REPL history search menu.
50pub const DEFAULT_REPL_HISTORY_MENU_ROWS: i64 = 5;
51/// Default upper bound for cached session results.
52pub const DEFAULT_SESSION_CACHE_MAX_RESULTS: i64 = 64;
53/// Default debug verbosity level.
54pub const DEFAULT_DEBUG_LEVEL: i64 = 0;
55/// Default toggle for file logging.
56pub const DEFAULT_LOG_FILE_ENABLED: bool = false;
57/// Default log level used for file logging.
58pub const DEFAULT_LOG_FILE_LEVEL: &str = "warn";
59/// Default render width hint.
60pub const DEFAULT_UI_WIDTH: i64 = 72;
61/// Default left margin for rendered output.
62pub const DEFAULT_UI_MARGIN: i64 = 0;
63/// Default indentation width for nested output.
64pub const DEFAULT_UI_INDENT: i64 = 2;
65/// Default presentation preset name.
66pub const DEFAULT_UI_PRESENTATION: &str = "expressive";
67/// Default semantic guide-format preference.
68pub const DEFAULT_UI_GUIDE_DEFAULT_FORMAT: &str = "guide";
69/// Default grouped-message layout mode.
70pub const DEFAULT_UI_MESSAGES_LAYOUT: &str = "grouped";
71/// Default section chrome frame style.
72pub const DEFAULT_UI_CHROME_FRAME: &str = "top";
73/// Default rule-sharing policy for sibling section chrome.
74pub const DEFAULT_UI_CHROME_RULE_POLICY: &str = "shared";
75/// Default table border style.
76pub const DEFAULT_UI_TABLE_BORDER: &str = "square";
77/// Default REPL intro mode.
78pub const DEFAULT_REPL_INTRO: &str = "full";
79/// Default threshold for rendering short lists compactly.
80pub const DEFAULT_UI_SHORT_LIST_MAX: i64 = 1;
81/// Default threshold for rendering medium lists before expanding further.
82pub const DEFAULT_UI_MEDIUM_LIST_MAX: i64 = 5;
83/// Default grid column padding.
84pub const DEFAULT_UI_GRID_PADDING: i64 = 4;
85/// Default adaptive grid column weight.
86pub const DEFAULT_UI_COLUMN_WEIGHT: i64 = 3;
87/// Default minimum width before MREG output stacks columns.
88pub const DEFAULT_UI_MREG_STACK_MIN_COL_WIDTH: i64 = 10;
89/// Default threshold for stacked MREG overflow behavior.
90pub const DEFAULT_UI_MREG_STACK_OVERFLOW_RATIO: i64 = 200;
91/// Default table overflow strategy.
92pub const DEFAULT_UI_TABLE_OVERFLOW: &str = "clip";
93
94const PROJECT_APPLICATION_NAME: &str = "osp";
95
96/// Options that control which runtime config sources are included.
97///
98/// # Examples
99///
100/// ```
101/// use osp_cli::config::{RuntimeBootstrapMode, RuntimeLoadOptions};
102///
103/// let options = RuntimeLoadOptions::default();
104///
105/// assert!(options.include_env);
106/// assert!(options.include_config_file);
107/// assert_eq!(options.bootstrap_mode, RuntimeBootstrapMode::Standard);
108/// ```
109///
110/// When callers need a sealed bootstrap path with no environment variables,
111/// file loading, or home/XDG-derived discovery, use
112/// [`RuntimeLoadOptions::defaults_only`].
113///
114/// ```
115/// use osp_cli::config::{RuntimeBootstrapMode, RuntimeLoadOptions};
116///
117/// let options = RuntimeLoadOptions::defaults_only();
118///
119/// assert!(!options.include_env);
120/// assert!(!options.include_config_file);
121/// assert_eq!(options.bootstrap_mode, RuntimeBootstrapMode::DefaultsOnly);
122/// ```
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum RuntimeBootstrapMode {
125    /// Use the crate's normal environment and platform-derived bootstrap
126    /// behavior.
127    Standard,
128    /// Use only built-in defaults plus explicit in-memory inputs.
129    ///
130    /// This disables environment-derived defaults, HOME/XDG path discovery,
131    /// config-file and secrets-file lookup, and env/path override discovery.
132    DefaultsOnly,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136#[non_exhaustive]
137#[must_use = "RuntimeLoadOptions builder-style methods return an updated value"]
138pub struct RuntimeLoadOptions {
139    /// Whether environment-derived layers should be loaded.
140    pub include_env: bool,
141    /// Whether the ordinary config file should be loaded.
142    ///
143    /// This does not disable the secrets layer; secrets files and secret
144    /// environment overrides still participate through the secrets pipeline.
145    pub include_config_file: bool,
146    /// Controls whether bootstrap may consult ambient environment and
147    /// platform-derived paths before the loader pipeline runs.
148    pub bootstrap_mode: RuntimeBootstrapMode,
149}
150
151impl Default for RuntimeLoadOptions {
152    fn default() -> Self {
153        Self {
154            include_env: true,
155            include_config_file: true,
156            bootstrap_mode: RuntimeBootstrapMode::Standard,
157        }
158    }
159}
160
161impl RuntimeLoadOptions {
162    /// Creates runtime-load options with the default source set enabled.
163    pub fn new() -> Self {
164        Self::default()
165    }
166
167    /// Creates a sealed bootstrap policy that uses only built-in defaults plus
168    /// explicit in-memory layers.
169    pub fn defaults_only() -> Self {
170        Self {
171            include_env: false,
172            include_config_file: false,
173            bootstrap_mode: RuntimeBootstrapMode::DefaultsOnly,
174        }
175    }
176
177    /// Sets whether environment-derived layers should be loaded.
178    ///
179    /// The default is `true`.
180    pub fn with_env(mut self, include_env: bool) -> Self {
181        self.include_env = include_env;
182        if include_env {
183            self.bootstrap_mode = RuntimeBootstrapMode::Standard;
184        }
185        self
186    }
187
188    /// Sets whether the ordinary config file should be loaded.
189    ///
190    /// The default is `true`. This does not disable the secrets layer.
191    pub fn with_config_file(mut self, include_config_file: bool) -> Self {
192        self.include_config_file = include_config_file;
193        if include_config_file {
194            self.bootstrap_mode = RuntimeBootstrapMode::Standard;
195        }
196        self
197    }
198
199    /// Sets whether bootstrap may consult ambient environment and
200    /// platform-derived paths before the loader pipeline runs.
201    ///
202    /// Switching to [`RuntimeBootstrapMode::DefaultsOnly`] also disables the
203    /// env and config-file loader layers.
204    pub fn with_bootstrap_mode(mut self, bootstrap_mode: RuntimeBootstrapMode) -> Self {
205        self.bootstrap_mode = bootstrap_mode;
206        if matches!(bootstrap_mode, RuntimeBootstrapMode::DefaultsOnly) {
207            self.include_env = false;
208            self.include_config_file = false;
209        }
210        self
211    }
212
213    /// Returns whether the load options seal bootstrap against ambient process
214    /// and home-directory state.
215    pub fn is_defaults_only(self) -> bool {
216        matches!(self.bootstrap_mode, RuntimeBootstrapMode::DefaultsOnly)
217    }
218}
219
220impl RuntimeBootstrapMode {
221    fn capture_env(self) -> RuntimeEnvironment {
222        match self {
223            Self::Standard => RuntimeEnvironment::capture(),
224            Self::DefaultsOnly => RuntimeEnvironment::defaults_only(),
225        }
226    }
227}
228
229impl RuntimeLoadOptions {
230    fn runtime_environment(self) -> RuntimeEnvironment {
231        self.bootstrap_mode.capture_env()
232    }
233}
234
235/// Minimal runtime-derived config that callers often need directly.
236///
237/// This is intentionally much smaller than [`ResolvedConfig`]. Keep the full
238/// [`ResolvedConfig`] when a caller needs arbitrary resolved keys, provenance,
239/// terminal selection, or explanation data. Use [`RuntimeConfig`] when the
240/// caller only needs the tiny runtime snapshot the host commonly carries
241/// around directly.
242#[derive(Debug, Clone)]
243pub struct RuntimeConfig {
244    /// Active profile name selected for the current invocation.
245    pub active_profile: String,
246}
247
248impl Default for RuntimeConfig {
249    fn default() -> Self {
250        Self {
251            active_profile: DEFAULT_PROFILE_NAME.to_string(),
252        }
253    }
254}
255
256impl RuntimeConfig {
257    /// Extracts the small runtime snapshot most callers need from a resolved config.
258    ///
259    /// # Examples
260    ///
261    /// ```
262    /// use osp_cli::config::{ConfigLayer, ConfigResolver, ResolveOptions, RuntimeConfig};
263    ///
264    /// let mut defaults = ConfigLayer::default();
265    /// defaults.set("profile.default", "default");
266    ///
267    /// let mut resolver = ConfigResolver::default();
268    /// resolver.set_defaults(defaults);
269    /// let resolved = resolver.resolve(ResolveOptions::default()).unwrap();
270    ///
271    /// let runtime = RuntimeConfig::from_resolved(&resolved);
272    /// assert_eq!(runtime.active_profile, "default");
273    /// ```
274    pub fn from_resolved(resolved: &ResolvedConfig) -> Self {
275        Self {
276            active_profile: resolved.active_profile().to_string(),
277        }
278    }
279}
280
281/// Discovered filesystem paths for runtime config inputs.
282#[derive(Debug, Clone, PartialEq, Eq)]
283pub struct RuntimeConfigPaths {
284    /// Path to the ordinary config file, when discovered.
285    pub config_file: Option<PathBuf>,
286    /// Path to the secrets config file, when discovered.
287    pub secrets_file: Option<PathBuf>,
288}
289
290impl RuntimeConfigPaths {
291    /// Discovers config and secrets paths from the current process environment.
292    ///
293    /// This is the standard path-discovery entrypoint for host bootstrap. Use
294    /// it together with [`RuntimeDefaults`] and [`build_runtime_pipeline`] when
295    /// a wrapper crate wants the same platform/env behavior as the stock host.
296    ///
297    /// # Examples
298    ///
299    /// ```no_run
300    /// use osp_cli::config::RuntimeConfigPaths;
301    ///
302    /// let paths = RuntimeConfigPaths::discover();
303    ///
304    /// let _config = paths.config_file.as_deref();
305    /// let _secrets = paths.secrets_file.as_deref();
306    /// ```
307    pub fn discover() -> Self {
308        Self::discover_with(RuntimeLoadOptions::default())
309    }
310
311    /// Discovers config and secrets paths using the supplied runtime bootstrap
312    /// policy.
313    ///
314    /// [`RuntimeLoadOptions::defaults_only`] returns an empty path set here so
315    /// callers can build a fully sealed bootstrap path.
316    pub fn discover_with(load: RuntimeLoadOptions) -> Self {
317        let paths = Self::from_env(&load.runtime_environment());
318        tracing::debug!(
319            config_file = ?paths.config_file.as_ref().map(|path| path.display().to_string()),
320            secrets_file = ?paths.secrets_file.as_ref().map(|path| path.display().to_string()),
321            bootstrap_mode = ?load.bootstrap_mode,
322            "discovered runtime config paths"
323        );
324        paths
325    }
326
327    fn from_env(env: &RuntimeEnvironment) -> Self {
328        Self {
329            config_file: env
330                .path_override("OSP_CONFIG_FILE")
331                .or_else(|| env.config_path("config.toml")),
332            secrets_file: env
333                .path_override("OSP_SECRETS_FILE")
334                .or_else(|| env.config_path("secrets.toml")),
335        }
336    }
337}
338
339/// Built-in default values seeded before user-provided config is loaded.
340#[derive(Debug, Clone, Default)]
341pub struct RuntimeDefaults {
342    layer: ConfigLayer,
343}
344
345impl RuntimeDefaults {
346    /// Builds the default layer using the current process environment.
347    ///
348    /// `default_theme_name` and `default_repl_prompt` are the product-level
349    /// knobs wrapper crates typically own themselves, while the rest of the
350    /// default layer follows the crate's standard runtime bootstrap rules.
351    ///
352    /// # Examples
353    ///
354    /// ```
355    /// use osp_cli::config::RuntimeDefaults;
356    ///
357    /// let defaults = RuntimeDefaults::from_process_env("dracula", "osp> ");
358    ///
359    /// assert_eq!(defaults.get_string("theme.name"), Some("dracula"));
360    /// assert_eq!(defaults.get_string("repl.prompt"), Some("osp> "));
361    /// ```
362    pub fn from_process_env(default_theme_name: &str, default_repl_prompt: &str) -> Self {
363        Self::from_runtime_load(
364            RuntimeLoadOptions::default(),
365            default_theme_name,
366            default_repl_prompt,
367        )
368    }
369
370    /// Builds the default layer using the supplied runtime bootstrap policy.
371    ///
372    /// [`RuntimeLoadOptions::defaults_only`] suppresses environment-derived
373    /// identity, theme-path, history-path, and log-path discovery so the
374    /// resulting layer depends only on built-in values plus the provided
375    /// product defaults.
376    pub fn from_runtime_load(
377        load: RuntimeLoadOptions,
378        default_theme_name: &str,
379        default_repl_prompt: &str,
380    ) -> Self {
381        Self::from_env(
382            &load.runtime_environment(),
383            default_theme_name,
384            default_repl_prompt,
385        )
386    }
387
388    fn from_env(
389        env: &RuntimeEnvironment,
390        default_theme_name: &str,
391        default_repl_prompt: &str,
392    ) -> Self {
393        let mut layer = ConfigLayer::default();
394
395        macro_rules! set_defaults {
396            ($($key:literal => $value:expr),* $(,)?) => {
397                $(layer.set($key, $value);)*
398            };
399        }
400
401        set_defaults! {
402            "profile.default" => DEFAULT_PROFILE_NAME.to_string(),
403            "theme.name" => default_theme_name.to_string(),
404            "user.name" => env.user_name(),
405            "domain" => env.domain_name(),
406            "repl.prompt" => default_repl_prompt.to_string(),
407            "repl.input_mode" => "auto".to_string(),
408            "repl.simple_prompt" => false,
409            "repl.shell_indicator" => "[{shell}]".to_string(),
410            "repl.intro" => DEFAULT_REPL_INTRO.to_string(),
411            "repl.history.path" => env.repl_history_path(),
412            "repl.history.max_entries" => DEFAULT_REPL_HISTORY_MAX_ENTRIES,
413            "repl.history.enabled" => DEFAULT_REPL_HISTORY_ENABLED,
414            "repl.history.dedupe" => DEFAULT_REPL_HISTORY_DEDUPE,
415            "repl.history.profile_scoped" => DEFAULT_REPL_HISTORY_PROFILE_SCOPED,
416            "repl.history.menu_rows" => DEFAULT_REPL_HISTORY_MENU_ROWS,
417            "session.cache.max_results" => DEFAULT_SESSION_CACHE_MAX_RESULTS,
418            "debug.level" => DEFAULT_DEBUG_LEVEL,
419            "log.file.enabled" => DEFAULT_LOG_FILE_ENABLED,
420            "log.file.path" => env.log_file_path(),
421            "log.file.level" => DEFAULT_LOG_FILE_LEVEL.to_string(),
422            "ui.width" => DEFAULT_UI_WIDTH,
423            "ui.margin" => DEFAULT_UI_MARGIN,
424            "ui.indent" => DEFAULT_UI_INDENT,
425            "ui.presentation" => DEFAULT_UI_PRESENTATION.to_string(),
426            "ui.help.level" => "inherit".to_string(),
427            "ui.guide.default_format" => DEFAULT_UI_GUIDE_DEFAULT_FORMAT.to_string(),
428            "ui.messages.layout" => DEFAULT_UI_MESSAGES_LAYOUT.to_string(),
429            "ui.message.verbosity" => "success".to_string(),
430            "ui.chrome.frame" => DEFAULT_UI_CHROME_FRAME.to_string(),
431            "ui.chrome.rule_policy" => DEFAULT_UI_CHROME_RULE_POLICY.to_string(),
432            "ui.table.overflow" => DEFAULT_UI_TABLE_OVERFLOW.to_string(),
433            "ui.table.border" => DEFAULT_UI_TABLE_BORDER.to_string(),
434            "ui.help.table_chrome" => "none".to_string(),
435            "ui.help.entry_indent" => "inherit".to_string(),
436            "ui.help.entry_gap" => "inherit".to_string(),
437            "ui.help.section_spacing" => "inherit".to_string(),
438            "ui.short_list_max" => DEFAULT_UI_SHORT_LIST_MAX,
439            "ui.medium_list_max" => DEFAULT_UI_MEDIUM_LIST_MAX,
440            "ui.grid_padding" => DEFAULT_UI_GRID_PADDING,
441            "ui.column_weight" => DEFAULT_UI_COLUMN_WEIGHT,
442            "ui.mreg.stack_min_col_width" => DEFAULT_UI_MREG_STACK_MIN_COL_WIDTH,
443            "ui.mreg.stack_overflow_ratio" => DEFAULT_UI_MREG_STACK_OVERFLOW_RATIO,
444            "extensions.plugins.timeout_ms" => 10_000,
445            "extensions.plugins.discovery.path" => false,
446        }
447
448        let theme_path = env.theme_paths();
449        if !theme_path.is_empty() {
450            layer.set("theme.path", theme_path);
451        }
452
453        for key in [
454            "color.text",
455            "color.text.muted",
456            "color.key",
457            "color.border",
458            "color.prompt.text",
459            "color.prompt.command",
460            "color.table.header",
461            "color.mreg.key",
462            "color.value",
463            "color.value.number",
464            "color.value.bool_true",
465            "color.value.bool_false",
466            "color.value.null",
467            "color.value.ipv4",
468            "color.value.ipv6",
469            "color.panel.border",
470            "color.panel.title",
471            "color.code",
472            "color.json.key",
473        ] {
474            layer.set(key, String::new());
475        }
476
477        Self { layer }
478    }
479
480    /// Returns a default string value by key from the global scope.
481    ///
482    /// # Examples
483    ///
484    /// ```
485    /// use osp_cli::config::RuntimeDefaults;
486    ///
487    /// let defaults = RuntimeDefaults::from_process_env("dracula", "> ");
488    ///
489    /// assert_eq!(defaults.get_string("theme.name"), Some("dracula"));
490    /// assert_eq!(defaults.get_string("repl.prompt"), Some("> "));
491    /// ```
492    pub fn get_string(&self, key: &str) -> Option<&str> {
493        self.layer
494            .entries()
495            .iter()
496            .find(|entry| entry.key == key && entry.scope == crate::config::Scope::global())
497            .and_then(|entry| match entry.value.reveal() {
498                crate::config::ConfigValue::String(value) => Some(value.as_str()),
499                _ => None,
500            })
501    }
502
503    /// Clones the defaults as a standalone config layer.
504    ///
505    /// # Examples
506    ///
507    /// ```
508    /// use osp_cli::config::RuntimeDefaults;
509    ///
510    /// let defaults = RuntimeDefaults::from_process_env("plain", "> ");
511    /// let layer = defaults.to_layer();
512    ///
513    /// assert!(layer.entries().iter().any(|entry| entry.key == "theme.name"));
514    /// ```
515    pub fn to_layer(&self) -> ConfigLayer {
516        self.layer.clone()
517    }
518}
519
520/// Assembles the runtime loader precedence stack for CLI startup.
521///
522/// The ordering encoded here is part of the config contract: defaults first,
523/// then optional presentation/env/file/secrets layers, then CLI/session
524/// overrides last.
525///
526/// This is the normal bootstrap path for hosts that want the crate's standard
527/// platform/env/file loading semantics without manually wiring each loader.
528///
529/// # Examples
530///
531/// ```no_run
532/// use osp_cli::config::{
533///     ResolveOptions, RuntimeConfigPaths, RuntimeDefaults, RuntimeLoadOptions,
534///     build_runtime_pipeline,
535/// };
536///
537/// let defaults = RuntimeDefaults::from_process_env("dracula", "osp> ").to_layer();
538/// let paths = RuntimeConfigPaths::discover();
539/// let presentation = None;
540/// let cli = None;
541/// let session = None;
542///
543/// let resolved = build_runtime_pipeline(
544///     defaults,
545///     presentation,
546///     &paths,
547///     RuntimeLoadOptions::default(),
548///     cli,
549///     session,
550/// )
551/// .resolve(ResolveOptions::new().with_terminal("cli"))?;
552///
553/// assert_eq!(resolved.terminal(), Some("cli"));
554/// # Ok::<(), osp_cli::config::ConfigError>(())
555/// ```
556pub fn build_runtime_pipeline(
557    defaults: ConfigLayer,
558    presentation: Option<ConfigLayer>,
559    paths: &RuntimeConfigPaths,
560    load: RuntimeLoadOptions,
561    cli: Option<ConfigLayer>,
562    session: Option<ConfigLayer>,
563) -> LoaderPipeline {
564    tracing::debug!(
565        include_env = load.include_env,
566        include_config_file = load.include_config_file,
567        config_file = ?paths.config_file.as_ref().map(|path| path.display().to_string()),
568        secrets_file = ?paths.secrets_file.as_ref().map(|path| path.display().to_string()),
569        has_presentation_layer = presentation.is_some(),
570        has_cli_layer = cli.is_some(),
571        has_session_layer = session.is_some(),
572        defaults_entries = defaults.entries().len(),
573        "building runtime loader pipeline"
574    );
575    let mut pipeline = LoaderPipeline::new(StaticLayerLoader::new(defaults));
576
577    if let Some(presentation_layer) = presentation {
578        pipeline = pipeline.with_presentation(StaticLayerLoader::new(presentation_layer));
579    }
580
581    if load.include_env {
582        pipeline = pipeline.with_env(EnvVarLoader::from_process_env());
583    }
584
585    if load.include_config_file
586        && let Some(path) = &paths.config_file
587    {
588        pipeline = pipeline.with_file(TomlFileLoader::new(path.clone()).optional());
589    }
590
591    if let Some(path) = &paths.secrets_file {
592        let mut secret_chain = ChainedLoader::new(SecretsTomlLoader::new(path.clone()).optional());
593        if load.include_env {
594            secret_chain = secret_chain.with(EnvSecretsLoader::from_process_env());
595        }
596        pipeline = pipeline.with_secrets(secret_chain);
597    } else if load.include_env {
598        pipeline = pipeline.with_secrets(ChainedLoader::new(EnvSecretsLoader::from_process_env()));
599    }
600
601    if let Some(cli_layer) = cli {
602        pipeline = pipeline.with_cli(StaticLayerLoader::new(cli_layer));
603    }
604    if let Some(session_layer) = session {
605        pipeline = pipeline.with_session(StaticLayerLoader::new(session_layer));
606    }
607
608    pipeline
609}
610
611/// Resolves the default platform config root from the current process environment.
612pub fn default_config_root_dir() -> Option<PathBuf> {
613    RuntimeEnvironment::capture().config_root_dir()
614}
615
616/// Resolves the default platform cache root from the current process environment.
617pub fn default_cache_root_dir() -> Option<PathBuf> {
618    RuntimeEnvironment::capture().cache_root_dir()
619}
620
621/// Resolves the default platform state root from the current process environment.
622pub fn default_state_root_dir() -> Option<PathBuf> {
623    RuntimeEnvironment::capture().state_root_dir()
624}
625
626/// Resolves the current user's home directory from the running platform.
627pub fn default_home_dir() -> Option<PathBuf> {
628    BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
629}
630
631#[derive(Debug, Clone, Default)]
632struct RuntimeEnvironment {
633    vars: BTreeMap<String, String>,
634    prefer_platform_dirs: bool,
635}
636
637impl RuntimeEnvironment {
638    fn capture() -> Self {
639        Self {
640            vars: std::env::vars().collect(),
641            prefer_platform_dirs: true,
642        }
643    }
644
645    fn defaults_only() -> Self {
646        Self {
647            vars: BTreeMap::new(),
648            prefer_platform_dirs: false,
649        }
650    }
651
652    #[cfg(test)]
653    fn from_pairs<I, K, V>(vars: I) -> Self
654    where
655        I: IntoIterator<Item = (K, V)>,
656        K: AsRef<str>,
657        V: AsRef<str>,
658    {
659        Self {
660            vars: vars
661                .into_iter()
662                .map(|(key, value)| (key.as_ref().to_string(), value.as_ref().to_string()))
663                .collect(),
664            prefer_platform_dirs: false,
665        }
666    }
667
668    fn config_root_dir(&self) -> Option<PathBuf> {
669        self.xdg_root_dir("XDG_CONFIG_HOME", &[".config"])
670    }
671
672    fn cache_root_dir(&self) -> Option<PathBuf> {
673        self.xdg_root_dir("XDG_CACHE_HOME", &[".cache"])
674    }
675
676    fn state_root_dir(&self) -> Option<PathBuf> {
677        if let Some(path) = self.get_nonempty("XDG_STATE_HOME") {
678            return Some(join_path(PathBuf::from(path), &[PROJECT_APPLICATION_NAME]));
679        }
680
681        if self.prefer_platform_dirs {
682            return project_dirs().map(|dirs| {
683                dirs.state_dir()
684                    .unwrap_or_else(|| dirs.data_local_dir())
685                    .to_path_buf()
686            });
687        }
688
689        self.home_root_dir(&[".local", "state"])
690    }
691
692    fn config_path(&self, leaf: &str) -> Option<PathBuf> {
693        self.config_root_dir().map(|root| join_path(root, &[leaf]))
694    }
695
696    fn theme_paths(&self) -> Vec<String> {
697        self.config_root_dir()
698            .map(|root| join_path(root, &["themes"]).to_string_lossy().to_string())
699            .into_iter()
700            .collect()
701    }
702
703    fn user_name(&self) -> String {
704        self.get_nonempty("USER")
705            .or_else(|| self.get_nonempty("USERNAME"))
706            .map(ToOwned::to_owned)
707            .unwrap_or_else(|| "anonymous".to_string())
708    }
709
710    fn domain_name(&self) -> String {
711        self.get_nonempty("HOSTNAME")
712            .or_else(|| self.get_nonempty("COMPUTERNAME"))
713            .unwrap_or("localhost")
714            .split_once('.')
715            .map(|(_, domain)| domain.to_string())
716            .filter(|domain| !domain.trim().is_empty())
717            .unwrap_or_else(|| "local".to_string())
718    }
719
720    fn repl_history_path(&self) -> String {
721        join_path(
722            self.state_root_dir_or_temp(),
723            &["history", "${user.name}@${profile.active}.history"],
724        )
725        .display()
726        .to_string()
727    }
728
729    fn log_file_path(&self) -> String {
730        join_path(self.state_root_dir_or_temp(), &["osp.log"])
731            .display()
732            .to_string()
733    }
734
735    fn path_override(&self, key: &str) -> Option<PathBuf> {
736        self.get_nonempty(key).map(PathBuf::from)
737    }
738
739    fn state_root_dir_or_temp(&self) -> PathBuf {
740        self.state_root_dir().unwrap_or_else(|| {
741            let mut path = std::env::temp_dir();
742            path.push(PROJECT_APPLICATION_NAME);
743            path
744        })
745    }
746
747    fn xdg_root_dir(&self, xdg_var: &str, home_suffix: &[&str]) -> Option<PathBuf> {
748        if let Some(path) = self.get_nonempty(xdg_var) {
749            return Some(join_path(PathBuf::from(path), &[PROJECT_APPLICATION_NAME]));
750        }
751
752        if self.prefer_platform_dirs {
753            return match xdg_var {
754                "XDG_CONFIG_HOME" => project_dirs().map(|dirs| dirs.config_dir().to_path_buf()),
755                "XDG_CACHE_HOME" => project_dirs().map(|dirs| dirs.cache_dir().to_path_buf()),
756                _ => None,
757            };
758        }
759
760        self.home_root_dir(home_suffix)
761    }
762
763    fn home_root_dir(&self, home_suffix: &[&str]) -> Option<PathBuf> {
764        let home = self.get_nonempty("HOME")?;
765        Some(join_path(PathBuf::from(home), home_suffix).join(PROJECT_APPLICATION_NAME))
766    }
767
768    fn get_nonempty(&self, key: &str) -> Option<&str> {
769        self.vars
770            .get(key)
771            .map(String::as_str)
772            .map(str::trim)
773            .filter(|value| !value.is_empty())
774    }
775}
776
777fn join_path(mut root: PathBuf, segments: &[&str]) -> PathBuf {
778    for segment in segments {
779        root.push(segment);
780    }
781    root
782}
783
784fn project_dirs() -> Option<ProjectDirs> {
785    ProjectDirs::from("", "", PROJECT_APPLICATION_NAME)
786}
787
788#[cfg(test)]
789mod tests {
790    use std::path::PathBuf;
791
792    use super::{
793        DEFAULT_PROFILE_NAME, RuntimeBootstrapMode, RuntimeConfigPaths, RuntimeDefaults,
794        RuntimeEnvironment, RuntimeLoadOptions,
795    };
796    use crate::config::{ConfigLayer, ConfigValue, Scope};
797
798    fn find_value<'a>(layer: &'a ConfigLayer, key: &str) -> Option<&'a ConfigValue> {
799        layer
800            .entries()
801            .iter()
802            .find(|entry| entry.key == key && entry.scope == Scope::global())
803            .map(|entry| &entry.value)
804    }
805
806    #[test]
807    fn runtime_defaults_seed_expected_keys_and_history_placeholders_unit() {
808        let defaults =
809            RuntimeDefaults::from_env(&RuntimeEnvironment::default(), "nord", "osp> ").to_layer();
810
811        assert_eq!(
812            find_value(&defaults, "profile.default"),
813            Some(&ConfigValue::String(DEFAULT_PROFILE_NAME.to_string()))
814        );
815        assert_eq!(
816            find_value(&defaults, "theme.name"),
817            Some(&ConfigValue::String("nord".to_string()))
818        );
819        assert_eq!(
820            find_value(&defaults, "repl.prompt"),
821            Some(&ConfigValue::String("osp> ".to_string()))
822        );
823        assert_eq!(
824            find_value(&defaults, "repl.intro"),
825            Some(&ConfigValue::String(super::DEFAULT_REPL_INTRO.to_string()))
826        );
827        assert_eq!(
828            find_value(&defaults, "repl.history.max_entries"),
829            Some(&ConfigValue::Integer(
830                super::DEFAULT_REPL_HISTORY_MAX_ENTRIES
831            ))
832        );
833        assert_eq!(
834            find_value(&defaults, "repl.history.menu_rows"),
835            Some(&ConfigValue::Integer(super::DEFAULT_REPL_HISTORY_MENU_ROWS))
836        );
837        assert_eq!(
838            find_value(&defaults, "ui.width"),
839            Some(&ConfigValue::Integer(super::DEFAULT_UI_WIDTH))
840        );
841        assert_eq!(
842            find_value(&defaults, "ui.presentation"),
843            Some(&ConfigValue::String(
844                super::DEFAULT_UI_PRESENTATION.to_string()
845            ))
846        );
847        assert_eq!(
848            find_value(&defaults, "ui.help.level"),
849            Some(&ConfigValue::String("inherit".to_string()))
850        );
851        assert_eq!(
852            find_value(&defaults, "ui.messages.layout"),
853            Some(&ConfigValue::String(
854                super::DEFAULT_UI_MESSAGES_LAYOUT.to_string()
855            ))
856        );
857        assert_eq!(
858            find_value(&defaults, "ui.message.verbosity"),
859            Some(&ConfigValue::String("success".to_string()))
860        );
861        assert_eq!(
862            find_value(&defaults, "ui.chrome.frame"),
863            Some(&ConfigValue::String(
864                super::DEFAULT_UI_CHROME_FRAME.to_string()
865            ))
866        );
867        assert_eq!(
868            find_value(&defaults, "ui.table.border"),
869            Some(&ConfigValue::String(
870                super::DEFAULT_UI_TABLE_BORDER.to_string()
871            ))
872        );
873        assert_eq!(
874            find_value(&defaults, "color.prompt.text"),
875            Some(&ConfigValue::String(String::new()))
876        );
877        let path = match find_value(&defaults, "repl.history.path") {
878            Some(ConfigValue::String(value)) => value.as_str(),
879            other => panic!("unexpected history path value: {other:?}"),
880        };
881
882        assert!(path.contains("${user.name}@${profile.active}.history"));
883    }
884
885    #[test]
886    fn defaults_only_runtime_load_options_disable_ambient_bootstrap_unit() {
887        let load = RuntimeLoadOptions::defaults_only();
888
889        assert!(!load.include_env);
890        assert!(!load.include_config_file);
891        assert_eq!(load.bootstrap_mode, RuntimeBootstrapMode::DefaultsOnly);
892        assert!(load.is_defaults_only());
893    }
894
895    #[test]
896    fn runtime_config_paths_prefer_explicit_file_overrides() {
897        let env = RuntimeEnvironment::from_pairs([
898            ("OSP_CONFIG_FILE", "/tmp/custom-config.toml"),
899            ("OSP_SECRETS_FILE", "/tmp/custom-secrets.toml"),
900            ("XDG_CONFIG_HOME", "/ignored"),
901        ]);
902
903        let paths = RuntimeConfigPaths::from_env(&env);
904
905        assert_eq!(
906            paths.config_file,
907            Some(PathBuf::from("/tmp/custom-config.toml"))
908        );
909        assert_eq!(
910            paths.secrets_file,
911            Some(PathBuf::from("/tmp/custom-secrets.toml"))
912        );
913
914        let env = RuntimeEnvironment::from_pairs([("XDG_CONFIG_HOME", "/var/tmp/xdg-config")]);
915
916        let paths = RuntimeConfigPaths::from_env(&env);
917
918        assert_eq!(
919            paths.config_file,
920            Some(PathBuf::from("/var/tmp/xdg-config/osp/config.toml"))
921        );
922        assert_eq!(
923            paths.secrets_file,
924            Some(PathBuf::from("/var/tmp/xdg-config/osp/secrets.toml"))
925        );
926    }
927
928    #[test]
929    fn runtime_environment_uses_home_and_temp_fallbacks_for_state_paths_unit() {
930        let env = RuntimeEnvironment::from_pairs([("HOME", "/home/tester")]);
931
932        assert_eq!(
933            env.config_root_dir(),
934            Some(PathBuf::from("/home/tester/.config/osp"))
935        );
936        assert_eq!(
937            env.cache_root_dir(),
938            Some(PathBuf::from("/home/tester/.cache/osp"))
939        );
940        assert_eq!(
941            env.state_root_dir(),
942            Some(PathBuf::from("/home/tester/.local/state/osp"))
943        );
944
945        let env = RuntimeEnvironment::default();
946        let mut expected_root = std::env::temp_dir();
947        expected_root.push("osp");
948
949        assert_eq!(
950            env.repl_history_path(),
951            expected_root
952                .join("history")
953                .join("${user.name}@${profile.active}.history")
954                .display()
955                .to_string()
956        );
957        assert_eq!(
958            env.log_file_path(),
959            expected_root.join("osp.log").display().to_string()
960        );
961    }
962
963    #[test]
964    fn defaults_only_bootstrap_skips_home_and_override_discovery_unit() {
965        let load = RuntimeLoadOptions::defaults_only();
966        let paths = RuntimeConfigPaths::discover_with(load);
967        let defaults = RuntimeDefaults::from_runtime_load(load, "nord", "osp> ");
968
969        assert_eq!(paths.config_file, None);
970        assert_eq!(paths.secrets_file, None);
971        assert_eq!(defaults.get_string("user.name"), Some("anonymous"));
972        assert_eq!(defaults.get_string("domain"), Some("local"));
973        assert_eq!(defaults.get_string("theme.name"), Some("nord"));
974        assert_eq!(defaults.get_string("repl.prompt"), Some("osp> "));
975        assert_eq!(defaults.get_string("theme.path"), None);
976    }
977}