1use crate::shells::ShellType;
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6mod discovery;
7mod env_layer;
8mod load;
9mod save;
10
11use discovery::resolve_config_presets_path;
12pub(crate) use discovery::{
13 ReadOnlyRuntimePaths, discover_runtime_paths_read_only, find_project_config,
14};
15use env_layer::deserialize_env_values;
16
17pub use crate::home::{full_expand, full_expand_with_home, tilde_expand};
18pub use env_layer::{validate_env_override_file, write_env_override_entry};
19
20const GLOBAL_CONFIG_FILE: &str = "config.toml";
21const PROJECT_CONFIG_FILE: &str = "shine.config.toml";
22const PROJECT_ENV_FILE: &str = "shine.env.toml";
23
24pub const CURRENT_RUNTIME_SCHEMA_VERSION: u32 = 2;
25
26pub const DEFAULT_ENV_VARS: &[(&str, &str)] = &[
27 ("HTTP_PROXY_PORT", "6152"),
28 ("SOCKS5_PROXY_PORT", "6153"),
29 ("PROXY_HOST", "127.0.0.1"),
30 ("PROXY_NO_PROXY", "localhost,127.0.0.1,::1"),
31 ("IMAGE_QUALITY", "80"),
32 ("IMAGE_MAX_WIDTH", "1920"),
33 ("IMAGE_MAX_HEIGHT", "1080"),
34 ("GHOSTTY_BG_LIGHT", ""),
35 ("GHOSTTY_BG_DARK", ""),
36];
37
38pub fn default_env_map() -> BTreeMap<String, String> {
39 DEFAULT_ENV_VARS
40 .iter()
41 .map(|(k, v)| (k.to_string(), v.to_string()))
42 .collect()
43}
44
45fn default_sync_terminal_theme() -> bool {
46 true
47}
48
49fn default_shell_type() -> ShellType {
50 crate::shells::get_shell().unwrap_or_default()
51}
52
53fn is_true(value: &bool) -> bool {
54 *value
55}
56
57pub use shine_core::runtime::ExternalShellMode;
58
59#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
61pub struct EnvProxyRule {
62 pub command: String,
63 #[serde(rename = "with")]
64 pub with: Vec<String>,
65 #[serde(default = "default_env_proxy_enabled", skip_serializing_if = "is_true")]
68 pub enabled: bool,
69}
70
71fn default_env_proxy_enabled() -> bool {
72 true
73}
74
75fn is_snapshot_mode(value: &ExternalShellMode) -> bool {
76 *value == ExternalShellMode::Snapshot
77}
78
79#[derive(Serialize, Deserialize, Clone, Debug)]
80pub struct Config {
81 #[serde(skip)]
83 presets_dir: PathBuf,
84 #[serde(skip)]
86 bin_dir: PathBuf,
87 #[serde(skip)]
89 config_path: PathBuf,
90 #[serde(skip)]
92 is_project_config: bool,
93 #[serde(skip)]
96 project_save_state: Option<ProjectSaveState>,
97 #[serde(skip)]
99 project_overrides_age_identities: bool,
100 #[serde(skip)]
102 shine_dir: PathBuf,
103 #[serde(skip)]
104 pub home_dir: PathBuf,
105 #[serde(default)]
106 pub schema_version: u32,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub last_cleared_schema_version: Option<u32>,
109 #[serde(skip, default = "default_shell_type")]
110 pub shell_type: ShellType,
111 #[serde(
114 rename = "presets_dir",
115 default,
116 skip_serializing_if = "Option::is_none"
117 )]
118 pub presets_dir_override: Option<PathBuf>,
119 #[serde(default, skip_serializing_if = "is_snapshot_mode")]
122 pub external_shell_mode: ExternalShellMode,
123 #[serde(
125 rename = "presets_overlay_dir",
126 default,
127 skip_serializing_if = "Option::is_none"
128 )]
129 pub presets_overlay_dir_override: Option<PathBuf>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub presets_overlay_git: Option<String>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub presets_overlay_git_branch: Option<String>,
140 #[serde(skip)]
144 managed_overlay_dir: Option<PathBuf>,
145 #[serde(
149 rename = "app_default_dest_root",
150 default,
151 skip_serializing_if = "Option::is_none"
152 )]
153 pub app_default_dest_root_override: Option<PathBuf>,
154 #[serde(skip)]
159 pub is_external_presets: bool,
160 #[serde(rename = "allow_app_hooks", default, skip_serializing)]
162 pub legacy_allow_app_hooks: bool,
163 #[serde(rename = "allow_sys_code", default, skip_serializing)]
165 pub legacy_allow_sys_code: bool,
166 #[serde(
173 default = "default_sync_terminal_theme",
174 skip_serializing_if = "is_true"
175 )]
176 pub sync_terminal_theme: bool,
177 #[serde(
180 rename = "self_install_dest",
181 default,
182 skip_serializing_if = "Option::is_none"
183 )]
184 pub self_install_dest: Option<PathBuf>,
185 #[serde(default, skip_serializing_if = "Vec::is_empty")]
188 pub gpg_recipients: Vec<String>,
189 #[serde(
190 rename = "gpg_key_id",
191 default,
192 skip_serializing_if = "Option::is_none"
193 )]
194 pub legacy_gpg_key_id: Option<String>,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub secret_backend: Option<String>,
201 #[serde(default, skip_serializing_if = "Vec::is_empty")]
206 pub age_recipients: Vec<String>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub age_identity: Option<String>,
212 #[serde(default, skip_serializing_if = "Vec::is_empty")]
215 pub age_identities: Vec<String>,
216 #[serde(
218 default = "default_env_map",
219 deserialize_with = "deserialize_env_values"
220 )]
221 pub env: BTreeMap<String, String>,
222 #[serde(default, skip_serializing_if = "Vec::is_empty")]
224 pub env_proxy: Vec<EnvProxyRule>,
225 #[serde(skip)]
227 pub env_descriptions: BTreeMap<String, String>,
228 #[serde(skip)]
234 pub env_override_sources: BTreeMap<String, EnvOverrideSource>,
235}
236
237#[derive(Clone, Debug, PartialEq, Eq)]
240pub struct EnvOverrideSource {
241 pub path: PathBuf,
242 pub kind: EnvOverrideKind,
246 pub is_managed_overlay: bool,
250}
251
252#[derive(Clone, Copy, Debug, PartialEq, Eq)]
256pub enum EnvOverrideKind {
257 Global,
259 Overlay,
261 Project,
263}
264
265#[derive(Clone, Debug)]
266struct ProjectSaveState {
267 original: toml::Table,
268 loaded: toml::Table,
269}
270
271impl Config {
272 pub fn presets_dir(&self) -> &Path {
273 &self.presets_dir
274 }
275
276 pub fn bin_dir(&self) -> &Path {
277 &self.bin_dir
278 }
279
280 pub fn shine_dir(&self) -> &Path {
281 &self.shine_dir
282 }
283
284 pub fn config_path(&self) -> &Path {
285 &self.config_path
286 }
287
288 pub fn global_config_path(&self) -> PathBuf {
290 self.shine_dir.join(GLOBAL_CONFIG_FILE)
291 }
292
293 pub fn is_project_config(&self) -> bool {
295 self.is_project_config
296 }
297
298 pub fn rendered_dir(&self) -> PathBuf {
301 self.shine_dir().join("rendered")
302 }
303
304 pub fn installed_shell_dir(&self) -> PathBuf {
306 self.shine_dir().join("installed").join("shell")
307 }
308
309 pub fn app_default_dest_root(&self) -> PathBuf {
310 match &self.app_default_dest_root_override {
311 Some(p) => {
312 let s = p.to_str().unwrap_or("~/.config");
313 PathBuf::from(tilde_expand(s))
314 }
315 None => self.home_dir.join(".config"),
316 }
317 }
318
319 pub fn new_for_test(dir: &Path) -> Self {
320 Self {
321 config_path: dir.join("config.toml"),
322 is_project_config: false,
323 project_save_state: None,
324 project_overrides_age_identities: false,
325 shine_dir: dir.to_path_buf(),
326 presets_dir: dir.join("presets"),
327 bin_dir: dir.join("bin"),
328 home_dir: dir.to_path_buf(),
329 schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
330 last_cleared_schema_version: None,
331 shell_type: default_shell_type(),
332 presets_dir_override: None,
333 external_shell_mode: ExternalShellMode::Snapshot,
334 presets_overlay_dir_override: None,
335 presets_overlay_git: None,
336 presets_overlay_git_branch: None,
337 managed_overlay_dir: None,
338 app_default_dest_root_override: None,
339 is_external_presets: false,
340 legacy_allow_app_hooks: false,
341 legacy_allow_sys_code: false,
342 sync_terminal_theme: default_sync_terminal_theme(),
343 self_install_dest: None,
344 gpg_recipients: Vec::new(),
345 legacy_gpg_key_id: None,
346 secret_backend: None,
347 age_recipients: Vec::new(),
348 age_identity: None,
349 age_identities: Vec::new(),
350 env: default_env_map(),
351 env_proxy: Vec::new(),
352 env_descriptions: BTreeMap::new(),
353 env_override_sources: BTreeMap::new(),
354 }
355 }
356
357 pub fn resolved_age_identities(&self) -> Vec<PathBuf> {
361 let mut identities = self
362 .age_identity
363 .iter()
364 .chain(self.age_identities.iter())
365 .map(String::as_str)
366 .map(str::trim)
367 .filter(|value| !value.is_empty())
368 .map(|value| PathBuf::from(tilde_expand(value)))
369 .fold(Vec::new(), |mut paths, path| {
370 if !paths.contains(&path) {
371 paths.push(path);
372 }
373 paths
374 });
375 if !identities.is_empty() {
376 return identities;
377 }
378 let default_path = self.shine_dir.join("age").join("identity.txt");
379 if default_path.is_file() {
380 identities.push(default_path);
381 }
382 identities
383 }
384
385 pub fn project_overrides_age_identities(&self) -> bool {
387 self.project_overrides_age_identities
388 }
389
390 pub fn with_presets_dir_override(self, value: Option<PathBuf>) -> Self {
392 Self {
393 presets_dir_override: value,
394 ..self
395 }
396 }
397
398 pub fn with_external_shell_mode(self, value: ExternalShellMode) -> Self {
399 Self {
400 external_shell_mode: value,
401 ..self
402 }
403 }
404
405 pub fn with_presets_overlay_git(self, url: Option<String>, branch: Option<String>) -> Self {
410 let managed_overlay_dir = url.as_ref().map(|_| self.shine_dir.join("overlay"));
411 Self {
412 presets_overlay_dir_override: if url.is_some() {
413 None
414 } else {
415 self.presets_overlay_dir_override
416 },
417 presets_overlay_git_branch: branch,
418 presets_overlay_git: url,
419 managed_overlay_dir,
420 ..self
421 }
422 }
423
424 pub fn with_presets_overlay_dir_override(self, value: Option<PathBuf>) -> Self {
426 Self {
427 presets_overlay_dir_override: value,
428 ..self
429 }
430 }
431
432 pub fn env_override_source(&self, key: &str) -> Option<&EnvOverrideSource> {
436 self.env_override_sources.get(key)
437 }
438
439 pub fn active_presets_overlay_dir(&self) -> Option<&Path> {
440 if let Some(dir) = self.presets_overlay_dir_override.as_deref() {
441 return Some(dir);
442 }
443 self.managed_overlay_dir
447 .as_deref()
448 .filter(|dir| dir.exists())
449 }
450
451 pub fn overlay_git_source(&self) -> Option<(&str, Option<&str>, &Path)> {
455 let url = self.presets_overlay_git.as_deref()?;
456 let dir = self.managed_overlay_dir.as_deref()?;
457 Some((url, self.presets_overlay_git_branch.as_deref(), dir))
458 }
459
460 pub fn preset_path(&self, relative: impl AsRef<Path>) -> PathBuf {
462 let relative = relative.as_ref();
463 if let Some(overlay) = self.active_presets_overlay_dir() {
464 let candidate = overlay.join(relative);
465 if candidate.exists() {
466 return candidate;
467 }
468 }
469 self.presets_dir().join(relative)
470 }
471
472 fn resolve_presets_overlay_dir(&mut self, config_dir: &Path) {
473 if let Some(path) = self.presets_overlay_dir_override.as_deref() {
474 self.presets_overlay_dir_override = Some(resolve_config_presets_path(path, config_dir));
475 }
476 }
477
478 fn resolve_managed_overlay_dir(&mut self) {
482 self.managed_overlay_dir = self
483 .presets_overlay_git
484 .as_ref()
485 .map(|_| self.shine_dir.join("overlay"));
486 }
487}
488
489impl AsRef<Path> for Config {
490 fn as_ref(&self) -> &Path {
491 self.shine_dir()
492 }
493}
494
495pub fn print_presets_note(config: &Config) {
499 for line in presets_note_lines(config) {
500 println!("{line}");
501 }
502}
503
504pub(crate) fn presets_note_lines(config: &Config) -> Vec<String> {
505 let mut lines = Vec::new();
506 if config.is_external_presets {
507 lines.push(crate::colors::external_presets_note(config.presets_dir()));
508 if let Some(dir) = config.active_presets_overlay_dir() {
509 lines.push(crate::colors::presets_overlay_note(dir));
510 }
511 let deployment = match config.external_shell_mode {
512 ExternalShellMode::Snapshot => "snapshot · changes require `shine upgrade`",
513 ExternalShellMode::Live => "live · content applies on next invocation",
514 };
515 lines.push(crate::colors::dim(&crate::colors::shell_deployment_note(
516 deployment,
517 )));
518 lines.push(String::new());
519 } else {
520 lines.push(crate::colors::presets_source_note("built-in"));
521 if let Some(dir) = config.active_presets_overlay_dir() {
522 lines.push(crate::colors::presets_overlay_note(dir));
523 }
524 lines.push(String::new());
525 }
526 lines
527}
528
529impl Default for Config {
530 fn default() -> Self {
531 let home_dir = crate::home::effective_home_dir();
532 let shine_dir = home_dir.join(".shine");
533
534 Self {
535 presets_dir: shine_dir.join("presets"),
536 bin_dir: shine_dir.join("bin"),
537 config_path: shine_dir.join("config.toml"),
538 is_project_config: false,
539 project_save_state: None,
540 project_overrides_age_identities: false,
541 shine_dir,
542 home_dir,
543 schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
544 last_cleared_schema_version: None,
545 shell_type: default_shell_type(),
546 presets_dir_override: None,
547 external_shell_mode: ExternalShellMode::Snapshot,
548 presets_overlay_dir_override: None,
549 presets_overlay_git: None,
550 presets_overlay_git_branch: None,
551 managed_overlay_dir: None,
552 app_default_dest_root_override: None,
553 is_external_presets: false,
554 legacy_allow_app_hooks: false,
555 legacy_allow_sys_code: false,
556 sync_terminal_theme: default_sync_terminal_theme(),
557 self_install_dest: None,
558 gpg_recipients: Vec::new(),
559 legacy_gpg_key_id: None,
560 secret_backend: None,
561 age_recipients: Vec::new(),
562 age_identity: None,
563 age_identities: Vec::new(),
564 env: default_env_map(),
565 env_proxy: Vec::new(),
566 env_descriptions: BTreeMap::new(),
567 env_override_sources: BTreeMap::new(),
568 }
569 }
570}
571
572#[cfg(test)]
573pub(super) mod test_util {
574 use super::Config;
575 use std::path::{Path, PathBuf};
576
577 pub(crate) fn config_in(dir: &Path) -> Config {
582 Config {
583 home_dir: dir.join("home"),
584 ..Config::new_for_test(dir)
585 }
586 }
587
588 pub(crate) async fn make_temp_dir() -> PathBuf {
589 crate::test_support::make_temp_dir("shine-test").await
590 }
591
592 pub(crate) fn restore_current_dir(dir: &Path) {
593 crate::test_support::restore_current_dir(dir)
594 }
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600
601 #[test]
602 fn new_for_test_bin_dir_is_under_root() {
603 let dir = std::env::temp_dir().join("shine-test-bin-dir");
604 let config = Config::new_for_test(&dir);
605 assert_eq!(config.bin_dir(), dir.join("bin"));
606 }
607
608 #[test]
609 fn git_overlay_is_inactive_until_checkout_exists() {
610 let dir = std::env::temp_dir().join(format!("shine-overlay-git-{}", uuid::Uuid::new_v4()));
611 let config = Config::new_for_test(&dir)
612 .with_presets_overlay_git(Some("https://example.com/o.git".to_string()), None);
613
614 let (url, branch, managed) = config.overlay_git_source().unwrap();
616 assert_eq!(url, "https://example.com/o.git");
617 assert_eq!(branch, None);
618 assert_eq!(managed, dir.join("overlay"));
619
620 assert!(config.active_presets_overlay_dir().is_none());
622 std::fs::create_dir_all(dir.join("overlay")).unwrap();
623 assert_eq!(
624 config.active_presets_overlay_dir(),
625 Some(dir.join("overlay").as_path())
626 );
627
628 std::fs::remove_dir_all(&dir).unwrap();
629 }
630
631 #[test]
632 fn manual_overlay_path_takes_precedence_over_git() {
633 let dir = std::env::temp_dir().join(format!("shine-overlay-prec-{}", uuid::Uuid::new_v4()));
634 std::fs::create_dir_all(dir.join("overlay")).unwrap();
635 let manual = dir.join("manual");
636 let config = Config::new_for_test(&dir)
637 .with_presets_overlay_git(Some("https://example.com/o.git".to_string()), None)
638 .with_presets_overlay_dir_override(Some(manual.clone()));
639
640 assert_eq!(config.active_presets_overlay_dir(), Some(manual.as_path()));
641
642 std::fs::remove_dir_all(&dir).unwrap();
643 }
644
645 #[test]
646 fn setting_git_overlay_clears_manual_path() {
647 let dir = std::env::temp_dir().join("shine-overlay-clear");
648 let config = Config::new_for_test(&dir)
649 .with_presets_overlay_dir_override(Some(dir.join("manual")))
650 .with_presets_overlay_git(
651 Some("https://example.com/o.git".to_string()),
652 Some("dev".to_string()),
653 );
654
655 assert!(config.presets_overlay_dir_override.is_none());
656 assert_eq!(
657 config.presets_overlay_git.as_deref(),
658 Some("https://example.com/o.git")
659 );
660 assert_eq!(config.overlay_git_source().unwrap().1, Some("dev"));
661 }
662
663 #[test]
664 fn age_identities_is_empty_without_configured_or_default_identity() {
665 let dir =
666 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
667 let config = Config::new_for_test(&dir);
668
669 assert!(config.resolved_age_identities().is_empty());
670 }
671
672 #[test]
673 fn age_identities_uses_configured_path_when_set() {
674 let dir =
675 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
676 let mut config = Config::new_for_test(&dir);
677 config.age_identity = Some("/tmp/my-identity.txt".to_string());
678
679 assert_eq!(
680 config.resolved_age_identities(),
681 vec![PathBuf::from("/tmp/my-identity.txt")]
682 );
683 }
684
685 #[test]
686 fn age_identities_treats_blank_configured_path_as_unset() {
687 let dir =
688 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
689 let mut config = Config::new_for_test(&dir);
690 config.age_identity = Some(" ".to_string());
691
692 assert!(config.resolved_age_identities().is_empty());
693 }
694
695 #[tokio::test]
696 async fn age_identities_falls_back_to_default_path_when_it_exists() {
697 let dir =
698 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
699 let config = Config::new_for_test(&dir);
700 let default_path = dir.join("age").join("identity.txt");
701 tokio::fs::create_dir_all(default_path.parent().unwrap())
702 .await
703 .unwrap();
704 tokio::fs::write(&default_path, "AGE-SECRET-KEY-1EXAMPLE\n")
705 .await
706 .unwrap();
707
708 assert_eq!(config.resolved_age_identities(), vec![default_path]);
709
710 tokio::fs::remove_dir_all(&dir).await.unwrap();
711 }
712
713 #[test]
714 fn resolved_age_identities_merges_and_deduplicates_paths() {
715 let dir =
716 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
717 let mut config = Config::new_for_test(&dir);
718 config.age_identity = Some("/tmp/primary.txt".to_string());
719 config.age_identities = vec![
720 "/tmp/additional.txt".to_string(),
721 "/tmp/primary.txt".to_string(),
722 " ".to_string(),
723 ];
724
725 assert_eq!(
726 config.resolved_age_identities(),
727 vec![
728 PathBuf::from("/tmp/primary.txt"),
729 PathBuf::from("/tmp/additional.txt"),
730 ]
731 );
732 }
733}