Skip to main content

modelexpress_common/
envs.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Centralized registry of every environment variable the workspace reads.
5//!
6//! Inspired by vLLM's `envs.py`: this module is the single source of truth for
7//! env-var *names* (the `pub const` block) and provides typed *getters* that
8//! encapsulate defaults, fallback chains, and parsing. Both ModelExpress-owned
9//! variables (`MODEL_EXPRESS_*`, `MX_*`) and third-party variables the code
10//! depends on (`HF_*`, `NGC_*`, `REDIS_*`, `POD_NAMESPACE`, `HOME`) live here.
11//!
12//! # Conventions
13//! - Getters read `std::env` on **every** call and never cache. Some callers
14//!   (and tests via [`crate::test_support::EnvVarGuard`]) mutate the process
15//!   environment at runtime, so a cached value would go stale.
16//! - The name constants are referenced directly from clap `#[arg(env = ...)]`
17//!   attributes so the CLI and the getters can never drift apart.
18//!
19//! # Not covered here
20//! - `CARGO_PKG_VERSION` is read at compile time via the `env!` macro in
21//!   `modelexpress_server::services`; it is not a runtime variable.
22//! - `HF_ENDPOINT` is read directly by the `hf_hub` crate (via
23//!   `ApiBuilder::from_env`); ModelExpress only sets it in tests. Its name is
24//!   registered below for reference.
25
26use crate::Error;
27use crate::constants;
28use std::env;
29use std::path::PathBuf;
30
31// ── Config-loader prefix ────────────────────────────────────────────────────
32/// Prefix consumed by the `config` crate's `Environment` source in
33/// [`crate::config::load_layered_config`] (env vars like `MODEL_EXPRESS_*`
34/// override matching config-file fields).
35pub const MODEL_EXPRESS_PREFIX: &str = "MODEL_EXPRESS";
36
37// ── ModelExpress-owned variables ────────────────────────────────────────────
38/// Client server endpoint (`ClientArgs::endpoint`).
39pub const MODEL_EXPRESS_ENDPOINT: &str = "MODEL_EXPRESS_ENDPOINT";
40/// Client request timeout in seconds (`ClientArgs::timeout`).
41pub const MODEL_EXPRESS_TIMEOUT: &str = "MODEL_EXPRESS_TIMEOUT";
42/// Local model cache directory (client, server, and both providers).
43pub const MODEL_EXPRESS_CACHE_DIRECTORY: &str = "MODEL_EXPRESS_CACHE_DIRECTORY";
44/// Log level (client and server).
45pub const MODEL_EXPRESS_LOG_LEVEL: &str = "MODEL_EXPRESS_LOG_LEVEL";
46/// Log output format (client and server).
47pub const MODEL_EXPRESS_LOG_FORMAT: &str = "MODEL_EXPRESS_LOG_FORMAT";
48/// Disable shared-storage mode (`ClientArgs::no_shared_storage`).
49pub const MODEL_EXPRESS_NO_SHARED_STORAGE: &str = "MODEL_EXPRESS_NO_SHARED_STORAGE";
50/// File-transfer chunk size in bytes (`ClientArgs::transfer_chunk_size`).
51pub const MODEL_EXPRESS_TRANSFER_CHUNK_SIZE: &str = "MODEL_EXPRESS_TRANSFER_CHUNK_SIZE";
52/// gRPC server listen port (`ServerArgs::port`).
53pub const MODEL_EXPRESS_SERVER_PORT: &str = "MODEL_EXPRESS_SERVER_PORT";
54/// Server host/bind address (`ServerArgs::host`).
55pub const MODEL_EXPRESS_SERVER_HOST: &str = "MODEL_EXPRESS_SERVER_HOST";
56/// Prometheus `/metrics` listen port (`ServerArgs::metrics_port`).
57///
58/// Read **only** through the clap `#[arg(env = ...)]` attribute, never through
59/// the layered config loader. [`crate::config::load_layered_config`] builds its
60/// environment source as `Environment::with_prefix("MODEL_EXPRESS").separator("_")`,
61/// so this name resolves to the key path `server.metrics.port` — which no field
62/// matches — and serde drops it without a warning. The clap override is the only
63/// path that reaches `ServerSettings::metrics_port`.
64pub const MODEL_EXPRESS_SERVER_METRICS_PORT: &str = "MODEL_EXPRESS_SERVER_METRICS_PORT";
65/// Toggle the background cache-eviction sweeper (`ServerArgs::cache_eviction_enabled`).
66pub const MODEL_EXPRESS_CACHE_EVICTION_ENABLED: &str = "MODEL_EXPRESS_CACHE_EVICTION_ENABLED";
67/// Server endpoint used by the cache module's default-endpoint helper.
68pub const MODEL_EXPRESS_SERVER_ENDPOINT: &str = "MODEL_EXPRESS_SERVER_ENDPOINT";
69
70// ── Metrics ─────────────────────────────────────────────────────────────────
71/// Benchmark run label carried by `mx_build_info`.
72///
73/// Shared verbatim with the Python client's `MX_METRICS_SCHEME`, so one run
74/// label covers both halves of a deployment. Note the asymmetry: the server
75/// carries it only on `mx_build_info`, while the client also carries it on every
76/// `mx_p2p_*` family. It is process-constant either way, so consolidating the
77/// client onto `mx_build_info` alone is a follow-on taxonomy change, not
78/// something to assume has already happened.
79pub const MX_METRICS_SCHEME: &str = "MX_METRICS_SCHEME";
80
81// ── HuggingFace ─────────────────────────────────────────────────────────────
82/// HuggingFace Hub auth token.
83pub const HF_TOKEN: &str = "HF_TOKEN";
84/// HuggingFace Hub cache directory.
85pub const HF_HUB_CACHE: &str = "HF_HUB_CACHE";
86/// Enables HuggingFace offline mode.
87pub const HF_HUB_OFFLINE: &str = "HF_HUB_OFFLINE";
88/// HuggingFace Hub endpoint override. Read directly by the `hf_hub` crate;
89/// registered here for reference (ModelExpress only sets it in tests).
90pub const HF_ENDPOINT: &str = "HF_ENDPOINT";
91
92// ── NGC ─────────────────────────────────────────────────────────────────────
93/// Base URL for the NGC artifact/download API.
94pub const NGC_API_ENDPOINT: &str = "NGC_API_ENDPOINT";
95/// Base URL for the NGC authentication endpoint.
96pub const NGC_AUTH_ENDPOINT: &str = "NGC_AUTH_ENDPOINT";
97/// NGC API key.
98pub const NGC_API_KEY: &str = "NGC_API_KEY";
99/// Alternate NGC CLI API key.
100pub const NGC_CLI_API_KEY: &str = "NGC_CLI_API_KEY";
101/// Root directory used to locate the NGC CLI config file (`~/.ngc/config`).
102pub const NGC_CLI_HOME: &str = "NGC_CLI_HOME";
103
104/// Default NGC API base URL when [`NGC_API_ENDPOINT`] is unset.
105pub const DEFAULT_NGC_API_BASE: &str = "https://api.ngc.nvidia.com";
106/// Default NGC auth base URL when [`NGC_AUTH_ENDPOINT`] is unset.
107pub const DEFAULT_NGC_AUTHN_BASE: &str = "https://authn.nvidia.com";
108
109// ── Redis / metadata backend (server) ───────────────────────────────────────
110/// Selects the metadata backend implementation (`redis`, `kubernetes`, `memory`).
111pub const MX_METADATA_BACKEND: &str = "MX_METADATA_BACKEND";
112/// Full Redis connection URL for the redis metadata backend.
113pub const REDIS_URL: &str = "REDIS_URL";
114/// Redis host (preferred) when building the URL from host + port.
115pub const MX_REDIS_HOST: &str = "MX_REDIS_HOST";
116/// Redis host alias for charts predating the `MX_` prefix.
117pub const REDIS_HOST: &str = "REDIS_HOST";
118/// Redis port (preferred) when building the URL from host + port.
119pub const MX_REDIS_PORT: &str = "MX_REDIS_PORT";
120/// Redis port alias for charts predating the `MX_` prefix.
121pub const REDIS_PORT: &str = "REDIS_PORT";
122/// Kubernetes namespace for ModelCacheEntry CRs (overrides [`POD_NAMESPACE`]).
123pub const MX_METADATA_NAMESPACE: &str = "MX_METADATA_NAMESPACE";
124/// Kubernetes namespace injected via the downward API for in-cluster pods.
125pub const POD_NAMESPACE: &str = "POD_NAMESPACE";
126
127// ── Reaper (server) ─────────────────────────────────────────────────────────
128/// Interval (seconds) between reaper scans for stale/GC worker sweeps.
129pub const MX_REAPER_SCAN_INTERVAL_SECS: &str = "MX_REAPER_SCAN_INTERVAL_SECS";
130/// Interval (seconds) between registry-statistics refresh passes.
131pub const MX_REGISTRY_STATS_INTERVAL_SECS: &str = "MX_REGISTRY_STATS_INTERVAL_SECS";
132/// Age (seconds) after which an active worker's heartbeat is considered stale.
133pub const MX_HEARTBEAT_TIMEOUT_SECS: &str = "MX_HEARTBEAT_TIMEOUT_SECS";
134/// Age (seconds) after which a STALE worker is garbage-collected.
135pub const MX_GC_TIMEOUT_SECS: &str = "MX_GC_TIMEOUT_SECS";
136
137// ── Security / auth (server) ────────────────────────────────────────────────
138/// ServiceAccount auth mode (`off`, `enforce`). Off by default.
139pub const MODEL_EXPRESS_SECURITY_MODE: &str = "MODEL_EXPRESS_SECURITY_MODE";
140/// Comma-separated SA token audiences the caller's token must carry.
141pub const MODEL_EXPRESS_SECURITY_TOKEN_AUDIENCES: &str = "MODEL_EXPRESS_SECURITY_TOKEN_AUDIENCES";
142/// Comma-separated allowed callers as `<namespace>:<serviceaccount>`.
143pub const MODEL_EXPRESS_SECURITY_ALLOWED_SERVICE_ACCOUNTS: &str =
144    "MODEL_EXPRESS_SECURITY_ALLOWED_SERVICE_ACCOUNTS";
145/// TTL for the verified-token and rejection caches, in seconds.
146pub const MODEL_EXPRESS_SECURITY_CACHE_TTL_SECS: &str = "MODEL_EXPRESS_SECURITY_CACHE_TTL_SECS";
147
148// ── Auth (client) ───────────────────────────────────────────────────────────
149/// Path to the Kubernetes projected ServiceAccount token file.
150pub const MX_AUTH_TOKEN_PATH: &str = "MX_AUTH_TOKEN_PATH";
151/// TTL in seconds for the cached token.
152pub const MX_AUTH_TOKEN_TTL_SECONDS: &str = "MX_AUTH_TOKEN_TTL_SECONDS";
153
154// ── System ──────────────────────────────────────────────────────────────────
155/// Primary source for the user's home directory.
156pub const HOME: &str = "HOME";
157/// Windows fallback for the home directory when [`HOME`] is unset.
158pub const USERPROFILE: &str = "USERPROFILE";
159/// Path to a kubeconfig file (consumed by the k8s integration tests).
160pub const KUBECONFIG: &str = "KUBECONFIG";
161
162// ── Default reaper timings ───────────────────────────────────────────────────
163const DEFAULT_REAPER_SCAN_INTERVAL_SECS: u64 = 30;
164/// Default registry-statistics refresh interval. Each pass walks the keyspace,
165/// so this is deliberately coarser than a scrape: the gauges it writes change
166/// on the timescale of downloads, not of scrapes.
167const DEFAULT_REGISTRY_STATS_INTERVAL_SECS: u64 = 60;
168const DEFAULT_HEARTBEAT_TIMEOUT_SECS: u64 = 90;
169const DEFAULT_GC_TIMEOUT_SECS: u64 = 3600;
170
171// ── Getters ───────────────────────────────────────────────────────────────
172
173/// Resolve the user's home directory: [`HOME`], then [`USERPROFILE`].
174///
175/// # Errors
176/// Returns an error when neither variable is set.
177pub fn home_dir() -> std::result::Result<String, Box<Error>> {
178    env::var(HOME)
179        .or_else(|_| env::var(USERPROFILE))
180        .map_err(|e| Error::Generic(format!("Failed to get home directory: {e}")).into())
181}
182
183/// Home directory as a `PathBuf`, falling back to `.` when unresolved.
184pub fn home_dir_or_cwd() -> PathBuf {
185    PathBuf::from(home_dir().unwrap_or_else(|_| ".".to_string()))
186}
187
188/// Model cache directory override from [`MODEL_EXPRESS_CACHE_DIRECTORY`].
189pub fn cache_directory() -> Option<PathBuf> {
190    env::var(MODEL_EXPRESS_CACHE_DIRECTORY)
191        .ok()
192        .map(PathBuf::from)
193}
194
195/// Default server gRPC endpoint: [`MODEL_EXPRESS_SERVER_ENDPOINT`] or
196/// `http://localhost:{DEFAULT_GRPC_PORT}`. Not normalized.
197pub fn server_endpoint_or_default() -> String {
198    env::var(MODEL_EXPRESS_SERVER_ENDPOINT)
199        .unwrap_or_else(|_| format!("http://localhost:{}", constants::DEFAULT_GRPC_PORT))
200}
201
202/// Benchmark run label from [`MX_METRICS_SCHEME`]; empty string when unset.
203///
204/// Empty is a valid value, not a missing one: outside a benchmark there is no
205/// scheme, and `mx_build_info` still needs its one series.
206pub fn metrics_scheme() -> String {
207    env::var(MX_METRICS_SCHEME).unwrap_or_default()
208}
209
210/// HuggingFace Hub token from [`HF_TOKEN`].
211pub fn hf_token() -> Option<String> {
212    env::var(HF_TOKEN).ok()
213}
214
215/// HuggingFace Hub cache directory from [`HF_HUB_CACHE`].
216pub fn hf_hub_cache() -> Option<PathBuf> {
217    env::var(HF_HUB_CACHE).ok().map(PathBuf::from)
218}
219
220/// Whether HuggingFace offline mode is enabled via [`HF_HUB_OFFLINE`].
221/// Enabled when the value is one of `1`, `ON`, `YES`, `TRUE` (case-insensitive).
222pub fn hf_offline() -> bool {
223    env::var(HF_HUB_OFFLINE)
224        .map(|v| matches!(v.to_uppercase().as_str(), "1" | "ON" | "YES" | "TRUE"))
225        .unwrap_or(false)
226}
227
228/// NGC API base URL: [`NGC_API_ENDPOINT`] or [`DEFAULT_NGC_API_BASE`].
229pub fn ngc_api_base() -> String {
230    env::var(NGC_API_ENDPOINT).unwrap_or_else(|_| DEFAULT_NGC_API_BASE.to_string())
231}
232
233/// NGC auth base URL: [`NGC_AUTH_ENDPOINT`] or [`DEFAULT_NGC_AUTHN_BASE`].
234pub fn ngc_authn_base() -> String {
235    env::var(NGC_AUTH_ENDPOINT).unwrap_or_else(|_| DEFAULT_NGC_AUTHN_BASE.to_string())
236}
237
238/// NGC API key from [`NGC_API_KEY`], then [`NGC_CLI_API_KEY`].
239/// Returns the first non-empty, trimmed value found.
240pub fn ngc_api_key() -> Option<String> {
241    for var in [NGC_API_KEY, NGC_CLI_API_KEY] {
242        if let Ok(v) = env::var(var) {
243            let trimmed = v.trim().to_string();
244            if !trimmed.is_empty() {
245                return Some(trimmed);
246            }
247        }
248    }
249    None
250}
251
252/// Root directory for the NGC CLI config from [`NGC_CLI_HOME`].
253pub fn ngc_cli_home() -> Option<PathBuf> {
254    env::var(NGC_CLI_HOME).ok().map(PathBuf::from)
255}
256
257/// Raw value of [`MX_METADATA_BACKEND`] (empty string when unset).
258pub fn metadata_backend() -> String {
259    env::var(MX_METADATA_BACKEND).unwrap_or_default()
260}
261
262/// Full Redis URL from [`REDIS_URL`].
263pub fn redis_url() -> Option<String> {
264    env::var(REDIS_URL).ok()
265}
266
267/// Redis host from [`MX_REDIS_HOST`], then [`REDIS_HOST`].
268pub fn redis_host() -> Option<String> {
269    env::var(MX_REDIS_HOST)
270        .or_else(|_| env::var(REDIS_HOST))
271        .ok()
272}
273
274/// Redis port from [`MX_REDIS_PORT`], then [`REDIS_PORT`].
275pub fn redis_port() -> Option<String> {
276    env::var(MX_REDIS_PORT)
277        .or_else(|_| env::var(REDIS_PORT))
278        .ok()
279}
280
281/// Kubernetes namespace from [`MX_METADATA_NAMESPACE`], then [`POD_NAMESPACE`].
282pub fn metadata_namespace() -> Option<String> {
283    env::var(MX_METADATA_NAMESPACE)
284        .or_else(|_| env::var(POD_NAMESPACE))
285        .ok()
286}
287
288/// Reaper scan interval in seconds ([`MX_REAPER_SCAN_INTERVAL_SECS`], default 30).
289pub fn reaper_scan_interval_secs() -> u64 {
290    env_u64(
291        MX_REAPER_SCAN_INTERVAL_SECS,
292        DEFAULT_REAPER_SCAN_INTERVAL_SECS,
293    )
294}
295
296/// Registry-statistics refresh interval in seconds
297/// ([`MX_REGISTRY_STATS_INTERVAL_SECS`], default 60).
298///
299/// Clamped to at least 1: `tokio::time::interval` panics on a zero period, so a
300/// deployment that set this to 0 would take the refresh task down at startup.
301pub fn registry_stats_interval_secs() -> u64 {
302    env_u64(
303        MX_REGISTRY_STATS_INTERVAL_SECS,
304        DEFAULT_REGISTRY_STATS_INTERVAL_SECS,
305    )
306    .max(1)
307}
308
309/// Heartbeat staleness timeout in seconds ([`MX_HEARTBEAT_TIMEOUT_SECS`], default 90).
310pub fn heartbeat_timeout_secs() -> u64 {
311    env_u64(MX_HEARTBEAT_TIMEOUT_SECS, DEFAULT_HEARTBEAT_TIMEOUT_SECS)
312}
313
314/// Garbage-collection timeout in seconds ([`MX_GC_TIMEOUT_SECS`], default 3600).
315pub fn gc_timeout_secs() -> u64 {
316    env_u64(MX_GC_TIMEOUT_SECS, DEFAULT_GC_TIMEOUT_SECS)
317}
318
319/// Read an environment variable as `u64`, falling back to `default`.
320fn env_u64(name: &str, default: u64) -> u64 {
321    env::var(name)
322        .ok()
323        .and_then(|v| v.parse().ok())
324        .unwrap_or(default)
325}
326
327#[cfg(test)]
328#[allow(clippy::expect_used)]
329mod tests {
330    use super::*;
331    use crate::test_support::{EnvVarGuard, acquire_env_mutex};
332
333    #[test]
334    fn name_constants_match_their_literals() {
335        assert_eq!(MODEL_EXPRESS_PREFIX, "MODEL_EXPRESS");
336        assert_eq!(MODEL_EXPRESS_ENDPOINT, "MODEL_EXPRESS_ENDPOINT");
337        assert_eq!(MODEL_EXPRESS_TIMEOUT, "MODEL_EXPRESS_TIMEOUT");
338        assert_eq!(
339            MODEL_EXPRESS_CACHE_DIRECTORY,
340            "MODEL_EXPRESS_CACHE_DIRECTORY"
341        );
342        assert_eq!(MODEL_EXPRESS_LOG_LEVEL, "MODEL_EXPRESS_LOG_LEVEL");
343        assert_eq!(MODEL_EXPRESS_LOG_FORMAT, "MODEL_EXPRESS_LOG_FORMAT");
344        assert_eq!(
345            MODEL_EXPRESS_NO_SHARED_STORAGE,
346            "MODEL_EXPRESS_NO_SHARED_STORAGE"
347        );
348        assert_eq!(
349            MODEL_EXPRESS_TRANSFER_CHUNK_SIZE,
350            "MODEL_EXPRESS_TRANSFER_CHUNK_SIZE"
351        );
352        assert_eq!(MODEL_EXPRESS_SERVER_PORT, "MODEL_EXPRESS_SERVER_PORT");
353        assert_eq!(MODEL_EXPRESS_SERVER_HOST, "MODEL_EXPRESS_SERVER_HOST");
354        assert_eq!(
355            MODEL_EXPRESS_SERVER_METRICS_PORT,
356            "MODEL_EXPRESS_SERVER_METRICS_PORT"
357        );
358        assert_eq!(
359            MODEL_EXPRESS_CACHE_EVICTION_ENABLED,
360            "MODEL_EXPRESS_CACHE_EVICTION_ENABLED"
361        );
362        assert_eq!(
363            MODEL_EXPRESS_SERVER_ENDPOINT,
364            "MODEL_EXPRESS_SERVER_ENDPOINT"
365        );
366        assert_eq!(MX_METRICS_SCHEME, "MX_METRICS_SCHEME");
367        assert_eq!(HF_TOKEN, "HF_TOKEN");
368        assert_eq!(HF_HUB_CACHE, "HF_HUB_CACHE");
369        assert_eq!(HF_HUB_OFFLINE, "HF_HUB_OFFLINE");
370        assert_eq!(HF_ENDPOINT, "HF_ENDPOINT");
371        assert_eq!(NGC_API_ENDPOINT, "NGC_API_ENDPOINT");
372        assert_eq!(NGC_AUTH_ENDPOINT, "NGC_AUTH_ENDPOINT");
373        assert_eq!(NGC_API_KEY, "NGC_API_KEY");
374        assert_eq!(NGC_CLI_API_KEY, "NGC_CLI_API_KEY");
375        assert_eq!(NGC_CLI_HOME, "NGC_CLI_HOME");
376        assert_eq!(MX_METADATA_BACKEND, "MX_METADATA_BACKEND");
377        assert_eq!(REDIS_URL, "REDIS_URL");
378        assert_eq!(MX_REDIS_HOST, "MX_REDIS_HOST");
379        assert_eq!(REDIS_HOST, "REDIS_HOST");
380        assert_eq!(MX_REDIS_PORT, "MX_REDIS_PORT");
381        assert_eq!(REDIS_PORT, "REDIS_PORT");
382        assert_eq!(MX_METADATA_NAMESPACE, "MX_METADATA_NAMESPACE");
383        assert_eq!(POD_NAMESPACE, "POD_NAMESPACE");
384        assert_eq!(MX_REAPER_SCAN_INTERVAL_SECS, "MX_REAPER_SCAN_INTERVAL_SECS");
385        assert_eq!(
386            MX_REGISTRY_STATS_INTERVAL_SECS,
387            "MX_REGISTRY_STATS_INTERVAL_SECS"
388        );
389        assert_eq!(MX_HEARTBEAT_TIMEOUT_SECS, "MX_HEARTBEAT_TIMEOUT_SECS");
390        assert_eq!(MX_GC_TIMEOUT_SECS, "MX_GC_TIMEOUT_SECS");
391        assert_eq!(MODEL_EXPRESS_SECURITY_MODE, "MODEL_EXPRESS_SECURITY_MODE");
392        assert_eq!(
393            MODEL_EXPRESS_SECURITY_TOKEN_AUDIENCES,
394            "MODEL_EXPRESS_SECURITY_TOKEN_AUDIENCES"
395        );
396        assert_eq!(
397            MODEL_EXPRESS_SECURITY_ALLOWED_SERVICE_ACCOUNTS,
398            "MODEL_EXPRESS_SECURITY_ALLOWED_SERVICE_ACCOUNTS"
399        );
400        assert_eq!(
401            MODEL_EXPRESS_SECURITY_CACHE_TTL_SECS,
402            "MODEL_EXPRESS_SECURITY_CACHE_TTL_SECS"
403        );
404        assert_eq!(MX_AUTH_TOKEN_PATH, "MX_AUTH_TOKEN_PATH");
405        assert_eq!(MX_AUTH_TOKEN_TTL_SECONDS, "MX_AUTH_TOKEN_TTL_SECONDS");
406        assert_eq!(HOME, "HOME");
407        assert_eq!(USERPROFILE, "USERPROFILE");
408        assert_eq!(KUBECONFIG, "KUBECONFIG");
409    }
410
411    #[test]
412    fn hf_offline_parses_truthy_values() {
413        let lock = acquire_env_mutex();
414        for truthy in ["1", "on", "YES", "true", "True"] {
415            let _g = EnvVarGuard::set(&lock, HF_HUB_OFFLINE, truthy);
416            assert!(hf_offline(), "expected {truthy} to enable offline mode");
417        }
418        for falsey in ["0", "off", "no", "maybe"] {
419            let _g = EnvVarGuard::set(&lock, HF_HUB_OFFLINE, falsey);
420            assert!(!hf_offline(), "expected {falsey} to disable offline mode");
421        }
422        let _g = EnvVarGuard::remove(&lock, HF_HUB_OFFLINE);
423        assert!(!hf_offline(), "unset should disable offline mode");
424    }
425
426    #[test]
427    fn ngc_bases_default_then_override() {
428        let lock = acquire_env_mutex();
429        let _api = EnvVarGuard::remove(&lock, NGC_API_ENDPOINT);
430        let _authn = EnvVarGuard::remove(&lock, NGC_AUTH_ENDPOINT);
431        assert_eq!(ngc_api_base(), DEFAULT_NGC_API_BASE);
432        assert_eq!(ngc_authn_base(), DEFAULT_NGC_AUTHN_BASE);
433
434        let _api = EnvVarGuard::set(&lock, NGC_API_ENDPOINT, "https://api.example.com");
435        let _authn = EnvVarGuard::set(&lock, NGC_AUTH_ENDPOINT, "https://authn.example.com");
436        assert_eq!(ngc_api_base(), "https://api.example.com");
437        assert_eq!(ngc_authn_base(), "https://authn.example.com");
438    }
439
440    #[test]
441    fn ngc_api_key_prefers_primary_then_falls_back() {
442        let lock = acquire_env_mutex();
443        let _p = EnvVarGuard::set(&lock, NGC_API_KEY, "  primary  ");
444        let _s = EnvVarGuard::set(&lock, NGC_CLI_API_KEY, "secondary");
445        assert_eq!(ngc_api_key().as_deref(), Some("primary"));
446
447        let _p = EnvVarGuard::remove(&lock, NGC_API_KEY);
448        assert_eq!(ngc_api_key().as_deref(), Some("secondary"));
449
450        let _s = EnvVarGuard::remove(&lock, NGC_CLI_API_KEY);
451        assert_eq!(ngc_api_key(), None);
452    }
453
454    #[test]
455    fn redis_and_namespace_fallbacks() {
456        let lock = acquire_env_mutex();
457        let _h1 = EnvVarGuard::remove(&lock, MX_REDIS_HOST);
458        let _h2 = EnvVarGuard::set(&lock, REDIS_HOST, "legacy-host");
459        assert_eq!(redis_host().as_deref(), Some("legacy-host"));
460        let _h1 = EnvVarGuard::set(&lock, MX_REDIS_HOST, "mx-host");
461        assert_eq!(redis_host().as_deref(), Some("mx-host"));
462
463        let _n1 = EnvVarGuard::remove(&lock, MX_METADATA_NAMESPACE);
464        let _n2 = EnvVarGuard::set(&lock, POD_NAMESPACE, "pod-ns");
465        assert_eq!(metadata_namespace().as_deref(), Some("pod-ns"));
466    }
467
468    #[test]
469    fn reaper_getters_default_parse_and_fallback() {
470        let lock = acquire_env_mutex();
471        let _g = EnvVarGuard::remove(&lock, MX_REAPER_SCAN_INTERVAL_SECS);
472        assert_eq!(
473            reaper_scan_interval_secs(),
474            DEFAULT_REAPER_SCAN_INTERVAL_SECS
475        );
476
477        let _g = EnvVarGuard::set(&lock, MX_HEARTBEAT_TIMEOUT_SECS, "120");
478        assert_eq!(heartbeat_timeout_secs(), 120);
479
480        let _g = EnvVarGuard::set(&lock, MX_GC_TIMEOUT_SECS, "not-a-number");
481        assert_eq!(gc_timeout_secs(), DEFAULT_GC_TIMEOUT_SECS);
482    }
483}