1use std::{collections::BTreeMap, path::PathBuf};
31
32use directories::{BaseDirs, ProjectDirs};
33
34use crate::config::{
35 ChainedLoader, ConfigLayer, DEFAULT_PROFILE_NAME, EnvSecretsLoader, EnvVarLoader,
36 LoaderPipeline, ResolvedConfig, SecretsTomlLoader, StaticLayerLoader, TomlFileLoader,
37 build_builtin_defaults,
38};
39
40const PROJECT_APPLICATION_NAME: &str = "osp";
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum RuntimeBootstrapMode {
71 Standard,
74 DefaultsOnly,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82#[non_exhaustive]
83#[must_use = "RuntimeLoadOptions builder-style methods return an updated value"]
84pub struct RuntimeLoadOptions {
94 pub include_env: bool,
96 pub include_config_file: bool,
101 pub bootstrap_mode: RuntimeBootstrapMode,
104}
105
106impl Default for RuntimeLoadOptions {
107 fn default() -> Self {
108 Self {
109 include_env: true,
110 include_config_file: true,
111 bootstrap_mode: RuntimeBootstrapMode::Standard,
112 }
113 }
114}
115
116impl RuntimeLoadOptions {
117 pub fn new() -> Self {
119 Self::default()
120 }
121
122 pub fn defaults_only() -> Self {
125 Self {
126 include_env: false,
127 include_config_file: false,
128 bootstrap_mode: RuntimeBootstrapMode::DefaultsOnly,
129 }
130 }
131
132 pub fn with_env(mut self, include_env: bool) -> Self {
136 self.include_env = include_env;
137 if include_env {
138 self.bootstrap_mode = RuntimeBootstrapMode::Standard;
139 }
140 self
141 }
142
143 pub fn with_config_file(mut self, include_config_file: bool) -> Self {
147 self.include_config_file = include_config_file;
148 if include_config_file {
149 self.bootstrap_mode = RuntimeBootstrapMode::Standard;
150 }
151 self
152 }
153
154 pub fn with_bootstrap_mode(mut self, bootstrap_mode: RuntimeBootstrapMode) -> Self {
160 self.bootstrap_mode = bootstrap_mode;
161 if matches!(bootstrap_mode, RuntimeBootstrapMode::DefaultsOnly) {
162 self.include_env = false;
163 self.include_config_file = false;
164 }
165 self
166 }
167
168 pub fn is_defaults_only(self) -> bool {
171 matches!(self.bootstrap_mode, RuntimeBootstrapMode::DefaultsOnly)
172 }
173}
174
175impl RuntimeBootstrapMode {
176 fn capture_env(self) -> RuntimeEnvironment {
177 match self {
178 Self::Standard => RuntimeEnvironment::capture(),
179 Self::DefaultsOnly => RuntimeEnvironment::defaults_only(),
180 }
181 }
182}
183
184impl RuntimeLoadOptions {
185 fn runtime_environment(self) -> RuntimeEnvironment {
186 self.bootstrap_mode.capture_env()
187 }
188}
189
190#[derive(Debug, Clone)]
198pub struct RuntimeConfig {
199 pub active_profile: String,
201}
202
203impl Default for RuntimeConfig {
204 fn default() -> Self {
205 Self {
206 active_profile: DEFAULT_PROFILE_NAME.to_string(),
207 }
208 }
209}
210
211impl RuntimeConfig {
212 pub fn from_resolved(resolved: &ResolvedConfig) -> Self {
230 Self {
231 active_profile: resolved.active_profile().to_string(),
232 }
233 }
234}
235
236#[derive(Debug, Clone, PartialEq, Eq)]
238pub struct RuntimeConfigPaths {
239 pub config_file: Option<PathBuf>,
241 pub secrets_file: Option<PathBuf>,
243}
244
245impl RuntimeConfigPaths {
246 pub fn discover() -> Self {
263 Self::discover_with(RuntimeLoadOptions::default())
264 }
265
266 pub fn discover_with(load: RuntimeLoadOptions) -> Self {
272 let paths = Self::from_env(&load.runtime_environment());
273 tracing::debug!(
274 config_file = ?paths.config_file.as_ref().map(|path| path.display().to_string()),
275 secrets_file = ?paths.secrets_file.as_ref().map(|path| path.display().to_string()),
276 bootstrap_mode = ?load.bootstrap_mode,
277 "discovered runtime config paths"
278 );
279 paths
280 }
281
282 fn from_env(env: &RuntimeEnvironment) -> Self {
283 Self {
284 config_file: env
285 .path_override("OSP_CONFIG_FILE")
286 .or_else(|| env.config_path("config.toml")),
287 secrets_file: env
288 .path_override("OSP_SECRETS_FILE")
289 .or_else(|| env.config_path("secrets.toml")),
290 }
291 }
292}
293
294#[derive(Debug, Clone, Default)]
296pub struct RuntimeDefaults {
297 layer: ConfigLayer,
298}
299
300impl RuntimeDefaults {
301 pub fn from_process_env(default_theme_name: &str, default_repl_prompt: &str) -> Self {
318 Self::from_runtime_load(
319 RuntimeLoadOptions::default(),
320 default_theme_name,
321 default_repl_prompt,
322 )
323 }
324
325 pub fn from_runtime_load(
332 load: RuntimeLoadOptions,
333 default_theme_name: &str,
334 default_repl_prompt: &str,
335 ) -> Self {
336 Self::from_env(
337 &load.runtime_environment(),
338 default_theme_name,
339 default_repl_prompt,
340 )
341 }
342
343 fn from_env(
344 env: &RuntimeEnvironment,
345 default_theme_name: &str,
346 default_repl_prompt: &str,
347 ) -> Self {
348 Self {
349 layer: build_builtin_defaults(env, default_theme_name, default_repl_prompt),
350 }
351 }
352
353 pub fn get_string(&self, key: &str) -> Option<&str> {
366 self.layer
367 .entries()
368 .iter()
369 .find(|entry| entry.key == key && entry.scope == crate::config::Scope::global())
370 .and_then(|entry| match entry.value.reveal() {
371 crate::config::ConfigValue::String(value) => Some(value.as_str()),
372 _ => None,
373 })
374 }
375
376 pub fn to_layer(&self) -> ConfigLayer {
389 self.layer.clone()
390 }
391}
392
393pub fn build_runtime_pipeline(
430 defaults: ConfigLayer,
431 presentation: Option<ConfigLayer>,
432 paths: &RuntimeConfigPaths,
433 load: RuntimeLoadOptions,
434 cli: Option<ConfigLayer>,
435 session: Option<ConfigLayer>,
436) -> LoaderPipeline {
437 tracing::debug!(
438 include_env = load.include_env,
439 include_config_file = load.include_config_file,
440 config_file = ?paths.config_file.as_ref().map(|path| path.display().to_string()),
441 secrets_file = ?paths.secrets_file.as_ref().map(|path| path.display().to_string()),
442 has_presentation_layer = presentation.is_some(),
443 has_cli_layer = cli.is_some(),
444 has_session_layer = session.is_some(),
445 defaults_entries = defaults.entries().len(),
446 "building runtime loader pipeline"
447 );
448 let mut pipeline = LoaderPipeline::new(StaticLayerLoader::new(defaults));
449
450 if let Some(presentation_layer) = presentation {
451 pipeline = pipeline.with_presentation(StaticLayerLoader::new(presentation_layer));
452 }
453
454 if load.include_env {
455 pipeline = pipeline.with_env(EnvVarLoader::from_process_env());
456 }
457
458 if load.include_config_file
459 && let Some(path) = &paths.config_file
460 {
461 pipeline = pipeline.with_file(TomlFileLoader::new(path.clone()).optional());
462 }
463
464 if let Some(path) = &paths.secrets_file {
465 let mut secret_chain = ChainedLoader::new(SecretsTomlLoader::new(path.clone()).optional());
466 if load.include_env {
467 secret_chain = secret_chain.with(EnvSecretsLoader::from_process_env());
468 }
469 pipeline = pipeline.with_secrets(secret_chain);
470 } else if load.include_env {
471 pipeline = pipeline.with_secrets(ChainedLoader::new(EnvSecretsLoader::from_process_env()));
472 }
473
474 if let Some(cli_layer) = cli {
475 pipeline = pipeline.with_cli(StaticLayerLoader::new(cli_layer));
476 }
477 if let Some(session_layer) = session {
478 pipeline = pipeline.with_session(StaticLayerLoader::new(session_layer));
479 }
480
481 pipeline
482}
483
484pub fn default_config_root_dir() -> Option<PathBuf> {
486 RuntimeEnvironment::capture().config_root_dir()
487}
488
489pub fn default_cache_root_dir() -> Option<PathBuf> {
491 RuntimeEnvironment::capture().cache_root_dir()
492}
493
494pub fn default_state_root_dir() -> Option<PathBuf> {
496 RuntimeEnvironment::capture().state_root_dir()
497}
498
499pub fn default_home_dir() -> Option<PathBuf> {
501 BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
502}
503
504#[derive(Debug, Clone, Default)]
505pub(super) struct RuntimeEnvironment {
506 vars: BTreeMap<String, String>,
507 prefer_platform_dirs: bool,
508}
509
510impl RuntimeEnvironment {
511 fn capture() -> Self {
512 Self {
513 vars: std::env::vars().collect(),
514 prefer_platform_dirs: true,
515 }
516 }
517
518 pub(super) fn defaults_only() -> Self {
519 Self {
520 vars: BTreeMap::new(),
521 prefer_platform_dirs: false,
522 }
523 }
524
525 #[cfg(test)]
526 pub(super) fn from_pairs<I, K, V>(vars: I) -> Self
527 where
528 I: IntoIterator<Item = (K, V)>,
529 K: AsRef<str>,
530 V: AsRef<str>,
531 {
532 Self {
533 vars: vars
534 .into_iter()
535 .map(|(key, value)| (key.as_ref().to_string(), value.as_ref().to_string()))
536 .collect(),
537 prefer_platform_dirs: false,
538 }
539 }
540
541 fn config_root_dir(&self) -> Option<PathBuf> {
542 self.xdg_root_dir("XDG_CONFIG_HOME", &[".config"])
543 }
544
545 fn cache_root_dir(&self) -> Option<PathBuf> {
546 self.xdg_root_dir("XDG_CACHE_HOME", &[".cache"])
547 }
548
549 fn state_root_dir(&self) -> Option<PathBuf> {
550 if let Some(path) = self.get_nonempty("XDG_STATE_HOME") {
551 return Some(join_path(PathBuf::from(path), &[PROJECT_APPLICATION_NAME]));
552 }
553
554 if self.prefer_platform_dirs {
555 return project_dirs().map(|dirs| {
556 dirs.state_dir()
557 .unwrap_or_else(|| dirs.data_local_dir())
558 .to_path_buf()
559 });
560 }
561
562 self.home_root_dir(&[".local", "state"])
563 }
564
565 fn config_path(&self, leaf: &str) -> Option<PathBuf> {
566 self.config_root_dir().map(|root| join_path(root, &[leaf]))
567 }
568
569 pub(super) fn theme_paths(&self) -> Vec<String> {
570 self.config_root_dir()
571 .map(|root| join_path(root, &["themes"]).to_string_lossy().to_string())
572 .into_iter()
573 .collect()
574 }
575
576 pub(super) fn user_name(&self) -> String {
577 self.get_nonempty("USER")
578 .or_else(|| self.get_nonempty("USERNAME"))
579 .map(ToOwned::to_owned)
580 .unwrap_or_else(|| "anonymous".to_string())
581 }
582
583 pub(super) fn domain_name(&self) -> String {
584 self.get_nonempty("HOSTNAME")
585 .or_else(|| self.get_nonempty("COMPUTERNAME"))
586 .unwrap_or("localhost")
587 .split_once('.')
588 .map(|(_, domain)| domain.to_string())
589 .filter(|domain| !domain.trim().is_empty())
590 .unwrap_or_else(|| "local".to_string())
591 }
592
593 pub(super) fn repl_history_path(&self) -> String {
594 join_path(
595 self.state_root_dir_or_temp(),
596 &["history", "${user.name}@${profile.active}.history"],
597 )
598 .display()
599 .to_string()
600 }
601
602 pub(super) fn log_file_path(&self) -> String {
603 join_path(self.state_root_dir_or_temp(), &["osp.log"])
604 .display()
605 .to_string()
606 }
607
608 fn path_override(&self, key: &str) -> Option<PathBuf> {
609 self.get_nonempty(key).map(PathBuf::from)
610 }
611
612 fn state_root_dir_or_temp(&self) -> PathBuf {
613 self.state_root_dir().unwrap_or_else(|| {
614 let mut path = std::env::temp_dir();
615 path.push(PROJECT_APPLICATION_NAME);
616 path
617 })
618 }
619
620 fn xdg_root_dir(&self, xdg_var: &str, home_suffix: &[&str]) -> Option<PathBuf> {
621 if let Some(path) = self.get_nonempty(xdg_var) {
622 return Some(join_path(PathBuf::from(path), &[PROJECT_APPLICATION_NAME]));
623 }
624
625 if self.prefer_platform_dirs {
626 return match xdg_var {
627 "XDG_CONFIG_HOME" => project_dirs().map(|dirs| dirs.config_dir().to_path_buf()),
628 "XDG_CACHE_HOME" => project_dirs().map(|dirs| dirs.cache_dir().to_path_buf()),
629 _ => None,
630 };
631 }
632
633 self.home_root_dir(home_suffix)
634 }
635
636 fn home_root_dir(&self, home_suffix: &[&str]) -> Option<PathBuf> {
637 let home = self.get_nonempty("HOME")?;
638 Some(join_path(PathBuf::from(home), home_suffix).join(PROJECT_APPLICATION_NAME))
639 }
640
641 fn get_nonempty(&self, key: &str) -> Option<&str> {
642 self.vars
643 .get(key)
644 .map(String::as_str)
645 .map(str::trim)
646 .filter(|value| !value.is_empty())
647 }
648}
649
650fn join_path(mut root: PathBuf, segments: &[&str]) -> PathBuf {
651 for segment in segments {
652 root.push(segment);
653 }
654 root
655}
656
657fn project_dirs() -> Option<ProjectDirs> {
658 ProjectDirs::from("", "", PROJECT_APPLICATION_NAME)
659}
660
661#[cfg(test)]
662mod tests {
663 use std::path::PathBuf;
664
665 use super::{
666 DEFAULT_PROFILE_NAME, RuntimeBootstrapMode, RuntimeConfigPaths, RuntimeDefaults,
667 RuntimeEnvironment, RuntimeLoadOptions,
668 };
669 use crate::config::{
670 ConfigLayer, ConfigValue, DEFAULT_REPL_HISTORY_MAX_ENTRIES, DEFAULT_REPL_HISTORY_MENU_ROWS,
671 DEFAULT_REPL_INTRO, DEFAULT_UI_CHROME_FRAME, DEFAULT_UI_MESSAGES_LAYOUT,
672 DEFAULT_UI_PRESENTATION, DEFAULT_UI_TABLE_BORDER, DEFAULT_UI_WIDTH, Scope,
673 };
674
675 fn find_value<'a>(layer: &'a ConfigLayer, key: &str) -> Option<&'a ConfigValue> {
676 layer
677 .entries()
678 .iter()
679 .find(|entry| entry.key == key && entry.scope == Scope::global())
680 .map(|entry| &entry.value)
681 }
682
683 #[test]
684 fn runtime_defaults_seed_expected_keys_and_history_placeholders_unit() {
685 let defaults =
686 RuntimeDefaults::from_env(&RuntimeEnvironment::default(), "nord", "osp> ").to_layer();
687
688 assert_eq!(
689 find_value(&defaults, "profile.default"),
690 Some(&ConfigValue::String(DEFAULT_PROFILE_NAME.to_string()))
691 );
692 assert_eq!(
693 find_value(&defaults, "theme.name"),
694 Some(&ConfigValue::String("nord".to_string()))
695 );
696 assert_eq!(
697 find_value(&defaults, "repl.prompt"),
698 Some(&ConfigValue::String("osp> ".to_string()))
699 );
700 assert_eq!(
701 find_value(&defaults, "repl.intro"),
702 Some(&ConfigValue::String(DEFAULT_REPL_INTRO.to_string()))
703 );
704 assert_eq!(
705 find_value(&defaults, "repl.history.max_entries"),
706 Some(&ConfigValue::Integer(DEFAULT_REPL_HISTORY_MAX_ENTRIES))
707 );
708 assert_eq!(
709 find_value(&defaults, "repl.history.menu_rows"),
710 Some(&ConfigValue::Integer(DEFAULT_REPL_HISTORY_MENU_ROWS))
711 );
712 assert_eq!(
713 find_value(&defaults, "ui.width"),
714 Some(&ConfigValue::Integer(DEFAULT_UI_WIDTH))
715 );
716 assert_eq!(
717 find_value(&defaults, "ui.presentation"),
718 Some(&ConfigValue::String(DEFAULT_UI_PRESENTATION.to_string()))
719 );
720 assert_eq!(
721 find_value(&defaults, "ui.help.level"),
722 Some(&ConfigValue::String("inherit".to_string()))
723 );
724 assert_eq!(
725 find_value(&defaults, "ui.messages.layout"),
726 Some(&ConfigValue::String(DEFAULT_UI_MESSAGES_LAYOUT.to_string()))
727 );
728 assert_eq!(
729 find_value(&defaults, "ui.message.verbosity"),
730 Some(&ConfigValue::String("success".to_string()))
731 );
732 assert_eq!(
733 find_value(&defaults, "ui.chrome.frame"),
734 Some(&ConfigValue::String(DEFAULT_UI_CHROME_FRAME.to_string()))
735 );
736 assert_eq!(
737 find_value(&defaults, "ui.table.border"),
738 Some(&ConfigValue::String(DEFAULT_UI_TABLE_BORDER.to_string()))
739 );
740 assert_eq!(
741 find_value(&defaults, "color.prompt.text"),
742 Some(&ConfigValue::String(String::new()))
743 );
744 let path = match find_value(&defaults, "repl.history.path") {
745 Some(ConfigValue::String(value)) => value.as_str(),
746 other => panic!("unexpected history path value: {other:?}"),
747 };
748
749 assert!(path.contains("${user.name}@${profile.active}.history"));
750 }
751
752 #[test]
753 fn defaults_only_runtime_load_options_disable_ambient_bootstrap_unit() {
754 let load = RuntimeLoadOptions::defaults_only();
755
756 assert!(!load.include_env);
757 assert!(!load.include_config_file);
758 assert_eq!(load.bootstrap_mode, RuntimeBootstrapMode::DefaultsOnly);
759 assert!(load.is_defaults_only());
760 }
761
762 #[test]
763 fn runtime_config_paths_prefer_explicit_file_overrides() {
764 let env = RuntimeEnvironment::from_pairs([
765 ("OSP_CONFIG_FILE", "/tmp/custom-config.toml"),
766 ("OSP_SECRETS_FILE", "/tmp/custom-secrets.toml"),
767 ("XDG_CONFIG_HOME", "/ignored"),
768 ]);
769
770 let paths = RuntimeConfigPaths::from_env(&env);
771
772 assert_eq!(
773 paths.config_file,
774 Some(PathBuf::from("/tmp/custom-config.toml"))
775 );
776 assert_eq!(
777 paths.secrets_file,
778 Some(PathBuf::from("/tmp/custom-secrets.toml"))
779 );
780
781 let env = RuntimeEnvironment::from_pairs([("XDG_CONFIG_HOME", "/var/tmp/xdg-config")]);
782
783 let paths = RuntimeConfigPaths::from_env(&env);
784
785 assert_eq!(
786 paths.config_file,
787 Some(PathBuf::from("/var/tmp/xdg-config/osp/config.toml"))
788 );
789 assert_eq!(
790 paths.secrets_file,
791 Some(PathBuf::from("/var/tmp/xdg-config/osp/secrets.toml"))
792 );
793 }
794
795 #[test]
796 fn runtime_environment_uses_home_and_temp_fallbacks_for_state_paths_unit() {
797 let env = RuntimeEnvironment::from_pairs([("HOME", "/home/tester")]);
798
799 assert_eq!(
800 env.config_root_dir(),
801 Some(PathBuf::from("/home/tester/.config/osp"))
802 );
803 assert_eq!(
804 env.cache_root_dir(),
805 Some(PathBuf::from("/home/tester/.cache/osp"))
806 );
807 assert_eq!(
808 env.state_root_dir(),
809 Some(PathBuf::from("/home/tester/.local/state/osp"))
810 );
811
812 let env = RuntimeEnvironment::default();
813 let mut expected_root = std::env::temp_dir();
814 expected_root.push("osp");
815
816 assert_eq!(
817 env.repl_history_path(),
818 expected_root
819 .join("history")
820 .join("${user.name}@${profile.active}.history")
821 .display()
822 .to_string()
823 );
824 assert_eq!(
825 env.log_file_path(),
826 expected_root.join("osp.log").display().to_string()
827 );
828 }
829
830 #[test]
831 fn defaults_only_bootstrap_skips_home_and_override_discovery_unit() {
832 let load = RuntimeLoadOptions::defaults_only();
833 let paths = RuntimeConfigPaths::discover_with(load);
834 let defaults = RuntimeDefaults::from_runtime_load(load, "nord", "osp> ");
835
836 assert_eq!(paths.config_file, None);
837 assert_eq!(paths.secrets_file, None);
838 assert_eq!(defaults.get_string("user.name"), Some("anonymous"));
839 assert_eq!(defaults.get_string("domain"), Some("local"));
840 assert_eq!(defaults.get_string("theme.name"), Some("nord"));
841 assert_eq!(defaults.get_string("repl.prompt"), Some("osp> "));
842 assert_eq!(defaults.get_string("theme.path"), None);
843 }
844}