1use std::{collections::BTreeMap, path::PathBuf};
31
32use crate::config::{
33 ChainedLoader, ConfigLayer, EnvSecretsLoader, EnvVarLoader, LoaderPipeline, ResolvedConfig,
34 SecretsTomlLoader, StaticLayerLoader, TomlFileLoader,
35};
36
37pub const DEFAULT_PROFILE_NAME: &str = "default";
39pub const DEFAULT_REPL_HISTORY_MAX_ENTRIES: i64 = 1000;
41pub const DEFAULT_REPL_HISTORY_ENABLED: bool = true;
43pub const DEFAULT_REPL_HISTORY_DEDUPE: bool = true;
45pub const DEFAULT_REPL_HISTORY_PROFILE_SCOPED: bool = true;
47pub const DEFAULT_REPL_HISTORY_MENU_ROWS: i64 = 5;
49pub const DEFAULT_SESSION_CACHE_MAX_RESULTS: i64 = 64;
51pub const DEFAULT_DEBUG_LEVEL: i64 = 0;
53pub const DEFAULT_LOG_FILE_ENABLED: bool = false;
55pub const DEFAULT_LOG_FILE_LEVEL: &str = "warn";
57pub const DEFAULT_UI_WIDTH: i64 = 72;
59pub const DEFAULT_UI_MARGIN: i64 = 0;
61pub const DEFAULT_UI_INDENT: i64 = 2;
63pub const DEFAULT_UI_PRESENTATION: &str = "expressive";
65pub const DEFAULT_UI_GUIDE_DEFAULT_FORMAT: &str = "guide";
67pub const DEFAULT_UI_MESSAGES_LAYOUT: &str = "grouped";
69pub const DEFAULT_UI_CHROME_FRAME: &str = "top";
71pub const DEFAULT_UI_TABLE_BORDER: &str = "square";
73pub const DEFAULT_REPL_INTRO: &str = "full";
75pub const DEFAULT_UI_SHORT_LIST_MAX: i64 = 1;
77pub const DEFAULT_UI_MEDIUM_LIST_MAX: i64 = 5;
79pub const DEFAULT_UI_GRID_PADDING: i64 = 4;
81pub const DEFAULT_UI_COLUMN_WEIGHT: i64 = 3;
83pub const DEFAULT_UI_MREG_STACK_MIN_COL_WIDTH: i64 = 10;
85pub const DEFAULT_UI_MREG_STACK_OVERFLOW_RATIO: i64 = 200;
87pub const DEFAULT_UI_TABLE_OVERFLOW: &str = "clip";
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103#[non_exhaustive]
104pub struct RuntimeLoadOptions {
105 pub include_env: bool,
107 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 pub fn new() -> Self {
123 Self::default()
124 }
125
126 pub fn with_env(mut self, include_env: bool) -> Self {
128 self.include_env = include_env;
129 self
130 }
131
132 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#[derive(Debug, Clone)]
141pub struct RuntimeConfig {
142 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 pub fn from_resolved(resolved: &ResolvedConfig) -> Self {
173 Self {
174 active_profile: resolved.active_profile().to_string(),
175 }
176 }
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct RuntimeConfigPaths {
182 pub config_file: Option<PathBuf>,
184 pub secrets_file: Option<PathBuf>,
186}
187
188impl RuntimeConfigPaths {
189 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#[derive(Debug, Clone, Default)]
214pub struct RuntimeDefaults {
215 layer: ConfigLayer,
216}
217
218impl RuntimeDefaults {
219 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 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 pub fn to_layer(&self) -> ConfigLayer {
356 self.layer.clone()
357 }
358}
359
360pub 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
420pub fn default_config_root_dir() -> Option<PathBuf> {
422 RuntimeEnvironment::capture().config_root_dir()
423}
424
425pub fn default_cache_root_dir() -> Option<PathBuf> {
427 RuntimeEnvironment::capture().cache_root_dir()
428}
429
430pub 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}