1use 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
39pub const DEFAULT_PROFILE_NAME: &str = "default";
41pub const DEFAULT_REPL_HISTORY_MAX_ENTRIES: i64 = 1000;
43pub const DEFAULT_REPL_HISTORY_ENABLED: bool = true;
45pub const DEFAULT_REPL_HISTORY_DEDUPE: bool = true;
47pub const DEFAULT_REPL_HISTORY_PROFILE_SCOPED: bool = true;
49pub const DEFAULT_REPL_HISTORY_MENU_ROWS: i64 = 5;
51pub const DEFAULT_SESSION_CACHE_MAX_RESULTS: i64 = 64;
53pub const DEFAULT_DEBUG_LEVEL: i64 = 0;
55pub const DEFAULT_LOG_FILE_ENABLED: bool = false;
57pub const DEFAULT_LOG_FILE_LEVEL: &str = "warn";
59pub const DEFAULT_UI_WIDTH: i64 = 72;
61pub const DEFAULT_UI_MARGIN: i64 = 0;
63pub const DEFAULT_UI_INDENT: i64 = 2;
65pub const DEFAULT_UI_PRESENTATION: &str = "expressive";
67pub const DEFAULT_UI_GUIDE_DEFAULT_FORMAT: &str = "guide";
69pub const DEFAULT_UI_MESSAGES_LAYOUT: &str = "grouped";
71pub const DEFAULT_UI_CHROME_FRAME: &str = "top";
73pub const DEFAULT_UI_CHROME_RULE_POLICY: &str = "shared";
75pub const DEFAULT_UI_TABLE_BORDER: &str = "square";
77pub const DEFAULT_REPL_INTRO: &str = "full";
79pub const DEFAULT_UI_SHORT_LIST_MAX: i64 = 1;
81pub const DEFAULT_UI_MEDIUM_LIST_MAX: i64 = 5;
83pub const DEFAULT_UI_GRID_PADDING: i64 = 4;
85pub const DEFAULT_UI_COLUMN_WEIGHT: i64 = 3;
87pub const DEFAULT_UI_MREG_STACK_MIN_COL_WIDTH: i64 = 10;
89pub const DEFAULT_UI_MREG_STACK_OVERFLOW_RATIO: i64 = 200;
91pub const DEFAULT_UI_TABLE_OVERFLOW: &str = "clip";
93
94const PROJECT_APPLICATION_NAME: &str = "osp";
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum RuntimeBootstrapMode {
125 Standard,
128 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 pub include_env: bool,
141 pub include_config_file: bool,
146 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 pub fn new() -> Self {
164 Self::default()
165 }
166
167 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 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 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 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 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#[derive(Debug, Clone)]
243pub struct RuntimeConfig {
244 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 pub fn from_resolved(resolved: &ResolvedConfig) -> Self {
275 Self {
276 active_profile: resolved.active_profile().to_string(),
277 }
278 }
279}
280
281#[derive(Debug, Clone, PartialEq, Eq)]
283pub struct RuntimeConfigPaths {
284 pub config_file: Option<PathBuf>,
286 pub secrets_file: Option<PathBuf>,
288}
289
290impl RuntimeConfigPaths {
291 pub fn discover() -> Self {
308 Self::discover_with(RuntimeLoadOptions::default())
309 }
310
311 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#[derive(Debug, Clone, Default)]
341pub struct RuntimeDefaults {
342 layer: ConfigLayer,
343}
344
345impl RuntimeDefaults {
346 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 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 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 pub fn to_layer(&self) -> ConfigLayer {
516 self.layer.clone()
517 }
518}
519
520pub 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
611pub fn default_config_root_dir() -> Option<PathBuf> {
613 RuntimeEnvironment::capture().config_root_dir()
614}
615
616pub fn default_cache_root_dir() -> Option<PathBuf> {
618 RuntimeEnvironment::capture().cache_root_dir()
619}
620
621pub fn default_state_root_dir() -> Option<PathBuf> {
623 RuntimeEnvironment::capture().state_root_dir()
624}
625
626pub 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}