Skip to main content

cli/env/
mod.rs

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/// User-editable environment variables stored in `config.toml` under `[env]`.
12///
13/// Values are substituted into preset files that opt in via the `template`
14/// transform (using `@@VAR_NAME@@` placeholders).
15#[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)]
71/// A config environment value selected using encrypted-first lookup.
72pub enum StoredValue<'a> {
73    Secret { key: String, value: &'a str },
74    Plaintext(&'a str),
75}
76
77/// Resolve `KEY_SECRET` first, falling back to plaintext `KEY`.
78pub 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
92/// Return the encrypted-storage key associated with an environment variable.
93pub fn secret_key(key: &str) -> String {
94    format!("{key}_SECRET")
95}
96
97/// A validated environment declaration using the `env run --with` grammar:
98/// resolve `source` from the active config and expose it under `target`.
99#[derive(Clone, Debug, PartialEq, Eq)]
100pub struct EnvVarSpec {
101    pub source: String,
102    pub target: String,
103}
104
105impl EnvVarSpec {
106    /// The `--with` argument token that reproduces this declaration:
107    /// `KEY` when source and target match, otherwise `SOURCE=TARGET`.
108    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
117/// Parse and validate an ordered list of `KEY` / `SOURCE_KEY=TARGET_KEY` specs
118/// (the shared grammar for `env run --with` and a Bun preset's `env` array).
119///
120/// Declaration order is preserved. Both names must be valid environment
121/// identifiers, and no two declarations may write the same target. Values are
122/// never resolved here — this is pure name validation, safe to run at metadata
123/// load time.
124pub(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
142/// Validate an environment variable name: first character `[A-Za-z_]`, remaining
143/// characters `[A-Za-z0-9_]*`.
144pub(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}