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 crate::config::{
33    ChainedLoader, ConfigLayer, EnvSecretsLoader, EnvVarLoader, LoaderPipeline, ResolvedConfig,
34    SecretsTomlLoader, StaticLayerLoader, TomlFileLoader,
35};
36
37/// Default logical profile name used when no profile override is active.
38pub const DEFAULT_PROFILE_NAME: &str = "default";
39/// Default maximum number of REPL history entries to keep.
40pub const DEFAULT_REPL_HISTORY_MAX_ENTRIES: i64 = 1000;
41/// Default toggle for persistent REPL history.
42pub const DEFAULT_REPL_HISTORY_ENABLED: bool = true;
43/// Default toggle for deduplicating REPL history entries.
44pub const DEFAULT_REPL_HISTORY_DEDUPE: bool = true;
45/// Default toggle for profile-scoped REPL history storage.
46pub const DEFAULT_REPL_HISTORY_PROFILE_SCOPED: bool = true;
47/// Default maximum number of rows shown in the REPL history search menu.
48pub const DEFAULT_REPL_HISTORY_MENU_ROWS: i64 = 5;
49/// Default upper bound for cached session results.
50pub const DEFAULT_SESSION_CACHE_MAX_RESULTS: i64 = 64;
51/// Default debug verbosity level.
52pub const DEFAULT_DEBUG_LEVEL: i64 = 0;
53/// Default toggle for file logging.
54pub const DEFAULT_LOG_FILE_ENABLED: bool = false;
55/// Default log level used for file logging.
56pub const DEFAULT_LOG_FILE_LEVEL: &str = "warn";
57/// Default render width hint.
58pub const DEFAULT_UI_WIDTH: i64 = 72;
59/// Default left margin for rendered output.
60pub const DEFAULT_UI_MARGIN: i64 = 0;
61/// Default indentation width for nested output.
62pub const DEFAULT_UI_INDENT: i64 = 2;
63/// Default presentation preset name.
64pub const DEFAULT_UI_PRESENTATION: &str = "expressive";
65/// Default semantic guide-format preference.
66pub const DEFAULT_UI_GUIDE_DEFAULT_FORMAT: &str = "guide";
67/// Default grouped-message layout mode.
68pub const DEFAULT_UI_MESSAGES_LAYOUT: &str = "grouped";
69/// Default section chrome frame style.
70pub const DEFAULT_UI_CHROME_FRAME: &str = "top";
71/// Default table border style.
72pub const DEFAULT_UI_TABLE_BORDER: &str = "square";
73/// Default REPL intro mode.
74pub const DEFAULT_REPL_INTRO: &str = "full";
75/// Default threshold for rendering short lists compactly.
76pub const DEFAULT_UI_SHORT_LIST_MAX: i64 = 1;
77/// Default threshold for rendering medium lists before expanding further.
78pub const DEFAULT_UI_MEDIUM_LIST_MAX: i64 = 5;
79/// Default grid column padding.
80pub const DEFAULT_UI_GRID_PADDING: i64 = 4;
81/// Default adaptive grid column weight.
82pub const DEFAULT_UI_COLUMN_WEIGHT: i64 = 3;
83/// Default minimum width before MREG output stacks columns.
84pub const DEFAULT_UI_MREG_STACK_MIN_COL_WIDTH: i64 = 10;
85/// Default threshold for stacked MREG overflow behavior.
86pub const DEFAULT_UI_MREG_STACK_OVERFLOW_RATIO: i64 = 200;
87/// Default table overflow strategy.
88pub const DEFAULT_UI_TABLE_OVERFLOW: &str = "clip";
89
90/// Options that control which runtime config sources are included.
91///
92/// # Examples
93///
94/// ```
95/// use osp_cli::config::RuntimeLoadOptions;
96///
97/// let options = RuntimeLoadOptions::default();
98///
99/// assert!(options.include_env);
100/// assert!(options.include_config_file);
101/// ```
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103#[non_exhaustive]
104pub struct RuntimeLoadOptions {
105    /// Whether environment-derived layers should be loaded.
106    pub include_env: bool,
107    /// Whether file-backed layers should be loaded.
108    pub include_config_file: bool,
109}
110
111impl Default for RuntimeLoadOptions {
112    fn default() -> Self {
113        Self {
114            include_env: true,
115            include_config_file: true,
116        }
117    }
118}
119
120impl RuntimeLoadOptions {
121    /// Creates runtime-load options with the default source set enabled.
122    pub fn new() -> Self {
123        Self::default()
124    }
125
126    /// Sets whether environment-derived layers should be loaded.
127    pub fn with_env(mut self, include_env: bool) -> Self {
128        self.include_env = include_env;
129        self
130    }
131
132    /// Sets whether file-backed layers should be loaded.
133    pub fn with_config_file(mut self, include_config_file: bool) -> Self {
134        self.include_config_file = include_config_file;
135        self
136    }
137}
138
139/// Minimal runtime-derived config that callers often need directly.
140#[derive(Debug, Clone)]
141pub struct RuntimeConfig {
142    /// Active profile name selected for the current invocation.
143    pub active_profile: String,
144}
145
146impl Default for RuntimeConfig {
147    fn default() -> Self {
148        Self {
149            active_profile: DEFAULT_PROFILE_NAME.to_string(),
150        }
151    }
152}
153
154impl RuntimeConfig {
155    /// Extracts the small runtime snapshot most callers need from a resolved config.
156    ///
157    /// # Examples
158    ///
159    /// ```
160    /// use osp_cli::config::{ConfigLayer, ConfigResolver, ResolveOptions, RuntimeConfig};
161    ///
162    /// let mut defaults = ConfigLayer::default();
163    /// defaults.set("profile.default", "default");
164    ///
165    /// let mut resolver = ConfigResolver::default();
166    /// resolver.set_defaults(defaults);
167    /// let resolved = resolver.resolve(ResolveOptions::default()).unwrap();
168    ///
169    /// let runtime = RuntimeConfig::from_resolved(&resolved);
170    /// assert_eq!(runtime.active_profile, "default");
171    /// ```
172    pub fn from_resolved(resolved: &ResolvedConfig) -> Self {
173        Self {
174            active_profile: resolved.active_profile().to_string(),
175        }
176    }
177}
178
179/// Discovered filesystem paths for runtime config inputs.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct RuntimeConfigPaths {
182    /// Path to the ordinary config file, when discovered.
183    pub config_file: Option<PathBuf>,
184    /// Path to the secrets config file, when discovered.
185    pub secrets_file: Option<PathBuf>,
186}
187
188impl RuntimeConfigPaths {
189    /// Discovers config and secrets paths from the current process environment.
190    pub fn discover() -> Self {
191        let paths = Self::from_env(&RuntimeEnvironment::capture());
192        tracing::debug!(
193            config_file = ?paths.config_file.as_ref().map(|path| path.display().to_string()),
194            secrets_file = ?paths.secrets_file.as_ref().map(|path| path.display().to_string()),
195            "discovered runtime config paths"
196        );
197        paths
198    }
199
200    fn from_env(env: &RuntimeEnvironment) -> Self {
201        Self {
202            config_file: env
203                .path_override("OSP_CONFIG_FILE")
204                .or_else(|| env.config_path("config.toml")),
205            secrets_file: env
206                .path_override("OSP_SECRETS_FILE")
207                .or_else(|| env.config_path("secrets.toml")),
208        }
209    }
210}
211
212/// Built-in default values seeded before user-provided config is loaded.
213#[derive(Debug, Clone, Default)]
214pub struct RuntimeDefaults {
215    layer: ConfigLayer,
216}
217
218impl RuntimeDefaults {
219    /// Builds the default layer using the current process environment.
220    pub fn from_process_env(default_theme_name: &str, default_repl_prompt: &str) -> Self {
221        Self::from_env(
222            &RuntimeEnvironment::capture(),
223            default_theme_name,
224            default_repl_prompt,
225        )
226    }
227
228    fn from_env(
229        env: &RuntimeEnvironment,
230        default_theme_name: &str,
231        default_repl_prompt: &str,
232    ) -> Self {
233        let mut layer = ConfigLayer::default();
234
235        macro_rules! set_defaults {
236            ($($key:literal => $value:expr),* $(,)?) => {
237                $(layer.set($key, $value);)*
238            };
239        }
240
241        set_defaults! {
242            "profile.default" => DEFAULT_PROFILE_NAME.to_string(),
243            "theme.name" => default_theme_name.to_string(),
244            "user.name" => env.user_name(),
245            "domain" => env.domain_name(),
246            "repl.prompt" => default_repl_prompt.to_string(),
247            "repl.input_mode" => "auto".to_string(),
248            "repl.simple_prompt" => false,
249            "repl.shell_indicator" => "[{shell}]".to_string(),
250            "repl.intro" => DEFAULT_REPL_INTRO.to_string(),
251            "repl.history.path" => env.repl_history_path(),
252            "repl.history.max_entries" => DEFAULT_REPL_HISTORY_MAX_ENTRIES,
253            "repl.history.enabled" => DEFAULT_REPL_HISTORY_ENABLED,
254            "repl.history.dedupe" => DEFAULT_REPL_HISTORY_DEDUPE,
255            "repl.history.profile_scoped" => DEFAULT_REPL_HISTORY_PROFILE_SCOPED,
256            "repl.history.menu_rows" => DEFAULT_REPL_HISTORY_MENU_ROWS,
257            "session.cache.max_results" => DEFAULT_SESSION_CACHE_MAX_RESULTS,
258            "debug.level" => DEFAULT_DEBUG_LEVEL,
259            "log.file.enabled" => DEFAULT_LOG_FILE_ENABLED,
260            "log.file.path" => env.log_file_path(),
261            "log.file.level" => DEFAULT_LOG_FILE_LEVEL.to_string(),
262            "ui.width" => DEFAULT_UI_WIDTH,
263            "ui.margin" => DEFAULT_UI_MARGIN,
264            "ui.indent" => DEFAULT_UI_INDENT,
265            "ui.presentation" => DEFAULT_UI_PRESENTATION.to_string(),
266            "ui.help.level" => "inherit".to_string(),
267            "ui.guide.default_format" => DEFAULT_UI_GUIDE_DEFAULT_FORMAT.to_string(),
268            "ui.messages.layout" => DEFAULT_UI_MESSAGES_LAYOUT.to_string(),
269            "ui.message.verbosity" => "success".to_string(),
270            "ui.chrome.frame" => DEFAULT_UI_CHROME_FRAME.to_string(),
271            "ui.chrome.rule_policy" => "per-section".to_string(),
272            "ui.table.overflow" => DEFAULT_UI_TABLE_OVERFLOW.to_string(),
273            "ui.table.border" => DEFAULT_UI_TABLE_BORDER.to_string(),
274            "ui.help.table_chrome" => "none".to_string(),
275            "ui.help.entry_indent" => "inherit".to_string(),
276            "ui.help.entry_gap" => "inherit".to_string(),
277            "ui.help.section_spacing" => "inherit".to_string(),
278            "ui.short_list_max" => DEFAULT_UI_SHORT_LIST_MAX,
279            "ui.medium_list_max" => DEFAULT_UI_MEDIUM_LIST_MAX,
280            "ui.grid_padding" => DEFAULT_UI_GRID_PADDING,
281            "ui.column_weight" => DEFAULT_UI_COLUMN_WEIGHT,
282            "ui.mreg.stack_min_col_width" => DEFAULT_UI_MREG_STACK_MIN_COL_WIDTH,
283            "ui.mreg.stack_overflow_ratio" => DEFAULT_UI_MREG_STACK_OVERFLOW_RATIO,
284            "extensions.plugins.timeout_ms" => 10_000,
285            "extensions.plugins.discovery.path" => false,
286        }
287
288        let theme_path = env.theme_paths();
289        if !theme_path.is_empty() {
290            layer.set("theme.path", theme_path);
291        }
292
293        for key in [
294            "color.text",
295            "color.text.muted",
296            "color.key",
297            "color.border",
298            "color.prompt.text",
299            "color.prompt.command",
300            "color.table.header",
301            "color.mreg.key",
302            "color.value",
303            "color.value.number",
304            "color.value.bool_true",
305            "color.value.bool_false",
306            "color.value.null",
307            "color.value.ipv4",
308            "color.value.ipv6",
309            "color.panel.border",
310            "color.panel.title",
311            "color.code",
312            "color.json.key",
313        ] {
314            layer.set(key, String::new());
315        }
316
317        Self { layer }
318    }
319
320    /// Returns a default string value by key from the global scope.
321    ///
322    /// # Examples
323    ///
324    /// ```
325    /// use osp_cli::config::RuntimeDefaults;
326    ///
327    /// let defaults = RuntimeDefaults::from_process_env("dracula", "> ");
328    ///
329    /// assert_eq!(defaults.get_string("theme.name"), Some("dracula"));
330    /// assert_eq!(defaults.get_string("repl.prompt"), Some("> "));
331    /// ```
332    pub fn get_string(&self, key: &str) -> Option<&str> {
333        self.layer
334            .entries()
335            .iter()
336            .find(|entry| entry.key == key && entry.scope == crate::config::Scope::global())
337            .and_then(|entry| match entry.value.reveal() {
338                crate::config::ConfigValue::String(value) => Some(value.as_str()),
339                _ => None,
340            })
341    }
342
343    /// Clones the defaults as a standalone config layer.
344    ///
345    /// # Examples
346    ///
347    /// ```
348    /// use osp_cli::config::RuntimeDefaults;
349    ///
350    /// let defaults = RuntimeDefaults::from_process_env("plain", "> ");
351    /// let layer = defaults.to_layer();
352    ///
353    /// assert!(layer.entries().iter().any(|entry| entry.key == "theme.name"));
354    /// ```
355    pub fn to_layer(&self) -> ConfigLayer {
356        self.layer.clone()
357    }
358}
359
360/// Assembles the runtime loader precedence stack for CLI startup.
361///
362/// The ordering encoded here is part of the config contract: defaults first,
363/// then optional presentation/env/file/secrets layers, then CLI/session
364/// overrides last.
365pub fn build_runtime_pipeline(
366    defaults: ConfigLayer,
367    presentation: Option<ConfigLayer>,
368    paths: &RuntimeConfigPaths,
369    load: RuntimeLoadOptions,
370    cli: Option<ConfigLayer>,
371    session: Option<ConfigLayer>,
372) -> LoaderPipeline {
373    tracing::debug!(
374        include_env = load.include_env,
375        include_config_file = load.include_config_file,
376        config_file = ?paths.config_file.as_ref().map(|path| path.display().to_string()),
377        secrets_file = ?paths.secrets_file.as_ref().map(|path| path.display().to_string()),
378        has_presentation_layer = presentation.is_some(),
379        has_cli_layer = cli.is_some(),
380        has_session_layer = session.is_some(),
381        defaults_entries = defaults.entries().len(),
382        "building runtime loader pipeline"
383    );
384    let mut pipeline = LoaderPipeline::new(StaticLayerLoader::new(defaults));
385
386    if let Some(presentation_layer) = presentation {
387        pipeline = pipeline.with_presentation(StaticLayerLoader::new(presentation_layer));
388    }
389
390    if load.include_env {
391        pipeline = pipeline.with_env(EnvVarLoader::from_process_env());
392    }
393
394    if load.include_config_file
395        && let Some(path) = &paths.config_file
396    {
397        pipeline = pipeline.with_file(TomlFileLoader::new(path.clone()).optional());
398    }
399
400    if let Some(path) = &paths.secrets_file {
401        let mut secret_chain = ChainedLoader::new(SecretsTomlLoader::new(path.clone()).optional());
402        if load.include_env {
403            secret_chain = secret_chain.with(EnvSecretsLoader::from_process_env());
404        }
405        pipeline = pipeline.with_secrets(secret_chain);
406    } else if load.include_env {
407        pipeline = pipeline.with_secrets(ChainedLoader::new(EnvSecretsLoader::from_process_env()));
408    }
409
410    if let Some(cli_layer) = cli {
411        pipeline = pipeline.with_cli(StaticLayerLoader::new(cli_layer));
412    }
413    if let Some(session_layer) = session {
414        pipeline = pipeline.with_session(StaticLayerLoader::new(session_layer));
415    }
416
417    pipeline
418}
419
420/// Resolves the default XDG-style config root from the current process environment.
421pub fn default_config_root_dir() -> Option<PathBuf> {
422    RuntimeEnvironment::capture().config_root_dir()
423}
424
425/// Resolves the default XDG-style cache root from the current process environment.
426pub fn default_cache_root_dir() -> Option<PathBuf> {
427    RuntimeEnvironment::capture().cache_root_dir()
428}
429
430/// Resolves the default XDG-style state root from the current process environment.
431pub fn default_state_root_dir() -> Option<PathBuf> {
432    RuntimeEnvironment::capture().state_root_dir()
433}
434
435#[derive(Debug, Clone, Default)]
436struct RuntimeEnvironment {
437    vars: BTreeMap<String, String>,
438}
439
440impl RuntimeEnvironment {
441    fn capture() -> Self {
442        Self::from_pairs(std::env::vars())
443    }
444
445    fn from_pairs<I, K, V>(vars: I) -> Self
446    where
447        I: IntoIterator<Item = (K, V)>,
448        K: AsRef<str>,
449        V: AsRef<str>,
450    {
451        Self {
452            vars: vars
453                .into_iter()
454                .map(|(key, value)| (key.as_ref().to_string(), value.as_ref().to_string()))
455                .collect(),
456        }
457    }
458
459    fn config_root_dir(&self) -> Option<PathBuf> {
460        self.xdg_root_dir("XDG_CONFIG_HOME", &[".config"])
461    }
462
463    fn cache_root_dir(&self) -> Option<PathBuf> {
464        self.xdg_root_dir("XDG_CACHE_HOME", &[".cache"])
465    }
466
467    fn state_root_dir(&self) -> Option<PathBuf> {
468        self.xdg_root_dir("XDG_STATE_HOME", &[".local", "state"])
469    }
470
471    fn config_path(&self, leaf: &str) -> Option<PathBuf> {
472        self.config_root_dir().map(|root| join_path(root, &[leaf]))
473    }
474
475    fn theme_paths(&self) -> Vec<String> {
476        self.config_root_dir()
477            .map(|root| join_path(root, &["themes"]).to_string_lossy().to_string())
478            .into_iter()
479            .collect()
480    }
481
482    fn user_name(&self) -> String {
483        self.get_nonempty("USER")
484            .or_else(|| self.get_nonempty("USERNAME"))
485            .map(ToOwned::to_owned)
486            .unwrap_or_else(|| "anonymous".to_string())
487    }
488
489    fn domain_name(&self) -> String {
490        self.get_nonempty("HOSTNAME")
491            .or_else(|| self.get_nonempty("COMPUTERNAME"))
492            .unwrap_or("localhost")
493            .split_once('.')
494            .map(|(_, domain)| domain.to_string())
495            .filter(|domain| !domain.trim().is_empty())
496            .unwrap_or_else(|| "local".to_string())
497    }
498
499    fn repl_history_path(&self) -> String {
500        join_path(
501            self.state_root_dir_or_temp(),
502            &["history", "${user.name}@${profile.active}.history"],
503        )
504        .display()
505        .to_string()
506    }
507
508    fn log_file_path(&self) -> String {
509        join_path(self.state_root_dir_or_temp(), &["osp.log"])
510            .display()
511            .to_string()
512    }
513
514    fn path_override(&self, key: &str) -> Option<PathBuf> {
515        self.get_nonempty(key).map(PathBuf::from)
516    }
517
518    fn state_root_dir_or_temp(&self) -> PathBuf {
519        self.state_root_dir().unwrap_or_else(|| {
520            let mut path = std::env::temp_dir();
521            path.push("osp");
522            path
523        })
524    }
525
526    fn xdg_root_dir(&self, xdg_var: &str, home_suffix: &[&str]) -> Option<PathBuf> {
527        if let Some(path) = self.get_nonempty(xdg_var) {
528            return Some(join_path(PathBuf::from(path), &["osp"]));
529        }
530
531        let home = self.get_nonempty("HOME")?;
532        Some(join_path(PathBuf::from(home), home_suffix).join("osp"))
533    }
534
535    fn get_nonempty(&self, key: &str) -> Option<&str> {
536        self.vars
537            .get(key)
538            .map(String::as_str)
539            .map(str::trim)
540            .filter(|value| !value.is_empty())
541    }
542}
543
544fn join_path(mut root: PathBuf, segments: &[&str]) -> PathBuf {
545    for segment in segments {
546        root.push(segment);
547    }
548    root
549}
550
551#[cfg(test)]
552mod tests {
553    use std::path::PathBuf;
554
555    use super::{DEFAULT_PROFILE_NAME, RuntimeConfigPaths, RuntimeDefaults, RuntimeEnvironment};
556    use crate::config::{ConfigLayer, ConfigValue, Scope};
557
558    fn find_value<'a>(layer: &'a ConfigLayer, key: &str) -> Option<&'a ConfigValue> {
559        layer
560            .entries()
561            .iter()
562            .find(|entry| entry.key == key && entry.scope == Scope::global())
563            .map(|entry| &entry.value)
564    }
565
566    #[test]
567    fn runtime_defaults_seed_expected_keys() {
568        let defaults =
569            RuntimeDefaults::from_env(&RuntimeEnvironment::default(), "nord", "osp> ").to_layer();
570
571        assert_eq!(
572            find_value(&defaults, "profile.default"),
573            Some(&ConfigValue::String(DEFAULT_PROFILE_NAME.to_string()))
574        );
575        assert_eq!(
576            find_value(&defaults, "theme.name"),
577            Some(&ConfigValue::String("nord".to_string()))
578        );
579        assert_eq!(
580            find_value(&defaults, "repl.prompt"),
581            Some(&ConfigValue::String("osp> ".to_string()))
582        );
583        assert_eq!(
584            find_value(&defaults, "repl.intro"),
585            Some(&ConfigValue::String(super::DEFAULT_REPL_INTRO.to_string()))
586        );
587        assert_eq!(
588            find_value(&defaults, "repl.history.max_entries"),
589            Some(&ConfigValue::Integer(
590                super::DEFAULT_REPL_HISTORY_MAX_ENTRIES
591            ))
592        );
593        assert_eq!(
594            find_value(&defaults, "repl.history.menu_rows"),
595            Some(&ConfigValue::Integer(super::DEFAULT_REPL_HISTORY_MENU_ROWS))
596        );
597        assert_eq!(
598            find_value(&defaults, "ui.width"),
599            Some(&ConfigValue::Integer(super::DEFAULT_UI_WIDTH))
600        );
601        assert_eq!(
602            find_value(&defaults, "ui.presentation"),
603            Some(&ConfigValue::String(
604                super::DEFAULT_UI_PRESENTATION.to_string()
605            ))
606        );
607        assert_eq!(
608            find_value(&defaults, "ui.help.level"),
609            Some(&ConfigValue::String("inherit".to_string()))
610        );
611        assert_eq!(
612            find_value(&defaults, "ui.messages.layout"),
613            Some(&ConfigValue::String(
614                super::DEFAULT_UI_MESSAGES_LAYOUT.to_string()
615            ))
616        );
617        assert_eq!(
618            find_value(&defaults, "ui.message.verbosity"),
619            Some(&ConfigValue::String("success".to_string()))
620        );
621        assert_eq!(
622            find_value(&defaults, "ui.chrome.frame"),
623            Some(&ConfigValue::String(
624                super::DEFAULT_UI_CHROME_FRAME.to_string()
625            ))
626        );
627        assert_eq!(
628            find_value(&defaults, "ui.table.border"),
629            Some(&ConfigValue::String(
630                super::DEFAULT_UI_TABLE_BORDER.to_string()
631            ))
632        );
633        assert_eq!(
634            find_value(&defaults, "color.prompt.text"),
635            Some(&ConfigValue::String(String::new()))
636        );
637    }
638
639    #[test]
640    fn runtime_defaults_history_path_keeps_placeholders() {
641        let defaults =
642            RuntimeDefaults::from_env(&RuntimeEnvironment::default(), "nord", "osp> ").to_layer();
643        let path = match find_value(&defaults, "repl.history.path") {
644            Some(ConfigValue::String(value)) => value.as_str(),
645            other => panic!("unexpected history path value: {other:?}"),
646        };
647
648        assert!(path.contains("${user.name}@${profile.active}.history"));
649    }
650
651    #[test]
652    fn runtime_config_paths_prefer_explicit_file_overrides() {
653        let env = RuntimeEnvironment::from_pairs([
654            ("OSP_CONFIG_FILE", "/tmp/custom-config.toml"),
655            ("OSP_SECRETS_FILE", "/tmp/custom-secrets.toml"),
656            ("XDG_CONFIG_HOME", "/ignored"),
657        ]);
658
659        let paths = RuntimeConfigPaths::from_env(&env);
660
661        assert_eq!(
662            paths.config_file,
663            Some(PathBuf::from("/tmp/custom-config.toml"))
664        );
665        assert_eq!(
666            paths.secrets_file,
667            Some(PathBuf::from("/tmp/custom-secrets.toml"))
668        );
669    }
670
671    #[test]
672    fn runtime_config_paths_fall_back_to_xdg_root() {
673        let env = RuntimeEnvironment::from_pairs([("XDG_CONFIG_HOME", "/var/tmp/xdg-config")]);
674
675        let paths = RuntimeConfigPaths::from_env(&env);
676
677        assert_eq!(
678            paths.config_file,
679            Some(PathBuf::from("/var/tmp/xdg-config/osp/config.toml"))
680        );
681        assert_eq!(
682            paths.secrets_file,
683            Some(PathBuf::from("/var/tmp/xdg-config/osp/secrets.toml"))
684        );
685    }
686
687    #[test]
688    fn runtime_environment_uses_home_when_xdg_is_missing() {
689        let env = RuntimeEnvironment::from_pairs([("HOME", "/home/tester")]);
690
691        assert_eq!(
692            env.config_root_dir(),
693            Some(PathBuf::from("/home/tester/.config/osp"))
694        );
695        assert_eq!(
696            env.cache_root_dir(),
697            Some(PathBuf::from("/home/tester/.cache/osp"))
698        );
699        assert_eq!(
700            env.state_root_dir(),
701            Some(PathBuf::from("/home/tester/.local/state/osp"))
702        );
703    }
704
705    #[test]
706    fn runtime_environment_state_artifacts_fall_back_to_temp_root() {
707        let env = RuntimeEnvironment::default();
708        let mut expected_root = std::env::temp_dir();
709        expected_root.push("osp");
710
711        assert_eq!(
712            env.repl_history_path(),
713            expected_root
714                .join("history")
715                .join("${user.name}@${profile.active}.history")
716                .display()
717                .to_string()
718        );
719        assert_eq!(
720            env.log_file_path(),
721            expected_root.join("osp.log").display().to_string()
722        );
723    }
724}