use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Clone)]
pub struct Paths {
home: PathBuf,
}
impl Paths {
pub fn new(home: impl Into<PathBuf>) -> Self {
Self { home: home.into() }
}
pub fn home(&self) -> &Path {
&self.home
}
pub fn purple_dir(&self) -> PathBuf {
self.home.join(".purple")
}
pub fn preferences(&self) -> PathBuf {
self.purple_dir().join("preferences")
}
pub fn snippets_dir(&self) -> PathBuf {
self.purple_dir().join("snippets")
}
pub fn container_cache(&self) -> PathBuf {
self.purple_dir().join("container_cache.jsonl")
}
pub fn log_file(&self) -> PathBuf {
self.purple_dir().join("purple.log")
}
pub fn history(&self) -> PathBuf {
self.purple_dir().join("history.tsv")
}
pub fn last_version_check(&self) -> PathBuf {
self.purple_dir().join("last_version_check")
}
pub fn certs_dir(&self) -> PathBuf {
self.purple_dir().join("certs")
}
pub fn cert_for(&self, alias: &str) -> PathBuf {
self.certs_dir().join(format!("{alias}-cert.pub"))
}
pub fn ssh_dir(&self) -> PathBuf {
self.home.join(".ssh")
}
pub fn askpass_marker(&self, alias: &str) -> PathBuf {
let safe = alias.replace(['/', '\\', '.'], "_");
self.purple_dir().join(format!(".askpass_{safe}"))
}
}
#[derive(Clone)]
pub struct Env {
paths: Option<Paths>,
vars: HashMap<String, String>,
#[cfg(test)]
_sandbox: Option<std::sync::Arc<tempfile::TempDir>>,
}
impl Env {
fn new_inner(paths: Option<Paths>, vars: HashMap<String, String>) -> Self {
Self {
paths,
vars,
#[cfg(test)]
_sandbox: None,
}
}
pub fn from_process() -> Self {
Self::new_inner(dirs::home_dir().map(Paths::new), std::env::vars().collect())
}
pub fn for_test(home: impl Into<PathBuf>) -> Self {
Self::new_inner(Some(Paths::new(home)), HashMap::new())
}
pub fn empty() -> Self {
Self::new_inner(None, HashMap::new())
}
#[cfg(test)]
pub fn sandboxed() -> Self {
let dir = tempfile::tempdir().expect("create test sandbox tempdir");
let mut env = Self::for_test(dir.path());
env._sandbox = Some(std::sync::Arc::new(dir));
env
}
#[must_use]
pub fn with_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.vars.insert(key.into(), value.into());
self
}
pub fn paths(&self) -> Option<&Paths> {
self.paths.as_ref()
}
pub fn var(&self, key: &str) -> Option<&str> {
self.vars.get(key).map(String::as_str)
}
pub fn vault_addr(&self) -> Option<&str> {
self.var("VAULT_ADDR")
}
pub fn aws_credentials(&self) -> Option<(&str, &str)> {
match (
self.var("AWS_ACCESS_KEY_ID"),
self.var("AWS_SECRET_ACCESS_KEY"),
) {
(Some(id), Some(secret)) => Some((id, secret)),
_ => None,
}
}
pub fn purple_token(&self) -> Option<&str> {
self.var("PURPLE_TOKEN")
}
pub fn no_color(&self) -> bool {
self.vars.contains_key("NO_COLOR")
}
pub fn colorterm(&self) -> Option<&str> {
self.var("COLORTERM")
}
pub fn term_program(&self) -> Option<&str> {
self.var("TERM_PROGRAM")
}
pub fn term(&self) -> Option<&str> {
self.var("TERM")
}
pub fn in_tmux(&self) -> bool {
self.vars.contains_key("TMUX")
}
pub fn active_proxy_vars(&self) -> Vec<&'static str> {
["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"]
.into_iter()
.filter(|k| self.var(k).is_some_and(|v| !v.is_empty()))
.collect()
}
pub fn command(&self, program: &str) -> std::process::Command {
let mut cmd = std::process::Command::new(program);
cmd.env_clear();
cmd.envs(&self.vars);
cmd
}
}
impl std::fmt::Debug for Env {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut names: Vec<&str> = self.vars.keys().map(String::as_str).collect();
names.sort_unstable();
f.debug_struct("Env")
.field("home", &self.paths.as_ref().map(Paths::home))
.field("var_names", &names)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paths_derive_under_purple_and_ssh() {
let p = Paths::new("/home/u");
assert_eq!(p.purple_dir(), PathBuf::from("/home/u/.purple"));
assert_eq!(
p.preferences(),
PathBuf::from("/home/u/.purple/preferences")
);
assert_eq!(p.snippets_dir(), PathBuf::from("/home/u/.purple/snippets"));
assert_eq!(
p.container_cache(),
PathBuf::from("/home/u/.purple/container_cache.jsonl")
);
assert_eq!(p.log_file(), PathBuf::from("/home/u/.purple/purple.log"));
assert_eq!(p.history(), PathBuf::from("/home/u/.purple/history.tsv"));
assert_eq!(
p.last_version_check(),
PathBuf::from("/home/u/.purple/last_version_check")
);
assert_eq!(p.certs_dir(), PathBuf::from("/home/u/.purple/certs"));
assert_eq!(p.ssh_dir(), PathBuf::from("/home/u/.ssh"));
}
#[test]
fn cert_for_uses_alias_filename() {
let p = Paths::new("/home/u");
assert_eq!(
p.cert_for("web-1"),
PathBuf::from("/home/u/.purple/certs/web-1-cert.pub")
);
}
#[test]
fn askpass_marker_sanitises_traversal_chars() {
let p = Paths::new("/home/u");
assert_eq!(
p.askpass_marker("a/b\\c.d"),
PathBuf::from("/home/u/.purple/.askpass_a_b_c_d")
);
}
#[test]
fn for_test_has_paths_and_no_vars() {
let env = Env::for_test("/tmp/x");
assert_eq!(env.paths().unwrap().home(), Path::new("/tmp/x"));
assert_eq!(env.var("ANYTHING"), None);
assert!(!env.no_color());
}
#[test]
fn empty_has_no_paths() {
let env = Env::empty();
assert!(env.paths().is_none());
}
#[test]
fn sandboxed_gives_isolated_existing_dirs() {
let a = Env::sandboxed();
let b = Env::sandboxed();
let pa = a.paths().unwrap().home().to_path_buf();
let pb = b.paths().unwrap().home().to_path_buf();
assert_ne!(pa, pb, "each sandbox is a distinct directory");
assert!(pa.exists(), "sandbox home exists for the Env's lifetime");
let prefs = a.paths().unwrap().preferences();
crate::fs_util::atomic_write(&prefs, b"theme=Purple\n").unwrap();
assert_eq!(std::fs::read_to_string(&prefs).unwrap(), "theme=Purple\n");
}
#[test]
fn with_var_sets_typed_accessors() {
let env = Env::for_test("/tmp/x")
.with_var("VAULT_ADDR", "https://vault.example:8200")
.with_var("COLORTERM", "truecolor")
.with_var("NO_COLOR", "1")
.with_var("TMUX", "/tmp/tmux-1000/default,1,0");
assert_eq!(env.vault_addr(), Some("https://vault.example:8200"));
assert_eq!(env.colorterm(), Some("truecolor"));
assert!(env.no_color());
assert!(env.in_tmux());
}
#[test]
fn aws_credentials_require_both_keys() {
let only_id = Env::for_test("/tmp/x").with_var("AWS_ACCESS_KEY_ID", "AKIA");
assert_eq!(only_id.aws_credentials(), None);
let both = only_id.with_var("AWS_SECRET_ACCESS_KEY", "secret");
assert_eq!(both.aws_credentials(), Some(("AKIA", "secret")));
}
#[test]
fn active_proxy_vars_filters_empty_and_orders() {
let env = Env::for_test("/tmp/x")
.with_var("HTTPS_PROXY", "http://proxy:3128")
.with_var("HTTP_PROXY", "")
.with_var("NO_PROXY", "localhost");
assert_eq!(env.active_proxy_vars(), vec!["HTTPS_PROXY", "NO_PROXY"]);
}
#[test]
fn debug_redacts_secret_values() {
let env = Env::for_test("/tmp/x")
.with_var("PURPLE_TOKEN", "super-secret")
.with_var("VAULT_ADDR", "https://vault.example:8200");
let rendered = format!("{env:?}");
assert!(!rendered.contains("super-secret"));
assert!(!rendered.contains("vault.example"));
assert!(rendered.contains("PURPLE_TOKEN"));
assert!(rendered.contains("VAULT_ADDR"));
}
#[test]
fn from_process_captures_home_and_vars() {
let env = Env::from_process();
let _ = env.paths();
let _ = env.var("PATH");
}
}