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 ("GHOSTTY_BG_LIGHT", ""),
32 ("GHOSTTY_BG_DARK", ""),
33];
34
35pub fn default_env_map() -> BTreeMap<String, String> {
36 DEFAULT_ENV_VARS
37 .iter()
38 .map(|(k, v)| (k.to_string(), v.to_string()))
39 .collect()
40}
41
42fn default_sync_terminal_theme() -> bool {
43 true
44}
45
46fn is_true(value: &bool) -> bool {
47 *value
48}
49
50#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
51#[serde(rename_all = "kebab-case")]
52pub enum ExternalShellMode {
53 #[default]
54 Snapshot,
55 Live,
56}
57
58#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
60pub struct EnvProxyRule {
61 pub command: String,
62 #[serde(rename = "with")]
63 pub with: Vec<String>,
64 #[serde(default = "default_env_proxy_enabled", skip_serializing_if = "is_true")]
67 pub enabled: bool,
68}
69
70fn default_env_proxy_enabled() -> bool {
71 true
72}
73
74fn is_snapshot_mode(value: &ExternalShellMode) -> bool {
75 *value == ExternalShellMode::Snapshot
76}
77
78#[derive(Serialize, Deserialize, Clone, Debug)]
79pub struct Config {
80 #[serde(skip)]
82 presets_dir: PathBuf,
83 #[serde(skip)]
85 bin_dir: PathBuf,
86 #[serde(skip)]
88 config_path: PathBuf,
89 #[serde(skip)]
91 is_project_config: bool,
92 #[serde(skip)]
95 project_save_state: Option<ProjectSaveState>,
96 #[serde(skip)]
98 shine_dir: PathBuf,
99 #[serde(skip)]
100 pub home_dir: PathBuf,
101 #[serde(default)]
102 pub schema_version: u32,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub last_cleared_schema_version: Option<u32>,
105 #[serde(skip)]
106 pub shell_type: ShellType,
107 #[serde(
110 rename = "presets_dir",
111 default,
112 skip_serializing_if = "Option::is_none"
113 )]
114 pub presets_dir_override: Option<PathBuf>,
115 #[serde(default, skip_serializing_if = "is_snapshot_mode")]
118 pub external_shell_mode: ExternalShellMode,
119 #[serde(
121 rename = "presets_overlay_dir",
122 default,
123 skip_serializing_if = "Option::is_none"
124 )]
125 pub presets_overlay_dir_override: Option<PathBuf>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub presets_overlay_git: Option<String>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub presets_overlay_git_branch: Option<String>,
136 #[serde(skip)]
140 managed_overlay_dir: Option<PathBuf>,
141 #[serde(
145 rename = "app_default_dest_root",
146 default,
147 skip_serializing_if = "Option::is_none"
148 )]
149 pub app_default_dest_root_override: Option<PathBuf>,
150 #[serde(skip)]
155 pub is_external_presets: bool,
156 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
159 pub allow_app_hooks: bool,
160 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
164 pub allow_sys_code: bool,
165 #[serde(
172 default = "default_sync_terminal_theme",
173 skip_serializing_if = "is_true"
174 )]
175 pub sync_terminal_theme: bool,
176 #[serde(
179 rename = "self_install_dest",
180 default,
181 skip_serializing_if = "Option::is_none"
182 )]
183 pub self_install_dest: Option<PathBuf>,
184 #[serde(default, skip_serializing_if = "Vec::is_empty")]
187 pub gpg_recipients: Vec<String>,
188 #[serde(
189 rename = "gpg_key_id",
190 default,
191 skip_serializing_if = "Option::is_none"
192 )]
193 pub legacy_gpg_key_id: Option<String>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub secret_backend: Option<String>,
200 #[serde(default, skip_serializing_if = "Vec::is_empty")]
205 pub age_recipients: Vec<String>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub age_identity: Option<String>,
212 #[serde(
214 default = "default_env_map",
215 deserialize_with = "deserialize_env_values"
216 )]
217 pub env: BTreeMap<String, String>,
218 #[serde(default, skip_serializing_if = "Vec::is_empty")]
220 pub env_proxy: Vec<EnvProxyRule>,
221 #[serde(skip)]
223 pub env_descriptions: BTreeMap<String, String>,
224 #[serde(skip)]
230 pub env_override_sources: BTreeMap<String, EnvOverrideSource>,
231}
232
233#[derive(Clone, Debug, PartialEq, Eq)]
236pub struct EnvOverrideSource {
237 pub path: PathBuf,
238 pub kind: EnvOverrideKind,
242 pub is_managed_overlay: bool,
246}
247
248#[derive(Clone, Copy, Debug, PartialEq, Eq)]
252pub enum EnvOverrideKind {
253 Global,
255 Overlay,
257 Project,
259}
260
261#[derive(Clone, Debug)]
262struct ProjectSaveState {
263 original: toml::Table,
264 loaded: toml::Table,
265}
266
267impl Config {
268 pub fn presets_dir(&self) -> &Path {
269 &self.presets_dir
270 }
271
272 pub fn bin_dir(&self) -> &Path {
273 &self.bin_dir
274 }
275
276 pub fn shine_dir(&self) -> &Path {
277 &self.shine_dir
278 }
279
280 pub fn config_path(&self) -> &Path {
281 &self.config_path
282 }
283
284 pub fn global_config_path(&self) -> PathBuf {
286 self.shine_dir.join(GLOBAL_CONFIG_FILE)
287 }
288
289 pub fn is_project_config(&self) -> bool {
291 self.is_project_config
292 }
293
294 pub fn rendered_dir(&self) -> PathBuf {
297 self.shine_dir().join("rendered")
298 }
299
300 pub fn installed_shell_dir(&self) -> PathBuf {
302 self.shine_dir().join("installed").join("shell")
303 }
304
305 pub fn app_default_dest_root(&self) -> PathBuf {
306 match &self.app_default_dest_root_override {
307 Some(p) => {
308 let s = p.to_str().unwrap_or("~/.config");
309 PathBuf::from(tilde_expand(s))
310 }
311 None => self.home_dir.join(".config"),
312 }
313 }
314
315 pub fn new_for_test(dir: &Path) -> Self {
316 Self {
317 config_path: dir.join("config.toml"),
318 is_project_config: false,
319 project_save_state: None,
320 shine_dir: dir.to_path_buf(),
321 presets_dir: dir.join("presets"),
322 bin_dir: dir.join("bin"),
323 home_dir: dir.to_path_buf(),
324 schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
325 last_cleared_schema_version: None,
326 shell_type: ShellType::default(),
327 presets_dir_override: None,
328 external_shell_mode: ExternalShellMode::Snapshot,
329 presets_overlay_dir_override: None,
330 presets_overlay_git: None,
331 presets_overlay_git_branch: None,
332 managed_overlay_dir: None,
333 app_default_dest_root_override: None,
334 is_external_presets: false,
335 allow_app_hooks: false,
336 allow_sys_code: false,
337 sync_terminal_theme: default_sync_terminal_theme(),
338 self_install_dest: None,
339 gpg_recipients: Vec::new(),
340 legacy_gpg_key_id: None,
341 secret_backend: None,
342 age_recipients: Vec::new(),
343 age_identity: None,
344 env: default_env_map(),
345 env_proxy: Vec::new(),
346 env_descriptions: BTreeMap::new(),
347 env_override_sources: BTreeMap::new(),
348 }
349 }
350
351 pub fn age_identities(&self) -> Vec<PathBuf> {
355 if let Some(identity) = self
356 .age_identity
357 .as_deref()
358 .map(str::trim)
359 .filter(|value| !value.is_empty())
360 {
361 return vec![PathBuf::from(tilde_expand(identity))];
362 }
363 let default_path = self.shine_dir.join("age").join("identity.txt");
364 if default_path.is_file() {
365 vec![default_path]
366 } else {
367 Vec::new()
368 }
369 }
370
371 pub fn with_presets_dir_override(self, value: Option<PathBuf>) -> Self {
373 Self {
374 presets_dir_override: value,
375 ..self
376 }
377 }
378
379 pub fn with_external_shell_mode(self, value: ExternalShellMode) -> Self {
380 Self {
381 external_shell_mode: value,
382 ..self
383 }
384 }
385
386 pub fn with_presets_overlay_git(self, url: Option<String>, branch: Option<String>) -> Self {
391 let managed_overlay_dir = url.as_ref().map(|_| self.shine_dir.join("overlay"));
392 Self {
393 presets_overlay_dir_override: if url.is_some() {
394 None
395 } else {
396 self.presets_overlay_dir_override
397 },
398 presets_overlay_git_branch: branch,
399 presets_overlay_git: url,
400 managed_overlay_dir,
401 ..self
402 }
403 }
404
405 pub fn with_presets_overlay_dir_override(self, value: Option<PathBuf>) -> Self {
407 Self {
408 presets_overlay_dir_override: value,
409 ..self
410 }
411 }
412
413 pub fn env_override_source(&self, key: &str) -> Option<&EnvOverrideSource> {
417 self.env_override_sources.get(key)
418 }
419
420 pub fn active_presets_overlay_dir(&self) -> Option<&Path> {
421 if let Some(dir) = self.presets_overlay_dir_override.as_deref() {
422 return Some(dir);
423 }
424 self.managed_overlay_dir
428 .as_deref()
429 .filter(|dir| dir.exists())
430 }
431
432 pub fn overlay_git_source(&self) -> Option<(&str, Option<&str>, &Path)> {
436 let url = self.presets_overlay_git.as_deref()?;
437 let dir = self.managed_overlay_dir.as_deref()?;
438 Some((url, self.presets_overlay_git_branch.as_deref(), dir))
439 }
440
441 pub fn preset_path(&self, relative: impl AsRef<Path>) -> PathBuf {
443 let relative = relative.as_ref();
444 if let Some(overlay) = self.active_presets_overlay_dir() {
445 let candidate = overlay.join(relative);
446 if candidate.exists() {
447 return candidate;
448 }
449 }
450 self.presets_dir().join(relative)
451 }
452
453 fn resolve_presets_overlay_dir(&mut self, config_dir: &Path) {
454 if let Some(path) = self.presets_overlay_dir_override.as_deref() {
455 self.presets_overlay_dir_override = Some(resolve_config_presets_path(path, config_dir));
456 }
457 }
458
459 fn resolve_managed_overlay_dir(&mut self) {
463 self.managed_overlay_dir = self
464 .presets_overlay_git
465 .as_ref()
466 .map(|_| self.shine_dir.join("overlay"));
467 }
468}
469
470pub fn print_presets_note(config: &Config) {
474 if config.is_external_presets {
475 println!(
476 "{}",
477 crate::colors::external_presets_note(config.presets_dir())
478 );
479 if let Some(dir) = config.active_presets_overlay_dir() {
480 println!("{}", crate::colors::presets_overlay_note(dir));
481 }
482 let deployment = match config.external_shell_mode {
483 ExternalShellMode::Snapshot => "snapshot · changes require `shine upgrade`",
484 ExternalShellMode::Live => "live · content applies on next invocation",
485 };
486 println!(
487 "{}",
488 crate::colors::dim(&crate::colors::shell_deployment_note(deployment))
489 );
490 println!();
491 } else {
492 println!("{}", crate::colors::presets_source_note("built-in"));
493 if let Some(dir) = config.active_presets_overlay_dir() {
494 println!("{}", crate::colors::presets_overlay_note(dir));
495 }
496 println!();
497 }
498}
499
500impl Default for Config {
501 fn default() -> Self {
502 let home_dir = crate::home::effective_home_dir();
503 let shine_dir = home_dir.join(".shine");
504
505 Self {
506 presets_dir: shine_dir.join("presets"),
507 bin_dir: shine_dir.join("bin"),
508 config_path: shine_dir.join("config.toml"),
509 is_project_config: false,
510 project_save_state: None,
511 shine_dir,
512 home_dir,
513 schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
514 last_cleared_schema_version: None,
515 shell_type: ShellType::default(),
516 presets_dir_override: None,
517 external_shell_mode: ExternalShellMode::Snapshot,
518 presets_overlay_dir_override: None,
519 presets_overlay_git: None,
520 presets_overlay_git_branch: None,
521 managed_overlay_dir: None,
522 app_default_dest_root_override: None,
523 is_external_presets: false,
524 allow_app_hooks: false,
525 allow_sys_code: false,
526 sync_terminal_theme: default_sync_terminal_theme(),
527 self_install_dest: None,
528 gpg_recipients: Vec::new(),
529 legacy_gpg_key_id: None,
530 secret_backend: None,
531 age_recipients: Vec::new(),
532 age_identity: None,
533 env: default_env_map(),
534 env_proxy: Vec::new(),
535 env_descriptions: BTreeMap::new(),
536 env_override_sources: BTreeMap::new(),
537 }
538 }
539}
540
541#[cfg(test)]
542pub(super) mod test_util {
543 use super::Config;
544 use std::path::{Path, PathBuf};
545
546 pub(crate) fn config_in(dir: &Path) -> Config {
551 Config {
552 home_dir: dir.join("home"),
553 ..Config::new_for_test(dir)
554 }
555 }
556
557 pub(crate) async fn make_temp_dir() -> PathBuf {
558 crate::test_support::make_temp_dir("shine-test").await
559 }
560
561 pub(crate) fn restore_current_dir(dir: &Path) {
562 crate::test_support::restore_current_dir(dir)
563 }
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569
570 #[test]
571 fn new_for_test_bin_dir_is_under_root() {
572 let dir = std::env::temp_dir().join("shine-test-bin-dir");
573 let config = Config::new_for_test(&dir);
574 assert_eq!(config.bin_dir(), dir.join("bin"));
575 }
576
577 #[test]
578 fn git_overlay_is_inactive_until_checkout_exists() {
579 let dir = std::env::temp_dir().join(format!("shine-overlay-git-{}", uuid::Uuid::new_v4()));
580 let config = Config::new_for_test(&dir)
581 .with_presets_overlay_git(Some("https://example.com/o.git".to_string()), None);
582
583 let (url, branch, managed) = config.overlay_git_source().unwrap();
585 assert_eq!(url, "https://example.com/o.git");
586 assert_eq!(branch, None);
587 assert_eq!(managed, dir.join("overlay"));
588
589 assert!(config.active_presets_overlay_dir().is_none());
591 std::fs::create_dir_all(dir.join("overlay")).unwrap();
592 assert_eq!(
593 config.active_presets_overlay_dir(),
594 Some(dir.join("overlay").as_path())
595 );
596
597 std::fs::remove_dir_all(&dir).unwrap();
598 }
599
600 #[test]
601 fn manual_overlay_path_takes_precedence_over_git() {
602 let dir = std::env::temp_dir().join(format!("shine-overlay-prec-{}", uuid::Uuid::new_v4()));
603 std::fs::create_dir_all(dir.join("overlay")).unwrap();
604 let manual = dir.join("manual");
605 let config = Config::new_for_test(&dir)
606 .with_presets_overlay_git(Some("https://example.com/o.git".to_string()), None)
607 .with_presets_overlay_dir_override(Some(manual.clone()));
608
609 assert_eq!(config.active_presets_overlay_dir(), Some(manual.as_path()));
610
611 std::fs::remove_dir_all(&dir).unwrap();
612 }
613
614 #[test]
615 fn setting_git_overlay_clears_manual_path() {
616 let dir = std::env::temp_dir().join("shine-overlay-clear");
617 let config = Config::new_for_test(&dir)
618 .with_presets_overlay_dir_override(Some(dir.join("manual")))
619 .with_presets_overlay_git(
620 Some("https://example.com/o.git".to_string()),
621 Some("dev".to_string()),
622 );
623
624 assert!(config.presets_overlay_dir_override.is_none());
625 assert_eq!(
626 config.presets_overlay_git.as_deref(),
627 Some("https://example.com/o.git")
628 );
629 assert_eq!(config.overlay_git_source().unwrap().1, Some("dev"));
630 }
631
632 #[test]
633 fn age_identities_is_empty_without_configured_or_default_identity() {
634 let dir =
635 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
636 let config = Config::new_for_test(&dir);
637
638 assert!(config.age_identities().is_empty());
639 }
640
641 #[test]
642 fn age_identities_uses_configured_path_when_set() {
643 let dir =
644 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
645 let mut config = Config::new_for_test(&dir);
646 config.age_identity = Some("/tmp/my-identity.txt".to_string());
647
648 assert_eq!(
649 config.age_identities(),
650 vec![PathBuf::from("/tmp/my-identity.txt")]
651 );
652 }
653
654 #[test]
655 fn age_identities_treats_blank_configured_path_as_unset() {
656 let dir =
657 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
658 let mut config = Config::new_for_test(&dir);
659 config.age_identity = Some(" ".to_string());
660
661 assert!(config.age_identities().is_empty());
662 }
663
664 #[tokio::test]
665 async fn age_identities_falls_back_to_default_path_when_it_exists() {
666 let dir =
667 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
668 let config = Config::new_for_test(&dir);
669 let default_path = dir.join("age").join("identity.txt");
670 tokio::fs::create_dir_all(default_path.parent().unwrap())
671 .await
672 .unwrap();
673 tokio::fs::write(&default_path, "AGE-SECRET-KEY-1EXAMPLE\n")
674 .await
675 .unwrap();
676
677 assert_eq!(config.age_identities(), vec![default_path]);
678
679 tokio::fs::remove_dir_all(&dir).await.unwrap();
680 }
681}