Skip to main content

cli/env/
mod.rs

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