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 is_true(value: &bool) -> bool {
50 *value
51}
52
53#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
54#[serde(rename_all = "kebab-case")]
55pub enum ExternalShellMode {
56 #[default]
57 Snapshot,
58 Live,
59}
60
61#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
63pub struct EnvProxyRule {
64 pub command: String,
65 #[serde(rename = "with")]
66 pub with: Vec<String>,
67 #[serde(default = "default_env_proxy_enabled", skip_serializing_if = "is_true")]
70 pub enabled: bool,
71}
72
73fn default_env_proxy_enabled() -> bool {
74 true
75}
76
77fn is_snapshot_mode(value: &ExternalShellMode) -> bool {
78 *value == ExternalShellMode::Snapshot
79}
80
81#[derive(Serialize, Deserialize, Clone, Debug)]
82pub struct Config {
83 #[serde(skip)]
85 presets_dir: PathBuf,
86 #[serde(skip)]
88 bin_dir: PathBuf,
89 #[serde(skip)]
91 config_path: PathBuf,
92 #[serde(skip)]
94 is_project_config: bool,
95 #[serde(skip)]
98 project_save_state: Option<ProjectSaveState>,
99 #[serde(skip)]
101 shine_dir: PathBuf,
102 #[serde(skip)]
103 pub home_dir: PathBuf,
104 #[serde(default)]
105 pub schema_version: u32,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub last_cleared_schema_version: Option<u32>,
108 #[serde(skip)]
109 pub shell_type: ShellType,
110 #[serde(
113 rename = "presets_dir",
114 default,
115 skip_serializing_if = "Option::is_none"
116 )]
117 pub presets_dir_override: Option<PathBuf>,
118 #[serde(default, skip_serializing_if = "is_snapshot_mode")]
121 pub external_shell_mode: ExternalShellMode,
122 #[serde(
124 rename = "presets_overlay_dir",
125 default,
126 skip_serializing_if = "Option::is_none"
127 )]
128 pub presets_overlay_dir_override: Option<PathBuf>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub presets_overlay_git: Option<String>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub presets_overlay_git_branch: Option<String>,
139 #[serde(skip)]
143 managed_overlay_dir: Option<PathBuf>,
144 #[serde(
148 rename = "app_default_dest_root",
149 default,
150 skip_serializing_if = "Option::is_none"
151 )]
152 pub app_default_dest_root_override: Option<PathBuf>,
153 #[serde(skip)]
158 pub is_external_presets: bool,
159 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
162 pub allow_app_hooks: bool,
163 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
167 pub allow_sys_code: bool,
168 #[serde(
175 default = "default_sync_terminal_theme",
176 skip_serializing_if = "is_true"
177 )]
178 pub sync_terminal_theme: bool,
179 #[serde(
182 rename = "self_install_dest",
183 default,
184 skip_serializing_if = "Option::is_none"
185 )]
186 pub self_install_dest: Option<PathBuf>,
187 #[serde(default, skip_serializing_if = "Vec::is_empty")]
190 pub gpg_recipients: Vec<String>,
191 #[serde(
192 rename = "gpg_key_id",
193 default,
194 skip_serializing_if = "Option::is_none"
195 )]
196 pub legacy_gpg_key_id: Option<String>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub secret_backend: Option<String>,
203 #[serde(default, skip_serializing_if = "Vec::is_empty")]
208 pub age_recipients: Vec<String>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub age_identity: Option<String>,
215 #[serde(
217 default = "default_env_map",
218 deserialize_with = "deserialize_env_values"
219 )]
220 pub env: BTreeMap<String, String>,
221 #[serde(default, skip_serializing_if = "Vec::is_empty")]
223 pub env_proxy: Vec<EnvProxyRule>,
224 #[serde(skip)]
226 pub env_descriptions: BTreeMap<String, String>,
227 #[serde(skip)]
233 pub env_override_sources: BTreeMap<String, EnvOverrideSource>,
234}
235
236#[derive(Clone, Debug, PartialEq, Eq)]
239pub struct EnvOverrideSource {
240 pub path: PathBuf,
241 pub kind: EnvOverrideKind,
245 pub is_managed_overlay: bool,
249}
250
251#[derive(Clone, Copy, Debug, PartialEq, Eq)]
255pub enum EnvOverrideKind {
256 Global,
258 Overlay,
260 Project,
262}
263
264#[derive(Clone, Debug)]
265struct ProjectSaveState {
266 original: toml::Table,
267 loaded: toml::Table,
268}
269
270impl Config {
271 pub fn presets_dir(&self) -> &Path {
272 &self.presets_dir
273 }
274
275 pub fn bin_dir(&self) -> &Path {
276 &self.bin_dir
277 }
278
279 pub fn shine_dir(&self) -> &Path {
280 &self.shine_dir
281 }
282
283 pub fn config_path(&self) -> &Path {
284 &self.config_path
285 }
286
287 pub fn global_config_path(&self) -> PathBuf {
289 self.shine_dir.join(GLOBAL_CONFIG_FILE)
290 }
291
292 pub fn is_project_config(&self) -> bool {
294 self.is_project_config
295 }
296
297 pub fn rendered_dir(&self) -> PathBuf {
300 self.shine_dir().join("rendered")
301 }
302
303 pub fn installed_shell_dir(&self) -> PathBuf {
305 self.shine_dir().join("installed").join("shell")
306 }
307
308 pub fn app_default_dest_root(&self) -> PathBuf {
309 match &self.app_default_dest_root_override {
310 Some(p) => {
311 let s = p.to_str().unwrap_or("~/.config");
312 PathBuf::from(tilde_expand(s))
313 }
314 None => self.home_dir.join(".config"),
315 }
316 }
317
318 pub fn new_for_test(dir: &Path) -> Self {
319 Self {
320 config_path: dir.join("config.toml"),
321 is_project_config: false,
322 project_save_state: None,
323 shine_dir: dir.to_path_buf(),
324 presets_dir: dir.join("presets"),
325 bin_dir: dir.join("bin"),
326 home_dir: dir.to_path_buf(),
327 schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
328 last_cleared_schema_version: None,
329 shell_type: ShellType::default(),
330 presets_dir_override: None,
331 external_shell_mode: ExternalShellMode::Snapshot,
332 presets_overlay_dir_override: None,
333 presets_overlay_git: None,
334 presets_overlay_git_branch: None,
335 managed_overlay_dir: None,
336 app_default_dest_root_override: None,
337 is_external_presets: false,
338 allow_app_hooks: false,
339 allow_sys_code: false,
340 sync_terminal_theme: default_sync_terminal_theme(),
341 self_install_dest: None,
342 gpg_recipients: Vec::new(),
343 legacy_gpg_key_id: None,
344 secret_backend: None,
345 age_recipients: Vec::new(),
346 age_identity: None,
347 env: default_env_map(),
348 env_proxy: Vec::new(),
349 env_descriptions: BTreeMap::new(),
350 env_override_sources: BTreeMap::new(),
351 }
352 }
353
354 pub fn age_identities(&self) -> Vec<PathBuf> {
358 if let Some(identity) = self
359 .age_identity
360 .as_deref()
361 .map(str::trim)
362 .filter(|value| !value.is_empty())
363 {
364 return vec![PathBuf::from(tilde_expand(identity))];
365 }
366 let default_path = self.shine_dir.join("age").join("identity.txt");
367 if default_path.is_file() {
368 vec![default_path]
369 } else {
370 Vec::new()
371 }
372 }
373
374 pub fn with_presets_dir_override(self, value: Option<PathBuf>) -> Self {
376 Self {
377 presets_dir_override: value,
378 ..self
379 }
380 }
381
382 pub fn with_external_shell_mode(self, value: ExternalShellMode) -> Self {
383 Self {
384 external_shell_mode: value,
385 ..self
386 }
387 }
388
389 pub fn with_presets_overlay_git(self, url: Option<String>, branch: Option<String>) -> Self {
394 let managed_overlay_dir = url.as_ref().map(|_| self.shine_dir.join("overlay"));
395 Self {
396 presets_overlay_dir_override: if url.is_some() {
397 None
398 } else {
399 self.presets_overlay_dir_override
400 },
401 presets_overlay_git_branch: branch,
402 presets_overlay_git: url,
403 managed_overlay_dir,
404 ..self
405 }
406 }
407
408 pub fn with_presets_overlay_dir_override(self, value: Option<PathBuf>) -> Self {
410 Self {
411 presets_overlay_dir_override: value,
412 ..self
413 }
414 }
415
416 pub fn env_override_source(&self, key: &str) -> Option<&EnvOverrideSource> {
420 self.env_override_sources.get(key)
421 }
422
423 pub fn active_presets_overlay_dir(&self) -> Option<&Path> {
424 if let Some(dir) = self.presets_overlay_dir_override.as_deref() {
425 return Some(dir);
426 }
427 self.managed_overlay_dir
431 .as_deref()
432 .filter(|dir| dir.exists())
433 }
434
435 pub fn overlay_git_source(&self) -> Option<(&str, Option<&str>, &Path)> {
439 let url = self.presets_overlay_git.as_deref()?;
440 let dir = self.managed_overlay_dir.as_deref()?;
441 Some((url, self.presets_overlay_git_branch.as_deref(), dir))
442 }
443
444 pub fn preset_path(&self, relative: impl AsRef<Path>) -> PathBuf {
446 let relative = relative.as_ref();
447 if let Some(overlay) = self.active_presets_overlay_dir() {
448 let candidate = overlay.join(relative);
449 if candidate.exists() {
450 return candidate;
451 }
452 }
453 self.presets_dir().join(relative)
454 }
455
456 fn resolve_presets_overlay_dir(&mut self, config_dir: &Path) {
457 if let Some(path) = self.presets_overlay_dir_override.as_deref() {
458 self.presets_overlay_dir_override = Some(resolve_config_presets_path(path, config_dir));
459 }
460 }
461
462 fn resolve_managed_overlay_dir(&mut self) {
466 self.managed_overlay_dir = self
467 .presets_overlay_git
468 .as_ref()
469 .map(|_| self.shine_dir.join("overlay"));
470 }
471}
472
473pub fn print_presets_note(config: &Config) {
477 if config.is_external_presets {
478 println!(
479 "{}",
480 crate::colors::external_presets_note(config.presets_dir())
481 );
482 if let Some(dir) = config.active_presets_overlay_dir() {
483 println!("{}", crate::colors::presets_overlay_note(dir));
484 }
485 let deployment = match config.external_shell_mode {
486 ExternalShellMode::Snapshot => "snapshot · changes require `shine upgrade`",
487 ExternalShellMode::Live => "live · content applies on next invocation",
488 };
489 println!(
490 "{}",
491 crate::colors::dim(&crate::colors::shell_deployment_note(deployment))
492 );
493 println!();
494 } else {
495 println!("{}", crate::colors::presets_source_note("built-in"));
496 if let Some(dir) = config.active_presets_overlay_dir() {
497 println!("{}", crate::colors::presets_overlay_note(dir));
498 }
499 println!();
500 }
501}
502
503impl Default for Config {
504 fn default() -> Self {
505 let home_dir = crate::home::effective_home_dir();
506 let shine_dir = home_dir.join(".shine");
507
508 Self {
509 presets_dir: shine_dir.join("presets"),
510 bin_dir: shine_dir.join("bin"),
511 config_path: shine_dir.join("config.toml"),
512 is_project_config: false,
513 project_save_state: None,
514 shine_dir,
515 home_dir,
516 schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
517 last_cleared_schema_version: None,
518 shell_type: ShellType::default(),
519 presets_dir_override: None,
520 external_shell_mode: ExternalShellMode::Snapshot,
521 presets_overlay_dir_override: None,
522 presets_overlay_git: None,
523 presets_overlay_git_branch: None,
524 managed_overlay_dir: None,
525 app_default_dest_root_override: None,
526 is_external_presets: false,
527 allow_app_hooks: false,
528 allow_sys_code: false,
529 sync_terminal_theme: default_sync_terminal_theme(),
530 self_install_dest: None,
531 gpg_recipients: Vec::new(),
532 legacy_gpg_key_id: None,
533 secret_backend: None,
534 age_recipients: Vec::new(),
535 age_identity: None,
536 env: default_env_map(),
537 env_proxy: Vec::new(),
538 env_descriptions: BTreeMap::new(),
539 env_override_sources: BTreeMap::new(),
540 }
541 }
542}
543
544#[cfg(test)]
545pub(super) mod test_util {
546 use super::Config;
547 use std::path::{Path, PathBuf};
548
549 pub(crate) fn config_in(dir: &Path) -> Config {
554 Config {
555 home_dir: dir.join("home"),
556 ..Config::new_for_test(dir)
557 }
558 }
559
560 pub(crate) async fn make_temp_dir() -> PathBuf {
561 crate::test_support::make_temp_dir("shine-test").await
562 }
563
564 pub(crate) fn restore_current_dir(dir: &Path) {
565 crate::test_support::restore_current_dir(dir)
566 }
567}
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572
573 #[test]
574 fn new_for_test_bin_dir_is_under_root() {
575 let dir = std::env::temp_dir().join("shine-test-bin-dir");
576 let config = Config::new_for_test(&dir);
577 assert_eq!(config.bin_dir(), dir.join("bin"));
578 }
579
580 #[test]
581 fn git_overlay_is_inactive_until_checkout_exists() {
582 let dir = std::env::temp_dir().join(format!("shine-overlay-git-{}", uuid::Uuid::new_v4()));
583 let config = Config::new_for_test(&dir)
584 .with_presets_overlay_git(Some("https://example.com/o.git".to_string()), None);
585
586 let (url, branch, managed) = config.overlay_git_source().unwrap();
588 assert_eq!(url, "https://example.com/o.git");
589 assert_eq!(branch, None);
590 assert_eq!(managed, dir.join("overlay"));
591
592 assert!(config.active_presets_overlay_dir().is_none());
594 std::fs::create_dir_all(dir.join("overlay")).unwrap();
595 assert_eq!(
596 config.active_presets_overlay_dir(),
597 Some(dir.join("overlay").as_path())
598 );
599
600 std::fs::remove_dir_all(&dir).unwrap();
601 }
602
603 #[test]
604 fn manual_overlay_path_takes_precedence_over_git() {
605 let dir = std::env::temp_dir().join(format!("shine-overlay-prec-{}", uuid::Uuid::new_v4()));
606 std::fs::create_dir_all(dir.join("overlay")).unwrap();
607 let manual = dir.join("manual");
608 let config = Config::new_for_test(&dir)
609 .with_presets_overlay_git(Some("https://example.com/o.git".to_string()), None)
610 .with_presets_overlay_dir_override(Some(manual.clone()));
611
612 assert_eq!(config.active_presets_overlay_dir(), Some(manual.as_path()));
613
614 std::fs::remove_dir_all(&dir).unwrap();
615 }
616
617 #[test]
618 fn setting_git_overlay_clears_manual_path() {
619 let dir = std::env::temp_dir().join("shine-overlay-clear");
620 let config = Config::new_for_test(&dir)
621 .with_presets_overlay_dir_override(Some(dir.join("manual")))
622 .with_presets_overlay_git(
623 Some("https://example.com/o.git".to_string()),
624 Some("dev".to_string()),
625 );
626
627 assert!(config.presets_overlay_dir_override.is_none());
628 assert_eq!(
629 config.presets_overlay_git.as_deref(),
630 Some("https://example.com/o.git")
631 );
632 assert_eq!(config.overlay_git_source().unwrap().1, Some("dev"));
633 }
634
635 #[test]
636 fn age_identities_is_empty_without_configured_or_default_identity() {
637 let dir =
638 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
639 let config = Config::new_for_test(&dir);
640
641 assert!(config.age_identities().is_empty());
642 }
643
644 #[test]
645 fn age_identities_uses_configured_path_when_set() {
646 let dir =
647 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
648 let mut config = Config::new_for_test(&dir);
649 config.age_identity = Some("/tmp/my-identity.txt".to_string());
650
651 assert_eq!(
652 config.age_identities(),
653 vec![PathBuf::from("/tmp/my-identity.txt")]
654 );
655 }
656
657 #[test]
658 fn age_identities_treats_blank_configured_path_as_unset() {
659 let dir =
660 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
661 let mut config = Config::new_for_test(&dir);
662 config.age_identity = Some(" ".to_string());
663
664 assert!(config.age_identities().is_empty());
665 }
666
667 #[tokio::test]
668 async fn age_identities_falls_back_to_default_path_when_it_exists() {
669 let dir =
670 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
671 let config = Config::new_for_test(&dir);
672 let default_path = dir.join("age").join("identity.txt");
673 tokio::fs::create_dir_all(default_path.parent().unwrap())
674 .await
675 .unwrap();
676 tokio::fs::write(&default_path, "AGE-SECRET-KEY-1EXAMPLE\n")
677 .await
678 .unwrap();
679
680 assert_eq!(config.age_identities(), vec![default_path]);
681
682 tokio::fs::remove_dir_all(&dir).await.unwrap();
683 }
684}