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