Skip to main content

purple_ssh/runtime/
env.rs

1// Resolved process environment and filesystem paths, captured once at the
2// edge (`launcher::run`) and threaded explicitly from there. Replaces ambient
3// `std::env::var` / `dirs::home_dir()` reads scattered through the codebase so
4// tests construct an `Env` directly instead of mutating process-global state.
5//
6// `Env` is immutable after construction, so an `Arc<Env>` crosses thread
7// boundaries into worker closures without a lock.
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12/// The four categories purple's files fall into, following the XDG Base
13/// Directory split. Each resolves to its own directory; by default all four
14/// point at the legacy `~/.purple`.
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum Category {
17    /// Files the user edits or syncs as dotfiles: preferences, snippets,
18    /// providers, themes.
19    Config,
20    /// Files that are important and not regenerable but not config: signed
21    /// certificates and the original SSH config backup.
22    Data,
23    /// History, recents, activity ledgers, logs and retry markers.
24    State,
25    /// Regenerable caches: the container cache and the version check.
26    Cache,
27}
28
29impl Category {
30    pub const ALL: [Category; 4] = [
31        Category::Config,
32        Category::Data,
33        Category::State,
34        Category::Cache,
35    ];
36
37    /// The explicit purple override variable, e.g. `PURPLE_CONFIG_DIR`.
38    pub fn purple_var(self) -> &'static str {
39        match self {
40            Category::Config => "PURPLE_CONFIG_DIR",
41            Category::Data => "PURPLE_DATA_DIR",
42            Category::State => "PURPLE_STATE_DIR",
43            Category::Cache => "PURPLE_CACHE_DIR",
44        }
45    }
46
47    /// The XDG base directory variable, e.g. `XDG_CONFIG_HOME`.
48    pub fn xdg_var(self) -> &'static str {
49        match self {
50            Category::Config => "XDG_CONFIG_HOME",
51            Category::Data => "XDG_DATA_HOME",
52            Category::State => "XDG_STATE_HOME",
53            Category::Cache => "XDG_CACHE_HOME",
54        }
55    }
56
57    /// Lowercase name for log lines.
58    pub fn name(self) -> &'static str {
59        match self {
60            Category::Config => "config",
61            Category::Data => "data",
62            Category::State => "state",
63            Category::Cache => "cache",
64        }
65    }
66}
67
68/// Subdirectory under an XDG base directory.
69const XDG_APP_DIR: &str = "purple";
70
71/// Home-derived file locations under `~/.purple` and `~/.ssh`. One place that
72/// knows the on-disk layout; every consumer asks here instead of joining the
73/// home directory itself.
74///
75/// The purple files live in four category directories (see [`Category`]).
76/// [`Paths::new`] puts all four at `~/.purple`; [`Paths::resolve`] honors the
77/// `PURPLE_*_DIR` and `XDG_*_HOME` variables per category.
78#[derive(Clone, Debug, PartialEq, Eq)]
79pub struct Paths {
80    home: PathBuf,
81    config_dir: PathBuf,
82    data_dir: PathBuf,
83    state_dir: PathBuf,
84    cache_dir: PathBuf,
85}
86
87impl Paths {
88    /// The legacy layout: every category under `~/.purple`.
89    pub fn new(home: impl Into<PathBuf>) -> Self {
90        let home = home.into();
91        let legacy = home.join(".purple");
92        Self {
93            home,
94            config_dir: legacy.clone(),
95            data_dir: legacy.clone(),
96            state_dir: legacy.clone(),
97            cache_dir: legacy,
98        }
99    }
100
101    /// Resolve each category directory from the environment. Precedence per
102    /// category: `PURPLE_<CATEGORY>_DIR` when set and non-empty (a leading
103    /// `~/` expands to the home directory), then `$XDG_<CATEGORY>_HOME/purple`
104    /// when that variable is set, non-empty and absolute (a relative XDG path
105    /// is invalid per the spec and ignored), else the legacy `~/.purple`.
106    pub fn resolve(home: impl Into<PathBuf>, var: impl Fn(&str) -> Option<String>) -> Self {
107        let mut paths = Self::new(home);
108        for category in Category::ALL {
109            let dir = resolve_category(&paths.home, category, &var);
110            *paths.dir_mut(category) = dir;
111        }
112        paths
113    }
114
115    fn dir_mut(&mut self, category: Category) -> &mut PathBuf {
116        match category {
117            Category::Config => &mut self.config_dir,
118            Category::Data => &mut self.data_dir,
119            Category::State => &mut self.state_dir,
120            Category::Cache => &mut self.cache_dir,
121        }
122    }
123
124    pub fn home(&self) -> &Path {
125        &self.home
126    }
127
128    /// `~/.purple`, the single directory every category used before the
129    /// split. Only the migration reads from here.
130    pub fn legacy_dir(&self) -> PathBuf {
131        self.home.join(".purple")
132    }
133
134    /// The directory a category resolved to.
135    pub fn dir(&self, category: Category) -> &Path {
136        match category {
137            Category::Config => &self.config_dir,
138            Category::Data => &self.data_dir,
139            Category::State => &self.state_dir,
140            Category::Cache => &self.cache_dir,
141        }
142    }
143
144    /// Preferences, snippets, providers and themes.
145    pub fn config_dir(&self) -> &Path {
146        &self.config_dir
147    }
148
149    /// Signed certificates and the original SSH config backup.
150    pub fn data_dir(&self) -> &Path {
151        &self.data_dir
152    }
153
154    /// History, recents, ledgers, logs and retry markers.
155    pub fn state_dir(&self) -> &Path {
156        &self.state_dir
157    }
158
159    /// Container cache and version check.
160    pub fn cache_dir(&self) -> &Path {
161        &self.cache_dir
162    }
163
164    /// True when at least one category lives outside `~/.purple`.
165    pub fn is_split(&self) -> bool {
166        let legacy = self.legacy_dir();
167        Category::ALL.iter().any(|c| self.dir(*c) != legacy)
168    }
169
170    /// `<config>/preferences`.
171    pub fn preferences(&self) -> PathBuf {
172        self.config_dir.join("preferences")
173    }
174
175    /// `<config>/snippets`, the INI-style snippet store.
176    pub fn snippets_file(&self) -> PathBuf {
177        self.config_dir.join("snippets")
178    }
179
180    /// `<config>/providers`, the provider config file.
181    pub fn providers_config(&self) -> PathBuf {
182        self.config_dir.join("providers")
183    }
184
185    /// `<config>/themes`, the custom theme directory.
186    pub fn themes_dir(&self) -> PathBuf {
187        self.config_dir.join("themes")
188    }
189
190    /// `<data>/certs`.
191    pub fn certs_dir(&self) -> PathBuf {
192        self.data_dir.join("certs")
193    }
194
195    /// `<data>/certs/<alias>-cert.pub`.
196    pub fn cert_for(&self, alias: &str) -> PathBuf {
197        self.certs_dir().join(format!("{alias}-cert.pub"))
198    }
199
200    /// `<data>/config.original`, the untouched SSH config backup taken on
201    /// the first launch.
202    pub fn config_original(&self) -> PathBuf {
203        self.data_dir.join("config.original")
204    }
205
206    /// `<state>/history.tsv`.
207    pub fn history(&self) -> PathBuf {
208        self.state_dir.join("history.tsv")
209    }
210
211    /// `<state>/recents.json`.
212    pub fn recents(&self) -> PathBuf {
213        self.state_dir.join("recents.json")
214    }
215
216    /// `<state>/key_activity.json`.
217    pub fn key_activity(&self) -> PathBuf {
218        self.state_dir.join("key_activity.json")
219    }
220
221    /// `<state>/snippet_runs.json`.
222    pub fn snippet_runs(&self) -> PathBuf {
223        self.state_dir.join("snippet_runs.json")
224    }
225
226    /// `<state>/sync_history.tsv`.
227    pub fn sync_history(&self) -> PathBuf {
228        self.state_dir.join("sync_history.tsv")
229    }
230
231    /// `<state>/purple.log`.
232    pub fn log_file(&self) -> PathBuf {
233        self.state_dir.join("purple.log")
234    }
235
236    /// `<state>/mcp-audit.log`.
237    pub fn mcp_audit_log(&self) -> PathBuf {
238        self.state_dir.join("mcp-audit.log")
239    }
240
241    /// Askpass retry marker `<state>/.askpass_<safe>`. The alias is
242    /// sanitized (`/`, `\`, `.` become `_`) to prevent path traversal.
243    pub fn askpass_marker(&self, alias: &str) -> PathBuf {
244        let safe = alias.replace(['/', '\\', '.'], "_");
245        self.state_dir.join(format!(".askpass_{safe}"))
246    }
247
248    /// `<cache>/container_cache.jsonl`.
249    pub fn container_cache(&self) -> PathBuf {
250        self.cache_dir.join("container_cache.jsonl")
251    }
252
253    /// `<cache>/last_version_check`.
254    pub fn last_version_check(&self) -> PathBuf {
255        self.cache_dir.join("last_version_check")
256    }
257
258    /// `~/.aws/credentials`, the shared AWS credentials file.
259    pub fn aws_credentials_file(&self) -> PathBuf {
260        self.home.join(".aws").join("credentials")
261    }
262
263    /// `~/.aws/config`, the shared AWS config file. Named profiles live here
264    /// as `[profile <name>]`, and it is the only file that carries the
265    /// assume-role keys.
266    pub fn aws_config_file(&self) -> PathBuf {
267        self.home.join(".aws").join("config")
268    }
269
270    /// `~/.ssh`.
271    pub fn ssh_dir(&self) -> PathBuf {
272        self.home.join(".ssh")
273    }
274
275    /// `path` for display, with the home directory abbreviated to `~`.
276    pub fn abbreviate_home(&self, path: &Path) -> String {
277        match path.strip_prefix(&self.home) {
278            Ok(rest) if !rest.as_os_str().is_empty() => format!("~/{}", rest.display()),
279            _ => path.display().to_string(),
280        }
281    }
282}
283
284/// One category's directory per the precedence documented on
285/// [`Paths::resolve`].
286fn resolve_category(
287    home: &Path,
288    category: Category,
289    var: &impl Fn(&str) -> Option<String>,
290) -> PathBuf {
291    if let Some(explicit) = var(category.purple_var()).filter(|v| !v.trim().is_empty()) {
292        return expand_home(home, &explicit);
293    }
294    if let Some(base) = var(category.xdg_var()).filter(|v| !v.trim().is_empty()) {
295        let base = PathBuf::from(base);
296        if base.is_absolute() {
297            return base.join(XDG_APP_DIR);
298        }
299    }
300    home.join(".purple")
301}
302
303/// Expand a leading `~`, `$HOME` or `${HOME}` in an override value. A value
304/// written in a file rather than typed in a shell arrives unexpanded.
305fn expand_home(home: &Path, value: &str) -> PathBuf {
306    if ["~", "$HOME", "${HOME}"].contains(&value) {
307        return home.to_path_buf();
308    }
309    for prefix in ["~/", "$HOME/", "${HOME}/"] {
310        if let Some(rest) = value.strip_prefix(prefix) {
311            return home.join(rest);
312        }
313    }
314    PathBuf::from(value)
315}
316
317/// The resolved environment for one process run: the home-derived paths plus a
318/// snapshot of the process environment variables. Built once via
319/// [`Env::from_process`] and passed down by reference (or `Arc`) rather than
320/// re-read on demand.
321#[derive(Clone)]
322pub struct Env {
323    paths: Option<Paths>,
324    vars: HashMap<String, String>,
325    // Test sandbox: owns the temp directory that `paths` points into so it
326    // lives exactly as long as the Env (and any `Arc<Env>` clone). Absent from
327    // production builds; `tempfile` is a dev-dependency.
328    #[cfg(test)]
329    _sandbox: Option<std::sync::Arc<tempfile::TempDir>>,
330}
331
332impl Env {
333    fn new_inner(paths: Option<Paths>, vars: HashMap<String, String>) -> Self {
334        Self {
335            paths,
336            vars,
337            #[cfg(test)]
338            _sandbox: None,
339        }
340    }
341
342    /// Capture the real process environment: the home directory and a snapshot
343    /// of all environment variables. The single point where production reads
344    /// `std::env` and `dirs::home_dir`. The category directories resolve from
345    /// the same snapshot.
346    pub fn from_process() -> Self {
347        let vars: HashMap<String, String> = std::env::vars().collect();
348        let paths = dirs::home_dir().map(|home| Paths::resolve(home, |k| vars.get(k).cloned()));
349        Self::new_inner(paths, vars)
350    }
351
352    /// A test environment rooted at `home` with the legacy layout and no
353    /// environment variables. Add variables with [`Env::with_var`], swap the
354    /// layout with [`Env::with_paths`].
355    pub fn for_test(home: impl Into<PathBuf>) -> Self {
356        Self::new_inner(Some(Paths::new(home)), HashMap::new())
357    }
358
359    /// An environment with neither a home directory nor variables. Models the
360    /// rare case where `dirs::home_dir()` returns `None`.
361    pub fn empty() -> Self {
362        Self::new_inner(None, HashMap::new())
363    }
364
365    /// A self-cleaning sandbox rooted at a fresh temp directory, owned by the
366    /// Env. Each call is isolated, so parallel tests never share on-disk state
367    /// and need no lock. The default for test `App` fixtures.
368    #[cfg(test)]
369    pub fn sandboxed() -> Self {
370        let dir = tempfile::tempdir().expect("create test sandbox tempdir");
371        let mut env = Self::for_test(dir.path());
372        env._sandbox = Some(std::sync::Arc::new(dir));
373        env
374    }
375
376    /// Builder: set a variable. Chainable, for test construction.
377    #[must_use]
378    pub fn with_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
379        self.vars.insert(key.into(), value.into());
380        self
381    }
382
383    /// Builder: replace the paths, e.g. with a split layout from
384    /// [`Paths::resolve`]. Chainable, for test construction.
385    #[must_use]
386    pub fn with_paths(mut self, paths: Paths) -> Self {
387        self.paths = Some(paths);
388        self
389    }
390
391    /// Home-derived paths, or `None` when the home directory is unknown.
392    pub fn paths(&self) -> Option<&Paths> {
393        self.paths.as_ref()
394    }
395
396    /// Raw lookup of an arbitrary variable. Used by SSH config `${VAR}`
397    /// expansion, which references user-chosen names.
398    pub fn var(&self, key: &str) -> Option<&str> {
399        self.vars.get(key).map(String::as_str)
400    }
401
402    /// `VAULT_ADDR` fallback for Vault SSH address resolution.
403    pub fn vault_addr(&self) -> Option<&str> {
404        self.var("VAULT_ADDR")
405    }
406
407    /// `(AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)` when both are set.
408    pub fn aws_credentials(&self) -> Option<(&str, &str)> {
409        match (
410            self.var("AWS_ACCESS_KEY_ID"),
411            self.var("AWS_SECRET_ACCESS_KEY"),
412        ) {
413            (Some(id), Some(secret)) => Some((id, secret)),
414            _ => None,
415        }
416    }
417
418    /// `AWS_SESSION_TOKEN`, set alongside temporary STS credentials.
419    pub fn aws_session_token(&self) -> Option<&str> {
420        self.var("AWS_SESSION_TOKEN")
421    }
422
423    /// Where the shared AWS credentials file lives. `AWS_SHARED_CREDENTIALS_FILE`
424    /// relocates it; otherwise it is `~/.aws/credentials`.
425    pub fn aws_credentials_file(&self) -> Option<PathBuf> {
426        self.relocatable_aws_file("AWS_SHARED_CREDENTIALS_FILE", Paths::aws_credentials_file)
427    }
428
429    /// Where the shared AWS config file lives. `AWS_CONFIG_FILE` relocates it;
430    /// otherwise it is `~/.aws/config`.
431    pub fn aws_config_file(&self) -> Option<PathBuf> {
432        self.relocatable_aws_file("AWS_CONFIG_FILE", Paths::aws_config_file)
433    }
434
435    /// Shared resolution for the two relocatable AWS files.
436    ///
437    /// A leading `~` in the override expands against the known home directory,
438    /// because the AWS CLI accepts that form and a shell does not expand it
439    /// inside a quoted assignment. Exported blank means no file rather than
440    /// the default one: the AWS CLI opens the empty path, finds nothing and
441    /// carries on, so blanking the variable is how you keep a tool away from
442    /// your keys.
443    fn relocatable_aws_file(&self, var: &str, default: fn(&Paths) -> PathBuf) -> Option<PathBuf> {
444        let Some(raw) = self.var(var) else {
445            return self.paths().map(default);
446        };
447        let raw = raw.trim();
448        if raw.is_empty() {
449            return None;
450        }
451        match self.paths() {
452            Some(paths) => Some(expand_home(paths.home(), raw)),
453            // No home to expand against, so the value can only be used as
454            // written.
455            None => Some(PathBuf::from(raw)),
456        }
457    }
458
459    /// `PURPLE_TOKEN`, the self-invocation auth token. A variable that is
460    /// exported but blank (`export PURPLE_TOKEN="$UNSET"`) reads as absent:
461    /// it is ambient shell state, not a token the caller supplied.
462    pub fn purple_token(&self) -> Option<&str> {
463        self.var("PURPLE_TOKEN")
464            .filter(|token| !token.trim().is_empty())
465    }
466
467    /// True when `NO_COLOR` is present (any value), per the no-color convention.
468    pub fn no_color(&self) -> bool {
469        self.vars.contains_key("NO_COLOR")
470    }
471
472    /// `COLORTERM`.
473    pub fn colorterm(&self) -> Option<&str> {
474        self.var("COLORTERM")
475    }
476
477    /// `TERM_PROGRAM`.
478    pub fn term_program(&self) -> Option<&str> {
479        self.var("TERM_PROGRAM")
480    }
481
482    /// `TERM`.
483    pub fn term(&self) -> Option<&str> {
484        self.var("TERM")
485    }
486
487    /// True when running inside tmux (`TMUX` is set).
488    pub fn in_tmux(&self) -> bool {
489        self.vars.contains_key("TMUX")
490    }
491
492    /// Proxy-related variable names that are set to a non-empty value, in a
493    /// stable order. Drives the startup banner's proxy summary.
494    pub fn active_proxy_vars(&self) -> Vec<&'static str> {
495        ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"]
496            .into_iter()
497            .filter(|k| self.var(k).is_some_and(|v| !v.is_empty()))
498            .collect()
499    }
500
501    /// Build a `Command` for `program` whose environment is exactly this Env's
502    /// snapshot. In production the snapshot is the full process environment
503    /// captured at startup, so the subprocess sees the same env it would have
504    /// inherited. Tests construct an `Env` with only the vars they care about
505    /// (e.g. a stub-binary `PATH`), so subprocess resolution and env-dependent
506    /// behaviour are controlled without mutating the process-global env (no
507    /// `unsafe set_var`, no lock).
508    pub fn command(&self, program: &str) -> std::process::Command {
509        let mut cmd = std::process::Command::new(program);
510        cmd.env_clear();
511        cmd.envs(&self.vars);
512        cmd
513    }
514}
515
516// Manual Debug so a stray `{:?}` never dumps secrets (PURPLE_TOKEN, AWS keys,
517// VAULT_ADDR). Shows the home directory and the set of variable names only.
518impl std::fmt::Debug for Env {
519    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
520        let mut names: Vec<&str> = self.vars.keys().map(String::as_str).collect();
521        names.sort_unstable();
522        f.debug_struct("Env")
523            .field("home", &self.paths.as_ref().map(Paths::home))
524            .field("var_names", &names)
525            .finish()
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    fn lookup<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
534        move |k| {
535            pairs
536                .iter()
537                .find(|(key, _)| *key == k)
538                .map(|(_, v)| (*v).to_string())
539        }
540    }
541
542    #[test]
543    fn paths_derive_under_purple_and_ssh() {
544        let p = Paths::new("/home/u");
545        assert_eq!(p.legacy_dir(), PathBuf::from("/home/u/.purple"));
546        assert_eq!(
547            p.preferences(),
548            PathBuf::from("/home/u/.purple/preferences")
549        );
550        assert_eq!(p.snippets_file(), PathBuf::from("/home/u/.purple/snippets"));
551        assert_eq!(
552            p.container_cache(),
553            PathBuf::from("/home/u/.purple/container_cache.jsonl")
554        );
555        assert_eq!(p.log_file(), PathBuf::from("/home/u/.purple/purple.log"));
556        assert_eq!(p.history(), PathBuf::from("/home/u/.purple/history.tsv"));
557        assert_eq!(
558            p.last_version_check(),
559            PathBuf::from("/home/u/.purple/last_version_check")
560        );
561        assert_eq!(p.certs_dir(), PathBuf::from("/home/u/.purple/certs"));
562        assert_eq!(
563            p.config_original(),
564            PathBuf::from("/home/u/.purple/config.original")
565        );
566        assert_eq!(
567            p.mcp_audit_log(),
568            PathBuf::from("/home/u/.purple/mcp-audit.log")
569        );
570        assert_eq!(p.ssh_dir(), PathBuf::from("/home/u/.ssh"));
571        assert!(!p.is_split());
572    }
573
574    #[test]
575    fn legacy_layout_puts_every_category_under_dot_purple() {
576        let p = Paths::new("/home/u");
577        for c in Category::ALL {
578            assert_eq!(p.dir(c), Path::new("/home/u/.purple"), "{c:?}");
579        }
580    }
581
582    #[test]
583    fn resolve_without_variables_is_the_legacy_layout() {
584        let resolved = Paths::resolve("/home/u", |_| None);
585        assert_eq!(resolved, Paths::new("/home/u"));
586    }
587
588    #[test]
589    fn resolve_honors_each_xdg_variable_independently() {
590        let vars = [("XDG_CONFIG_HOME", "/home/u/.config")];
591        let p = Paths::resolve("/home/u", lookup(&vars));
592        assert_eq!(p.config_dir(), Path::new("/home/u/.config/purple"));
593        assert_eq!(
594            p.preferences(),
595            PathBuf::from("/home/u/.config/purple/preferences")
596        );
597        assert_eq!(
598            p.themes_dir(),
599            PathBuf::from("/home/u/.config/purple/themes")
600        );
601        // The other categories stay put when their variable is unset.
602        assert_eq!(p.data_dir(), Path::new("/home/u/.purple"));
603        assert_eq!(p.state_dir(), Path::new("/home/u/.purple"));
604        assert_eq!(p.cache_dir(), Path::new("/home/u/.purple"));
605        assert!(p.is_split());
606    }
607
608    #[test]
609    fn resolve_maps_every_file_to_its_category() {
610        let vars = [
611            ("XDG_CONFIG_HOME", "/x/cfg"),
612            ("XDG_DATA_HOME", "/x/data"),
613            ("XDG_STATE_HOME", "/x/state"),
614            ("XDG_CACHE_HOME", "/x/cache"),
615        ];
616        let p = Paths::resolve("/home/u", lookup(&vars));
617        assert_eq!(p.preferences(), PathBuf::from("/x/cfg/purple/preferences"));
618        assert_eq!(p.snippets_file(), PathBuf::from("/x/cfg/purple/snippets"));
619        assert_eq!(
620            p.providers_config(),
621            PathBuf::from("/x/cfg/purple/providers")
622        );
623        assert_eq!(p.themes_dir(), PathBuf::from("/x/cfg/purple/themes"));
624        assert_eq!(p.certs_dir(), PathBuf::from("/x/data/purple/certs"));
625        assert_eq!(
626            p.cert_for("web"),
627            PathBuf::from("/x/data/purple/certs/web-cert.pub")
628        );
629        assert_eq!(
630            p.config_original(),
631            PathBuf::from("/x/data/purple/config.original")
632        );
633        assert_eq!(p.history(), PathBuf::from("/x/state/purple/history.tsv"));
634        assert_eq!(p.recents(), PathBuf::from("/x/state/purple/recents.json"));
635        assert_eq!(
636            p.key_activity(),
637            PathBuf::from("/x/state/purple/key_activity.json")
638        );
639        assert_eq!(
640            p.snippet_runs(),
641            PathBuf::from("/x/state/purple/snippet_runs.json")
642        );
643        assert_eq!(
644            p.sync_history(),
645            PathBuf::from("/x/state/purple/sync_history.tsv")
646        );
647        assert_eq!(p.log_file(), PathBuf::from("/x/state/purple/purple.log"));
648        assert_eq!(
649            p.mcp_audit_log(),
650            PathBuf::from("/x/state/purple/mcp-audit.log")
651        );
652        assert_eq!(
653            p.askpass_marker("a"),
654            PathBuf::from("/x/state/purple/.askpass_a")
655        );
656        assert_eq!(
657            p.container_cache(),
658            PathBuf::from("/x/cache/purple/container_cache.jsonl")
659        );
660        assert_eq!(
661            p.last_version_check(),
662            PathBuf::from("/x/cache/purple/last_version_check")
663        );
664        // Home-relative paths outside purple's own tree do not move.
665        assert_eq!(p.ssh_dir(), PathBuf::from("/home/u/.ssh"));
666        assert_eq!(
667            p.aws_credentials_file(),
668            PathBuf::from("/home/u/.aws/credentials")
669        );
670        assert_eq!(p.legacy_dir(), PathBuf::from("/home/u/.purple"));
671    }
672
673    #[test]
674    fn resolve_prefers_purple_override_over_xdg() {
675        let vars = [
676            ("PURPLE_CONFIG_DIR", "/srv/purple-cfg"),
677            ("XDG_CONFIG_HOME", "/home/u/.config"),
678        ];
679        let p = Paths::resolve("/home/u", lookup(&vars));
680        // The override is used verbatim: no `purple` subdirectory appended.
681        assert_eq!(p.config_dir(), Path::new("/srv/purple-cfg"));
682    }
683
684    #[test]
685    fn resolve_expands_home_in_purple_overrides() {
686        for value in ["~/purple-cfg", "$HOME/purple-cfg", "${HOME}/purple-cfg"] {
687            let vars = [("PURPLE_CONFIG_DIR", value)];
688            let p = Paths::resolve("/home/u", lookup(&vars));
689            assert_eq!(p.config_dir(), Path::new("/home/u/purple-cfg"), "{value}");
690        }
691    }
692
693    #[test]
694    fn resolve_treats_empty_variables_as_unset() {
695        let vars = [
696            ("PURPLE_STATE_DIR", ""),
697            ("XDG_STATE_HOME", "  "),
698            ("XDG_CACHE_HOME", ""),
699        ];
700        let p = Paths::resolve("/home/u", lookup(&vars));
701        assert_eq!(p.state_dir(), Path::new("/home/u/.purple"));
702        assert_eq!(p.cache_dir(), Path::new("/home/u/.purple"));
703    }
704
705    #[test]
706    fn resolve_ignores_relative_xdg_paths() {
707        // The spec calls a relative XDG path invalid; it must be ignored.
708        let vars = [("XDG_DATA_HOME", "relative/share")];
709        let p = Paths::resolve("/home/u", lookup(&vars));
710        assert_eq!(p.data_dir(), Path::new("/home/u/.purple"));
711    }
712
713    #[test]
714    fn resolve_empty_purple_override_falls_through_to_xdg() {
715        let vars = [
716            ("PURPLE_DATA_DIR", ""),
717            ("XDG_DATA_HOME", "/home/u/.local/share"),
718        ];
719        let p = Paths::resolve("/home/u", lookup(&vars));
720        assert_eq!(p.data_dir(), Path::new("/home/u/.local/share/purple"));
721    }
722
723    #[test]
724    fn category_variable_names() {
725        assert_eq!(Category::Config.purple_var(), "PURPLE_CONFIG_DIR");
726        assert_eq!(Category::Data.xdg_var(), "XDG_DATA_HOME");
727        assert_eq!(Category::State.purple_var(), "PURPLE_STATE_DIR");
728        assert_eq!(Category::Cache.xdg_var(), "XDG_CACHE_HOME");
729        assert_eq!(Category::State.name(), "state");
730    }
731
732    #[test]
733    fn abbreviate_home_swaps_the_home_prefix_for_a_tilde() {
734        let p = Paths::new("/home/u");
735        assert_eq!(
736            p.abbreviate_home(&p.config_original()),
737            "~/.purple/config.original"
738        );
739        assert_eq!(p.abbreviate_home(Path::new("/srv/x/y")), "/srv/x/y");
740        assert_eq!(p.abbreviate_home(Path::new("/home/u")), "/home/u");
741    }
742
743    #[test]
744    fn cert_for_uses_alias_filename() {
745        let p = Paths::new("/home/u");
746        assert_eq!(
747            p.cert_for("web-1"),
748            PathBuf::from("/home/u/.purple/certs/web-1-cert.pub")
749        );
750    }
751
752    #[test]
753    fn askpass_marker_sanitises_traversal_chars() {
754        let p = Paths::new("/home/u");
755        assert_eq!(
756            p.askpass_marker("a/b\\c.d"),
757            PathBuf::from("/home/u/.purple/.askpass_a_b_c_d")
758        );
759    }
760
761    #[test]
762    fn for_test_has_paths_and_no_vars() {
763        let env = Env::for_test("/tmp/x");
764        assert_eq!(env.paths().unwrap().home(), Path::new("/tmp/x"));
765        assert_eq!(env.var("ANYTHING"), None);
766        assert!(!env.no_color());
767    }
768
769    #[test]
770    fn with_paths_replaces_the_layout() {
771        let split = Paths::resolve("/tmp/x", lookup(&[("XDG_CACHE_HOME", "/tmp/c")]));
772        let env = Env::for_test("/tmp/x").with_paths(split.clone());
773        assert_eq!(env.paths(), Some(&split));
774        assert_eq!(env.paths().unwrap().cache_dir(), Path::new("/tmp/c/purple"));
775    }
776
777    #[test]
778    fn empty_has_no_paths() {
779        let env = Env::empty();
780        assert!(env.paths().is_none());
781    }
782
783    #[test]
784    fn sandboxed_gives_isolated_existing_dirs() {
785        let a = Env::sandboxed();
786        let b = Env::sandboxed();
787        let pa = a.paths().unwrap().home().to_path_buf();
788        let pb = b.paths().unwrap().home().to_path_buf();
789        assert_ne!(pa, pb, "each sandbox is a distinct directory");
790        assert!(pa.exists(), "sandbox home exists for the Env's lifetime");
791        // Writing through the derived paths works (atomic_write creates parents).
792        let prefs = a.paths().unwrap().preferences();
793        crate::fs_util::atomic_write(&prefs, b"theme=Purple\n").unwrap();
794        assert_eq!(std::fs::read_to_string(&prefs).unwrap(), "theme=Purple\n");
795    }
796
797    #[test]
798    fn with_var_sets_typed_accessors() {
799        let env = Env::for_test("/tmp/x")
800            .with_var("VAULT_ADDR", "https://vault.example:8200")
801            .with_var("COLORTERM", "truecolor")
802            .with_var("NO_COLOR", "1")
803            .with_var("TMUX", "/tmp/tmux-1000/default,1,0");
804        assert_eq!(env.vault_addr(), Some("https://vault.example:8200"));
805        assert_eq!(env.colorterm(), Some("truecolor"));
806        assert!(env.no_color());
807        assert!(env.in_tmux());
808    }
809
810    #[test]
811    fn aws_credentials_require_both_keys() {
812        let only_id = Env::for_test("/tmp/x").with_var("AWS_ACCESS_KEY_ID", "AKIA");
813        assert_eq!(only_id.aws_credentials(), None);
814        let both = only_id.with_var("AWS_SECRET_ACCESS_KEY", "secret");
815        assert_eq!(both.aws_credentials(), Some(("AKIA", "secret")));
816    }
817
818    #[test]
819    fn purple_token_treats_a_blank_variable_as_absent() {
820        // `export PURPLE_TOKEN="$UNSET"` leaves the variable present and
821        // empty. Reading that as a supplied token makes callers skip their
822        // stored-token fallback and save the blank over a real credential.
823        assert_eq!(Env::for_test("/tmp/x").purple_token(), None);
824        for blank in ["", "   "] {
825            assert_eq!(
826                Env::for_test("/tmp/x")
827                    .with_var("PURPLE_TOKEN", blank)
828                    .purple_token(),
829                None,
830                "a blank PURPLE_TOKEN must read as absent"
831            );
832        }
833        assert_eq!(
834            Env::for_test("/tmp/x")
835                .with_var("PURPLE_TOKEN", "tok")
836                .purple_token(),
837            Some("tok")
838        );
839    }
840
841    #[test]
842    fn aws_session_token_reads_env_var() {
843        let env = Env::for_test("/tmp/x");
844        assert_eq!(env.aws_session_token(), None);
845        let with_token = env.with_var("AWS_SESSION_TOKEN", "TOKEN");
846        assert_eq!(with_token.aws_session_token(), Some("TOKEN"));
847    }
848
849    #[test]
850    fn aws_session_token_is_independent_of_key_pair() {
851        // Temporary credentials always arrive as a triple, but the accessor
852        // must not depend on the key pair being present.
853        let env = Env::for_test("/tmp/x").with_var("AWS_SESSION_TOKEN", "TOKEN");
854        assert_eq!(env.aws_credentials(), None);
855        assert_eq!(env.aws_session_token(), Some("TOKEN"));
856    }
857
858    #[test]
859    fn active_proxy_vars_filters_empty_and_orders() {
860        let env = Env::for_test("/tmp/x")
861            .with_var("HTTPS_PROXY", "http://proxy:3128")
862            .with_var("HTTP_PROXY", "")
863            .with_var("NO_PROXY", "localhost");
864        assert_eq!(env.active_proxy_vars(), vec!["HTTPS_PROXY", "NO_PROXY"]);
865    }
866
867    #[test]
868    fn debug_redacts_secret_values() {
869        let env = Env::for_test("/tmp/x")
870            .with_var("PURPLE_TOKEN", "super-secret")
871            .with_var("VAULT_ADDR", "https://vault.example:8200");
872        let rendered = format!("{env:?}");
873        assert!(!rendered.contains("super-secret"));
874        assert!(!rendered.contains("vault.example"));
875        assert!(rendered.contains("PURPLE_TOKEN"));
876        assert!(rendered.contains("VAULT_ADDR"));
877    }
878
879    #[test]
880    fn from_process_captures_home_and_vars() {
881        // Smoke test against the real process: home is usually set, and the
882        // snapshot is internally consistent with the typed accessors.
883        let env = Env::from_process();
884        // No assertion on specific vars (CI environments differ); just verify
885        // the snapshot mechanism works end to end.
886        let _ = env.paths();
887        let _ = env.var("PATH");
888    }
889    #[test]
890    fn the_aws_files_default_to_the_dot_aws_directory() {
891        let env = Env::for_test("/home/u");
892        assert_eq!(
893            env.aws_credentials_file(),
894            Some(PathBuf::from("/home/u/.aws/credentials"))
895        );
896        assert_eq!(
897            env.aws_config_file(),
898            Some(PathBuf::from("/home/u/.aws/config"))
899        );
900    }
901
902    #[test]
903    fn an_absolute_override_relocates_the_aws_file() {
904        let env = Env::for_test("/home/u")
905            .with_var("AWS_SHARED_CREDENTIALS_FILE", "/etc/aws/creds")
906            .with_var("AWS_CONFIG_FILE", "/etc/aws/conf");
907        assert_eq!(
908            env.aws_credentials_file(),
909            Some(PathBuf::from("/etc/aws/creds"))
910        );
911        assert_eq!(env.aws_config_file(), Some(PathBuf::from("/etc/aws/conf")));
912    }
913
914    #[test]
915    fn a_home_relative_override_expands_against_the_home_directory() {
916        // The AWS CLI accepts this form, and a value read from a file rather
917        // than typed in a shell arrives unexpanded.
918        let env = Env::for_test("/home/u")
919            .with_var("AWS_SHARED_CREDENTIALS_FILE", "~/work/creds")
920            .with_var("AWS_CONFIG_FILE", "${HOME}/work/conf");
921        assert_eq!(
922            env.aws_credentials_file(),
923            Some(PathBuf::from("/home/u/work/creds"))
924        );
925        assert_eq!(
926            env.aws_config_file(),
927            Some(PathBuf::from("/home/u/work/conf"))
928        );
929    }
930
931    #[test]
932    fn a_bare_tilde_override_is_the_home_directory() {
933        let env = Env::for_test("/home/u").with_var("AWS_CONFIG_FILE", "~");
934        assert_eq!(env.aws_config_file(), Some(PathBuf::from("/home/u")));
935    }
936
937    #[test]
938    fn an_exported_but_blank_override_means_no_file() {
939        // Blanking the variable is how a user keeps a tool away from their
940        // keys. Falling back to the default would read them anyway.
941        let env = Env::for_test("/home/u")
942            .with_var("AWS_SHARED_CREDENTIALS_FILE", "")
943            .with_var("AWS_CONFIG_FILE", "   ");
944        assert_eq!(env.aws_credentials_file(), None);
945        assert_eq!(env.aws_config_file(), None);
946    }
947
948    #[test]
949    fn without_a_home_directory_only_an_override_names_an_aws_file() {
950        assert_eq!(Env::empty().aws_credentials_file(), None);
951        assert_eq!(
952            Env::empty()
953                .with_var("AWS_CONFIG_FILE", "/etc/aws/conf")
954                .aws_config_file(),
955            Some(PathBuf::from("/etc/aws/conf"))
956        );
957    }
958}