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    /// `~/.ssh`.
264    pub fn ssh_dir(&self) -> PathBuf {
265        self.home.join(".ssh")
266    }
267
268    /// `path` for display, with the home directory abbreviated to `~`.
269    pub fn abbreviate_home(&self, path: &Path) -> String {
270        match path.strip_prefix(&self.home) {
271            Ok(rest) if !rest.as_os_str().is_empty() => format!("~/{}", rest.display()),
272            _ => path.display().to_string(),
273        }
274    }
275}
276
277/// One category's directory per the precedence documented on
278/// [`Paths::resolve`].
279fn resolve_category(
280    home: &Path,
281    category: Category,
282    var: &impl Fn(&str) -> Option<String>,
283) -> PathBuf {
284    if let Some(explicit) = var(category.purple_var()).filter(|v| !v.trim().is_empty()) {
285        return expand_home(home, &explicit);
286    }
287    if let Some(base) = var(category.xdg_var()).filter(|v| !v.trim().is_empty()) {
288        let base = PathBuf::from(base);
289        if base.is_absolute() {
290            return base.join(XDG_APP_DIR);
291        }
292    }
293    home.join(".purple")
294}
295
296/// Expand a leading `~/`, `$HOME/` or `${HOME}/` in an override value. A
297/// value written in a file rather than typed in a shell arrives unexpanded.
298fn expand_home(home: &Path, value: &str) -> PathBuf {
299    for prefix in ["~/", "$HOME/", "${HOME}/"] {
300        if let Some(rest) = value.strip_prefix(prefix) {
301            return home.join(rest);
302        }
303    }
304    PathBuf::from(value)
305}
306
307/// The resolved environment for one process run: the home-derived paths plus a
308/// snapshot of the process environment variables. Built once via
309/// [`Env::from_process`] and passed down by reference (or `Arc`) rather than
310/// re-read on demand.
311#[derive(Clone)]
312pub struct Env {
313    paths: Option<Paths>,
314    vars: HashMap<String, String>,
315    // Test sandbox: owns the temp directory that `paths` points into so it
316    // lives exactly as long as the Env (and any `Arc<Env>` clone). Absent from
317    // production builds; `tempfile` is a dev-dependency.
318    #[cfg(test)]
319    _sandbox: Option<std::sync::Arc<tempfile::TempDir>>,
320}
321
322impl Env {
323    fn new_inner(paths: Option<Paths>, vars: HashMap<String, String>) -> Self {
324        Self {
325            paths,
326            vars,
327            #[cfg(test)]
328            _sandbox: None,
329        }
330    }
331
332    /// Capture the real process environment: the home directory and a snapshot
333    /// of all environment variables. The single point where production reads
334    /// `std::env` and `dirs::home_dir`. The category directories resolve from
335    /// the same snapshot.
336    pub fn from_process() -> Self {
337        let vars: HashMap<String, String> = std::env::vars().collect();
338        let paths = dirs::home_dir().map(|home| Paths::resolve(home, |k| vars.get(k).cloned()));
339        Self::new_inner(paths, vars)
340    }
341
342    /// A test environment rooted at `home` with the legacy layout and no
343    /// environment variables. Add variables with [`Env::with_var`], swap the
344    /// layout with [`Env::with_paths`].
345    pub fn for_test(home: impl Into<PathBuf>) -> Self {
346        Self::new_inner(Some(Paths::new(home)), HashMap::new())
347    }
348
349    /// An environment with neither a home directory nor variables. Models the
350    /// rare case where `dirs::home_dir()` returns `None`.
351    pub fn empty() -> Self {
352        Self::new_inner(None, HashMap::new())
353    }
354
355    /// A self-cleaning sandbox rooted at a fresh temp directory, owned by the
356    /// Env. Each call is isolated, so parallel tests never share on-disk state
357    /// and need no lock. The default for test `App` fixtures.
358    #[cfg(test)]
359    pub fn sandboxed() -> Self {
360        let dir = tempfile::tempdir().expect("create test sandbox tempdir");
361        let mut env = Self::for_test(dir.path());
362        env._sandbox = Some(std::sync::Arc::new(dir));
363        env
364    }
365
366    /// Builder: set a variable. Chainable, for test construction.
367    #[must_use]
368    pub fn with_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
369        self.vars.insert(key.into(), value.into());
370        self
371    }
372
373    /// Builder: replace the paths, e.g. with a split layout from
374    /// [`Paths::resolve`]. Chainable, for test construction.
375    #[must_use]
376    pub fn with_paths(mut self, paths: Paths) -> Self {
377        self.paths = Some(paths);
378        self
379    }
380
381    /// Home-derived paths, or `None` when the home directory is unknown.
382    pub fn paths(&self) -> Option<&Paths> {
383        self.paths.as_ref()
384    }
385
386    /// Raw lookup of an arbitrary variable. Used by SSH config `${VAR}`
387    /// expansion, which references user-chosen names.
388    pub fn var(&self, key: &str) -> Option<&str> {
389        self.vars.get(key).map(String::as_str)
390    }
391
392    /// `VAULT_ADDR` fallback for Vault SSH address resolution.
393    pub fn vault_addr(&self) -> Option<&str> {
394        self.var("VAULT_ADDR")
395    }
396
397    /// `(AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)` when both are set.
398    pub fn aws_credentials(&self) -> Option<(&str, &str)> {
399        match (
400            self.var("AWS_ACCESS_KEY_ID"),
401            self.var("AWS_SECRET_ACCESS_KEY"),
402        ) {
403            (Some(id), Some(secret)) => Some((id, secret)),
404            _ => None,
405        }
406    }
407
408    /// `AWS_SESSION_TOKEN`, set alongside temporary STS credentials.
409    pub fn aws_session_token(&self) -> Option<&str> {
410        self.var("AWS_SESSION_TOKEN")
411    }
412
413    /// `PURPLE_TOKEN`, the self-invocation auth token. A variable that is
414    /// exported but blank (`export PURPLE_TOKEN="$UNSET"`) reads as absent:
415    /// it is ambient shell state, not a token the caller supplied.
416    pub fn purple_token(&self) -> Option<&str> {
417        self.var("PURPLE_TOKEN")
418            .filter(|token| !token.trim().is_empty())
419    }
420
421    /// True when `NO_COLOR` is present (any value), per the no-color convention.
422    pub fn no_color(&self) -> bool {
423        self.vars.contains_key("NO_COLOR")
424    }
425
426    /// `COLORTERM`.
427    pub fn colorterm(&self) -> Option<&str> {
428        self.var("COLORTERM")
429    }
430
431    /// `TERM_PROGRAM`.
432    pub fn term_program(&self) -> Option<&str> {
433        self.var("TERM_PROGRAM")
434    }
435
436    /// `TERM`.
437    pub fn term(&self) -> Option<&str> {
438        self.var("TERM")
439    }
440
441    /// True when running inside tmux (`TMUX` is set).
442    pub fn in_tmux(&self) -> bool {
443        self.vars.contains_key("TMUX")
444    }
445
446    /// Proxy-related variable names that are set to a non-empty value, in a
447    /// stable order. Drives the startup banner's proxy summary.
448    pub fn active_proxy_vars(&self) -> Vec<&'static str> {
449        ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"]
450            .into_iter()
451            .filter(|k| self.var(k).is_some_and(|v| !v.is_empty()))
452            .collect()
453    }
454
455    /// Build a `Command` for `program` whose environment is exactly this Env's
456    /// snapshot. In production the snapshot is the full process environment
457    /// captured at startup, so the subprocess sees the same env it would have
458    /// inherited. Tests construct an `Env` with only the vars they care about
459    /// (e.g. a stub-binary `PATH`), so subprocess resolution and env-dependent
460    /// behaviour are controlled without mutating the process-global env (no
461    /// `unsafe set_var`, no lock).
462    pub fn command(&self, program: &str) -> std::process::Command {
463        let mut cmd = std::process::Command::new(program);
464        cmd.env_clear();
465        cmd.envs(&self.vars);
466        cmd
467    }
468}
469
470// Manual Debug so a stray `{:?}` never dumps secrets (PURPLE_TOKEN, AWS keys,
471// VAULT_ADDR). Shows the home directory and the set of variable names only.
472impl std::fmt::Debug for Env {
473    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
474        let mut names: Vec<&str> = self.vars.keys().map(String::as_str).collect();
475        names.sort_unstable();
476        f.debug_struct("Env")
477            .field("home", &self.paths.as_ref().map(Paths::home))
478            .field("var_names", &names)
479            .finish()
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486
487    fn lookup<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
488        move |k| {
489            pairs
490                .iter()
491                .find(|(key, _)| *key == k)
492                .map(|(_, v)| (*v).to_string())
493        }
494    }
495
496    #[test]
497    fn paths_derive_under_purple_and_ssh() {
498        let p = Paths::new("/home/u");
499        assert_eq!(p.legacy_dir(), PathBuf::from("/home/u/.purple"));
500        assert_eq!(
501            p.preferences(),
502            PathBuf::from("/home/u/.purple/preferences")
503        );
504        assert_eq!(p.snippets_file(), PathBuf::from("/home/u/.purple/snippets"));
505        assert_eq!(
506            p.container_cache(),
507            PathBuf::from("/home/u/.purple/container_cache.jsonl")
508        );
509        assert_eq!(p.log_file(), PathBuf::from("/home/u/.purple/purple.log"));
510        assert_eq!(p.history(), PathBuf::from("/home/u/.purple/history.tsv"));
511        assert_eq!(
512            p.last_version_check(),
513            PathBuf::from("/home/u/.purple/last_version_check")
514        );
515        assert_eq!(p.certs_dir(), PathBuf::from("/home/u/.purple/certs"));
516        assert_eq!(
517            p.config_original(),
518            PathBuf::from("/home/u/.purple/config.original")
519        );
520        assert_eq!(
521            p.mcp_audit_log(),
522            PathBuf::from("/home/u/.purple/mcp-audit.log")
523        );
524        assert_eq!(p.ssh_dir(), PathBuf::from("/home/u/.ssh"));
525        assert!(!p.is_split());
526    }
527
528    #[test]
529    fn legacy_layout_puts_every_category_under_dot_purple() {
530        let p = Paths::new("/home/u");
531        for c in Category::ALL {
532            assert_eq!(p.dir(c), Path::new("/home/u/.purple"), "{c:?}");
533        }
534    }
535
536    #[test]
537    fn resolve_without_variables_is_the_legacy_layout() {
538        let resolved = Paths::resolve("/home/u", |_| None);
539        assert_eq!(resolved, Paths::new("/home/u"));
540    }
541
542    #[test]
543    fn resolve_honors_each_xdg_variable_independently() {
544        let vars = [("XDG_CONFIG_HOME", "/home/u/.config")];
545        let p = Paths::resolve("/home/u", lookup(&vars));
546        assert_eq!(p.config_dir(), Path::new("/home/u/.config/purple"));
547        assert_eq!(
548            p.preferences(),
549            PathBuf::from("/home/u/.config/purple/preferences")
550        );
551        assert_eq!(
552            p.themes_dir(),
553            PathBuf::from("/home/u/.config/purple/themes")
554        );
555        // The other categories stay put when their variable is unset.
556        assert_eq!(p.data_dir(), Path::new("/home/u/.purple"));
557        assert_eq!(p.state_dir(), Path::new("/home/u/.purple"));
558        assert_eq!(p.cache_dir(), Path::new("/home/u/.purple"));
559        assert!(p.is_split());
560    }
561
562    #[test]
563    fn resolve_maps_every_file_to_its_category() {
564        let vars = [
565            ("XDG_CONFIG_HOME", "/x/cfg"),
566            ("XDG_DATA_HOME", "/x/data"),
567            ("XDG_STATE_HOME", "/x/state"),
568            ("XDG_CACHE_HOME", "/x/cache"),
569        ];
570        let p = Paths::resolve("/home/u", lookup(&vars));
571        assert_eq!(p.preferences(), PathBuf::from("/x/cfg/purple/preferences"));
572        assert_eq!(p.snippets_file(), PathBuf::from("/x/cfg/purple/snippets"));
573        assert_eq!(
574            p.providers_config(),
575            PathBuf::from("/x/cfg/purple/providers")
576        );
577        assert_eq!(p.themes_dir(), PathBuf::from("/x/cfg/purple/themes"));
578        assert_eq!(p.certs_dir(), PathBuf::from("/x/data/purple/certs"));
579        assert_eq!(
580            p.cert_for("web"),
581            PathBuf::from("/x/data/purple/certs/web-cert.pub")
582        );
583        assert_eq!(
584            p.config_original(),
585            PathBuf::from("/x/data/purple/config.original")
586        );
587        assert_eq!(p.history(), PathBuf::from("/x/state/purple/history.tsv"));
588        assert_eq!(p.recents(), PathBuf::from("/x/state/purple/recents.json"));
589        assert_eq!(
590            p.key_activity(),
591            PathBuf::from("/x/state/purple/key_activity.json")
592        );
593        assert_eq!(
594            p.snippet_runs(),
595            PathBuf::from("/x/state/purple/snippet_runs.json")
596        );
597        assert_eq!(
598            p.sync_history(),
599            PathBuf::from("/x/state/purple/sync_history.tsv")
600        );
601        assert_eq!(p.log_file(), PathBuf::from("/x/state/purple/purple.log"));
602        assert_eq!(
603            p.mcp_audit_log(),
604            PathBuf::from("/x/state/purple/mcp-audit.log")
605        );
606        assert_eq!(
607            p.askpass_marker("a"),
608            PathBuf::from("/x/state/purple/.askpass_a")
609        );
610        assert_eq!(
611            p.container_cache(),
612            PathBuf::from("/x/cache/purple/container_cache.jsonl")
613        );
614        assert_eq!(
615            p.last_version_check(),
616            PathBuf::from("/x/cache/purple/last_version_check")
617        );
618        // Home-relative paths outside purple's own tree do not move.
619        assert_eq!(p.ssh_dir(), PathBuf::from("/home/u/.ssh"));
620        assert_eq!(
621            p.aws_credentials_file(),
622            PathBuf::from("/home/u/.aws/credentials")
623        );
624        assert_eq!(p.legacy_dir(), PathBuf::from("/home/u/.purple"));
625    }
626
627    #[test]
628    fn resolve_prefers_purple_override_over_xdg() {
629        let vars = [
630            ("PURPLE_CONFIG_DIR", "/srv/purple-cfg"),
631            ("XDG_CONFIG_HOME", "/home/u/.config"),
632        ];
633        let p = Paths::resolve("/home/u", lookup(&vars));
634        // The override is used verbatim: no `purple` subdirectory appended.
635        assert_eq!(p.config_dir(), Path::new("/srv/purple-cfg"));
636    }
637
638    #[test]
639    fn resolve_expands_home_in_purple_overrides() {
640        for value in ["~/purple-cfg", "$HOME/purple-cfg", "${HOME}/purple-cfg"] {
641            let vars = [("PURPLE_CONFIG_DIR", value)];
642            let p = Paths::resolve("/home/u", lookup(&vars));
643            assert_eq!(p.config_dir(), Path::new("/home/u/purple-cfg"), "{value}");
644        }
645    }
646
647    #[test]
648    fn resolve_treats_empty_variables_as_unset() {
649        let vars = [
650            ("PURPLE_STATE_DIR", ""),
651            ("XDG_STATE_HOME", "  "),
652            ("XDG_CACHE_HOME", ""),
653        ];
654        let p = Paths::resolve("/home/u", lookup(&vars));
655        assert_eq!(p.state_dir(), Path::new("/home/u/.purple"));
656        assert_eq!(p.cache_dir(), Path::new("/home/u/.purple"));
657    }
658
659    #[test]
660    fn resolve_ignores_relative_xdg_paths() {
661        // The spec calls a relative XDG path invalid; it must be ignored.
662        let vars = [("XDG_DATA_HOME", "relative/share")];
663        let p = Paths::resolve("/home/u", lookup(&vars));
664        assert_eq!(p.data_dir(), Path::new("/home/u/.purple"));
665    }
666
667    #[test]
668    fn resolve_empty_purple_override_falls_through_to_xdg() {
669        let vars = [
670            ("PURPLE_DATA_DIR", ""),
671            ("XDG_DATA_HOME", "/home/u/.local/share"),
672        ];
673        let p = Paths::resolve("/home/u", lookup(&vars));
674        assert_eq!(p.data_dir(), Path::new("/home/u/.local/share/purple"));
675    }
676
677    #[test]
678    fn category_variable_names() {
679        assert_eq!(Category::Config.purple_var(), "PURPLE_CONFIG_DIR");
680        assert_eq!(Category::Data.xdg_var(), "XDG_DATA_HOME");
681        assert_eq!(Category::State.purple_var(), "PURPLE_STATE_DIR");
682        assert_eq!(Category::Cache.xdg_var(), "XDG_CACHE_HOME");
683        assert_eq!(Category::State.name(), "state");
684    }
685
686    #[test]
687    fn abbreviate_home_swaps_the_home_prefix_for_a_tilde() {
688        let p = Paths::new("/home/u");
689        assert_eq!(
690            p.abbreviate_home(&p.config_original()),
691            "~/.purple/config.original"
692        );
693        assert_eq!(p.abbreviate_home(Path::new("/srv/x/y")), "/srv/x/y");
694        assert_eq!(p.abbreviate_home(Path::new("/home/u")), "/home/u");
695    }
696
697    #[test]
698    fn cert_for_uses_alias_filename() {
699        let p = Paths::new("/home/u");
700        assert_eq!(
701            p.cert_for("web-1"),
702            PathBuf::from("/home/u/.purple/certs/web-1-cert.pub")
703        );
704    }
705
706    #[test]
707    fn askpass_marker_sanitises_traversal_chars() {
708        let p = Paths::new("/home/u");
709        assert_eq!(
710            p.askpass_marker("a/b\\c.d"),
711            PathBuf::from("/home/u/.purple/.askpass_a_b_c_d")
712        );
713    }
714
715    #[test]
716    fn for_test_has_paths_and_no_vars() {
717        let env = Env::for_test("/tmp/x");
718        assert_eq!(env.paths().unwrap().home(), Path::new("/tmp/x"));
719        assert_eq!(env.var("ANYTHING"), None);
720        assert!(!env.no_color());
721    }
722
723    #[test]
724    fn with_paths_replaces_the_layout() {
725        let split = Paths::resolve("/tmp/x", lookup(&[("XDG_CACHE_HOME", "/tmp/c")]));
726        let env = Env::for_test("/tmp/x").with_paths(split.clone());
727        assert_eq!(env.paths(), Some(&split));
728        assert_eq!(env.paths().unwrap().cache_dir(), Path::new("/tmp/c/purple"));
729    }
730
731    #[test]
732    fn empty_has_no_paths() {
733        let env = Env::empty();
734        assert!(env.paths().is_none());
735    }
736
737    #[test]
738    fn sandboxed_gives_isolated_existing_dirs() {
739        let a = Env::sandboxed();
740        let b = Env::sandboxed();
741        let pa = a.paths().unwrap().home().to_path_buf();
742        let pb = b.paths().unwrap().home().to_path_buf();
743        assert_ne!(pa, pb, "each sandbox is a distinct directory");
744        assert!(pa.exists(), "sandbox home exists for the Env's lifetime");
745        // Writing through the derived paths works (atomic_write creates parents).
746        let prefs = a.paths().unwrap().preferences();
747        crate::fs_util::atomic_write(&prefs, b"theme=Purple\n").unwrap();
748        assert_eq!(std::fs::read_to_string(&prefs).unwrap(), "theme=Purple\n");
749    }
750
751    #[test]
752    fn with_var_sets_typed_accessors() {
753        let env = Env::for_test("/tmp/x")
754            .with_var("VAULT_ADDR", "https://vault.example:8200")
755            .with_var("COLORTERM", "truecolor")
756            .with_var("NO_COLOR", "1")
757            .with_var("TMUX", "/tmp/tmux-1000/default,1,0");
758        assert_eq!(env.vault_addr(), Some("https://vault.example:8200"));
759        assert_eq!(env.colorterm(), Some("truecolor"));
760        assert!(env.no_color());
761        assert!(env.in_tmux());
762    }
763
764    #[test]
765    fn aws_credentials_require_both_keys() {
766        let only_id = Env::for_test("/tmp/x").with_var("AWS_ACCESS_KEY_ID", "AKIA");
767        assert_eq!(only_id.aws_credentials(), None);
768        let both = only_id.with_var("AWS_SECRET_ACCESS_KEY", "secret");
769        assert_eq!(both.aws_credentials(), Some(("AKIA", "secret")));
770    }
771
772    #[test]
773    fn purple_token_treats_a_blank_variable_as_absent() {
774        // `export PURPLE_TOKEN="$UNSET"` leaves the variable present and
775        // empty. Reading that as a supplied token makes callers skip their
776        // stored-token fallback and save the blank over a real credential.
777        assert_eq!(Env::for_test("/tmp/x").purple_token(), None);
778        for blank in ["", "   "] {
779            assert_eq!(
780                Env::for_test("/tmp/x")
781                    .with_var("PURPLE_TOKEN", blank)
782                    .purple_token(),
783                None,
784                "a blank PURPLE_TOKEN must read as absent"
785            );
786        }
787        assert_eq!(
788            Env::for_test("/tmp/x")
789                .with_var("PURPLE_TOKEN", "tok")
790                .purple_token(),
791            Some("tok")
792        );
793    }
794
795    #[test]
796    fn aws_session_token_reads_env_var() {
797        let env = Env::for_test("/tmp/x");
798        assert_eq!(env.aws_session_token(), None);
799        let with_token = env.with_var("AWS_SESSION_TOKEN", "TOKEN");
800        assert_eq!(with_token.aws_session_token(), Some("TOKEN"));
801    }
802
803    #[test]
804    fn aws_session_token_is_independent_of_key_pair() {
805        // Temporary credentials always arrive as a triple, but the accessor
806        // must not depend on the key pair being present.
807        let env = Env::for_test("/tmp/x").with_var("AWS_SESSION_TOKEN", "TOKEN");
808        assert_eq!(env.aws_credentials(), None);
809        assert_eq!(env.aws_session_token(), Some("TOKEN"));
810    }
811
812    #[test]
813    fn active_proxy_vars_filters_empty_and_orders() {
814        let env = Env::for_test("/tmp/x")
815            .with_var("HTTPS_PROXY", "http://proxy:3128")
816            .with_var("HTTP_PROXY", "")
817            .with_var("NO_PROXY", "localhost");
818        assert_eq!(env.active_proxy_vars(), vec!["HTTPS_PROXY", "NO_PROXY"]);
819    }
820
821    #[test]
822    fn debug_redacts_secret_values() {
823        let env = Env::for_test("/tmp/x")
824            .with_var("PURPLE_TOKEN", "super-secret")
825            .with_var("VAULT_ADDR", "https://vault.example:8200");
826        let rendered = format!("{env:?}");
827        assert!(!rendered.contains("super-secret"));
828        assert!(!rendered.contains("vault.example"));
829        assert!(rendered.contains("PURPLE_TOKEN"));
830        assert!(rendered.contains("VAULT_ADDR"));
831    }
832
833    #[test]
834    fn from_process_captures_home_and_vars() {
835        // Smoke test against the real process: home is usually set, and the
836        // snapshot is internally consistent with the typed accessors.
837        let env = Env::from_process();
838        // No assertion on specific vars (CI environments differ); just verify
839        // the snapshot mechanism works end to end.
840        let _ = env.paths();
841        let _ = env.var("PATH");
842    }
843}