Skip to main content

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}