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::{ReadOnlyRuntimePaths, discover_runtime_paths_read_only};
13use env_layer::deserialize_env_values;
14
15pub use crate::home::{full_expand, full_expand_with_home, tilde_expand};
16pub use env_layer::{validate_env_override_file, write_env_override_entry};
17
18const GLOBAL_CONFIG_FILE: &str = "config.toml";
19const PROJECT_CONFIG_FILE: &str = "shine.config.toml";
20const PROJECT_ENV_FILE: &str = "shine.env.toml";
21
22pub const CURRENT_RUNTIME_SCHEMA_VERSION: u32 = 1;
23
24pub const DEFAULT_ENV_VARS: &[(&str, &str)] = &[
25 ("HTTP_PROXY_PORT", "6152"),
26 ("SOCKS5_PROXY_PORT", "6153"),
27 ("PROXY_HOST", "127.0.0.1"),
28 ("PROXY_NO_PROXY", "localhost,127.0.0.1,::1"),
29 ("GHOSTTY_BG_LIGHT", ""),
30 ("GHOSTTY_BG_DARK", ""),
31];
32
33pub fn default_env_map() -> BTreeMap<String, String> {
34 DEFAULT_ENV_VARS
35 .iter()
36 .map(|(k, v)| (k.to_string(), v.to_string()))
37 .collect()
38}
39
40fn default_sync_terminal_theme() -> bool {
41 true
42}
43
44fn is_true(value: &bool) -> bool {
45 *value
46}
47
48#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
49#[serde(rename_all = "kebab-case")]
50pub enum ExternalShellMode {
51 #[default]
52 Snapshot,
53 Live,
54}
55
56#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
58pub struct EnvProxyRule {
59 pub command: String,
60 #[serde(rename = "with")]
61 pub with: Vec<String>,
62 #[serde(default = "default_env_proxy_enabled", skip_serializing_if = "is_true")]
65 pub enabled: bool,
66}
67
68fn default_env_proxy_enabled() -> bool {
69 true
70}
71
72fn is_snapshot_mode(value: &ExternalShellMode) -> bool {
73 *value == ExternalShellMode::Snapshot
74}
75
76#[derive(Serialize, Deserialize, Clone, Debug)]
77pub struct Config {
78 #[serde(skip)]
80 presets_dir: PathBuf,
81 #[serde(skip)]
83 bin_dir: PathBuf,
84 #[serde(skip)]
86 config_path: PathBuf,
87 #[serde(skip)]
89 is_project_config: bool,
90 #[serde(skip)]
93 project_save_state: Option<ProjectSaveState>,
94 #[serde(skip)]
96 shine_dir: PathBuf,
97 #[serde(skip)]
98 pub home_dir: PathBuf,
99 #[serde(default)]
100 pub schema_version: u32,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub last_cleared_schema_version: Option<u32>,
103 #[serde(skip)]
104 pub shell_type: ShellType,
105 #[serde(
108 rename = "presets_dir",
109 default,
110 skip_serializing_if = "Option::is_none"
111 )]
112 pub presets_dir_override: Option<PathBuf>,
113 #[serde(default, skip_serializing_if = "is_snapshot_mode")]
116 pub external_shell_mode: ExternalShellMode,
117 #[serde(
119 rename = "presets_overlay_dir",
120 default,
121 skip_serializing_if = "Option::is_none"
122 )]
123 pub presets_overlay_dir_override: Option<PathBuf>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub presets_overlay_git: Option<String>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub presets_overlay_git_branch: Option<String>,
134 #[serde(skip)]
138 managed_overlay_dir: Option<PathBuf>,
139 #[serde(
143 rename = "app_default_dest_root",
144 default,
145 skip_serializing_if = "Option::is_none"
146 )]
147 pub app_default_dest_root_override: Option<PathBuf>,
148 #[serde(skip)]
153 pub is_external_presets: bool,
154 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
157 pub allow_app_hooks: bool,
158 #[serde(
165 default = "default_sync_terminal_theme",
166 skip_serializing_if = "is_true"
167 )]
168 pub sync_terminal_theme: bool,
169 #[serde(
172 rename = "self_install_dest",
173 default,
174 skip_serializing_if = "Option::is_none"
175 )]
176 pub self_install_dest: Option<PathBuf>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub gpg_key_id: Option<String>,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub secret_backend: Option<String>,
187 #[serde(default, skip_serializing_if = "Vec::is_empty")]
192 pub age_recipients: Vec<String>,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub age_identity: Option<String>,
199 #[serde(
201 default = "default_env_map",
202 deserialize_with = "deserialize_env_values"
203 )]
204 pub env: BTreeMap<String, String>,
205 #[serde(default, skip_serializing_if = "Vec::is_empty")]
207 pub env_proxy: Vec<EnvProxyRule>,
208 #[serde(skip)]
210 pub env_descriptions: BTreeMap<String, String>,
211 #[serde(skip)]
217 pub env_override_sources: BTreeMap<String, EnvOverrideSource>,
218}
219
220#[derive(Clone, Debug, PartialEq, Eq)]
223pub struct EnvOverrideSource {
224 pub path: PathBuf,
225 pub kind: EnvOverrideKind,
229 pub is_managed_overlay: bool,
233}
234
235#[derive(Clone, Copy, Debug, PartialEq, Eq)]
239pub enum EnvOverrideKind {
240 Global,
242 Overlay,
244 Project,
246}
247
248#[derive(Clone, Debug)]
249struct ProjectSaveState {
250 original: toml::Table,
251 loaded: toml::Table,
252}
253
254impl Config {
255 pub fn presets_dir(&self) -> &Path {
256 &self.presets_dir
257 }
258
259 pub fn bin_dir(&self) -> &Path {
260 &self.bin_dir
261 }
262
263 pub fn shine_dir(&self) -> &Path {
264 &self.shine_dir
265 }
266
267 pub fn config_path(&self) -> &Path {
268 &self.config_path
269 }
270
271 pub fn is_project_config(&self) -> bool {
273 self.is_project_config
274 }
275
276 pub fn rendered_dir(&self) -> PathBuf {
279 self.shine_dir().join("rendered")
280 }
281
282 pub fn installed_shell_dir(&self) -> PathBuf {
284 self.shine_dir().join("installed").join("shell")
285 }
286
287 pub fn app_default_dest_root(&self) -> PathBuf {
288 match &self.app_default_dest_root_override {
289 Some(p) => {
290 let s = p.to_str().unwrap_or("~/.config");
291 PathBuf::from(tilde_expand(s))
292 }
293 None => self.home_dir.join(".config"),
294 }
295 }
296
297 pub fn new_for_test(dir: &Path) -> Self {
298 Self {
299 config_path: dir.join("config.toml"),
300 is_project_config: false,
301 project_save_state: None,
302 shine_dir: dir.to_path_buf(),
303 presets_dir: dir.join("presets"),
304 bin_dir: dir.join("bin"),
305 home_dir: dir.to_path_buf(),
306 schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
307 last_cleared_schema_version: None,
308 shell_type: ShellType::default(),
309 presets_dir_override: None,
310 external_shell_mode: ExternalShellMode::Snapshot,
311 presets_overlay_dir_override: None,
312 presets_overlay_git: None,
313 presets_overlay_git_branch: None,
314 managed_overlay_dir: None,
315 app_default_dest_root_override: None,
316 is_external_presets: false,
317 allow_app_hooks: false,
318 sync_terminal_theme: default_sync_terminal_theme(),
319 self_install_dest: None,
320 gpg_key_id: None,
321 secret_backend: None,
322 age_recipients: Vec::new(),
323 age_identity: None,
324 env: default_env_map(),
325 env_proxy: Vec::new(),
326 env_descriptions: BTreeMap::new(),
327 env_override_sources: BTreeMap::new(),
328 }
329 }
330
331 pub fn age_identities(&self) -> Vec<PathBuf> {
335 if let Some(identity) = self
336 .age_identity
337 .as_deref()
338 .map(str::trim)
339 .filter(|value| !value.is_empty())
340 {
341 return vec![PathBuf::from(tilde_expand(identity))];
342 }
343 let default_path = self.shine_dir.join("age").join("identity.txt");
344 if default_path.is_file() {
345 vec![default_path]
346 } else {
347 Vec::new()
348 }
349 }
350
351 pub fn with_presets_dir_override(self, value: Option<PathBuf>) -> Self {
353 Self {
354 presets_dir_override: value,
355 ..self
356 }
357 }
358
359 pub fn with_external_shell_mode(self, value: ExternalShellMode) -> Self {
360 Self {
361 external_shell_mode: value,
362 ..self
363 }
364 }
365
366 pub fn with_presets_overlay_git(self, url: Option<String>, branch: Option<String>) -> Self {
371 let managed_overlay_dir = url.as_ref().map(|_| self.shine_dir.join("overlay"));
372 Self {
373 presets_overlay_dir_override: if url.is_some() {
374 None
375 } else {
376 self.presets_overlay_dir_override
377 },
378 presets_overlay_git_branch: branch,
379 presets_overlay_git: url,
380 managed_overlay_dir,
381 ..self
382 }
383 }
384
385 pub fn with_presets_overlay_dir_override(self, value: Option<PathBuf>) -> Self {
387 Self {
388 presets_overlay_dir_override: value,
389 ..self
390 }
391 }
392
393 pub fn env_override_source(&self, key: &str) -> Option<&EnvOverrideSource> {
397 self.env_override_sources.get(key)
398 }
399
400 pub fn active_presets_overlay_dir(&self) -> Option<&Path> {
401 if let Some(dir) = self.presets_overlay_dir_override.as_deref() {
402 return Some(dir);
403 }
404 self.managed_overlay_dir
408 .as_deref()
409 .filter(|dir| dir.exists())
410 }
411
412 pub fn overlay_git_source(&self) -> Option<(&str, Option<&str>, &Path)> {
416 let url = self.presets_overlay_git.as_deref()?;
417 let dir = self.managed_overlay_dir.as_deref()?;
418 Some((url, self.presets_overlay_git_branch.as_deref(), dir))
419 }
420
421 pub fn preset_path(&self, relative: impl AsRef<Path>) -> PathBuf {
423 let relative = relative.as_ref();
424 if let Some(overlay) = self.active_presets_overlay_dir() {
425 let candidate = overlay.join(relative);
426 if candidate.exists() {
427 return candidate;
428 }
429 }
430 self.presets_dir().join(relative)
431 }
432
433 fn resolve_presets_overlay_dir(&mut self, config_dir: &Path) {
434 if let Some(path) = self.presets_overlay_dir_override.as_deref() {
435 self.presets_overlay_dir_override = Some(resolve_config_presets_path(path, config_dir));
436 }
437 }
438
439 fn resolve_managed_overlay_dir(&mut self) {
443 self.managed_overlay_dir = self
444 .presets_overlay_git
445 .as_ref()
446 .map(|_| self.shine_dir.join("overlay"));
447 }
448}
449
450pub fn print_presets_note(config: &Config) {
453 if config.is_external_presets {
454 println!(
455 "{}",
456 crate::colors::external_presets_note(config.presets_dir())
457 );
458 if let Some(dir) = config.active_presets_overlay_dir() {
459 println!("{}", crate::colors::presets_overlay_note(dir));
460 }
461 let deployment = match config.external_shell_mode {
462 ExternalShellMode::Snapshot => "snapshot · changes require `shine upgrade`",
463 ExternalShellMode::Live => "live · content applies on next invocation",
464 };
465 println!(
466 "{}",
467 crate::colors::dim(&crate::colors::shell_deployment_note(deployment))
468 );
469 println!();
470 } else if let Some(dir) = config.active_presets_overlay_dir() {
471 println!("{}", crate::colors::presets_overlay_note(dir));
472 println!();
473 }
474}
475
476impl Default for Config {
477 fn default() -> Self {
478 let home_dir = crate::home::effective_home_dir();
479 let shine_dir = home_dir.join(".shine");
480
481 Self {
482 presets_dir: shine_dir.join("presets"),
483 bin_dir: shine_dir.join("bin"),
484 config_path: shine_dir.join("config.toml"),
485 is_project_config: false,
486 project_save_state: None,
487 shine_dir,
488 home_dir,
489 schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
490 last_cleared_schema_version: None,
491 shell_type: ShellType::default(),
492 presets_dir_override: None,
493 external_shell_mode: ExternalShellMode::Snapshot,
494 presets_overlay_dir_override: None,
495 presets_overlay_git: None,
496 presets_overlay_git_branch: None,
497 managed_overlay_dir: None,
498 app_default_dest_root_override: None,
499 is_external_presets: false,
500 allow_app_hooks: false,
501 sync_terminal_theme: default_sync_terminal_theme(),
502 self_install_dest: None,
503 gpg_key_id: None,
504 secret_backend: None,
505 age_recipients: Vec::new(),
506 age_identity: None,
507 env: default_env_map(),
508 env_proxy: Vec::new(),
509 env_descriptions: BTreeMap::new(),
510 env_override_sources: BTreeMap::new(),
511 }
512 }
513}
514
515#[cfg(test)]
516pub(super) mod test_util {
517 use super::Config;
518 use std::path::{Path, PathBuf};
519
520 pub(crate) fn config_in(dir: &Path) -> Config {
525 Config {
526 home_dir: dir.join("home"),
527 ..Config::new_for_test(dir)
528 }
529 }
530
531 pub(crate) async fn make_temp_dir() -> PathBuf {
532 crate::test_support::make_temp_dir("shine-test").await
533 }
534
535 pub(crate) fn restore_current_dir(dir: &Path) {
536 crate::test_support::restore_current_dir(dir)
537 }
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543
544 #[test]
545 fn new_for_test_bin_dir_is_under_root() {
546 let dir = std::env::temp_dir().join("shine-test-bin-dir");
547 let config = Config::new_for_test(&dir);
548 assert_eq!(config.bin_dir(), dir.join("bin"));
549 }
550
551 #[test]
552 fn git_overlay_is_inactive_until_checkout_exists() {
553 let dir = std::env::temp_dir().join(format!("shine-overlay-git-{}", uuid::Uuid::new_v4()));
554 let config = Config::new_for_test(&dir)
555 .with_presets_overlay_git(Some("https://example.com/o.git".to_string()), None);
556
557 let (url, branch, managed) = config.overlay_git_source().unwrap();
559 assert_eq!(url, "https://example.com/o.git");
560 assert_eq!(branch, None);
561 assert_eq!(managed, dir.join("overlay"));
562
563 assert!(config.active_presets_overlay_dir().is_none());
565 std::fs::create_dir_all(dir.join("overlay")).unwrap();
566 assert_eq!(
567 config.active_presets_overlay_dir(),
568 Some(dir.join("overlay").as_path())
569 );
570
571 std::fs::remove_dir_all(&dir).unwrap();
572 }
573
574 #[test]
575 fn manual_overlay_path_takes_precedence_over_git() {
576 let dir = std::env::temp_dir().join(format!("shine-overlay-prec-{}", uuid::Uuid::new_v4()));
577 std::fs::create_dir_all(dir.join("overlay")).unwrap();
578 let manual = dir.join("manual");
579 let config = Config::new_for_test(&dir)
580 .with_presets_overlay_git(Some("https://example.com/o.git".to_string()), None)
581 .with_presets_overlay_dir_override(Some(manual.clone()));
582
583 assert_eq!(config.active_presets_overlay_dir(), Some(manual.as_path()));
584
585 std::fs::remove_dir_all(&dir).unwrap();
586 }
587
588 #[test]
589 fn setting_git_overlay_clears_manual_path() {
590 let dir = std::env::temp_dir().join("shine-overlay-clear");
591 let config = Config::new_for_test(&dir)
592 .with_presets_overlay_dir_override(Some(dir.join("manual")))
593 .with_presets_overlay_git(
594 Some("https://example.com/o.git".to_string()),
595 Some("dev".to_string()),
596 );
597
598 assert!(config.presets_overlay_dir_override.is_none());
599 assert_eq!(
600 config.presets_overlay_git.as_deref(),
601 Some("https://example.com/o.git")
602 );
603 assert_eq!(config.overlay_git_source().unwrap().1, Some("dev"));
604 }
605
606 #[test]
607 fn age_identities_is_empty_without_configured_or_default_identity() {
608 let dir =
609 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
610 let config = Config::new_for_test(&dir);
611
612 assert!(config.age_identities().is_empty());
613 }
614
615 #[test]
616 fn age_identities_uses_configured_path_when_set() {
617 let dir =
618 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
619 let mut config = Config::new_for_test(&dir);
620 config.age_identity = Some("/tmp/my-identity.txt".to_string());
621
622 assert_eq!(
623 config.age_identities(),
624 vec![PathBuf::from("/tmp/my-identity.txt")]
625 );
626 }
627
628 #[test]
629 fn age_identities_treats_blank_configured_path_as_unset() {
630 let dir =
631 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
632 let mut config = Config::new_for_test(&dir);
633 config.age_identity = Some(" ".to_string());
634
635 assert!(config.age_identities().is_empty());
636 }
637
638 #[tokio::test]
639 async fn age_identities_falls_back_to_default_path_when_it_exists() {
640 let dir =
641 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
642 let config = Config::new_for_test(&dir);
643 let default_path = dir.join("age").join("identity.txt");
644 tokio::fs::create_dir_all(default_path.parent().unwrap())
645 .await
646 .unwrap();
647 tokio::fs::write(&default_path, "AGE-SECRET-KEY-1EXAMPLE\n")
648 .await
649 .unwrap();
650
651 assert_eq!(config.age_identities(), vec![default_path]);
652
653 tokio::fs::remove_dir_all(&dir).await.unwrap();
654 }
655}