Skip to main content

osp_cli/config/
runtime.rs

1use std::{collections::BTreeMap, path::PathBuf};
2
3use crate::config::{
4    ChainedLoader, ConfigLayer, EnvSecretsLoader, EnvVarLoader, LoaderPipeline, ResolvedConfig,
5    SecretsTomlLoader, StaticLayerLoader, TomlFileLoader,
6};
7
8pub const DEFAULT_PROFILE_NAME: &str = "default";
9pub const DEFAULT_REPL_HISTORY_MAX_ENTRIES: i64 = 1000;
10pub const DEFAULT_REPL_HISTORY_ENABLED: bool = true;
11pub const DEFAULT_REPL_HISTORY_DEDUPE: bool = true;
12pub const DEFAULT_REPL_HISTORY_PROFILE_SCOPED: bool = true;
13pub const DEFAULT_SESSION_CACHE_MAX_RESULTS: i64 = 64;
14pub const DEFAULT_DEBUG_LEVEL: i64 = 0;
15pub const DEFAULT_LOG_FILE_ENABLED: bool = false;
16pub const DEFAULT_LOG_FILE_LEVEL: &str = "warn";
17pub const DEFAULT_UI_WIDTH: i64 = 72;
18pub const DEFAULT_UI_MARGIN: i64 = 0;
19pub const DEFAULT_UI_INDENT: i64 = 2;
20pub const DEFAULT_UI_PRESENTATION: &str = "expressive";
21pub const DEFAULT_UI_HELP_LAYOUT: &str = "full";
22pub const DEFAULT_UI_MESSAGES_LAYOUT: &str = "grouped";
23pub const DEFAULT_UI_CHROME_FRAME: &str = "top";
24pub const DEFAULT_UI_TABLE_BORDER: &str = "square";
25pub const DEFAULT_REPL_INTRO: &str = "full";
26pub const DEFAULT_UI_SHORT_LIST_MAX: i64 = 1;
27pub const DEFAULT_UI_MEDIUM_LIST_MAX: i64 = 5;
28pub const DEFAULT_UI_GRID_PADDING: i64 = 4;
29pub const DEFAULT_UI_COLUMN_WEIGHT: i64 = 3;
30pub const DEFAULT_UI_MREG_STACK_MIN_COL_WIDTH: i64 = 10;
31pub const DEFAULT_UI_MREG_STACK_OVERFLOW_RATIO: i64 = 200;
32pub const DEFAULT_UI_TABLE_OVERFLOW: &str = "clip";
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub struct RuntimeLoadOptions {
36    pub include_env: bool,
37    pub include_config_file: bool,
38}
39
40impl Default for RuntimeLoadOptions {
41    fn default() -> Self {
42        Self {
43            include_env: true,
44            include_config_file: true,
45        }
46    }
47}
48
49#[derive(Debug, Clone)]
50pub struct RuntimeConfig {
51    pub active_profile: String,
52}
53
54impl Default for RuntimeConfig {
55    fn default() -> Self {
56        Self {
57            active_profile: DEFAULT_PROFILE_NAME.to_string(),
58        }
59    }
60}
61
62impl RuntimeConfig {
63    pub fn from_resolved(resolved: &ResolvedConfig) -> Self {
64        Self {
65            active_profile: resolved.active_profile().to_string(),
66        }
67    }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct RuntimeConfigPaths {
72    pub config_file: Option<PathBuf>,
73    pub secrets_file: Option<PathBuf>,
74}
75
76impl RuntimeConfigPaths {
77    pub fn discover() -> Self {
78        let paths = Self::from_env(&RuntimeEnvironment::capture());
79        tracing::debug!(
80            config_file = ?paths.config_file.as_ref().map(|path| path.display().to_string()),
81            secrets_file = ?paths.secrets_file.as_ref().map(|path| path.display().to_string()),
82            "discovered runtime config paths"
83        );
84        paths
85    }
86
87    fn from_env(env: &RuntimeEnvironment) -> Self {
88        Self {
89            config_file: env
90                .path_override("OSP_CONFIG_FILE")
91                .or_else(|| env.config_path("config.toml")),
92            secrets_file: env
93                .path_override("OSP_SECRETS_FILE")
94                .or_else(|| env.config_path("secrets.toml")),
95        }
96    }
97}
98
99#[derive(Debug, Clone, Default)]
100pub struct RuntimeDefaults {
101    layer: ConfigLayer,
102}
103
104impl RuntimeDefaults {
105    pub fn from_process_env(default_theme_name: &str, default_repl_prompt: &str) -> Self {
106        Self::from_env(
107            &RuntimeEnvironment::capture(),
108            default_theme_name,
109            default_repl_prompt,
110        )
111    }
112
113    fn from_env(
114        env: &RuntimeEnvironment,
115        default_theme_name: &str,
116        default_repl_prompt: &str,
117    ) -> Self {
118        let mut layer = ConfigLayer::default();
119
120        macro_rules! set_defaults {
121            ($($key:literal => $value:expr),* $(,)?) => {
122                $(layer.set($key, $value);)*
123            };
124        }
125
126        set_defaults! {
127            "profile.default" => DEFAULT_PROFILE_NAME.to_string(),
128            "theme.name" => default_theme_name.to_string(),
129            "user.name" => env.user_name(),
130            "domain" => env.domain_name(),
131            "repl.prompt" => default_repl_prompt.to_string(),
132            "repl.input_mode" => "auto".to_string(),
133            "repl.simple_prompt" => false,
134            "repl.shell_indicator" => "[{shell}]".to_string(),
135            "repl.intro" => DEFAULT_REPL_INTRO.to_string(),
136            "repl.history.path" => env.repl_history_path(),
137            "repl.history.max_entries" => DEFAULT_REPL_HISTORY_MAX_ENTRIES,
138            "repl.history.enabled" => DEFAULT_REPL_HISTORY_ENABLED,
139            "repl.history.dedupe" => DEFAULT_REPL_HISTORY_DEDUPE,
140            "repl.history.profile_scoped" => DEFAULT_REPL_HISTORY_PROFILE_SCOPED,
141            "session.cache.max_results" => DEFAULT_SESSION_CACHE_MAX_RESULTS,
142            "debug.level" => DEFAULT_DEBUG_LEVEL,
143            "log.file.enabled" => DEFAULT_LOG_FILE_ENABLED,
144            "log.file.path" => env.log_file_path(),
145            "log.file.level" => DEFAULT_LOG_FILE_LEVEL.to_string(),
146            "ui.width" => DEFAULT_UI_WIDTH,
147            "ui.margin" => DEFAULT_UI_MARGIN,
148            "ui.indent" => DEFAULT_UI_INDENT,
149            "ui.presentation" => DEFAULT_UI_PRESENTATION.to_string(),
150            "ui.help.layout" => DEFAULT_UI_HELP_LAYOUT.to_string(),
151            "ui.messages.layout" => DEFAULT_UI_MESSAGES_LAYOUT.to_string(),
152            "ui.chrome.frame" => DEFAULT_UI_CHROME_FRAME.to_string(),
153            "ui.table.overflow" => DEFAULT_UI_TABLE_OVERFLOW.to_string(),
154            "ui.table.border" => DEFAULT_UI_TABLE_BORDER.to_string(),
155            "ui.short_list_max" => DEFAULT_UI_SHORT_LIST_MAX,
156            "ui.medium_list_max" => DEFAULT_UI_MEDIUM_LIST_MAX,
157            "ui.grid_padding" => DEFAULT_UI_GRID_PADDING,
158            "ui.column_weight" => DEFAULT_UI_COLUMN_WEIGHT,
159            "ui.mreg.stack_min_col_width" => DEFAULT_UI_MREG_STACK_MIN_COL_WIDTH,
160            "ui.mreg.stack_overflow_ratio" => DEFAULT_UI_MREG_STACK_OVERFLOW_RATIO,
161            "extensions.plugins.timeout_ms" => 10_000,
162            "extensions.plugins.discovery.path" => false,
163        }
164
165        let theme_path = env.theme_paths();
166        if !theme_path.is_empty() {
167            layer.set("theme.path", theme_path);
168        }
169
170        for key in [
171            "color.text",
172            "color.text.muted",
173            "color.key",
174            "color.border",
175            "color.prompt.text",
176            "color.prompt.command",
177            "color.table.header",
178            "color.mreg.key",
179            "color.value",
180            "color.value.number",
181            "color.value.bool_true",
182            "color.value.bool_false",
183            "color.value.null",
184            "color.value.ipv4",
185            "color.value.ipv6",
186            "color.panel.border",
187            "color.panel.title",
188            "color.code",
189            "color.json.key",
190        ] {
191            layer.set(key, String::new());
192        }
193
194        Self { layer }
195    }
196
197    pub fn get_string(&self, key: &str) -> Option<&str> {
198        self.layer
199            .entries()
200            .iter()
201            .find(|entry| entry.key == key && entry.scope == crate::config::Scope::global())
202            .and_then(|entry| match entry.value.reveal() {
203                crate::config::ConfigValue::String(value) => Some(value.as_str()),
204                _ => None,
205            })
206    }
207
208    pub fn to_layer(&self) -> ConfigLayer {
209        self.layer.clone()
210    }
211}
212
213pub fn build_runtime_pipeline(
214    defaults: ConfigLayer,
215    presentation: Option<ConfigLayer>,
216    paths: &RuntimeConfigPaths,
217    load: RuntimeLoadOptions,
218    cli: Option<ConfigLayer>,
219    session: Option<ConfigLayer>,
220) -> LoaderPipeline {
221    tracing::debug!(
222        include_env = load.include_env,
223        include_config_file = load.include_config_file,
224        config_file = ?paths.config_file.as_ref().map(|path| path.display().to_string()),
225        secrets_file = ?paths.secrets_file.as_ref().map(|path| path.display().to_string()),
226        has_presentation_layer = presentation.is_some(),
227        has_cli_layer = cli.is_some(),
228        has_session_layer = session.is_some(),
229        defaults_entries = defaults.entries().len(),
230        "building runtime loader pipeline"
231    );
232    let mut pipeline = LoaderPipeline::new(StaticLayerLoader::new(defaults));
233
234    if let Some(presentation_layer) = presentation {
235        pipeline = pipeline.with_presentation(StaticLayerLoader::new(presentation_layer));
236    }
237
238    if load.include_env {
239        pipeline = pipeline.with_env(EnvVarLoader::from_process_env());
240    }
241
242    if load.include_config_file
243        && let Some(path) = &paths.config_file
244    {
245        pipeline = pipeline.with_file(TomlFileLoader::new(path.clone()).optional());
246    }
247
248    if let Some(path) = &paths.secrets_file {
249        let mut secret_chain = ChainedLoader::new(SecretsTomlLoader::new(path.clone()).optional());
250        if load.include_env {
251            secret_chain = secret_chain.with(EnvSecretsLoader::from_process_env());
252        }
253        pipeline = pipeline.with_secrets(secret_chain);
254    } else if load.include_env {
255        pipeline = pipeline.with_secrets(ChainedLoader::new(EnvSecretsLoader::from_process_env()));
256    }
257
258    if let Some(cli_layer) = cli {
259        pipeline = pipeline.with_cli(StaticLayerLoader::new(cli_layer));
260    }
261    if let Some(session_layer) = session {
262        pipeline = pipeline.with_session(StaticLayerLoader::new(session_layer));
263    }
264
265    pipeline
266}
267
268pub fn default_config_root_dir() -> Option<PathBuf> {
269    RuntimeEnvironment::capture().config_root_dir()
270}
271
272pub fn default_cache_root_dir() -> Option<PathBuf> {
273    RuntimeEnvironment::capture().cache_root_dir()
274}
275
276pub fn default_state_root_dir() -> Option<PathBuf> {
277    RuntimeEnvironment::capture().state_root_dir()
278}
279
280#[derive(Debug, Clone, Default)]
281struct RuntimeEnvironment {
282    vars: BTreeMap<String, String>,
283}
284
285impl RuntimeEnvironment {
286    fn capture() -> Self {
287        Self::from_pairs(std::env::vars())
288    }
289
290    fn from_pairs<I, K, V>(vars: I) -> Self
291    where
292        I: IntoIterator<Item = (K, V)>,
293        K: AsRef<str>,
294        V: AsRef<str>,
295    {
296        Self {
297            vars: vars
298                .into_iter()
299                .map(|(key, value)| (key.as_ref().to_string(), value.as_ref().to_string()))
300                .collect(),
301        }
302    }
303
304    fn config_root_dir(&self) -> Option<PathBuf> {
305        self.xdg_root_dir("XDG_CONFIG_HOME", &[".config"])
306    }
307
308    fn cache_root_dir(&self) -> Option<PathBuf> {
309        self.xdg_root_dir("XDG_CACHE_HOME", &[".cache"])
310    }
311
312    fn state_root_dir(&self) -> Option<PathBuf> {
313        self.xdg_root_dir("XDG_STATE_HOME", &[".local", "state"])
314    }
315
316    fn config_path(&self, leaf: &str) -> Option<PathBuf> {
317        self.config_root_dir().map(|root| join_path(root, &[leaf]))
318    }
319
320    fn theme_paths(&self) -> Vec<String> {
321        self.config_root_dir()
322            .map(|root| join_path(root, &["themes"]).to_string_lossy().to_string())
323            .into_iter()
324            .collect()
325    }
326
327    fn user_name(&self) -> String {
328        self.get_nonempty("USER")
329            .or_else(|| self.get_nonempty("USERNAME"))
330            .map(ToOwned::to_owned)
331            .unwrap_or_else(|| "anonymous".to_string())
332    }
333
334    fn domain_name(&self) -> String {
335        self.get_nonempty("HOSTNAME")
336            .or_else(|| self.get_nonempty("COMPUTERNAME"))
337            .unwrap_or("localhost")
338            .split_once('.')
339            .map(|(_, domain)| domain.to_string())
340            .filter(|domain| !domain.trim().is_empty())
341            .unwrap_or_else(|| "local".to_string())
342    }
343
344    fn repl_history_path(&self) -> String {
345        join_path(
346            self.state_root_dir_or_temp(),
347            &["history", "${user.name}@${profile.active}.history"],
348        )
349        .display()
350        .to_string()
351    }
352
353    fn log_file_path(&self) -> String {
354        join_path(self.state_root_dir_or_temp(), &["osp.log"])
355            .display()
356            .to_string()
357    }
358
359    fn path_override(&self, key: &str) -> Option<PathBuf> {
360        self.get_nonempty(key).map(PathBuf::from)
361    }
362
363    fn state_root_dir_or_temp(&self) -> PathBuf {
364        self.state_root_dir().unwrap_or_else(|| {
365            let mut path = std::env::temp_dir();
366            path.push("osp");
367            path
368        })
369    }
370
371    fn xdg_root_dir(&self, xdg_var: &str, home_suffix: &[&str]) -> Option<PathBuf> {
372        if let Some(path) = self.get_nonempty(xdg_var) {
373            return Some(join_path(PathBuf::from(path), &["osp"]));
374        }
375
376        let home = self.get_nonempty("HOME")?;
377        Some(join_path(PathBuf::from(home), home_suffix).join("osp"))
378    }
379
380    fn get_nonempty(&self, key: &str) -> Option<&str> {
381        self.vars
382            .get(key)
383            .map(String::as_str)
384            .map(str::trim)
385            .filter(|value| !value.is_empty())
386    }
387}
388
389fn join_path(mut root: PathBuf, segments: &[&str]) -> PathBuf {
390    for segment in segments {
391        root.push(segment);
392    }
393    root
394}
395
396#[cfg(test)]
397mod tests {
398    use std::path::PathBuf;
399
400    use super::{DEFAULT_PROFILE_NAME, RuntimeConfigPaths, RuntimeDefaults, RuntimeEnvironment};
401    use crate::config::{ConfigLayer, ConfigValue, Scope};
402
403    fn find_value<'a>(layer: &'a ConfigLayer, key: &str) -> Option<&'a ConfigValue> {
404        layer
405            .entries()
406            .iter()
407            .find(|entry| entry.key == key && entry.scope == Scope::global())
408            .map(|entry| &entry.value)
409    }
410
411    #[test]
412    fn runtime_defaults_seed_expected_keys() {
413        let defaults =
414            RuntimeDefaults::from_env(&RuntimeEnvironment::default(), "nord", "osp> ").to_layer();
415
416        assert_eq!(
417            find_value(&defaults, "profile.default"),
418            Some(&ConfigValue::String(DEFAULT_PROFILE_NAME.to_string()))
419        );
420        assert_eq!(
421            find_value(&defaults, "theme.name"),
422            Some(&ConfigValue::String("nord".to_string()))
423        );
424        assert_eq!(
425            find_value(&defaults, "repl.prompt"),
426            Some(&ConfigValue::String("osp> ".to_string()))
427        );
428        assert_eq!(
429            find_value(&defaults, "repl.intro"),
430            Some(&ConfigValue::String(super::DEFAULT_REPL_INTRO.to_string()))
431        );
432        assert_eq!(
433            find_value(&defaults, "repl.history.max_entries"),
434            Some(&ConfigValue::Integer(
435                super::DEFAULT_REPL_HISTORY_MAX_ENTRIES
436            ))
437        );
438        assert_eq!(
439            find_value(&defaults, "ui.width"),
440            Some(&ConfigValue::Integer(super::DEFAULT_UI_WIDTH))
441        );
442        assert_eq!(
443            find_value(&defaults, "ui.presentation"),
444            Some(&ConfigValue::String(
445                super::DEFAULT_UI_PRESENTATION.to_string()
446            ))
447        );
448        assert_eq!(
449            find_value(&defaults, "ui.help.layout"),
450            Some(&ConfigValue::String(
451                super::DEFAULT_UI_HELP_LAYOUT.to_string()
452            ))
453        );
454        assert_eq!(
455            find_value(&defaults, "ui.messages.layout"),
456            Some(&ConfigValue::String(
457                super::DEFAULT_UI_MESSAGES_LAYOUT.to_string()
458            ))
459        );
460        assert_eq!(
461            find_value(&defaults, "ui.chrome.frame"),
462            Some(&ConfigValue::String(
463                super::DEFAULT_UI_CHROME_FRAME.to_string()
464            ))
465        );
466        assert_eq!(
467            find_value(&defaults, "ui.table.border"),
468            Some(&ConfigValue::String(
469                super::DEFAULT_UI_TABLE_BORDER.to_string()
470            ))
471        );
472        assert_eq!(
473            find_value(&defaults, "color.prompt.text"),
474            Some(&ConfigValue::String(String::new()))
475        );
476    }
477
478    #[test]
479    fn runtime_defaults_history_path_keeps_placeholders() {
480        let defaults =
481            RuntimeDefaults::from_env(&RuntimeEnvironment::default(), "nord", "osp> ").to_layer();
482        let path = match find_value(&defaults, "repl.history.path") {
483            Some(ConfigValue::String(value)) => value.as_str(),
484            other => panic!("unexpected history path value: {other:?}"),
485        };
486
487        assert!(path.contains("${user.name}@${profile.active}.history"));
488    }
489
490    #[test]
491    fn runtime_config_paths_prefer_explicit_file_overrides() {
492        let env = RuntimeEnvironment::from_pairs([
493            ("OSP_CONFIG_FILE", "/tmp/custom-config.toml"),
494            ("OSP_SECRETS_FILE", "/tmp/custom-secrets.toml"),
495            ("XDG_CONFIG_HOME", "/ignored"),
496        ]);
497
498        let paths = RuntimeConfigPaths::from_env(&env);
499
500        assert_eq!(
501            paths.config_file,
502            Some(PathBuf::from("/tmp/custom-config.toml"))
503        );
504        assert_eq!(
505            paths.secrets_file,
506            Some(PathBuf::from("/tmp/custom-secrets.toml"))
507        );
508    }
509
510    #[test]
511    fn runtime_config_paths_fall_back_to_xdg_root() {
512        let env = RuntimeEnvironment::from_pairs([("XDG_CONFIG_HOME", "/var/tmp/xdg-config")]);
513
514        let paths = RuntimeConfigPaths::from_env(&env);
515
516        assert_eq!(
517            paths.config_file,
518            Some(PathBuf::from("/var/tmp/xdg-config/osp/config.toml"))
519        );
520        assert_eq!(
521            paths.secrets_file,
522            Some(PathBuf::from("/var/tmp/xdg-config/osp/secrets.toml"))
523        );
524    }
525
526    #[test]
527    fn runtime_environment_uses_home_when_xdg_is_missing() {
528        let env = RuntimeEnvironment::from_pairs([("HOME", "/home/tester")]);
529
530        assert_eq!(
531            env.config_root_dir(),
532            Some(PathBuf::from("/home/tester/.config/osp"))
533        );
534        assert_eq!(
535            env.cache_root_dir(),
536            Some(PathBuf::from("/home/tester/.cache/osp"))
537        );
538        assert_eq!(
539            env.state_root_dir(),
540            Some(PathBuf::from("/home/tester/.local/state/osp"))
541        );
542    }
543
544    #[test]
545    fn runtime_environment_state_artifacts_fall_back_to_temp_root() {
546        let env = RuntimeEnvironment::default();
547        let mut expected_root = std::env::temp_dir();
548        expected_root.push("osp");
549
550        assert_eq!(
551            env.repl_history_path(),
552            expected_root
553                .join("history")
554                .join("${user.name}@${profile.active}.history")
555                .display()
556                .to_string()
557        );
558        assert_eq!(
559            env.log_file_path(),
560            expected_root.join("osp.log").display().to_string()
561        );
562    }
563}