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 askpass_withheld_marker(&self, alias: &str) -> PathBuf {
254 let mut safe = String::with_capacity(alias.len());
255 for c in alias.chars() {
256 match c {
257 '%' => safe.push_str("%25"),
258 '/' => safe.push_str("%2f"),
259 '\\' => safe.push_str("%5c"),
260 '.' => safe.push_str("%2e"),
261 other => safe.push(other),
262 }
263 }
264 self.state_dir
265 .join(format!("{}{safe}", crate::askpass::WITHHELD_MARKER_PREFIX))
266 }
267
268 pub fn container_cache(&self) -> PathBuf {
270 self.cache_dir.join("container_cache.jsonl")
271 }
272
273 pub fn last_version_check(&self) -> PathBuf {
275 self.cache_dir.join("last_version_check")
276 }
277
278 pub fn aws_credentials_file(&self) -> PathBuf {
280 self.home.join(".aws").join("credentials")
281 }
282
283 pub fn aws_config_file(&self) -> PathBuf {
287 self.home.join(".aws").join("config")
288 }
289
290 pub fn ssh_dir(&self) -> PathBuf {
292 self.home.join(".ssh")
293 }
294
295 pub fn abbreviate_home(&self, path: &Path) -> String {
297 match path.strip_prefix(&self.home) {
298 Ok(rest) if !rest.as_os_str().is_empty() => format!("~/{}", rest.display()),
299 _ => path.display().to_string(),
300 }
301 }
302}
303
304fn resolve_category(
307 home: &Path,
308 category: Category,
309 var: &impl Fn(&str) -> Option<String>,
310) -> PathBuf {
311 if let Some(explicit) = var(category.purple_var()).filter(|v| !v.trim().is_empty()) {
312 return expand_home(home, &explicit);
313 }
314 if let Some(base) = var(category.xdg_var()).filter(|v| !v.trim().is_empty()) {
315 let base = PathBuf::from(base);
316 if base.is_absolute() {
317 return base.join(XDG_APP_DIR);
318 }
319 }
320 home.join(".purple")
321}
322
323fn expand_home(home: &Path, value: &str) -> PathBuf {
326 if ["~", "$HOME", "${HOME}"].contains(&value) {
327 return home.to_path_buf();
328 }
329 for prefix in ["~/", "$HOME/", "${HOME}/"] {
330 if let Some(rest) = value.strip_prefix(prefix) {
331 return home.join(rest);
332 }
333 }
334 PathBuf::from(value)
335}
336
337#[derive(Clone)]
342pub struct Env {
343 paths: Option<Paths>,
344 vars: HashMap<String, String>,
345 #[cfg(test)]
349 _sandbox: Option<std::sync::Arc<tempfile::TempDir>>,
350}
351
352impl Env {
353 fn new_inner(paths: Option<Paths>, vars: HashMap<String, String>) -> Self {
354 Self {
355 paths,
356 vars,
357 #[cfg(test)]
358 _sandbox: None,
359 }
360 }
361
362 pub fn from_process() -> Self {
367 let vars: HashMap<String, String> = std::env::vars().collect();
368 let paths = dirs::home_dir().map(|home| Paths::resolve(home, |k| vars.get(k).cloned()));
369 Self::new_inner(paths, vars)
370 }
371
372 pub fn for_test(home: impl Into<PathBuf>) -> Self {
376 Self::new_inner(Some(Paths::new(home)), HashMap::new())
377 }
378
379 pub fn empty() -> Self {
382 Self::new_inner(None, HashMap::new())
383 }
384
385 #[cfg(test)]
389 pub fn sandboxed() -> Self {
390 let dir = tempfile::tempdir().expect("create test sandbox tempdir");
391 let mut env = Self::for_test(dir.path());
392 env._sandbox = Some(std::sync::Arc::new(dir));
393 env
394 }
395
396 #[must_use]
398 pub fn with_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
399 self.vars.insert(key.into(), value.into());
400 self
401 }
402
403 #[must_use]
406 pub fn with_paths(mut self, paths: Paths) -> Self {
407 self.paths = Some(paths);
408 self
409 }
410
411 pub fn paths(&self) -> Option<&Paths> {
413 self.paths.as_ref()
414 }
415
416 pub fn var(&self, key: &str) -> Option<&str> {
419 self.vars.get(key).map(String::as_str)
420 }
421
422 pub fn vault_addr(&self) -> Option<&str> {
424 self.var("VAULT_ADDR")
425 }
426
427 pub fn aws_credentials(&self) -> Option<(&str, &str)> {
429 match (
430 self.var("AWS_ACCESS_KEY_ID"),
431 self.var("AWS_SECRET_ACCESS_KEY"),
432 ) {
433 (Some(id), Some(secret)) => Some((id, secret)),
434 _ => None,
435 }
436 }
437
438 pub fn aws_session_token(&self) -> Option<&str> {
440 self.var("AWS_SESSION_TOKEN")
441 }
442
443 pub fn aws_credentials_file(&self) -> Option<PathBuf> {
446 self.relocatable_aws_file("AWS_SHARED_CREDENTIALS_FILE", Paths::aws_credentials_file)
447 }
448
449 pub fn aws_config_file(&self) -> Option<PathBuf> {
452 self.relocatable_aws_file("AWS_CONFIG_FILE", Paths::aws_config_file)
453 }
454
455 fn relocatable_aws_file(&self, var: &str, default: fn(&Paths) -> PathBuf) -> Option<PathBuf> {
464 let Some(raw) = self.var(var) else {
465 return self.paths().map(default);
466 };
467 let raw = raw.trim();
468 if raw.is_empty() {
469 return None;
470 }
471 match self.paths() {
472 Some(paths) => Some(expand_home(paths.home(), raw)),
473 None => Some(PathBuf::from(raw)),
476 }
477 }
478
479 pub fn purple_token(&self) -> Option<&str> {
483 self.var("PURPLE_TOKEN")
484 .filter(|token| !token.trim().is_empty())
485 }
486
487 pub fn no_color(&self) -> bool {
489 self.vars.contains_key("NO_COLOR")
490 }
491
492 pub fn colorterm(&self) -> Option<&str> {
494 self.var("COLORTERM")
495 }
496
497 pub fn term_program(&self) -> Option<&str> {
499 self.var("TERM_PROGRAM")
500 }
501
502 pub fn term(&self) -> Option<&str> {
504 self.var("TERM")
505 }
506
507 pub fn in_tmux(&self) -> bool {
509 self.vars.contains_key("TMUX")
510 }
511
512 pub fn active_proxy_vars(&self) -> Vec<&'static str> {
515 ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"]
516 .into_iter()
517 .filter(|k| self.var(k).is_some_and(|v| !v.is_empty()))
518 .collect()
519 }
520
521 pub fn command(&self, program: &str) -> std::process::Command {
529 let mut cmd = std::process::Command::new(program);
530 cmd.env_clear();
531 cmd.envs(&self.vars);
532 cmd
533 }
534}
535
536impl std::fmt::Debug for Env {
539 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
540 let mut names: Vec<&str> = self.vars.keys().map(String::as_str).collect();
541 names.sort_unstable();
542 f.debug_struct("Env")
543 .field("home", &self.paths.as_ref().map(Paths::home))
544 .field("var_names", &names)
545 .finish()
546 }
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552
553 fn lookup<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
554 move |k| {
555 pairs
556 .iter()
557 .find(|(key, _)| *key == k)
558 .map(|(_, v)| (*v).to_string())
559 }
560 }
561
562 #[test]
563 fn paths_derive_under_purple_and_ssh() {
564 let p = Paths::new("/home/u");
565 assert_eq!(p.legacy_dir(), PathBuf::from("/home/u/.purple"));
566 assert_eq!(
567 p.preferences(),
568 PathBuf::from("/home/u/.purple/preferences")
569 );
570 assert_eq!(p.snippets_file(), PathBuf::from("/home/u/.purple/snippets"));
571 assert_eq!(
572 p.container_cache(),
573 PathBuf::from("/home/u/.purple/container_cache.jsonl")
574 );
575 assert_eq!(p.log_file(), PathBuf::from("/home/u/.purple/purple.log"));
576 assert_eq!(p.history(), PathBuf::from("/home/u/.purple/history.tsv"));
577 assert_eq!(
578 p.last_version_check(),
579 PathBuf::from("/home/u/.purple/last_version_check")
580 );
581 assert_eq!(p.certs_dir(), PathBuf::from("/home/u/.purple/certs"));
582 assert_eq!(
583 p.config_original(),
584 PathBuf::from("/home/u/.purple/config.original")
585 );
586 assert_eq!(
587 p.mcp_audit_log(),
588 PathBuf::from("/home/u/.purple/mcp-audit.log")
589 );
590 assert_eq!(p.ssh_dir(), PathBuf::from("/home/u/.ssh"));
591 assert!(!p.is_split());
592 }
593
594 #[test]
595 fn legacy_layout_puts_every_category_under_dot_purple() {
596 let p = Paths::new("/home/u");
597 for c in Category::ALL {
598 assert_eq!(p.dir(c), Path::new("/home/u/.purple"), "{c:?}");
599 }
600 }
601
602 #[test]
603 fn resolve_without_variables_is_the_legacy_layout() {
604 let resolved = Paths::resolve("/home/u", |_| None);
605 assert_eq!(resolved, Paths::new("/home/u"));
606 }
607
608 #[test]
609 fn resolve_honors_each_xdg_variable_independently() {
610 let vars = [("XDG_CONFIG_HOME", "/home/u/.config")];
611 let p = Paths::resolve("/home/u", lookup(&vars));
612 assert_eq!(p.config_dir(), Path::new("/home/u/.config/purple"));
613 assert_eq!(
614 p.preferences(),
615 PathBuf::from("/home/u/.config/purple/preferences")
616 );
617 assert_eq!(
618 p.themes_dir(),
619 PathBuf::from("/home/u/.config/purple/themes")
620 );
621 assert_eq!(p.data_dir(), Path::new("/home/u/.purple"));
623 assert_eq!(p.state_dir(), Path::new("/home/u/.purple"));
624 assert_eq!(p.cache_dir(), Path::new("/home/u/.purple"));
625 assert!(p.is_split());
626 }
627
628 #[test]
629 fn resolve_maps_every_file_to_its_category() {
630 let vars = [
631 ("XDG_CONFIG_HOME", "/x/cfg"),
632 ("XDG_DATA_HOME", "/x/data"),
633 ("XDG_STATE_HOME", "/x/state"),
634 ("XDG_CACHE_HOME", "/x/cache"),
635 ];
636 let p = Paths::resolve("/home/u", lookup(&vars));
637 assert_eq!(p.preferences(), PathBuf::from("/x/cfg/purple/preferences"));
638 assert_eq!(p.snippets_file(), PathBuf::from("/x/cfg/purple/snippets"));
639 assert_eq!(
640 p.providers_config(),
641 PathBuf::from("/x/cfg/purple/providers")
642 );
643 assert_eq!(p.themes_dir(), PathBuf::from("/x/cfg/purple/themes"));
644 assert_eq!(p.certs_dir(), PathBuf::from("/x/data/purple/certs"));
645 assert_eq!(
646 p.cert_for("web"),
647 PathBuf::from("/x/data/purple/certs/web-cert.pub")
648 );
649 assert_eq!(
650 p.config_original(),
651 PathBuf::from("/x/data/purple/config.original")
652 );
653 assert_eq!(p.history(), PathBuf::from("/x/state/purple/history.tsv"));
654 assert_eq!(p.recents(), PathBuf::from("/x/state/purple/recents.json"));
655 assert_eq!(
656 p.key_activity(),
657 PathBuf::from("/x/state/purple/key_activity.json")
658 );
659 assert_eq!(
660 p.snippet_runs(),
661 PathBuf::from("/x/state/purple/snippet_runs.json")
662 );
663 assert_eq!(
664 p.sync_history(),
665 PathBuf::from("/x/state/purple/sync_history.tsv")
666 );
667 assert_eq!(p.log_file(), PathBuf::from("/x/state/purple/purple.log"));
668 assert_eq!(
669 p.mcp_audit_log(),
670 PathBuf::from("/x/state/purple/mcp-audit.log")
671 );
672 assert_eq!(
673 p.askpass_marker("a"),
674 PathBuf::from("/x/state/purple/.askpass_a")
675 );
676 assert_eq!(
677 p.container_cache(),
678 PathBuf::from("/x/cache/purple/container_cache.jsonl")
679 );
680 assert_eq!(
681 p.last_version_check(),
682 PathBuf::from("/x/cache/purple/last_version_check")
683 );
684 assert_eq!(p.ssh_dir(), PathBuf::from("/home/u/.ssh"));
686 assert_eq!(
687 p.aws_credentials_file(),
688 PathBuf::from("/home/u/.aws/credentials")
689 );
690 assert_eq!(p.legacy_dir(), PathBuf::from("/home/u/.purple"));
691 }
692
693 #[test]
694 fn resolve_prefers_purple_override_over_xdg() {
695 let vars = [
696 ("PURPLE_CONFIG_DIR", "/srv/purple-cfg"),
697 ("XDG_CONFIG_HOME", "/home/u/.config"),
698 ];
699 let p = Paths::resolve("/home/u", lookup(&vars));
700 assert_eq!(p.config_dir(), Path::new("/srv/purple-cfg"));
702 }
703
704 #[test]
705 fn resolve_expands_home_in_purple_overrides() {
706 for value in ["~/purple-cfg", "$HOME/purple-cfg", "${HOME}/purple-cfg"] {
707 let vars = [("PURPLE_CONFIG_DIR", value)];
708 let p = Paths::resolve("/home/u", lookup(&vars));
709 assert_eq!(p.config_dir(), Path::new("/home/u/purple-cfg"), "{value}");
710 }
711 }
712
713 #[test]
714 fn resolve_treats_empty_variables_as_unset() {
715 let vars = [
716 ("PURPLE_STATE_DIR", ""),
717 ("XDG_STATE_HOME", " "),
718 ("XDG_CACHE_HOME", ""),
719 ];
720 let p = Paths::resolve("/home/u", lookup(&vars));
721 assert_eq!(p.state_dir(), Path::new("/home/u/.purple"));
722 assert_eq!(p.cache_dir(), Path::new("/home/u/.purple"));
723 }
724
725 #[test]
726 fn resolve_ignores_relative_xdg_paths() {
727 let vars = [("XDG_DATA_HOME", "relative/share")];
729 let p = Paths::resolve("/home/u", lookup(&vars));
730 assert_eq!(p.data_dir(), Path::new("/home/u/.purple"));
731 }
732
733 #[test]
734 fn resolve_empty_purple_override_falls_through_to_xdg() {
735 let vars = [
736 ("PURPLE_DATA_DIR", ""),
737 ("XDG_DATA_HOME", "/home/u/.local/share"),
738 ];
739 let p = Paths::resolve("/home/u", lookup(&vars));
740 assert_eq!(p.data_dir(), Path::new("/home/u/.local/share/purple"));
741 }
742
743 #[test]
744 fn category_variable_names() {
745 assert_eq!(Category::Config.purple_var(), "PURPLE_CONFIG_DIR");
746 assert_eq!(Category::Data.xdg_var(), "XDG_DATA_HOME");
747 assert_eq!(Category::State.purple_var(), "PURPLE_STATE_DIR");
748 assert_eq!(Category::Cache.xdg_var(), "XDG_CACHE_HOME");
749 assert_eq!(Category::State.name(), "state");
750 }
751
752 #[test]
753 fn abbreviate_home_swaps_the_home_prefix_for_a_tilde() {
754 let p = Paths::new("/home/u");
755 assert_eq!(
756 p.abbreviate_home(&p.config_original()),
757 "~/.purple/config.original"
758 );
759 assert_eq!(p.abbreviate_home(Path::new("/srv/x/y")), "/srv/x/y");
760 assert_eq!(p.abbreviate_home(Path::new("/home/u")), "/home/u");
761 }
762
763 #[test]
764 fn cert_for_uses_alias_filename() {
765 let p = Paths::new("/home/u");
766 assert_eq!(
767 p.cert_for("web-1"),
768 PathBuf::from("/home/u/.purple/certs/web-1-cert.pub")
769 );
770 }
771
772 #[test]
773 fn askpass_marker_sanitises_traversal_chars() {
774 let p = Paths::new("/home/u");
775 assert_eq!(
776 p.askpass_marker("a/b\\c.d"),
777 PathBuf::from("/home/u/.purple/.askpass_a_b_c_d")
778 );
779 }
780
781 #[test]
782 fn askpass_withheld_marker_keeps_each_alias_to_itself() {
783 let p = Paths::new("/home/u");
786 assert_ne!(
787 p.askpass_withheld_marker("web.prod"),
788 p.askpass_withheld_marker("web_prod")
789 );
790 assert_eq!(
791 p.askpass_withheld_marker("web.prod"),
792 PathBuf::from("/home/u/.purple/.askpass_withheld.web%2eprod")
793 );
794 }
795
796 #[test]
797 fn askpass_withheld_marker_cannot_walk_out_of_the_state_dir() {
798 let p = Paths::new("/home/u");
799 assert_eq!(
800 p.askpass_withheld_marker("../../etc/passwd"),
801 PathBuf::from("/home/u/.purple/.askpass_withheld.%2e%2e%2f%2e%2e%2fetc%2fpasswd")
802 );
803 }
804
805 #[test]
806 fn askpass_withheld_marker_never_collides_with_a_retry_marker() {
807 let p = Paths::new("/home/u");
810 assert_ne!(
811 p.askpass_marker("withheld.x"),
812 p.askpass_withheld_marker("x")
813 );
814 }
815
816 #[test]
817 fn for_test_has_paths_and_no_vars() {
818 let env = Env::for_test("/tmp/x");
819 assert_eq!(env.paths().unwrap().home(), Path::new("/tmp/x"));
820 assert_eq!(env.var("ANYTHING"), None);
821 assert!(!env.no_color());
822 }
823
824 #[test]
825 fn with_paths_replaces_the_layout() {
826 let split = Paths::resolve("/tmp/x", lookup(&[("XDG_CACHE_HOME", "/tmp/c")]));
827 let env = Env::for_test("/tmp/x").with_paths(split.clone());
828 assert_eq!(env.paths(), Some(&split));
829 assert_eq!(env.paths().unwrap().cache_dir(), Path::new("/tmp/c/purple"));
830 }
831
832 #[test]
833 fn empty_has_no_paths() {
834 let env = Env::empty();
835 assert!(env.paths().is_none());
836 }
837
838 #[test]
839 fn sandboxed_gives_isolated_existing_dirs() {
840 let a = Env::sandboxed();
841 let b = Env::sandboxed();
842 let pa = a.paths().unwrap().home().to_path_buf();
843 let pb = b.paths().unwrap().home().to_path_buf();
844 assert_ne!(pa, pb, "each sandbox is a distinct directory");
845 assert!(pa.exists(), "sandbox home exists for the Env's lifetime");
846 let prefs = a.paths().unwrap().preferences();
848 crate::fs_util::atomic_write(&prefs, b"theme=Purple\n").unwrap();
849 assert_eq!(std::fs::read_to_string(&prefs).unwrap(), "theme=Purple\n");
850 }
851
852 #[test]
853 fn with_var_sets_typed_accessors() {
854 let env = Env::for_test("/tmp/x")
855 .with_var("VAULT_ADDR", "https://vault.example:8200")
856 .with_var("COLORTERM", "truecolor")
857 .with_var("NO_COLOR", "1")
858 .with_var("TMUX", "/tmp/tmux-1000/default,1,0");
859 assert_eq!(env.vault_addr(), Some("https://vault.example:8200"));
860 assert_eq!(env.colorterm(), Some("truecolor"));
861 assert!(env.no_color());
862 assert!(env.in_tmux());
863 }
864
865 #[test]
866 fn aws_credentials_require_both_keys() {
867 let only_id = Env::for_test("/tmp/x").with_var("AWS_ACCESS_KEY_ID", "AKIA");
868 assert_eq!(only_id.aws_credentials(), None);
869 let both = only_id.with_var("AWS_SECRET_ACCESS_KEY", "secret");
870 assert_eq!(both.aws_credentials(), Some(("AKIA", "secret")));
871 }
872
873 #[test]
874 fn purple_token_treats_a_blank_variable_as_absent() {
875 assert_eq!(Env::for_test("/tmp/x").purple_token(), None);
879 for blank in ["", " "] {
880 assert_eq!(
881 Env::for_test("/tmp/x")
882 .with_var("PURPLE_TOKEN", blank)
883 .purple_token(),
884 None,
885 "a blank PURPLE_TOKEN must read as absent"
886 );
887 }
888 assert_eq!(
889 Env::for_test("/tmp/x")
890 .with_var("PURPLE_TOKEN", "tok")
891 .purple_token(),
892 Some("tok")
893 );
894 }
895
896 #[test]
897 fn aws_session_token_reads_env_var() {
898 let env = Env::for_test("/tmp/x");
899 assert_eq!(env.aws_session_token(), None);
900 let with_token = env.with_var("AWS_SESSION_TOKEN", "TOKEN");
901 assert_eq!(with_token.aws_session_token(), Some("TOKEN"));
902 }
903
904 #[test]
905 fn aws_session_token_is_independent_of_key_pair() {
906 let env = Env::for_test("/tmp/x").with_var("AWS_SESSION_TOKEN", "TOKEN");
909 assert_eq!(env.aws_credentials(), None);
910 assert_eq!(env.aws_session_token(), Some("TOKEN"));
911 }
912
913 #[test]
914 fn active_proxy_vars_filters_empty_and_orders() {
915 let env = Env::for_test("/tmp/x")
916 .with_var("HTTPS_PROXY", "http://proxy:3128")
917 .with_var("HTTP_PROXY", "")
918 .with_var("NO_PROXY", "localhost");
919 assert_eq!(env.active_proxy_vars(), vec!["HTTPS_PROXY", "NO_PROXY"]);
920 }
921
922 #[test]
923 fn debug_redacts_secret_values() {
924 let env = Env::for_test("/tmp/x")
925 .with_var("PURPLE_TOKEN", "super-secret")
926 .with_var("VAULT_ADDR", "https://vault.example:8200");
927 let rendered = format!("{env:?}");
928 assert!(!rendered.contains("super-secret"));
929 assert!(!rendered.contains("vault.example"));
930 assert!(rendered.contains("PURPLE_TOKEN"));
931 assert!(rendered.contains("VAULT_ADDR"));
932 }
933
934 #[test]
935 fn from_process_captures_home_and_vars() {
936 let env = Env::from_process();
939 let _ = env.paths();
942 let _ = env.var("PATH");
943 }
944 #[test]
945 fn the_aws_files_default_to_the_dot_aws_directory() {
946 let env = Env::for_test("/home/u");
947 assert_eq!(
948 env.aws_credentials_file(),
949 Some(PathBuf::from("/home/u/.aws/credentials"))
950 );
951 assert_eq!(
952 env.aws_config_file(),
953 Some(PathBuf::from("/home/u/.aws/config"))
954 );
955 }
956
957 #[test]
958 fn an_absolute_override_relocates_the_aws_file() {
959 let env = Env::for_test("/home/u")
960 .with_var("AWS_SHARED_CREDENTIALS_FILE", "/etc/aws/creds")
961 .with_var("AWS_CONFIG_FILE", "/etc/aws/conf");
962 assert_eq!(
963 env.aws_credentials_file(),
964 Some(PathBuf::from("/etc/aws/creds"))
965 );
966 assert_eq!(env.aws_config_file(), Some(PathBuf::from("/etc/aws/conf")));
967 }
968
969 #[test]
970 fn a_home_relative_override_expands_against_the_home_directory() {
971 let env = Env::for_test("/home/u")
974 .with_var("AWS_SHARED_CREDENTIALS_FILE", "~/work/creds")
975 .with_var("AWS_CONFIG_FILE", "${HOME}/work/conf");
976 assert_eq!(
977 env.aws_credentials_file(),
978 Some(PathBuf::from("/home/u/work/creds"))
979 );
980 assert_eq!(
981 env.aws_config_file(),
982 Some(PathBuf::from("/home/u/work/conf"))
983 );
984 }
985
986 #[test]
987 fn a_bare_tilde_override_is_the_home_directory() {
988 let env = Env::for_test("/home/u").with_var("AWS_CONFIG_FILE", "~");
989 assert_eq!(env.aws_config_file(), Some(PathBuf::from("/home/u")));
990 }
991
992 #[test]
993 fn an_exported_but_blank_override_means_no_file() {
994 let env = Env::for_test("/home/u")
997 .with_var("AWS_SHARED_CREDENTIALS_FILE", "")
998 .with_var("AWS_CONFIG_FILE", " ");
999 assert_eq!(env.aws_credentials_file(), None);
1000 assert_eq!(env.aws_config_file(), None);
1001 }
1002
1003 #[test]
1004 fn without_a_home_directory_only_an_override_names_an_aws_file() {
1005 assert_eq!(Env::empty().aws_credentials_file(), None);
1006 assert_eq!(
1007 Env::empty()
1008 .with_var("AWS_CONFIG_FILE", "/etc/aws/conf")
1009 .aws_config_file(),
1010 Some(PathBuf::from("/etc/aws/conf"))
1011 );
1012 }
1013}