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 = "Option::is_none")]
204 pub hybrid_decrypt_backend: Option<String>,
205 #[serde(default, skip_serializing_if = "Vec::is_empty")]
210 pub age_recipients: Vec<String>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub age_identity: Option<String>,
216 #[serde(default, skip_serializing_if = "Vec::is_empty")]
219 pub age_identities: Vec<String>,
220 #[serde(
222 default = "default_env_map",
223 deserialize_with = "deserialize_env_values"
224 )]
225 pub env: BTreeMap<String, String>,
226 #[serde(default, skip_serializing_if = "Vec::is_empty")]
228 pub env_proxy: Vec<EnvProxyRule>,
229 #[serde(skip)]
231 pub env_descriptions: BTreeMap<String, String>,
232 #[serde(skip)]
238 pub env_override_sources: BTreeMap<String, EnvOverrideSource>,
239}
240
241#[derive(Clone, Debug, PartialEq, Eq)]
244pub struct EnvOverrideSource {
245 pub path: PathBuf,
246 pub kind: EnvOverrideKind,
250 pub is_managed_overlay: bool,
254}
255
256#[derive(Clone, Copy, Debug, PartialEq, Eq)]
260pub enum EnvOverrideKind {
261 Global,
263 Overlay,
265 Project,
267}
268
269#[derive(Clone, Debug)]
270struct ProjectSaveState {
271 original: toml::Table,
272 loaded: toml::Table,
273}
274
275impl Config {
276 pub fn presets_dir(&self) -> &Path {
277 &self.presets_dir
278 }
279
280 pub fn bin_dir(&self) -> &Path {
281 &self.bin_dir
282 }
283
284 pub fn shine_dir(&self) -> &Path {
285 &self.shine_dir
286 }
287
288 pub fn config_path(&self) -> &Path {
289 &self.config_path
290 }
291
292 pub fn global_config_path(&self) -> PathBuf {
294 self.shine_dir.join(GLOBAL_CONFIG_FILE)
295 }
296
297 pub fn is_project_config(&self) -> bool {
299 self.is_project_config
300 }
301
302 pub fn rendered_dir(&self) -> PathBuf {
305 self.shine_dir().join("rendered")
306 }
307
308 pub fn installed_shell_dir(&self) -> PathBuf {
310 self.shine_dir().join("installed").join("shell")
311 }
312
313 pub fn app_default_dest_root(&self) -> PathBuf {
314 match &self.app_default_dest_root_override {
315 Some(p) => {
316 let s = p.to_str().unwrap_or("~/.config");
317 PathBuf::from(tilde_expand(s))
318 }
319 None => self.home_dir.join(".config"),
320 }
321 }
322
323 pub fn new_for_test(dir: &Path) -> Self {
324 Self {
325 config_path: dir.join("config.toml"),
326 is_project_config: false,
327 project_save_state: None,
328 project_overrides_age_identities: false,
329 shine_dir: dir.to_path_buf(),
330 presets_dir: dir.join("presets"),
331 bin_dir: dir.join("bin"),
332 home_dir: dir.to_path_buf(),
333 schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
334 last_cleared_schema_version: None,
335 shell_type: default_shell_type(),
336 presets_dir_override: None,
337 external_shell_mode: ExternalShellMode::Snapshot,
338 presets_overlay_dir_override: None,
339 presets_overlay_git: None,
340 presets_overlay_git_branch: None,
341 managed_overlay_dir: None,
342 app_default_dest_root_override: None,
343 is_external_presets: false,
344 legacy_allow_app_hooks: false,
345 legacy_allow_sys_code: false,
346 sync_terminal_theme: default_sync_terminal_theme(),
347 self_install_dest: None,
348 gpg_recipients: Vec::new(),
349 legacy_gpg_key_id: None,
350 secret_backend: None,
351 hybrid_decrypt_backend: None,
352 age_recipients: Vec::new(),
353 age_identity: None,
354 age_identities: Vec::new(),
355 env: default_env_map(),
356 env_proxy: Vec::new(),
357 env_descriptions: BTreeMap::new(),
358 env_override_sources: BTreeMap::new(),
359 }
360 }
361
362 pub fn resolved_age_identities(&self) -> Vec<PathBuf> {
366 let mut identities = self
367 .age_identity
368 .iter()
369 .chain(self.age_identities.iter())
370 .map(String::as_str)
371 .map(str::trim)
372 .filter(|value| !value.is_empty())
373 .map(|value| PathBuf::from(tilde_expand(value)))
374 .fold(Vec::new(), |mut paths, path| {
375 if !paths.contains(&path) {
376 paths.push(path);
377 }
378 paths
379 });
380 if !identities.is_empty() {
381 return identities;
382 }
383 let default_path = self.shine_dir.join("age").join("identity.txt");
384 if default_path.is_file() {
385 identities.push(default_path);
386 }
387 identities
388 }
389
390 pub fn project_overrides_age_identities(&self) -> bool {
392 self.project_overrides_age_identities
393 }
394
395 pub fn with_presets_dir_override(self, value: Option<PathBuf>) -> Self {
397 Self {
398 presets_dir_override: value,
399 ..self
400 }
401 }
402
403 pub fn with_external_shell_mode(self, value: ExternalShellMode) -> Self {
404 Self {
405 external_shell_mode: value,
406 ..self
407 }
408 }
409
410 pub fn with_presets_overlay_git(self, url: Option<String>, branch: Option<String>) -> Self {
415 let managed_overlay_dir = url.as_ref().map(|_| self.shine_dir.join("overlay"));
416 Self {
417 presets_overlay_dir_override: if url.is_some() {
418 None
419 } else {
420 self.presets_overlay_dir_override
421 },
422 presets_overlay_git_branch: branch,
423 presets_overlay_git: url,
424 managed_overlay_dir,
425 ..self
426 }
427 }
428
429 pub fn with_presets_overlay_dir_override(self, value: Option<PathBuf>) -> Self {
431 Self {
432 presets_overlay_dir_override: value,
433 ..self
434 }
435 }
436
437 pub fn env_override_source(&self, key: &str) -> Option<&EnvOverrideSource> {
441 self.env_override_sources.get(key)
442 }
443
444 pub fn active_presets_overlay_dir(&self) -> Option<&Path> {
445 if let Some(dir) = self.presets_overlay_dir_override.as_deref() {
446 return Some(dir);
447 }
448 self.managed_overlay_dir
452 .as_deref()
453 .filter(|dir| dir.exists())
454 }
455
456 pub fn overlay_git_source(&self) -> Option<(&str, Option<&str>, &Path)> {
460 let url = self.presets_overlay_git.as_deref()?;
461 let dir = self.managed_overlay_dir.as_deref()?;
462 Some((url, self.presets_overlay_git_branch.as_deref(), dir))
463 }
464
465 pub fn preset_path(&self, relative: impl AsRef<Path>) -> PathBuf {
467 let relative = relative.as_ref();
468 if let Some(overlay) = self.active_presets_overlay_dir() {
469 let candidate = overlay.join(relative);
470 if candidate.exists() {
471 return candidate;
472 }
473 }
474 self.presets_dir().join(relative)
475 }
476
477 fn resolve_presets_overlay_dir(&mut self, config_dir: &Path) {
478 if let Some(path) = self.presets_overlay_dir_override.as_deref() {
479 self.presets_overlay_dir_override = Some(resolve_config_presets_path(path, config_dir));
480 }
481 }
482
483 fn resolve_managed_overlay_dir(&mut self) {
487 self.managed_overlay_dir = self
488 .presets_overlay_git
489 .as_ref()
490 .map(|_| self.shine_dir.join("overlay"));
491 }
492}
493
494impl AsRef<Path> for Config {
495 fn as_ref(&self) -> &Path {
496 self.shine_dir()
497 }
498}
499
500pub fn print_presets_note(config: &Config) {
504 for line in presets_note_lines(config) {
505 println!("{line}");
506 }
507}
508
509pub(crate) fn presets_note_lines(config: &Config) -> Vec<String> {
510 let mut lines = Vec::new();
511 if config.is_external_presets {
512 lines.push(crate::colors::external_presets_note(config.presets_dir()));
513 if let Some(dir) = config.active_presets_overlay_dir() {
514 lines.push(crate::colors::presets_overlay_note(dir));
515 }
516 let deployment = match config.external_shell_mode {
517 ExternalShellMode::Snapshot => "snapshot · changes require `shine upgrade`",
518 ExternalShellMode::Live => "live · content applies on next invocation",
519 };
520 lines.push(crate::colors::dim(&crate::colors::shell_deployment_note(
521 deployment,
522 )));
523 lines.push(String::new());
524 } else {
525 lines.push(crate::colors::presets_source_note("built-in"));
526 if let Some(dir) = config.active_presets_overlay_dir() {
527 lines.push(crate::colors::presets_overlay_note(dir));
528 }
529 lines.push(String::new());
530 }
531 lines
532}
533
534impl Default for Config {
535 fn default() -> Self {
536 let home_dir = crate::home::effective_home_dir();
537 let shine_dir = home_dir.join(".shine");
538
539 Self {
540 presets_dir: shine_dir.join("presets"),
541 bin_dir: shine_dir.join("bin"),
542 config_path: shine_dir.join("config.toml"),
543 is_project_config: false,
544 project_save_state: None,
545 project_overrides_age_identities: false,
546 shine_dir,
547 home_dir,
548 schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
549 last_cleared_schema_version: None,
550 shell_type: default_shell_type(),
551 presets_dir_override: None,
552 external_shell_mode: ExternalShellMode::Snapshot,
553 presets_overlay_dir_override: None,
554 presets_overlay_git: None,
555 presets_overlay_git_branch: None,
556 managed_overlay_dir: None,
557 app_default_dest_root_override: None,
558 is_external_presets: false,
559 legacy_allow_app_hooks: false,
560 legacy_allow_sys_code: false,
561 sync_terminal_theme: default_sync_terminal_theme(),
562 self_install_dest: None,
563 gpg_recipients: Vec::new(),
564 legacy_gpg_key_id: None,
565 secret_backend: None,
566 hybrid_decrypt_backend: None,
567 age_recipients: Vec::new(),
568 age_identity: None,
569 age_identities: Vec::new(),
570 env: default_env_map(),
571 env_proxy: Vec::new(),
572 env_descriptions: BTreeMap::new(),
573 env_override_sources: BTreeMap::new(),
574 }
575 }
576}
577
578#[cfg(test)]
579pub(super) mod test_util {
580 use super::Config;
581 use std::path::{Path, PathBuf};
582
583 pub(crate) fn config_in(dir: &Path) -> Config {
588 Config {
589 home_dir: dir.join("home"),
590 ..Config::new_for_test(dir)
591 }
592 }
593
594 pub(crate) async fn make_temp_dir() -> PathBuf {
595 crate::test_support::make_temp_dir("shine-test").await
596 }
597
598 pub(crate) fn restore_current_dir(dir: &Path) {
599 crate::test_support::restore_current_dir(dir)
600 }
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606
607 #[test]
608 fn new_for_test_bin_dir_is_under_root() {
609 let dir = std::env::temp_dir().join("shine-test-bin-dir");
610 let config = Config::new_for_test(&dir);
611 assert_eq!(config.bin_dir(), dir.join("bin"));
612 }
613
614 #[test]
615 fn git_overlay_is_inactive_until_checkout_exists() {
616 let dir = std::env::temp_dir().join(format!("shine-overlay-git-{}", uuid::Uuid::new_v4()));
617 let config = Config::new_for_test(&dir)
618 .with_presets_overlay_git(Some("https://example.com/o.git".to_string()), None);
619
620 let (url, branch, managed) = config.overlay_git_source().unwrap();
622 assert_eq!(url, "https://example.com/o.git");
623 assert_eq!(branch, None);
624 assert_eq!(managed, dir.join("overlay"));
625
626 assert!(config.active_presets_overlay_dir().is_none());
628 std::fs::create_dir_all(dir.join("overlay")).unwrap();
629 assert_eq!(
630 config.active_presets_overlay_dir(),
631 Some(dir.join("overlay").as_path())
632 );
633
634 std::fs::remove_dir_all(&dir).unwrap();
635 }
636
637 #[test]
638 fn manual_overlay_path_takes_precedence_over_git() {
639 let dir = std::env::temp_dir().join(format!("shine-overlay-prec-{}", uuid::Uuid::new_v4()));
640 std::fs::create_dir_all(dir.join("overlay")).unwrap();
641 let manual = dir.join("manual");
642 let config = Config::new_for_test(&dir)
643 .with_presets_overlay_git(Some("https://example.com/o.git".to_string()), None)
644 .with_presets_overlay_dir_override(Some(manual.clone()));
645
646 assert_eq!(config.active_presets_overlay_dir(), Some(manual.as_path()));
647
648 std::fs::remove_dir_all(&dir).unwrap();
649 }
650
651 #[test]
652 fn setting_git_overlay_clears_manual_path() {
653 let dir = std::env::temp_dir().join("shine-overlay-clear");
654 let config = Config::new_for_test(&dir)
655 .with_presets_overlay_dir_override(Some(dir.join("manual")))
656 .with_presets_overlay_git(
657 Some("https://example.com/o.git".to_string()),
658 Some("dev".to_string()),
659 );
660
661 assert!(config.presets_overlay_dir_override.is_none());
662 assert_eq!(
663 config.presets_overlay_git.as_deref(),
664 Some("https://example.com/o.git")
665 );
666 assert_eq!(config.overlay_git_source().unwrap().1, Some("dev"));
667 }
668
669 #[test]
670 fn age_identities_is_empty_without_configured_or_default_identity() {
671 let dir =
672 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
673 let config = Config::new_for_test(&dir);
674
675 assert!(config.resolved_age_identities().is_empty());
676 }
677
678 #[test]
679 fn age_identities_uses_configured_path_when_set() {
680 let dir =
681 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
682 let mut config = Config::new_for_test(&dir);
683 config.age_identity = Some("/tmp/my-identity.txt".to_string());
684
685 assert_eq!(
686 config.resolved_age_identities(),
687 vec![PathBuf::from("/tmp/my-identity.txt")]
688 );
689 }
690
691 #[test]
692 fn age_identities_treats_blank_configured_path_as_unset() {
693 let dir =
694 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
695 let mut config = Config::new_for_test(&dir);
696 config.age_identity = Some(" ".to_string());
697
698 assert!(config.resolved_age_identities().is_empty());
699 }
700
701 #[tokio::test]
702 async fn age_identities_falls_back_to_default_path_when_it_exists() {
703 let dir =
704 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
705 let config = Config::new_for_test(&dir);
706 let default_path = dir.join("age").join("identity.txt");
707 tokio::fs::create_dir_all(default_path.parent().unwrap())
708 .await
709 .unwrap();
710 tokio::fs::write(&default_path, "AGE-SECRET-KEY-1EXAMPLE\n")
711 .await
712 .unwrap();
713
714 assert_eq!(config.resolved_age_identities(), vec![default_path]);
715
716 tokio::fs::remove_dir_all(&dir).await.unwrap();
717 }
718
719 #[test]
720 fn resolved_age_identities_merges_and_deduplicates_paths() {
721 let dir =
722 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
723 let mut config = Config::new_for_test(&dir);
724 config.age_identity = Some("/tmp/primary.txt".to_string());
725 config.age_identities = vec![
726 "/tmp/additional.txt".to_string(),
727 "/tmp/primary.txt".to_string(),
728 " ".to_string(),
729 ];
730
731 assert_eq!(
732 config.resolved_age_identities(),
733 vec![
734 PathBuf::from("/tmp/primary.txt"),
735 PathBuf::from("/tmp/additional.txt"),
736 ]
737 );
738 }
739}