1use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum Category {
17 Config,
20 Data,
23 State,
25 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 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 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 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
68const XDG_APP_DIR: &str = "purple";
70
71#[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 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 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 pub fn legacy_dir(&self) -> PathBuf {
131 self.home.join(".purple")
132 }
133
134 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 pub fn config_dir(&self) -> &Path {
146 &self.config_dir
147 }
148
149 pub fn data_dir(&self) -> &Path {
151 &self.data_dir
152 }
153
154 pub fn state_dir(&self) -> &Path {
156 &self.state_dir
157 }
158
159 pub fn cache_dir(&self) -> &Path {
161 &self.cache_dir
162 }
163
164 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 pub fn preferences(&self) -> PathBuf {
172 self.config_dir.join("preferences")
173 }
174
175 pub fn snippets_file(&self) -> PathBuf {
177 self.config_dir.join("snippets")
178 }
179
180 pub fn providers_config(&self) -> PathBuf {
182 self.config_dir.join("providers")
183 }
184
185 pub fn themes_dir(&self) -> PathBuf {
187 self.config_dir.join("themes")
188 }
189
190 pub fn certs_dir(&self) -> PathBuf {
192 self.data_dir.join("certs")
193 }
194
195 pub fn cert_for(&self, alias: &str) -> PathBuf {
197 self.certs_dir().join(format!("{alias}-cert.pub"))
198 }
199
200 pub fn config_original(&self) -> PathBuf {
203 self.data_dir.join("config.original")
204 }
205
206 pub fn history(&self) -> PathBuf {
208 self.state_dir.join("history.tsv")
209 }
210
211 pub fn recents(&self) -> PathBuf {
213 self.state_dir.join("recents.json")
214 }
215
216 pub fn key_activity(&self) -> PathBuf {
218 self.state_dir.join("key_activity.json")
219 }
220
221 pub fn snippet_runs(&self) -> PathBuf {
223 self.state_dir.join("snippet_runs.json")
224 }
225
226 pub fn sync_history(&self) -> PathBuf {
228 self.state_dir.join("sync_history.tsv")
229 }
230
231 pub fn log_file(&self) -> PathBuf {
233 self.state_dir.join("purple.log")
234 }
235
236 pub fn mcp_audit_log(&self) -> PathBuf {
238 self.state_dir.join("mcp-audit.log")
239 }
240
241 pub fn askpass_marker(&self, alias: &str) -> PathBuf {
244 let safe = alias.replace(['/', '\\', '.'], "_");
245 self.state_dir.join(format!(".askpass_{safe}"))
246 }
247
248 pub fn container_cache(&self) -> PathBuf {
250 self.cache_dir.join("container_cache.jsonl")
251 }
252
253 pub fn last_version_check(&self) -> PathBuf {
255 self.cache_dir.join("last_version_check")
256 }
257
258 pub fn aws_credentials_file(&self) -> PathBuf {
260 self.home.join(".aws").join("credentials")
261 }
262
263 pub fn ssh_dir(&self) -> PathBuf {
265 self.home.join(".ssh")
266 }
267
268 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
277fn 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
296fn 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#[derive(Clone)]
312pub struct Env {
313 paths: Option<Paths>,
314 vars: HashMap<String, String>,
315 #[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 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 pub fn for_test(home: impl Into<PathBuf>) -> Self {
346 Self::new_inner(Some(Paths::new(home)), HashMap::new())
347 }
348
349 pub fn empty() -> Self {
352 Self::new_inner(None, HashMap::new())
353 }
354
355 #[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 #[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 #[must_use]
376 pub fn with_paths(mut self, paths: Paths) -> Self {
377 self.paths = Some(paths);
378 self
379 }
380
381 pub fn paths(&self) -> Option<&Paths> {
383 self.paths.as_ref()
384 }
385
386 pub fn var(&self, key: &str) -> Option<&str> {
389 self.vars.get(key).map(String::as_str)
390 }
391
392 pub fn vault_addr(&self) -> Option<&str> {
394 self.var("VAULT_ADDR")
395 }
396
397 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 pub fn aws_session_token(&self) -> Option<&str> {
410 self.var("AWS_SESSION_TOKEN")
411 }
412
413 pub fn purple_token(&self) -> Option<&str> {
417 self.var("PURPLE_TOKEN")
418 .filter(|token| !token.trim().is_empty())
419 }
420
421 pub fn no_color(&self) -> bool {
423 self.vars.contains_key("NO_COLOR")
424 }
425
426 pub fn colorterm(&self) -> Option<&str> {
428 self.var("COLORTERM")
429 }
430
431 pub fn term_program(&self) -> Option<&str> {
433 self.var("TERM_PROGRAM")
434 }
435
436 pub fn term(&self) -> Option<&str> {
438 self.var("TERM")
439 }
440
441 pub fn in_tmux(&self) -> bool {
443 self.vars.contains_key("TMUX")
444 }
445
446 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 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
470impl 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 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 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 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 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 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 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 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 let env = Env::from_process();
838 let _ = env.paths();
841 let _ = env.var("PATH");
842 }
843}