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
56fn is_snapshot_mode(value: &ExternalShellMode) -> bool {
57 *value == ExternalShellMode::Snapshot
58}
59
60#[derive(Serialize, Deserialize, Clone, Debug)]
61pub struct Config {
62 #[serde(skip)]
64 presets_dir: PathBuf,
65 #[serde(skip)]
67 bin_dir: PathBuf,
68 #[serde(skip)]
70 config_path: PathBuf,
71 #[serde(skip)]
73 is_project_config: bool,
74 #[serde(skip)]
77 project_save_state: Option<ProjectSaveState>,
78 #[serde(skip)]
80 shine_dir: PathBuf,
81 #[serde(skip)]
82 pub home_dir: PathBuf,
83 #[serde(default)]
84 pub schema_version: u32,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub last_cleared_schema_version: Option<u32>,
87 #[serde(skip)]
88 pub shell_type: ShellType,
89 #[serde(
92 rename = "presets_dir",
93 default,
94 skip_serializing_if = "Option::is_none"
95 )]
96 pub presets_dir_override: Option<PathBuf>,
97 #[serde(default, skip_serializing_if = "is_snapshot_mode")]
100 pub external_shell_mode: ExternalShellMode,
101 #[serde(
103 rename = "presets_overlay_dir",
104 default,
105 skip_serializing_if = "Option::is_none"
106 )]
107 pub presets_overlay_dir_override: Option<PathBuf>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub presets_overlay_git: Option<String>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub presets_overlay_git_branch: Option<String>,
118 #[serde(skip)]
122 managed_overlay_dir: Option<PathBuf>,
123 #[serde(
127 rename = "app_default_dest_root",
128 default,
129 skip_serializing_if = "Option::is_none"
130 )]
131 pub app_default_dest_root_override: Option<PathBuf>,
132 #[serde(skip)]
137 pub is_external_presets: bool,
138 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
141 pub allow_app_hooks: bool,
142 #[serde(
149 default = "default_sync_terminal_theme",
150 skip_serializing_if = "is_true"
151 )]
152 pub sync_terminal_theme: bool,
153 #[serde(
156 rename = "self_install_dest",
157 default,
158 skip_serializing_if = "Option::is_none"
159 )]
160 pub self_install_dest: Option<PathBuf>,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub gpg_key_id: Option<String>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub secret_backend: Option<String>,
171 #[serde(default, skip_serializing_if = "Vec::is_empty")]
176 pub age_recipients: Vec<String>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub age_identity: Option<String>,
183 #[serde(
185 default = "default_env_map",
186 deserialize_with = "deserialize_env_values"
187 )]
188 pub env: BTreeMap<String, String>,
189 #[serde(skip)]
191 pub env_descriptions: BTreeMap<String, String>,
192 #[serde(skip)]
198 pub env_override_sources: BTreeMap<String, EnvOverrideSource>,
199}
200
201#[derive(Clone, Debug, PartialEq, Eq)]
204pub struct EnvOverrideSource {
205 pub path: PathBuf,
206 pub kind: EnvOverrideKind,
210 pub is_managed_overlay: bool,
214}
215
216#[derive(Clone, Copy, Debug, PartialEq, Eq)]
220pub enum EnvOverrideKind {
221 Global,
223 Overlay,
225 Project,
227}
228
229#[derive(Clone, Debug)]
230struct ProjectSaveState {
231 original: toml::Table,
232 loaded: toml::Table,
233}
234
235impl Config {
236 pub fn presets_dir(&self) -> &Path {
237 &self.presets_dir
238 }
239
240 pub fn bin_dir(&self) -> &Path {
241 &self.bin_dir
242 }
243
244 pub fn shine_dir(&self) -> &Path {
245 &self.shine_dir
246 }
247
248 pub fn config_path(&self) -> &Path {
249 &self.config_path
250 }
251
252 pub fn rendered_dir(&self) -> PathBuf {
255 self.shine_dir().join("rendered")
256 }
257
258 pub fn installed_shell_dir(&self) -> PathBuf {
260 self.shine_dir().join("installed").join("shell")
261 }
262
263 pub fn app_default_dest_root(&self) -> PathBuf {
264 match &self.app_default_dest_root_override {
265 Some(p) => {
266 let s = p.to_str().unwrap_or("~/.config");
267 PathBuf::from(tilde_expand(s))
268 }
269 None => self.home_dir.join(".config"),
270 }
271 }
272
273 pub fn new_for_test(dir: &Path) -> Self {
274 Self {
275 config_path: dir.join("config.toml"),
276 is_project_config: false,
277 project_save_state: None,
278 shine_dir: dir.to_path_buf(),
279 presets_dir: dir.join("presets"),
280 bin_dir: dir.join("bin"),
281 home_dir: dir.to_path_buf(),
282 schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
283 last_cleared_schema_version: None,
284 shell_type: ShellType::default(),
285 presets_dir_override: None,
286 external_shell_mode: ExternalShellMode::Snapshot,
287 presets_overlay_dir_override: None,
288 presets_overlay_git: None,
289 presets_overlay_git_branch: None,
290 managed_overlay_dir: None,
291 app_default_dest_root_override: None,
292 is_external_presets: false,
293 allow_app_hooks: false,
294 sync_terminal_theme: default_sync_terminal_theme(),
295 self_install_dest: None,
296 gpg_key_id: None,
297 secret_backend: None,
298 age_recipients: Vec::new(),
299 age_identity: None,
300 env: default_env_map(),
301 env_descriptions: BTreeMap::new(),
302 env_override_sources: BTreeMap::new(),
303 }
304 }
305
306 pub fn age_identities(&self) -> Vec<PathBuf> {
310 if let Some(identity) = self
311 .age_identity
312 .as_deref()
313 .map(str::trim)
314 .filter(|value| !value.is_empty())
315 {
316 return vec![PathBuf::from(tilde_expand(identity))];
317 }
318 let default_path = self.shine_dir.join("age").join("identity.txt");
319 if default_path.is_file() {
320 vec![default_path]
321 } else {
322 Vec::new()
323 }
324 }
325
326 pub fn with_presets_dir_override(self, value: Option<PathBuf>) -> Self {
328 Self {
329 presets_dir_override: value,
330 ..self
331 }
332 }
333
334 pub fn with_external_shell_mode(self, value: ExternalShellMode) -> Self {
335 Self {
336 external_shell_mode: value,
337 ..self
338 }
339 }
340
341 pub fn with_presets_overlay_git(self, url: Option<String>, branch: Option<String>) -> Self {
346 let managed_overlay_dir = url.as_ref().map(|_| self.shine_dir.join("overlay"));
347 Self {
348 presets_overlay_dir_override: if url.is_some() {
349 None
350 } else {
351 self.presets_overlay_dir_override
352 },
353 presets_overlay_git_branch: branch,
354 presets_overlay_git: url,
355 managed_overlay_dir,
356 ..self
357 }
358 }
359
360 pub fn with_presets_overlay_dir_override(self, value: Option<PathBuf>) -> Self {
362 Self {
363 presets_overlay_dir_override: value,
364 ..self
365 }
366 }
367
368 pub fn env_override_source(&self, key: &str) -> Option<&EnvOverrideSource> {
372 self.env_override_sources.get(key)
373 }
374
375 pub fn active_presets_overlay_dir(&self) -> Option<&Path> {
376 if let Some(dir) = self.presets_overlay_dir_override.as_deref() {
377 return Some(dir);
378 }
379 self.managed_overlay_dir
383 .as_deref()
384 .filter(|dir| dir.exists())
385 }
386
387 pub fn overlay_git_source(&self) -> Option<(&str, Option<&str>, &Path)> {
391 let url = self.presets_overlay_git.as_deref()?;
392 let dir = self.managed_overlay_dir.as_deref()?;
393 Some((url, self.presets_overlay_git_branch.as_deref(), dir))
394 }
395
396 pub fn preset_path(&self, relative: impl AsRef<Path>) -> PathBuf {
398 let relative = relative.as_ref();
399 if let Some(overlay) = self.active_presets_overlay_dir() {
400 let candidate = overlay.join(relative);
401 if candidate.exists() {
402 return candidate;
403 }
404 }
405 self.presets_dir().join(relative)
406 }
407
408 fn resolve_presets_overlay_dir(&mut self, config_dir: &Path) {
409 if let Some(path) = self.presets_overlay_dir_override.as_deref() {
410 self.presets_overlay_dir_override = Some(resolve_config_presets_path(path, config_dir));
411 }
412 }
413
414 fn resolve_managed_overlay_dir(&mut self) {
418 self.managed_overlay_dir = self
419 .presets_overlay_git
420 .as_ref()
421 .map(|_| self.shine_dir.join("overlay"));
422 }
423}
424
425pub fn print_presets_note(config: &Config) {
428 if config.is_external_presets {
429 println!(
430 "{}",
431 crate::colors::external_presets_note(config.presets_dir())
432 );
433 if let Some(dir) = config.active_presets_overlay_dir() {
434 println!("{}", crate::colors::presets_overlay_note(dir));
435 }
436 let deployment = match config.external_shell_mode {
437 ExternalShellMode::Snapshot => "snapshot · changes require `shine upgrade`",
438 ExternalShellMode::Live => "live · content applies on next invocation",
439 };
440 println!(
441 "{}",
442 crate::colors::dim(&crate::colors::shell_deployment_note(deployment))
443 );
444 println!();
445 } else if let Some(dir) = config.active_presets_overlay_dir() {
446 println!("{}", crate::colors::presets_overlay_note(dir));
447 println!();
448 }
449}
450
451impl Default for Config {
452 fn default() -> Self {
453 let home_dir = crate::home::effective_home_dir();
454 let shine_dir = home_dir.join(".shine");
455
456 Self {
457 presets_dir: shine_dir.join("presets"),
458 bin_dir: shine_dir.join("bin"),
459 config_path: shine_dir.join("config.toml"),
460 is_project_config: false,
461 project_save_state: None,
462 shine_dir,
463 home_dir,
464 schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
465 last_cleared_schema_version: None,
466 shell_type: ShellType::default(),
467 presets_dir_override: None,
468 external_shell_mode: ExternalShellMode::Snapshot,
469 presets_overlay_dir_override: None,
470 presets_overlay_git: None,
471 presets_overlay_git_branch: None,
472 managed_overlay_dir: None,
473 app_default_dest_root_override: None,
474 is_external_presets: false,
475 allow_app_hooks: false,
476 sync_terminal_theme: default_sync_terminal_theme(),
477 self_install_dest: None,
478 gpg_key_id: None,
479 secret_backend: None,
480 age_recipients: Vec::new(),
481 age_identity: None,
482 env: default_env_map(),
483 env_descriptions: BTreeMap::new(),
484 env_override_sources: BTreeMap::new(),
485 }
486 }
487}
488
489#[cfg(test)]
490pub(super) mod test_util {
491 use super::Config;
492 use std::path::{Path, PathBuf};
493
494 pub(crate) fn config_in(dir: &Path) -> Config {
499 Config {
500 home_dir: dir.join("home"),
501 ..Config::new_for_test(dir)
502 }
503 }
504
505 pub(crate) async fn make_temp_dir() -> PathBuf {
506 crate::test_support::make_temp_dir("shine-test").await
507 }
508
509 pub(crate) fn restore_current_dir(dir: &Path) {
510 crate::test_support::restore_current_dir(dir)
511 }
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517
518 #[test]
519 fn new_for_test_bin_dir_is_under_root() {
520 let dir = std::env::temp_dir().join("shine-test-bin-dir");
521 let config = Config::new_for_test(&dir);
522 assert_eq!(config.bin_dir(), dir.join("bin"));
523 }
524
525 #[test]
526 fn git_overlay_is_inactive_until_checkout_exists() {
527 let dir = std::env::temp_dir().join(format!("shine-overlay-git-{}", uuid::Uuid::new_v4()));
528 let config = Config::new_for_test(&dir)
529 .with_presets_overlay_git(Some("https://example.com/o.git".to_string()), None);
530
531 let (url, branch, managed) = config.overlay_git_source().unwrap();
533 assert_eq!(url, "https://example.com/o.git");
534 assert_eq!(branch, None);
535 assert_eq!(managed, dir.join("overlay"));
536
537 assert!(config.active_presets_overlay_dir().is_none());
539 std::fs::create_dir_all(dir.join("overlay")).unwrap();
540 assert_eq!(
541 config.active_presets_overlay_dir(),
542 Some(dir.join("overlay").as_path())
543 );
544
545 std::fs::remove_dir_all(&dir).unwrap();
546 }
547
548 #[test]
549 fn manual_overlay_path_takes_precedence_over_git() {
550 let dir = std::env::temp_dir().join(format!("shine-overlay-prec-{}", uuid::Uuid::new_v4()));
551 std::fs::create_dir_all(dir.join("overlay")).unwrap();
552 let manual = dir.join("manual");
553 let config = Config::new_for_test(&dir)
554 .with_presets_overlay_git(Some("https://example.com/o.git".to_string()), None)
555 .with_presets_overlay_dir_override(Some(manual.clone()));
556
557 assert_eq!(config.active_presets_overlay_dir(), Some(manual.as_path()));
558
559 std::fs::remove_dir_all(&dir).unwrap();
560 }
561
562 #[test]
563 fn setting_git_overlay_clears_manual_path() {
564 let dir = std::env::temp_dir().join("shine-overlay-clear");
565 let config = Config::new_for_test(&dir)
566 .with_presets_overlay_dir_override(Some(dir.join("manual")))
567 .with_presets_overlay_git(
568 Some("https://example.com/o.git".to_string()),
569 Some("dev".to_string()),
570 );
571
572 assert!(config.presets_overlay_dir_override.is_none());
573 assert_eq!(
574 config.presets_overlay_git.as_deref(),
575 Some("https://example.com/o.git")
576 );
577 assert_eq!(config.overlay_git_source().unwrap().1, Some("dev"));
578 }
579
580 #[test]
581 fn age_identities_is_empty_without_configured_or_default_identity() {
582 let dir =
583 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
584 let config = Config::new_for_test(&dir);
585
586 assert!(config.age_identities().is_empty());
587 }
588
589 #[test]
590 fn age_identities_uses_configured_path_when_set() {
591 let dir =
592 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
593 let mut config = Config::new_for_test(&dir);
594 config.age_identity = Some("/tmp/my-identity.txt".to_string());
595
596 assert_eq!(
597 config.age_identities(),
598 vec![PathBuf::from("/tmp/my-identity.txt")]
599 );
600 }
601
602 #[test]
603 fn age_identities_treats_blank_configured_path_as_unset() {
604 let dir =
605 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
606 let mut config = Config::new_for_test(&dir);
607 config.age_identity = Some(" ".to_string());
608
609 assert!(config.age_identities().is_empty());
610 }
611
612 #[tokio::test]
613 async fn age_identities_falls_back_to_default_path_when_it_exists() {
614 let dir =
615 std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
616 let config = Config::new_for_test(&dir);
617 let default_path = dir.join("age").join("identity.txt");
618 tokio::fs::create_dir_all(default_path.parent().unwrap())
619 .await
620 .unwrap();
621 tokio::fs::write(&default_path, "AGE-SECRET-KEY-1EXAMPLE\n")
622 .await
623 .unwrap();
624
625 assert_eq!(config.age_identities(), vec![default_path]);
626
627 tokio::fs::remove_dir_all(&dir).await.unwrap();
628 }
629}