use crate::shells::ShellType;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
mod discovery;
mod env_layer;
mod load;
mod save;
use discovery::resolve_config_presets_path;
pub(crate) use discovery::{
ReadOnlyRuntimePaths, discover_runtime_paths_read_only, find_project_config,
};
use env_layer::deserialize_env_values;
pub use crate::home::{full_expand, full_expand_with_home, tilde_expand};
pub use env_layer::{validate_env_override_file, write_env_override_entry};
const GLOBAL_CONFIG_FILE: &str = "config.toml";
const PROJECT_CONFIG_FILE: &str = "shine.config.toml";
const PROJECT_ENV_FILE: &str = "shine.env.toml";
pub const CURRENT_RUNTIME_SCHEMA_VERSION: u32 = 2;
pub const DEFAULT_ENV_VARS: &[(&str, &str)] = &[
("HTTP_PROXY_PORT", "6152"),
("SOCKS5_PROXY_PORT", "6153"),
("PROXY_HOST", "127.0.0.1"),
("PROXY_NO_PROXY", "localhost,127.0.0.1,::1"),
("GHOSTTY_BG_LIGHT", ""),
("GHOSTTY_BG_DARK", ""),
];
pub fn default_env_map() -> BTreeMap<String, String> {
DEFAULT_ENV_VARS
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn default_sync_terminal_theme() -> bool {
true
}
fn is_true(value: &bool) -> bool {
*value
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum ExternalShellMode {
#[default]
Snapshot,
Live,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct EnvProxyRule {
pub command: String,
#[serde(rename = "with")]
pub with: Vec<String>,
#[serde(default = "default_env_proxy_enabled", skip_serializing_if = "is_true")]
pub enabled: bool,
}
fn default_env_proxy_enabled() -> bool {
true
}
fn is_snapshot_mode(value: &ExternalShellMode) -> bool {
*value == ExternalShellMode::Snapshot
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Config {
#[serde(skip)]
presets_dir: PathBuf,
#[serde(skip)]
bin_dir: PathBuf,
#[serde(skip)]
config_path: PathBuf,
#[serde(skip)]
is_project_config: bool,
#[serde(skip)]
project_save_state: Option<ProjectSaveState>,
#[serde(skip)]
shine_dir: PathBuf,
#[serde(skip)]
pub home_dir: PathBuf,
#[serde(default)]
pub schema_version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_cleared_schema_version: Option<u32>,
#[serde(skip)]
pub shell_type: ShellType,
#[serde(
rename = "presets_dir",
default,
skip_serializing_if = "Option::is_none"
)]
pub presets_dir_override: Option<PathBuf>,
#[serde(default, skip_serializing_if = "is_snapshot_mode")]
pub external_shell_mode: ExternalShellMode,
#[serde(
rename = "presets_overlay_dir",
default,
skip_serializing_if = "Option::is_none"
)]
pub presets_overlay_dir_override: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub presets_overlay_git: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub presets_overlay_git_branch: Option<String>,
#[serde(skip)]
managed_overlay_dir: Option<PathBuf>,
#[serde(
rename = "app_default_dest_root",
default,
skip_serializing_if = "Option::is_none"
)]
pub app_default_dest_root_override: Option<PathBuf>,
#[serde(skip)]
pub is_external_presets: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub allow_app_hooks: bool,
#[serde(
default = "default_sync_terminal_theme",
skip_serializing_if = "is_true"
)]
pub sync_terminal_theme: bool,
#[serde(
rename = "self_install_dest",
default,
skip_serializing_if = "Option::is_none"
)]
pub self_install_dest: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub gpg_recipients: Vec<String>,
#[serde(
rename = "gpg_key_id",
default,
skip_serializing_if = "Option::is_none"
)]
pub legacy_gpg_key_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secret_backend: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub age_recipients: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub age_identity: Option<String>,
#[serde(
default = "default_env_map",
deserialize_with = "deserialize_env_values"
)]
pub env: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub env_proxy: Vec<EnvProxyRule>,
#[serde(skip)]
pub env_descriptions: BTreeMap<String, String>,
#[serde(skip)]
pub env_override_sources: BTreeMap<String, EnvOverrideSource>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EnvOverrideSource {
pub path: PathBuf,
pub kind: EnvOverrideKind,
pub is_managed_overlay: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EnvOverrideKind {
Global,
Overlay,
Project,
}
#[derive(Clone, Debug)]
struct ProjectSaveState {
original: toml::Table,
loaded: toml::Table,
}
impl Config {
pub fn presets_dir(&self) -> &Path {
&self.presets_dir
}
pub fn bin_dir(&self) -> &Path {
&self.bin_dir
}
pub fn shine_dir(&self) -> &Path {
&self.shine_dir
}
pub fn config_path(&self) -> &Path {
&self.config_path
}
pub fn is_project_config(&self) -> bool {
self.is_project_config
}
pub fn rendered_dir(&self) -> PathBuf {
self.shine_dir().join("rendered")
}
pub fn installed_shell_dir(&self) -> PathBuf {
self.shine_dir().join("installed").join("shell")
}
pub fn app_default_dest_root(&self) -> PathBuf {
match &self.app_default_dest_root_override {
Some(p) => {
let s = p.to_str().unwrap_or("~/.config");
PathBuf::from(tilde_expand(s))
}
None => self.home_dir.join(".config"),
}
}
pub fn new_for_test(dir: &Path) -> Self {
Self {
config_path: dir.join("config.toml"),
is_project_config: false,
project_save_state: None,
shine_dir: dir.to_path_buf(),
presets_dir: dir.join("presets"),
bin_dir: dir.join("bin"),
home_dir: dir.to_path_buf(),
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
last_cleared_schema_version: None,
shell_type: ShellType::default(),
presets_dir_override: None,
external_shell_mode: ExternalShellMode::Snapshot,
presets_overlay_dir_override: None,
presets_overlay_git: None,
presets_overlay_git_branch: None,
managed_overlay_dir: None,
app_default_dest_root_override: None,
is_external_presets: false,
allow_app_hooks: false,
sync_terminal_theme: default_sync_terminal_theme(),
self_install_dest: None,
gpg_recipients: Vec::new(),
legacy_gpg_key_id: None,
secret_backend: None,
age_recipients: Vec::new(),
age_identity: None,
env: default_env_map(),
env_proxy: Vec::new(),
env_descriptions: BTreeMap::new(),
env_override_sources: BTreeMap::new(),
}
}
pub fn age_identities(&self) -> Vec<PathBuf> {
if let Some(identity) = self
.age_identity
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return vec![PathBuf::from(tilde_expand(identity))];
}
let default_path = self.shine_dir.join("age").join("identity.txt");
if default_path.is_file() {
vec![default_path]
} else {
Vec::new()
}
}
pub fn with_presets_dir_override(self, value: Option<PathBuf>) -> Self {
Self {
presets_dir_override: value,
..self
}
}
pub fn with_external_shell_mode(self, value: ExternalShellMode) -> Self {
Self {
external_shell_mode: value,
..self
}
}
pub fn with_presets_overlay_git(self, url: Option<String>, branch: Option<String>) -> Self {
let managed_overlay_dir = url.as_ref().map(|_| self.shine_dir.join("overlay"));
Self {
presets_overlay_dir_override: if url.is_some() {
None
} else {
self.presets_overlay_dir_override
},
presets_overlay_git_branch: branch,
presets_overlay_git: url,
managed_overlay_dir,
..self
}
}
pub fn with_presets_overlay_dir_override(self, value: Option<PathBuf>) -> Self {
Self {
presets_overlay_dir_override: value,
..self
}
}
pub fn env_override_source(&self, key: &str) -> Option<&EnvOverrideSource> {
self.env_override_sources.get(key)
}
pub fn active_presets_overlay_dir(&self) -> Option<&Path> {
if let Some(dir) = self.presets_overlay_dir_override.as_deref() {
return Some(dir);
}
self.managed_overlay_dir
.as_deref()
.filter(|dir| dir.exists())
}
pub fn overlay_git_source(&self) -> Option<(&str, Option<&str>, &Path)> {
let url = self.presets_overlay_git.as_deref()?;
let dir = self.managed_overlay_dir.as_deref()?;
Some((url, self.presets_overlay_git_branch.as_deref(), dir))
}
pub fn preset_path(&self, relative: impl AsRef<Path>) -> PathBuf {
let relative = relative.as_ref();
if let Some(overlay) = self.active_presets_overlay_dir() {
let candidate = overlay.join(relative);
if candidate.exists() {
return candidate;
}
}
self.presets_dir().join(relative)
}
fn resolve_presets_overlay_dir(&mut self, config_dir: &Path) {
if let Some(path) = self.presets_overlay_dir_override.as_deref() {
self.presets_overlay_dir_override = Some(resolve_config_presets_path(path, config_dir));
}
}
fn resolve_managed_overlay_dir(&mut self) {
self.managed_overlay_dir = self
.presets_overlay_git
.as_ref()
.map(|_| self.shine_dir.join("overlay"));
}
}
pub fn print_presets_note(config: &Config) {
if config.is_external_presets {
println!(
"{}",
crate::colors::external_presets_note(config.presets_dir())
);
if let Some(dir) = config.active_presets_overlay_dir() {
println!("{}", crate::colors::presets_overlay_note(dir));
}
let deployment = match config.external_shell_mode {
ExternalShellMode::Snapshot => "snapshot · changes require `shine upgrade`",
ExternalShellMode::Live => "live · content applies on next invocation",
};
println!(
"{}",
crate::colors::dim(&crate::colors::shell_deployment_note(deployment))
);
println!();
} else if let Some(dir) = config.active_presets_overlay_dir() {
println!("{}", crate::colors::presets_overlay_note(dir));
println!();
}
}
impl Default for Config {
fn default() -> Self {
let home_dir = crate::home::effective_home_dir();
let shine_dir = home_dir.join(".shine");
Self {
presets_dir: shine_dir.join("presets"),
bin_dir: shine_dir.join("bin"),
config_path: shine_dir.join("config.toml"),
is_project_config: false,
project_save_state: None,
shine_dir,
home_dir,
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
last_cleared_schema_version: None,
shell_type: ShellType::default(),
presets_dir_override: None,
external_shell_mode: ExternalShellMode::Snapshot,
presets_overlay_dir_override: None,
presets_overlay_git: None,
presets_overlay_git_branch: None,
managed_overlay_dir: None,
app_default_dest_root_override: None,
is_external_presets: false,
allow_app_hooks: false,
sync_terminal_theme: default_sync_terminal_theme(),
self_install_dest: None,
gpg_recipients: Vec::new(),
legacy_gpg_key_id: None,
secret_backend: None,
age_recipients: Vec::new(),
age_identity: None,
env: default_env_map(),
env_proxy: Vec::new(),
env_descriptions: BTreeMap::new(),
env_override_sources: BTreeMap::new(),
}
}
}
#[cfg(test)]
pub(super) mod test_util {
use super::Config;
use std::path::{Path, PathBuf};
pub(crate) fn config_in(dir: &Path) -> Config {
Config {
home_dir: dir.join("home"),
..Config::new_for_test(dir)
}
}
pub(crate) async fn make_temp_dir() -> PathBuf {
crate::test_support::make_temp_dir("shine-test").await
}
pub(crate) fn restore_current_dir(dir: &Path) {
crate::test_support::restore_current_dir(dir)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_for_test_bin_dir_is_under_root() {
let dir = std::env::temp_dir().join("shine-test-bin-dir");
let config = Config::new_for_test(&dir);
assert_eq!(config.bin_dir(), dir.join("bin"));
}
#[test]
fn git_overlay_is_inactive_until_checkout_exists() {
let dir = std::env::temp_dir().join(format!("shine-overlay-git-{}", uuid::Uuid::new_v4()));
let config = Config::new_for_test(&dir)
.with_presets_overlay_git(Some("https://example.com/o.git".to_string()), None);
let (url, branch, managed) = config.overlay_git_source().unwrap();
assert_eq!(url, "https://example.com/o.git");
assert_eq!(branch, None);
assert_eq!(managed, dir.join("overlay"));
assert!(config.active_presets_overlay_dir().is_none());
std::fs::create_dir_all(dir.join("overlay")).unwrap();
assert_eq!(
config.active_presets_overlay_dir(),
Some(dir.join("overlay").as_path())
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn manual_overlay_path_takes_precedence_over_git() {
let dir = std::env::temp_dir().join(format!("shine-overlay-prec-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(dir.join("overlay")).unwrap();
let manual = dir.join("manual");
let config = Config::new_for_test(&dir)
.with_presets_overlay_git(Some("https://example.com/o.git".to_string()), None)
.with_presets_overlay_dir_override(Some(manual.clone()));
assert_eq!(config.active_presets_overlay_dir(), Some(manual.as_path()));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn setting_git_overlay_clears_manual_path() {
let dir = std::env::temp_dir().join("shine-overlay-clear");
let config = Config::new_for_test(&dir)
.with_presets_overlay_dir_override(Some(dir.join("manual")))
.with_presets_overlay_git(
Some("https://example.com/o.git".to_string()),
Some("dev".to_string()),
);
assert!(config.presets_overlay_dir_override.is_none());
assert_eq!(
config.presets_overlay_git.as_deref(),
Some("https://example.com/o.git")
);
assert_eq!(config.overlay_git_source().unwrap().1, Some("dev"));
}
#[test]
fn age_identities_is_empty_without_configured_or_default_identity() {
let dir =
std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
let config = Config::new_for_test(&dir);
assert!(config.age_identities().is_empty());
}
#[test]
fn age_identities_uses_configured_path_when_set() {
let dir =
std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
let mut config = Config::new_for_test(&dir);
config.age_identity = Some("/tmp/my-identity.txt".to_string());
assert_eq!(
config.age_identities(),
vec![PathBuf::from("/tmp/my-identity.txt")]
);
}
#[test]
fn age_identities_treats_blank_configured_path_as_unset() {
let dir =
std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
let mut config = Config::new_for_test(&dir);
config.age_identity = Some(" ".to_string());
assert!(config.age_identities().is_empty());
}
#[tokio::test]
async fn age_identities_falls_back_to_default_path_when_it_exists() {
let dir =
std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
let config = Config::new_for_test(&dir);
let default_path = dir.join("age").join("identity.txt");
tokio::fs::create_dir_all(default_path.parent().unwrap())
.await
.unwrap();
tokio::fs::write(&default_path, "AGE-SECRET-KEY-1EXAMPLE\n")
.await
.unwrap();
assert_eq!(config.age_identities(), vec![default_path]);
tokio::fs::remove_dir_all(&dir).await.unwrap();
}
}