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 aws_config_file(&self) -> PathBuf {
267 self.home.join(".aws").join("config")
268 }
269
270 pub fn ssh_dir(&self) -> PathBuf {
272 self.home.join(".ssh")
273 }
274
275 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
284fn 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
303fn 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#[derive(Clone)]
322pub struct Env {
323 paths: Option<Paths>,
324 vars: HashMap<String, String>,
325 #[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 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 pub fn for_test(home: impl Into<PathBuf>) -> Self {
356 Self::new_inner(Some(Paths::new(home)), HashMap::new())
357 }
358
359 pub fn empty() -> Self {
362 Self::new_inner(None, HashMap::new())
363 }
364
365 #[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 #[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 #[must_use]
386 pub fn with_paths(mut self, paths: Paths) -> Self {
387 self.paths = Some(paths);
388 self
389 }
390
391 pub fn paths(&self) -> Option<&Paths> {
393 self.paths.as_ref()
394 }
395
396 pub fn var(&self, key: &str) -> Option<&str> {
399 self.vars.get(key).map(String::as_str)
400 }
401
402 pub fn vault_addr(&self) -> Option<&str> {
404 self.var("VAULT_ADDR")
405 }
406
407 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 pub fn aws_session_token(&self) -> Option<&str> {
420 self.var("AWS_SESSION_TOKEN")
421 }
422
423 pub fn aws_credentials_file(&self) -> Option<PathBuf> {
426 self.relocatable_aws_file("AWS_SHARED_CREDENTIALS_FILE", Paths::aws_credentials_file)
427 }
428
429 pub fn aws_config_file(&self) -> Option<PathBuf> {
432 self.relocatable_aws_file("AWS_CONFIG_FILE", Paths::aws_config_file)
433 }
434
435 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 None => Some(PathBuf::from(raw)),
456 }
457 }
458
459 pub fn purple_token(&self) -> Option<&str> {
463 self.var("PURPLE_TOKEN")
464 .filter(|token| !token.trim().is_empty())
465 }
466
467 pub fn no_color(&self) -> bool {
469 self.vars.contains_key("NO_COLOR")
470 }
471
472 pub fn colorterm(&self) -> Option<&str> {
474 self.var("COLORTERM")
475 }
476
477 pub fn term_program(&self) -> Option<&str> {
479 self.var("TERM_PROGRAM")
480 }
481
482 pub fn term(&self) -> Option<&str> {
484 self.var("TERM")
485 }
486
487 pub fn in_tmux(&self) -> bool {
489 self.vars.contains_key("TMUX")
490 }
491
492 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 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
516impl 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 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 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 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 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 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 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 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 let env = Env::from_process();
884 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 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 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}