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(
167 default = "default_sync_terminal_theme",
168 skip_serializing_if = "is_true"
169 )]
170 pub sync_terminal_theme: bool,
171 #[serde(
174 rename = "self_install_dest",
175 default,
176 skip_serializing_if = "Option::is_none"
177 )]
178 pub self_install_dest: Option<PathBuf>,
179 #[serde(default, skip_serializing_if = "Vec::is_empty")]
182 pub gpg_recipients: Vec<String>,
183 #[serde(
184 rename = "gpg_key_id",
185 default,
186 skip_serializing_if = "Option::is_none"
187 )]
188 pub legacy_gpg_key_id: Option<String>,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub secret_backend: Option<String>,
195 #[serde(default, skip_serializing_if = "Vec::is_empty")]
200 pub age_recipients: Vec<String>,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub age_identity: Option<String>,
207 #[serde(
209 default = "default_env_map",
210 deserialize_with = "deserialize_env_values"
211 )]
212 pub env: BTreeMap<String, String>,
213 #[serde(default, skip_serializing_if = "Vec::is_empty")]
215 pub env_proxy: Vec<EnvProxyRule>,
216 #[serde(skip)]
218 pub env_descriptions: BTreeMap<String, String>,
219 #[serde(skip)]
225 pub env_override_sources: BTreeMap<String, EnvOverrideSource>,
226}
227
228#[derive(Clone, Debug, PartialEq, Eq)]
231pub struct EnvOverrideSource {
232 pub path: PathBuf,
233 pub kind: EnvOverrideKind,
237 pub is_managed_overlay: bool,
241}
242
243#[derive(Clone, Copy, Debug, PartialEq, Eq)]
247pub enum EnvOverrideKind {
248 Global,
250 Overlay,
252 Project,
254}
255
256#[derive(Clone, Debug)]
257struct ProjectSaveState {
258 original: toml::Table,
259 loaded: toml::Table,
260}
261
262impl Config {
263 pub fn presets_dir(&self) -> &Path {
264 &self.presets_dir
265 }
266
267 pub fn bin_dir(&self) -> &Path {
268 &self.bin_dir
269 }
270
271 pub fn shine_dir(&self) -> &Path {
272 &self.shine_dir
273 }
274
275 pub fn config_path(&self) -> &Path {
276 &self.config_path
277 }
278
279 pub fn is_project_config(&self) -> bool {
281 self.is_project_config
282 }
283
284 pub fn rendered_dir(&self) -> PathBuf {
287 self.shine_dir().join("rendered")
288 }
289
290 pub fn installed_shell_dir(&self) -> PathBuf {
292 self.shine_dir().join("installed").join("shell")
293 }
294
295 pub fn app_default_dest_root(&self) -> PathBuf {
296 match &self.app_default_dest_root_override {
297 Some(p) => {
298 let s = p.to_str().unwrap_or("~/.config");
299 PathBuf::from(tilde_expand(s))
300 }
301 None => self.home_dir.join(".config"),
302 }
303 }
304
305 pub fn new_for_test(dir: &Path) -> Self {
306 Self {
307 config_path: dir.join("config.toml"),
308 is_project_config: false,
309 project_save_state: None,
310 shine_dir: dir.to_path_buf(),
311 presets_dir: dir.join("presets"),
312 bin_dir: dir.join("bin"),
313 home_dir: dir.to_path_buf(),
314 schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
315 last_cleared_schema_version: None,
316 shell_type: ShellType::default(),
317 presets_dir_override: None,
318 external_shell_mode: ExternalShellMode::Snapshot,
319 presets_overlay_dir_override: None,
320 presets_overlay_git: None,
321 presets_overlay_git_branch: None,
322 managed_overlay_dir: None,
323 app_default_dest_root_override: None,
324 is_external_presets: false,
325 allow_app_hooks: false,
326 sync_terminal_theme: default_sync_terminal_theme(),
327 self_install_dest: None,
328 gpg_recipients: Vec::new(),
329 legacy_gpg_key_id: None,
330 secret_backend: None,
331 age_recipients: Vec::new(),
332 age_identity: None,
333 env: default_env_map(),
334 env_proxy: Vec::new(),
335 env_descriptions: BTreeMap::new(),
336 env_override_sources: BTreeMap::new(),
337 }
338 }
339
340 pub fn age_identities(&self) -> Vec<PathBuf> {
344 if let Some(identity) = self
345 .age_identity
346 .as_deref()
347 .map(str::trim)
348 .filter(|value| !value.is_empty())
349 {
350 return vec![PathBuf::from(tilde_expand(identity))];
351 }
352 let default_path = self.shine_dir.join("age").join("identity.txt");
353 if default_path.is_file() {
354 vec![default_path]
355 } else {
356 Vec::new()
357 }
358 }
359
360 pub fn with_presets_dir_override(self, value: Option<PathBuf>) -> Self {
362 Self {
363 presets_dir_override: value,
364 ..self
365 }
366 }
367
368 pub fn with_external_shell_mode(self, value: ExternalShellMode) -> Self {
369 Self {
370 external_shell_mode: value,
371 ..self
372 }
373 }
374
375 pub fn with_presets_overlay_git(self, url: Option<String>, branch: Option<String>) -> Self {
380 let managed_overlay_dir = url.as_ref().map(|_| self.shine_dir.join("overlay"));
381 Self {
382 presets_overlay_dir_override: if url.is_some() {
383 None
384 } else {
385 self.presets_overlay_dir_override
386 },
387 presets_overlay_git_branch: branch,
388 presets_overlay_git: url,
389 managed_overlay_dir,
390 ..self
391 }
392 }
393
394 pub fn with_presets_overlay_dir_override(self, value: Option<PathBuf>) -> Self {
396 Self {
397 presets_overlay_dir_override: value,
398 ..self
399 }
400 }
401
402 pub fn env_override_source(&self, key: &str) -> Option<&EnvOverrideSource> {
406 self.env_override_sources.get(key)
407 }
408
409 pub fn active_presets_overlay_dir(&self) -> Option<&Path> {
410 if let Some(dir) = self.presets_overlay_dir_override.as_deref() {
411 return Some(dir);
412 }
413 self.managed_overlay_dir
417 .as_deref()
418 .filter(|dir| dir.exists())
419 }
420
421 pub fn overlay_git_source(&self) -> Option<(&str, Option<&str>, &Path)> {
425 let url = self.presets_overlay_git.as_deref()?;
426 let dir = self.managed_overlay_dir.as_deref()?;
427 Some((url, self.presets_overlay_git_branch.as_deref(), dir))
428 }
429
430 pub fn preset_path(&self, relative: impl AsRef<Path>) -> PathBuf {
432 let relative = relative.as_ref();
433 if let Some(overlay) = self.active_presets_overlay_dir() {
434 let candidate = overlay.join(relative);
435 if candidate.exists() {
436 return candidate;
437 }
438 }
439 self.presets_dir().join(relative)
440 }
441
442 fn resolve_presets_overlay_dir(&mut self, config_dir: &Path) {
443 if let Some(path) = self.presets_overlay_dir_override.as_deref() {
444 self.presets_overlay_dir_override = Some(resolve_config_presets_path(path, config_dir));
445 }
446 }
447
448 fn resolve_managed_overlay_dir(&mut self) {
452 self.managed_overlay_dir = self
453 .presets_overlay_git
454 .as_ref()
455 .map(|_| self.shine_dir.join("overlay"));
456 }
457}
458
459pub fn print_presets_note(config: &Config) {
462 if config.is_external_presets {
463 println!(
464 "{}",
465 crate::colors::external_presets_note(config.presets_dir())
466 );
467 if let Some(dir) = config.active_presets_overlay_dir() {
468 println!("{}", crate::colors::presets_overlay_note(dir));
469 }
470 let deployment = match config.external_shell_mode {
471 ExternalShellMode::Snapshot => "snapshot · changes require `shine upgrade`",
472 ExternalShellMode::Live => "live · content applies on next invocation",
473 };
474 println!(
475 "{}",
476 crate::colors::dim(&crate::colors::shell_deployment_note(deployment))
477 );
478 println!();
479 } else if let Some(dir) = config.active_presets_overlay_dir() {
480 println!("{}", crate::colors::presets_overlay_note(dir));
481 println!();
482 }
483}
484
485impl Default for Config {
486 fn default() -> Self {
487 let home_dir = crate::home::effective_home_dir();
488 let shine_dir = home_dir.join(".shine");
489
490 Self {
491 presets_dir: shine_dir.join("presets"),
492 bin_dir: shine_dir.join("bin"),
493 config_path: shine_dir.join("config.toml"),
494 is_project_config: false,
495 project_save_state: None,
496 shine_dir,
497 home_dir,
498 schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
499 last_cleared_schema_version: None,
500 shell_type: ShellType::default(),
501 presets_dir_override: None,
502 external_shell_mode: ExternalShellMode::Snapshot,
503 presets_overlay_dir_override: None,
504 presets_overlay_git: None,
505 presets_overlay_git_branch: None,
506 managed_overlay_dir: None,
507 app_default_dest_root_override: None,
508 is_external_presets: false,
509 allow_app_hooks: false,
510 sync_terminal_theme: default_sync_terminal_theme(),
511 self_install_dest: None,
512 gpg_recipients: Vec::new(),
513 legacy_gpg_key_id: None,
514 secret_backend: None,
515 age_recipients: Vec::new(),
516 age_identity: None,
517 env: default_env_map(),
518 env_proxy: Vec::new(),
519 env_descriptions: BTreeMap::new(),
520 env_override_sources: BTreeMap::new(),
521 }
522 }
523}
524
525#[cfg(test)]
526pub(super) mod test_util {
527 use super::Config;
528 use std::path::{Path, PathBuf};
529
530 pub(crate) fn config_in(dir: &Path) -> Config {
535 Config {
536 home_dir: dir.join("home"),
537 ..Config::new_for_test(dir)
538 }
539 }
540
541 pub(crate) async fn make_temp_dir() -> PathBuf {
542 crate::test_support::make_temp_dir("shine-test").await
543 }
544
545 pub(crate) fn restore_current_dir(dir: &Path) {
546 crate::test_support::restore_current_dir(dir)
547 }
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553
554 #[test]
555 fn new_for_test_bin_dir_is_under_root() {
556 let dir = std::env::temp_dir().join("shine-test-bin-dir");
557 let config = Config::new_for_test(&dir);
558 assert_eq!(config.bin_dir(), dir.join("bin"));
559 }
560
561 #[test]
562 fn git_overlay_is_inactive_until_checkout_exists() {
563 let dir = std::env::temp_dir().join(format!("shine-overlay-git-{}", uuid::Uuid::new_v4()));
564 let config = Config::new_for_test(&dir)
565 .with_presets_overlay_git(Some("https://example.com/o.git".to_string()), None);
566
567 let (url, branch, managed) = config.overlay_git_source().unwrap();
569 assert_eq!(url, "https://example.com/o.git");
570 assert_eq!(branch, None);
571 assert_eq!(managed, dir.join("overlay"));
572
573 assert!(config.active_presets_overlay_dir().is_none());
575 std::fs::create_dir_all(dir.join("overlay")).unwrap();
576 assert_eq!(
577 config.active_presets_overlay_dir(),
578 Some(dir.join("overlay").as_path())
579 );
580
581 std::fs::remove_dir_all(&dir).unwrap();
582 }
583
584 #[test]
585 fn manual_overlay_path_takes_precedence_over_git() {
586 let dir = std::env::temp_dir().join(format!("shine-overlay-prec-{}", uuid::Uuid::new_v4()));
587 std::fs::create_dir_all(dir.join("overlay")).unwrap();
588 let manual = dir.join("manual");
589 let config = Config::new_for_test(&dir)
590 .with_presets_overlay_git(Some("https://example.com/o.git".to_string()), None)
591 .with_presets_overlay_dir_override(Some(manual.clone()));
592
593 assert_eq!(config.active_presets_overlay_dir(), Some(manual.as_path()));
594
595 std::fs::remove_dir_all(&dir).unwrap();
596 }
597
598 #[test]
599 fn setting_git_overlay_clears_manual_path() {
600 let dir = std::env::temp_dir().join("shine-overlay-clear");
601 let config = Config::new_for_test(&dir)
602 .with_presets_overlay_dir_override(Some(dir.join("manual")))
603 .with_presets_overlay_git(
604 Some("https://example.com/o.git".to_string()),
605 Some("dev".to_string()),
606 );
607
608 assert!(config.presets_overlay_dir_override.is_none());
609 assert_eq!(
610 config.presets_overlay_git.as_deref(),
611 Some("https://example.com/o.git")
612 );
613 assert_eq!(config.overlay_git_source().unwrap().1, Some("dev"));
614 }
615
616 #[test]
617 fn age_identities_is_empty_without_configured_or_default_identity() {
618 let dir =
619 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
620 let config = Config::new_for_test(&dir);
621
622 assert!(config.age_identities().is_empty());
623 }
624
625 #[test]
626 fn age_identities_uses_configured_path_when_set() {
627 let dir =
628 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
629 let mut config = Config::new_for_test(&dir);
630 config.age_identity = Some("/tmp/my-identity.txt".to_string());
631
632 assert_eq!(
633 config.age_identities(),
634 vec![PathBuf::from("/tmp/my-identity.txt")]
635 );
636 }
637
638 #[test]
639 fn age_identities_treats_blank_configured_path_as_unset() {
640 let dir =
641 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
642 let mut config = Config::new_for_test(&dir);
643 config.age_identity = Some(" ".to_string());
644
645 assert!(config.age_identities().is_empty());
646 }
647
648 #[tokio::test]
649 async fn age_identities_falls_back_to_default_path_when_it_exists() {
650 let dir =
651 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
652 let config = Config::new_for_test(&dir);
653 let default_path = dir.join("age").join("identity.txt");
654 tokio::fs::create_dir_all(default_path.parent().unwrap())
655 .await
656 .unwrap();
657 tokio::fs::write(&default_path, "AGE-SECRET-KEY-1EXAMPLE\n")
658 .await
659 .unwrap();
660
661 assert_eq!(config.age_identities(), vec![default_path]);
662
663 tokio::fs::remove_dir_all(&dir).await.unwrap();
664 }
665}