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}