1pub mod catalog;
2pub mod commands;
3pub mod identity;
4pub mod proxy;
5pub mod upgrade;
6pub mod workspace;
7
8use crate::config::Config;
9use anyhow::{Result, bail};
10use std::collections::{BTreeMap, BTreeSet};
11
12#[derive(Clone, Debug, Default)]
17pub struct EnvConfig {
18 vars: BTreeMap<String, String>,
19 descriptions: BTreeMap<String, String>,
20}
21
22impl EnvConfig {
23 pub fn from_config(config: &Config) -> Self {
24 Self {
25 vars: config.env.clone(),
26 descriptions: config.env_descriptions.clone(),
27 }
28 }
29
30 pub async fn load_or_init(config: &Config) -> Result<Self> {
31 Ok(Self::from_config(config))
32 }
33
34 pub fn get(&self, key: &str) -> Option<&str> {
35 self.vars.get(key).map(|s| s.as_str())
36 }
37
38 pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
39 self.vars.insert(key.into(), value.into());
40 }
41
42 pub fn remove(&mut self, key: &str) -> Option<String> {
43 self.vars.remove(key)
44 }
45
46 pub fn as_map(&self) -> &BTreeMap<String, String> {
47 &self.vars
48 }
49
50 pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
51 self.vars.iter().map(|(k, v)| (k.as_str(), v.as_str()))
52 }
53
54 pub fn description(&self, key: &str) -> Option<&str> {
55 self.descriptions.get(key).map(String::as_str)
56 }
57
58 pub async fn save(&self, config: &Config) -> Result<()> {
59 let mut updated = config.clone();
60 updated.env = self.vars.clone();
61 updated.save().await
62 }
63}
64
65impl From<EnvConfig> for BTreeMap<String, String> {
66 fn from(value: EnvConfig) -> Self {
67 value.vars
68 }
69}
70
71#[derive(Debug, PartialEq, Eq)]
72pub enum StoredValue<'a> {
74 Secret { key: String, value: &'a str },
75 Plaintext(&'a str),
76}
77
78pub fn resolve_stored_value<'a>(env: &'a EnvConfig, key: &str) -> Result<StoredValue<'a>> {
80 let secret_key = secret_key(key);
81 if let Some(value) = env.get(&secret_key) {
82 return Ok(StoredValue::Secret {
83 key: secret_key,
84 value,
85 });
86 }
87 if let Some(value) = env.get(key) {
88 return Ok(StoredValue::Plaintext(value));
89 }
90 bail!("{secret_key} or {key} is not set in the active config [env]");
91}
92
93pub fn secret_key(key: &str) -> String {
95 format!("{key}_SECRET")
96}
97
98#[derive(Clone, Debug, PartialEq, Eq)]
101pub struct EnvVarSpec {
102 pub source: String,
103 pub target: String,
104}
105
106impl EnvVarSpec {
107 pub fn to_with_arg(&self) -> String {
110 if self.source == self.target {
111 self.source.clone()
112 } else {
113 format!("{}={}", self.source, self.target)
114 }
115 }
116}
117
118pub(crate) fn parse_env_specs(specs: &[String]) -> Result<Vec<EnvVarSpec>> {
126 let mut parsed = Vec::with_capacity(specs.len());
127 let mut targets = BTreeSet::new();
128 for spec in specs {
129 let (source, target) = spec.split_once('=').unwrap_or((spec, spec));
130 validate_env_key(source)?;
131 validate_env_key(target)?;
132 if !targets.insert(target.to_string()) {
133 bail!("duplicate target variable: {target}");
134 }
135 parsed.push(EnvVarSpec {
136 source: source.to_string(),
137 target: target.to_string(),
138 });
139 }
140 Ok(parsed)
141}
142
143pub(crate) fn validate_env_key(key: &str) -> Result<()> {
146 let mut chars = key.chars();
147 let Some(first) = chars.next() else {
148 bail!("environment variable name must not be empty");
149 };
150 if !(first == '_' || first.is_ascii_alphabetic())
151 || !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
152 {
153 bail!("invalid environment variable name: {key}");
154 }
155 Ok(())
156}
157
158impl EnvConfig {
159 #[cfg(test)]
160 fn with_defaults() -> Self {
161 Self {
162 vars: crate::config::default_env_map(),
163 descriptions: BTreeMap::new(),
164 }
165 }
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn from_config_reads_env_table() {
174 let dir = std::env::temp_dir().join(format!("shine-env-test-{}", uuid::Uuid::new_v4()));
175 let mut config = Config::new_for_test(&dir);
176 config.env.insert("HTTP_PROXY_PORT".into(), "7890".into());
177
178 let env = EnvConfig::from_config(&config);
179
180 assert_eq!(env.get("HTTP_PROXY_PORT"), Some("7890"));
181 }
182
183 #[test]
184 fn from_config_reads_description() {
185 let dir = std::env::temp_dir().join(format!("shine-env-test-{}", uuid::Uuid::new_v4()));
186 let mut config = Config::new_for_test(&dir);
187 config
188 .env_descriptions
189 .insert("MY_TOKEN".into(), "Internal token".into());
190
191 let env = EnvConfig::from_config(&config);
192
193 assert_eq!(env.description("MY_TOKEN"), Some("Internal token"));
194 }
195
196 #[test]
197 fn set_and_get_roundtrip() {
198 let mut env = EnvConfig::default();
199 env.set("MY_VAR", "hello");
200 assert_eq!(env.get("MY_VAR"), Some("hello"));
201 assert_eq!(env.get("OTHER"), None);
202 }
203
204 #[test]
205 fn remove_deletes_existing_key() {
206 let mut env = EnvConfig::default();
207 env.set("MY_VAR", "hello");
208
209 assert_eq!(env.remove("MY_VAR"), Some("hello".to_string()));
210 assert_eq!(env.get("MY_VAR"), None);
211 }
212
213 #[test]
214 fn remove_missing_key_returns_none() {
215 let mut env = EnvConfig::default();
216
217 assert_eq!(env.remove("OTHER"), None);
218 }
219
220 #[test]
221 fn as_map_reflects_all_vars() {
222 let mut env = EnvConfig::default();
223 env.set("A", "1");
224 env.set("B", "2");
225 let map = env.as_map();
226 assert_eq!(map.get("A").map(|s| s.as_str()), Some("1"));
227 assert_eq!(map.get("B").map(|s| s.as_str()), Some("2"));
228 }
229
230 #[test]
231 fn defaults_are_available_for_tests() {
232 let env = EnvConfig::with_defaults();
233 assert_eq!(env.get("HTTP_PROXY_PORT"), Some("6152"));
234 assert_eq!(env.get("SOCKS5_PROXY_PORT"), Some("6153"));
235 assert_eq!(env.get("PROXY_HOST"), Some("127.0.0.1"));
236 assert_eq!(env.get("PROXY_NO_PROXY"), Some("localhost,127.0.0.1,::1"));
237 assert_eq!(env.get("GHOSTTY_BG_LIGHT"), Some(""));
238 assert_eq!(env.get("GHOSTTY_BG_DARK"), Some(""));
239 }
240}