socket_patch_core/utils/env_compat.rs
1//! Legacy → new env-var compatibility shim.
2//!
3//! The v3.0 CLI surface migrated three env vars from the `SOCKET_PATCH_*`
4//! prefix to the unified `SOCKET_*` prefix:
5//!
6//! | New | Legacy |
7//! |------------------------------|-------------------------------------|
8//! | `SOCKET_PROXY_URL` | `SOCKET_PATCH_PROXY_URL` |
9//! | `SOCKET_DEBUG` | `SOCKET_PATCH_DEBUG` |
10//! | `SOCKET_TELEMETRY_DISABLED` | `SOCKET_PATCH_TELEMETRY_DISABLED` |
11//!
12//! `read_env_with_legacy` reads the new name; if absent, it falls back to the
13//! legacy name and prints a one-shot deprecation warning to stderr. The
14//! warning fires **unconditionally** — even under `--silent` / `--json` — so
15//! users see the transition signal in scripts and CI logs. The legacy names
16//! will be removed in the next major release.
17
18use std::collections::HashSet;
19use std::sync::Mutex;
20
21use once_cell::sync::Lazy;
22
23/// Names of legacy env vars that have already warned in this process. Used
24/// so each legacy var warns at most once per invocation, even when read
25/// from multiple call sites.
26static WARNED: Lazy<Mutex<HashSet<&'static str>>> = Lazy::new(|| Mutex::new(HashSet::new()));
27
28/// Read the new-style env var `new_name`. If absent, fall back to
29/// `legacy_name` and print a one-shot deprecation warning to stderr (the
30/// warning fires regardless of CLI verbosity flags so users notice the
31/// transition).
32///
33/// Returns `None` when neither name is set (or both are set to an empty
34/// string, matching the prior call sites' filtering).
35pub fn read_env_with_legacy(new_name: &'static str, legacy_name: &'static str) -> Option<String> {
36 if let Ok(v) = std::env::var(new_name) {
37 if !v.is_empty() {
38 return Some(v);
39 }
40 }
41 match std::env::var(legacy_name) {
42 Ok(v) if !v.is_empty() => {
43 warn_legacy_once(legacy_name, new_name);
44 Some(v)
45 }
46 _ => None,
47 }
48}
49
50/// Print a one-shot deprecation warning. Public so callers that read the
51/// legacy name through other code paths (e.g. clap's `env =` attribute,
52/// which reads only the new name) can still surface the deprecation when
53/// they detect the legacy name was set.
54pub fn warn_legacy_once(legacy_name: &'static str, new_name: &'static str) {
55 let mut warned = match WARNED.lock() {
56 Ok(g) => g,
57 Err(poisoned) => poisoned.into_inner(),
58 };
59 if warned.insert(legacy_name) {
60 eprintln!(
61 "[socket-patch] warning: env var `{legacy_name}` is deprecated; \
62 use `{new_name}` instead. The legacy name will be removed in a \
63 future major release."
64 );
65 }
66}
67
68/// Renamed env vars whose legacy `SOCKET_PATCH_*` names are still honored.
69///
70/// First entry of each tuple is the new name (what clap and current code
71/// read); second is the legacy name that gets a deprecation warning.
72pub const LEGACY_ENV_RENAMES: &[(&str, &str)] = &[
73 ("SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"),
74 ("SOCKET_DEBUG", "SOCKET_PATCH_DEBUG"),
75 (
76 "SOCKET_TELEMETRY_DISABLED",
77 "SOCKET_PATCH_TELEMETRY_DISABLED",
78 ),
79];
80
81/// Promote legacy `SOCKET_PATCH_*` env vars to their new `SOCKET_*` names
82/// in-process. When the new name is unset and the legacy name is set, copy
83/// the value over and emit a one-shot deprecation warning to stderr.
84///
85/// Call this *once*, very early in `main`, before clap parses. After
86/// promotion every downstream reader (clap `env =`, core code) only needs
87/// to know the new name.
88///
89/// The warning fires unconditionally — even under `--silent` / `--json`
90/// — so the transition signal isn't swallowed in CI logs.
91pub fn promote_legacy_env_vars() {
92 for (new_name, legacy_name) in LEGACY_ENV_RENAMES {
93 let new_already_set = std::env::var(new_name)
94 .ok()
95 .filter(|v| !v.is_empty())
96 .is_some();
97 if new_already_set {
98 continue;
99 }
100 if let Ok(value) = std::env::var(legacy_name) {
101 if !value.is_empty() {
102 warn_legacy_once(legacy_name, new_name);
103 std::env::set_var(new_name, value);
104 }
105 }
106 }
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112
113 /// The warning bookkeeping is process-global, so tests must use env-var
114 /// names that no other test touches. `std::env` serializes access behind
115 /// an internal lock, so distinct names never race for memory safety; the
116 /// only hazard is two tests fighting over the *same* name, which unique
117 /// names avoid.
118 #[test]
119 fn warn_legacy_once_fires_only_once_per_name() {
120 let name = "SOCKET_TEST_LEGACY_ONCE_PATCH";
121 let new = "SOCKET_TEST_LEGACY_ONCE";
122 warn_legacy_once(name, new);
123 warn_legacy_once(name, new);
124 // The dedup is driven by `HashSet::insert` returning `false` once the
125 // name has been recorded. Prove that directly: after `warn_legacy_once`
126 // ran, re-inserting the same name must report "already present", which
127 // is exactly what suppresses any second eprintln.
128 let mut warned = WARNED.lock().unwrap();
129 assert!(warned.contains(name));
130 assert!(
131 !warned.insert(name),
132 "name should already be recorded, so a second warning is suppressed"
133 );
134 }
135
136 #[test]
137 fn read_env_prefers_new_var_over_legacy() {
138 const NEW: &str = "SOCKET_TEST_READ_PREFERS_NEW";
139 const LEGACY: &str = "SOCKET_TEST_READ_PREFERS_NEW_PATCH";
140 std::env::set_var(NEW, "new-value");
141 std::env::set_var(LEGACY, "legacy-value");
142 assert_eq!(
143 read_env_with_legacy(NEW, LEGACY),
144 Some("new-value".to_string())
145 );
146 std::env::remove_var(NEW);
147 std::env::remove_var(LEGACY);
148 }
149
150 #[test]
151 fn read_env_falls_back_to_legacy_when_new_unset() {
152 const NEW: &str = "SOCKET_TEST_READ_FALLBACK_NEW";
153 const LEGACY: &str = "SOCKET_TEST_READ_FALLBACK_NEW_PATCH";
154 std::env::remove_var(NEW);
155 std::env::set_var(LEGACY, "legacy-value");
156 assert_eq!(
157 read_env_with_legacy(NEW, LEGACY),
158 Some("legacy-value".to_string())
159 );
160 std::env::remove_var(LEGACY);
161 }
162
163 /// Regression: an empty new var must be treated as "unset" and fall back to
164 /// the legacy name, matching the prior call sites' `!is_empty()` filtering.
165 #[test]
166 fn read_env_empty_new_falls_back_to_legacy() {
167 const NEW: &str = "SOCKET_TEST_READ_EMPTY_NEW";
168 const LEGACY: &str = "SOCKET_TEST_READ_EMPTY_NEW_PATCH";
169 std::env::set_var(NEW, "");
170 std::env::set_var(LEGACY, "legacy-value");
171 assert_eq!(
172 read_env_with_legacy(NEW, LEGACY),
173 Some("legacy-value".to_string())
174 );
175 std::env::remove_var(NEW);
176 std::env::remove_var(LEGACY);
177 }
178
179 #[test]
180 fn read_env_none_when_neither_set() {
181 const NEW: &str = "SOCKET_TEST_READ_NONE_NEW";
182 const LEGACY: &str = "SOCKET_TEST_READ_NONE_NEW_PATCH";
183 std::env::remove_var(NEW);
184 std::env::remove_var(LEGACY);
185 assert_eq!(read_env_with_legacy(NEW, LEGACY), None);
186 }
187
188 /// Regression: both names set but empty → `None` (empty == unset on both
189 /// sides), per the documented contract.
190 #[test]
191 fn read_env_none_when_both_empty() {
192 const NEW: &str = "SOCKET_TEST_READ_BOTH_EMPTY_NEW";
193 const LEGACY: &str = "SOCKET_TEST_READ_BOTH_EMPTY_NEW_PATCH";
194 std::env::set_var(NEW, "");
195 std::env::set_var(LEGACY, "");
196 assert_eq!(read_env_with_legacy(NEW, LEGACY), None);
197 std::env::remove_var(NEW);
198 std::env::remove_var(LEGACY);
199 }
200}